Compare commits

..
188 Commits
Author SHA1 Message Date
Omar López 32aaa26bf1 chore: v4.7.4 (#17728)
release.yml / release (push) Canceled after 0s
2024-11-18 13:12:53 -07:00
Omar López 7c0ee9f186 revert: "feat: Populate gCal calendar cache via webhooks (#11928)"" (#17682) 2024-11-18 19:48:33 +00:00
Benny Joo 50df22928b fix: correct domain for link column in org member table (#17726) 2024-11-18 18:40:24 +00:00
Calcom BotandReplexica fbda8a38cb feat: update translations via @replexica (#17722)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-11-18 14:56:01 +00:00
8194ffcd07 feat: Autofill feature (#15437)
* feat: Autofill feature

* Revert "feat: Autofill feature"

This reverts commit ac94257cbb516e140ed550ae804e725ca5d036ca.

* feat: store prev response in localStorage

* review fixes

* add tests

* remove unused import

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2024-11-18 20:04:02 +05:30
VijayandAnik Dhabal Babu 7b94822c8d fix: add default schedule while creating org member (#16855)
* add default schedule while creating org user

* naming as per review comments

* added e2e test

* move bookTeamEvent and expectPageNotFound to testUtils

* Update EventTeamAssignmentTab.tsx

* Update AddMembersWithSwitch.tsx

* Update utils.ts

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-18 20:03:24 +05:30
Syed Ali Shahbaz 9aa8f8a30c Update oauth.mdx (#17671) 2024-11-18 14:29:33 +00:00
Rajiv Sahal e206ead611 fix: calendar settings atom modifications (#17579)
* update selected calendars display when no calendar connected

* remove no calendar connected message
2024-11-18 11:05:34 -03:00
Udit Takkar ecac331fd1 perf: get event types (#17689)
* refactor: get event types

* chore

* test: add team and managed team event types

* perf: use Promise.all

* fix: type err
2024-11-18 11:05:12 -03:00
sean-brydon fb4cd7f0e4 feat: Add column visibility filters to routing insights table (#17715)
* add table as a child callback to we can pass it to columnVisibility

* add back failed bookings component
2024-11-18 11:03:58 -03:00
Rajiv Sahal f743db9eaa feat: enable all users to create platform team (#17707)
* add extra checks for platform to make sure user is genuine

* make slug unique for every team
2024-11-18 11:03:23 -03:00
Udit Takkar 0f3773d724 feat: add instant meeting parameter (#17647)
* feat: add instant meeting parameter

* feat: show modal only when parameter present

* chore: remove logs

* fix: type err

* fix: UI

* fix: remove useFieldArray
2024-11-18 19:29:10 +05:30
Eunjae Lee e6633050ba fix: provide explicit column width for member list (#17718) 2024-11-18 13:43:14 +00:00
Replexica Bot f1e972a213 feat: update translations via @replexica (#17712) 2024-11-18 13:01:07 +00:00
Morgan 12e78a7350 chore: add default reading direction to atoms wrapper (#17710) 2024-11-18 12:25:11 +00:00
Amit Sharmaandsean-brydon 277b3e0cf5 feat: DataTable component should use a "key" of data as the selection Key. (#17714)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-11-18 10:25:52 +00:00
Lauris Skraucis 2b1041460c feat: v2 subsequent recurring booking cancellation (#17645)
* refactor: cancelling all remaining seated recurring bookings not possible

* chore: document cancel booking endpoint

* chore: remove unused imports

* feat: cancel subsequent bookigns

* fix: flaky test

* refactor: getCanceledSubsequentBookings

* refactor: after cancellation return whole recurrence sequence
2024-11-18 08:17:48 +00:00
Keith Williams ce0d685849 chore: Remove faulty languages for Replexica (#17705) 2024-11-18 02:58:16 +00:00
cc5ce4e598 feat: update translations via @replexica (#17704)
* chore: sync with main

* feat: update translations via @replexica

* Update common.json

---------

Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-18 02:21:52 +00:00
Keith Williams 4123152bd5 feat: Added more supported languages (#17702) 2024-11-18 01:09:35 +00:00
Keith Williams 1b4469f6e5 chore: v4.7.3 (#17701)
release.yml / release (push) Canceled after 0s
2024-11-17 21:58:45 -03:00
Benny JooandKeith Williams 5401bcca4e feat: AI description - DB model + frontend + backend (fetch only) (#17651)
* feat: AI description - DB model + frontend + backend (fetch only)

* fix types and add validation to backend

* improve log

* improve

* import type

* fix replexica error

* fix

* fix test

* update replexica type

* Renamed descriptionTranslations to fieldTranslations

* Moved the eventTypeId column to 2nd

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-18 00:16:56 +00:00
Benny Joo f9afca2939 feat: Add link column to CSV file for org members (#17695)
* improve spacing

* feat: Add link column to CSV file for org members

* update tests
2024-11-17 10:53:43 +00:00
Alex van Andel fc1d553b74 chore: Release v4.7.2 (#17692)
release.yml / release (push) Canceled after 0s
2024-11-16 18:36:43 +00:00
Calcom BotandReplexica b8c2fdac44 feat: update translations via @replexica (#17691)
Co-authored-by: Replexica <support@replexica.com>
2024-11-16 18:32:52 +00:00
Abul Hassan SathuliandPrashant Varma e24c40f518 fix: how-to-setup-api-in-a-local-instance.mdx in docs with correction after api-v2 (#17667)
* Updating how-to-setup-api-in-a-local-instance.mdx with correction after api v2

After the implementation of api v2, some of the documents are not yet updated and could you make developers spend too much time on finding these things.

* fix: typo

---------

Co-authored-by: Prashant Varma <prashantvarma5083@gmail.com>
2024-11-16 18:27:02 +00:00
Alex van Andel c5cefc3e61 chore: Found a critical index missing on profileId (#17690) 2024-11-16 18:26:50 +00:00
Omar López 2c57ad0288 perf: optimize organization admin check (#17683) 2024-11-16 18:26:23 +00:00
sean-brydon af9bf14d9e fix: routing column filters (#17688)
* remove column filters

* remove column filters

* use a loading state while loading columns

* fix
2024-11-16 18:05:36 +00:00
sean-brydon b1a0cdc8e2 fix: large org routing insights UI issues (#17687)
* fix overflow colour in darkmode

* truncate/show overflow scroll indicator when overflowing on response values

* remove total created count

* overflow Y on failed response container

* fix darkmode styles on barlist

* radix hovercards to properly allow overflow in container

* column visible state

* remove constrained width on dropdowns to w-fit

* export type
2024-11-16 11:01:26 -03:00
Alex van AndelandPeer Richelsen 7fef3e7b85 chore: Release v4.7.1 (#17686)
release.yml / release (push) Canceled after 0s
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-11-16 10:00:16 +00:00
Hariom Balhara 384deab0ce test: findTeamMemberEmailFromCrm (#17685)
* We need to choose contact owner when crm app slug is not present

* Add unit tests for getTeamMemberEmailFromCrm module
2024-11-16 09:32:33 +00:00
Hariom Balhara 22da681f8c We need to choose contact owner when crm app slug is not present (#17684) 2024-11-16 09:02:24 +00:00
Omar López f4f4afad39 perf: optimize team admin check queries (#17681)
## What does this PR do?

Optimizes team and organization admin checks by:
- Removing unnecessary member data fetching in profile repository
- Simplifying team admin verification query to directly check membership
- Refactoring organization admin check to use the optimized team verification method

## Mandatory Tasks

- [x] I have self-reviewed the code
- [x] I have updated the developer docs in /docs
- [ ] I confirm automated tests are in place

## How should this be tested?

1. Log in as a team admin/owner
2. Verify team admin privileges still work correctly
3. Log in as an organization admin
4. Confirm organization admin permissions are maintained
5. Check that admin-only features remain accessible

Expected behavior:
- Team admins/owners should retain all their existing permissions
- Organization admins should maintain their administrative capabilities
- No degradation in admin functionality while improving query performance

## Checklist

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings
2024-11-15 19:00:18 -07:00
Keith Williams 1c05952077 chore: Remove playwright install for API v2 E2E (#17677) 2024-11-15 23:47:26 +00:00
Keith Williams 1df70e75ed chore: v4.7.0 (#17664)
release.yml / release (push) Canceled after 0s
2024-11-15 20:11:00 -03:00
Udit Takkar 8962f6f08e fix: update current cursor (#17626) 2024-11-15 16:07:17 -07:00
Keith Williams 6272c881c8 revert: "feat: Populate gCal calendar cache via webhooks (#11928)" (#17675)
This reverts commit d294a74aad.
2024-11-15 19:28:30 -03:00
Omar López bdf94c7f48 fix: removed calendar cache admin page (#17676) 2024-11-15 21:46:08 +00:00
b79c5e7c86 feat: update translations via @replexica (#17674)
* feat: update translations via @replexica

* feat: update translations via @replexica

* Delete packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.handler.ts

* Delete packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts

---------

Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-15 21:34:07 +00:00
3eaccb8738 fix: Preview queue position and contact owner in preview (#17552)
* wip

* Add preview mode in router

* only fetch bookings of this month for weighted rr

* test set up

* only get bookings of current month

* reverts test setup

* first changes for weight adjustments

* reset booking count + adjust calibration

* depreciate weightAdjustment

* get only bookings created this month

* fix typo

* make sure createdAt for hosts is correct

* use earliest possibel date as fallback

* add missing createdAt date to tests

* fix typo

* clean up changes in tests

* fix typo

* change end date to current date

* fix: Fall back to empty host array when no hosts are found

* fix: Restructure code a little

* fixed test, incorrectly used now outdated var

* perf: remove Dayjs from getLuckyUser

* Refactor getHostsWithCalibration for optimised performance, intentionally break test as findMany is always an array

* Better mock for host.findMany

* Remove team-event-types.test.ts, move to appropriate package

* TypeScript cannot auto-infer that an array is non-empty when assigning to a var

* fix: Type Fixes and DistributionMethod enum add

* Optimise tests

* Added test to show that bookings made before a newHost was added affect the lucky user result

* Throw error when the usersWithHighestPriority is empty, which should never happen

* WIP

* remove comment

* update migrations

* get attributes weights and virtual queue data

* use attribute weights and use bookings of virtual queue only

* clean up migrations

* Add shortfall column and add tests

* code clean up from feedback

* wrapper function for getLuckyUser

* code clean up

* fetch routingFormResponse in handleNewBooking

* fix type errors

* fix type errors in tests

* fix getAttributesQueryValue import for tests

* fix totalWeight

* add test for attributes weights and virtual queues

* clean up code

* add test for prepareQueuesAndAttributesData

* remove console.log

* use lazy import

* Add more tests and more columns to matching members queue

* fix issue from merge

* always send usersAndTheirBookingShortfalls

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-11-15 20:39:46 +00:00
Anik Dhabal Babu 9273226fb4 test: Add test to validate error when 'in' field in where clause is undefine and contain null values (#17670) 2024-11-15 19:55:34 +00:00
sean-brydonandJoe Au-Yeung 7ce5886b4f feat: insights routing forms (#17583)
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2024-11-15 14:54:51 -05:00
Anik Dhabal Babu e85c0e1a85 fix: unable to lock individual event type creation (#17668) 2024-11-15 15:58:19 -03:00
Omar LópezandUdit Takkar d294a74aad feat: Populate gCal calendar cache via webhooks (#11928)
* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* Update CalendarService.ts

* Type fixes

* WIP

* fix: improve cache hits

* Update CalendarService.ts

* Update CalendarService.ts

* Update CalendarService.ts

* Update CalendarService.ts

* Update CalendarService.ts

* Update CalendarService.ts

* Feedback

* Update CalendarService.ts

* Update CalendarService.ts

* Update _router.ts

* feedback

* WIP

* WIP

* Update schema.prisma

* feedback

* typefixes

* Update Calendar.d.ts

* fix: watches when adding a calendar

* Discard changes to packages/app-store/googlecalendar/api/add.ts

* WIP

Signed-off-by: Omar López <zomars@me.com>

* WIP

Signed-off-by: Omar López <zomars@me.com>

* WP

Signed-off-by: Omar López <zomars@me.com>

* Update calendar.ts

* Update calendar.ts

* Update callback.ts

* Update callback.ts

* Conflicts

* WIP

* WIP

* Update CalendarService.ts

* Cleanup

* Discard changes to packages/features/settings/layouts/SettingsLayout.tsx

* Update calendar-cache.repository.ts

* WIP

* Update getSelectedCalendarsToWatch.sql

* WIP

* Update CalendarService.ts

* Cleanup

* Discard changes to packages/app-store/googlecalendar/lib/CalendarService.ts

* Create CalendarService.wip.ts

* WIP

* Update CalendarService.ts

* Update getSelectedCalendarsToWatch.sql

* Delete CalendarService.wip.ts

* test updates

* cleanup

Signed-off-by: Omar López <zomars@me.com>

* Update CalendarService.test.ts

* Update CalendarService.ts

* type fixes

* Update OAuthManager.ts

* Update CalendarService.ts

* Almost there

* Update CalendarService.test.ts

* Update calendar.ts

* Update callback.ts

* Update toggleFeatureFlag.handler.ts

* fix: feedback

* Update getSelectedCalendarsToWatch.sql

* Fix unit tests

---------

Signed-off-by: Omar López <zomars@me.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-15 14:59:37 -03:00
5012a16794 feat: attribute weights and virtual queues (#17567)
* only fetch bookings of this month for weighted rr

* test set up

* only get bookings of current month

* reverts test setup

* first changes for weight adjustments

* reset booking count + adjust calibration

* depreciate weightAdjustment

* get only bookings created this month

* fix typo

* make sure createdAt for hosts is correct

* use earliest possibel date as fallback

* add missing createdAt date to tests

* fix typo

* clean up changes in tests

* fix typo

* change end date to current date

* fix: Fall back to empty host array when no hosts are found

* fix: Restructure code a little

* fixed test, incorrectly used now outdated var

* perf: remove Dayjs from getLuckyUser

* Refactor getHostsWithCalibration for optimised performance, intentionally break test as findMany is always an array

* Better mock for host.findMany

* Remove team-event-types.test.ts, move to appropriate package

* TypeScript cannot auto-infer that an array is non-empty when assigning to a var

* fix: Type Fixes and DistributionMethod enum add

* Optimise tests

* Added test to show that bookings made before a newHost was added affect the lucky user result

* Throw error when the usersWithHighestPriority is empty, which should never happen

* WIP

* remove comment

* update migrations

* get attributes weights and virtual queue data

* use attribute weights and use bookings of virtual queue only

* clean up migrations

* code clean up from feedback

* wrapper function for getLuckyUser

* code clean up

* fetch routingFormResponse in handleNewBooking

* fix type errors

* fix type errors in tests

* fix getAttributesQueryValue import for tests

* fix totalWeight

* add test for attributes weights and virtual queues

* clean up code

* add test for prepareQueuesAndAttributesData

* remove console.log

* use lazy import

* use findUnique

* add fallback weight

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-11-15 17:36:46 +00:00
Calcom BotandReplexica 322ca2a12c feat: update translations via @replexica (#17666)
* feat: update translations via @replexica

* feat: update translations via @replexica

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-11-15 16:50:57 +00:00
Udit Takkar 9b79c037c2 fix: remove cal video logo from select (#17665)
* fix: remove cal video logo from managed event types

* fix: do not fetch cal video logo
2024-11-15 16:45:44 +00:00
Alex van Andel f76696032e feat: Implements maxLeadThreshold (#17643)
* feat: Implements maxLeadThreshold which puts a maximum to the amount of lead a given host is allowed to be ahead

* Refactor getLuckyUser + implement in handleNewBooking + move logic to repo

* Decoupled @prisma from findQualifiedHosts

* Fix deep type error

* Add maxLeadThreshold to builder.ts

* Removed a console.log :O + fix handleChildrenEventTypes input

* Add distribution UI to enable balanced distribution/maximize availability

* 0-1-2 instead of 0-1-2-3 (3 bookings instead of 4)
2024-11-15 15:54:02 +00:00
Udit Takkar c6aa57177a fix: filter cal video logo (#17663)
* fix: filter cal video logo

* chore: message
2024-11-15 14:38:28 +00:00
Anik Dhabal Babu 7f2db48c72 fix: meeting not created on hubspot for contacts (#17661) 2024-11-15 13:18:17 +00:00
c59e733b5a feat: exclude emails and domains (#17591)
* feat: add constants

* feat: add excludeEmails field in baseSchema

* feat: add ExcludeEmails Input field in FieldEditDialog

* feat: add excludedEmails check in FormBuilderField

* chore: move validations in getBookingResponsesSchema

* Update i18n string to be more generic

* Update apps/web/public/static/locales/en/common.json

* feat: add validation on excludeEmails field

* feat: add fieldSchema as resolver

* chore: remove comment

* chore: format comments

* feat: add tests for excluded email or domains

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-15 10:46:25 +00:00
sean-brydon e0713d78b3 fix: z index issue on dropdown menu (#17656)
* portal content

* fix(dropdown-z-index-issue): Fixes dropdown z index issue by portaling
2024-11-15 10:51:39 +01:00
Max Prilutskiy d868b325c4 feat(CAL-4730): add sk-SK language (#17652) 2024-11-15 09:41:36 +00:00
Keith Williams f8f6c31e40 chore: v4.6.24 (#17639)
release.yml / release (push) Canceled after 0s
2024-11-14 18:06:09 -03:00
Anik Dhabal Babu 7246fa4084 fix: unable to update host schedules (#17649) 2024-11-14 15:37:19 -05:00
Benny Joo 039a0b7a85 chore: remove wrong logs (#17648) 2024-11-14 18:57:51 +00:00
sean-brydon 12c5005305 chore: routingform org attribute seed (#17510)
* seed to org

* more org+routing form seed improvments

* add routing responses

* check if attributes already exist to prevent crash

* remove files not for PR

* fix type error

* fix connection

* assign manually

* fix seed

* update to match integration tests

* fix integration test to new user count
2024-11-14 14:50:26 -03:00
Sachin Siddhuandunknown 1ff6465384 fix: resolved troubleshoot-sidbar bug (#17613)
* resolved troubleshoot-sidbar bug

* fulfilled review changes

---------

Co-authored-by: unknown <sachinsiddhu112@gmail.com>
2024-11-14 13:26:55 -03:00
ae706bb6d6 feat: update translations via @replexica (#17629)
* feat: update translations via @replexica

* feat: update translations via @replexica

* Delete packages/ui/components/data-table/tanstack.d.ts

* Delete packages/lib/sync/services/CloseComService.ts

* Delete packages/lib/sync/SyncServiceManager.ts

---------

Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-14 16:19:03 +00:00
Lauris Skraucis 538a39328e fix: useOAuthClient is not initialized (#17637) 2024-11-14 15:35:45 +00:00
Joe Au-Yeung 3589767099 feat: Salesforce when create contacts under an account if creating leads is chosen [CAL-4713] (#17630) 2024-11-14 10:29:21 -05:00
Kartik SainiandUdit Takkar ff2754f663 feat: allow marking no show after booking has started (#17625)
* feat: allow marking no show after booking has started

* fix

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-14 09:55:39 +00:00
Rajiv Sahal 65484e7d01 fix: BookerEmbed atom localStorage issues (#17611) 2024-11-14 08:07:47 +00:00
Peer Richelsen 4d942a454a chore: typo in webhook (#17628) 2024-11-14 02:57:03 +00:00
Keith WilliamsandUdit Takkar 7dbcb62724 perf: Move /me stats to separate tRPC handler (#17206)
* perf: Move /me stats to separate tRPC handler

* Update packages/trpc/server/routers/loggedInViewer/meStats.handler.ts

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>

* Rename and skipping batch for myStats

* Refactor of counting team events

* Fixed type issues

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-13 20:55:57 -03:00
Benny Joo c197c33fd0 chore: upgrade @vercel/og lib (#17503)
## What does this PR do?

<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

- housekeeping: having important packages up to date

<!-- Please provide a loom video for visual changes to speed up reviews
 Loom Video: https://www.loom.com/
-->

## Mandatory Tasks (DO NOT REMOVE)

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

## How should this be tested?

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->

- Are there environment variables that should be set?
- What are the minimal test data to have?
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR

## Checklist

<!-- Remove bullet points below that don't apply to you -->

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings
2024-11-13 13:50:21 -07:00
Keith Williams 815065d67c feat: Add API v2 logs to axiom (#17595)
## What does this PR do?

Adds ability to send API v2 logs to Axiom.

## Mandatory Tasks (DO NOT REMOVE)

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

## How should this be tested?

- Add the AXIOM_DATASET and AXIOM_TOKEN variables and run the API locally. Ensure the logs flow through to Axiom.
2024-11-13 13:18:53 -07:00
Omar López 02cecd6707 chore: Cleanup Close.com (#16929) 2024-11-13 15:14:31 -05:00
Keith Williams 81386d4ec0 chore: v4.6.23 (#17620)
release.yml / release (push) Canceled after 0s
2024-11-13 14:40:22 -03:00
Eunjae LeeandKeith Williams 204e411585 fix: make actions column sticky in members list (#17554)
* fix: make actions column sticky in members list

* clean up import

* change type definition

* remove old export

* include tanstack.d.ts

* fix declaration file

* move type def to packages/types/tanstack-table.d.ts

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-13 16:36:01 +00:00
Chiranjeev VishnoiandAnik Dhabal Babu 1ec1554cb5 change telly url (#17618)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-13 16:21:53 +00:00
Udit Takkar 241579eba6 refactor: change title (#17623)
* chore: change title

* chore: translation
2024-11-13 16:19:58 +00:00
Udit Takkar 8ccad29ab6 fix: skip triggering webhook in case of duplicate email (#17612)
* fix: skip sending webhook in case of duplicate response

* fix: type err

* fix: tests

* refactor: only check email

* fix: type err

* chore: changed it to 60 min

* fix: use lowercase
2024-11-13 15:50:26 +00:00
74b4d7a94a chore: Automated API v2 OpenAPI spec generation (#17046)
* chore: Automated API v2 OpenAPI spec generation

* chore: update docs

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-11-13 15:28:25 +00:00
5c6e60b726 fix: fetch next batch if event types are empty and cursor exists (#17109)
* fix: fetch next batch if event types are empty and cursor exists

* refactor: improve

* fix: add isFetchingForFirstTime

* chore: add number type

* fix: type err

* chore: add logs

* fix: type err

* fix: conflict

* fix: bug

* fix: type err

* chore: try changing type

* chore: add nul

* Update packages/trpc/server/routers/viewer/eventTypes/getEventTypesFromGroup.handler.ts

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* chore: use log.info

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2024-11-13 14:37:03 +00:00
Alex van Andel 18c7c3c940 fix: Refactor publicPageUrl to use getBookerBaseUrlSync like elsewhere (#17607) 2024-11-13 08:55:01 -05:00
fbd113b976 feat: update translations via @replexica (#17619)
* feat: update translations via @replexica

* feat: update translations via @replexica

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-13 13:53:51 +00:00
Udit Takkar c7f11c6795 fix: mark guest are no show (#17588)
* fix: mark guest are no show

* chore: missing import

* nit

* fix: type err
2024-11-13 19:03:55 +05:30
Peer Richelsen 84fb9bb371 chore: update Instant Meeting Error Message (#17617)
* Update common.json

* small copy changes
2024-11-13 13:08:05 +00:00
Lauris Skraucis 1aac70f643 feat: v2 confirm / decline booking endpoints (#17578)
* feat: v2 confirm / decline booking endpoints

* chore: bump platform libraries

* fix: type export

* tests: accepting and declining a booking

* refactor: sent emails abstract renderEmail with getHtml that is mocked in test
2024-11-13 11:20:43 +02:00
Lauris Skraucis 0dde8c1db9 feat: v2 multiple length event types and their bookings (#17598)
* feat: input lengthInMinutesOptions

* Revert "feat: input lengthInMinutesOptions"

This reverts commit f23fd8a3861097f0972cc6210b47f25511d85183.

* feat: handle lengthInMinutesOptions input

* feat: handle lengthInMinutesOptions event output

* fix: remove it.only

* feat: variable length bookings
2024-11-13 11:13:11 +02:00
Ankit YadavandOmar López 0da3a9fa11 fix: #17602 (#17604)
Co-authored-by: Omar López <zomars@me.com>
2024-11-12 20:20:38 +00:00
Alex van Andel eb8f5bd194 chore: Release v4.6.22 (#17600)
release.yml / release (push) Canceled after 0s
2024-11-12 18:18:37 +00:00
Morgan 82901fee94 fix: apiv2 updateNewTeamMemberEventTypes silently (#17555)
* fix: apiv2 updateNewTeamMemberEventTypes silently

* fixup! fix: apiv2 updateNewTeamMemberEventTypes silently
2024-11-12 09:32:21 -03:00
Udit Takkar 4e0e3a47f5 perf: routing forms loading time (#17518)
* perf: routing forms loading time

* chore: add TODO

* chore

* chore: add type

* chore: use Promise.all

* fix: type err

* fix: fetch id

* refactor: don't fetch team

* chore: add name
2024-11-12 17:32:42 +05:30
Syed Ali Shahbaz dc400d80d0 fix: split firstname lastname support, phone support & timezone specific startTime in success redirect params (#17593)
* init

* fixes

* fix type
2024-11-12 11:16:41 +00:00
Morgan 6889592d4f feat: add free platform plan (#17581)
* feat: add free platform plan

* chore: handle payment success / fail overdue

* chore: refactor and add tests
2024-11-12 11:14:51 +02:00
Benny Joo 95b833bc1c fix: avatars are not showing in preview image (#17592) 2024-11-12 07:57:06 +00:00
b06683458a feat: update translations via @replexica (#17585)
Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-11 22:20:17 -03:00
Alex van Andel 01d764c6d3 chore: Release v4.6.21 (#17586)
release.yml / release (push) Canceled after 0s
2024-11-12 01:09:55 +00:00
Joe Au-YeungandAlex van Andel 49333addc2 feat: Salesforce RR skip based on a user on a lookup field on an account (#17526)
* move types to types file

* Create salesforce routing form components

* Save salesforce data to routing form

* Fixes

* Add event type service

* Change commenting

* Pass data from routing form to CRM

* Init Salesforce routing form booking form handler

* Salesforce find user associated with lookup field

* Add looking up the contact owner

* If salesforce is disabled then don't change the Salesforce option

* Small refactor

* Refactor getting event type app metadata

* Refactor eventType service

* Type fix

* Clean up

* Add translations

Co-authored-by: Alex van Andel <emrysal@users.noreply.github.com>

---------

Co-authored-by: Alex van Andel <emrysal@users.noreply.github.com>
2024-11-12 01:09:30 +00:00
Anik Dhabal Babu 6c40d29745 fix: incorrect booking title for diff users (#17448)
* fix: incorrect booking title for diff users

* Update common.json

* update
2024-11-11 19:10:45 +00:00
Anik Dhabal BabuandUdit Takkar fc5ad53a6d fix: hidden phone booking field should never need validation (#17564)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-11 15:06:38 +00:00
Anik Dhabal Babu 4d38c6d0c7 fix: Form link in Reroute Dialog gives 404 for members that aren't admin/owner of team (#17537)
* fix: Form link in Reroute Dialog gives 404 for members that aren't admin/owner of team

* NIT
2024-11-11 20:25:08 +05:30
Anik Dhabal Babu cd15335eed chore: plain text plugin for Editor (#17134)
* chore: plain text plugin for Editor

* fix build error
2024-11-11 20:23:12 +05:30
Anik Dhabal Babu aaa75100d4 chore: Routing form - Add submitted_at and Sort entries with latest on top (#17491)
* chore: Routing form - Add submitted_at and sort rows by this timestamp in reporting

* fix e2e

* fix e2e

* Update basic.e2e.ts

* Update basic.e2e.ts

* Update
2024-11-11 19:57:57 +05:30
Anik Dhabal Babu ae9063804d fix: Users details leak for team booking when the team is private (#17403)
* fix: host details leaking

* type error

* update

* update

* Update getPublicEvent.ts

* Update getPublicEvent.ts
2024-11-11 19:55:42 +05:30
Calcom BotandReplexica 30f9d9b980 feat: update translations via @replexica (#17577)
Co-authored-by: Replexica <support@replexica.com>
2024-11-11 13:57:50 +00:00
Anik Dhabal Babu 42a264380a fix (#17548) 2024-11-11 09:37:14 +00:00
Hariom BalharaandUdit Takkar 62aaba32cf fix: Routing Form - Small Improvements (#17538)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-11 14:14:39 +05:30
Amit Sharmaandsean-brydon aaf630f01f feat: Delete attribute modal (#17557)
* feat: Delete attribute modal

* move Modal file

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-11-11 08:33:19 +00:00
Rajiv Sahal 838e8b9e89 fix: iframe issue for atoms documentation (#17570)
* fix syntax for code examples

* update iframe styling
2024-11-11 08:28:02 +00:00
Kunall Banerjee ce373c475c fix: incorrect capitalization in company names (#17573) 2024-11-10 08:42:29 +00:00
Omar López ed662b7956 chore: v4.6.20 (#17571)
release.yml / release (push) Canceled after 0s
2024-11-09 16:48:45 -07:00
Rajiv Sahal 2e49110ce6 fix: docs loom iframe fixes (#17569) 2024-11-09 08:41:55 +00:00
Rajiv Sahal fc35f9de85 fix: atoms docs (#17556)
* fixup

* resolve merge conflicts

* fixup fixup

* trying some fixups
2024-11-09 07:39:48 +00:00
Joe Au-Yeung 9c67ee7375 fix: pass upId to JWT if logging in through SAML (#17566)
* pass `upId` to JWT if logging in through SAML

* Correct change
2024-11-08 22:43:56 +00:00
Syed Ali Shahbaz a1477dc7e1 feat: adds attendee and host names, and guest emails in forwarded params (#17530)
* adds attendee and host names in forwarded params

* address feedback

* add guest emails
2024-11-08 22:33:22 +05:30
25d39b4273 feat: api v2 reassign (#17460)
* adding input types and method to controller/service

* add output types

* add outputs and inputs

* feat: reassign automatically

* feat: reassign to user

* e2e test for reassign

* update platform libraries + experimental modules for jest

* fix test

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2024-11-08 12:42:59 +00:00
sean-brydon 4976e24e12 fix: move team availability search to serverside (#17549)
* move search to severside

* transform zod stirng to lowercase
2024-11-08 15:32:08 +05:30
Rajiv Sahal 5ae6e1e983 fix: loom iframe for atoms (#17550) 2024-11-08 09:37:29 +00:00
Omar López 6080ce0c85 v4.6.19 (#17524)
release.yml / release (push) Canceled after 0s
2024-11-07 13:24:04 -07:00
Morgan 79f0754bf7 chore: add sentry performance tracking api-v2 (#17519)
* chore: add sentry performance tracking api-v2

* fixup! chore: add sentry performance tracking api-v2
2024-11-07 09:38:05 -03:00
Vijay 540bd478dc fix: issues in 'updateNewTeamMemberEventTypes' while adding new team member. (#17513)
* fix due to error in zod.parse while adding new member

* updated zod parsing

* fix the linking with parent

* update fixture to accomodate assignAllTeamMembers for team and org scenarios

* added e2e test

* nit
2024-11-07 10:33:25 +00:00
9873e373dc feat: reset RR every month (#17472)
* only fetch bookings of this month for weighted rr

* test set up

* only get bookings of current month

* reverts test setup

* first changes for weight adjustments

* reset booking count + adjust calibration

* depreciate weightAdjustment

* get only bookings created this month

* fix typo

* make sure createdAt for hosts is correct

* use earliest possibel date as fallback

* add missing createdAt date to tests

* fix typo

* clean up changes in tests

* fix typo

* change end date to current date

* fix: Fall back to empty host array when no hosts are found

* fix: Restructure code a little

* fixed test, incorrectly used now outdated var

* perf: remove Dayjs from getLuckyUser

* Refactor getHostsWithCalibration for optimised performance, intentionally break test as findMany is always an array

* Better mock for host.findMany

* Remove team-event-types.test.ts, move to appropriate package

* TypeScript cannot auto-infer that an array is non-empty when assigning to a var

* fix: Type Fixes and DistributionMethod enum add

* Optimise tests

* Added test to show that bookings made before a newHost was added affect the lucky user result

* Throw error when the usersWithHighestPriority is empty, which should never happen

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-11-07 10:24:23 +00:00
Morgan 52093862ce fix: booker timezone from preference instead of session (#17520)
* fix: booker timezone from preference instead of session

* fixup! fix: booker timezone from preference instead of session
2024-11-07 09:42:38 +00:00
Rajiv Sahal e101f35b4f fix: event types atom docs (#17528)
* fix syntax for code examples

* fixup
2024-11-07 07:59:29 +00:00
Benny Joo 54cc2dc094 chore: cache meeting OG images for 1 day (#17525) 2024-11-07 00:19:20 +00:00
Alex van Andel 88e57d48fe chore: Release v4.6.18 (#17521)
release.yml / release (push) Canceled after 0s
2024-11-06 20:39:39 +00:00
Morgan 6619701c88 fix: event-type atom tabs controlled by prop (#17480)
* fix: event-type atom tabs controlled by prop

* fixup! fix: event-type atom tabs controlled by prop
2024-11-06 20:38:29 +00:00
Alex van Andel ebb8bb9c67 fix: saml does not add future members to eventtype (#17522)
* fix: trigger createOrUpdateMemberships from dsync so SAML signup triggers side-effects

* forEach does not wait for async/await done-ness
2024-11-06 20:38:08 +00:00
Alex van Andel 993bf23986 fix: common schedule toggling incorrectly (#17517)
* fix: common schedule toggling incorrectly

* type fixes to util.ts

* fix another type error
2024-11-06 20:21:25 +00:00
Benny Joo 4b38d73120 chore: Replace prisma actions Credential Repository / SelectedCalendar Repository methods (#17443)
* create lib/google.ts

* add updateAvatar method to UserRepository

* refactor googlecalendar callback

* add methods to CredentialRepository and SelectedCalendarRepository

* refactor

* remove not needed code

* refactor

* make credential repository and selected calendar repository more generic

* create GoogleRepository
2024-11-06 14:48:45 -05:00
Joe Au-Yeung 8f92c9b884 fix: Logic fix in Salesforce no-show (#17516) 2024-11-06 16:49:31 +00:00
Joe Au-Yeung edecc7e60e feat: Round Robin Skip If contact owner first available slot is more than 2 weeks, round robin as normal [CAL-4651] (#17416)
* If contact owner first available slot is more than 2 weeks. Normal RR

* If contact owner first available slot is more than 2 weeks. Normal RR

* Type fix

* Add test
2024-11-06 15:51:36 +00:00
sean-brydon 4eefb08a0d fix: copy to clipboard button (#17512)
* fixes copy to clipboard button

* fix type error
2024-11-06 15:11:55 +00:00
Calcom BotandReplexica 3f7e632d5c feat: update translations (#17474)
Co-authored-by: Replexica <support@replexica.com>
2024-11-06 10:08:15 -03:00
Peer RichelsenandhbjORbj ef0fd67646 chore: Update SettingsLayout.tsx (#17498)
* chore: Update SettingsLayout.tsx

* Update settings layout app dir

---------

Co-authored-by: hbjORbj <sldisek783@gmail.com>
2024-11-06 09:34:30 -03:00
Hariom Balhara 8e7567b6a3 fix: Don't let the modal close accidentally atleast when loading (#17508)
* Dont close during loading state

* Dont allow accidental close when loader is running
2024-11-06 09:34:04 -03:00
Amit Sharma 193855d60f fix: routing form: dont disable routing and reporting on mobile and fix mobile layout instead (#17444) 2024-11-06 09:29:51 -03:00
Vincent Lam 92fafa7344 feat: team availability - vertical selector and align times (#17502)
* - aligns timezones offset by minutes and add minutes to the label in the teams availability view
- added a vertical guidebar to view alligned schedules

* fixed type-check issues
2024-11-06 10:37:21 +00:00
Somay Chauhan 21aaff01fa fix: disable all emails modal crash on platform (#17485) 2024-11-06 10:43:57 +02:00
e07a28a2d1 feat: Salesforce - on booking write to fields on person record [CAL-4575] (#17477)
* Create data type

* Start FE component

* Rename and add new enums

* Add new schema for write on record options

* Add options on the frontend

* Backend write to custom fields

* refactor: Salesforce - on booking write to fields on person record (#17481)

* Add translations

* fix: type err

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-11-06 08:20:52 +00:00
Benny Joo 9a1b5ad149 fix: broken social media preview and title (#17504)
* fix: broken social media preview and title

* remove code not needed
2024-11-06 13:28:35 +05:30
Benny Joo 1c20a257d3 fix: missing responsive design in org members page (#17495) 2024-11-05 17:26:41 +00:00
Keith Williams 656fcafcde chore: v4.6.17 (#17493)
release.yml / release (push) Canceled after 0s
2024-11-05 14:05:23 -03:00
Nizzy 6d7080d9fc posthog vars (#17494) 2024-11-05 16:37:00 +00:00
Rajiv Sahal f943debda2 feat: docs for event type atom (#17466)
* init docs for event types atom

* images for event type atom

* update images for destination and selected calendar atoms

* update event types docs
2024-11-05 13:32:45 -03:00
Benny Joo 803fc488c5 feat: Add feature to download routing form reports (#17400)
* remove unused code

* add i18n for some strings in widget

* improve UI and add download button to routing forms reporting page

* add more margin

* add downloadAsCsv util

* integrate download button to routing forms report

* refactor

* address comment

* escape comma multiselect items

* fix

* preserve new lines and wrapping

* refactor

* ensure fetching all rows
2024-11-05 11:21:13 -05:00
Rajiv Sahal 423b284278 feat: docs for payment form atom (#17360)
* init docs for stripe payment form

* update payment form page

* fixup
2024-11-05 15:48:51 +00:00
sean-brydon f1111c9d42 show who a RR was reassigned to on auto (#17454) 2024-11-05 12:44:49 -03:00
Keith Williams ed6d88e30f perf: Allow bypassing 3rd party calendar busy times (#17492)
* perf: Allow bypassing 3rd party calendar busy times

* Fix typing issues

* Fixed API v1 build

* Another fix for API v1
2024-11-05 12:31:33 -03:00
Morgan e675ad0c6c fix: assign/remove team members to team event-types (#17483)
* fix: assign/remove team members to team event-types

* fixup! fix: assign/remove team members to team event-types

* fixup! fixup! fix: assign/remove team members to team event-types

* fixup! fixup! fixup! fix: assign/remove team members to team event-types
2024-11-05 17:19:43 +02:00
Udit Takkar a01d6aa932 feat: instant meeting modal improvements (#17489)
* feat: instant meeting modal improvements

* fix: type error
2024-11-05 09:02:30 -05:00
Lauris Skraucis f868b86e6c fix: creating recurring seated bookings for same timeslot (#17482)
* fix: creating multiple seated bookings for same timeslot

* add test
2024-11-05 15:38:21 +02:00
Peer Richelsen db0a7f3797 chore: Update build-an-app.mdx (#17486) 2024-11-05 13:38:07 +00:00
Morgan 5646229cd4 fix: cancelling invalid booking seat apiv2 (#17484) 2024-11-05 12:30:38 +01:00
Anik Dhabal Babu f39e817d86 fix: Update the common schedule toggle check logic (#17439) 2024-11-05 05:10:12 +00:00
Benny Joo 42c4c134fa chore: add missing i18n to metadata in settings/platform pages (#17476) 2024-11-05 02:37:12 +00:00
Benny Joo ffc4b69edd fix: api/routing-forms/responses (#17473)
* add i18n

* fix api/routing-forms/responses
2024-11-04 13:19:58 -06:00
Benny Joo 7263f3b00b chore: settings/platform, settings/license-key - remove pages router and use app router (#17384)
* remove pages router and use app router

* remove pages router and use app router for platform

* add billing page and plans page

* fix metadata for settings/platform/billing

* fix metadata for settings/platform/oauth-clients
2024-11-04 15:56:43 -03:00
ddfbe25903 feat: adding posthog libs for node + js (#16862)
* adding posthog libs for node + js

* updating lock

* move to init posthog client - pageview component

* adding identify in shell

* removing test event captures

* add signout tracking to dropdown as it was missed in merge

---------

Co-authored-by: sean <sean@brydon.io>
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2024-11-04 11:46:25 -05:00
Udit Takkar dd944bace1 chore: add webhook in payload (#17468)
* chore: add webhook in payload

* chore

* chore: add more fields

* add host email

* chore: change name
2024-11-04 16:12:34 +00:00
Keith Williams e4353af839 chore: v4.6.16 (#17465)
release.yml / release (push) Canceled after 0s
2024-11-04 10:57:04 -03:00
Keith Williams ff9936fdeb perf: Remove extra spans (#17464) 2024-11-04 13:50:49 +00:00
Benny Joo e74718ac66 fix: ensure fetching all members before exporting csv (#17461) 2024-11-04 11:35:37 +00:00
Udit Takkar a78f10223f chore: remove no option text (#17457) 2024-11-04 09:47:36 +00:00
Benny Joo c850af7409 feat: allow users to generate csv tables for org members (#17458)
* add downloadAsCsv util

* add sanitizeValue util

* write logic

* refactor

* add tests
2024-11-04 09:47:10 +00:00
Udit Takkar 06dccd7a73 fix: filter by team url (#17456) 2024-11-04 09:45:14 +00:00
Rajiv Sahal 3b59185f75 fix typo (#17451) 2024-11-04 08:39:42 +00:00
Morgan c907bcc646 fix: docs mdx syntax errors (#17450)
* fix: docs mdx syntax errors

* fixup! fix: docs mdx syntax errors
2024-11-04 08:20:33 +00:00
Keith Williams 6c5b12db57 perf: Add more specific spans to getBusyTimes (#17437)
* perf: Add more specific spans to getBusyTimes

* fix types
2024-11-03 12:21:35 -03:00
Benny Joo bcc1a8e279 fix: create a wrapper for google calendar fetching (#17440)
* fix: create a wrapper for google cal fetching

* improve log
2024-11-01 20:22:57 -03:00
Benny Joo a277b8515f fix (#17441) 2024-11-01 20:51:02 +00:00
Benny Joo 42a02cbb60 fix: update profile photo logic in google cal integration (#17438) 2024-11-01 16:21:42 -04:00
sean-brydon 0c60ad1c82 fix: no availability email frequency fix (#17430)
* pass teamID to only send to relevant team admins not org admins

* a lot more tests aroundthis

* improve test cases

* change reddis cache key to take into account teamID

* add start/endtime to template

* fix text in email

* add endTime to tests
2024-11-01 14:54:34 -04:00
Keith Williams 1add8e73ee Revert "perf: Cache app store imports (#17425)" (#17428)
This reverts commit b2d395de52.
2024-10-31 19:26:55 +00:00
Alex van Andel ff15ec77c3 chore: Release v4.6.15 (#17422)
release.yml / release (push) Canceled after 0s
2024-10-31 18:20:08 +00:00
Keith Williams b2d395de52 perf: Cache app store imports (#17425) 2024-10-31 18:19:07 +00:00
Joe Au-Yeung 9dd35c7ab3 feat: Salesforce only get active users (#17426)
* Add isActive boolean when querying owners

* Add conditional
2024-10-31 18:18:35 +00:00
Calcom BotandReplexica bd30bf335b feat: update translations (#17421)
Co-authored-by: Replexica <support@replexica.com>
2024-10-31 17:56:22 +00:00
Hariom BalharaandCarinaWolli ab65ce73c2 feat: Support Attribute Logic fallback (#17290)
* Add fallback

* Support attribute query fallback

* Refactor

* Add tests and cleanup SingleFofrm

* small text fixes

* With fallback in picture, we dont throw error in preview now instead we capture errors and show them gracefully

* Get attribute logic preview working without saving Fixes CAL-4582

* Abstract useRoutes out

* Update e2e

* Dont define Page component again and again

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-10-31 12:53:34 -04:00
Joe Au-Yeung 06f83e6d0d feat: Send Attendee No Show Data To Salesforce (#17423) 2024-10-31 16:39:58 +00:00
Udit Takkarandsean ebd5ca6ceb fix: people filter in /bookings and create OOO modal (#17396)
* fix: people filter in /bookings

* refactor: lazy loading people filters and OOO

* chore: type errr

* chore: type errr

* fallback to username if no name is set

---------

Co-authored-by: sean <sean@brydon.io>
2024-10-31 14:57:07 +00:00
Benny Joo bcaf067ed6 fix: routing-forms reporting - "in" operator logic is broken + overall refactor (#17398)
* fix: routing-forms reporting - "in" operator logic is broken

* fix

* refactor to make it more readable

* add tests
2024-10-31 14:09:10 +00:00
Morgan 120cfed8a1 fix: booker platform atom multi duration selector (#17419) 2024-10-31 13:14:58 +00:00
sean-brydonandPeer Richelsen e728ed74ce fix: add reassignedBy and reassign reason to both status (#17418)
* add reassignedBy and reassign reason to both status

* fix w-full issue in dialog

* fix types

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-10-31 12:32:05 +00:00
Keith Williams 64551a506a perf: Allow configuring Sentry:debug (#17420) 2024-10-31 12:15:07 +00:00
fee3b73bda feat: payment card atom (#17203)
* init stripe payment card atom

* cleanup

* update platform libraries

* fix import paths

* add function to get user payment info

* fixup

* resolve merge conflicts

* fixup fixup

* resolve merge conflicts

* fixup

* update payments page

* add props for event payment success and failure

* update props and fixup

* bump platform libraries

* fixup! bump platform libraries

* fixup! fixup! bump platform libraries

* resolve merge conflicts

* fix: add isActive to paymentsTab

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-10-31 11:21:13 +02:00
Carina WollendorferandCarinaWolli a5806cb100 fix: buffers in handleNewBooking (#17415)
* send missing buffer params

* add tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-10-31 08:48:36 +00:00
Anik Dhabal Babu f649f8b78d fix: huddle01 is not generating meeting link (#17417)
* fix: huddle01 not generating link

* update
2024-10-31 04:59:34 +00:00
Keith Williams ced7f282a5 perf: Reduce spans in highPerf temporarily (#17413)
* perf: Reduce spans in highPerf temporarily

* Just completely removed them
2024-10-30 16:15:51 +00:00
Keith Williams dfa4e10c83 perf: Add ability to configure Sentry maxSpans (#17411) 2024-10-30 13:54:22 +00:00
c3f03c8000 fix: stripe connect atom for teams (#17294)
* add teamId

* add team app credentials method

* add handler to get all user admin teams

* update stripe logic to handle teams

* enable teamId for stripe connect

* update stripe module

* implement PR feedback

* update stripe module

* add method to get credential by team id

* handle check for teamId

* abstract team and individual stripe connection checks into two separate parts

* hide badge for platform user

* update typing

* add logic to handle team stripe integration

* bump platform libraries

* bump platform libraries

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-10-30 15:28:26 +02:00
Benny Joo aaa5531a3a fix: main google calendar not being connected (#17404) 2024-10-30 11:58:44 +00:00
Morgan 2784564427 Revert "chore: ensure idempotency cancel booking api v2" (#17407)
This reverts commit 85246f0754.
2024-10-30 11:37:17 +00:00
Keith Williams 4b6b9badf5 perf: Sentry perf tracing (#17202)
* chore: Add Sentry perf tracing

* Just to build

* perf: Add Sentry perf tracing

* Removed the extra integrations tracing config

* Got spans working

* Now only tracing individual endpoints

* Removed check for booking event type

* Fixed types

* Added back for spans

* Consolidate sentry imports

* Added more traces

* Moved the start of the span
2024-10-30 00:35:25 +00:00
550 changed files with 39779 additions and 13173 deletions
+19 -3
View File
@@ -16,7 +16,7 @@
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
#
# To enable enterprise-only features please add your environment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
# To enable enterprise-only features please add your environment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
CALCOM_LICENSE_KEY=
# Signature token for the Cal.com License API (used for self-hosted integrations)
# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key
@@ -102,6 +102,11 @@ NEXT_PUBLIC_INTERCOM_APP_ID=
# Secret to enable Intercom Identity Verification
INTERCOM_SECRET=
# Posthog Config
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=
# Zendesk Config
NEXT_PUBLIC_ZENDESK_KEY=
@@ -124,6 +129,10 @@ GOOGLE_LOGIN_ENABLED=false
# Needed to enable Google Calendar integration and Login with Google
# @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials
GOOGLE_API_CREDENTIALS=
# Token to verify incoming webhooks from Google Calendar
GOOGLE_WEBHOOK_TOKEN=
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
GOOGLE_WEBHOOK_URL=
# Inbox to send user feedback
SEND_FEEDBACK_EMAIL=
@@ -137,6 +146,9 @@ NEXT_PUBLIC_SENDGRID_SENDER_NAME=
# Sentry
# Used for capturing exceptions and logging messages
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_DEBUG=
SENTRY_MAX_SPANS=
SENTRY_TRACES_SAMPLE_RATE=
# Formbricks Experience Management Integration
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
@@ -334,8 +346,6 @@ AB_TEST_BUCKET_PROBABILITY=50
# whether we redirect to the future/event-types from event-types or not
APP_ROUTER_EVENT_TYPES_ENABLED=0
APP_ROUTER_SETTINGS_DEVELOPER_ENABLED=0
APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED=0
APP_ROUTER_SETTINGS_PLATFORM_ENABLED=0
APP_ROUTER_SETTINGS_ORG_ENABLED=0
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
APP_ROUTER_APPS_SLUG_ENABLED=0
@@ -384,6 +394,9 @@ UNKEY_ROOT_KEY=
# https://retellai.com
RETELL_AI_KEY=
# Used for the huddle01 integration
HUDDLE01_API_TOKEN=
# Used to disallow emails as being added as guests on bookings
BLACKLISTED_GUEST_EMAILS=
@@ -399,3 +412,6 @@ NEXT_PUBLIC_WEBSITE_TERMS_URL=
# NEXT_PUBLIC_LOGGER_LEVEL=3 sets to log info, warn, error and fatal logs.
# [0: silly & upwards, 1: trace & upwards, 2: debug & upwards, 3: info & upwards, 4: warn & upwards, 5: error & fatal, 6: fatal]
NEXT_PUBLIC_LOGGER_LEVEL=
# Used to use Replexica SDK, a tool for real-time AI-powered localization
REPLEXICA_API_KEY=
-1
View File
@@ -65,7 +65,6 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/yarn-playwright-install
- uses: ./.github/actions/cache-db
- name: Run Tests
working-directory: apps/api/v2
@@ -199,6 +199,7 @@ async function handler(req: NextApiRequest) {
eventTypeId,
userId,
returnDateOverrides: true,
bypassBusyCalendarTimes: false,
});
const team = await prisma.team.findUnique({
where: { id: teamId },
@@ -235,6 +236,7 @@ async function handler(req: NextApiRequest) {
dateTo,
eventTypeId,
returnDateOverrides: true,
bypassBusyCalendarTimes: false,
}),
};
});
@@ -81,7 +81,7 @@ describe("retrieveScopedAccessibleUsers tests", () => {
where: { email: "member2-acme@example.com" },
});
expect(accessibleUserIds.length).toBe(3);
expect(accessibleUserIds.length).toBe(11);
expect(accessibleUserIds).toContain(memberOneUser.id);
expect(accessibleUserIds).toContain(memberTwoUser.id);
expect(accessibleUserIds).toContain(adminUser.id);
+4
View File
@@ -28,3 +28,7 @@ API_KEY_PREFIX=cal_
GET_LICENSE_KEY_URL="https://console.cal.com/api/license"
IS_E2E=false
DOCS_URL=
# Axiom logging
AXIOM_DATASET=
AXIOM_TOKEN=
+7 -3
View File
@@ -19,16 +19,17 @@
"test:watch": "yarn dev:build && jest --watch",
"test:cov": "yarn dev:build && jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192' jest --ci --forceExit --config ./jest-e2e.json",
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.json",
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch",
"prisma": "yarn workspace @calcom/prisma prisma",
"generate-schemas": "yarn prisma generate && yarn prisma format",
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
},
"dependencies": {
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.54",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.61",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -45,7 +46,9 @@
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "6.2.1",
"@sentry/nestjs": "^8.37.1",
"@sentry/node": "^8.8.0",
"@sentry/profiling-node": "^8.37.1",
"body-parser": "^1.20.2",
"bull": "^4.12.4",
"class-transformer": "^0.5.1",
@@ -67,7 +70,8 @@
"rxjs": "^7.8.1",
"stripe": "^15.3.0",
"uuid": "^8.3.2",
"winston": "^3.11.0",
"winston": "^3.13.0",
"winston-transport": "^4.7.0",
"zod": "^3.22.4"
},
"devDependencies": {
+7 -1
View File
@@ -17,13 +17,15 @@ import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis"
import { BullModule } from "@nestjs/bull";
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from "@nestjs/core";
import { seconds, ThrottlerModule } from "@nestjs/throttler";
import { SentryModule, SentryGlobalFilter } from "@sentry/nestjs/setup";
import { AppController } from "./app.controller";
@Module({
imports: [
SentryModule.forRoot(),
ConfigModule.forRoot({
ignoreEnvFile: true,
isGlobal: true,
@@ -59,6 +61,10 @@ import { AppController } from "./app.controller";
],
controllers: [AppController],
providers: [
{
provide: APP_FILTER,
useClass: SentryGlobalFilter,
},
{
provide: ThrottlerStorageRedisService,
useFactory: (redisService: RedisService) => {
-4
View File
@@ -7,7 +7,6 @@ import type { ValidationError } from "@nestjs/common";
import { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common";
import { BaseExceptionFilter, HttpAdapterHost } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import * as Sentry from "@sentry/node";
import * as cookieParser from "cookie-parser";
import { Request } from "express";
import helmet from "helmet";
@@ -71,9 +70,6 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
// Exception filters, new filters go at the bottom, keep the order
const { httpAdapter } = app.get(HttpAdapterHost);
if (process.env.SENTRY_DSN) {
Sentry.setupNestErrorHandler(app, new BaseExceptionFilter(httpAdapter));
}
app.useGlobalFilters(new PrismaExceptionFilter());
app.useGlobalFilters(new ZodExceptionFilter());
app.useGlobalFilters(new HttpExceptionFilter());
@@ -7,6 +7,7 @@ import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_20
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { BillingModule } from "@/modules/billing/billing.module";
import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -27,6 +28,7 @@ import { Module } from "@nestjs/common";
OutputBookingsService_2024_08_13,
BookingsRepository_2024_08_13,
EventTypesRepository_2024_06_14,
BookingSeatRepository,
ApiKeyRepository,
],
controllers: [BookingsController_2024_08_13],
@@ -56,6 +56,17 @@ export class BookingsRepository_2024_08_13 {
});
}
async getByUidWithUser(bookingUid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
uid: bookingUid,
},
include: {
user: true,
},
});
}
async getByIdWithAttendeesAndUserAndEvent(id: number) {
return this.dbRead.prisma.booking.findUnique({
where: {
@@ -2,6 +2,7 @@ import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.gua
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { VERSION_2024_08_13_VALUE } from "@/lib/api-versions";
@@ -9,6 +10,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Post,
@@ -46,12 +48,12 @@ import {
CreateBookingInputPipe,
CreateBookingInput,
GetBookingsInput_2024_08_13,
RescheduleBookingInput_2024_08_13,
CancelBookingInput_2024_08_13,
ReassignToUserBookingInput_2024_08_13,
MarkAbsentBookingInput_2024_08_13,
CreateBookingInput_2024_08_13,
CreateInstantBookingInput_2024_08_13,
CreateRecurringBookingInput_2024_08_13,
DeclineBookingInput_2024_08_13,
} from "@calcom/platform-types";
@Controller({
@@ -189,22 +191,18 @@ export class BookingsController_2024_08_13 {
@Post("/:bookingUid/cancel")
@UseGuards(BookingUidGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Cancel a booking" })
@ApiOperation({
summary: "Cancel a booking",
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.`,
})
async cancelBooking(
@Req() request: Request,
@Param("bookingUid") bookingUid: string,
@Body(new CancelBookingInputPipe())
body: CancelBookingInput
): Promise<CancelBookingOutput_2024_08_13> {
// TODO: implement Idempotency middleware
const alreadyCancelledBooking = await this.bookingsService.getBooking(bookingUid);
if (!Array.isArray(alreadyCancelledBooking) && alreadyCancelledBooking.status === "cancelled") {
return {
status: SUCCESS_STATUS,
data: alreadyCancelledBooking,
};
}
const cancelledBooking = await this.bookingsService.cancelBooking(request, bookingUid, body);
return {
@@ -236,4 +234,104 @@ export class BookingsController_2024_08_13 {
data: booking,
};
}
@Post("/:bookingUid/reassign")
@HttpCode(HttpStatus.OK)
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
@ApiOperation({ summary: "Automatically reassign booking to a new host" })
async reassignBooking(
@Param("bookingUid") bookingUid: string,
@GetUser() user: UserWithProfile
): Promise<ReassignBookingOutput_2024_08_13> {
const booking = await this.bookingsService.reassignBooking(bookingUid, user);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Post("/:bookingUid/reassign/:userId")
@HttpCode(HttpStatus.OK)
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
@ApiOperation({ summary: "Reassign a booking to a specific user" })
async reassignBookingToUser(
@Param("bookingUid") bookingUid: string,
@Param("userId") userId: number,
@GetUser("id") reassignedById: number,
@Body() body: ReassignToUserBookingInput_2024_08_13
): Promise<ReassignBookingOutput_2024_08_13> {
const booking = await this.bookingsService.reassignBookingToUser(
bookingUid,
userId,
reassignedById,
body
);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Post("/:bookingUid/confirm")
@HttpCode(HttpStatus.OK)
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
@ApiOperation({ summary: "Confirm booking that requires a confirmation" })
async confirmBooking(
@Param("bookingUid") bookingUid: string,
@GetUser() user: UserWithProfile
): Promise<GetBookingOutput_2024_08_13> {
const booking = await this.bookingsService.confirmBooking(bookingUid, user);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Post("/:bookingUid/decline")
@HttpCode(HttpStatus.OK)
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
@ApiOperation({ summary: "Decline booking that requires a confirmation" })
async declineBooking(
@Param("bookingUid") bookingUid: string,
@Body() body: DeclineBookingInput_2024_08_13,
@GetUser() user: UserWithProfile
): Promise<GetBookingOutput_2024_08_13> {
const booking = await this.bookingsService.declineBooking(bookingUid, user, body.reason);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
}
@@ -0,0 +1,262 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
AttendeeScheduledEmail,
OrganizerScheduledEmail,
AttendeeDeclinedEmail,
} from "@calcom/platform-libraries";
import { BookingOutput_2024_08_13, GetBookingOutput_2024_08_13 } from "@calcom/platform-types";
import { Booking, PlatformOAuthClient, Team } from "@calcom/prisma/client";
jest.spyOn(AttendeeScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
jest.spyOn(OrganizerScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
jest.spyOn(AttendeeDeclinedEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
describe("Bookings Endpoints 2024-08-13", () => {
describe("Bookings confirmation", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
let eventTypeId: number;
const eventTypeSlug = "peer-coding";
let createdBooking1: Booking;
let createdBooking2: Booking;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const event = await eventTypesRepositoryFixture.create(
{ title: "peer coding", slug: eventTypeSlug, length: 60 },
user.id
);
eventTypeId = event.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
});
it("should confirm a booking", async () => {
const status = "PENDING";
createdBooking1 = await bookingsRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 7, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 7, 14, 0, 0)),
title: "peer coding",
uid: "peer-coding-one",
eventType: {
connect: {
id: eventTypeId,
},
},
location: "via 10, rome, italy",
customInputs: {},
metadata: {},
responses: {
name: "Bob",
email: "bob@gmail.com",
},
attendees: {
create: {
email: "bob@gmail.com",
name: "Bob",
locale: "it",
timeZone: "Europe/Rome",
},
},
status,
});
expect(createdBooking1.status).toEqual("PENDING");
return request(app.getHttpServer())
.post(`/v2/bookings/${createdBooking1.uid}/confirm`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_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.uid).toBeDefined();
expect(data.status).toEqual("accepted");
const dbBooking = await bookingsRepositoryFixture.getByUid(data.uid);
expect(dbBooking?.status).toEqual("ACCEPTED");
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should decline a booking", async () => {
const status = "PENDING";
createdBooking2 = await bookingsRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 7, 10, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 7, 11, 0, 0)),
title: "peer coding",
uid: "peer-coding-two",
eventType: {
connect: {
id: eventTypeId,
},
},
location: "via 10, rome, italy",
customInputs: {},
metadata: {},
responses: {
name: "Bob",
email: "bob@gmail.com",
},
attendees: {
create: {
email: "bob@gmail.com",
name: "Bob",
locale: "it",
timeZone: "Europe/Rome",
},
},
status,
});
expect(createdBooking2.status).toEqual("PENDING");
return request(app.getHttpServer())
.post(`/v2/bookings/${createdBooking2.uid}/decline`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_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.uid).toBeDefined();
expect(data.status).toEqual("rejected");
const dbBooking = await bookingsRepositoryFixture.getByUid(data.uid);
expect(dbBooking?.status).toEqual("REJECTED");
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
});
@@ -0,0 +1,331 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { Booking, User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("Reassign bookings", () => {
let app: INestApplication;
let organization: Team;
let team: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let hostsRepositoryFixture: HostsRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
const teamUserEmail = "orgUser1team1@api.com";
const teamUserEmail2 = "orgUser2team1@api.com";
let teamUser1: User;
let teamUser2: User;
let teamRoundRobinEventTypeId: number;
let roundRobinBooking: Booking;
beforeAll(async () => {
const moduleRef = await withApiAuth(
teamUserEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({ name: "organization team bookings" });
team = await teamRepositoryFixture.create({
name: "team 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
});
oAuthClient = await createOAuthClient(organization.id);
teamUser1 = await userRepositoryFixture.create({
email: teamUserEmail,
locale: "it",
name: "orgUser1team1",
});
teamUser2 = await userRepositoryFixture.create({
email: teamUserEmail2,
locale: "it",
name: "orgUser2team1",
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(teamUser1.id, userSchedule);
await schedulesService.createUserSchedule(teamUser2.id, userSchedule);
await profileRepositoryFixture.create({
uid: `usr-${teamUser1.id}`,
username: teamUserEmail,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: teamUser1.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${teamUser2.id}`,
username: teamUserEmail2,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: teamUser2.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
team: { connect: { id: team.id } },
user: { connect: { id: teamUser1.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
team: { connect: { id: team.id } },
user: { connect: { id: teamUser2.id } },
accepted: true,
});
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
users: {
connect: [{ id: teamUser1.id }, { id: teamUser2.id }],
},
title: "Round Robin Event Type",
slug: "round-robin-event-type",
length: 60,
assignAllTeamMembers: false,
bookingFields: [],
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
});
teamRoundRobinEventTypeId = team1EventType.id;
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: teamUser1.id,
},
},
eventType: {
connect: {
id: team1EventType.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: teamUser2.id,
},
},
eventType: {
connect: {
id: team1EventType.id,
},
},
});
roundRobinBooking = await bookingsRepositoryFixture.create({
user: {
connect: {
id: teamUser1.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 7, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 7, 14, 0, 0)),
title: "round robin coding lets goo",
uid: "round-robin-coding",
eventType: {
connect: {
id: teamRoundRobinEventTypeId,
},
},
location: "via 10, rome, italy",
customInputs: {},
metadata: {},
responses: {
name: "Bob",
email: "bob@gmail.com",
},
attendees: {
create: {
email: "bob@gmail.com",
name: "Bob",
locale: "it",
timeZone: "Europe/Rome",
},
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should reassign round robin booking", async () => {
const booking = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
expect(booking?.userId).toEqual(teamUser1.id);
return request(app.getHttpServer())
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
expect(data.bookingUid).toEqual(roundRobinBooking.uid);
expect(data.reassignedTo).toEqual({
id: teamUser2.id,
name: teamUser2.name,
email: teamUser2.email,
});
const reassigned = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
expect(reassigned?.userId).toEqual(teamUser2.id);
});
});
it("should reassign round robin booking to a specific user", async () => {
const booking = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
expect(booking?.userId).toEqual(teamUser2.id);
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: teamRoundRobinEventTypeId,
attendee: {
name: "alice",
email: "alice@gmail.com",
timeZone: "Europe/Madrid",
language: "es",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser1.id}`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
expect(data.bookingUid).toEqual(roundRobinBooking.uid);
expect(data.reassignedTo).toEqual({
id: teamUser1.id,
name: teamUser1.name,
email: teamUser1.email,
});
const reassigned = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
expect(reassigned?.userId).toEqual(teamUser1.id);
});
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(teamUser1.email);
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
await bookingsRepositoryFixture.deleteAllBookings(teamUser1.id, teamUser1.email);
await bookingsRepositoryFixture.deleteAllBookings(teamUser2.id, teamUser2.email);
await app.close();
});
});
});
@@ -0,0 +1,691 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import { AttendeeCancelledEmail, OrganizerCancelledEmail } from "@calcom/platform-libraries";
import {
CreateRecurringBookingInput_2024_08_13,
RecurringBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("Creating recurring bookings", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
const maxRecurrenceCount = 3;
let recurringEventTypeId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const recurringEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: "peer-coding-recurring",
length: 60,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
},
user.id
);
recurringEventTypeId = recurringEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should not create recurring booking with recurrenceCount larger than event type recurrence count", async () => {
const recurrenceCount = 1000;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should create a recurring booking with recurrenceCount smaller than event type recurrence count", async () => {
const recurrenceCount = maxRecurrenceCount - 1;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount - 1);
const firstBooking = data[0];
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.recurringBookingUid).toBeDefined();
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(secondBooking.recurringBookingUid).toBeDefined();
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should create a recurring booking with recurrenceCount equal to event type recurrence count", async () => {
const recurrenceCount = maxRecurrenceCount;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount);
const firstBooking = data[0];
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.meetingUrl).toEqual(body.location);
expect(firstBooking.recurringBookingUid).toBeDefined();
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(secondBooking.recurringBookingUid).toBeDefined();
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
const thirdBooking = data[2];
expect(thirdBooking.id).toBeDefined();
expect(thirdBooking.uid).toBeDefined();
expect(thirdBooking.hosts[0].id).toEqual(user.id);
expect(thirdBooking.status).toEqual("accepted");
expect(thirdBooking.start).toEqual(new Date(Date.UTC(2030, 1, 18, 13, 0, 0)).toISOString());
expect(thirdBooking.end).toEqual(new Date(Date.UTC(2030, 1, 18, 14, 0, 0)).toISOString());
expect(thirdBooking.duration).toEqual(60);
expect(thirdBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(thirdBooking.recurringBookingUid).toBeDefined();
expect(thirdBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(thirdBooking.location).toEqual(body.location);
expect(thirdBooking.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
afterEach(async () => {
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
describe("Recurring bookings cancel all subsequent bookings", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
const maxRecurrenceCount = 4;
let recurringEventTypeId: number;
let recurringBooking: RecurringBookingOutput_2024_08_13[];
beforeAll(async () => {
jest.spyOn(AttendeeCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
jest.spyOn(OrganizerCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const recurringEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: "peer-coding-recurring",
length: 60,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
},
user.id
);
recurringEventTypeId = recurringEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should create a recurring booking", async () => {
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount);
recurringBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should cancel all recurrences after a specific recurrence", async () => {
const recurringBookingUid = recurringBooking[2].recurringBookingUid;
const bookings = await bookingsRepositoryFixture.getByRecurringBookingUid(recurringBookingUid);
for (const booking of bookings) {
expect(booking.status).toEqual("ACCEPTED");
}
// note(Lauris): cancel all recurrences after and including third recurrence in the series of 4 recurrences
// aka cancel 3rd and 4th recurrences.
const thirdRecurrenceUid = recurringBooking[2].uid;
const fourthRecurringUid = recurringBooking[3].uid;
const body: CancelBookingInput_2024_08_13 = {
cancelSubsequentBookings: true,
};
return request(app.getHttpServer())
.post(`/v2/bookings/${thirdRecurrenceUid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(4);
const firstRecurrence = data.find((booking) => booking.uid === recurringBooking[0].uid);
expect(firstRecurrence).toBeDefined();
expect(firstRecurrence?.status).toEqual("accepted");
const secondRecurrence = data.find((booking) => booking.uid === recurringBooking[1].uid);
expect(secondRecurrence).toBeDefined();
expect(secondRecurrence?.status).toEqual("accepted");
const thirdRecurrence = data.find((booking) => booking.uid === thirdRecurrenceUid);
expect(thirdRecurrence).toBeDefined();
expect(thirdRecurrence?.status).toEqual("cancelled");
const fourthRecurrence = data.find((booking) => booking.uid === fourthRecurringUid);
expect(fourthRecurrence).toBeDefined();
expect(fourthRecurrence?.status).toEqual("cancelled");
const bookings = await bookingsRepositoryFixture.getByRecurringBookingUid(recurringBookingUid);
const bookingFirst = bookings.find((booking) => booking.uid === recurringBooking[0].uid);
expect(bookingFirst?.status).toEqual("ACCEPTED");
const bookingSecond = bookings.find((booking) => booking.uid === recurringBooking[1].uid);
expect(bookingSecond?.status).toEqual("ACCEPTED");
const bookingThird = bookings.find((booking) => booking.uid === recurringBooking[2].uid);
expect(bookingThird?.status).toEqual("CANCELLED");
const bookingFourth = bookings.find((booking) => booking.uid === recurringBooking[3].uid);
expect(bookingFourth?.status).toEqual("CANCELLED");
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
describe("Recurring bookings cancel all remaining bookings (bookings with start time greater than this moment)", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
const maxRecurrenceCount = 4;
let recurringEventTypeId: number;
let recurringBooking: RecurringBookingOutput_2024_08_13[];
beforeAll(async () => {
jest.spyOn(AttendeeCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
jest.spyOn(OrganizerCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const recurringEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: "peer-coding-recurring",
length: 60,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
},
user.id
);
recurringEventTypeId = recurringEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should create a recurring booking", async () => {
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 1, 3, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount);
recurringBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should cancel all remaning recurrences", async () => {
const recurringBookingUid = recurringBooking[0].recurringBookingUid;
return request(app.getHttpServer())
.post(`/v2/bookings/${recurringBookingUid}/cancel`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(4);
for (const booking of data) {
expect(booking.status).toEqual("cancelled");
}
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -614,45 +614,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
it("should return already cancelled seated booking (idempotency)", async () => {
const body: CancelSeatedBookingInput_2024_08_13 = {
seatUid: createdSeatedBooking.seatUid,
};
return request(app.getHttpServer())
.post(`/v2/bookings/${createdSeatedBooking.uid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsGetSeatedBooking(responseBody.data)) {
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("cancelled");
expect(data.start).toEqual(createdSeatedBooking.start);
expect(data.end).toEqual(createdSeatedBooking.end);
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(seatedEventTypeId);
expect(data.eventType).toEqual({
id: seatedEventTypeId,
slug: seatedTventTypeSlug,
});
expect(data.attendees.length).toEqual(0);
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
} else {
throw new Error("Invalid response data - expected booking but received array response");
}
});
});
function responseDataIsCreateSeatedBooking(data: any): data is CreateSeatedBookingOutput_2024_08_13 {
return data.hasOwnProperty("seatUid");
}
@@ -681,4 +642,374 @@ describe("Bookings Endpoints 2024-08-13", () => {
await app.close();
});
});
describe("Recurring seated bookings creation", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
const userEmail = "seated-bookings-controller-e2e@api.com";
let user: User;
const seatedTventTypeSlug = "peer-coding-seated";
const recurringSeatedTventTypeSlug = "peer-coding-recurring-seated";
let seatedEventTypeId: number;
let recurringSeatedEventTypeId: number;
const maxRecurrenceCount = 3;
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[];
const emailAttendeeOne = "mr_proper_first@gmail.com";
const nameAttendeeOne = "Mr Proper First";
const emailAttendeeTwo = "mr_proper_second@gmail.com";
const nameAttendeeTwo = "Mr Proper Second";
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const seatedEvent = await eventTypesRepositoryFixture.create(
{
title: "peer coding",
slug: seatedTventTypeSlug,
length: 60,
seatsPerTimeSlot: 5,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
},
user.id
);
seatedEventTypeId = seatedEvent.id;
const recurringSeatedEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: recurringSeatedTventTypeSlug,
length: 60,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
seatsPerTimeSlot: 5,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
},
user.id
);
recurringSeatedEventTypeId = recurringSeatedEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should book a recurring event type with seats for the first time", async () => {
const recurrenceCount = 2;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
eventTypeId: recurringSeatedEventTypeId,
attendee: {
name: nameAttendeeOne,
email: emailAttendeeOne,
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(recurrenceCount);
const firstBooking = data[0];
expect(firstBooking.seatUid).toBeDefined();
const seatUid = firstBooking.seatUid;
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(body.start);
expect(firstBooking.end).toEqual(
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
expect(firstBooking.eventType).toEqual({
id: recurringSeatedEventTypeId,
slug: recurringSeatedTventTypeSlug,
});
expect(firstBooking.attendees.length).toEqual(1);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid,
bookingFieldsResponses: {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.seatUid).toBeDefined();
const secondSeatUid = secondBooking.seatUid;
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
expect(secondBooking.start).toEqual(expectedStart);
expect(secondBooking.end).toEqual(
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
expect(secondBooking.eventType).toEqual({
id: recurringSeatedEventTypeId,
slug: recurringSeatedTventTypeSlug,
});
expect(secondBooking.attendees.length).toEqual(1);
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: secondSeatUid,
bookingFieldsResponses: {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
createdRecurringSeatedBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should book a recurring event type with seats for the second time", async () => {
const recurrenceCount = 2;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
eventTypeId: recurringSeatedEventTypeId,
attendee: {
name: nameAttendeeTwo,
email: emailAttendeeTwo,
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(recurrenceCount);
const firstBooking = data[0];
expect(firstBooking.seatUid).toBeDefined();
const seatUid = firstBooking.seatUid;
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(body.start);
expect(firstBooking.end).toEqual(
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
expect(firstBooking.eventType).toEqual({
id: recurringSeatedEventTypeId,
slug: recurringSeatedTventTypeSlug,
});
expect(firstBooking.attendees.length).toEqual(2);
expect(firstBooking.attendees[0]).toEqual({
name: nameAttendeeOne,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: createdRecurringSeatedBooking[0].seatUid,
bookingFieldsResponses: {
name: nameAttendeeOne,
...body.bookingFieldsResponses,
},
});
expect(firstBooking.attendees[1]).toEqual({
name: nameAttendeeTwo,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid,
bookingFieldsResponses: {
name: nameAttendeeTwo,
...body.bookingFieldsResponses,
},
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.seatUid).toBeDefined();
const secondSeatUid = secondBooking.seatUid;
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
expect(secondBooking.start).toEqual(expectedStart);
expect(secondBooking.end).toEqual(
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
expect(secondBooking.eventType).toEqual({
id: recurringSeatedEventTypeId,
slug: recurringSeatedTventTypeSlug,
});
expect(secondBooking.attendees.length).toEqual(2);
expect(secondBooking.attendees[0]).toEqual({
name: nameAttendeeOne,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: createdRecurringSeatedBooking[1].seatUid,
bookingFieldsResponses: {
name: nameAttendeeOne,
...body.bookingFieldsResponses,
},
});
expect(secondBooking.attendees[1]).toEqual({
name: nameAttendeeTwo,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: secondSeatUid,
bookingFieldsResponses: {
name: nameAttendeeTwo,
...body.bookingFieldsResponses,
},
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
createdRecurringSeatedBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
function responseDataIsCreateRecurringSeatedBooking(
data: any
): data is CreateRecurringSeatedBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 {
return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid"));
}
function responseDataIsGetRecurringSeatedBooking(
data: any
): data is GetRecurringSeatedBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -1127,293 +1127,4 @@ describe("Bookings Endpoints 2024-08-13", () => {
function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
describe("Recurring bookings", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
const maxRecurrenceCount = 3;
let recurringEventTypeId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const recurringEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: "peer-coding-recurring",
length: 60,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
},
user.id
);
recurringEventTypeId = recurringEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should not create recurring booking with recurrenceCount larger than event type recurrence count", async () => {
const recurrenceCount = 1000;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should create a recurring booking with recurrenceCount smaller than event type recurrence count", async () => {
const recurrenceCount = maxRecurrenceCount - 1;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount - 1);
const firstBooking = data[0];
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.recurringBookingUid).toBeDefined();
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(secondBooking.recurringBookingUid).toBeDefined();
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should create a recurring booking with recurrenceCount equal to event type recurrence count", async () => {
const recurrenceCount = maxRecurrenceCount;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount);
const firstBooking = data[0];
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.meetingUrl).toEqual(body.location);
expect(firstBooking.recurringBookingUid).toBeDefined();
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(secondBooking.recurringBookingUid).toBeDefined();
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
const thirdBooking = data[2];
expect(thirdBooking.id).toBeDefined();
expect(thirdBooking.uid).toBeDefined();
expect(thirdBooking.hosts[0].id).toEqual(user.id);
expect(thirdBooking.status).toEqual("accepted");
expect(thirdBooking.start).toEqual(new Date(Date.UTC(2030, 1, 18, 13, 0, 0)).toISOString());
expect(thirdBooking.end).toEqual(new Date(Date.UTC(2030, 1, 18, 14, 0, 0)).toISOString());
expect(thirdBooking.duration).toEqual(60);
expect(thirdBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(thirdBooking.recurringBookingUid).toBeDefined();
expect(thirdBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(thirdBooking.location).toEqual(body.location);
expect(thirdBooking.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
afterEach(async () => {
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -0,0 +1,251 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { EventType, User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { 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 { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("User bookings", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
let variableLengthEventType: EventType;
const variableLengthEventTypeSlug = "variable-length-event";
let normalEventType: EventType;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
variableLengthEventType = await eventTypesRepositoryFixture.create(
{
title: "variable length event",
slug: variableLengthEventTypeSlug,
length: 15,
metadata: { multipleDuration: [15, 30, 60] },
},
user.id
);
normalEventType = await eventTypesRepositoryFixture.create(
{ title: "normal event", slug: "normal-event", length: 15 },
user.id
);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
});
describe("create bookings", () => {
it("should not be able to specify length of booking for non variable length event type", async () => {
const lengthInMinutes = 30;
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: normalEventType.id,
lengthInMinutes,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should create a booking with default length", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: variableLengthEventType.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(
new Date(Date.UTC(2030, 0, 8, 10, variableLengthEventType.length, 0)).toISOString()
);
expect(data.duration).toEqual(variableLengthEventType.length);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should create a booking with specified length that is not default length", async () => {
const lengthInMinutes = 30;
const body: CreateBookingInput_2024_08_13 = {
lengthInMinutes,
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
eventTypeId: variableLengthEventType.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 11, lengthInMinutes, 0)).toISOString());
expect(data.duration).toEqual(lengthInMinutes);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
});
@@ -0,0 +1,21 @@
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { ReassignBookingOutput_2024_08_13 as ReassignBookingOutputData_2024_08_13 } from "@calcom/platform-types";
export class ReassignBookingOutput_2024_08_13 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
oneOf: [{ $ref: getSchemaPath(ReassignBookingOutputData_2024_08_13) }],
description:
"Booking data, which can be either a ReassignAutoBookingOutput object or a ReassignManualBookingOutput object",
})
@ValidateNested()
@Type(() => Object)
data!: ReassignBookingOutputData_2024_08_13;
}
@@ -3,7 +3,10 @@ import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servic
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { BillingService } from "@/modules/billing/services/billing.service";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException } from "@nestjs/common";
import { Request } from "express";
@@ -14,7 +17,10 @@ import {
getAllUserBookings,
handleInstantMeeting,
handleCancelBooking,
roundRobinReassignment,
roundRobinManualReassignment,
handleMarkNoShow,
confirmBookingHandler,
} from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
@@ -22,8 +28,8 @@ import {
CreateRecurringBookingInput_2024_08_13,
GetBookingsInput_2024_08_13,
CreateInstantBookingInput_2024_08_13,
CancelBookingInput_2024_08_13,
MarkAbsentBookingInput_2024_08_13,
ReassignToUserBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
@@ -46,9 +52,12 @@ export class BookingsService_2024_08_13 {
private readonly inputService: InputBookingsService_2024_08_13,
private readonly outputService: OutputBookingsService_2024_08_13,
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly bookingSeatRepository: BookingSeatRepository,
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly prismaReadService: PrismaReadService,
private readonly billingService: BillingService
private readonly billingService: BillingService,
private readonly usersService: UsersService,
private readonly usersRepository: UsersRepository
) {}
async createBooking(request: Request, body: CreateBookingInput) {
@@ -107,7 +116,7 @@ export class BookingsService_2024_08_13 {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
const bookings = await handleNewRecurringBooking(bookingRequest);
return this.outputService.getOutputCreateRecurringSeatedBookings(
bookings.map((booking) => ({ id: booking.id || 0, seatUid: booking.seatReferenceUid || "" }))
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" }))
);
}
@@ -282,11 +291,42 @@ export class BookingsService_2024_08_13 {
}
async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput) {
if (this.inputService.isCancelSeatedBody(body)) {
const seat = await this.bookingSeatRepository.getByReferenceUid(body.seatUid);
if (!seat) {
throw new BadRequestException(
"Invalid seatUid: this seat does not exist or has already been cancelled."
);
}
if (seat && bookingUid !== seat.booking.uid) {
throw new BadRequestException("Invalid seatUid: this seat does not belong to this booking.");
}
}
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
await handleCancelBooking(bookingRequest);
if ("cancelSubsequentBookings" in body && body.cancelSubsequentBookings) {
return this.getAllRecurringBookingsByIndividualUid(bookingUid);
}
return this.getBooking(bookingUid);
}
private async getAllRecurringBookingsByIndividualUid(bookingUid: string) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
const recurringBookingUid = booking?.recurringEventId;
if (!recurringBookingUid) {
throw new BadRequestException(
`Booking with bookingUid=${bookingUid} is not part of a recurring booking.`
);
}
return await this.getBooking(recurringBookingUid);
}
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
@@ -342,4 +382,95 @@ export class BookingsService_2024_08_13 {
fromReschedule: oldBookingUid,
});
}
async reassignBooking(bookingUid: string, requestUser: UserWithProfile) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const profile = this.usersService.getUserMainProfile(requestUser);
await roundRobinReassignment({
bookingId: booking.id,
orgId: profile?.organizationId || null,
});
const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid);
if (!reassigned) {
throw new NotFoundException(`Reassigned booking with uid=${bookingUid} was not found in the database`);
}
return this.outputService.getOutputReassignedBooking(reassigned);
}
async reassignBookingToUser(
bookingUid: string,
newUserId: number,
reassignedById: number,
body: ReassignToUserBookingInput_2024_08_13
) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const user = await this.usersRepository.findByIdWithProfile(newUserId);
if (!user) {
throw new NotFoundException(`User with id=${newUserId} was not found in the database`);
}
const profile = this.usersService.getUserMainProfile(user);
const reassigned = await roundRobinManualReassignment({
bookingId: booking.id,
newUserId,
orgId: profile?.organizationId || null,
reassignReason: body.reason,
reassignedById,
});
return this.outputService.getOutputReassignedBooking(reassigned);
}
async confirmBooking(bookingUid: string, requestUser: UserWithProfile) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
await confirmBookingHandler({
ctx: {
user: requestUser,
},
input: {
bookingId: booking.id,
confirmed: true,
recurringEventId: booking.recurringEventId,
},
});
return this.getBooking(bookingUid);
}
async declineBooking(bookingUid: string, requestUser: UserWithProfile, reason?: string) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
await confirmBookingHandler({
ctx: {
user: requestUser,
},
input: {
bookingId: booking.id,
confirmed: false,
recurringEventId: booking.recurringEventId,
reason,
},
});
return this.getBooking(bookingUid);
}
}
@@ -19,6 +19,7 @@ import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { EventTypeMetaDataSchema } from "@calcom/platform-libraries";
import {
CancelBookingInput,
CancelBookingInput_2024_08_13,
@@ -32,6 +33,7 @@ import {
RescheduleBookingInput_2024_08_13,
RescheduleSeatedBookingInput_2024_08_13,
} from "@calcom/platform-types";
import { EventType } from "@calcom/prisma/client";
type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams;
@@ -123,10 +125,13 @@ export class InputBookingsService_2024_08_13 {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
}
this.validateBookingLengthInMinutes(inputBooking, eventType);
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(
inputBooking.attendee.timeZone
);
const endTime = startTime.plus({ minutes: eventType.length });
const endTime = startTime.plus({ minutes: lengthInMinutes });
return {
start: startTime.toISO(),
@@ -150,6 +155,25 @@ export class InputBookingsService_2024_08_13 {
};
}
validateBookingLengthInMinutes(inputBooking: CreateBookingInput_2024_08_13, eventType: EventType) {
const eventTypeMetadata = EventTypeMetaDataSchema.parse(eventType.metadata);
if (inputBooking.lengthInMinutes && !eventTypeMetadata?.multipleDuration) {
throw new BadRequestException(
"Can't specify 'lengthInMinutes' because event type does not have multiple possible lengths. Please, remove the 'lengthInMinutes' field from the request."
);
}
if (
inputBooking.lengthInMinutes &&
!eventTypeMetadata?.multipleDuration?.includes(inputBooking.lengthInMinutes)
) {
throw new BadRequestException(
`Provided 'lengthInMinutes' is not one of the possible lengths for the event type. The possible lengths are: ${eventTypeMetadata?.multipleDuration?.join(
", "
)}`
);
}
}
async createRecurringBookingRequest(
request: Request,
body: CreateRecurringBookingInput_2024_08_13
@@ -469,23 +493,38 @@ export class InputBookingsService_2024_08_13 {
}
async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) {
let allRemainingBookings = false;
let uid = bookingUid;
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(
bookingUid
);
const recurringBooking = await this.bookingsRepository.getRecurringByUid(bookingUid);
// note(Lauris): isRecurring means that recurringEventId was passed as uid. isRecurring does not refer to the uid of 1 individual booking within a recurring booking consisting of many bookings.
// That is what recurringEventId refers to.
const isRecurringUid = !!recurringBooking.length;
if (recurringBooking.length) {
// note(Lauirs): this means that bookingUid is equal to recurringEventId on individual bookings of recurring one aka main recurring event
allRemainingBookings = true;
// note(Lauirs): we need to set uid as one of the individual recurring ids, not the main recurring event id
uid = recurringBooking[0].uid;
if (isRecurringUid && inputBooking.cancelSubsequentBookings) {
throw new BadRequestException(
"Cannot cancel subsequent bookings for recurring event - you have to provide uid of one of the individual bookings of a recurring booking."
);
}
if (isRecurringUid) {
return {
// note(Lauris): set uid as one of the oldest individual recurring ids
uid: recurringBooking[0].uid,
cancellationReason: inputBooking.cancellationReason,
allRemainingBookings: true,
};
}
if (inputBooking.cancelSubsequentBookings) {
return {
uid: bookingUid,
cancellationReason: inputBooking.cancellationReason,
cancelSubsequentBookings: true,
};
}
return {
uid,
uid: bookingUid,
cancellationReason: inputBooking.cancellationReason,
allRemainingBookings,
allRemainingBookings: false,
};
}
@@ -493,23 +532,12 @@ export class InputBookingsService_2024_08_13 {
bookingUid: string,
inputBooking: CancelSeatedBookingInput_2024_08_13
) {
let allRemainingBookings = false;
let uid = bookingUid;
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(
bookingUid
);
if (recurringBooking.length) {
// note(Lauirs): this means that bookingUid is equal to recurringEventId on individual bookings of recurring one aka main recurring event
allRemainingBookings = true;
// note(Lauirs): we need to set uid as one of the individual recurring ids, not the main recurring event id
uid = recurringBooking[0].uid;
}
// note(Lauris): for recurring seated booking it is not possible to cancel all remaining bookings
// for an individual person, so api users need to call booking by booking using uid + seatUid to cancel it.
return {
uid,
uid: bookingUid,
cancellationReason: "",
allRemainingBookings,
allRemainingBookings: false,
seatReferenceUid: inputBooking.seatUid,
};
}
@@ -10,6 +10,7 @@ import {
CreateSeatedBookingOutput_2024_08_13,
GetRecurringSeatedBookingOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
ReassignBookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
SeatedAttendee,
} from "@calcom/platform-types";
@@ -50,6 +51,8 @@ type DatabaseBooking = Booking & {
}[];
} & { user: { id: number; name: string | null; email: string } | null };
type BookingWithUser = Booking & { user: { id: number; name: string | null; email: string } | null };
@Injectable()
export class OutputBookingsService_2024_08_13 {
constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {}
@@ -230,14 +233,14 @@ export class OutputBookingsService_2024_08_13 {
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
}
async getOutputCreateRecurringSeatedBookings(bookings: { id: number; seatUid: string }[]) {
async getOutputCreateRecurringSeatedBookings(bookings: { uid: string; seatUid: string }[]) {
const transformed = [];
for (const booking of bookings) {
const databaseBooking =
await this.bookingsRepository.getByIdWithAttendeesWithBookingSeatAndUserAndEvent(booking.id);
await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(booking.uid);
if (!databaseBooking) {
throw new Error(`Booking with id=${booking.id} was not found in the database`);
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
}
transformed.push(this.getOutputCreateRecurringSeatedBooking(databaseBooking, booking.seatUid));
}
@@ -308,4 +311,17 @@ export class OutputBookingsService_2024_08_13 {
return parsed;
}
getOutputReassignedBooking(
databaseBooking: Pick<BookingWithUser, "uid" | "user">
): ReassignBookingOutput_2024_08_13 {
return {
bookingUid: databaseBooking.uid,
reassignedTo: {
id: databaseBooking?.user?.id || 0,
name: databaseBooking?.user?.name || "unknown",
email: databaseBooking?.user?.email || "unknown",
},
};
}
}
@@ -212,6 +212,7 @@ describe("Event types Endpoints", () => {
slug: "coding-class",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
lengthInMinutesOptions: [30, 60, 90],
locations: [
{
type: "integration",
@@ -300,6 +301,7 @@ describe("Event types Endpoints", () => {
expect(createdEventType.title).toEqual(body.title);
expect(createdEventType.description).toEqual(body.description);
expect(createdEventType.lengthInMinutes).toEqual(body.lengthInMinutes);
expect(createdEventType.lengthInMinutesOptions).toEqual(body.lengthInMinutesOptions);
expect(createdEventType.locations).toEqual(body.locations);
expect(createdEventType.ownerId).toEqual(user.id);
expect(createdEventType.scheduleId).toEqual(firstSchedule.id);
@@ -360,6 +362,7 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType?.title).toEqual(eventType.title);
expect(fetchedEventType?.description).toEqual(eventType.description);
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType?.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType?.ownerId).toEqual(user.id);
@@ -665,6 +668,7 @@ describe("Event types Endpoints", () => {
const body: UpdateEventTypeInput_2024_06_14 = {
title: newTitle,
scheduleId: secondSchedule.id,
lengthInMinutesOptions: [15, 30],
bookingFields: [
nameBookingField,
{
@@ -733,6 +737,7 @@ describe("Event types Endpoints", () => {
expect(updatedEventType.id).toEqual(eventType.id);
expect(updatedEventType.title).toEqual(newTitle);
expect(updatedEventType.lengthInMinutesOptions).toEqual(body.lengthInMinutesOptions);
expect(updatedEventType.description).toEqual(eventType.description);
expect(updatedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(updatedEventType.locations).toEqual(eventType.locations);
@@ -772,6 +777,7 @@ describe("Event types Endpoints", () => {
eventType.title = newTitle;
eventType.scheduleId = secondSchedule.id;
eventType.lengthInMinutesOptions = updatedEventType.lengthInMinutesOptions;
eventType.bookingLimitsCount = updatedEventType.bookingLimitsCount;
eventType.onlyShowFirstAvailableSlot = updatedEventType.onlyShowFirstAvailableSlot;
eventType.bookingLimitsDuration = updatedEventType.bookingLimitsDuration;
@@ -819,6 +825,7 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.title).toEqual(eventType.title);
expect(fetchedEventType.description).toEqual(eventType.description);
expect(fetchedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
expect(fetchedEventType.locations).toEqual(eventType.locations);
expect(fetchedEventType.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType.ownerId).toEqual(user.id);
@@ -857,6 +864,7 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType?.title).toEqual(eventType.title);
expect(fetchedEventType?.description).toEqual(eventType.description);
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType?.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType?.ownerId).toEqual(user.id);
@@ -104,6 +104,7 @@ export class InputEventTypesService_2024_06_14 {
const {
lengthInMinutes,
lengthInMinutesOptions,
locations,
bookingFields,
bookingLimitsCount,
@@ -135,6 +136,7 @@ export class InputEventTypesService_2024_06_14 {
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
multipleDuration: lengthInMinutesOptions,
},
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
requiresConfirmationWillBlockSlot:
@@ -152,6 +154,7 @@ export class InputEventTypesService_2024_06_14 {
async transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14, eventTypeId: number) {
const {
lengthInMinutes,
lengthInMinutesOptions,
locations,
bookingFields,
bookingLimitsCount,
@@ -191,6 +194,7 @@ export class InputEventTypesService_2024_06_14 {
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
multipleDuration: lengthInMinutesOptions,
},
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
@@ -153,6 +153,7 @@ export class OutputEventTypesService_2024_06_14 {
id,
ownerId,
lengthInMinutes: length,
lengthInMinutesOptions: metadata.multipleDuration,
title,
slug,
description: description || "",
+4
View File
@@ -10,6 +10,8 @@ export type Environment = {
DATABASE_URL: string;
JWT_SECRET: string;
SENTRY_DSN: string;
SENTRY_TRACES_SAMPLE_RATE?: number;
SENTRY_PROFILES_SAMPLE_RATE?: number;
LOG_LEVEL: keyof typeof logLevels;
REDIS_URL: string;
STRIPE_API_KEY: string;
@@ -26,6 +28,8 @@ export type Environment = {
RATE_LIMIT_DEFAULT_LIMIT_ACCESS_TOKEN: number;
RATE_LIMIT_DEFAULT_LIMIT: number;
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: number;
AXIOM_DATASET: string;
AXIOM_TOKEN: string;
};
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
+7 -4
View File
@@ -1,12 +1,15 @@
import { getEnv } from "@/env";
import * as Sentry from "@sentry/node";
import * as Sentry from "@sentry/nestjs";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
if (process.env.SENTRY_DSN) {
// Ensure to call this before requiring any other modules!
Sentry.init({
dsn: getEnv("SENTRY_DSN"),
// Add Performance Monitoring by setting tracesSampleRate
// We recommend adjusting this value in production
// todo: Evaluate? tracesSampleRate: 1.0
integrations: [nodeProfilingIntegration()],
// Performance Monitoring
tracesSampleRate: getEnv("SENTRY_TRACES_SAMPLE_RATE") ?? 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: getEnv("SENTRY_PROFILES_SAMPLE_RATE") ?? 1.0,
});
}
+18 -4
View File
@@ -1,5 +1,7 @@
import { WinstonTransport as AxiomTransport } from "@axiomhq/winston";
import type { LoggerOptions } from "winston";
import { format, transports } from "winston";
import { format, transports as Transports } from "winston";
import type Transport from "winston-transport";
const formattedTimestamp = format.timestamp({
format: "YYYY-MM-DD HH:mm:ss.SSS",
@@ -36,13 +38,25 @@ export const logLevels = {
export const loggerConfig = (): LoggerOptions => {
const isProduction = process.env.NODE_ENV === "production";
const transports: Transport[] = [];
transports.push(new Transports.Console());
if (!!process.env.AXIOM_TOKEN && !!process.env.AXIOM_DATASET) {
transports.push(
new AxiomTransport({
token: process.env.AXIOM_TOKEN,
dataset: process.env.AXIOM_DATASET,
})
);
}
return {
levels: logLevels,
level: process.env.LOG_LEVEL ?? "info",
format: isProduction ? WINSTON_PROD_FORMAT : WINSTON_DEV_FORMAT,
transports: [new transports.Console()],
exceptionHandlers: [new transports.Console()],
rejectionHandlers: [new transports.Console()],
transports,
exceptionHandlers: transports,
rejectionHandlers: transports,
defaultMeta: {
service: "cal-platform-api",
},
+12 -4
View File
@@ -1,4 +1,5 @@
import type { AppConfig } from "@/config/type";
import { getEnv } from "@/env";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
@@ -107,13 +108,20 @@ async function generateSwagger(app: NestExpressApplication<Server>) {
const document = SwaggerModule.createDocument(app, config);
document.paths = groupAndSortPathsByFirstTag(document.paths);
const outputFile = "./swagger/documentation.json";
const swaggerOutputFile = "./swagger/documentation.json";
const docsOutputFile = "../../../docs/api-reference/v2/openapi.json";
const stringifiedContents = JSON.stringify(document, null, 2);
if (fs.existsSync(outputFile)) {
fs.unlinkSync(outputFile);
if (fs.existsSync(swaggerOutputFile)) {
fs.unlinkSync(swaggerOutputFile);
}
fs.writeFileSync(outputFile, JSON.stringify(document, null, 2), { encoding: "utf8" });
fs.writeFileSync(swaggerOutputFile, stringifiedContents, { encoding: "utf8" });
if (fs.existsSync(docsOutputFile) && getEnv("NODE_ENV") === "development") {
fs.unlinkSync(docsOutputFile);
fs.writeFileSync(docsOutputFile, stringifiedContents, { encoding: "utf8" });
}
if (!process.env.DOCS_URL) {
SwaggerModule.setup("docs", app, document, {
@@ -21,4 +21,15 @@ export class AppsRepository {
},
});
}
async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) {
return this.dbWrite.prisma.credential.create({
data: {
type: type,
key: key,
teamId: teamId,
appId: appId,
},
});
}
}
@@ -1,5 +1,6 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
@@ -17,6 +18,7 @@ import { Module } from "@nestjs/common";
MembershipsRepository,
CredentialsRepository,
UsersRepository,
AtomsRepository,
UsersService,
SchedulesRepository_2024_06_11,
],
@@ -0,0 +1,17 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { paymentDataSelect } from "@calcom/platform-libraries";
@Injectable()
export class AtomsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getRawPayment(uid: string) {
return await this.dbRead.prisma.payment.findFirst({
where: { uid },
select: paymentDataSelect,
});
}
}
@@ -72,6 +72,18 @@ export class AtomsController {
};
}
@Get("payment/:uid")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getUserPaymentInfoById(@Param("uid") uid: string): Promise<ApiResponse<unknown>> {
const data = await this.eventTypesService.getUserPaymentInfo(uid);
return {
status: SUCCESS_STATUS,
data,
};
}
@Patch("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@@ -1,4 +1,5 @@
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
@@ -17,6 +18,7 @@ import {
getEnabledAppsFromCredentials,
getAppFromSlug,
MembershipRole,
EventTypeMetaDataSchema,
} from "@calcom/platform-libraries";
import type {
App,
@@ -27,6 +29,7 @@ import type {
TDependencyData,
CredentialPayload,
} from "@calcom/platform-libraries";
import { getClientSecretFromPayment } from "@calcom/platform-libraries";
import { PrismaClient } from "@calcom/prisma/client";
type EnabledAppType = App & {
@@ -40,6 +43,7 @@ export class EventTypesAtomService {
constructor(
private readonly membershipsRepository: MembershipsRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly atomsRepository: AtomsRepository,
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
private readonly usersService: UsersService,
private readonly dbWrite: PrismaWriteService,
@@ -274,4 +278,43 @@ export class EventTypesAtomService {
);
return apps[0];
}
async getUserPaymentInfo(uid: string) {
const rawPayment = await this.atomsRepository.getRawPayment(uid);
if (!rawPayment) throw new NotFoundException(`Payment with uid ${uid} not found`);
const { data, booking: _booking, ...restPayment } = rawPayment;
const payment = {
...restPayment,
data: data as Record<string, unknown>,
};
if (!_booking) throw new NotFoundException(`Booking with uid ${uid} not found`);
const { startTime, endTime, eventType, ...restBooking } = _booking;
const booking = {
...restBooking,
startTime: startTime.toString(),
endTime: endTime.toString(),
};
if (!eventType) throw new NotFoundException(`Event type with uid ${uid} not found`);
if (eventType.users.length === 0 && !!!eventType.team)
throw new NotFoundException(`No users found or no team present for event type with uid ${uid}`);
const [user] = eventType?.users.length
? eventType.users
: [{ name: null, theme: null, hideBranding: null, username: null }];
const profile = {
name: eventType.team?.name || user?.name || null,
theme: (!eventType.team?.name && user?.theme) || null,
hideBranding: eventType.team?.hideBranding || user?.hideBranding || null,
};
return {
user,
eventType: {
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
},
booking,
payment,
clientSecret: getClientSecretFromPayment(payment),
profile,
};
}
}
@@ -46,9 +46,9 @@ export class BillingProcessor {
return;
}
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
billingSubscription.subscriptionId
);
const stripeSubscription = await this.stripeService
.getStripe()
.subscriptions.retrieve(billingSubscription.subscriptionId);
if (!stripeSubscription?.id) {
this.logger.error(`Failed to retrieve stripe subscription (${billingSubscription.subscriptionId})`, {
teamId,
@@ -69,7 +69,7 @@ export class BillingProcessor {
return;
}
await this.stripeService.stripe.subscriptionItems.createUsageRecord(meteredItem.id, {
await this.stripeService.getStripe().subscriptionItems.createUsageRecord(meteredItem.id, {
action: "increment",
quantity: 1,
timestamp: "now",
@@ -1,10 +1,11 @@
import { PlatformPlan } from "@/modules/billing/types";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
@Injectable()
export class BillingRepository {
private readonly logger = new Logger("BillingRepository");
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
getBillingForTeam = (teamId: number) =>
@@ -30,6 +31,35 @@ export class BillingRepository {
billingCycleEnd: billingEnd,
subscriptionId,
plan: plan.toString(),
overdue: false,
},
});
}
async updateBillingOverdue(subId: string, cusId: string, overdue: boolean) {
try {
return this.dbWrite.prisma.platformBilling.update({
where: {
subscriptionId: subId,
customerId: cusId,
},
data: {
overdue,
},
});
} catch (err) {
this.logger.error("Could not update billing overdue", {
subId,
cusId,
err,
});
}
}
async deleteBilling(id: number) {
return this.dbWrite.prisma.platformBilling.delete({
where: {
id,
},
});
}
@@ -0,0 +1,211 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeService } from "@/modules/stripe/stripe.service";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { UserWithProfile } from "@/modules/users/users.repository";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import Stripe from "stripe";
import * as request from "supertest";
import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { Team, PlatformOAuthClient, PlatformBilling } from "@calcom/prisma/client";
describe("Platform Billing Controller (e2e)", () => {
let app: INestApplication;
const userEmail = "platform-billing-controller-e2e@api.com";
let user: UserWithProfile;
let billing: PlatformBilling;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let organization: Team;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
organization = await organizationsRepositoryFixture.create({ name: "platform billing" });
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: userEmail,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "OWNER",
team: { connect: { id: organization.id } },
user: { connect: { id: user.id } },
accepted: true,
});
billing = await platformBillingRepositoryFixture.create(organization.id);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
afterAll(async () => {
userRepositoryFixture.deleteByEmail(user.email);
await app.close();
});
it("/billing/webhook (POST) should set billing free plan for org", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
({
webhooks: {
constructEventAsync: async () => {
return {
type: "checkout.session.completed",
data: {
object: {
metadata: {
teamId: organization.id,
plan: "FREE",
},
mode: "subscription",
},
},
};
},
},
} as unknown as Stripe)
);
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
expect(billing?.plan).toEqual("FREE");
});
});
it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
({
webhooks: {
constructEventAsync: async () => {
return {
type: "invoice.payment_failed",
data: {
object: {
customer: billing?.customerId,
subscription: billing?.subscriptionId,
},
},
};
},
},
} as unknown as Stripe)
);
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
expect(billing?.overdue).toEqual(true);
});
});
it("/billing/webhook (POST) success payment should set billing free plan to not overdue", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
({
webhooks: {
constructEventAsync: async () => {
return {
type: "invoice.payment_succeeded",
data: {
object: {
customer: billing?.customerId,
subscription: billing?.subscriptionId,
},
},
};
},
},
} as unknown as Stripe)
);
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
expect(billing?.overdue).toEqual(false);
});
});
it("/billing/webhook (POST) should delete subscription", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
({
webhooks: {
constructEventAsync: async () => {
return {
type: "customer.subscription.deleted",
data: {
object: {
metadata: {
teamId: organization.id,
plan: "FREE",
},
id: billing?.subscriptionId,
},
},
};
},
},
} as unknown as Stripe)
);
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
expect(billing).toBeNull();
});
});
});
@@ -102,13 +102,26 @@ export class BillingController {
@Req() request: Request,
@Headers("stripe-signature") stripeSignature: string
): Promise<ApiResponse> {
const event = await this.billingService.stripeService.stripe.webhooks.constructEventAsync(
request.body,
stripeSignature,
this.stripeWhSecret
);
const event = await this.billingService.stripeService
.getStripe()
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
await this.billingService.createOrUpdateStripeSubscription(event);
switch (event.type) {
case "checkout.session.completed":
await this.billingService.handleStripeCheckoutEvents(event);
break;
case "customer.subscription.deleted":
await this.billingService.handleStripeSubscriptionDeleted(event);
break;
case "invoice.payment_failed":
await this.billingService.handleStripePaymentFailed(event);
break;
case "invoice.payment_succeeded":
await this.billingService.handleStripePaymentSuccess(event);
break;
default:
break;
}
return {
status: "success",
@@ -64,7 +64,7 @@ export class BillingService implements OnModuleDestroy {
}
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
const { url } = await this.stripeService.stripe.checkout.sessions.create({
const { url } = await this.stripeService.getStripe().checkout.sessions.create({
customer: customerId,
line_items: [
{
@@ -101,7 +101,7 @@ export class BillingService implements OnModuleDestroy {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
const customerId = teamWithBilling?.platformBilling?.customerId;
const { url } = await this.stripeService.stripe.checkout.sessions.create({
const { url } = await this.stripeService.getStripe().checkout.sessions.create({
customer: customerId,
success_url: `${this.webAppUrl}/settings/platform/`,
cancel_url: `${this.webAppUrl}/settings/platform/plans`,
@@ -131,41 +131,92 @@ export class BillingService implements OnModuleDestroy {
);
}
async createOrUpdateStripeSubscription(event: Stripe.Event) {
if (event.type === "checkout.session.completed") {
const subscription = event.data.object as Stripe.Checkout.Session;
if (!subscription.metadata?.teamId) {
return {
status: "success",
};
}
const teamId = Number.parseInt(subscription.metadata.teamId);
const plan = subscription.metadata.plan;
if (!plan || !teamId) {
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
return {
status: "success",
};
}
if (subscription.mode === "subscription") {
await this.setSubscriptionForTeam(
async handleStripeSubscriptionDeleted(event: Stripe.Event) {
const subscription = event.data.object as Stripe.Subscription;
const teamId = subscription?.metadata?.teamId;
const plan = PlatformPlan[subscription?.metadata?.plan?.toUpperCase() as keyof typeof PlatformPlan];
if (teamId && plan) {
const currentBilling = await this.billingRepository.getBillingForTeam(Number.parseInt(teamId));
if (currentBilling?.subscriptionId === subscription.id) {
await this.billingRepository.deleteBilling(currentBilling.id);
this.logger.log(`Stripe Subscription deleted`, {
customerId: currentBilling.customerId,
subscriptionId: currentBilling.subscriptionId,
teamId,
subscription.subscription as string,
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
);
});
return;
}
if (subscription.mode === "setup") {
await this.updateStripeSubscriptionForTeam(teamId, plan as PlatformPlan);
}
return {
status: "success",
};
this.logger.log("No platform billing found.");
return;
}
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
return;
}
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
if (typeof invoice.subscription === "string") {
return invoice.subscription;
} else if (invoice.subscription && typeof invoice.subscription === "object") {
return invoice.subscription.id;
} else {
return null;
}
}
getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null {
if (typeof invoice.customer === "string") {
return invoice.customer;
} else if (invoice.customer && typeof invoice.customer === "object") {
return invoice.customer.id;
} else {
return null;
}
}
async handleStripePaymentSuccess(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
const customerId = this.getCustomerIdFromInvoice(invoice);
if (subscriptionId && customerId) {
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, false);
}
}
async handleStripePaymentFailed(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
const customerId = this.getCustomerIdFromInvoice(invoice);
if (subscriptionId && customerId) {
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, true);
}
}
async handleStripeCheckoutEvents(event: Stripe.Event) {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
if (!checkoutSession.metadata?.teamId) {
return;
}
const teamId = Number.parseInt(checkoutSession.metadata.teamId);
const plan = checkoutSession.metadata.plan;
if (!plan || !teamId) {
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
return;
}
if (checkoutSession.mode === "subscription") {
await this.setSubscriptionForTeam(
teamId,
checkoutSession.subscription as string,
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
);
}
if (checkoutSession.mode === "setup") {
await this.updateStripeSubscriptionForTeam(teamId, plan as PlatformPlan);
}
return;
}
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
@@ -175,9 +226,9 @@ export class BillingService implements OnModuleDestroy {
throw new NotFoundException("Team plan not found");
}
const existingUserSubscription = await this.stripeService.stripe.subscriptions.retrieve(
teamWithBilling?.platformBilling?.subscriptionId
);
const existingUserSubscription = await this.stripeService
.getStripe()
.subscriptions.retrieve(teamWithBilling?.platformBilling?.subscriptionId);
const currentLicensedItem = existingUserSubscription.items.data.find(
(item) => item.price?.recurring?.usage_type === "licensed"
);
@@ -193,21 +244,23 @@ export class BillingService implements OnModuleDestroy {
throw new NotFoundException("There is no overage item present in the subscription");
}
await this.stripeService.stripe.subscriptions.update(teamWithBilling?.platformBilling?.subscriptionId, {
items: [
{
id: currentLicensedItem.id,
price: this.billingConfigService.get(plan)?.base,
},
{
id: currentOverageItem.id,
price: this.billingConfigService.get(plan)?.overage,
clear_usage: false,
},
],
billing_cycle_anchor: "now",
proration_behavior: "create_prorations",
});
await this.stripeService
.getStripe()
.subscriptions.update(teamWithBilling?.platformBilling?.subscriptionId, {
items: [
{
id: currentLicensedItem.id,
price: this.billingConfigService.get(plan)?.base,
},
{
id: currentOverageItem.id,
price: this.billingConfigService.get(plan)?.overage,
clear_usage: false,
},
],
billing_cycle_anchor: "now",
proration_behavior: "create_prorations",
});
await this.setSubscriptionForTeam(
teamId,
+2 -1
View File
@@ -1,8 +1,9 @@
export enum PlatformPlan {
FREE = "FREE",
STARTER = "STARTER",
ESSENTIALS = "ESSENTIALS",
SCALE = "SCALE",
ENTERPRISE = "ENTERPRISE",
}
export type PlatformPlanType = "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
@@ -12,6 +12,9 @@ export class BookingSeatRepository {
where: {
referenceUid,
},
include: {
booking: { select: { uid: true } },
},
});
}
}
@@ -37,6 +37,10 @@ export class CredentialsRepository {
return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } });
}
getByTypeAndTeamId(type: string, teamId: number) {
return this.dbWrite.prisma.credential.findFirst({ where: { type, teamId } });
}
getAllUserCredentialsByTypeAndId(type: string, userId: number) {
return this.dbRead.prisma.credential.findMany({ where: { type, userId } });
}
@@ -510,7 +510,7 @@ describe("OAuth Client Users Endpoints", () => {
expect(oAuthClient1).toBeDefined();
});
it(`should create managed user and update team event-types of OAuthClient marked as assignAllTeamMembers: true`, async () => {
it(`should create managed user and update team event-types`, async () => {
const requestBody: CreateManagedUserInput = {
email: userEmail,
timeZone: userTimeZone,
@@ -532,48 +532,13 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
await userHasCorrectEventTypes(responseBody.data.user.id);
await teamHasCorrectEventTypes(team1.id);
expect(responseBody.data.user.name).toEqual(requestBody.name);
});
async function userHasCorrectEventTypes(userId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
expect(eventTypes?.length).toEqual(5);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType1.slug)).toBeTruthy();
// note(Lauris): default event types
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutes.slug)
).toBeTruthy();
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutes.slug)
).toBeTruthy();
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutesVideo.slug)
).toBeTruthy();
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutesVideo.slug)
).toBeTruthy();
}
async function teamHasCorrectEventTypes(teamId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId);
expect(eventTypes?.length).toEqual(2);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType1.slug)).toBeTruthy();
// note(Lauris): check if managed user added to collective event-type hosts given that it has assignAllTeamMembers: true
const collective = eventTypes?.find((eventType) => eventType.schedulingType === "COLLECTIVE");
expect(collective).toBeTruthy();
expect(collective?.hosts).toBeDefined();
expect(collective?.hosts?.length).toEqual(1);
expect(collective?.hosts[0].userId).toEqual(postResponseData.user.id);
}
afterAll(async () => {
@@ -25,9 +25,9 @@ import {
import {
ApiTags as DocsTags,
ApiExcludeController as DocsExcludeController,
ApiExcludeEndpoint as DocsExcludeEndpoint,
ApiOperation as DocsOperation,
ApiOkResponse as DocsOkResponse,
ApiExcludeEndpoint as DocsExcludeEndpoint,
ApiBadRequestResponse as DocsBadRequestResponse,
ApiHeader as DocsHeader,
} from "@nestjs/swagger";
@@ -50,20 +50,7 @@ export class OAuthFlowController {
@Post("/authorize")
@HttpCode(HttpStatus.OK)
@UseGuards(NextAuthGuard)
@DocsOperation({
summary: "Authorize an OAuth client",
description:
"Redirects the user to the specified 'redirect_uri' with an authorization code in query parameter if the client is authorized successfully. The code is then exchanged for access and refresh tokens via the `/exchange` endpoint.",
})
@DocsOkResponse({
description:
"The user is redirected to the 'redirect_uri' with an authorization code in query parameter e.g. `redirectUri?code=secretcode.`",
})
@DocsBadRequestResponse({
description:
"Bad request if the OAuth client is not found, if the redirect URI is invalid, or if the user has already authorized the client.",
})
@DocsExcludeEndpoint(getEnv("NODE_ENV") === "production")
@DocsExcludeEndpoint()
async authorize(
@Param("clientId") clientId: string,
@Body() body: OAuthAuthorizeInput,
@@ -97,20 +84,7 @@ export class OAuthFlowController {
@Post("/exchange")
@HttpCode(HttpStatus.OK)
@DocsOperation({
summary: "Exchange authorization code for access tokens",
description:
"Exchanges the authorization code received from the `/authorize` endpoint for access and refresh tokens. The authorization code should be provided in the 'Authorization' header prefixed with 'Bearer '.",
})
@DocsOkResponse({
type: KeysResponseDto,
description: "Successfully exchanged authorization code for access and refresh tokens.",
})
@DocsBadRequestResponse({
description:
"Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match.",
})
@DocsExcludeEndpoint(getEnv("NODE_ENV") === "production")
@DocsExcludeEndpoint()
async exchange(
@Headers("Authorization") authorization: string,
@Param("clientId") clientId: string,
@@ -83,8 +83,6 @@ export class OAuthClientUsersService {
user.defaultScheduleId = defaultSchedule.id;
}
await this.organizationsTeamsService.addUserToPlatformTeamEvents(user.id, organizationId, oAuthClientId);
return {
user,
tokens: {
@@ -9,8 +9,9 @@ import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import { EventType, User } from "@prisma/client";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
@@ -26,6 +27,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
describe("User Authentication - User is Org Admin", () => {
let app: INestApplication;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
@@ -36,6 +38,8 @@ describe("Organizations Teams Memberships Endpoints", () => {
let org: Team;
let orgTeam: Team;
let nonOrgTeam: Team;
let teamEventType: EventType;
let managedEventType: EventType;
let membership: Membership;
let membership2: Membership;
let membershipCreatedViaApi: OrgTeamMembershipOutputDto;
@@ -66,6 +70,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
@@ -97,6 +102,32 @@ describe("Organizations Teams Memberships Endpoints", () => {
parent: { connect: { id: org.id } },
});
teamEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: orgTeam.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 30,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
managedEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "MANAGED",
team: {
connect: { id: orgTeam.id },
},
title: "Managed Event Type",
slug: "managed-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
nonOrgTeam = await teamsRepositoryFixture.create({
name: "Non Org Team",
isOrganization: false,
@@ -264,7 +295,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
.expect(404);
});
it("should create the membership of the org's team", async () => {
it("should have created the membership of the org's team and assigned team wide events", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
.send({
@@ -281,9 +312,22 @@ describe("Organizations Teams Memberships Endpoints", () => {
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id);
expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email);
userHasCorrectEventTypes(membershipCreatedViaApi.userId);
});
});
async function userHasCorrectEventTypes(userId: number) {
const managedEventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(orgTeam.id);
expect(managedEventTypes?.length).toEqual(1);
expect(teamEventTypes?.length).toEqual(2);
const collectiveEvenType = teamEventTypes?.find((eventType) => eventType.slug === teamEventType.slug);
expect(collectiveEvenType).toBeTruthy();
const userHost = collectiveEvenType?.hosts.find((host) => host.userId === userId);
expect(userHost).toBeTruthy();
expect(managedEventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
}
it("should fail to create the membership of the org's team for a non org user", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
@@ -15,6 +15,7 @@ import {
OrgTeamMembershipsOutputResponseDto,
OrgTeamMembershipOutputResponseDto,
} from "@/modules/organizations/outputs/organization-teams-memberships.output";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service";
import {
Controller,
@@ -30,11 +31,13 @@ import {
HttpStatus,
HttpCode,
UnprocessableEntityException,
Logger,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
@@ -44,8 +47,11 @@ import { SkipTakePagination } from "@calcom/platform-types";
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Orgs / Teams / Memberships")
export class OrganizationsTeamsMembershipsController {
private logger = new Logger("OrganizationsTeamsMembershipsController");
constructor(
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
private oganizationsEventTypesService: OrganizationsEventTypesService,
private readonly organizationsRepository: OrganizationsRepository
) {}
@@ -112,6 +118,9 @@ export class OrganizationsTeamsMembershipsController {
teamId,
membershipId
);
await this.oganizationsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
@@ -129,15 +138,29 @@ export class OrganizationsTeamsMembershipsController {
@Param("membershipId", ParseIntPipe) membershipId: number,
@Body() data: UpdateOrgTeamMembershipDto
): Promise<OrgTeamMembershipOutputResponseDto> {
const membership = await this.organizationsTeamsMembershipsService.updateOrgTeamMembership(
const currentMembership = await this.organizationsTeamsMembershipsService.getOrgTeamMembership(
orgId,
teamId,
membershipId
);
const updatedMembership = await this.organizationsTeamsMembershipsService.updateOrgTeamMembership(
orgId,
teamId,
membershipId,
data
);
if (!currentMembership.accepted && updatedMembership.accepted) {
try {
await updateNewTeamMemberEventTypes(updatedMembership.userId, teamId);
} catch (err) {
this.logger.error("Could not update new team member eventTypes", err);
}
}
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
data: plainToClass(OrgTeamMembershipOutputDto, updatedMembership, { strategy: "excludeAll" }),
};
}
@@ -158,6 +181,13 @@ export class OrganizationsTeamsMembershipsController {
}
const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(teamId, data);
if (membership.accepted) {
try {
await updateNewTeamMemberEventTypes(user.id, teamId);
} catch (err) {
this.logger.error("Could not update new team member eventTypes", err);
}
}
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
@@ -469,34 +469,13 @@ describe("Organizations Users Endpoints", () => {
const userData = body.data;
expect(body.status).toBe(SUCCESS_STATUS);
userHasCorrectEventTypes(userData.id);
createdUser = userData;
teamHasCorrectEventTypes(team.id);
});
async function userHasCorrectEventTypes(userId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
expect(eventTypes?.length).toEqual(1);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
}
async function teamHasCorrectEventTypes(teamId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId);
expect(eventTypes?.length).toEqual(2);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
// note(Lauris): check if managed user added to collective event-type hosts given that it has assignAllTeamMembers: true
const collective = eventTypes?.find((eventType) => eventType.schedulingType === "COLLECTIVE");
expect(collective).toBeTruthy();
expect(collective?.hosts).toBeDefined();
expect(collective?.hosts?.length).toEqual(1);
expect(collective?.hosts[0].userId).toEqual(createdUser.id);
}
afterAll(async () => {
@@ -33,7 +33,7 @@ export class OrganizationsRepository {
}
async createNewBillingRelation(orgId: number, plan?: PlatformPlan) {
const { id } = await this.stripeService.stripe.customers.create({
const { id } = await this.stripeService.getStripe().customers.create({
metadata: {
createdBy: "oauth_client_no_csid", // mark in case this is needed in the future.
},
@@ -70,4 +70,26 @@ export class OrganizationsEventTypesRepository {
include: { children: true },
});
}
async deleteUserManagedTeamEventTypes(userId: number, teamId: number) {
return this.dbWrite.prisma.eventType.deleteMany({
where: {
parent: {
teamId,
},
userId,
},
});
}
async removeUserFromTeamEventTypesHosts(userId: number, teamId: number) {
return this.dbWrite.prisma.host.deleteMany({
where: {
userId,
eventType: {
teamId,
},
},
});
}
}
@@ -6,13 +6,15 @@ import { DatabaseTeamEventType } from "@/modules/organizations/services/event-ty
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { InputTeamEventTransformed_2024_06_14 } from "@calcom/platform-types";
@Injectable()
export class OrganizationsEventTypesService {
private readonly logger = new Logger("OrganizationsEventTypesService");
constructor(
private readonly eventTypesService: EventTypesService_2024_06_14,
private readonly dbWrite: PrismaWriteService,
@@ -145,4 +147,17 @@ export class OrganizationsEventTypesService {
return this.eventTypesRepository.deleteEventType(eventTypeId);
}
async deleteUserTeamEventTypesAndHosts(userId: number, teamId: number) {
try {
await this.organizationEventTypesRepository.deleteUserManagedTeamEventTypes(userId, teamId);
await this.organizationEventTypesRepository.removeUserFromTeamEventTypesHosts(userId, teamId);
} catch (err) {
this.logger.error("Could not remove user from all team event-types.", {
error: err,
userId,
teamId,
});
}
}
}
@@ -5,8 +5,6 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/repositori
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationsTeamsService {
constructor(
@@ -69,23 +67,4 @@ export class OrganizationsTeamsService {
}
return team;
}
async addUserToTeamEvents(userId: number, organizationId: number) {
const orgTeams = await this.organizationsTeamRepository.findOrgTeams(organizationId);
for (const team of orgTeams) {
await updateNewTeamMemberEventTypes(userId, team.id);
}
}
async addUserToPlatformTeamEvents(userId: number, organizationId: number, oAuthClientId: string) {
const oAuthClientTeams = await this.organizationsTeamRepository.getPlatformOrgTeams(
organizationId,
oAuthClientId
);
for (const team of oAuthClientTeams) {
await updateNewTeamMemberEventTypes(userId, team.id);
}
}
}
@@ -79,8 +79,6 @@ export class OrganizationsUsersService {
updateUserBody
);
await this.organizationsTeamsService.addUserToTeamEvents(user.id, org.id);
// Need to send email to new user to create password
await this.emailService.sendSignupToOrganizationEmail({
usernameOrEmail,
@@ -21,6 +21,7 @@ import {
Req,
BadRequestException,
Headers,
Param,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiOperation } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
@@ -46,7 +47,8 @@ export class StripeController {
@Headers("Authorization") authorization: string,
@GetUser() user: UserWithProfile,
@Query("redir") redir?: string | null,
@Query("errorRedir") errorRedir?: string | null
@Query("errorRedir") errorRedir?: string | null,
@Query("teamId") teamId?: string | null
): Promise<StripConnectOutputResponseDto> {
const origin = req.headers.origin;
const accessToken = authorization.replace("Bearer ", "");
@@ -56,6 +58,7 @@ export class StripeController {
fromApp: false,
returnTo: !!redir ? redir : origin,
accessToken,
teamId: Number(teamId) ?? null,
};
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(
@@ -99,6 +102,16 @@ export class StripeController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Check stripe connection" })
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
return await this.stripeService.checkIfStripeAccountConnected(user.id);
return await this.stripeService.checkIfIndividualStripeAccountConnected(user.id);
}
@Get("/check/:teamId")
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Check team stripe connection" })
async checkTeamStripeConnection(
@Param("teamId") teamId: string
): Promise<StripCredentialsCheckOutputResponseDto> {
return await this.stripeService.checkIfTeamStripeAccountConnected(Number(teamId));
}
}
@@ -1,5 +1,6 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeController } from "@/modules/stripe/controllers/stripe.controller";
import { StripeService } from "@/modules/stripe/stripe.service";
@@ -10,7 +11,7 @@ import { ConfigModule } from "@nestjs/config";
@Module({
imports: [ConfigModule, PrismaModule],
exports: [StripeService],
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository],
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository, MembershipsRepository],
controllers: [StripeController],
})
export class StripeModule {}
@@ -1,13 +1,14 @@
import { AppConfig } from "@/config/type";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Prisma } from "@prisma/client";
import type { Prisma, Credential } from "@prisma/client";
import Stripe from "stripe";
import { z } from "zod";
@@ -17,9 +18,17 @@ import { stripeKeysResponseSchema } from "./utils/stripeDataSchemas";
import stringify = require("qs-stringify");
type IntegrationOAuthCallbackState = {
accessToken: string;
returnTo: string;
onErrorReturnTo: string;
fromApp: boolean;
teamId?: number | null;
};
@Injectable()
export class StripeService {
public stripe: Stripe;
private stripe: Stripe;
private redirectUri = `${this.config.get("api.url")}/stripe/save`;
constructor(
@@ -27,13 +36,18 @@ export class StripeService {
private readonly config: ConfigService,
private readonly appsRepository: AppsRepository,
private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository
private readonly tokensRepository: TokensRepository,
private readonly membershipRepository: MembershipsRepository
) {
this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", {
apiVersion: "2020-08-27",
});
}
getStripe() {
return this.stripe;
}
async getStripeRedirectUrl(state: string, userEmail?: string, userName?: string | null) {
const { client_id } = await this.getStripeAppKeys();
@@ -76,6 +90,7 @@ export class StripeService {
async saveStripeAccount(state: string, code: string, accessToken: string): Promise<{ url: string }> {
const userId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
const oAuthCallbackState: IntegrationOAuthCallbackState = JSON.parse(state);
if (!userId) {
throw new UnauthorizedException("Invalid Access token.");
@@ -92,6 +107,19 @@ export class StripeService {
data["default_currency"] = account.default_currency;
}
if (oAuthCallbackState.teamId) {
await this.checkIfUserHasAdminAccessToTeam(oAuthCallbackState.teamId, userId);
await this.appsRepository.createTeamAppCredential(
"stripe_payment",
data as unknown as Prisma.InputJsonObject,
oAuthCallbackState.teamId,
"stripe"
);
return { url: getReturnToValueFromQueryState(state) };
}
await this.appsRepository.createAppCredential(
"stripe_payment",
data as unknown as Prisma.InputJsonObject,
@@ -102,18 +130,39 @@ export class StripeService {
return { url: getReturnToValueFromQueryState(state) };
}
async checkIfStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
async checkIfIndividualStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const stripeCredentials = await this.credentialRepository.getByTypeAndUserId("stripe_payment", userId);
if (!stripeCredentials) {
return await this.validateStripeCredentials(stripeCredentials);
}
async checkIfTeamStripeAccountConnected(teamId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const stripeCredentials = await this.credentialRepository.getByTypeAndTeamId("stripe_payment", teamId);
return await this.validateStripeCredentials(stripeCredentials);
}
async checkIfUserHasAdminAccessToTeam(teamId: number, userId: number) {
const teamMembership = await this.membershipRepository.findMembershipByTeamId(teamId, userId);
const hasAdminAccessToTeam = teamMembership?.role === "ADMIN" || teamMembership?.role === "OWNER";
if (!hasAdminAccessToTeam) {
throw new BadRequestException("You must be team owner or admin to do this");
}
}
async validateStripeCredentials(
credentials?: Credential | null
): Promise<{ status: typeof SUCCESS_STATUS }> {
if (!credentials) {
throw new NotFoundException("Credentials for stripe not found.");
}
if (stripeCredentials.invalid) {
if (credentials.invalid) {
throw new BadRequestException("Invalid stripe credentials.");
}
const stripeKey = JSON.stringify(stripeCredentials.key);
const stripeKey = JSON.stringify(credentials.key);
const stripeKeyObject = JSON.parse(stripeKey);
const stripeAccount = await stripeInstance.accounts.retrieve(stripeKeyObject?.stripe_user_id);
@@ -82,7 +82,10 @@ export class WebhooksController {
}
@Get("/")
@ApiOperation({ summary: "Get all webooks (paginated)" })
@ApiOperation({
summary: "Get all webooks",
description: "Gets a paginated list of webhooks for the authenticated user.",
})
async getWebhooks(
@GetUser() user: UserWithProfile,
@Query() query: SkipTakePagination
+432 -116
View File
@@ -3162,6 +3162,7 @@
"post": {
"operationId": "BookingsController_2024_08_13_cancelBooking",
"summary": "Cancel a booking",
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.",
"parameters": [
{
"name": "cal-api-version",
@@ -3257,6 +3258,230 @@
]
}
},
"/v2/bookings/{bookingUid}/reassign": {
"post": {
"operationId": "BookingsController_2024_08_13_reassignBooking",
"summary": "Automatically reassign booking to a new host",
"parameters": [
{
"name": "cal-api-version",
"in": "header",
"description": "Must be set to `2024-08-13`",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReassignBookingOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Bookings"
]
}
},
"/v2/bookings/{bookingUid}/reassign/{userId}": {
"post": {
"operationId": "BookingsController_2024_08_13_reassignBookingToUser",
"summary": "Reassign a booking to a specific user",
"parameters": [
{
"name": "cal-api-version",
"in": "header",
"description": "Must be set to `2024-08-13`",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "userId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReassignToUserBookingInput_2024_08_13"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReassignBookingOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Bookings"
]
}
},
"/v2/bookings/{bookingUid}/confirm": {
"post": {
"operationId": "BookingsController_2024_08_13_confirmBooking",
"summary": "Confirm booking that requires a confirmation",
"parameters": [
{
"name": "cal-api-version",
"in": "header",
"description": "Must be set to `2024-08-13`",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetBookingOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Bookings"
]
}
},
"/v2/bookings/{bookingUid}/decline": {
"post": {
"operationId": "BookingsController_2024_08_13_declineBooking",
"summary": "Decline booking that requires a confirmation",
"parameters": [
{
"name": "cal-api-version",
"in": "header",
"description": "Must be set to `2024-08-13`",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeclineBookingInput_2024_08_13"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetBookingOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Bookings"
]
}
},
"/v2/calendars/ics-feed/save": {
"post": {
"operationId": "CalendarsController_createIcsFeed",
@@ -4214,97 +4439,6 @@
]
}
},
"/v2/oauth/{clientId}/authorize": {
"post": {
"operationId": "OAuthFlowController_authorize",
"summary": "Authorize an OAuth client",
"description": "Redirects the user to the specified 'redirect_uri' with an authorization code in query parameter if the client is authorized successfully. The code is then exchanged for access and refresh tokens via the `/exchange` endpoint.",
"parameters": [
{
"name": "clientId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthAuthorizeInput"
}
}
}
},
"responses": {
"200": {
"description": "The user is redirected to the 'redirect_uri' with an authorization code in query parameter e.g. `redirectUri?code=secretcode.`"
},
"400": {
"description": "Bad request if the OAuth client is not found, if the redirect URI is invalid, or if the user has already authorized the client."
}
},
"tags": [
"OAuth"
]
}
},
"/v2/oauth/{clientId}/exchange": {
"post": {
"operationId": "OAuthFlowController_exchange",
"summary": "Exchange authorization code for access tokens",
"description": "Exchanges the authorization code received from the `/authorize` endpoint for access and refresh tokens. The authorization code should be provided in the 'Authorization' header prefixed with 'Bearer '.",
"parameters": [
{
"name": "Authorization",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
},
{
"name": "clientId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExchangeAuthorizationCodeInput"
}
}
}
},
"responses": {
"200": {
"description": "Successfully exchanged authorization code for access and refresh tokens.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KeysResponseDto"
}
}
}
},
"400": {
"description": "Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match."
}
},
"tags": [
"OAuth"
]
}
},
"/v2/oauth/{clientId}/refresh": {
"post": {
"operationId": "OAuthFlowController_refreshAccessToken",
@@ -5033,6 +5167,37 @@
]
}
},
"/v2/stripe/check/{teamId}": {
"get": {
"operationId": "StripeController_checkTeamStripeConnection",
"summary": "Check team stripe connection",
"parameters": [
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto"
}
}
}
}
},
"tags": [
"Stripe"
]
}
},
"/v2/timezones": {
"get": {
"operationId": "TimezonesController_getTimeZones",
@@ -5088,7 +5253,8 @@
},
"get": {
"operationId": "WebhooksController_getWebhooks",
"summary": "Get all webooks (paginated)",
"summary": "Get all webooks",
"description": "Gets a paginated list of webhooks for the authenticated user.",
"parameters": [
{
"name": "take",
@@ -5818,28 +5984,6 @@
}
}
},
"OAuthAuthorizeInput": {
"type": "object",
"properties": {
"redirectUri": {
"type": "string"
}
},
"required": [
"redirectUri"
]
},
"ExchangeAuthorizationCodeInput": {
"type": "object",
"properties": {
"clientSecret": {
"type": "string"
}
},
"required": [
"clientSecret"
]
},
"RefreshTokenInput": {
"type": "object",
"properties": {
@@ -6720,6 +6864,18 @@
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -6943,6 +7099,7 @@
},
"required": [
"lengthInMinutes",
"lengthInMinutesOptions",
"title",
"slug"
]
@@ -8085,6 +8242,18 @@
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -8308,6 +8477,7 @@
"required": [
"id",
"lengthInMinutes",
"lengthInMinutesOptions",
"title",
"slug",
"description",
@@ -8423,6 +8593,18 @@
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -8643,7 +8825,10 @@
"hideCalendarEventDetails": {
"type": "boolean"
}
}
},
"required": [
"lengthInMinutesOptions"
]
},
"UpdateEventTypeOutput_2024_06_14": {
"type": "object",
@@ -9902,6 +10087,18 @@
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -10138,6 +10335,7 @@
},
"required": [
"lengthInMinutes",
"lengthInMinutesOptions",
"title",
"slug",
"schedulingType",
@@ -10219,6 +10417,19 @@
"minimum": 1,
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "number",
"minimum": 1
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -10644,6 +10855,18 @@
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -10874,7 +11097,10 @@
"type": "boolean",
"description": "If true, all current and future team members will be assigned to this event type"
}
}
},
"required": [
"lengthInMinutesOptions"
]
},
"UpdateTeamEventTypeOutput": {
"type": "object",
@@ -12584,6 +12810,11 @@
"description": "The start time of the booking in ISO 8601 format in UTC timezone.",
"example": "2024-08-13T09:00:00Z"
},
"lengthInMinutes": {
"type": "number",
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
},
"eventTypeId": {
"type": "number",
"description": "The ID of the event type that is booked.",
@@ -12629,6 +12860,7 @@
},
"required": [
"start",
"lengthInMinutes",
"eventTypeId",
"attendee"
]
@@ -12641,6 +12873,11 @@
"description": "The start time of the booking in ISO 8601 format in UTC timezone.",
"example": "2024-08-13T09:00:00Z"
},
"lengthInMinutes": {
"type": "number",
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
},
"eventTypeId": {
"type": "number",
"description": "The ID of the event type that is booked.",
@@ -12691,6 +12928,7 @@
},
"required": [
"start",
"lengthInMinutes",
"eventTypeId",
"attendee",
"instant"
@@ -12704,6 +12942,11 @@
"description": "The start time of the booking in ISO 8601 format in UTC timezone.",
"example": "2024-08-13T09:00:00Z"
},
"lengthInMinutes": {
"type": "number",
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
},
"eventTypeId": {
"type": "number",
"description": "The ID of the event type that is booked.",
@@ -12754,6 +12997,7 @@
},
"required": [
"start",
"lengthInMinutes",
"eventTypeId",
"attendee"
]
@@ -13278,6 +13522,78 @@
"data"
]
},
"ReassignedToDto": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 123
},
"name": {
"type": "string",
"example": "John Doe"
},
"email": {
"type": "string",
"example": "john.doe@example.com"
}
},
"required": [
"id",
"name",
"email"
]
},
"ReassignBookingOutput_2024_08_13": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"oneOf": [
{
"$ref": "#/components/schemas/ReassignBookingOutput_2024_08_13"
}
],
"description": "Booking data, which can be either a ReassignAutoBookingOutput object or a ReassignManualBookingOutput object",
"allOf": [
{
"$ref": "#/components/schemas/ReassignBookingOutput_2024_08_13"
}
]
}
},
"required": [
"status",
"data"
]
},
"ReassignToUserBookingInput_2024_08_13": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"example": "Host has to take another call",
"description": "Reason for reassigning the booking"
}
}
},
"DeclineBookingInput_2024_08_13": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"example": "Host has to take another call",
"description": "Reason for declining a booking that requires a confirmation"
}
}
},
"ReserveSlotInput": {
"type": "object",
"properties": {
@@ -23,6 +23,14 @@ export class PlatformBillingRepositoryFixture {
});
}
async get(orgId: number) {
return this.prismaWriteClient.platformBilling.findFirst({
where: {
id: orgId,
},
});
}
async deleteSubscriptionForTeam(teamId: number) {
// silently try to delete the subscription
try {
@@ -22,6 +22,14 @@ export class BookingsRepositoryFixture {
return this.prismaReadClient.booking.findUnique({ where: { uid: bookingUid } });
}
async getByRecurringBookingUid(recurringBookingUid: string) {
return this.prismaReadClient.booking.findMany({
where: {
recurringEventId: recurringBookingUid,
},
});
}
async create(booking: Prisma.BookingCreateInput) {
return this.prismaWriteClient.booking.create({ data: booking });
}
-2
View File
@@ -8,8 +8,6 @@ import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME }
const ROUTES: [URLPattern, boolean][] = [
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
["/settings/developer/:path*", process.env.APP_ROUTER_SETTINGS_DEVELOPER_ENABLED === "1"] as const,
["/settings/my-account/:path*", process.env.APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED === "1"] as const,
["/settings/platform/:path*", process.env.APP_ROUTER_SETTINGS_PLATFORM_ENABLED === "1"] as const,
["/settings/organizations/:path*", process.env.APP_ROUTER_SETTINGS_ORG_ENABLED === "1"] as const,
["/apps/installed/:category", process.env.APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED === "1"] as const,
["/apps/:slug", process.env.APP_ROUTER_APPS_SLUG_ENABLED === "1"] as const,
@@ -5,7 +5,7 @@ import EditView from "~/settings/platform/oauth-clients/[clientId]/edit/edit-vie
export const generateMetadata = async () =>
await _generateMetadata(
() => "OAuth client updation form",
(t) => t("oAuth_client_updation_form"),
() => ""
);
@@ -5,7 +5,7 @@ import EditWebhooksView from "~/settings/platform/oauth-clients/[clientId]/edit/
export const generateMetadata = async () =>
await _generateMetadata(
() => "OAuth client updation form",
(t) => t("oAuth_client_updation_form"),
() => ""
);
@@ -0,0 +1,15 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import PlatformBillingUpgrade from "~/settings/platform/billing/billing-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("platform_billing"),
(t) => t("manage_billing_description")
);
};
export default WithLayout({
Page: PlatformBillingUpgrade,
});
@@ -0,0 +1,21 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import { type inferSSRProps } from "@lib/types/inferSSRProps";
import LegacyPage, { LayoutWrapper } from "~/settings/platform/new/create-new-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("set_up_your_platform_organization"),
(t) => t("platform_organization_description")
);
export default WithLayout({
getLayout: LayoutWrapper,
Page: LegacyPage,
getData: withAppDirSsr<inferSSRProps<typeof getServerSideProps>>(getServerSideProps),
requiresLicense: true,
});
@@ -0,0 +1,15 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import EditView from "~/settings/platform/oauth-clients/[clientId]/edit/edit-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("oAuth_client_updation_form"),
() => ""
);
export default WithLayout({
getLayout: null,
Page: EditView,
});
@@ -0,0 +1,15 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import EditWebhooksView from "~/settings/platform/oauth-clients/[clientId]/edit/edit-webhooks-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("webhook_update_form"),
() => ""
);
export default WithLayout({
getLayout: null,
Page: EditWebhooksView,
});
@@ -0,0 +1,16 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import CreateNewView from "~/settings/platform/oauth-clients/create-new-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("oAuth_client_creation_form"),
() => ""
);
};
export default WithLayout({
getLayout: null,
Page: CreateNewView,
});
+16
View File
@@ -0,0 +1,16 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import PlatformView from "~/settings/platform/platform-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("platform"),
(t) => t("platform_description")
);
};
export default WithLayout({
getLayout: null,
Page: PlatformView,
});
@@ -0,0 +1,15 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import PlatformPlansView from "~/settings/platform/plans/platform-plans-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${t("platform")} ${t("plans")}`,
() => ""
);
};
export default WithLayout({
Page: PlatformPlansView,
});
+2
View File
@@ -40,6 +40,8 @@ function PageWrapper(props: AppProps) {
pageStatus = "404";
} else if (router.pathname === "/500") {
pageStatus = "500";
} else if (router.pathname === "/403") {
pageStatus = "403";
}
// On client side don't let nonce creep into DOM
@@ -31,6 +31,8 @@ function PageWrapper(props: PageWrapperProps) {
pageStatus = "404";
} else if (pathname === "/500") {
pageStatus = "500";
} else if (pathname === "/403") {
pageStatus = "403";
}
// On client side don't let nonce creep into DOM
@@ -132,6 +132,7 @@ function BookingListItem(booking: BookingItemProps) {
});
const isUpcoming = new Date(booking.endTime) >= new Date();
const isOngoing = isUpcoming && new Date() >= new Date(booking.startTime);
const isBookingInPast = new Date(booking.endTime) < new Date();
const isCancelled = booking.status === BookingStatus.CANCELLED;
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
@@ -269,7 +270,7 @@ function BookingListItem(booking: BookingItemProps) {
});
}
if (isBookingInPast) {
if (isBookingInPast || isOngoing) {
editBookingActions.push({
id: "no_show",
label: t("mark_as_no_show"),
+32 -16
View File
@@ -106,10 +106,10 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
});
const roundRobinReassignMutation = trpc.viewer.teams.roundRobinReassign.useMutation({
onSuccess: async () => {
onSuccess: async (data) => {
await utils.viewer.bookings.get.invalidate();
setIsOpenDialog(false);
showToast(t("booking_reassigned"), "success");
showToast(t("booking_reassigned_to_host", { host: data?.reassignedTo.name }), "success");
},
onError: async (error) => {
if (error.message.includes(ErrorCode.NoAvailableUsersFound)) {
@@ -135,7 +135,13 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
},
});
const [showConfirmation, setShowConfirmation] = useState(false);
const [confirmationModal, setConfirmationModal] = useState<{
show: boolean;
membersStatus: "unavailable" | "available" | null;
}>({
show: false,
membersStatus: null,
});
const handleSubmit = (values: FormValues) => {
if (values.reassignType === "round_robin") {
@@ -143,13 +149,10 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
} else {
if (values.teamMemberId) {
const selectedMember = teamMemberOptions?.find((member) => member.value === values.teamMemberId);
if (selectedMember && selectedMember.status === "unavailable") {
setShowConfirmation(true);
} else {
roundRobinManualReassignMutation.mutate({
bookingId,
teamMemberId: values.teamMemberId,
reassignReason: values.reassignReason,
if (selectedMember) {
setConfirmationModal({
show: true,
membersStatus: selectedMember.status as "available" | "unavailable",
});
}
}
@@ -263,10 +266,16 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
</DialogContent>
</Dialog>
<Dialog open={showConfirmation} onOpenChange={setShowConfirmation}>
<Dialog
open={confirmationModal?.show}
onOpenChange={(open) => setConfirmationModal({ ...confirmationModal, show: open })}>
<ConfirmationDialogContent
variety="warning"
title={t("confirm_reassign_unavailable")}
variety={confirmationModal?.membersStatus === "unavailable" ? "warning" : "success"}
title={
confirmationModal?.membersStatus === "unavailable"
? t("confirm_reassign_unavailable")
: t("confirm_reassign_available")
}
confirmBtnText={t("yes_reassign")}
cancelBtnText={t("cancel")}
onConfirm={() => {
@@ -279,14 +288,21 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
teamMemberId,
reassignReason: form.getValues("reassignReason"),
});
setShowConfirmation(false);
setConfirmationModal({
show: false,
membersStatus: null,
});
}}>
<p className="mb-4">{t("reassign_unavailable_team_member_description")}</p>
<p className="mb-4">
{confirmationModal?.membersStatus === "unavailable"
? t("reassign_unavailable_team_member_description")
: t("reassign_available_team_member_description")}
</p>
<TextAreaField
name="reassignReason"
label={t("reassign_reason")}
onChange={(e) => form.setValue("reassignReason", e.target.value)}
required
required={confirmationModal?.membersStatus === "unavailable"}
/>
</ConfirmationDialogContent>
</Dialog>
+3 -3
View File
@@ -775,9 +775,9 @@ const RerouteDialogContentAndFooterWithFormResponse = ({
>([]);
const findTeamMembersMatchingAttributeLogicMutation =
trpc.viewer.appRoutingForms.findTeamMembersMatchingAttributeLogic.useMutation({
trpc.viewer.routingForms.findTeamMembersMatchingAttributeLogic.useMutation({
onSuccess(data) {
setTeamMembersMatchingAttributeLogic(data.result);
setTeamMembersMatchingAttributeLogic(data.result ? data.result.users : data.result);
},
});
@@ -798,7 +798,7 @@ const RerouteDialogContentAndFooterWithFormResponse = ({
findTeamMembersMatchingAttributeLogicMutation.mutate({
formId: form.id,
response: currentResponse,
routeId: route.id,
route,
});
}
@@ -164,28 +164,6 @@ vi.mock("@calcom/trpc/react", () => ({
isPending: false,
})),
},
findTeamMembersMatchingAttributeLogic: {
useMutation: vi.fn(({ onSuccess }) => {
return {
mutate: vi.fn(() => {
onSuccess({
result: [
{
id: 1,
name: "Matching User 1",
email: "matching-user-1@example.com",
},
{
id: 2,
name: "Matching User 2",
email: "matching-user-2@example.com",
},
],
});
}),
};
}),
},
},
eventTypes: {
get: {
@@ -204,11 +182,37 @@ vi.mock("@calcom/trpc/react", () => ({
})),
},
},
routingForms: {
findTeamMembersMatchingAttributeLogic: {
useMutation: vi.fn(({ onSuccess }) => {
return {
mutate: vi.fn(() => {
onSuccess({
result: {
users: [
{
id: 1,
name: "Matching User 1",
email: "matching-user-1@example.com",
},
{
id: 2,
name: "Matching User 2",
email: "matching-user-2@example.com",
},
],
},
});
}),
};
}),
},
},
},
},
}));
const mockMutateFn = vi.fn(({ __testOnSuccess }) => {
const mockReactQueryMutateFn = vi.fn(({ __testOnSuccess }) => {
__testOnSuccess({
uid: "RESCHEDULED_BOOKING_UID_SAME_TIMESLOT",
});
@@ -218,7 +222,7 @@ vi.mock("@tanstack/react-query", () => ({
useMutation: vi.fn(({ onSuccess }) => {
return {
mutate: vi.fn((payload) => {
mockMutateFn({
mockReactQueryMutateFn({
...payload,
__testOnSuccess: onSuccess,
});
@@ -441,7 +445,7 @@ describe("RerouteDialog", () => {
);
clickVerifyNewRouteButton();
clickRescheduleWithSameTimeslotOfChosenEventButton();
expect(mockMutateFn).toHaveBeenCalledWith(
expect(mockReactQueryMutateFn).toHaveBeenCalledWith(
expect.objectContaining({
rescheduleUid: mockBooking.uid,
// Shouldn't include the user who booked the booking
@@ -1,82 +0,0 @@
import type { TFunction } from "next-i18next";
import { Trans } from "next-i18next";
import { useState } from "react";
import {
SettingsToggle,
Dialog,
DialogContent,
DialogFooter,
InputField,
DialogClose,
Button,
} from "@calcom/ui";
interface DisableEmailsSettingProps {
checked: boolean;
onCheckedChange: (e: boolean) => void;
recipient: "attendees" | "hosts";
t: TFunction;
}
export const DisableAllEmailsSetting = ({
checked,
onCheckedChange,
recipient,
t,
}: DisableEmailsSettingProps) => {
const [dialogOpen, setDialogOpen] = useState(false);
const [confirmText, setConfirmText] = useState("");
const title =
recipient === "attendees" ? t("disable_all_emails_to_attendees") : t("disable_all_emails_to_hosts");
return (
<div>
<Dialog open={dialogOpen} onOpenChange={(e) => setDialogOpen(e)}>
<DialogContent title={title} Icon="circle-alert">
<p className="text-default text-sm">
<Trans i18nKey="disable_attendees_emails_description">
This will disable all emails to {{ recipient }}. This includes booking confirmations, requests,
reschedules and reschedule requests, cancellation emails, and any other emails related to
booking updates.
<br />
<br />
It is your responsibility to ensure that your {{ recipient }} are aware of any bookings and
changes to their bookings.
</Trans>
</p>
<p className="text-default mb-1 mt-2 text-sm">{t("type_confirm_to_continue")}</p>
<InputField
value={confirmText}
onChange={(e) => {
setConfirmText(e.target.value);
}}
/>
<DialogFooter>
<DialogClose />
<Button
disabled={confirmText !== "confirm"}
onClick={(e) => {
onCheckedChange(true);
setDialogOpen(false);
}}>
{t("disable_email")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={title}
description={t("disable_all_emails_description")}
checked={!!checked}
onCheckedChange={() => {
checked ? onCheckedChange(!checked) : setDialogOpen(true);
}}
/>
</div>
);
};
@@ -8,12 +8,12 @@ type IndividualPlatformPlan = {
// if pricing or plans change in future modify this
export const platformPlans: IndividualPlatformPlan[] = [
{
plan: "Starter",
plan: "Free",
description:
"Perfect for just getting started with community support and access to hosted platform APIs, Cal.com Atoms (React components) and more.",
pricing: 99,
"The best plan to dip your toes into scheduling infrastructure. Start immediately with 25 free meetings per month",
pricing: 0,
includes: [
"Up to 100 bookings a month",
"Up to 25 bookings a month",
"$0.99 overage beyond",
"Community Support",
"Cal Atoms (React Library)",
@@ -37,7 +37,7 @@ export const PlatformBillingCard = ({
</h1>
<p className="pb-5 pt-3 text-base">{description}</p>
<h1 className="text-3xl font-semibold">
{pricing && (
{pricing !== undefined && (
<>
US${pricing} <span className="text-sm">per month</span>
</>
@@ -50,7 +50,7 @@ export const PlatformBillingCard = ({
loading={isLoading}
onClick={handleSubscribe}
className="flex w-[100%] items-center justify-center">
{pricing ? "Subscribe" : "Schedule a time"}
{pricing !== undefined ? "Subscribe" : "Schedule a time"}
</Button>
</div>
)}
+5 -2
View File
@@ -1,9 +1,12 @@
import { CronJob } from "cron";
import dotEnv from "dotenv";
dotEnv.config({ path: "../../.env" });
async function fetchCron(endpoint: string) {
const apiKey = process.env.CRON_API_KEY;
const res = await fetch(`http://localhost:3000/api${endpoint}?${apiKey}`, {
const res = await fetch(`http://localhost:3000/api${endpoint}?apiKey=${apiKey}`, {
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.CRON_SECRET}`,
@@ -20,7 +23,7 @@ try {
"*/5 * * * * *",
async function () {
await Promise.allSettled([
fetchCron("/tasks/cron"),
fetchCron("/calendar-cache/cron"),
// fetchCron("/cron/calVideoNoShowWebhookTriggers"),
//
// fetchCron("/tasks/cleanup"),
@@ -0,0 +1,410 @@
import prismock from "../../../../tests/libs/__mocks__/prisma";
import { v4 } from "uuid";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
import bookingFormHandlers from "@calcom/app-store/routing-forms/appBookingFormHandler";
import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants";
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
import { SchedulingType } from "@calcom/prisma/enums";
import { getTeamMemberEmailForResponseOrContactUsingUrlQuery } from "../getTeamMemberEmailFromCrm";
vi.mock("@calcom/app-store/routing-forms/appBookingFormHandler", () => ({
default: {
salesforce: vi.fn(),
},
}));
vi.mock("@calcom/app-store/_utils/CRMRoundRobinSkip", () => ({
getCRMContactOwnerForRRLeadSkip: vi.fn(),
}));
function mockGetCRMContactOwnerForRRLeadSkip({
bookerEmail,
teamMemberEmail,
}: {
bookerEmail: string;
teamMemberEmail: string;
}) {
vi.mocked(getCRMContactOwnerForRRLeadSkip).mockImplementation((_bookerEmail, _eventMetadata) => {
if (_bookerEmail === bookerEmail) {
return Promise.resolve(teamMemberEmail);
}
return Promise.resolve(undefined);
});
}
function mockBookingFormHandler({
bookerEmail,
teamMemberEmail,
}: {
bookerEmail: string;
teamMemberEmail: string;
}) {
vi.mocked(bookingFormHandlers.salesforce).mockImplementation(
(_bookerEmail, _attributeRoutingConfig, _eventTypeId) => {
if (_bookerEmail === bookerEmail) {
return Promise.resolve({ email: teamMemberEmail });
}
return Promise.resolve({ email: null });
}
);
}
async function createRoutingFormWithResponse({
formData,
}: {
formData: {
routes: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryValue?: any;
action: {
eventTypeId: number;
type: RouteActionType;
value: string;
};
attributeRoutingConfig: {
skipContactOwner: boolean;
};
}[];
};
}) {
const user = await prismock.user.create({
data: {
name: "Routing Form User",
username: "routing-form-user",
email: "routing-form-user@example.com",
},
});
const form = await prismock.app_RoutingForms_Form.create({
data: {
...formData,
name: "Test Form",
routes: formData.routes.map((route) => ({
queryValue: {
id: v4(),
type: "group",
children1: [],
properties: {},
},
id: v4(),
...route,
})),
user: {
connect: {
id: user.id,
},
},
},
});
const responseRecord = await prismock.app_RoutingForms_FormResponse.create({
data: {
response: {},
form: {
connect: {
id: form.id,
},
},
},
});
return { form, responseRecord };
}
async function createHostForEvent({
eventTypeData,
userData,
}: {
eventTypeData: { id: number } & Partial<{
title: string;
slug: string;
length: number;
}>;
userData: {
email: string;
} & Partial<{
name: string;
username: string;
}>;
}) {
const user = await prismock.user.create({
data: {
name: "Test User",
username: "testuser",
...userData,
},
});
await prismock.eventType.create({
data: {
title: "Title",
slug: "slug",
length: 30,
...eventTypeData,
hosts: {
create: {
userId: user.id,
isFixed: true,
},
},
},
});
}
describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => {
beforeEach(() => {
vi.resetAllMocks();
});
const mockEventData = {
id: 1,
isInstantEvent: false,
schedulingType: SchedulingType.ROUND_ROBIN,
metadata: null,
length: 30,
};
it("should return null when email is not provided in query", async () => {
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {},
eventData: mockEventData,
});
expect(result).toBeNull();
});
it("should return null when scheduling type is not ROUND_ROBIN", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "teamMember@example.com";
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: { email: bookerEmail },
eventData: { ...mockEventData, schedulingType: SchedulingType.COLLECTIVE },
});
expect(result).toBeNull();
});
it("should return CRM owner email when valid", async () => {
const ownerEmail = "owner@example.com";
const bookerEmail = "booker@example.com";
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail: ownerEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: ownerEmail,
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: { email: "booker@example.com" },
eventData: mockEventData,
});
expect(result).toBe(ownerEmail);
});
it("should return null when CRM owner is not found", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "teamMember@example.com";
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail });
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: { email: bookerEmail },
eventData: mockEventData,
});
expect(result).toBeNull();
});
it("should return null when CRM owner is not part of event type", async () => {
const ownerEmail = "owner@example.com";
const bookerEmail = "booker@example.com";
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail: ownerEmail });
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: { email: bookerEmail },
eventData: mockEventData,
});
expect(result).toBeNull();
});
describe("Booking form handler", () => {
it("should return teamMember email through booking form handler when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: false },
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
});
expect(result).toBe(teamMemberEmail);
});
it("should return null when skipContactOwner is true even when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: true },
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
});
// Because skipContactOwner is true, the booking form handler should return null
expect(result).toBe(null);
});
it("should return null when when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided but the returned email isn't an event member", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
const SOME_OTHER_EVENT_ID = 200;
await createHostForEvent({
eventTypeData: {
id: SOME_OTHER_EVENT_ID,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: false },
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
});
expect(result).toBe(null);
});
it("should return null when cal.routingFormResponseId is provided but cal.salesforce.xxxx is not", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: false },
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.somethingelse.xxx": "true",
},
eventData: mockEventData,
});
expect(result).toBe(null);
});
});
});
+4 -1
View File
@@ -14,6 +14,7 @@ import type { ReactNode } from "react";
import { useEffect } from "react";
import CacheProvider from "react-inlinesvg/provider";
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
@@ -299,7 +300,9 @@ const AppProviders = (props: PageWrapperProps) => {
return (
<DynamicHelpscoutProvider>
<DynamicIntercomProvider>{RemainingProviders}</DynamicIntercomProvider>
<DynamicIntercomProvider>
<DynamicPostHogProvider>{RemainingProviders}</DynamicPostHogProvider>
</DynamicIntercomProvider>
</DynamicHelpscoutProvider>
);
};
+15 -1
View File
@@ -7,11 +7,13 @@ import type { SSRConfig } from "next-i18next";
import { appWithTranslation } from "next-i18next";
import { ThemeProvider } from "next-themes";
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
import dynamic from "next/dynamic";
import type { ParsedUrlQuery } from "querystring";
import type { PropsWithChildren, ReactNode } from "react";
import { useEffect } from "react";
import CacheProvider from "react-inlinesvg/provider";
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
@@ -56,6 +58,13 @@ export type AppProps = Omit<
err?: Error;
};
const PostHogPageView = dynamic(
() => import("@calcom/features/ee/event-tracking/lib/posthog/web/PostHogPageView"),
{
ssr: false,
}
);
type AppPropsWithChildren = AppProps & {
children: ReactNode;
};
@@ -319,7 +328,12 @@ const AppProviders = (props: AppPropsWithChildren) => {
return (
<DynamicHelpscoutProvider>
<DynamicIntercomProvider>{RemainingProviders}</DynamicIntercomProvider>
<DynamicIntercomProvider>
<DynamicPostHogProvider>
<PostHogPageView />
{RemainingProviders}
</DynamicPostHogProvider>
</DynamicIntercomProvider>
</DynamicHelpscoutProvider>
);
};
+241
View File
@@ -0,0 +1,241 @@
import type { ParsedUrlQuery } from "querystring";
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants";
import { enabledAppSlugs } from "@calcom/app-store/routing-forms/lib/enabledApps";
import type { AttributeRoutingConfig, LocalRoute } from "@calcom/app-store/routing-forms/types/types";
import { zodRoutes as routesSchema } from "@calcom/app-store/routing-forms/zod";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["getTeamMemberEmailFromCrm"] });
interface EventData {
id: number;
isInstantEvent: boolean;
schedulingType: SchedulingType | null;
metadata: Prisma.JsonValue | null;
length: number;
}
async function findUserByEmailWhoIsAHostOfEventType({
email,
eventTypeId,
}: {
email: string;
eventTypeId: number;
}) {
return prisma.user.findFirst({
where: {
email,
hosts: { some: { eventTypeId } },
},
});
}
function getRoutingFormResponseIdFromQuery(query: ParsedUrlQuery) {
const routingFormResponseIdAsNumber = Number(query[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]);
const routingFormResponseId = isNaN(routingFormResponseIdAsNumber) ? null : routingFormResponseIdAsNumber;
return routingFormResponseId;
}
async function getAttributeRoutingConfig(
data:
| {
routingFormResponseId: number;
eventTypeId: number;
}
| {
route: Pick<LocalRoute, "attributeRoutingConfig">;
}
) {
if ("route" in data) {
return data.route.attributeRoutingConfig ?? null;
}
const { routingFormResponseId, eventTypeId } = data;
const routingFormQuery = await prisma.app_RoutingForms_Form.findFirst({
where: {
responses: {
some: {
id: routingFormResponseId,
},
},
},
select: {
routes: true,
},
});
if (!routingFormQuery || !routingFormQuery?.routes) return null;
const parsedRoutes = routesSchema.safeParse(routingFormQuery.routes);
if (!parsedRoutes.success || !parsedRoutes.data) return null;
// Find the route with the attributeRoutingConfig
// FIXME: There could be multiple routes with same action.eventTypeId, we should actually ensure we have the chosenRouteId in here and use that route.
const route = parsedRoutes.data.find((route) => {
if ("action" in route) {
return route.action.eventTypeId === eventTypeId;
}
});
if (!route || !("attributeRoutingConfig" in route)) return null;
// Get attributeRoutingConfig for the form
const attributeRoutingConfig = route.attributeRoutingConfig;
if (!attributeRoutingConfig) return null;
return attributeRoutingConfig;
}
function getEnabledRoutingFormAppSlugFromQuery(query: ParsedUrlQuery) {
// Determine if a routing form enabled app is in the query. Then pass it to the proper handler
// Routing form apps will have the format cal.appSlug
let enabledRoutingFormApp;
for (const key of Object.keys(query)) {
const keySplit = key.split(".");
const appSlug = keySplit[1];
if (enabledAppSlugs.includes(appSlug)) {
enabledRoutingFormApp = appSlug;
break;
}
}
return enabledRoutingFormApp;
}
/**
* Uses the owner of the contact directly from CRM
*/
async function getOwnerEmailFromCrm(eventData: EventData, email: string): Promise<string | null> {
const crmContactOwnerEmail = await getCRMContactOwnerForRRLeadSkip(email, eventData.metadata);
if (!crmContactOwnerEmail) return null;
// Determine if the contactOwner is a part of the event type
const contactOwnerQuery = await findUserByEmailWhoIsAHostOfEventType({
email: crmContactOwnerEmail,
eventTypeId: eventData.id,
});
if (!contactOwnerQuery) return null;
return crmContactOwnerEmail;
}
/**
* Handles custom lookup field logic
*/
async function getTeamMemberEmailUsingRoutingFormHandler({
bookerEmail,
eventTypeId,
attributeRoutingConfig,
crmAppSlug,
}: {
bookerEmail: string;
eventTypeId: number;
attributeRoutingConfig: AttributeRoutingConfig | null;
crmAppSlug: string;
}) {
const nullReturnValue = { email: null, skipContactOwner: false };
if (!attributeRoutingConfig) return nullReturnValue;
// If the skipContactOwner is enabled then don't return an team member email
if (attributeRoutingConfig.skipContactOwner) return { ...nullReturnValue, skipContactOwner: true };
const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler"))
.default;
const appHandler = appBookingFormHandler[crmAppSlug];
if (!appHandler) return nullReturnValue;
const { email: userEmail } = await appHandler(bookerEmail, attributeRoutingConfig, eventTypeId);
if (!userEmail) return nullReturnValue;
// Determine if the user is a part of the event type
const userQuery = await findUserByEmailWhoIsAHostOfEventType({ email: userEmail, eventTypeId });
if (!userQuery) return nullReturnValue;
return { ...nullReturnValue, email: userEmail };
}
async function getTeamMemberEmailForResponseOrContact({
bookerEmail,
eventData,
routingFormResponseId,
chosenRoute,
crmAppSlug,
}: {
bookerEmail: string;
eventData: EventData;
routingFormResponseId?: number | null;
/**
* If provided, we won't go look for the route from DB.
*/
chosenRoute?: LocalRoute;
crmAppSlug?: string;
}) {
const eventTypeId = eventData.id;
if (eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return null;
const attributeRoutingConfigGetterData = routingFormResponseId
? { routingFormResponseId, eventTypeId }
: chosenRoute
? { route: chosenRoute }
: null;
// If we have found crmAppSlug, it means that the CRM App in the routing-form will handle the logic
if (attributeRoutingConfigGetterData && crmAppSlug) {
log.debug(
"Using CRM App handler in routing-forms",
safeStringify({ attributeRoutingConfigGetterData, crmAppSlug })
);
const attributeRoutingConfig = await getAttributeRoutingConfig(attributeRoutingConfigGetterData);
const { email, skipContactOwner } = await getTeamMemberEmailUsingRoutingFormHandler({
bookerEmail,
eventTypeId,
attributeRoutingConfig,
crmAppSlug,
});
if (skipContactOwner) return null;
if (email) return email;
} else {
log.debug("Getting the contact owner email from CRM");
return await getOwnerEmailFromCrm(eventData, bookerEmail);
}
return null;
}
export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query,
eventData,
chosenRoute,
}: {
query: ParsedUrlQuery;
eventData: EventData;
chosenRoute?: LocalRoute;
}) {
// Without email no lookup is possible
if (!query.email || typeof query.email !== "string") {
return null;
}
log.debug("getTeamMemberEmailForResponseOrContactUsingUrlQuery", safeStringify({ query }));
const crmAppSlug = getEnabledRoutingFormAppSlugFromQuery(query);
const routingFormResponseId = getRoutingFormResponseIdFromQuery(query);
return await getTeamMemberEmailForResponseOrContact({
bookerEmail: query.email,
eventData,
routingFormResponseId,
chosenRoute,
crmAppSlug,
});
}
+5 -4
View File
@@ -21,12 +21,13 @@ type HelpCardInfo = {
// if pricing or plans change in future modify this
export const platformPlans: IndividualPlatformPlan[] = [
{
plan: "Starter",
plan: "Free",
description:
"Perfect for just getting started with community support and access to hosted platform APIs, Cal.com Atoms (React components) and more.",
pricing: 99,
"The best plan to dip your toes into scheduling infrastructure. Start immediately with 25 free meetings per month",
pricing: 0,
includes: [
"Up to 100 bookings a month",
"Up to 25 bookings a month",
"$0.99 overage beyond",
"Community Support",
"Cal Atoms (React Library)",
"Platform APIs",
@@ -1,8 +1,6 @@
import type { Prisma } from "@prisma/client";
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
@@ -10,7 +8,6 @@ import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/org
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
@@ -79,6 +76,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
}
const eventData = team.eventTypes[0];
const eventTypeId = eventData.id;
let booking: GetBookingType | null = null;
if (rescheduleUid) {
@@ -88,11 +86,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const ssr = await ssrInit(context);
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
"@calcom/web/lib/getTeamMemberEmailFromCrm"
);
return {
props: {
eventData: {
eventTypeId: eventData.id,
eventTypeId,
entity: {
fromRedirectOfNonOrgLink,
considerUnpublished: isUnpublished && !fromRedirectOfNonOrgLink,
@@ -112,36 +112,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
isInstantMeeting: eventData && queryIsInstantMeeting ? true : false,
themeBasis: null,
orgBannerUrl: team.parent?.bannerUrl ?? "",
teamMemberEmail: await getTeamMemberEmail(eventData, email as string),
teamMemberEmail: await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query,
eventData,
}),
},
};
};
async function getTeamMemberEmail(
eventData: {
id: number;
isInstantEvent: boolean;
schedulingType: SchedulingType | null;
metadata: Prisma.JsonValue | null;
length: number;
},
email?: string
): Promise<string | null> {
// Pre-requisites
if (!eventData || !email || eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return null;
const crmContactOwnerEmail = await getCRMContactOwnerForRRLeadSkip(email, eventData.metadata);
if (!crmContactOwnerEmail) return null;
// Determine if the contactOwner is a part of the event type
const contactOwnerQuery = await prisma.user.findFirst({
where: {
email: crmContactOwnerEmail,
hosts: {
some: {
eventTypeId: eventData.id,
},
},
},
});
if (!contactOwnerQuery) return null;
return crmContactOwnerEmail;
}
@@ -9,6 +9,7 @@ import {
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
@@ -55,6 +56,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
).profile
: null;
const calVideoLogo = profile?.organization
? await OrganizationRepository.findCalVideoLogoByOrgId({ id: profile.organization.id })
: null;
//daily.co calls have a 14 days exit buffer when a user enters a call when it's not available it will trigger the modals
const now = new Date();
const exitDate = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
@@ -134,6 +139,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
: bookingObj.user,
},
hasTeamPlan: !!hasTeamPlan,
calVideoLogo,
trpcState: ssr.dehydrate(),
},
};
@@ -315,8 +315,23 @@ export default function Success(props: PageProps) {
return t(`needs_to_be_confirmed_or_rejected${titleSuffix}`);
}
if (bookingInfo.user) {
return t(`${titlePrefix}emailed_you_and_attendees${titleSuffix}`, {
user: bookingInfo.user.name || bookingInfo.user.email,
const isHost = bookingInfo.user.id === session?.user?.id;
const isAttendee = bookingInfo.attendees.find((attendee) => attendee.email === session?.user?.email);
const attendee = bookingInfo.attendees[0]?.name || bookingInfo.attendees[0]?.email || "Nameless";
const host = bookingInfo.user.name || bookingInfo.user.email;
if (isHost) {
return t(`${titlePrefix}emailed_you_and_attendees${titleSuffix}`, {
user: attendee,
});
}
if (isAttendee) {
return t(`${titlePrefix}emailed_you_and_attendees${titleSuffix}`, {
user: host,
});
}
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
host,
attendee,
});
}
return t(`emailed_you_and_attendees${titleSuffix}`);
@@ -18,8 +18,9 @@ import { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/S
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import Shell from "@calcom/features/shell/Shell";
import { parseEventTypeColor } from "@calcom/lib";
import { APP_NAME } from "@calcom/lib/constants";
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -80,7 +81,7 @@ interface InfiniteEventTypeListProps {
group: InfiniteEventTypeGroup;
readOnly: boolean;
bookerUrl: string | null;
pages: { nextCursor: number | undefined; eventTypes: InfiniteEventType[] }[] | undefined;
pages: { nextCursor: number | null | undefined; eventTypes: InfiniteEventType[] }[] | undefined;
lockedByOrg?: boolean;
isPending?: boolean;
debouncedSearchTerm?: string;
@@ -257,6 +258,7 @@ export const InfiniteEventTypeList = ({
const router = useRouter();
const pathname = usePathname();
const searchParams = useCompatSearchParams();
const { copyToClipboard } = useCopy();
const [parent] = useAutoAnimate<HTMLUListElement>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
@@ -347,9 +349,14 @@ export const InfiniteEventTypeList = ({
group: { teamId: group?.teamId, parentId: group?.parentId },
},
(data) => {
if (!data) return { pages: [], pageParams: [] };
return {
pageParams: data?.pageParams ?? [],
pages: newOrder,
...data,
pages: newOrder.map((page) => ({
...page,
nextCursor: page.nextCursor ?? undefined,
})),
};
}
);
@@ -566,7 +573,7 @@ export const InfiniteEventTypeList = ({
StartIcon="link"
onClick={() => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(calLink);
copyToClipboard(calLink);
}}
/>
</Tooltip>
@@ -579,7 +586,7 @@ export const InfiniteEventTypeList = ({
StartIcon="venetian-mask"
onClick={() => {
showToast(t("private_link_copied"), "success");
navigator.clipboard.writeText(placeholderHashedLink);
copyToClipboard(placeholderHashedLink);
setPrivateLinkCopyIndices((prev) => {
const prevIndex = prev[type.slug] ?? 0;
prev[type.slug] = (prevIndex + 1) % type.hashedLink.length;
@@ -893,6 +900,7 @@ const InfiniteScrollMain = ({
}) => {
const searchParams = useCompatSearchParams();
const { data } = useTypedQuery(querySchema);
const orgBranding = useOrgBranding();
if (status === "error") {
return <Alert severity="error" title="Something went wrong" message={errorMessage} />;
@@ -911,6 +919,18 @@ const InfiniteScrollMain = ({
const activeEventTypeGroup =
eventTypeGroups.filter((item) => item.teamId === data.teamId) ?? eventTypeGroups[0];
const bookerUrl = orgBranding ? orgBranding?.fullDomain : WEBSITE_URL;
// If the event type group is the same as the org branding team, or the parent team, set the bookerUrl to the org branding URL
// This is to ensure that the bookerUrl is always the same as the one in the org branding settings
// This keeps the app working for personal event types that were not migrated to the org (rare)
if (
activeEventTypeGroup[0].teamId === orgBranding?.id ||
activeEventTypeGroup[0].parentId === orgBranding?.id
) {
activeEventTypeGroup[0].bookerUrl = bookerUrl;
}
return (
<>
{eventTypeGroups.length >= 1 && (
@@ -0,0 +1,48 @@
"use client";
import {
FailedBookingsByField,
RoutingFormResponsesTable,
RoutingKPICards,
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { RoutingInsightsFilters } from "@calcom/features/insights/filters/routing/FilterBar";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import InsightsLayout from "./layout";
export default function InsightsPage() {
const { t } = useLocale();
return (
<InsightsLayout>
<FiltersProvider>
<div className="mb-4 space-y-4">
<RoutingFormResponsesTable>
{/* We now render the "filters and KPI as a children of the table but we still need to pass the table instance to it so we can access column status in the toolbar.*/}
{(table) => (
<div className="header mb-4">
<div className="flex items-center justify-between">
<RoutingInsightsFilters table={table} />
</div>
<RoutingKPICards />
</div>
)}
</RoutingFormResponsesTable>
<FailedBookingsByField />
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
</InsightsLayout>
);
}
+34 -87
View File
@@ -14,107 +14,54 @@ import {
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { Filters } from "@calcom/features/insights/filters";
import Shell from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button, ButtonGroup } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import type { getServerSideProps } from "@lib/insights/getServerSideProps";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import InsightsLayout from "./layout";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function InsightsPage() {
const { t } = useLocale();
const { data: user } = trpc.viewer.me.useQuery();
const features = [
{
icon: <Icon name="users" className="h-5 w-5" />,
title: t("view_bookings_across"),
description: t("view_bookings_across_description"),
},
{
icon: <Icon name="refresh-ccw" className="h-5 w-5" />,
title: t("identify_booking_trends"),
description: t("identify_booking_trends_description"),
},
{
icon: <Icon name="user-plus" className="h-5 w-5" />,
title: t("spot_popular_event_types"),
description: t("spot_popular_event_types_description"),
},
];
return (
<div>
<Shell
withoutMain={false}
heading={t("insights")}
subtitle={t("insights_subtitle")}
title={t("insights")}
description={t("insights_subtitle")}>
<UpgradeTip
plan="team"
title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")}
features={features}
background="/tips/insights"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="minimal" href="https://go.cal.com/insights" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
{!user ? (
<></>
) : (
<FiltersProvider>
<Filters />
<InsightsLayout>
<FiltersProvider>
<Filters />
<div className="mb-4 space-y-4">
<BookingKPICards />
<div className="mb-4 space-y-4">
<BookingKPICards />
<BookingStatusLineChart />
<BookingStatusLineChart />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<PopularEventsTable />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<PopularEventsTable />
<AverageEventDurationChart />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MostBookedTeamMembersTable />
<LeastBookedTeamMembersTable />
</div>
<RecentFeedbackTable />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<HighestNoShowHostTable />
<HighestRatedMembersTable />
<LowestRatedMembersTable />
</div>
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
)}
</UpgradeTip>
</Shell>
</div>
<AverageEventDurationChart />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MostBookedTeamMembersTable />
<LeastBookedTeamMembersTable />
</div>
<RecentFeedbackTable />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<HighestNoShowHostTable />
<HighestRatedMembersTable />
<LowestRatedMembersTable />
</div>
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
</InsightsLayout>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import Shell from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button, ButtonGroup } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import type { getServerSideProps } from "@lib/insights/getServerSideProps";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function InsightsLayout({ children }: { children: React.ReactNode }) {
const { t } = useLocale();
const { data: user } = trpc.viewer.me.useQuery();
const features = [
{
icon: <Icon name="users" className="h-5 w-5" />,
title: t("view_bookings_across"),
description: t("view_bookings_across_description"),
},
{
icon: <Icon name="refresh-ccw" className="h-5 w-5" />,
title: t("identify_booking_trends"),
description: t("identify_booking_trends_description"),
},
{
icon: <Icon name="user-plus" className="h-5 w-5" />,
title: t("spot_popular_event_types"),
description: t("spot_popular_event_types_description"),
},
];
return (
<div>
<Shell
withoutMain={false}
heading={t("insights")}
subtitle={t("insights_subtitle")}
title={t("insights")}
description={t("insights_subtitle")}>
<UpgradeTip
plan="team"
title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")}
features={features}
background="/tips/insights"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="minimal" href="https://go.cal.com/insights" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
{!user ? null : children}
</UpgradeTip>
</Shell>
</div>
);
}
@@ -55,11 +55,11 @@ export default function PlatformBillingUpgrade() {
return (
<div>
<Shell
heading="Platform billing"
title="Platform billing"
heading={t("platform_billing")}
title={t("platform_billing")}
hideHeadingOnMobile
withoutMain={false}
subtitle="Manage all things billing"
subtitle={t("manage_billing_description")}
isPlatformUser={true}>
<>
<div className="border-subtle space-y-6 rounded-lg border px-6 py-8 text-sm sm:space-y-8">
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import Shell from "@calcom/features/shell/Shell";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants";
import { showToast } from "@calcom/ui";
@@ -30,6 +31,7 @@ import {
} from "../../../../../../../../packages/platform/utils/permissions";
export default function EditOAuthClient() {
const { t } = useLocale();
const router = useRouter();
const params = useParams<{ clientId: string }>();
const clientId = params?.clientId || "";
@@ -78,15 +80,15 @@ export default function EditOAuthClient() {
if (isPlatformUser && isPaidUser) {
return (
<div>
<Shell title="OAuth client updation form" isPlatformUser={true}>
<Shell title={t("oAuth_client_updation_form")} isPlatformUser={true}>
<div className="m-2 md:mx-14 md:mx-5">
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
<div className="flex w-full flex-col">
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
OAuth client updation form
{t("oAuth_client_updation_form")}
</h1>
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
This is the form to edit an existing OAuth client
{t("oAuth_client_updation_form_description")}
</p>
</div>
</div>
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import Shell from "@calcom/features/shell/Shell";
import { WebhookForm } from "@calcom/features/webhooks/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { showToast } from "@calcom/ui";
@@ -18,6 +19,7 @@ import NoPlatformPlan from "@components/settings/platform/dashboard/NoPlatformPl
import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes";
export default function EditOAuthClientWebhooks() {
const { t } = useLocale();
const router = useRouter();
const params = useParams<{ clientId: string }>();
const clientId = params?.clientId || "";
@@ -40,14 +42,16 @@ export default function EditOAuthClientWebhooks() {
if (isPlatformUser && isPaidUser) {
return (
<div>
<Shell title="OAuth client updation form" isPlatformUser={true}>
<Shell title={t("webhook_update_form")} isPlatformUser={true}>
<div className="m-2 md:mx-5">
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
<div className="flex w-full flex-col">
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
Webhook update form
{t("webhook_update_form")}
</h1>
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">Add a webhook to your OAuthClient.</p>
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
{t("webhook_update_form_description")}
</p>
</div>
</div>
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import Shell from "@calcom/features/shell/Shell";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions";
import { showToast } from "@calcom/ui";
@@ -18,6 +19,7 @@ import { OAuthClientForm } from "@components/settings/platform/oauth-clients/oau
export default function CreateOAuthClient() {
const searchParams = useCompatSearchParams();
const router = useRouter();
const { t } = useLocale();
const clientId = searchParams?.get("clientId") || "";
const { isUserLoading, isPlatformUser, isPaidUser } = useGetUserAttributes();
@@ -62,15 +64,15 @@ export default function CreateOAuthClient() {
if (isPlatformUser && isPaidUser) {
return (
<div>
<Shell title={`OAuth client ${!!clientId ? "updation" : "creation"} form`} isPlatformUser={true}>
<Shell title={t("oAuth_client_creation_form")} isPlatformUser={true}>
<div className="m-2 md:mx-5">
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
<div className="flex w-full flex-col">
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
OAuth client creation form
{t("oAuth_client_creation_form")}
</h1>
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
This is the form to create a new OAuth client
{t("oAuth_client_creation_form_description")}
</p>
</div>
</div>
@@ -24,7 +24,7 @@ export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function JoinCall(props: PageProps) {
const { t } = useLocale();
const { meetingUrl, meetingPassword, booking, hasTeamPlan } = props;
const { meetingUrl, meetingPassword, booking, hasTeamPlan, calVideoLogo } = props;
const [daily, setDaily] = useState<DailyCall | null>(null);
useEffect(() => {
@@ -103,10 +103,10 @@ export default function JoinCall(props: PageProps) {
<CalAiTranscribe />
</div>
<div style={{ zIndex: 2, position: "relative" }}>
{booking?.user?.organization?.calVideoLogo ? (
{calVideoLogo ? (
<img
className="min-w-16 min-h-16 fixed z-10 hidden aspect-square h-16 w-16 rounded-full sm:inline-block"
src={booking.user.organization.calVideoLogo}
src={calVideoLogo}
alt="My Org Logo"
style={{
top: 32,
+1 -1
View File
@@ -663,7 +663,7 @@ const nextConfig = {
if (!!process.env.NEXT_PUBLIC_SENTRY_DSN) {
plugins.push((nextConfig) =>
withSentryConfig(nextConfig, {
autoInstrumentServerFunctions: true,
autoInstrumentServerFunctions: false,
hideSourceMaps: true,
// disable source map generation for the server code
disableServerWebpackPlugin: !!process.env.SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN,
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.6.14",
"version": "4.7.4",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -64,6 +64,7 @@
"@radix-ui/react-switch": "^1.0.0",
"@radix-ui/react-toggle-group": "^1.0.0",
"@radix-ui/react-tooltip": "^1.0.0",
"@replexica/sdk": "^0.6.0",
"@sentry/nextjs": "^8.8.0",
"@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.35.0",
@@ -74,7 +75,7 @@
"@upstash/redis": "^1.21.0",
"@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1",
"@vercel/og": "^0.5.0",
"@vercel/og": "^0.6.3",
"accept-language-parser": "^1.5.0",
"async": "^3.2.4",
"bcp-47-match": "^2.0.3",
@@ -109,6 +110,8 @@
"nodemailer": "^6.7.8",
"nuqs": "^1.20.0",
"otplib": "^12.0.1",
"posthog-js": "^1.164.1",
"posthog-node": "^4.2.0",
"qrcode": "^1.5.1",
"raw-body": "^2.5.1",
"react": "^18.2.0",
+30
View File
@@ -0,0 +1,30 @@
import Head from "next/head";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
export default function AccessDenied() {
const { t } = useLocale();
return (
<div className="bg-subtle flex h-screen">
<Head>
<title>{`${t("access_denied")} | ${APP_NAME}`}</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="rtl: bg-default m-auto rounded-md p-10 text-right ltr:text-left">
<h1 className="font-cal text-emphasis text-6xl">403</h1>
<h2 className="text-emphasis mt-6 text-2xl font-medium">{t("dont_have_access_this_page")}</h2>
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">
{t("you_need_admin_or_owner_privileges_to_access")}
</p>
<Button href={WEBAPP_URL}>{t("go_back_home")}</Button>
</div>
</div>
);
}
AccessDenied.PageWrapper = PageWrapper;

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