Compare commits

...
55 Commits
Author SHA1 Message Date
GitHub Actions d15490d5aa chore: release v5.3.2
Post release / release (push) Canceled after 0s
2025-05-19 16:45:11 +00:00
Lauris Skraucis ba6e547f6c chore: bump platform libraries for v2 (#21391) 2025-05-19 16:30:13 +00:00
Anik Dhabal Babu 2493315f92 fix: wrong event details (#21390) 2025-05-19 16:20:07 +00:00
Keith Williams ec117d4034 revert: "perf: add p-limit to _getUsersAvailability to limit concurrent executions to 10 (#21374)" (#21396)
This reverts commit 6cb3bdc567.
2025-05-19 11:47:27 -04:00
Eunjae Lee 646058b8a1 chore: add missing migration for RoutingFormResponseDenormalized (#21388)
* chore: add missing migration for RoutingFormResponseDenormalized

* more changes

* remove cascade deleting

* update migration

* updates

* update

* update schema

* update

* update

* update

* update
2025-05-19 11:28:57 -04:00
Keith Williams 06c359ac9b revert: "chore: release v5.3.2"
This reverts commit 0d96d9c44c.
2025-05-19 08:44:37 -04:00
GitHub Actions 0d96d9c44c chore: release v5.3.2 2025-05-19 12:43:24 +00:00
Udit Takkar e5f8d4a1ac feat: support no show webhook for instant meetings (#21251)
* feat: support no show webhook for instant meetings

* fix: type error
2025-05-19 13:05:06 +04:00
Alex van Andel 19c72323b8 chore: Delete .github/workflows/advisories-to-slack.yml 2025-05-18 23:17:33 +01:00
Alex van Andel f19b2804b6 Update advisories-to-slack.yml (#21384) 2025-05-18 20:03:49 +01:00
Alex van Andel f387fdb166 chore: Add workflow to post security advisories to Slack (#21383) 2025-05-18 17:45:17 +00:00
ec8f9d5f7a feat: option to enforce language for bookingPage (#18782)
* feature eventType specific language or bookingPage language

* chore

* fix type checks

* chore

* chore: remove log

* improvement: this updates in DatePicker is not required with context provider method

* added e2e tests for InterfaceLanguage feature

* corrected prev merge conflict issues

* update to align with hooks usage pattern

* chore

* unrelated auto-formatting changes

* unrelated auto-formatting changes

* unrelated auto-formatting changes

* update to affect interfaceLanguage on success page

* undone prettier changes

* update test for success page translation

* chore

* return null from memo

* fix typecheck error due to atom imports in useLocale

* set default interfaceLanguage as null

* updated to use null instead of constant

* enhancements

* fix latest unit test, use prop instead of useSession hook

* fix build issue

* chore

* fix atom build issue

* fix unit test with reqd mock

* update to use server i18n rendering

* nit

* Move import localeOptions to EventSetupTabWebWrapper

* make sure we only display interface language option in web app

* using customI18nProvider

---------

Co-authored-by: amrit <iamamrit27@gmail.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: Tushar <tusharbhatt0135@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-05-18 01:12:26 -04:00
devin-ai-integration[bot]andkeith@cal.com 6cb3bdc567 perf: add p-limit to _getUsersAvailability to limit concurrent executions to 10 (#21374)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
2025-05-17 17:05:45 -04:00
NISHANT SINGH d574840c3f fix: make 'Send reset email' button text visible on hover (#21334) (#21335)
* fix: ensure button text remains visible on hover in forgot password page

Before: Button text turned black on hover, blending with the dark background.
After: Added 'enabled:hover:text-white' to maintain readability.

* Re-update forgot-password-view.tsx

* Update globals.css brand-text

* Update globals.css --cal-brand-text: white

* Reverted globals.css update

* Update forgot-password-view.tsx , change brand-text color

change "--cal-brand-text": 
white to Black
2025-05-17 09:48:21 +00:00
devin-ai-integration[bot]andkeith@cal.com 6a85d0e10b perf: optimize getSlots performance by selectively merging overlapping date ranges (#21370)
* perf: optimize getSlots performance by selectively merging overlapping date ranges

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

* fix: handle date ranges with end time before start time in filterRedundantDateRanges

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

* perf: apply filterRedundantDateRanges to all scheduling types

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

* fix: preserve uniqueness check before filtering redundant date ranges

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
2025-05-17 03:44:56 +00:00
devin-ai-integration[bot]andkeith@cal.com 816df3704e perf: Optimize getSlots function to handle large dateRanges arrays efficiently (#21371)
* perf: Optimize getSlots function to handle large dateRanges arrays efficiently

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

* Update packages/lib/slots.test.ts

* test: Update performance test to use 2000 date ranges

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

* test: Update performance test to process 2000 date ranges across multiple days

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
2025-05-17 04:00:13 +01:00
Alex van Andel badd9a5698 perf: Better perf by going event type first w/o booking (#21369)
* perf: Better perf by going event type first w/o booking

* minor fixup

* Use an IN with children instead of an OR with parent

* Turns out we already had a variable with the right values

* Make sure the right event type is used even for children

* few fixes

* Full select is needed to keep typings working

* Fix last remaining issue
2025-05-16 17:55:57 -07:00
GitHub Actions 4e02fe4251 chore: release v5.3.1
Post release / release (push) Canceled after 0s
2025-05-17 00:03:32 +00:00
Benny Joo 01b9780938 perf: ignore Object Not Found Matching Id error (#21365) 2025-05-16 23:11:33 +00:00
Anik Dhabal Babu 6be144f95f fix: unable to send verifiation code (#21367) 2025-05-16 23:09:19 +00:00
Anik Dhabal Babu 37dd6d888b fix: add reschedule by (#21363) 2025-05-16 17:14:06 +00:00
Lauris Skraucis f5ffee0a20 feat: automated changelogs and releases by changesets (#21299)
* feat: automated changesets

* chore: update changesets to latest

* readme
2025-05-16 12:52:45 -04:00
Carina WollendorferandCarinaWolli 6cd51e0a8d remove disabled state in addActionDialog (#21362)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-16 16:36:29 +00:00
Lauris Skraucis 80fad2aab9 fix: create event type with output of another create event type (#21273)
* feat: accept location booking field with label customization

* feat: return location label

* chore: bump libraries

* test

* fix: tests
2025-05-16 16:34:44 +03:00
Lauris Skraucis 1ccfc65766 fix: POST /v2/bookings validate submitted booking fields (#21163) 2025-05-16 13:00:58 +00:00
Lauris Skraucis 86918b03cf fix: booker atom seated events reservation (#21339)
* fix: reserving first seated booking slot

* fix: input type in useReserveSlot

* fix: handleReserveSlot submit bookingUid to api

* null -> undefined

* dont touch web app
2025-05-16 14:32:06 +02:00
a5b684af1f test: add unit tests for getWhereForTeamOrAllTeams function (#21152)
* test: add unit tests for getWhereForTeamOrAllTeams function

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

* test: remove implementation details and add tests for invalid parameter combinations

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

* test: update mock values to use more distinct numbers

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

* test: update mock values to use completely distinct numbers

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

* test: update mock values to maintain data integrity

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

* test: convert to integration tests for getWhereForTeamOrAllTeams function

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

* chore: remove integration test file

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

* test: reorganize tests with proper describe blocks

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

* test: move test file to __tests__ directory

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

* test: update import path for routing-events in test file

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-05-16 08:19:17 +00:00
8889e8566c fix: eventType filter on /insights and add unit tests for buildBaseWhereCondition (#21154)
* test: Add unit tests for buildBaseWhereCondition function

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

* refactor: Export buildBaseWhereCondition function instead of copying it

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

* refactor: Remove implementation detail checks from tests

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

* test: Fix tests to match actual function behavior

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

* test: Fix test issues with buildBaseWhereCondition

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

* test: Update tests to match actual function behavior

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

* fix: Address security issues in buildBaseWhereCondition function

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

* refactor: Simplify buildBaseWhereCondition function

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

* refactor: Simplify code and remove isEmptyResponse property

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

* refactor: Use array of conditions approach for buildBaseWhereCondition

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

* refactor: Make buildBaseWhereCondition implementation consistent and update tests

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

* fix: Fix incorrect usage of buildBaseWhereCondition function

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

* chore: Revert yarn.lock changes

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

* fix: Comment out non-existent Sentry API method to fix type error

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

* Revert "fix: Comment out non-existent Sentry API method to fix type error"

This reverts commit 44c21a7396a518c63047b33967cd8af7e6e4f268.

* remove unnecessary if-statement

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-05-16 10:10:57 +02:00
fab364f084 fix: Allow zero routingFormResponseId in API v2 available endpoint (#21301)
* Allow zero routingFormResponseId

* fix: conditionally validate routingFormResponseId to allow 0 only for dry run operations

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

* fix: pass _isDryRun parameter to API v2 available endpoint

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

* fix runtime error and improve message

* Add e2e test to verify the constraint works

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
2025-05-16 12:23:11 +05:30
Devanshu Sharma 5bc9d71a88 fix: (hu) correct typo 'ektávolítás' to 'eltávolítás' in Hungarian translation (#21348) 2025-05-16 03:27:58 +00:00
GitHub Actions 2b5ea649d4 chore: release v5.3.0
Post release / release (push) Canceled after 0s
2025-05-15 20:43:11 +00:00
Benny Joo 0802ebb17b refactor: check insights feature flag in higher stack (#21341) 2025-05-15 12:41:22 -07:00
Anik Dhabal Babu 29b8d68f45 fix: unable to save workflow with verified secondary email (#21338)
* fix: unable to save workflow

* Update util.ts
2025-05-15 19:40:45 +00:00
Lauris Skraucis 6994cac5e0 fix: v2 token secret conflicts (#21279)
* fix: accessToken.create unique constraint failed on the secret field

* fix: force refresh tokens within single transaction
2025-05-15 16:04:50 +00:00
Carina Wollendorfer df9b72583d add learn more help link (#21333) 2025-05-15 11:02:57 +00:00
Carina WollendorferandCarinaWolli 553eab59e6 fix: sms credits follow up (#21304)
* address mrge review comments

* code clean up

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-15 08:33:38 +00:00
Joe Au-Yeung 8d675fd8b6 feat: Add AssignmentReason to BOOKING_CREATED webhook (#21315)
* AssignmentREasonRecorder methods return the reason generated

* Add assignmentReason to `EventPayloadType`

* Pass `assignmentReason` to webhook payload data

* Undo constants commit
2025-05-14 23:33:25 +00:00
GitHub Actions a5639a8290 chore: release v5.2.17
Post release / release (push) Canceled after 0s
2025-05-14 23:10:04 +00:00
Anik Dhabal Babu 44d9c4bcc5 fix: Seated events and round robin - all round robin hosts are being added to the calendar event (#21317)
* fix: add org email into ics

* fix: Seated events and round robin - all round robin hosts are being added to the calendar event

* added test
2025-05-14 22:52:26 +00:00
Anik Dhabal Babu 1b586560f8 fix: userprimaryemail not updating (#21318) 2025-05-14 22:01:43 +00:00
Benny Joo e082cc7d3d refactor: Handle apiv2 team slots in useSchedule hook (#21309) 2025-05-14 10:12:09 -07:00
Keith Williams d411e70ea3 chore: Add @calcom/Consumer as CODEOWNER for ui-playground (#21312) 2025-05-14 16:17:13 +00:00
Keith Williams 8dffa4d371 chore: Back down @calcom/Consumer CODEOWNERS (#21311) 2025-05-14 16:09:58 +00:00
Eunjae Lee b7bf6e80e6 fix: case insensitive filter for attendees on /bookings (#21302) 2025-05-14 15:51:22 +00:00
Eunjae Lee 2a93e661f9 feat: add denormalized table for RoutingFormResponse view (#21009)
* feat: add denormalized table for RoutingFormResponse

* WIP

* improve

* update schema

* update schema

* improvement

* fix type of bookingStatus

* cascading DELETE

* split update trigger

* denormalize fields into a separate table

* error handling

* address feedback

* fix

* fix constraints and relations

* delete rows when rows are deleted in the joined tables

* add tests and make booking nullable in RoutingFormResponseDenormalized

* add tests

* update test

* use e2e tests instead

* fix type error

* address feedback

* move e2e to integration test

* add missing tests

* move index migration to another file

* clean up migrations
2025-05-14 10:41:28 -04:00
Lauris Skraucis bfb51b82b2 fix: useLocale hook is being used outside of App Router & time zone dropdown timeout (#21306) 2025-05-14 14:07:35 +00:00
Keith Williams f50be6c08b chore: Lower number of errors sent to Sentry (#21300) 2025-05-14 12:21:13 +00:00
Hariom Balhara df75df7008 fix: DelegationCredential - Duplicate GoogleMeet and unnecessary serviceAccount key access (#21269)
* Remove unused return statement

* Fix return from fn instead of continuing the loop

* Ensure userId is set for existing records on update

* ensure that serviceAccountKey is only retrieved whenb assked
2025-05-14 09:49:47 +00:00
Eunjae Lee deb55c0536 fix: sticky columns in DataTable (#21278)
* fix: sticky columns in DataTable

* update th color

* remove rounded corner

* border change
2025-05-14 08:34:50 +00:00
Lauris Skraucis 7fc11ff341 fix: v2 bookings with sms reminders (#21191)
* fix: v2 bookings with sms reminders

* fix: falsey booking field values
2025-05-14 10:25:29 +03:00
Lauris Skraucis 1a70f83d34 fix: v2 sentry errors (#21214)
* fix: PATCH, POST event-types Address location must have an address

* fix: GET event-types Address location must have an address

* fix: POST /v2/bookings Address location must have an address

* refactor: GET /v2/slots Invalid time range given.

* fix: POST v2/event-types Cannot read properties of null (reading 'type')

* fix: POST /v2/bookings/:bookingUid/mark-absent Failed to update no-show status
2025-05-14 10:11:35 +03:00
39077a08f1 docs: correct documentation for slot format in 2024-09-04 API (#21196)
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-05-14 06:57:41 +00:00
Benny Joo 9a671f9063 fix: add arg validation to ensureValidPhoneNumber (#21259) 2025-05-13 18:57:00 -07:00
3bc9fa11d0 chore: remove @calcom/lib barrel file (#21267)
* refactor: replace imports from @calcom/lib barrel file with direct imports

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

* fix: add deprecated barrel file with warning to support tests

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

* remove barrel file

* fix: restore barrel file with deprecation notice and add CreditType enum export

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

* fix: add CreditType enum definition to barrel file

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

* fix: add mock for CreditType enum in credit-service test

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

* fix: use importOriginal in CreditType enum mock

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

* fix: remove barrel file completely

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

* fix: restore barrel file with deprecation notice to support tests

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

* fix: update test-setup to use direct import from @calcom/ui/classNames

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

* fix: completely remove barrel file

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

* fix: update package.json to remove references to index.ts

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

* fix: remove main and types fields from package.json

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

* fix: restore barrel file with deprecation notice to support tests

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

* remove barrel file

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: benny@cal.com <benny@cal.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
2025-05-13 21:55:58 -04:00
Joe Au-Yeung 7e699ab810 fix: Salesforce - fix option to create contact if account exists (#21288)
* Add `onCheckedChange`

* Remove console.logs
2025-05-13 22:17:57 +00:00
177 changed files with 5355 additions and 1091 deletions
+6 -2
View File
@@ -1,11 +1,15 @@
{
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
"changelog": "@changesets/cli/changelog",
"changelog": ["@changesets/changelog-github", { "repo": "calcom/cal.com" }],
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
"ignore": [],
"privatePackages": {
"version": false,
"tag": false
}
}
+1
View File
@@ -166,6 +166,7 @@ SENTRY_AUTH_TOKEN=
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_DEBUG=
SENTRY_MAX_SPANS=
SENTRY_SAMPLE_RATE=
SENTRY_TRACES_SAMPLE_RATE=
SENTRY_REPLAYS_SESSION_SAMPLE_RATE=
SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=
-2
View File
@@ -1,8 +1,6 @@
/**/package.json @calcom/Foundation
/apps/api/**/* @calcom/Platform @calcom/Foundation
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
/apps/web/components/**/* @calcom/Consumer @calcom/Foundation
/apps/web/modules/**/* @calcom/Consumer @calcom/Foundation
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
/apps/web/lib/**/* @calcom/Foundation
/apps/web/middleware.ts @calcom/Foundation
+37
View File
@@ -0,0 +1,37 @@
name: Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions: {} # remove permissions and set explicitly under "permissions:" below
jobs:
release:
if: github.repository == 'calcom/cal.com' # prevent this action from running on forks
permissions:
id-token: write # for NPM provenance - developers can verify where and how it was built
contents: write # to create release by changesets/action below
pull-requests: write # to create pull request by changesets/action below
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup node, yarn and install dependencies
uses: ./.github/actions/yarn-install
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
version: yarn changesets-version
publish: yarn changesets-release
commit: "chore: version packages" # commit message
title: "chore: version packages" # PR title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
+8
View File
@@ -646,6 +646,14 @@ following
15. Create a verify service
16. Copy Verify Service SID to your `.env` file into the `TWILIO_VERIFY_SID` field
## Changesets
We use changesets to generate changelogs and publish public packages (packages with `private: true` are ignored).
An example of good readme is [atoms readme](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/README.md). Every public package must:
1. Follow semantic versioning when using changesets.
2. Mark breaking changes using `❗️Breaking change`
<!-- LICENSE -->
## License
+1 -2
View File
@@ -3,7 +3,6 @@
"version": "0.0.1",
"description": "Platform API for Cal.com",
"author": "Cal.com Inc.",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "yarn dev:build && nest build",
@@ -38,7 +37,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.197",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.208",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -55,6 +55,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
let eventTypeId: number;
let seatedEvent: EventType;
let eventTypeWithBookingFields: EventType;
let eventTypeWithBookingFielsCustom: EventType;
const eventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
const eventTypeWithBookingFieldsSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
const seatedEventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
@@ -331,6 +332,313 @@ describe("Bookings Endpoints 2024-08-13", () => {
user.id
);
eventTypeWithBookingFielsCustom = await eventTypesRepositoryFixture.create(
{
title: "test booking field types",
slug: "test-booking-field-types",
length: 30,
bookingFields: [
{
name: "name",
type: "name",
sources: [{ id: "default", type: "default", label: "Default" }],
variant: "fullName",
editable: "system",
required: true,
defaultLabel: "your_name",
variantsConfig: {
variants: {
fullName: {
fields: [
{
name: "fullName",
type: "text",
required: true,
},
],
},
firstAndLastName: {
fields: [
{
name: "firstName",
type: "text",
label: "",
required: true,
placeholder: "",
},
{
name: "lastName",
type: "text",
label: "",
required: false,
placeholder: "",
},
],
},
},
},
},
{
name: "email",
type: "email",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: true,
defaultLabel: "email_address",
},
{
name: "location",
type: "radioInput",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: false,
defaultLabel: "location",
getOptionsAt: "locations",
optionsInputs: {
phone: { type: "phone", required: true, placeholder: "" },
somewhereElse: { type: "text", required: true, placeholder: "" },
attendeeInPerson: { type: "address", required: true, placeholder: "" },
},
hideWhenJustOneOption: true,
},
{
name: "test-phone",
type: "phone",
label: "test-phone",
hidden: false,
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-address",
type: "address",
label: "test-address",
hidden: false,
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-text",
type: "text",
label: "test-text",
hidden: false,
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-number",
type: "number",
label: "test-number",
hidden: false,
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-textarea",
type: "textarea",
label: "test-textarea",
hidden: false,
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-boolean",
type: "boolean",
label: "test-boolean",
hidden: false,
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
editable: "user",
required: false,
labelAsSafeHtml: "<p>test-boolean</p>\n",
disableOnPrefill: false,
},
{
name: "test-url",
type: "url",
label: "test-url",
hidden: false,
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
editable: "user",
required: false,
placeholder: "",
labelAsSafeHtml: "<p>test-url</p>\n",
disableOnPrefill: false,
},
{
name: "test-multiemail",
type: "multiemail",
label: "test-multiemail",
hidden: false,
sources: [
{
id: "user",
type: "user",
label: "User",
fieldRequired: true,
},
],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-multiselect",
type: "multiselect",
label: "test-multiselect",
hidden: false,
options: [
{
label: "apple",
value: "apple",
},
{
label: "orange",
value: "orange",
},
],
sources: [
{
id: "user",
type: "user",
label: "User",
fieldRequired: true,
},
],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-checkbox",
type: "checkbox",
label: "test-checkbox",
hidden: false,
options: [
{
label: "blue",
value: "blue",
},
{
label: "red",
value: "red",
},
],
sources: [
{
id: "user",
type: "user",
label: "User",
fieldRequired: true,
},
],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-radio",
type: "radio",
label: "test-radio",
hidden: false,
options: [
{
label: "pineapple",
value: "pineapple",
},
],
sources: [
{
id: "user",
type: "user",
label: "User",
fieldRequired: true,
},
],
editable: "user",
required: false,
disableOnPrefill: false,
},
{
name: "test-select",
type: "select",
label: "test-select",
options: [
{
label: "water",
value: "water",
},
{
label: "juice",
value: "juice",
},
],
sources: [
{
id: "user",
type: "user",
label: "User",
fieldRequired: true,
},
],
editable: "user",
required: true,
placeholder: "",
disableOnPrefill: false,
},
{
name: "title",
type: "text",
hidden: true,
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: true,
defaultLabel: "what_is_this_meeting_about",
defaultPlaceholder: "",
},
{
name: "notes",
type: "textarea",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "additional_notes",
defaultPlaceholder: "share_additional_notes",
},
{
name: "guests",
type: "multiemail",
hidden: false,
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "additional_guests",
defaultPlaceholder: "email",
},
{
name: "rescheduleReason",
type: "textarea",
views: [{ id: "reschedule", label: "Reschedule View" }],
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "reason_for_reschedule",
defaultPlaceholder: "reschedule_placeholder",
},
],
},
user.id
);
bookingWithSplitName = await bookingsRepositoryFixture.create({
user: {
connect: {
@@ -543,6 +851,318 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("Booking Field Type Validation", () => {
const basePayload = {
start: "2025-06-19T11:00:00.000Z",
attendee: {
name: "Charlie TypeTest",
email: "charlie.typetest@example.com",
timeZone: "Europe/Madrid",
language: "en",
},
};
it("should reject with 400 if 'test-phone' (phone) is not a string", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-phone": 12345,
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-phone'. Expected type string (compatible with field type 'phone'), but received number."
);
});
it("should reject with 400 if 'test-address' (address) is not a string", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-address": true,
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-address'. Expected type string (compatible with field type 'address'), but received boolean."
);
});
it("should reject with 400 if 'test-text' (text) is not a string", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-text": 123,
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-text'. Expected type string (compatible with field type 'text'), but received number."
);
});
it("should reject with 400 if 'test-number' (number) is not a number", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-number": "123",
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-number'. Expected type number (compatible with field type 'number'), but received string."
);
});
it("should reject with 400 if 'test-textarea' (textarea) is not a string", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-textarea": { text: "invalid" },
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-textarea'. Expected type string (compatible with field type 'textarea'), but received object."
);
});
it("should reject with 400 if 'test-boolean' (boolean) is not a boolean", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-boolean": "true",
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-boolean'. Expected type boolean (compatible with field type 'boolean'), but received string."
);
});
it("should reject with 400 if 'test-url' (url) is not a string", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-url": 12345,
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-url'. Expected type string (compatible with field type 'url'), but received number."
);
});
it("should reject with 400 if 'test-multiemail' (multiemail) is not an array", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-multiemail": "not-an-array@example.com",
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-multiemail'. Expected type array (compatible with field type 'multiemail'), but received string."
);
});
it("should reject with 400 if 'test-multiselect' (multiselect) is not an array", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-multiselect": "not-an-array",
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-multiselect'. Expected type array (compatible with field type 'multiselect'), but received string."
);
});
it("should reject with 400 if 'test-checkbox' (checkbox) is not an array", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-checkbox": true,
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-checkbox'. Expected type array (compatible with field type 'checkbox'), but received boolean."
);
});
it("should reject with 400 if 'test-radio' (radio) is not a string or number", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-radio": true,
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-radio'. Expected type string or number (compatible with field type 'radio'), but received boolean."
);
});
it("should reject with 400 if 'test-select' (select) is not a string or number", async () => {
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
"test-select": true,
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(response.status).toBe(400);
expect(response.body.error.message).toBe(
"Invalid type for booking field 'test-select'. Expected type string or number (compatible with field type 'select'), but received boolean."
);
});
it("should throw BadRequestException for test-select field with invalid option", async () => {
const fieldName = "test-select";
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
[fieldName]: "INVALID_DRINK",
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toBe(
`Invalid option 'INVALID_DRINK' for booking field '${fieldName}'. Allowed options are: water, juice.`
);
});
it("should throw BadRequestException for test-radio field with invalid option", async () => {
const fieldName = "test-radio";
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
[fieldName]: "INVALID_FRUIT_FOR_RADIO",
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toBe(
`Invalid option 'INVALID_FRUIT_FOR_RADIO' for booking field '${fieldName}'. Allowed options are: pineapple.`
);
});
it("should throw BadRequestException for test-multiselect field with an invalid option", async () => {
const fieldName = "test-multiselect";
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
[fieldName]: ["apple", "INVALID_FRUIT"],
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toBe(
`One or more invalid options for booking field '${fieldName}'. Allowed options are: apple, orange.`
);
});
it("should throw BadRequestException for test-checkbox field with invalid options", async () => {
const fieldName = "test-checkbox";
const payload = {
...basePayload,
eventTypeId: eventTypeWithBookingFielsCustom.id,
bookingFieldsResponses: {
[fieldName]: ["blue", "INVALID_COLOR"],
},
};
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(payload)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toBe(
`One or more invalid options for booking field '${fieldName}'. Allowed options are: blue, red.`
);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -14,7 +14,7 @@ import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { EventType, User } from "@prisma/client";
import { EventType, User, Workflow } from "@prisma/client";
import { advanceTo, clear } from "jest-date-mock";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
@@ -22,6 +22,8 @@ import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-type
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 { WorkflowReminderRepositoryFixture } from "test/fixtures/repository/workflow-reminder.repository.fixture";
import { WorkflowRepositoryFixture } from "test/fixtures/repository/workflow.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
@@ -32,6 +34,7 @@ import {
VERSION_2024_08_13,
X_CAL_CLIENT_ID,
} from "@calcom/platform-constants";
import { AttendeeScheduledEmail, OrganizerScheduledEmail } from "@calcom/platform-libraries/emails";
import {
CreateEventTypeInput_2024_06_14,
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
@@ -60,6 +63,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let workflowReminderRepositoryFixture: WorkflowReminderRepositoryFixture;
let workflowRepositoryFixture: WorkflowRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
@@ -68,6 +73,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
let eventTypeId: number;
let eventType: EventType;
let eventTypeWithAttendeeSmsReminder: EventType;
let workflow: Workflow;
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
let recurringEventTypeId: number;
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
@@ -97,6 +104,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
workflowReminderRepositoryFixture = new WorkflowReminderRepositoryFixture(moduleRef);
workflowRepositoryFixture = new WorkflowRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({
name: `user-bookings-organization-${randomString()}`,
@@ -177,6 +186,85 @@ describe("Bookings Endpoints 2024-08-13", () => {
rating: 10,
});
workflow = await workflowRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
trigger: "BEFORE_EVENT",
time: 24,
timeUnit: "HOUR",
position: 0,
isActiveOnAll: false,
name: "Attendee SMS Reminder",
steps: {
create: {
stepNumber: 1,
action: "SMS_ATTENDEE",
sendTo: null,
reminderBody:
"Hi {ATTENDEE}, this is a reminder that your meeting ({EVENT_NAME}) with {ORGANIZER} is on {EVENT_DATE_YYYY MMM D} at {EVENT_TIME_h:mma} {TIMEZONE}.",
emailSubject: "Reminder: {EVENT_NAME} - {EVENT_DATE_ddd, MMM D, YYYY h:mma}",
template: "REMINDER",
numberRequired: true,
sender: "Cal",
numberVerificationPending: false,
includeCalendarEvent: false,
verifiedAt: new Date(),
},
},
});
eventTypeWithAttendeeSmsReminder = await eventTypesRepositoryFixture.create(
{
title: "event with attendee sms reminder",
slug: "event-with-attendee-sms-reminder",
length: 15,
bookingFields: [
{
name: "smsReminderNumber",
type: "phone",
sources: [
{
id: String(workflow.id),
type: "workflow",
label: "Workflow",
editUrl: `/workflows/${workflow.id}`,
fieldRequired: true,
},
],
editable: "system",
required: true,
defaultLabel: "number_text_notifications",
defaultPlaceholder: "enter_phone_number",
},
],
metadata: {
disableStandardEmails: {
all: {
attendee: true,
host: true,
},
confirmation: {
host: true,
attendee: true,
},
},
},
workflows: {
create: {
workflow: {
connect: {
id: workflow.id,
},
},
},
},
},
user.id
);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -1668,7 +1756,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
describe("book by username and event type slug", () => {
it("should not create a booking by if neither event type id nor username and event type slug provided", async () => {
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
const body = {
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
attendee: {
@@ -1677,7 +1764,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
timeZone: "Europe/Rome",
language: "it",
},
location: googleMeetUrl,
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
@@ -2227,6 +2314,91 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("create booking with attendee sms reminder", () => {
it("should not be able create a booking if attendee sms reminder is missing", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
eventTypeId: eventTypeWithAttendeeSmsReminder.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message).toEqual(
`Missing attendee phone number - it is required by the event type. Pass it as \"attendee.phoneNumber\" string in the request.`
);
});
it("should be able create a booking if attendee sms reminder is provided", async () => {
const phoneNumber = "+37122222222";
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
eventTypeId: eventTypeWithAttendeeSmsReminder.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
phoneNumber,
},
};
const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201);
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 15, 15, 0)).toISOString());
expect(data.duration).toEqual(15);
expect(data.eventTypeId).toEqual(eventTypeWithAttendeeSmsReminder.id);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
phoneNumber: body.attendee.phoneNumber,
absent: false,
});
expect(data.bookingFieldsResponses).toEqual({
name: body.attendee.name,
email: body.attendee.email,
attendeePhoneNumber: body.attendee.phoneNumber,
smsReminderNumber: body.attendee.phoneNumber,
});
const dbBooking = await bookingsRepositoryFixture.getById(data.id);
expect(dbBooking?.smsReminderNumber).toEqual(phoneNumber);
const dbWorkflowReminder = await workflowReminderRepositoryFixture.getByBookingUid(data.uid);
expect(dbWorkflowReminder?.method).toEqual("SMS");
} else {
throw new Error("Invalid response data");
}
});
});
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
return (
!Array.isArray(data) &&
@@ -20,6 +20,7 @@ import { UsersRepository, UserWithProfile } from "@/modules/users/users.reposito
import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException } from "@nestjs/common";
import { Request } from "express";
import { DateTime } from "luxon";
import { z } from "zod";
import {
@@ -59,13 +60,17 @@ type CreatedBooking = {
start: string;
};
const eventTypeBookingFieldSchema = z.object({
name: z.string(),
required: z.boolean(),
editable: z.string(),
});
const eventTypeBookingFieldSchema = z
.object({
name: z.string(),
required: z.boolean(),
editable: z.string(),
type: z.string(),
options: z.array(z.object({ value: z.string() })).optional(),
})
.passthrough();
const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
export const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
export type EventTypeWithOwnerAndTeam = EventType & { owner: User | null; team: Team | null };
@@ -171,36 +176,187 @@ export class BookingsService_2024_08_13 {
}
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
const bookingFields = { ...body.bookingFieldsResponses, attendeePhoneNumber: body.attendee.phoneNumber };
const bookingFieldsResponses: Record<string, unknown> = {
...body.bookingFieldsResponses,
attendeePhoneNumber: body.attendee.phoneNumber,
smsReminderNumber: body.attendee.phoneNumber,
};
if (!eventType?.bookingFields) {
return true;
}
// note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee
// note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee. Only exception
// is smsReminderNumber, because if it is required and not passed sms workflow won't work.
const eventTypeBookingFields = eventTypeBookingFieldsSchema
.parse(eventType.bookingFields)
.filter((field) => !field.editable.startsWith("system"));
.filter((field) => !field.editable.startsWith("system") || field.name === "smsReminderNumber");
if (!eventTypeBookingFields.length) {
return true;
}
for (const field of eventTypeBookingFields) {
if (field.required && !(field.name in bookingFields)) {
if (field.name === "attendeePhoneNumber") {
for (const eventTypeBookingField of eventTypeBookingFields) {
if (
eventTypeBookingField.required &&
(bookingFieldsResponses[eventTypeBookingField.name] === null ||
bookingFieldsResponses[eventTypeBookingField.name] === undefined)
) {
if (
eventTypeBookingField.name === "attendeePhoneNumber" ||
eventTypeBookingField.name === "smsReminderNumber"
) {
throw new BadRequestException(
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" in the request.`
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" string in the request.`
);
}
throw new BadRequestException(
`Missing required booking field response: ${field.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses. You can fetch the event type with ID ${eventType.id} to see the required fields.`
`Missing required booking field response: ${eventTypeBookingField.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses. You can fetch the event type with ID ${eventType.id} to see the required fields.`
);
}
const bookingFieldResponseValue = bookingFieldsResponses[eventTypeBookingField.name];
if (bookingFieldResponseValue !== undefined) {
const bookingFieldResponseValueType = typeof bookingFieldResponseValue;
let expectedBookingFieldResponseValueType = "";
let isValidType = false;
const eventTypeBookingFieldType = eventTypeBookingField.type;
switch (eventTypeBookingFieldType) {
case "phone":
expectedBookingFieldResponseValueType = "string";
isValidType = bookingFieldResponseValueType === "string";
break;
case "address":
expectedBookingFieldResponseValueType = "string";
isValidType = bookingFieldResponseValueType === "string";
break;
case "text":
expectedBookingFieldResponseValueType = "string";
isValidType = bookingFieldResponseValueType === "string";
break;
case "number":
expectedBookingFieldResponseValueType = "number";
isValidType = bookingFieldResponseValueType === "number";
break;
case "textarea":
expectedBookingFieldResponseValueType = "string";
isValidType = bookingFieldResponseValueType === "string";
break;
case "multiemail":
expectedBookingFieldResponseValueType = "array";
isValidType = Array.isArray(bookingFieldResponseValue);
break;
case "boolean":
expectedBookingFieldResponseValueType = "boolean";
isValidType = bookingFieldResponseValueType === "boolean";
break;
case "select":
expectedBookingFieldResponseValueType = "string or number";
isValidType =
bookingFieldResponseValueType === "string" || bookingFieldResponseValueType === "number";
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
const submittedValue = bookingFieldResponseValue as string | number;
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) {
throw new BadRequestException(
`Invalid option '${submittedValue}' for booking field '${
eventTypeBookingField.name
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
);
}
}
break;
case "multiselect":
expectedBookingFieldResponseValueType = "array";
isValidType = Array.isArray(bookingFieldResponseValue);
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
const submittedValues = bookingFieldResponseValue as (string | number)[];
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) {
throw new BadRequestException(
`One or more invalid options for booking field '${
eventTypeBookingField.name
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
);
}
}
break;
case "checkbox":
expectedBookingFieldResponseValueType = "array";
isValidType = Array.isArray(bookingFieldResponseValue);
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
const submittedValues = bookingFieldResponseValue as (string | number)[];
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) {
throw new BadRequestException(
`One or more invalid options for booking field '${
eventTypeBookingField.name
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
);
}
}
break;
case "radio":
expectedBookingFieldResponseValueType = "string or number";
isValidType =
bookingFieldResponseValueType === "string" || bookingFieldResponseValueType === "number";
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
const submittedValue = bookingFieldResponseValue as string | number;
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) {
throw new BadRequestException(
`Invalid option '${submittedValue}' for booking field '${
eventTypeBookingField.name
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
);
}
}
break;
case "url":
expectedBookingFieldResponseValueType = "string";
isValidType = bookingFieldResponseValueType === "string";
break;
default:
// note(Lauris): by default pass the field if we have a missing "case" in the switch
isValidType = true;
break;
}
if (!isValidType) {
throw new BadRequestException(
`Invalid type for booking field '${eventTypeBookingField.name}'. Expected type ${expectedBookingFieldResponseValueType} (compatible with field type '${eventTypeBookingFieldType}'), but received ${bookingFieldResponseValueType}.`
);
}
}
}
return true;
}
private isValidSingleOptionValue(
bookingFieldResponseValue: string | number,
eventTypeBookingFieldOptions: string[]
): boolean {
if (eventTypeBookingFieldOptions.length === 0) {
// note(Lauris): If no options defined, cannot validate against them, so pass.
return true;
}
return eventTypeBookingFieldOptions.some((val) => val === String(bookingFieldResponseValue));
}
private areValidMultipleOptionValues(
bookingFieldResponseValues: (string | number)[],
eventTypeBookingFieldOptions: string[]
): boolean {
if (eventTypeBookingFieldOptions.length === 0) {
// note(Lauris): If no options defined, cannot validate against them, so pass.
return true;
}
return bookingFieldResponseValues.every((submittedVal) =>
eventTypeBookingFieldOptions.some((allowedVal) => allowedVal === String(submittedVal))
);
}
async createInstantBooking(
request: Request,
body: CreateInstantBookingInput_2024_08_13,
@@ -594,6 +750,20 @@ export class BookingsService_2024_08_13 {
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
if (!bookingBefore) {
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
}
const nowUtc = DateTime.utc();
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
if (nowUtc < bookingStartTimeUtc) {
throw new BadRequestException(
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
);
}
const platformClientParams = bookingBefore?.eventTypeId
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
: undefined;
@@ -1,5 +1,8 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { EventTypeWithOwnerAndTeam } from "@/ee/bookings/2024-08-13/services/bookings.service";
import {
eventTypeBookingFieldsSchema,
EventTypeWithOwnerAndTeam,
} from "@/ee/bookings/2024-08-13/services/bookings.service";
import {
bookingResponsesSchema,
seatedBookingDataSchema,
@@ -159,6 +162,12 @@ export class InputBookingsService_2024_08_13 {
this.isBookingLocationWithEventTypeLocations(inputLocation, eventType);
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
const needsSmsReminderNumber = eventType.bookingFields
? eventTypeBookingFieldsSchema
.parse(eventType.bookingFields)
.find((field) => field.name === "smsReminderNumber")
: undefined;
return {
start: startTime.toISO(),
end: endTime.toISO(),
@@ -174,6 +183,7 @@ export class InputBookingsService_2024_08_13 {
name: inputBooking.attendee.name,
email: attendeeEmail ?? "",
attendeePhoneNumber: inputBooking.attendee.phoneNumber,
smsReminderNumber: needsSmsReminderNumber ? inputBooking.attendee.phoneNumber : undefined,
guests,
location,
},
@@ -1,4 +1,4 @@
import { OrganizerIntegrationLocation } from "@calcom/lib";
import { OrganizerIntegrationLocation } from "@calcom/lib/event-types/transformers/internal/locations";
type BaseEventType = {
length: number;
@@ -1428,7 +1428,6 @@ describe("Event types Endpoints", () => {
{
name: "location",
type: "radioInput",
label: "",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: false,
@@ -1558,7 +1557,6 @@ describe("Event types Endpoints", () => {
{
name: "location",
type: "radioInput",
label: "",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: false,
@@ -1783,6 +1781,68 @@ describe("Event types Endpoints", () => {
});
});
describe("creating event type with input of another event type output", () => {
let firstCreatedEventType: EventTypeOutput_2024_06_14;
let secondCreatedEventType: EventTypeOutput_2024_06_14;
it("should create first event type", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "first created coding class",
slug: "first-created-coding-class",
lengthInMinutes: 60,
locations: [
{
type: "address",
address: "via volturno 10, Roma",
public: true,
},
],
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
expect(createdEventType).toHaveProperty("id");
expect(createdEventType.title).toEqual(body.title);
expect(createdEventType.locations).toEqual(body.locations);
firstCreatedEventType = responseBody.data;
});
});
it("should create second event type using first as input", async () => {
const body = {
...firstCreatedEventType,
title: "second created coding class",
slug: "second-created-coding-class",
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
expect(createdEventType).toHaveProperty("id");
expect(createdEventType.title).toEqual(body.title);
secondCreatedEventType = responseBody.data;
const { id, title, slug, ...restFirst } = firstCreatedEventType;
const { id: id2, title: title2, slug: slug2, ...restSecond } = secondCreatedEventType;
expect(restFirst).toEqual(restSecond);
expect(id2).not.toEqual(id);
expect(title2).not.toEqual(title);
expect(slug2).not.toEqual(slug);
});
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -224,6 +224,7 @@ export class InputEventTypesService_2024_06_14 {
const systemCustomNameField = systemCustomFields?.find((field) => field.type === "name");
const systemCustomEmailField = systemCustomFields?.find((field) => field.type === "email");
const systemCustomTitleField = systemCustomFields?.find((field) => field.name === "title");
const systemCustomLocationField = systemCustomFields?.find((field) => field.name === "location");
const systemCustomNotesField = systemCustomFields?.find((field) => field.name === "notes");
const systemCustomGuestsField = systemCustomFields?.find((field) => field.name === "guests");
const systemCustomRescheduleReasonField = systemCustomFields?.find(
@@ -233,7 +234,7 @@ export class InputEventTypesService_2024_06_14 {
const defaultFieldsBefore: (SystemField | CustomField)[] = [
systemCustomNameField || systemBeforeFieldName,
systemCustomEmailField || systemBeforeFieldEmail,
systemBeforeFieldLocation,
systemCustomLocationField || systemBeforeFieldLocation,
];
const defaultFieldsAfter = [
@@ -274,7 +275,8 @@ export class InputEventTypesService_2024_06_14 {
field.name !== "title" &&
field.name !== "notes" &&
field.name !== "guests" &&
field.name !== "rescheduleReason"
field.name !== "rescheduleReason" &&
field.name !== "location"
);
}
@@ -177,7 +177,7 @@ export class OAuthClientUsersController {
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
await this.tokensRepository.createOAuthTokens(oAuthClientId, id, true);
await this.tokensRepository.forceRefreshOAuthTokens(oAuthClientId, id);
return {
status: SUCCESS_STATUS,
@@ -685,6 +685,92 @@ describe("Slots 2024-04-15 Endpoints", () => {
await bookingsRepositoryFixture.deleteById(booking.id);
});
describe("routingFormResponseId and _isDryRun validation", () => {
const baseUrl = "/api/v2/slots/available";
const baseParams = {
startTime: "2050-09-05",
endTime: "2050-09-10",
};
it("should allow routingFormResponseId=0 in dry-run mode", async () => {
return request(app.getHttpServer())
.get(baseUrl)
.query({
...baseParams,
eventTypeId,
routingFormResponseId: 0,
_isDryRun: true,
})
.expect(200)
.expect((res) => {
expect(res.body.status).toEqual(SUCCESS_STATUS);
});
});
it("should reject routingFormResponseId=1 in dry-run mode", async () => {
return request(app.getHttpServer())
.get(baseUrl)
.query({
...baseParams,
eventTypeId,
routingFormResponseId: 1,
_isDryRun: true,
})
.expect(400)
.expect((res) => {
expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain(
"routingFormResponseId must be 0 for dry run"
);
});
});
it("should allow routingFormResponseId=1 in non-dry-run mode", async () => {
return request(app.getHttpServer())
.get(baseUrl)
.query({
...baseParams,
eventTypeId,
routingFormResponseId: 1,
})
.expect(200)
.expect((res) => {
expect(res.body.status).toEqual(SUCCESS_STATUS);
});
});
it("should reject routingFormResponseId=0 in non-dry-run mode", async () => {
return request(app.getHttpServer())
.get(baseUrl)
.query({
...baseParams,
eventTypeId,
routingFormResponseId: 0,
})
.expect(400)
.expect((res) => {
expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain(
"routingFormResponseId must be a positive number"
);
});
});
it("should reject routingFormResponseId=-1 in non-dry-run mode", async () => {
return request(app.getHttpServer())
.get(baseUrl)
.query({
...baseParams,
eventTypeId,
routingFormResponseId: -1,
})
.expect(400)
.expect((res) => {
expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain(
"routingFormResponseId must be a positive number"
);
});
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await selectedSlotsRepositoryFixture.deleteByUId(reservedSlotUid);
@@ -1,7 +1,7 @@
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
import type { RangeSlots, TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
import { SlotsService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots.service";
import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common";
import { Query, Body, Controller, Get, Delete, Post, Req, Res, BadRequestException } from "@nestjs/common";
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger";
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
@@ -157,6 +157,7 @@ export class SlotsController_2024_04_15 {
@Query() query: GetAvailableSlotsInput_2024_04_15,
@Req() req: ExpressRequest
): Promise<ApiResponse<{ slots: TimeSlots["slots"] | RangeSlots["slots"] }>> {
try {
const isTeamEvent =
query.isTeamEvent === undefined
? await this.slotsService.checkIfIsTeamEvent(query.eventTypeId)
@@ -171,19 +172,29 @@ export class SlotsController_2024_04_15 {
},
});
const { slots } = await this.slotsOutputService.getOutputSlots(
availableSlots,
query.duration,
query.eventTypeId,
query.slotFormat,
query.timeZone
);
const { slots } = await this.slotsOutputService.getOutputSlots(
availableSlots,
query.duration,
query.eventTypeId,
query.slotFormat,
query.timeZone
);
return {
data: {
slots,
},
status: SUCCESS_STATUS,
};
return {
data: {
slots,
},
status: SUCCESS_STATUS,
};
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("Invalid time range given")) {
throw new BadRequestException(
"Invalid time range given - check the 'startTime' and 'endTime' query parameters."
);
}
}
throw error;
}
}
}
@@ -21,7 +21,9 @@ export class SlotsService_2024_04_15 {
let shouldReserveSlot = true;
if (eventType.seatsPerTimeSlot) {
const bookingWithAttendees = await this.slotsRepo.getBookingWithAttendees(input.bookingUid);
const bookingWithAttendees = input.bookingUid
? await this.slotsRepo.getBookingWithAttendees(input.bookingUid)
: undefined;
const bookingAttendeesLength = bookingWithAttendees?.attendees?.length;
if (bookingAttendeesLength) {
const seatsLeft = eventType.seatsPerTimeSlot - bookingAttendeesLength;
@@ -82,7 +82,7 @@ export class SlotsController_2024_09_04 {
Optional parameters are:
- timeZone: Time zone in which the available slots should be returned. Defaults to UTC.
- duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.
- slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value.
- format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value.
`,
})
@ApiQuery({
@@ -99,7 +99,7 @@ export class SlotsController_2024_09_04 {
example: "60",
})
@ApiQuery({
name: "slotFormat",
name: "format",
required: false,
description:
"Format of slot times in response. Use 'range' to get start and end times. Use 'time' or omit this query parameter to get only start time.",
@@ -42,24 +42,33 @@ export class SlotsService_2024_09_04 {
) {}
async getAvailableSlots(query: GetSlotsInput_2024_09_04) {
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
try {
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
const availableSlots: TimeSlots = await getAvailableSlots({
input: {
...queryTransformed,
},
ctx: {},
});
const formatted = await this.slotsOutputService.getAvailableSlots(
availableSlots,
queryTransformed.eventTypeId,
queryTransformed.duration,
query.format,
queryTransformed.timeZone
);
const availableSlots: TimeSlots = await getAvailableSlots({
input: {
...queryTransformed,
},
ctx: {},
});
const formatted = await this.slotsOutputService.getAvailableSlots(
availableSlots,
queryTransformed.eventTypeId,
queryTransformed.duration,
query.format,
queryTransformed.timeZone
);
return formatted;
return formatted;
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("Invalid time range given")) {
throw new BadRequestException(
"Invalid time range given - check the 'start' and 'end' query parameters."
);
}
}
throw error;
}
}
async reserveSlot(input: ReserveSlotInput_2024_09_04, authUserId?: number) {
@@ -5,6 +5,7 @@ import { Injectable } from "@nestjs/common";
import { Logger } from "@nestjs/common";
import { PlatformAuthorizationToken } from "@prisma/client";
import { DateTime } from "luxon";
import { v4 as uuidv4 } from "uuid";
@Injectable()
export class TokensRepository {
@@ -50,31 +51,18 @@ export class TokensRepository {
});
}
async createOAuthTokens(clientId: string, ownerId: number, deleteOld?: boolean) {
if (deleteOld) {
try {
await this.dbWrite.prisma.$transaction([
this.dbWrite.prisma.accessToken.deleteMany({
where: { client: { id: clientId }, userId: ownerId },
}),
this.dbWrite.prisma.refreshToken.deleteMany({
where: {
client: { id: clientId },
userId: ownerId,
},
}),
]);
} catch (err) {
this.logger.error("createOAuthTokens - Failed to delete old tokens", err);
}
}
async createOAuthTokens(clientId: string, ownerId: number) {
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
this.dbWrite.prisma.accessToken.create({
data: {
secret: this.jwtService.signAccessToken({ clientId, ownerId, expiresAt: accessExpiry.valueOf() }),
secret: this.jwtService.signAccessToken({
clientId,
ownerId,
expiresAt: accessExpiry.valueOf(),
jti: uuidv4(),
}),
expiresAt: accessExpiry,
client: { connect: { id: clientId } },
owner: { connect: { id: ownerId } },
@@ -82,7 +70,12 @@ export class TokensRepository {
}),
this.dbWrite.prisma.refreshToken.create({
data: {
secret: this.jwtService.signRefreshToken({ clientId, ownerId, expiresAt: refreshExpiry.valueOf() }),
secret: this.jwtService.signRefreshToken({
clientId,
ownerId,
expiresAt: refreshExpiry.valueOf(),
jti: uuidv4(),
}),
expiresAt: refreshExpiry,
client: { connect: { id: clientId } },
owner: { connect: { id: ownerId } },
@@ -98,6 +91,57 @@ export class TokensRepository {
};
}
async forceRefreshOAuthTokens(clientId: string, ownerId: number) {
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
const [_deletedAccessToken, _deletedRefreshToken, accessToken, refreshToken] =
await this.dbWrite.prisma.$transaction([
this.dbWrite.prisma.accessToken.deleteMany({
where: { client: { id: clientId }, userId: ownerId },
}),
this.dbWrite.prisma.refreshToken.deleteMany({
where: {
client: { id: clientId },
userId: ownerId,
},
}),
this.dbWrite.prisma.accessToken.create({
data: {
secret: this.jwtService.signAccessToken({
clientId,
ownerId,
expiresAt: accessExpiry.valueOf(),
jti: uuidv4(),
}),
expiresAt: accessExpiry,
client: { connect: { id: clientId } },
owner: { connect: { id: ownerId } },
},
}),
this.dbWrite.prisma.refreshToken.create({
data: {
secret: this.jwtService.signRefreshToken({
clientId,
ownerId,
expiresAt: refreshExpiry.valueOf(),
jti: uuidv4(),
}),
expiresAt: refreshExpiry,
client: { connect: { id: clientId } },
owner: { connect: { id: ownerId } },
},
}),
]);
return {
accessToken: accessToken.secret,
accessTokenExpiresAt: accessToken.expiresAt,
refreshToken: refreshToken.secret,
refreshTokenExpiresAt: refreshToken.expiresAt,
};
}
async getAccessTokenExpiryDate(accessTokenSecret: string) {
const accessToken = await this.dbRead.prisma.accessToken.findFirst({
where: {
@@ -140,6 +184,7 @@ export class TokensRepository {
ownerId: tokenUserId,
expiresAt: accessExpiry.valueOf(),
userId: tokenUserId,
jti: uuidv4(),
}),
expiresAt: accessExpiry,
client: { connect: { id: clientId } },
@@ -155,6 +200,7 @@ export class TokensRepository {
ownerId: tokenUserId,
expiresAt: refreshExpiry.valueOf(),
userId: tokenUserId,
jti: uuidv4(),
}),
expiresAt: refreshExpiry,
client: { connect: { id: clientId } },
+45 -14
View File
@@ -10506,7 +10506,7 @@
"get": {
"operationId": "SlotsController_2024_09_04_getAvailableSlots",
"summary": "Find out when is an event type ready to be booked.",
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n\n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n ",
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n\n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n ",
"parameters": [
{
"name": "cal-api-version",
@@ -10583,7 +10583,7 @@
"schema": {}
},
{
"name": "slotFormat",
"name": "format",
"required": false,
"in": "query",
"description": "Format of slot times in response. Use 'range' to get start and end times. Use 'time' or omit this query parameter to get only start time.",
@@ -14546,6 +14546,22 @@
"slug"
]
},
"LocationDefaultFieldInput_2024_06_14": {
"type": "object",
"properties": {
"slug": {
"type": "string",
"example": "location",
"description": "only allowed value for type is `location`. This booking field is displayed only when event type has 2 or more locations in order to allow person doing the booking pick the location."
},
"label": {
"type": "string"
}
},
"required": [
"slug"
]
},
"NotesDefaultFieldInput_2024_06_14": {
"type": "object",
"properties": {
@@ -14759,6 +14775,9 @@
{
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
},
@@ -15350,6 +15369,9 @@
"hidden": {
"type": "boolean",
"description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both."
},
"label": {
"type": "string"
}
},
"required": [
@@ -15366,11 +15388,11 @@
"slug": {
"type": "string",
"enum": [
"name",
"email",
"title",
"location",
"notes",
"guests"
"guests",
"rescheduleReason"
],
"example": "rescheduleReason",
"description": "only allowed value for type is `rescheduleReason`",
@@ -15416,11 +15438,11 @@
"slug": {
"type": "string",
"enum": [
"name",
"email",
"title",
"location",
"notes",
"guests"
"guests",
"rescheduleReason"
],
"example": "title",
"description": "only allowed value for type is `title`",
@@ -15466,11 +15488,11 @@
"slug": {
"type": "string",
"enum": [
"name",
"email",
"title",
"location",
"notes",
"guests"
"guests",
"rescheduleReason"
],
"example": "notes",
"description": "only allowed value for type is `notes`",
@@ -15516,11 +15538,11 @@
"slug": {
"type": "string",
"enum": [
"name",
"email",
"title",
"location",
"notes",
"guests"
"guests",
"rescheduleReason"
],
"example": "guests",
"description": "only allowed value for type is `guests`",
@@ -16749,6 +16771,9 @@
{
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
},
@@ -18563,6 +18588,9 @@
{
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
},
@@ -19421,6 +19449,9 @@
{
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
},
@@ -0,0 +1,17 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
export class WorkflowReminderRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async getByBookingUid(uid: string) {
return this.prismaReadClient.workflowReminder.findFirst({ where: { bookingUid: uid } });
}
}
@@ -0,0 +1,23 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma } from "@calcom/prisma/client";
export class WorkflowRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async create(data: Prisma.WorkflowCreateInput) {
return this.prismaWriteClient.workflow.create({ data });
}
async delete(id: number) {
return this.prismaWriteClient.workflow.delete({ where: { id } });
}
}
@@ -1,7 +1,10 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import type { PageProps as ServerPageProps } from "app/_types";
import { cookies, headers } from "next/headers";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@server/lib/[user]/[type]/getServerSideProps";
@@ -13,6 +16,18 @@ const getData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(context);
const locale = props.eventData?.interfaceLanguage;
if (locale) {
const ns = "common";
const translations = await loadTranslations(locale, ns);
return (
<CustomI18nProvider translations={translations} locale={locale} ns={ns}>
<TypePage {...props} />
</CustomI18nProvider>
);
}
return <TypePage {...props} />;
};
@@ -1,9 +1,11 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { generateMeetingMetadata } from "app/_utils";
import { headers, cookies } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
@@ -55,6 +57,17 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(legacyCtx);
const locale = props.eventData?.interfaceLanguage;
if (locale) {
const ns = "common";
const translations = await loadTranslations(locale, ns);
return (
<CustomI18nProvider translations={translations} locale={locale} ns={ns}>
<LegacyPage {...props} />
</CustomI18nProvider>
);
}
return <LegacyPage {...props} />;
};
@@ -1,7 +1,10 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import type { PageProps as ServerPageProps } from "app/_types";
import { cookies, headers } from "next/headers";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
@@ -17,8 +20,32 @@ export type ClientPageProps = UserTypePageProps | TeamTypePageProps;
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(context);
if ((props as TeamTypePageProps)?.teamId) return <TeamTypePage {...(props as TeamTypePageProps)} />;
return <UserTypePage {...(props as UserTypePageProps)} />;
const eventLocale = props.eventData?.interfaceLanguage;
const ns = "common";
let translations;
if (eventLocale) {
const ns = "common";
translations = await loadTranslations(eventLocale, ns);
}
if ((props as TeamTypePageProps)?.teamId) {
return eventLocale ? (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
<TeamTypePage {...(props as TeamTypePageProps)} />
</CustomI18nProvider>
) : (
<TeamTypePage {...(props as TeamTypePageProps)} />
);
}
return eventLocale ? (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
<UserTypePage {...(props as UserTypePageProps)} />
</CustomI18nProvider>
) : (
<UserTypePage {...(props as UserTypePageProps)} />
);
};
export default ServerPage;
@@ -1,9 +1,11 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { generateMeetingMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
@@ -63,10 +65,32 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
const props = await getData(
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
);
if ((props as TeamTypePageProps)?.teamId) {
return <TeamTypePage {...(props as TeamTypePageProps)} />;
const eventLocale = props.eventData?.interfaceLanguage;
const ns = "common";
let translations;
if (eventLocale) {
const ns = "common";
translations = await loadTranslations(eventLocale, ns);
}
return <UserTypePage {...(props as UserTypePageProps)} />;
if ((props as TeamTypePageProps)?.teamId) {
return eventLocale ? (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
<TeamTypePage {...(props as TeamTypePageProps)} />
</CustomI18nProvider>
) : (
<TeamTypePage {...(props as TeamTypePageProps)} />
);
}
return eventLocale ? (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
<UserTypePage {...(props as UserTypePageProps)} />
</CustomI18nProvider>
) : (
<UserTypePage {...(props as UserTypePageProps)} />
);
};
export default ServerPage;
@@ -1,7 +1,10 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import type { PageProps as ServerPageProps } from "app/_types";
import { cookies, headers } from "next/headers";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
@@ -12,6 +15,18 @@ const getData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(context);
const eventLocale = props.eventData?.interfaceLanguage;
if (eventLocale) {
const ns = "common";
const translations = await loadTranslations(eventLocale, ns);
return (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
<TypePage {...props} />
</CustomI18nProvider>
);
}
return <TypePage {...props} />;
};
@@ -1,9 +1,11 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { generateMeetingMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
@@ -54,6 +56,18 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
const props = await getData(
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
);
const eventLocale = props.eventData?.interfaceLanguage;
if (eventLocale) {
const ns = "common";
const translations = await loadTranslations(eventLocale, ns);
return (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
<LegacyPage {...props} />
</CustomI18nProvider>
);
}
return <LegacyPage {...props} />;
};
export default ServerPage;
@@ -1,7 +1,10 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import type { PageProps as ServerPageProps } from "app/_types";
import { cookies, headers } from "next/headers";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import OldPage from "~/bookings/views/bookings-single-view";
@@ -15,6 +18,18 @@ const getEmbedData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getEmbedData(context);
const eventLocale = props.eventType?.interfaceLanguage;
if (eventLocale) {
const ns = "common";
const translations = await loadTranslations(eventLocale, ns);
return (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
<OldPage {...props} />
</CustomI18nProvider>
);
}
return <OldPage {...props} />;
};
@@ -1,9 +1,11 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps as _PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { BookingStatus } from "@calcom/prisma/enums";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
@@ -36,6 +38,18 @@ const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: _PageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(context);
const eventLocale = props.eventType?.interfaceLanguage;
if (eventLocale) {
const ns = "common";
const translations = await loadTranslations(eventLocale, ns);
return (
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
<OldPage {...props} />
</CustomI18nProvider>
);
}
return <OldPage {...props} />;
};
export default ServerPage;
@@ -1,12 +1,22 @@
import { getTranslate } from "app/_utils";
import { notFound } from "next/navigation";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import Shell from "@calcom/features/shell/Shell";
import UpgradeTipWrapper from "./UpgradeTipWrapper";
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
}
const t = await getTranslate();
return (
<>
<div>
@@ -1,7 +1,4 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import InsightsPage from "~/insights/insights-view";
@@ -15,12 +12,5 @@ export const generateMetadata = async () =>
);
export default async function Page() {
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
}
return <InsightsPage />;
}
@@ -1,7 +1,4 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import InsightsVirtualQueuesPage from "~/insights/insights-virtual-queues-view";
@@ -15,12 +12,5 @@ export const generateMetadata = async () =>
);
export default async function Page() {
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
}
return <InsightsVirtualQueuesPage />;
}
@@ -1,7 +1,4 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import InsightsRoutingPage from "~/insights/insights-routing-view";
@@ -15,12 +12,5 @@ export const generateMetadata = async () =>
);
export default async function Page() {
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
}
return <InsightsRoutingPage />;
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { createContext, useMemo } from "react";
import type { ReactNode } from "react";
type CustomI18nContextType = {
translations: Record<string, string>;
ns: string;
locale: string;
};
export const CustomI18nContext = createContext<CustomI18nContextType | null>(null);
export function CustomI18nProvider({
children,
translations,
locale,
ns,
}: CustomI18nContextType & {
children: ReactNode;
}) {
// Memoize the value to prevent re-renders unless the data changes
const value = useMemo(
() => ({
translations,
locale,
ns,
}),
[locale, ns]
);
return <CustomI18nContext.Provider value={value}>{children}</CustomI18nContext.Provider>;
}
@@ -5,7 +5,8 @@ import { NextResponse } from "next/server";
import dayjs from "@calcom/dayjs";
import { sendOrganizerRequestReminderEmail } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { BookingStatus, ReminderType } from "@calcom/prisma/enums";
+13
View File
@@ -6,6 +6,7 @@ import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
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,
@@ -20,6 +21,18 @@ Sentry.init({
// 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",
+1
View File
@@ -26,6 +26,7 @@ export const getEventTypesFromDB = async (id: number) => {
id: true,
title: true,
description: true,
interfaceLanguage: true,
length: true,
eventName: true,
recurringEvent: true,
@@ -141,6 +141,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
title: eventData.title,
users: eventHostsUserData,
hidden: eventData.hidden,
interfaceLanguage: eventData.interfaceLanguage,
},
booking,
user: teamSlug,
@@ -206,6 +207,7 @@ const getTeamWithEventsData = async (
hidden: true,
disableCancelling: true,
disableRescheduling: true,
interfaceLanguage: true,
hosts: {
take: 3,
select: {
@@ -112,7 +112,7 @@ export default function ForgotPassword(props: PageProps) {
{
"--cal-brand": "#111827",
"--cal-brand-emphasis": "#101010",
"--cal-brand-text": "white",
"--cal-brand-text": "Black",
"--cal-brand-subtle": "#9CA3AF",
} as CSSProperties
}>
@@ -127,7 +127,7 @@ export default function ForgotPassword(props: PageProps) {
/>
<div className="space-y-2">
<Button
className="w-full justify-center dark:bg-white dark:text-black"
className="w-full justify-center bg-white hover:bg-black hover:text-white"
type="submit"
color="primary"
disabled={loading}
@@ -5,8 +5,8 @@ import { z } from "zod";
import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
import { parseRecurringEvent } from "@calcom/lib";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
@@ -15,7 +15,6 @@ import { EventTypeDescription } from "@calcom/features/eventtypes/components";
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
import { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader";
import { parseEventTypeColor } from "@calcom/lib";
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
@@ -24,6 +23,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error";
import { parseEventTypeColor } from "@calcom/lib/isEventTypeColor";
import type { MembershipRole } from "@calcom/prisma/enums";
import { SchedulingType } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -1,9 +1,11 @@
"use client";
import { ProgressBar } from "@tremor/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
@@ -61,7 +63,19 @@ export default function BillingCredits() {
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-6 py-6 pb-6 text-sm sm:space-y-8">
<div>
<h2 className="text-base font-semibold">{t("credits")}</h2>
<p>{t("view_and_manage_credits_description")}</p>
<ServerTrans
t={t}
i18nKey="view_and_manage_credits_description"
components={[
<Link
key="Credit System"
className="underline underline-offset-2"
target="_blank"
href="https://cal.com/help/billing-and-usage/messaging-credits">
Learn more
</Link>,
]}
/>
<div className="-mx-6 mt-6">
<hr className="border-subtle" />
</div>
@@ -90,7 +104,6 @@ export default function BillingCredits() {
) : (
<></>
)}
<Label>
{creditsData.credits.totalMonthlyCredits ? t("additional_credits") : t("available_credits")}
</Label>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.2.16",
"version": "5.3.2",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+2 -2
View File
@@ -76,7 +76,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const creditService = new CreditService();
if (countryCode === "US" || countryCode === "CA") {
// SMS to US and CA are free on a team plan
// SMS to US and CA are free
let teamIdToCharge = parsedTeamId;
if (!teamIdToCharge && parsedUserId) {
@@ -100,7 +100,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
credits: 0,
});
return res.status(200).send(`SMS to US and CA are free on a team plan. Credits set to 0`);
return res.status(200).send(`SMS to US and CA are free. Credits set to 0`);
}
let orgId;
+139
View File
@@ -421,6 +421,145 @@ test.describe("Event Types tests", () => {
await expect(offerSeatsToggle).toBeDisabled();
});
});
test.describe("Interface Language Tests", () => {
test.use({
locale: "en",
});
test("by default the Interface language has 'Visitor's browser language' selected", async ({
page,
users,
}) => {
await test.step("should create a en user", async () => {
const user = await users.create({
locale: "en",
});
await user.apiLogin();
await page.goto("/event-types");
await page.waitForSelector('[data-testid="event-types"]');
});
await test.step("should open first eventType and check Interface Language", async () => {
await gotoFirstEventType(page);
const interfaceLanguageValue = page
.getByTestId("event-interface-language")
.locator('div[class$="-singleValue"]');
await expect(interfaceLanguageValue).toHaveText("Visitor's browser language");
});
});
test("user can change the interface language to any other language and the booking page should be rendered in that language", async ({
page,
users,
}) => {
await test.step("should create a en user", async () => {
const user = await users.create({
locale: "en",
});
await user.apiLogin();
await page.goto("/event-types");
await page.waitForSelector('[data-testid="event-types"]');
});
await test.step("should open first eventType and change Interface Language to Deutsche", async () => {
await gotoFirstEventType(page);
await page.getByTestId("event-interface-language").click();
await page.locator(`text="Deutsch"`).click();
await saveEventType(page);
});
await test.step("should open corresponding booking page and ensure language rendered is Deutsche", async () => {
await gotoBookingPage(page);
//expect the slot selection page to be rendered in 'Deutsch'
await expect(page.locator(`text="So"`).nth(0)).toBeVisible();
await expect(page.locator(`text="Mo"`).nth(0)).toBeVisible();
await expect(page.locator(`text="Di"`).nth(0)).toBeVisible();
await expect(page.locator(`text="Mi"`).nth(0)).toBeVisible();
await expect(page.locator(`text="Do"`).nth(0)).toBeVisible();
await expect(page.locator(`text="Fr"`).nth(0)).toBeVisible();
await expect(page.locator(`text="Sa"`).nth(0)).toBeVisible();
await expect(page.locator(`text="12 Std"`).nth(0)).toBeVisible();
await expect(page.locator(`text="24 Std"`).nth(0)).toBeVisible();
await selectFirstAvailableTimeSlotNextMonth(page);
//expect the booking inputs page to be rendered in 'Deutsch'
await expect(page.locator(`text="Ihr Name"`).nth(0)).toBeVisible();
await expect(page.locator(`text="E-Mail Adresse"`).nth(0)).toBeVisible();
await expect(page.locator(`text="Zusätzliche Notizen"`).nth(0)).toBeVisible();
await expect(page.locator(`text="+ Weitere Gäste"`).nth(0)).toBeVisible();
await expect(page.locator(`text="Zurück"`).nth(0)).toBeVisible();
await expect(page.locator(`text="Bestätigen"`).nth(0)).toBeVisible();
});
await test.step("should be able to book successfully and ensure success page is rendered in Deutsche", async () => {
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator(`text="Dieser Termin ist geplant"`).nth(0)).toBeVisible();
});
});
test("user locale setting is overridden by event type language setting for booking page", async ({
page,
users,
}) => {
await test.step("should create a de user and ensure app is rendered in de", async () => {
const user = await users.create({
locale: "de",
});
await user.apiLogin();
await page.goto("/event-types");
await page.waitForSelector('[data-testid="event-types"]');
{
const locator = page.getByText("Ereignistypen", { exact: true }).first(); // "general"
await expect(locator).toBeVisible();
}
});
await test.step("should open first eventType and change Interface Language to Español", async () => {
await page.goto("/event-types");
await page.waitForSelector('[data-testid="event-types"]');
await gotoFirstEventType(page);
await page.getByTestId("event-interface-language").click();
await page.getByTestId("select-option-es").click();
await saveEventType(page);
});
await test.step("should go to booking page and verify the Interface language is Español", async () => {
await gotoBookingPage(page);
//expect the slot selection page to be rendered in 'Español'
await expect(page.locator(`text="dom"`).nth(0)).toBeVisible();
await expect(page.locator(`text="lun"`).nth(0)).toBeVisible();
await expect(page.locator(`text="mar"`).nth(0)).toBeVisible();
await expect(page.locator(`text="mié"`).nth(0)).toBeVisible();
await expect(page.locator(`text="jue"`).nth(0)).toBeVisible();
await expect(page.locator(`text="vie"`).nth(0)).toBeVisible();
await expect(page.locator(`text="sáb"`).nth(0)).toBeVisible();
await expect(page.locator(`text="12 h"`).nth(0)).toBeVisible();
await expect(page.locator(`text="24hs"`).nth(0)).toBeVisible();
await selectFirstAvailableTimeSlotNextMonth(page);
//expect the booking inputs page to be rendered in 'Español'
await expect(page.locator(`text="Tu Nombre"`).nth(0)).toBeVisible();
await expect(page.locator(`text="Email"`).nth(0)).toBeVisible();
await expect(page.locator(`text="Notas Adicionales"`).nth(0)).toBeVisible();
await expect(page.locator(`text="Añadir invitados"`).nth(0)).toBeVisible();
await expect(page.locator(`text="Atrás"`).nth(0)).toBeVisible();
await expect(page.locator(`text="Confirmar"`).nth(0)).toBeVisible();
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
await test.step("ensure other components of the App is still rendered in de and not affected by setting eventType Interface Language to Español", async () => {
await page.goto("/event-types");
await page.waitForSelector('[data-testid="event-types"]');
{
const locator = page.getByText("Ereignistypen", { exact: true }).first(); // "general"
await expect(locator).toBeVisible();
}
});
});
});
});
const selectAttendeePhoneNumber = async (page: Page) => {
@@ -3024,7 +3024,7 @@
"buy_credits": "Buy Credits",
"credits": "Credits",
"view_and_manage_credits": "View and manage credits",
"view_and_manage_credits_description": "View and manage credits for sending SMS messages. One credit is worth 1¢ (USD)",
"view_and_manage_credits_description": "View and manage credits for sending SMS messages. One credit is worth 1¢ (USD). <0>Learn more</0>",
"buy_additional_credits": "Buy additional credits ($0.01 per credit)",
"overview": "Overview",
"organization_slug_taken": "Organization slug is already taken",
@@ -3118,6 +3118,8 @@
"could_not_find_slug_to_publish_org": "Could not find slug to publish the organization",
"picklist": "Picklist",
"most_cancelled_bookings": "Most Cancelled Bookings",
"interface_language": "Interface Language",
"visitors_browser_language": "Visitor's browser language",
"name_or_email": "Name or Email",
"salesforce_round_robin_skip_fallback_to_lead_owner": "If no contact is found, fallback to lead owner if it exists",
"credit_purchase_failed": "Credit purchase failed. Please try again or contact support.",
@@ -1089,7 +1089,7 @@
"learn_more": "Tudj meg többet",
"privacy_policy": "Adatvédelmi Irányelvek",
"terms_of_service": "Szolgáltatás feltételei",
"remove": "Ektávolítás",
"remove": "Eltávolítás",
"add": "Hozzáadás",
"installed_other": "{{count}} telepítve",
"verify_wallet": "A Tárca ellenőrzése",
+1 -1
View File
@@ -6,7 +6,7 @@ import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
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,
+1
View File
@@ -6,6 +6,7 @@ import * as Sentry from "@sentry/nextjs";
Sentry.init({
debug: !!process.env.SENTRY_DEBUG,
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
integrations: [Sentry.prismaIntegration()],
beforeSend(event) {
+35 -6
View File
@@ -10016,7 +10016,7 @@
"get": {
"operationId": "SlotsController_2024_09_04_getAvailableSlots",
"summary": "Find out when is an event type ready to be booked.",
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n\n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n ",
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n\n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n ",
"parameters": [
{
"name": "cal-api-version",
@@ -10093,7 +10093,7 @@
"schema": {}
},
{
"name": "slotFormat",
"name": "format",
"required": false,
"in": "query",
"description": "Format of slot times in response. Use 'range' to get start and end times. Use 'time' or omit this query parameter to get only start time.",
@@ -13687,6 +13687,20 @@
},
"required": ["slug"]
},
"LocationDefaultFieldInput_2024_06_14": {
"type": "object",
"properties": {
"slug": {
"type": "string",
"example": "location",
"description": "only allowed value for type is `location`. This booking field is displayed only when event type has 2 or more locations in order to allow person doing the booking pick the location."
},
"label": {
"type": "string"
}
},
"required": ["slug"]
},
"NotesDefaultFieldInput_2024_06_14": {
"type": "object",
"properties": {
@@ -13871,6 +13885,9 @@
{
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
},
@@ -14429,6 +14446,9 @@
"hidden": {
"type": "boolean",
"description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both."
},
"label": {
"type": "string"
}
},
"required": ["isDefault", "slug", "type", "required", "hidden"]
@@ -14438,7 +14458,7 @@
"properties": {
"slug": {
"type": "string",
"enum": ["name", "email", "title", "notes", "guests"],
"enum": ["title", "location", "notes", "guests", "rescheduleReason"],
"example": "rescheduleReason",
"description": "only allowed value for type is `rescheduleReason`",
"default": "rescheduleReason"
@@ -14478,7 +14498,7 @@
"properties": {
"slug": {
"type": "string",
"enum": ["name", "email", "title", "notes", "guests"],
"enum": ["title", "location", "notes", "guests", "rescheduleReason"],
"example": "title",
"description": "only allowed value for type is `title`",
"default": "title"
@@ -14518,7 +14538,7 @@
"properties": {
"slug": {
"type": "string",
"enum": ["name", "email", "title", "notes", "guests"],
"enum": ["title", "location", "notes", "guests", "rescheduleReason"],
"example": "notes",
"description": "only allowed value for type is `notes`",
"default": "notes"
@@ -14558,7 +14578,7 @@
"properties": {
"slug": {
"type": "string",
"enum": ["name", "email", "title", "notes", "guests"],
"enum": ["title", "location", "notes", "guests", "rescheduleReason"],
"example": "guests",
"description": "only allowed value for type is `guests`",
"default": "guests"
@@ -15656,6 +15676,9 @@
{
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
},
@@ -17205,6 +17228,9 @@
{
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
},
@@ -17996,6 +18022,9 @@
{
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
},
{
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
},
+5 -2
View File
@@ -79,10 +79,13 @@
"web": "yarn workspace @calcom/web",
"docker-build-api": "docker build -t cal-api -f ./infra/docker/api/Dockerfile .",
"docker-run-api": "docker run -p 80:80 cal-api",
"docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop"
"docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop",
"changesets-version": "yarn changeset version",
"changesets-release": "turbo run build && yarn changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.26.1",
"@changesets/changelog-github": "^0.5.1",
"@changesets/cli": "2.29.4",
"@jetstreamapp/soql-parser-js": "^6.1.0",
"@playwright/test": "^1.45.3",
"@snaplet/copycat": "^4.1.0",
@@ -31,17 +31,14 @@ async function postHandler(req: NextApiRequest) {
const selectedCalendar = await SelectedCalendarRepository.findFirstByGoogleChannelId(channelId);
if (!selectedCalendar) {
throw new HttpError({
statusCode: 200,
message: `No selected calendar found for googleChannelId: ${channelId}`,
});
log.info("postHandler", `No selected calendar found for googleChannelId: ${channelId}`);
return { message: "ok" };
}
const { credential } = selectedCalendar;
if (!credential)
throw new HttpError({
statusCode: 200,
message: `No credential found for selected calendar for googleChannelId: ${channelId}`,
});
if (!credential) {
log.info("postHandler", `No credential found for selected calendar for googleChannelId: ${channelId}`);
return { message: "ok" };
}
const { selectedCalendars } = credential;
const credentialForCalendarCache = await getCredentialForCalendarCache({ credentialId: credential.id });
const calendarServiceForCalendarCache = await getCalendar(credentialForCalendarCache);
@@ -167,7 +167,14 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
icon="user-plus"
title={t("salesforce_create_event_on_contact")}
labelFor="create-event-on-contact">
<Switch size="sm" labelOnLeading checked={createEventOnLeadCheckForContact} />
<Switch
size="sm"
labelOnLeading
checked={createEventOnLeadCheckForContact}
onCheckedChange={(checked) => {
setAppData("createEventOnLeadCheckForContact", checked);
}}
/>
</Section.SubSectionHeader>
</Section.SubSection>
<CreateContactUnderAccount />
+4 -1
View File
@@ -125,7 +125,10 @@ export async function getLocationGroupedOptions(
: {}),
};
if (apps[groupByCategory]) {
apps[groupByCategory] = [...apps[groupByCategory], option];
const existingOption = apps[groupByCategory].find((o) => o.value === option.value);
if (!existingOption) {
apps[groupByCategory] = [...apps[groupByCategory], option];
}
} else {
apps[groupByCategory] = [option];
}
+2 -2
View File
@@ -26,8 +26,8 @@ vi.mock("@calcom/atoms/hooks/useIsPlatform", () => ({
return false;
},
}));
vi.mock("@calcom/lib", () => ({
classNames: (...args: string[]) => {
vi.mock("@calcom/ui/classNames", () => ({
default: (...args: string[]) => {
return args.filter(Boolean).join(" ");
},
}));
@@ -105,6 +105,9 @@ export const BaseScheduledEmail = (
)}
</>
)}
{props.calEvent.rescheduledBy && (
<Info label={t("rescheduled_by")} description={props.calEvent.rescheduledBy} withSpacer />
)}
<Info label={t("what")} description={props.calEvent.title} withSpacer />
<WhenInfo timeFormat={timeFormat} calEvent={props.calEvent} t={t} timeZone={timeZone} locale={locale} />
<WhoInfo calEvent={props.calEvent} t={t} />
@@ -52,7 +52,7 @@ export const CreditBalanceLimitReachedEmail = (
<div style={{ textAlign: "center", marginTop: "24px" }}>
<CallToAction
label={user.t("buy_credits")}
href={`${WEBAPP_URL}/settings/teams/${user.id}/credits`}
href={`${WEBAPP_URL}/settings/billing`}
endIconName="linkIcon"
/>
</div>
@@ -61,7 +61,7 @@ export const CreditBalanceLowWarningEmail = (
<div style={{ textAlign: "center", marginTop: "24px" }}>
<CallToAction
label={user.t("buy_credits")}
href={`${WEBAPP_URL}/settings/teams/${user.id}/billing`}
href={`${WEBAPP_URL}/settings/billing`}
endIconName="linkIcon"
/>
</div>
@@ -136,7 +136,8 @@ export const AvailableTimeSlots = ({
schedule?.invalidate();
}
setTentativeSelectedTimeslots([]);
setSelectedTimeslot(time);
// note(Lauris): setting setSeatedEventData before setSelectedTimeslot so that in useSlots we have seated event data available
// and only then we invoke handleReserveSlot that is triggered by the changes in setSelectedTimeslot.
if (seatsPerTimeSlot) {
setSeatedEventData({
seatsPerTimeSlot,
@@ -145,6 +146,7 @@ export const AvailableTimeSlots = ({
showAvailableSeatsCount,
});
}
setSelectedTimeslot(time);
const isTimeSlotAvailable = !unavailableTimeSlots.includes(time);
if (skipConfirmStep && isTimeSlotAvailable) {
onSubmit(time);
@@ -97,7 +97,7 @@ export const useScheduleForEvent = ({
const searchParams = useCompatSearchParams();
const rescheduleUid = searchParams?.get("rescheduleUid");
const scheduleUsingApiV2 = useSchedule({
const schedule = useSchedule({
username: usernameFromStore ?? username,
eventSlug: eventSlugFromStore ?? eventSlug,
eventId,
@@ -115,29 +115,6 @@ export const useScheduleForEvent = ({
useApiV2: useApiV2,
});
const scheduleNotUsingApiV2 = useSchedule({
username: usernameFromStore ?? username,
eventSlug: eventSlugFromStore ?? eventSlug,
eventId,
timezone,
selectedDate,
prefetchNextMonth,
monthCount,
dayCount,
rescheduleUid,
month: monthFromStore ?? month,
duration: durationFromStore ?? duration,
isTeamEvent,
orgSlug,
teamMemberEmail,
useApiV2: false,
// only run this query if the one using Api v2 fails
// Network error does not trigger `isError` flag, so we are instead using `failureReason` here
enabled: isTeamEvent && !!scheduleUsingApiV2?.failureReason,
});
const schedule = scheduleUsingApiV2?.isSuccess ? scheduleUsingApiV2 : scheduleNotUsingApiV2;
return {
data: schedule?.data,
isPending: schedule?.isPending,
@@ -15,13 +15,14 @@ import { UserRepository } from "@calcom/lib/server/repository/user";
// }
// })
describe("getAllCredentials", () => {
describe("getAllCredentialsIncludeServiceAccountKey", () => {
test("Get an individual's credentials", async () => {
vi.spyOn(UserRepository, "enrichUserWithItsProfile").mockReturnValue({
profile: null,
});
const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials;
const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials"))
.getAllCredentialsIncludeServiceAccountKey;
const userCredential = {
id: 1,
@@ -38,7 +39,7 @@ describe("getAllCredentials", () => {
{ type: "team-credential", teamId: 1, key: {} },
]);
const credentials = await getAllCredentials(
const credentials = await getAllCredentialsIncludeServiceAccountKey(
{
id: 1,
username: "test",
@@ -65,7 +66,8 @@ describe("getAllCredentials", () => {
profile: null,
});
const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials;
const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials"))
.getAllCredentialsIncludeServiceAccountKey;
const crmCredential = {
id: 1,
@@ -101,7 +103,7 @@ describe("getAllCredentials", () => {
},
]);
const credentials = await getAllCredentials(
const credentials = await getAllCredentialsIncludeServiceAccountKey(
{
id: 1,
username: "test",
@@ -138,7 +140,8 @@ describe("getAllCredentials", () => {
profile: null,
});
const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials;
const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials"))
.getAllCredentialsIncludeServiceAccountKey;
const crmCredential = {
id: 1,
@@ -170,7 +173,7 @@ describe("getAllCredentials", () => {
},
]);
const credentials = await getAllCredentials(
const credentials = await getAllCredentialsIncludeServiceAccountKey(
{
id: 1,
username: "test",
@@ -204,7 +207,8 @@ describe("getAllCredentials", () => {
profile: null,
});
const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials;
const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials"))
.getAllCredentialsIncludeServiceAccountKey;
const teamId = 1;
const crmCredential = {
@@ -261,7 +265,7 @@ describe("getAllCredentials", () => {
console.log(testEventType);
const credentials = await getAllCredentials(
const credentials = await getAllCredentialsIncludeServiceAccountKey(
{
id: 1,
username: "test",
@@ -291,7 +295,8 @@ describe("getAllCredentials", () => {
expect(credentials).toContainEqual(expect.objectContaining({ teamId, type: "salesforce_crm" }));
});
test("For an org user", async () => {
const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials;
const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials"))
.getAllCredentialsIncludeServiceAccountKey;
const orgId = 3;
vi.spyOn(UserRepository, "enrichUserWithItsProfile").mockReturnValue({
profile: { organizationId: orgId },
@@ -357,7 +362,7 @@ describe("getAllCredentials", () => {
},
]);
const credentials = await getAllCredentials(
const credentials = await getAllCredentialsIncludeServiceAccountKey(
{
id: 1,
username: "test",
@@ -394,7 +399,8 @@ describe("getAllCredentials", () => {
});
describe("Default with _other_calendar credentials", () => {
test("For users", async () => {
const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials;
const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials"))
.getAllCredentialsIncludeServiceAccountKey;
const crmCredential = {
id: 1,
@@ -430,7 +436,7 @@ describe("getAllCredentials", () => {
},
]);
const credentials = await getAllCredentials(
const credentials = await getAllCredentialsIncludeServiceAccountKey(
{
id: 1,
username: "test",
@@ -459,7 +465,8 @@ describe("getAllCredentials", () => {
);
});
test("For teams", async () => {
const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials;
const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials"))
.getAllCredentialsIncludeServiceAccountKey;
const crmCredential = {
id: 1,
@@ -491,7 +498,7 @@ describe("getAllCredentials", () => {
},
]);
const credentials = await getAllCredentials(
const credentials = await getAllCredentialsIncludeServiceAccountKey(
{
id: 1,
username: "test",
@@ -517,7 +524,8 @@ describe("getAllCredentials", () => {
);
});
test("For child of managed event type", async () => {
const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials;
const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials"))
.getAllCredentialsIncludeServiceAccountKey;
const teamId = 1;
const crmCredential = {
@@ -574,7 +582,7 @@ describe("getAllCredentials", () => {
console.log(testEventType);
const credentials = await getAllCredentials(
const credentials = await getAllCredentialsIncludeServiceAccountKey(
{
id: 1,
username: "test",
@@ -600,7 +608,8 @@ describe("getAllCredentials", () => {
);
});
test("For an org user", async () => {
const getAllCredentials = (await import("./getAllCredentials")).getAllCredentials;
const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials"))
.getAllCredentialsIncludeServiceAccountKey;
const orgId = 3;
vi.spyOn(UserRepository, "enrichUserWithItsProfile").mockReturnValue({
profile: { organizationId: orgId },
@@ -666,7 +675,7 @@ describe("getAllCredentials", () => {
},
]);
const credentials = await getAllCredentials(
const credentials = await getAllCredentialsIncludeServiceAccountKey(
{
id: 1,
username: "test",
@@ -1,6 +1,6 @@
import type z from "zod";
import { enrichUserWithDelegationCredentialsWithoutOrgId } from "@calcom/lib/delegationCredential/server";
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/lib/delegationCredential/server";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
@@ -19,7 +19,7 @@ export type EventType = {
* Gets credentials from the user, team, and org if applicable
*
*/
export const getAllCredentials = async (
export const getAllCredentialsIncludeServiceAccountKey = async (
user: { id: number; username: string | null; email: string; credentials: CredentialPayload[] },
eventType: EventType
) => {
@@ -120,7 +120,7 @@ export const getAllCredentials = async (
}
});
const userWithDelegationCredentials = await enrichUserWithDelegationCredentialsWithoutOrgId({
const userWithDelegationCredentials = await enrichUserWithDelegationCredentialsIncludeServiceAccountKey({
user: { ...user, credentials: allCredentials },
});
@@ -17,6 +17,7 @@ export const bookingResponsesDbSchema = z.record(dbReadResponseSchema);
const catchAllSchema = bookingResponsesDbSchema;
const ensureValidPhoneNumber = (value: string) => {
if (!value) return "";
// + in URL could be replaced with space, so we need to replace it back
// Replace the space(s) in the beginning with + as it is supposed to be provided in the beginning only
return value.replace(/^ +/, "+");
@@ -12,12 +12,13 @@ import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { deleteWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/scheduleTrigger";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import EventManager from "@calcom/lib/EventManager";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import logger from "@calcom/lib/logger";
import { processPaymentRefund } from "@calcom/lib/payment/processPaymentRefund";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -36,7 +37,7 @@ import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { getAllCredentials } from "./getAllCredentialsForUsersOnEvent/getAllCredentials";
import { getAllCredentialsIncludeServiceAccountKey } from "./getAllCredentialsForUsersOnEvent/getAllCredentials";
import { getBookingToDelete } from "./getBookingToDelete";
import { handleInternalNote } from "./handleInternalNote";
import cancelAttendeeSeat from "./handleSeats/cancel/cancelAttendeeSeat";
@@ -466,7 +467,7 @@ async function handler(input: CancelBookingInput) {
const bookingToDeleteEventTypeMetadata = bookingToDeleteEventTypeMetadataParsed.data;
const credentials = await getAllCredentials(bookingToDelete.user, {
const credentials = await getAllCredentialsIncludeServiceAccountKey(bookingToDelete.user, {
...bookingToDelete.eventType,
metadata: bookingToDeleteEventTypeMetadata,
});
@@ -1,4 +1,4 @@
import type { DestinationCalendar } from "@prisma/client";
import type { DestinationCalendar, User } from "@prisma/client";
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
import short, { uuid } from "short-uuid";
@@ -68,6 +68,7 @@ import { getTranslation } from "@calcom/lib/server/i18n";
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import type { AssignmentReasonEnum } from "@calcom/prisma/enums";
import { BookingStatus, SchedulingType, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { CreationSource } from "@calcom/prisma/enums";
import {
@@ -81,7 +82,7 @@ import type { CredentialForCalendarService } from "@calcom/types/Credential";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import type { EventPayloadType, EventTypeInfo } from "../../webhooks/lib/sendPayload";
import { getAllCredentials } from "./getAllCredentialsForUsersOnEvent/getAllCredentials";
import { getAllCredentialsIncludeServiceAccountKey } from "./getAllCredentialsForUsersOnEvent/getAllCredentials";
import { refreshCredentials } from "./getAllCredentialsForUsersOnEvent/refreshCredentials";
import getBookingDataSchema from "./getBookingDataSchema";
import { addVideoCallDataToEvent } from "./handleNewBooking/addVideoCallDataToEvent";
@@ -250,6 +251,80 @@ const buildDryRunEventManager = () => {
};
};
export const buildEventForTeamEventType = async ({
existingEvent: evt,
users,
organizerUser,
schedulingType,
team,
}: {
existingEvent: Partial<CalendarEvent>;
users: (Pick<User, "id" | "name" | "timeZone" | "locale" | "email"> & {
destinationCalendar: DestinationCalendar | null;
isFixed?: boolean;
})[];
organizerUser: { email: string };
schedulingType: SchedulingType | null;
team?: {
id: number;
name: string;
} | null;
}) => {
// not null assertion.
if (!schedulingType) {
throw new Error("Scheduling type is required for team event type");
}
const teamDestinationCalendars: DestinationCalendar[] = [];
// Organizer or user owner of this event type it's not listed as a team member.
const teamMemberPromises = users
.filter((user) => {
if (user.email === organizerUser.email) return false;
// Skip non-fixed users in ROUND_ROBIN team event
if (schedulingType === SchedulingType.ROUND_ROBIN && !user.isFixed) return false;
return true;
})
.map(async (user) => {
// TODO: Add back once EventManager tests are ready https://github.com/calcom/cal.com/pull/14610#discussion_r1567817120
// push to teamDestinationCalendars if it's a team event but collective only
if (schedulingType === "COLLECTIVE" && user.destinationCalendar) {
teamDestinationCalendars.push({
...user.destinationCalendar,
externalId: processExternalId(user.destinationCalendar),
});
}
return {
id: user.id,
email: user.email ?? "",
name: user.name ?? "",
firstName: "",
lastName: "",
timeZone: user.timeZone,
language: {
translate: await getTranslation(user.locale ?? "en", "common"),
locale: user.locale ?? "en",
},
};
});
const teamMembers = await Promise.all(teamMemberPromises);
evt = CalendarEventBuilder.fromEvent(evt)
.withDestinationCalendar([...(evt.destinationCalendar ?? []), ...teamDestinationCalendars])
.build();
return CalendarEventBuilder.fromEvent(evt)
.withTeam({
members: teamMembers,
name: team?.name || "Nameless",
id: team?.id ?? 0,
})
.build();
};
function buildTroubleshooterData({
eventType,
}: {
@@ -820,7 +895,7 @@ async function handler(
: users[0];
const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common");
const allCredentials = await getAllCredentials(organizerUser, eventType);
const allCredentials = await getAllCredentialsIncludeServiceAccountKey(organizerUser, eventType);
// If the Organizer himself is rescheduling, the booker should be sent the communication in his timezone and locale.
const attendeeInfoOnReschedule =
@@ -925,36 +1000,6 @@ async function handler(
log.info("event type locations", eventType.locations);
const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs);
const teamDestinationCalendars: DestinationCalendar[] = [];
// Organizer or user owner of this event type it's not listed as a team member.
const teamMemberPromises = users
.filter((user) => user.email !== organizerUser.email)
.map(async (user) => {
// TODO: Add back once EventManager tests are ready https://github.com/calcom/cal.com/pull/14610#discussion_r1567817120
// push to teamDestinationCalendars if it's a team event but collective only
if (isTeamEventType && eventType.schedulingType === "COLLECTIVE" && user.destinationCalendar) {
teamDestinationCalendars.push({
...user.destinationCalendar,
externalId: processExternalId(user.destinationCalendar),
});
}
return {
id: user.id,
email: user.email ?? "",
name: user.name ?? "",
firstName: "",
lastName: "",
timeZone: user.timeZone,
language: {
translate: await getTranslation(user.locale ?? "en", "common"),
locale: user.locale ?? "en",
},
};
});
const teamMembers = await Promise.all(teamMemberPromises);
const attendeesList = [...invitee, ...guests];
const responses = reqBody.responses || null;
@@ -1076,10 +1121,14 @@ async function handler(
.build();
}
if (isTeamEventType && eventType.schedulingType === "COLLECTIVE") {
evt = CalendarEventBuilder.fromEvent(evt)
.withDestinationCalendar([...(evt.destinationCalendar ?? []), ...teamDestinationCalendars])
.build();
if (isTeamEventType) {
evt = await buildEventForTeamEventType({
existingEvent: evt,
schedulingType: eventType.schedulingType,
users,
team: eventType.team,
organizerUser,
});
}
// data needed for triggering webhooks
@@ -1140,16 +1189,6 @@ async function handler(
organizerUser.id
);
if (isTeamEventType) {
evt = CalendarEventBuilder.fromEvent(evt)
.withTeam({
members: teamMembers,
name: eventType.team?.name || "Nameless",
id: eventType.team?.id ?? 0,
})
.build();
}
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
if (eventType.seatsPerTimeSlot) {
const newBooking = await handleSeats({
@@ -1263,6 +1302,8 @@ async function handler(
})
);
let assignmentReason: { reasonEnum: AssignmentReasonEnum; reasonString: string } | undefined;
try {
if (!isDryRun) {
booking = await createBooking({
@@ -1303,7 +1344,7 @@ async function handler(
// If it's a round robin event, record the reason for the host assignment
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
if (reqBody.crmOwnerRecordType && reqBody.crmAppSlug && contactOwnerEmail && routingFormResponseId) {
await AssignmentReasonRecorder.CRMOwnership({
assignmentReason = await AssignmentReasonRecorder.CRMOwnership({
bookingId: booking.id,
crmAppSlug: reqBody.crmAppSlug,
teamMemberEmail: contactOwnerEmail,
@@ -1311,7 +1352,7 @@ async function handler(
routingFormResponseId,
});
} else if (routingFormResponseId && teamId) {
await AssignmentReasonRecorder.routingFormRoute({
assignmentReason = await AssignmentReasonRecorder.routingFormRoute({
bookingId: booking.id,
routingFormResponseId,
organizerId: organizerUser.id,
@@ -1408,6 +1449,7 @@ async function handler(
await WorkflowRepository.deleteAllWorkflowReminders(originalRescheduledBooking.workflowReminders);
evt = addVideoCallDataToEvent(originalRescheduledBooking.references, evt);
evt.rescheduledBy = reqBody.rescheduledBy;
// If organizer is changed in RR event then we need to delete the previous host destination calendar events
const previousHostDestinationCalendar = originalRescheduledBooking?.destinationCalendar
@@ -1560,6 +1602,7 @@ async function handler(
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
};
const cancelledRRHostEvt = cloneDeep(copyEventAdditionalInfo);
loggerWithEventDetails.debug("Emails: Sending rescheduled emails for booking confirmation");
/*
@@ -1582,11 +1625,28 @@ async function handler(
}
if (originalRescheduledBooking.user) {
const translate = await getTranslation(originalRescheduledBooking.user.locale ?? "en", "common");
const originalOrganizer = originalRescheduledBooking.user;
originalBookingMemberEmails.push({
...originalRescheduledBooking.user,
name: originalRescheduledBooking.user.name || "",
language: { translate, locale: originalRescheduledBooking.user.locale ?? "en" },
});
if (changedOrganizer) {
cancelledRRHostEvt.title = originalRescheduledBooking.title;
cancelledRRHostEvt.startTime =
dayjs(originalRescheduledBooking?.startTime).utc().format() ||
copyEventAdditionalInfo.startTime;
cancelledRRHostEvt.endTime =
dayjs(originalRescheduledBooking?.endTime).utc().format() || copyEventAdditionalInfo.endTime;
cancelledRRHostEvt.organizer = {
email: originalOrganizer.email,
name: originalOrganizer.name || "",
timeZone: originalOrganizer.timeZone,
language: { translate, locale: originalOrganizer.locale || "en" },
};
}
}
const newBookingMemberEmails: Person[] =
@@ -1629,7 +1689,7 @@ async function handler(
members: newBookedMembers,
eventTypeMetadata: eventType.metadata,
});
sendRoundRobinCancelledEmailsAndSMS(copyEventAdditionalInfo, cancelledMembers, eventType.metadata);
sendRoundRobinCancelledEmailsAndSMS(cancelledRRHostEvt, cancelledMembers, eventType.metadata);
}
} else {
if (!isDryRun) {
@@ -1847,6 +1907,7 @@ async function handler(
status: "ACCEPTED",
smsReminderNumber: booking?.smsReminderNumber || undefined,
rescheduledBy: reqBody.rescheduledBy,
...(assignmentReason ? { assignmentReason: [assignmentReason] } : {}),
};
if (bookingRequiresPayment) {
@@ -4,7 +4,7 @@ import type { z } from "zod";
import type { routingFormResponseInDbSchema } from "@calcom/app-store/routing-forms/zod";
import dayjs from "@calcom/dayjs";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import { withReporting } from "@calcom/lib/sentryWrapper";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -1,8 +1,8 @@
import type { LocationObject } from "@calcom/app-store/locations";
import { workflowSelect } from "@calcom/ee/workflows/lib/getAllWorkflows";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib";
import type { DefaultEvent } from "@calcom/lib/defaultEvents";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { withSelectedCalendars } from "@calcom/lib/server/repository/user";
import prisma, { userSelect } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
@@ -0,0 +1,144 @@
// or wherever it's from
import { vi, describe, it, expect, beforeEach } from "vitest";
import { SchedulingType } from "@calcom/prisma/enums";
import { buildEventForTeamEventType } from "../../handleNewBooking";
vi.mock("@calcom/lib/server/i18n", () => ({
getTranslation: vi.fn().mockResolvedValue("translated"),
}));
const withTeamSpy = vi.fn().mockReturnThis();
const withDestinationCalendarSpy = vi.fn().mockReturnThis();
vi.mock("@calcom/features/CalendarEventBuilder", () => {
return {
CalendarEventBuilder: {
fromEvent: vi.fn().mockImplementation((evt) => ({
withDestinationCalendar: withDestinationCalendarSpy,
withTeam: withTeamSpy,
build: vi.fn().mockImplementation(() => ({
destinationCalendar: [],
team: {}, // <- you won’t use this result anyway
})),
})),
},
};
});
vi.mock("@calcom/app-store/_utils/calendars/processExternalId", () => ({
default: vi.fn((dc) => `external-${dc?.externalId ?? "id"}`),
}));
const baseUser = (overrides: Record<string, unknown> = {}) => ({
id: 1,
name: "Alice",
email: "alice@example.com",
timeZone: "Europe/Paris",
locale: "fr",
destinationCalendar: {
id: 123,
integration: "google",
externalId: "ext-123",
primaryEmail: "alice@example.com",
userId: 1,
eventTypeId: null,
credentialId: null,
delegationCredentialId: null,
domainWideDelegationCredentialId: null,
},
isFixed: true,
...overrides,
});
describe("buildEventForTeamEventType", () => {
it("throws if schedulingType is null", async () => {
await expect(
buildEventForTeamEventType({
existingEvent: {},
users: [],
organizerUser: { email: "organizer@example.com" },
schedulingType: null,
})
).rejects.toThrow("Scheduling type is required for team event type");
});
it("filters out the organizer", async () => {
const result = await buildEventForTeamEventType({
existingEvent: {},
users: [baseUser({ email: "organizer@example.com" })],
organizerUser: { email: "organizer@example.com" },
schedulingType: SchedulingType.COLLECTIVE,
});
const teamArgs = withTeamSpy.mock.calls[0][0];
const memberEmails = teamArgs.members.map((m: any) => m.email);
expect(memberEmails).not.toContain("organizer@example.com");
});
it("includes destinationCalendars for COLLECTIVE", async () => {
await buildEventForTeamEventType({
existingEvent: { destinationCalendar: [] },
users: [baseUser({ id: 2 })],
organizerUser: { email: "organizer@example.com" },
schedulingType: SchedulingType.COLLECTIVE,
});
const withDestinationCalendarArgs = withDestinationCalendarSpy.mock.calls[0][0];
expect(withDestinationCalendarArgs).not.toHaveLength(0);
});
it("does not include destinationCalendars for ROUND_ROBIN", async () => {
await buildEventForTeamEventType({
existingEvent: { destinationCalendar: [] },
users: [baseUser({ id: 2 })],
organizerUser: { email: "organizer@example.com" },
schedulingType: SchedulingType.ROUND_ROBIN,
});
const withDestinationCalendarArgs = withDestinationCalendarSpy.mock.calls[0][0];
expect(withDestinationCalendarArgs).toHaveLength(0);
});
it("excludes non-fixed users for ROUND_ROBIN", async () => {
await buildEventForTeamEventType({
existingEvent: {},
users: [
baseUser({ id: 2, isFixed: false, email: "notfixed@example.com" }),
baseUser({ id: 3, isFixed: true, email: "fixed@example.com" }),
],
organizerUser: { email: "organizer@example.com" },
schedulingType: SchedulingType.ROUND_ROBIN,
});
const teamArgs = withTeamSpy.mock.calls[0][0];
const memberEmails = teamArgs.members.map((m: any) => m.email);
expect(memberEmails).toContain("fixed@example.com");
expect(memberEmails).not.toContain("notfixed@example.com");
});
it("builds a team with fallback name and id", async () => {
await buildEventForTeamEventType({
existingEvent: {},
users: [baseUser()],
organizerUser: { email: "organizer@example.com" },
schedulingType: SchedulingType.COLLECTIVE,
team: null,
});
// now inspect what was passed into withTeam()
const teamArgs = withTeamSpy.mock.calls[0][0];
expect(teamArgs.name).toBe("Nameless");
expect(teamArgs.id).toBe(0);
});
beforeEach(() => {
vi.clearAllMocks();
});
});
@@ -0,0 +1,172 @@
import {
getBooker,
TestData,
getOrganizer,
createBookingScenario,
Timezones,
getScenarioData,
mockSuccessfulVideoMeetingCreation,
BookingLocations,
getDate,
getMockBookingAttendee,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe, test, vi, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
describe("Seated Round Robin Events", () => {
setupAndTeardown();
test("For second seat booking, organizer remains the same with no team members included", async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const EventManager = (await import("@calcom/lib/EventManager")).default;
const eventManagerSpy = vi.spyOn(EventManager.prototype, "updateCalendarAttendees");
const booker = getBooker({
email: "seat2@example.com",
name: "Seat 2",
});
const assignedHost = getOrganizer({
name: "Assigned Host",
email: "assigned-host@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
});
const teamMembers = [
{
name: "Team Member 1",
username: "team-member-1",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-1@example.com",
id: 102,
schedules: [TestData.schedules.IstEveningShift],
},
{
name: "Team Member 2",
username: "team-member-2",
timeZone: Timezones["+5:30"],
defaultScheduleId: null,
email: "team-member-2@example.com",
id: 103,
schedules: [TestData.schedules.IstEveningShift],
},
];
const bookingId = 1;
const bookingUid = "abc123";
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const bookingStartTime = `${plus1DateString}T04:00:00Z`;
const bookingEndTime = `${plus1DateString}T04:30:00Z`;
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slug: "seated-round-robin-event",
slotInterval: 30,
length: 30,
schedulingType: SchedulingType.ROUND_ROBIN,
users: [{ id: assignedHost.id }, { id: teamMembers[0].id }, { id: teamMembers[1].id }],
hosts: [
{ userId: assignedHost.id, isFixed: false },
{ userId: teamMembers[0].id, isFixed: false },
{ userId: teamMembers[1].id, isFixed: false },
],
seatsPerTimeSlot: 3,
seatsShowAttendees: false,
},
],
bookings: [
{
id: bookingId,
uid: bookingUid,
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
startTime: bookingStartTime,
endTime: bookingEndTime,
userId: assignedHost.id, // This is the assigned host for the booking
metadata: {
videoCallUrl: "https://existing-daily-video-call-url.example.com",
},
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
credentialId: null,
},
],
attendees: [
getMockBookingAttendee({
id: 1,
name: "Seat 1",
email: "seat1@test.com",
locale: "en",
timeZone: "America/Toronto",
bookingSeat: {
referenceUid: "booking-seat-1",
data: {},
},
}),
],
},
],
organizer: assignedHost,
usersApartFromOrganizer: [...teamMembers],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-1`,
},
});
const reqBookingUser = "seatedAttendee";
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
bookingUid: bookingUid,
user: reqBookingUser,
},
});
await handleNewBooking({
bookingData: mockBookingData,
});
expect(eventManagerSpy).toHaveBeenCalled();
const calendarEvent = eventManagerSpy.mock.calls[0][0];
expect(calendarEvent.organizer.email).toBe(assignedHost.email);
expect(calendarEvent.team?.members).toBeDefined();
expect(calendarEvent.team?.members.length).toBe(0);
const teamMemberEmails = calendarEvent.team?.members.map((member) => member.email);
expect(teamMemberEmails).not.toContain(teamMembers[0].email);
expect(teamMemberEmails).not.toContain(teamMembers[1].email);
});
});
@@ -2,7 +2,7 @@ import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { sendCancelledSeatEmailsAndSMS } from "@calcom/emails";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { getAllDelegationCredentialsForUser } from "@calcom/lib/delegationCredential/server";
import { getAllDelegationCredentialsForUserIncludeServiceAccountKey } from "@calcom/lib/delegationCredential/server";
import { getDelegationCredentialOrFindRegularCredential } from "@calcom/lib/delegationCredential/server";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
@@ -71,7 +71,8 @@ async function cancelAttendeeSeat(
const attendee = bookingToDelete?.attendees.find((attendee) => attendee.id === seatReference.attendeeId);
const bookingToDeleteUser = bookingToDelete.user ?? null;
const delegationCredentials = bookingToDeleteUser
? await getAllDelegationCredentialsForUser({
? // We fetch delegation credentials with ServiceAccount key as CalendarService instance created later in the flow needs it
await getAllDelegationCredentialsForUserIncludeServiceAccountKey({
user: { email: bookingToDeleteUser.email, id: bookingToDeleteUser.id },
})
: [];
@@ -2,7 +2,7 @@ import type { Attendee } from "@prisma/client";
// eslint-disable-next-line no-restricted-imports
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { getAllDelegationCredentialsForUser } from "@calcom/lib/delegationCredential/server";
import { getAllDelegationCredentialsForUserIncludeServiceAccountKey } from "@calcom/lib/delegationCredential/server";
import { getDelegationCredentialOrFindRegularCredential } from "@calcom/lib/delegationCredential/server";
import { deleteMeeting } from "@calcom/lib/videoClient";
import prisma from "@calcom/prisma";
@@ -21,7 +21,8 @@ const lastAttendeeDeleteBooking = async (
let deletedReferences = false;
const bookingUser = originalRescheduledBooking?.user;
const delegationCredentials = bookingUser
? await getAllDelegationCredentialsForUser({
? // We fetch delegation credentials with ServiceAccount key as CalendarService instance created later in the flow needs it
await getAllDelegationCredentialsForUserIncludeServiceAccountKey({
user: { email: bookingUser.email, id: bookingUser.id },
})
: [];
+1 -1
View File
@@ -18,7 +18,7 @@ export type NewSeatedBookingObject = {
bookerUrl: string;
};
invitee: Invitee;
allCredentials: Awaited<ReturnType<typeof getAllCredentials>>;
allCredentials: Awaited<ReturnType<typeof getAllCredentialsIncludeServiceAccountKey>>;
organizerUser: OrganizerUser;
originalRescheduledBooking: OriginalRescheduledBooking;
bookerEmail: string;
+1
View File
@@ -61,6 +61,7 @@ export type BookerEvent = Pick<
| "autoTranslateDescriptionEnabled"
| "disableCancelling"
| "disableRescheduling"
| "interfaceLanguage"
> & {
subsetOfUsers: BookerEventUser[];
showInstantEventConnectNowModal: boolean;
@@ -6,8 +6,9 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { sendCancelledEmailsAndSMS } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { deleteWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/scheduleTrigger";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { buildNonDelegationCredential } from "@calcom/lib/delegationCredential/server";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { DailyLocationType } from "@calcom/lib/location";
import { deletePayment } from "@calcom/lib/payment/deletePayment";
import { getTranslation } from "@calcom/lib/server/i18n";
@@ -163,6 +163,7 @@ export function DataTable<TData>({
}}
className={classNames(
"relative flex shrink-0 items-center",
"bg-muted",
column.getIsPinned() && "top-0 z-20 sm:sticky"
)}>
<TableHeadLabel header={header} />
@@ -287,7 +288,7 @@ function DataTableBody<TData>({
return (
<TableBody
className="border-subtle relative grid overflow-hidden rounded-xl border"
className="border-subtle relative grid border-t"
data-testid={testId}
style={{ height: tableHeight }}>
{rowsToRender.map(({ row, virtualItem }) => (
@@ -95,8 +95,7 @@ Step 6: Enable Delegation Credential(To Be taken By Cal.com organization Owner/A
### Terminology
- Delegation Credential: A Delegation Credential service account key along with user's email becomes the Delegation Credential which is an alternative to regular Credential in DB.
- DWD: Domain Wide Delegation
- non-dwd credential: Regular credentials that are stored in Credentials table
- Delegation User Credential: A Delegation User Credential is a Credential record in DB that uses DelegationCredential record to actually access the user's calendar. A Credential record with delegationCredentialId set is a Delegation User Credential.
### How Delegation Credential works
@@ -108,6 +107,13 @@ Step 6: Enable Delegation Credential(To Be taken By Cal.com organization Owner/A
- A Delegation Credential service account key along with user's email becomes the Delegation Credential which is an alternative to regular Credential in DB.
- Delegation Credential doesn't completely replace the regular credentials. Delegation Credential gives access to the cal.com user's email in Google Calendar. So, if the user needs to connect to some other email's calendar, we need to use the regular credentials.
### Cron Jobs
Cron jobs ensure that for each and every member of the organization that has Delegation Credential enabled, corresponding SelectedCalendar records are there. These crons currently run every 5 minutes, look at vercel.json for the up-to-date schedule.
- `credentials` cron job creates Delegation User Credential records for all the members of the organization who don't have Delegation User Credentials yet. It also ensures that on disabling Delegation Credential, the Delegation User Credentials are deleted which automatically deletes the SelectedCalendars through DB cascade.
- `selected-calendars` cron job creates SelectedCalendar records for all the Delegation User Credentials of the organization who don't have Selected Calendars yet.
### Important Points
- No Credential table entry is created when enabling Delegation Credential. The workspace platform's related apps will be considered as "installed" for the users with email matching dwd domain. An in-memory credential like object is created for this purpose. It allows avoiding creation of thousands of records for all the members of the organization when Delegation Credential is enabled.
@@ -122,23 +128,16 @@ Step 6: Enable Delegation Credential(To Be taken By Cal.com organization Owner/A
1. Identify the logged in user's email
2. Identify the domainWideDelegations for that email's domain
3. Build in-memory credentials for the domainWideDelegations and use them along with the actual credentials(that user might have connected) of the user
4. We don't show the non-dwd connected calendar(if there is a corresponding dwd connected calendar). Though we use the non-dwd credentials to identify the selected calendars, for the dwd connected calendar.
4. We don't show the non DelegationCredential connected calendar(if there is a corresponding DelegationCredential connected calendar). Though we use the non DelegationCredential credentials to identify the selected calendars, for the DelegationCredential connected calendar.
### Impact of disabling Delegation Credential
Disabling effectively stops generating in-memory delegation user credentials. So, any members who haven't manually connected their Calendar and thus their calendar connections were working only because of Delegation Credential, would have their connections broken.
Disabling effectively stops generating in-memory delegation user credentials. So, any members who haven't manually connected their Calendar and thus their calendar connections were working only because of Delegation Credential, would have their calendar connections broken.
#### What would not work correctly ?
- Calendar won't be checked for conflicts. So, they could get booked at a time when they are marked busy in their calendar.
- Cal.com bookings would still be checked for conflicts.
- Bookings might not appear in the attendee and host's Google Calendar. Because we would be unable to use the API to create the events in calendar and Google doesn't always add events to calendar automatically based on .ics file alone.
- Cal Video would be used as the booking location instead of Google Meet.
#### What would work correctly ?
- Bookings would still go through. People relying on Salesforce for booking details, would face no issues.
- Cal.com bookings would still be checked for conflicts.
### Impact of enabling Delegation Credential
- Existing calendar-cache records are re-used as we identify the relevant record by userId and key of CalendarCache record.
- Any updates to those calendar-cache records keep on working by using the non-delegation credential attached with the SelectedCalendar record.
- For any new members, we create Credential records and SelectedCalendar records through cron jobs and thus their calendar-cache records will also be created.
### Notes when testing locally
@@ -146,3 +145,9 @@ Disabling effectively stops generating in-memory delegation user credentials. So
- You could use Acme org and login as <owner1-acme@example.com>
- Make sure to change the email of the user above to your workspace owner's email(other member's email might also work). This is necessary otherwise you won't be able to enable Delegation Credential for the organization.
- Note: After changing the email, you would have to logout and login again
## TODO
- Test what happens when credential expires that was used in CalendarCache/SelectedCalendar
- It seems that if refresh token is valid then it would still be refreshed but if it becomes invalid then it ends up causing the calendar-cache updates to break because it isn't able to renew the access token. How do you fix it?
@@ -14,7 +14,7 @@ const handler = async (data: SWHMap["checkout.session.completed"]["data"]) => {
const userId = session.metadata?.userId ? Number(session.metadata.userId) : undefined;
if (!teamId && !userId) {
throw new HttpCode(400, "Team id and user id, but at least one is required");
throw new HttpCode(400, "Team id and user id are missing, but at least one is required");
}
const lineItems = await stripe.checkout.sessions.listLineItems(session.id);
@@ -22,6 +22,17 @@ vi.mock("@calcom/lib/constants", async () => {
};
});
vi.mock("@calcom/prisma/enums", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
CreditType: {
MONTHLY: "MONTHLY",
ADDITIONAL: "ADDITIONAL",
},
};
});
vi.mock("@calcom/lib/server/repository/credits");
vi.mock("@calcom/lib/server/repository/membership");
vi.mock("@calcom/lib/server/repository/team");
@@ -384,7 +395,6 @@ describe("CreditService", () => {
vi.spyOn(CreditService.prototype, "getMonthlyCredits").mockResolvedValue(500);
const result = await creditService.getAllCreditsForTeam(1);
console.log(`tesult ${JSON.stringify(result)}`);
expect(result).toEqual({
totalMonthlyCredits: 500,
totalRemainingMonthlyCredits: 420, // 500 - (50 + 30)
+2 -2
View File
@@ -5,7 +5,7 @@ import type Stripe from "stripe";
import { sendAttendeeRequestEmailAndSMS, sendOrganizerRequestEmail } from "@calcom/emails";
import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation";
import { getAllCredentials } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation";
import stripe from "@calcom/features/ee/payments/server/stripe";
import EventManager from "@calcom/lib/EventManager";
@@ -75,7 +75,7 @@ const handleSetupSuccess = async (event: Stripe.Event) => {
});
const metadata = eventTypeMetaDataSchemaWithTypedApps.parse(eventType?.metadata);
const allCredentials = await getAllCredentials(user, {
const allCredentials = await getAllCredentialsIncludeServiceAccountKey(user, {
...booking.eventType,
metadata,
});
@@ -113,13 +113,21 @@ export default class AssignmentReasonRecorder {
}
}
const reasonEnum = AssignmentReasonEnum.ROUTING_FORM_ROUTING;
const reasonString = attributeValues.join(", ");
await prisma.assignmentReason.create({
data: {
bookingId: bookingId,
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
reasonString: attributeValues.join(", "),
reasonEnum,
reasonString,
},
});
return {
reasonEnum,
reasonString,
};
}
// Separate method to handle rerouting
@@ -148,13 +156,20 @@ export default class AssignmentReasonRecorder {
if (!crmRoutingReason || !crmRoutingReason.assignmentReason) return;
const { reasonEnum, assignmentReason } = crmRoutingReason;
await prisma.assignmentReason.create({
data: {
bookingId,
reasonEnum: crmRoutingReason.reasonEnum,
reasonString: crmRoutingReason.assignmentReason,
reasonEnum,
reasonString: assignmentReason,
},
});
return {
reasonEnum,
reasonString: assignmentReason,
};
}
static roundRobinReassignment = withReporting(
AssignmentReasonRecorder._roundRobinReassignment,
@@ -180,6 +195,11 @@ export default class AssignmentReasonRecorder {
},
});
const reasonEnum =
reassignmentType === RRReassignmentType.MANUAL
? AssignmentReasonEnum.REASSIGNED
: AssignmentReasonEnum.RR_REASSIGNED;
const reasonString = `Reassigned by: ${reassignedBy?.username || "team member"}. ${
reassignReason ? `Reason: ${reassignReason}` : ""
}`;
@@ -187,12 +207,14 @@ export default class AssignmentReasonRecorder {
await prisma.assignmentReason.create({
data: {
bookingId: bookingId,
reasonEnum:
reassignmentType === RRReassignmentType.MANUAL
? AssignmentReasonEnum.REASSIGNED
: AssignmentReasonEnum.RR_REASSIGNED,
reasonEnum,
reasonString,
},
});
return {
reasonEnum,
reasonString,
};
}
}
@@ -3,7 +3,7 @@ import type { Prisma } from "@prisma/client";
import { metadata as GoogleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata";
import { MeetLocationType } from "@calcom/app-store/locations";
import { getAllCredentials } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
import type { EventType } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials";
import { getVideoCallDetails } from "@calcom/features/bookings/lib/handleNewBooking/getVideoCallDetails";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
@@ -53,7 +53,10 @@ export const handleRescheduleEventManager = async ({
prefix: ["handleRescheduleEventManager", `${bookingId}`],
});
const allCredentials = await getAllCredentials(initParams.user, initParams?.eventType);
const allCredentials = await getAllCredentialsIncludeServiceAccountKey(
initParams.user,
initParams?.eventType
);
const eventManager = new EventManager(
{ ...initParams.user, credentials: allCredentials },
@@ -19,12 +19,12 @@ import {
deleteScheduledEmailReminder,
} from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import { SENDER_NAME } from "@calcom/lib/constants";
import { enrichUserWithDelegationCredentialsWithoutOrgId } from "@calcom/lib/delegationCredential/server";
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/lib/delegationCredential/server";
import { getEventName } from "@calcom/lib/event";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
@@ -282,7 +282,7 @@ export const roundRobinManualReassignment = async ({
include: { user: { select: { email: true } } },
});
const newUserWithCredentials = await enrichUserWithDelegationCredentialsWithoutOrgId({
const newUserWithCredentials = await enrichUserWithDelegationCredentialsIncludeServiceAccountKey({
user: { ...newUser, credentials },
});
@@ -21,15 +21,15 @@ import {
deleteScheduledEmailReminder,
} from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
import { SENDER_NAME } from "@calcom/lib/constants";
import {
enrichHostsWithDelegationCredentials,
enrichUserWithDelegationCredentialsWithoutOrgId,
enrichUserWithDelegationCredentialsIncludeServiceAccountKey,
} from "@calcom/lib/delegationCredential/server";
import { getEventName } from "@calcom/lib/event";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import logger from "@calcom/lib/logger";
import { getLuckyUser } from "@calcom/lib/server/getLuckyUser";
import { getTranslation } from "@calcom/lib/server/i18n";
@@ -244,6 +244,7 @@ export const roundRobinReassignment = async ({
},
data: {
userId: reassignedRRHost.id,
userPrimaryEmail: reassignedRRHost.email,
title: newBookingTitle,
},
select: bookingSelect,
@@ -337,7 +338,7 @@ export const roundRobinReassignment = async ({
},
});
const organizerWithCredentials = await enrichUserWithDelegationCredentialsWithoutOrgId({
const organizerWithCredentials = await enrichUserWithDelegationCredentialsIncludeServiceAccountKey({
user: { ...organizer, credentials },
});
@@ -168,11 +168,6 @@ export const AddActionDialog = (props: IAddActionDialog) => {
options={actionOptions.map((option) => ({
...option,
}))}
isOptionDisabled={(option: {
label: string;
value: WorkflowActions;
needsCredits: boolean;
}) => option.needsCredits}
/>
);
}}
@@ -4,7 +4,7 @@ import { RRule } from "rrule";
import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import { parseRecurringEvent } from "@calcom/lib";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import type { Prisma, User } from "@calcom/prisma/client";
type Booking = Prisma.BookingGetPayload<{
@@ -4,9 +4,9 @@ import type { z } from "zod";
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
import { PriceIcon } from "@calcom/features/bookings/components/event-meta/PriceIcon";
import { parseRecurringEvent } from "@calcom/lib";
import { getPaymentAppData } from "@calcom/lib/getPaymentAppData";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { baseEventTypeSelect } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
@@ -18,7 +18,6 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import { slugify } from "@calcom/lib/slugify";
import turndown from "@calcom/lib/turndownService";
import { Skeleton } from "@calcom/ui/components/skeleton";
import classNames from "@calcom/ui/classNames";
import { Editor } from "@calcom/ui/components/editor";
import { TextAreaField } from "@calcom/ui/components/form";
@@ -26,6 +25,7 @@ import { Label } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form";
import { Select } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import { Skeleton } from "@calcom/ui/components/skeleton";
export type EventSetupTabCustomClassNames = {
wrapper?: string;
@@ -58,12 +58,23 @@ export type EventSetupTabProps = Pick<
customClassNames?: EventSetupTabCustomClassNames;
};
export const EventSetupTab = (
props: EventSetupTabProps & { urlPrefix: string; hasOrgBranding: boolean; orgId?: number }
props: EventSetupTabProps & {
urlPrefix: string;
hasOrgBranding: boolean;
orgId?: number;
localeOptions?: { value: string; label: string }[];
}
) => {
const { t } = useLocale();
const isPlatform = useIsPlatform();
const formMethods = useFormContext<FormValues>();
const { eventType, team, urlPrefix, hasOrgBranding, customClassNames, orgId } = props;
const interfaceLanguageOptions =
props.localeOptions && props.localeOptions.length > 0
? [{ label: t("visitors_browser_language"), value: "" }, ...props.localeOptions]
: [];
const [multipleDuration, setMultipleDuration] = useState(
formMethods.getValues("metadata")?.multipleDuration
);
@@ -161,6 +172,34 @@ export const EventSetupTab = (
/>
</div>
)}
{!isPlatform && interfaceLanguageOptions.length > 0 && (
<div>
<Skeleton
as={Label}
loadingClassName="w-16"
htmlFor="interfaceLanguage"
className={customClassNames?.locationSection?.label}>
{t("interface_language")}
{shouldLockIndicator("interfaceLanguage")}
</Skeleton>
<Controller
name="interfaceLanguage"
control={formMethods.control}
defaultValue={eventType.interfaceLanguage ?? ""}
render={({ field: { value, onChange } }) => (
<Select<{ label: string; value: string }>
data-testid="event-interface-language"
className="capitalize"
options={interfaceLanguageOptions}
onChange={(option) => {
onChange(option?.value);
}}
value={interfaceLanguageOptions.find((option) => option.value === value)}
/>
)}
/>
</div>
)}
<TextField
required
label={isPlatform ? "Slug" : t("URL")}
@@ -7,12 +7,12 @@ import { getAppFromSlug } from "@calcom/app-store/utils";
import dayjs from "@calcom/dayjs";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { isRecurringEvent, parseRecurringEvent } from "@calcom/lib";
import { getOrgOrTeamAvatar } from "@calcom/lib/defaultAvatarImage";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import { isRecurringEvent, parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { UserRepository } from "@calcom/lib/server/repository/user";
import type { PrismaClient } from "@calcom/prisma";
@@ -55,6 +55,7 @@ const getPublicEventSelect = (fetchAllUsers: boolean) => {
id: true,
title: true,
description: true,
interfaceLanguage: true,
eventName: true,
slug: true,
isInstantEvent: true,
@@ -527,6 +528,7 @@ export const getPublicEvent = async (
assignAllTeamMembers: event.assignAllTeamMembers,
disableCancelling: event.disableCancelling,
disableRescheduling: event.disableRescheduling,
interfaceLanguage: event.interfaceLanguage,
};
};
@@ -72,6 +72,7 @@ export type FormValues = {
eventTitle: string;
eventName: string;
slug: string;
interfaceLanguage: string | null;
isInstantEvent: boolean;
instantMeetingParameters: string[];
instantMeetingExpiryTimeOffsetInSeconds: number;
@@ -0,0 +1,730 @@
import { Prisma } from "@prisma/client";
import { describe, expect, beforeEach, afterEach, it } from "vitest";
import type { Field as FormField } from "@calcom/app-store/routing-forms/types/types";
import prisma from "@calcom/prisma";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
describe("RoutingFormResponseDenormalized", () => {
let userId: number;
let formId: string;
let eventTypeId: number;
let bookingId: number;
let responseId: number;
const randomId = Math.floor(Math.random() * 1000000);
const email = `booking-denorm-${randomId}@example.com`;
beforeEach(async () => {
// Create test user
const user = await prisma.user.create({
data: {
email,
username: `booking-denorm-testuser-${randomId}`,
name: "Test User",
},
});
userId = user.id;
// Create test event type
const eventType = await prisma.eventType.create({
data: {
title: "Test Event",
slug: "test-event",
length: 60,
userId: user.id,
},
});
eventTypeId = eventType.id;
// Create test form
const form = await prisma.app_RoutingForms_Form.create({
data: {
name: "Test Form",
userId: user.id,
fields: [
{
id: "57734f65-8bbb-4065-9e71-fb7f0b7485f8",
type: "text",
label: "Manager",
required: true,
},
{
id: "674c169a-e40a-492c-b4bb-6f5213873bd6",
type: "select",
label: "Location",
required: true,
options: [
{
id: "a33f39e2-0846-45a1-bb77-3cba2e18817c",
label: "New York",
},
],
},
{
id: "83316968-45bf-4c9d-b5d4-5368a8d2d2a8",
type: "multiselect",
label: "skills",
required: false,
options: [
{
id: "ee494e36-1f15-4a04-b88e-fc4795e57eb1",
label: "JavaScript",
},
{
id: "34c50dca-9eed-4ade-89d4-9b2ccda52acc",
label: "TypeScript",
},
],
},
{
id: "dd28ffcf-7029-401e-bddb-ce2e7496a1c1",
type: "email",
label: "Email",
required: true,
},
{
id: "f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729",
type: "number",
label: "Rating",
required: false,
},
],
},
});
formId = form.id;
// Create test booking
const booking = await prisma.booking.create({
data: {
uid: `booking-denorm-${randomId}`,
title: "Test Booking",
startTime: new Date(),
endTime: new Date(Date.now() + 60 * 60 * 1000),
userId: user.id,
eventTypeId: eventType.id,
status: BookingStatus.PENDING,
},
});
bookingId = booking.id;
// Create test form response
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler",
formId: form.id,
response: {
"57734f65-8bbb-4065-9e71-fb7f0b7485f8": {
label: "Manager",
value: "Miss Dora Farrell",
},
"674c169a-e40a-492c-b4bb-6f5213873bd6": {
label: "Location",
value: "a33f39e2-0846-45a1-bb77-3cba2e18817c",
},
"83316968-45bf-4c9d-b5d4-5368a8d2d2a8": {
label: "skills",
value: ["ee494e36-1f15-4a04-b88e-fc4795e57eb1", "34c50dca-9eed-4ade-89d4-9b2ccda52acc"],
},
"dd28ffcf-7029-401e-bddb-ce2e7496a1c1": {
label: "Email",
value: "Felicity.Schuppe9@gmail.com",
},
"f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729": {
label: "Rating",
value: 1,
},
},
routedToBookingUid: booking.uid,
},
});
responseId = formResponse.id;
});
afterEach(async () => {
// Clean up test data in reverse order of creation
if (bookingId) {
await prisma.booking.deleteMany({
where: { id: bookingId },
});
}
if (responseId) {
await prisma.app_RoutingForms_FormResponse.deleteMany({
where: { id: responseId },
});
}
if (formId) {
await prisma.app_RoutingForms_Form.deleteMany({
where: { id: formId },
});
}
if (eventTypeId) {
await prisma.eventType.deleteMany({
where: { id: eventTypeId },
});
}
if (userId) {
await prisma.user.deleteMany({
where: { id: userId },
});
}
});
describe("Creation and Updates", () => {
it("should create denormalized entry when form response is created", async () => {
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
});
// Verify form fields are properly stored
const formWithFields = await prisma.app_RoutingForms_Form.findUniqueOrThrow({
where: { id: formId },
});
const fields = formWithFields.fields as FormField[];
if (!fields) {
throw new Error("Form fields are required for this test");
}
expect(fields).toHaveLength(5);
expect(fields[0]).toEqual({
id: "57734f65-8bbb-4065-9e71-fb7f0b7485f8",
type: "text",
label: "Manager",
required: true,
});
expect(fields[2].options).toHaveLength(2); // Verify multiselect options
// Verify the rest of the denormalized data
expect(denormalizedResponse.formId).toBe(formId);
expect(denormalizedResponse.formName).toBe("Test Form");
expect(denormalizedResponse.formUserId).toBe(userId);
expect(denormalizedResponse.bookingId).toBe(bookingId);
expect(denormalizedResponse.bookingUserId).toBe(userId);
expect(denormalizedResponse.bookingUserName).toBe("Test User");
expect(denormalizedResponse.bookingUserEmail).toBe(email);
expect(denormalizedResponse.eventTypeId).toBe(eventTypeId);
});
it("should update denormalized entry when form response is updated", async () => {
await prisma.app_RoutingForms_FormResponse.update({
where: { id: responseId },
data: {
response: {
"57734f65-8bbb-4065-9e71-fb7f0b7485f8": {
label: "Manager",
value: "Updated Manager Name",
},
"674c169a-e40a-492c-b4bb-6f5213873bd6": {
label: "Location",
value: "a33f39e2-0846-45a1-bb77-3cba2e18817c",
},
"83316968-45bf-4c9d-b5d4-5368a8d2d2a8": {
label: "skills",
value: ["ee494e36-1f15-4a04-b88e-fc4795e57eb1"],
},
"dd28ffcf-7029-401e-bddb-ce2e7496a1c1": {
label: "Email",
value: "updated.email@example.com",
},
"f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729": {
label: "Rating",
value: 2,
},
},
},
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
include: {
response: true,
},
});
// Verify the response was updated
const formResponse = await prisma.app_RoutingForms_FormResponse.findUniqueOrThrow({
where: { id: responseId },
});
expect(formResponse.response).toEqual(denormalizedResponse.response.response);
});
it("should update denormalized entry when user is updated", async () => {
await prisma.user.update({
where: { id: userId },
data: {
name: "Updated User",
email: "updated@example.com",
},
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
});
expect(denormalizedResponse.bookingUserName).toBe("Updated User");
expect(denormalizedResponse.bookingUserEmail).toBe("updated@example.com");
});
it("should update denormalized entry when booking is updated", async () => {
await prisma.booking.update({
where: { id: bookingId },
data: {
status: BookingStatus.CANCELLED,
},
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
});
expect(denormalizedResponse.bookingStatus).toBe(BookingStatus.CANCELLED);
});
it("should update denormalized entry when event type is updated", async () => {
// Create a parent event type
const parentEventType = await prisma.eventType.create({
data: {
title: "Parent Event",
slug: "parent-event",
length: 60,
userId: userId,
},
});
await prisma.eventType.update({
where: { id: eventTypeId },
data: {
schedulingType: SchedulingType.ROUND_ROBIN,
parentId: parentEventType.id,
},
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
});
expect(denormalizedResponse.eventTypeId).toBe(eventTypeId);
expect(denormalizedResponse.eventTypeParentId).toBe(parentEventType.id);
expect(denormalizedResponse.eventTypeSchedulingType).toBe("roundRobin");
// Clean up the parent event type
await prisma.eventType.delete({
where: { id: parentEventType.id },
});
});
});
describe("Deletion Scenarios", () => {
it("should delete denormalized entry when form response is deleted", async () => {
await prisma.app_RoutingForms_FormResponse.delete({
where: { id: responseId },
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
where: { id: responseId },
});
expect(denormalizedResponse).toBeNull();
});
it("should not delete denormalized entry but nullify booking data when booking is deleted", async () => {
await prisma.booking.delete({
where: { id: bookingId },
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
});
expect(denormalizedResponse).not.toBeNull();
expect(denormalizedResponse.bookingId).toBeNull();
expect(denormalizedResponse.bookingStatus).toBeNull();
expect(denormalizedResponse.bookingStartTime).toBeNull();
expect(denormalizedResponse.bookingEndTime).toBeNull();
});
it("should cascade delete denormalized entries when form is deleted", async () => {
await prisma.app_RoutingForms_Form.delete({
where: { id: formId },
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
where: { id: responseId },
});
expect(denormalizedResponse).toBeNull();
});
it("should cascade delete denormalized entries when user is deleted", async () => {
await prisma.user.delete({
where: { id: userId },
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
where: { id: responseId },
});
expect(denormalizedResponse).toBeNull();
});
it("should not delete but nullify event type data when event type is deleted", async () => {
await prisma.eventType.delete({
where: { id: eventTypeId },
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
});
expect(denormalizedResponse).not.toBeNull();
expect(denormalizedResponse.eventTypeId).toBeNull();
expect(denormalizedResponse.eventTypeParentId).toBeNull();
});
});
describe("Multiple Records Scenarios", () => {
it("should handle multiple form responses for the same form", async () => {
const response2 = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-2",
formId: formId,
response: {},
},
});
const denormalizedResponse1 = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
});
const denormalizedResponse2 = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: response2.id },
});
// clean up
await prisma.app_RoutingForms_FormResponse.delete({
where: { id: response2.id },
});
expect(denormalizedResponse1.formId).toBe(formId);
expect(denormalizedResponse2.formId).toBe(formId);
expect(denormalizedResponse1.bookingId).toBe(bookingId);
expect(denormalizedResponse2.bookingId).toBeNull();
expect(denormalizedResponse1.id).not.toBe(denormalizedResponse2.id);
});
});
describe("Edge Cases", () => {
it("should handle null values in optional fields", async () => {
const responseWithNulls = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-nulls",
formId: formId,
response: {
"57734f65-8bbb-4065-9e71-fb7f0b7485f8": {
label: "Manager",
value: null,
},
"674c169a-e40a-492c-b4bb-6f5213873bd6": {
label: "Location",
value: null,
},
},
},
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseWithNulls.id },
});
expect(denormalizedResponse.bookingId).toBeNull();
expect(denormalizedResponse.bookingStatus).toBeNull();
expect(denormalizedResponse.eventTypeId).toBeNull();
});
it("should handle special characters in text fields", async () => {
const specialChars = "Test 'quotes' and \"double quotes\" and \\ backslashes";
await prisma.app_RoutingForms_Form.update({
where: { id: formId },
data: {
name: specialChars,
},
});
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
});
expect(denormalizedResponse.formName).toBe(specialChars);
});
});
describe("Data Integrity", () => {
it("should maintain correct relationships across all tables", async () => {
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUniqueOrThrow({
where: { id: responseId },
include: {
response: true,
booking: true,
},
});
expect(denormalizedResponse.formId).toBe(formId);
expect(denormalizedResponse.bookingId).toBe(bookingId);
expect(denormalizedResponse.response.formId).toBe(formId);
expect(denormalizedResponse.booking?.id).toBe(bookingId);
});
});
describe("Field Filtering and Relationships", () => {
it("should allow filtering by field values including arrays", async () => {
// Create a response with specific field values for testing
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-fields",
formId: formId,
response: {
"83316968-45bf-4c9d-b5d4-5368a8d2d2a8": {
label: "skills",
value: ["644e7f80-b76b-4a3c-94f7-bf0efa65d3d6", "34c50dca-9eed-4ade-89d4-9b2ccda52acc"],
},
},
},
});
// First verify the denormalized records were created
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
where: { id: formResponse.id },
include: { fields: true },
});
expect(denormalizedResponse).not.toBeNull();
expect(denormalizedResponse?.fields).toBeDefined();
expect(denormalizedResponse?.fields).toHaveLength(1);
// Test filtering by string array (skills)
const arrayFilterResult = await prisma.routingFormResponseDenormalized.findMany({
where: {
fields: {
some: {
fieldId: "83316968-45bf-4c9d-b5d4-5368a8d2d2a8",
valueStringArray: {
has: "644e7f80-b76b-4a3c-94f7-bf0efa65d3d6",
},
},
},
},
include: {
fields: true,
},
});
expect(arrayFilterResult).toHaveLength(1);
expect(arrayFilterResult[0].fields).toBeDefined();
expect(arrayFilterResult[0].fields).toHaveLength(1);
expect(
arrayFilterResult[0].fields.some(
(field) =>
field.fieldId === "83316968-45bf-4c9d-b5d4-5368a8d2d2a8" &&
field.valueStringArray?.includes("644e7f80-b76b-4a3c-94f7-bf0efa65d3d6")
)
).toBe(true);
// Clean up
await prisma.routingFormResponseField.deleteMany({
where: { responseId: formResponse.id },
});
await prisma.routingFormResponseDenormalized.deleteMany({
where: { id: formResponse.id },
});
await prisma.app_RoutingForms_FormResponse.delete({
where: { id: formResponse.id },
});
});
it("should allow filtering by string and number field values", async () => {
// Create a response with specific field values for testing
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-fields-2",
formId: formId,
response: {
"57734f65-8bbb-4065-9e71-fb7f0b7485f8": {
label: "Manager",
value: "John Smith",
},
"f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729": {
label: "Rating",
value: 5,
},
},
},
});
// First verify the denormalized records were created
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
where: { id: formResponse.id },
include: { fields: true },
});
expect(denormalizedResponse).not.toBeNull();
expect(denormalizedResponse?.fields).toBeDefined();
expect(denormalizedResponse?.fields).toHaveLength(2);
// Test filtering by string value
const stringFilterResult = await prisma.routingFormResponseDenormalized.findMany({
where: {
fields: {
some: {
fieldId: "57734f65-8bbb-4065-9e71-fb7f0b7485f8",
valueString: "John Smith",
},
},
},
include: {
fields: true,
},
});
expect(stringFilterResult).toHaveLength(1);
expect(stringFilterResult[0].fields).toBeDefined();
expect(
stringFilterResult[0].fields.some(
(field) =>
field.fieldId === "57734f65-8bbb-4065-9e71-fb7f0b7485f8" && field.valueString === "John Smith"
)
).toBe(true);
// Test filtering by number value
const numberFilterResult = await prisma.routingFormResponseDenormalized.findMany({
where: {
formId,
fields: {
some: {
fieldId: "f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729",
valueNumber: 5,
},
},
},
include: {
fields: true,
},
});
expect(numberFilterResult).toHaveLength(1);
expect(numberFilterResult[0].fields).toBeDefined();
expect(
numberFilterResult[0].fields.some(
(field) =>
field.fieldId === "f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729" &&
field.valueNumber?.equals(new Prisma.Decimal(5))
)
).toBe(true);
// Clean up
await prisma.routingFormResponseField.deleteMany({
where: { responseId: formResponse.id },
});
await prisma.routingFormResponseDenormalized.deleteMany({
where: { id: formResponse.id },
});
await prisma.app_RoutingForms_FormResponse.delete({
where: { id: formResponse.id },
});
});
it("should allow filtering by attendee email", async () => {
// Create a booking with attendees
const booking = await prisma.booking.create({
data: {
uid: `booking-denorm-${Math.floor(Math.random() * 1000000)}`,
title: "Test Booking",
startTime: new Date(),
endTime: new Date(Date.now() + 60 * 60 * 1000),
userId: userId,
eventTypeId: eventTypeId,
status: BookingStatus.PENDING,
attendees: {
create: [
{
email: "attendee1@example.com",
name: "Attendee One",
timeZone: "UTC",
},
{
email: "attendee2@example.com",
name: "Attendee Two",
timeZone: "UTC",
},
],
},
},
include: {
attendees: true,
},
});
// Create a form response linked to the booking
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-attendees",
formId: formId,
response: {
"dd28ffcf-7029-401e-bddb-ce2e7496a1c1": {
label: "Email",
value: "attendee1@example.com",
},
},
routedToBookingUid: booking.uid,
},
});
// First verify the denormalized records were created
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
where: { id: formResponse.id },
include: { fields: true },
});
expect(denormalizedResponse).not.toBeNull();
expect(denormalizedResponse?.bookingId).toBe(booking.id);
// Test filtering by attendee email
const attendeeFilterResult = await prisma.routingFormResponseDenormalized.findMany({
where: {
booking: {
attendees: {
some: {
email: {
startsWith: "attendee1@",
},
},
},
},
},
include: {
fields: true,
booking: {
include: {
attendees: true,
},
},
},
});
expect(attendeeFilterResult).toHaveLength(1);
expect(attendeeFilterResult[0].booking?.attendees).toBeDefined();
expect(
attendeeFilterResult[0].booking?.attendees.some(
(attendee) => attendee.email === "attendee1@example.com"
)
).toBe(true);
// Clean up
await prisma.booking.delete({
where: { id: booking.id },
});
await prisma.app_RoutingForms_FormResponse.delete({
where: { id: formResponse.id },
});
});
});
});
@@ -0,0 +1,297 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { readonlyPrisma } from "@calcom/prisma";
import { buildBaseWhereCondition } from "../trpc-router";
const mockTeamFindMany = vi.fn();
const mockMembershipFindMany = vi.fn();
const mockInsightsDb = {
team: {
findMany: mockTeamFindMany,
},
membership: {
findMany: mockMembershipFindMany,
},
} as unknown as typeof readonlyPrisma;
const createMockContext = (overrides = {}) => ({
userIsOwnerAdminOfParentTeam: false,
userOrganizationId: null,
insightsDb: mockInsightsDb,
...overrides,
});
describe("buildBaseWhereCondition", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("Basic filtering", () => {
it("should set eventTypeId condition when eventTypeId is provided", async () => {
const ctx = createMockContext();
const result = await buildBaseWhereCondition({
eventTypeId: 123,
ctx,
});
expect(result.whereCondition).toEqual({
OR: [{ eventTypeId: 123 }, { eventParentId: 123 }],
});
});
it("should set userId condition when memberUserId is provided", async () => {
const ctx = createMockContext();
const result = await buildBaseWhereCondition({
memberUserId: 456,
ctx,
});
expect(result.whereCondition).toEqual({
userId: 456,
});
});
it("should set userId and teamId conditions when userId is provided", async () => {
const ctx = createMockContext();
const result = await buildBaseWhereCondition({
userId: 789,
ctx,
});
expect(result.whereCondition).toEqual({
userId: 789,
teamId: null,
});
});
});
describe("Organization-wide queries", () => {
it("should return appropriate where condition when no teams found in organization", async () => {
mockTeamFindMany.mockResolvedValue([]);
const ctx = createMockContext({
userIsOwnerAdminOfParentTeam: true,
userOrganizationId: 100,
});
const result = await buildBaseWhereCondition({
isAll: true,
ctx,
});
expect(result.whereCondition).toEqual({
OR: [
{
teamId: { in: [100] },
isTeamBooking: true,
},
],
});
});
it("should build complex where condition for organization-wide query", async () => {
mockTeamFindMany.mockResolvedValue([{ id: 101 }, { id: 102 }]);
mockMembershipFindMany.mockResolvedValue([{ userId: 201 }, { userId: 202 }]);
const ctx = createMockContext({
userIsOwnerAdminOfParentTeam: true,
userOrganizationId: 100,
});
const result = await buildBaseWhereCondition({
isAll: true,
ctx,
});
expect(result.whereCondition).toEqual({
OR: [
{
teamId: {
in: [100, 101, 102],
},
isTeamBooking: true,
},
{
userId: {
in: [201, 202],
},
isTeamBooking: false,
},
],
});
});
});
describe("Team-specific queries", () => {
it("should build where condition for team-specific query", async () => {
mockMembershipFindMany.mockResolvedValue([{ userId: 301 }, { userId: 302 }]);
const ctx = createMockContext();
const result = await buildBaseWhereCondition({
teamId: 200,
isAll: false,
ctx,
});
expect(result.whereCondition).toEqual({
OR: [
{
teamId: 200,
isTeamBooking: true,
},
{
userId: {
in: [301, 302],
},
isTeamBooking: false,
},
],
});
});
it("should apply both team and eventTypeId conditions when both are provided", async () => {
mockMembershipFindMany.mockResolvedValue([{ userId: 301 }, { userId: 302 }]);
const ctx = createMockContext();
const result = await buildBaseWhereCondition({
teamId: 200,
eventTypeId: 500,
isAll: false,
ctx,
});
expect(result.whereCondition).toEqual({
AND: [
{
OR: [{ eventTypeId: 500 }, { eventParentId: 500 }],
},
{
OR: [
{
teamId: 200,
isTeamBooking: true,
},
{
userId: {
in: [301, 302],
},
isTeamBooking: false,
},
],
},
],
});
});
});
describe("Combined filtering", () => {
it("should combine eventTypeId and memberUserId conditions", async () => {
const ctx = createMockContext();
const result = await buildBaseWhereCondition({
eventTypeId: 123,
memberUserId: 456,
ctx,
});
expect(result.whereCondition).toEqual({
AND: [
{
OR: [{ eventTypeId: 123 }, { eventParentId: 123 }],
},
{
userId: 456,
},
],
});
});
});
describe("Invalid parameters", () => {
it("should handle missing parameters and return restrictive where condition", async () => {
const ctx = createMockContext();
const result = await buildBaseWhereCondition({
ctx,
});
expect(result.whereCondition).toEqual({ id: -1 });
});
it("should handle null teamId with restrictive where condition", async () => {
const ctx = createMockContext();
const result = await buildBaseWhereCondition({
teamId: null,
ctx,
});
expect(result.whereCondition).toEqual({ id: -1 });
});
it("should handle empty team members with proper team condition", async () => {
mockMembershipFindMany.mockResolvedValue([]);
const ctx = createMockContext();
const result = await buildBaseWhereCondition({
teamId: 200,
isAll: false,
ctx,
});
expect(result.whereCondition).toEqual({
OR: [
{
teamId: 200,
isTeamBooking: true,
},
{
userId: {
in: [],
},
isTeamBooking: false,
},
],
});
});
it("should handle rejected team membership differently from empty team", async () => {
mockMembershipFindMany.mockImplementation((params) => {
if (params?.where?.accepted === true) {
// No accepted members
return Promise.resolve([]);
}
return Promise.resolve([{ userId: 601 }, { userId: 602 }]);
});
const ctx = createMockContext();
const result = await buildBaseWhereCondition({
teamId: 200,
isAll: false,
ctx,
});
// Should only include team bookings since there are no accepted members
expect(result.whereCondition).toEqual({
OR: [
{
teamId: 200,
isTeamBooking: true,
},
{
userId: {
in: [], // Empty because no accepted members
},
isTeamBooking: false,
},
],
});
});
});
});
@@ -0,0 +1,212 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { readonlyPrisma } from "@calcom/prisma";
import { RoutingEventsInsights } from "../routing-events";
vi.mock("@calcom/prisma", () => ({
readonlyPrisma: {
team: {
findMany: vi.fn(),
},
membership: {
findMany: vi.fn(),
},
},
}));
describe("RoutingEventsInsights", () => {
class TestRoutingEventsInsights extends RoutingEventsInsights {
static async testGetWhereForTeamOrAllTeams(params: {
userId?: number | null;
teamId?: number | null;
isAll: boolean;
organizationId?: number | null;
routingFormId?: string | null;
}) {
return super.getWhereForTeamOrAllTeams(params);
}
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("getWhereForTeamOrAllTeams", () => {
describe("Basic filtering scenarios", () => {
it("should return correct where condition when isAll is true with organizationId", async () => {
vi.mocked(readonlyPrisma.team.findMany).mockResolvedValue([{ id: 1 }, { id: 2 }]);
vi.mocked(readonlyPrisma.membership.findMany).mockResolvedValue([{ teamId: 1 }, { teamId: 2 }]);
const result = await TestRoutingEventsInsights.testGetWhereForTeamOrAllTeams({
userId: 1,
isAll: true,
organizationId: 1,
});
expect(result).toEqual({
teamId: {
in: [1, 2],
},
});
});
it("should return correct where condition when teamId is provided", async () => {
vi.mocked(readonlyPrisma.membership.findMany).mockResolvedValue([{ teamId: 5 }]);
const result = await TestRoutingEventsInsights.testGetWhereForTeamOrAllTeams({
userId: 1,
teamId: 5,
isAll: false,
});
expect(result).toEqual({
teamId: {
in: [5],
},
});
});
it("should return correct where condition when neither teamId nor organizationId is provided", async () => {
const result = await TestRoutingEventsInsights.testGetWhereForTeamOrAllTeams({
userId: 1,
isAll: false,
});
expect(result).toEqual({
userId: 1,
teamId: null,
});
});
});
describe("Edge cases", () => {
it("should return correct where condition when user has no access to teams", async () => {
vi.mocked(readonlyPrisma.team.findMany).mockResolvedValue([{ id: 2 }, { id: 3 }]);
vi.mocked(readonlyPrisma.membership.findMany).mockResolvedValue([]);
const result = await TestRoutingEventsInsights.testGetWhereForTeamOrAllTeams({
userId: 1,
isAll: true,
organizationId: 1,
});
expect(result).toEqual({
userId: 1,
teamId: null,
});
});
it("should return correct where condition when routingFormId is provided", async () => {
vi.mocked(readonlyPrisma.membership.findMany).mockResolvedValue([{ teamId: 5 }]);
const result = await TestRoutingEventsInsights.testGetWhereForTeamOrAllTeams({
userId: 1,
teamId: 5,
isAll: false,
routingFormId: "form-123",
});
expect(result).toEqual({
teamId: {
in: [5],
},
id: "form-123",
});
});
it("should handle null userId by using default value", async () => {
vi.mocked(readonlyPrisma.membership.findMany).mockResolvedValue([{ teamId: 5 }]);
const result = await TestRoutingEventsInsights.testGetWhereForTeamOrAllTeams({
userId: null,
teamId: 5,
isAll: false,
});
expect(result).toEqual({
teamId: {
in: [5],
},
});
});
it("should handle empty parameters with only isAll provided", async () => {
const result = await TestRoutingEventsInsights.testGetWhereForTeamOrAllTeams({
isAll: false,
});
expect(result).toEqual({
userId: -1,
teamId: null,
});
});
});
describe("Invalid parameter combinations", () => {
it("should handle when isAll is true but no organizationId is provided", async () => {
const result = await TestRoutingEventsInsights.testGetWhereForTeamOrAllTeams({
userId: 1,
isAll: true,
});
expect(result).toEqual({
userId: 1,
teamId: null,
});
});
it("should handle when both teamId and organizationId are provided", async () => {
vi.mocked(readonlyPrisma.membership.findMany).mockResolvedValue([{ teamId: 5 }]);
const result = await TestRoutingEventsInsights.testGetWhereForTeamOrAllTeams({
userId: 1,
teamId: 5,
isAll: false,
organizationId: 10,
});
expect(result).toEqual({
teamId: {
in: [5],
},
});
});
it("should handle when both teamId and organizationId are provided with isAll true", async () => {
vi.mocked(readonlyPrisma.team.findMany).mockResolvedValue([{ id: 1 }, { id: 2 }]);
vi.mocked(readonlyPrisma.membership.findMany).mockResolvedValue([{ teamId: 1 }, { teamId: 2 }]);
const result = await TestRoutingEventsInsights.testGetWhereForTeamOrAllTeams({
userId: 1,
teamId: 5,
isAll: true,
organizationId: 1,
});
expect(result).toEqual({
teamId: {
in: [1, 2],
},
});
});
it("should handle undefined teamId with isAll true", async () => {
vi.mocked(readonlyPrisma.team.findMany).mockResolvedValue([]);
vi.mocked(readonlyPrisma.membership.findMany).mockResolvedValue([]);
const result = await TestRoutingEventsInsights.testGetWhereForTeamOrAllTeams({
userId: 1,
teamId: undefined,
isAll: true,
organizationId: 1,
});
expect(result).toEqual({
userId: 1,
teamId: null,
});
});
});
});
});
@@ -41,7 +41,7 @@ interface BuildBaseWhereConditionType {
const bookingStatusSchema = z.enum(["NO_BOOKING", ...Object.values(BookingStatus)]).optional();
const buildBaseWhereCondition = async ({
export const buildBaseWhereCondition = async ({
teamId,
eventTypeId,
memberUserId,
@@ -50,17 +50,28 @@ const buildBaseWhereCondition = async ({
ctx,
}: BuildBaseWhereConditionType): Promise<{
whereCondition: Prisma.BookingTimeStatusWhereInput;
isEmptyResponse?: boolean;
}> => {
let whereCondition: Prisma.BookingTimeStatusWhereInput = {};
const conditions: Prisma.BookingTimeStatusWhereInput[] = [];
// EventType Filter
if (eventTypeId) whereCondition.OR = [{ eventTypeId }, { eventParentId: eventTypeId }];
// User/Member filter
if (memberUserId) whereCondition.userId = memberUserId;
if (userId) {
whereCondition.teamId = null;
whereCondition.userId = userId;
if (eventTypeId) {
conditions.push({
OR: [{ eventTypeId }, { eventParentId: eventTypeId }],
});
}
// User/Member filter
if (memberUserId) {
conditions.push({ userId: memberUserId });
}
if (userId) {
conditions.push({
teamId: null,
userId: userId,
});
}
// organization-wide queries condition
if (isAll && ctx.userIsOwnerAdminOfParentTeam && ctx.userOrganizationId) {
const teamsFromOrg = await ctx.insightsDb.team.findMany({
@@ -72,57 +83,48 @@ const buildBaseWhereCondition = async ({
},
});
if (teamsFromOrg.length === 0) {
return {
whereCondition: {
...whereCondition,
OR: [
...(whereCondition.OR ?? []),
{
teamId: ctx.userOrganizationId,
isTeamBooking: true,
const teamIds = [ctx.userOrganizationId, ...teamsFromOrg.map((t) => t.id)];
const usersFromOrg =
teamsFromOrg.length > 0
? await ctx.insightsDb.membership.findMany({
where: {
team: {
id: {
in: teamIds,
},
},
accepted: true,
},
],
},
isEmptyResponse: true,
};
}
select: {
userId: true,
},
})
: [];
const teamConditional = {
id: {
in: [ctx.userOrganizationId, ...teamsFromOrg.map((t) => t.id)],
},
};
const usersFromOrg = await ctx.insightsDb.membership.findMany({
where: {
team: teamConditional,
accepted: true,
},
select: {
userId: true,
},
});
const userIdsFromOrg = usersFromOrg.map((u) => u.userId);
whereCondition = {
...whereCondition,
conditions.push({
OR: [
{
teamId: {
in: [ctx.userOrganizationId, ...teamsFromOrg.map((t) => t.id)],
in: teamIds,
},
isTeamBooking: true,
},
{
userId: {
in: userIdsFromOrg,
},
isTeamBooking: false,
},
...(usersFromOrg.length > 0
? [
{
userId: {
in: usersFromOrg.map((u) => u.userId),
},
isTeamBooking: false,
},
]
: []),
],
};
});
}
if (teamId && !isAll && !eventTypeId) {
// Team-specific queries condition
if (!isAll && teamId) {
const usersFromTeam = await ctx.insightsDb.membership.findMany({
where: {
teamId: teamId,
@@ -133,8 +135,8 @@ const buildBaseWhereCondition = async ({
},
});
const userIdsFromTeam = usersFromTeam.map((u) => u.userId);
whereCondition = {
...whereCondition,
conditions.push({
OR: [
{
teamId,
@@ -147,9 +149,23 @@ const buildBaseWhereCondition = async ({
isTeamBooking: false,
},
],
};
});
}
return { whereCondition };
let whereCondition: Prisma.BookingTimeStatusWhereInput = {};
if (conditions.length === 1) {
whereCondition = conditions[0];
} else if (conditions.length > 1) {
whereCondition = { AND: conditions };
}
return {
whereCondition:
Object.keys(whereCondition).length === 0
? { id: -1 } // Ensure no data is returned for invalid parameters
: whereCondition,
};
};
const buildHashMapForUsers = <
@@ -1200,8 +1216,6 @@ export const insightsRouter = router({
bookingWhere = {
...bookingWhere,
teamId,
eventTypeId,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
@@ -1318,8 +1332,6 @@ export const insightsRouter = router({
bookingWhere = {
...bookingWhere,
teamId,
eventTypeId,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
@@ -1397,8 +1409,7 @@ export const insightsRouter = router({
let { whereCondition: bookingWhere } = r;
bookingWhere = {
teamId,
eventTypeId,
...bookingWhere,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
@@ -1476,8 +1487,7 @@ export const insightsRouter = router({
let { whereCondition: bookingWhere } = r;
bookingWhere = {
teamId,
eventTypeId,
...bookingWhere,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
@@ -13,9 +13,9 @@ import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBook
import { getFullName } from "@calcom/features/form-builder/utils";
import { sendNotification } from "@calcom/features/notifications/sendNotification";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import { WEBAPP_URL } from "@calcom/lib/constants";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { createInstantMeetingWithCalVideo } from "@calcom/lib/videoClient";

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