Compare commits

..
104 Commits
Author SHA1 Message Date
GitHub Actions 4a96be4175 chore: release v5.2.12
Post release / release (push) Canceled after 0s
2025-05-07 19:34:41 +00:00
594bb606b8 perf: Refactor bookings queries for API (#16407)
* perf: Refactor bookings queries for API

* fix syntax

* fixing vars

* Inverted the filter by attendees if

* fixed prisma filter issue

* Defaulting list of emails to empty array

* Only running 2 queries if we have data to filter by

* Reverted changes that have been separated out to other PR

* fix: ts build error

* adds tests + extracts logic for units

* fix integration tests

* fix tests expectes

* Fix test

* switching back

* attempt : fix tests

* test: add test for duplicate bookings (#21165)

* feat: Add tests for booking and duration limits (#21158)

* Add tests for booking and duration limits in getSchedule.test.ts

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

* fix: use SchedulingType enum instead of string for schedulingType

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>

* test: add test to ensure no duplicate bookings when merging query results

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

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* fix: deduplicate bookings when merging results from multiple queries

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

* fix: ensure pagination works correctly when merging results from multiple queries (#21167)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: Tushar <tusharbhatt0135@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
2025-05-07 11:44:56 -07:00
Omar López 1c088f65c5 chore: Removes around app (#20650) 2025-05-07 14:41:46 -04:00
devin-ai-integration[bot]andkeith@cal.com 7d7a429780 feat: Add tests for booking and duration limits (#21158)
* Add tests for booking and duration limits in getSchedule.test.ts

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

* fix: use SchedulingType enum instead of string for schedulingType

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
2025-05-07 13:06:10 -04:00
GiridharandTushar Bhatt d3228441aa fix:decline-booking-reason-missing (#21144)
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-07 21:01:33 +05:30
Morgan 21d6bf1fca fix: api v2 routing form responses flaky tests (#21150) 2025-05-07 16:22:02 +03:00
a4134aa5a3 feat: add 'View Responses' menu item to Routing Form dropdown (#21114)
* feat: add 'View Responses' menu item to Routing Form dropdown

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

* fix: use nuqs createSerializer for URL generation

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

* refactor: move serializer to data-table lib and use proper type constants

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

* fix: type errors in serializers and FormActions

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

* fix: use parseAsArrayOf with parseAsJson for proper nuqs serialization

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

* fix: update to use dataTableQueryParamsSerializer with proper nuqs pattern

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

* refactor: extract parsers from DataTableProvider to be reused in serializers

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

* fix: remove deprecated activeFiltersSerializer and use empty array instead of null

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

* fix: only show View Responses menu when form id and teamId exist

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

* remove teamId condition

* refactor: improve viewResponses URL generation with cleaner conditional

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

* Update packages/features/data-table/lib/parsers.ts

* refactor: make constants in parsers.ts non-exported

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

* fix: add explicit type annotations to constants in parsers.ts

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

* fix: add explicit type annotations and export DEFAULT_PAGE_SIZE in parsers.ts

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

* fix: add explicit type assertions to DEFAULT_ACTIVE_FILTERS and DEFAULT_SORTING

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-05-07 13:10:21 +00:00
Anik Dhabal Babu 61397e5184 fix: e2e flakes (#20630)
* fix

* Update managed-event-types.e2e.ts

* fix
2025-05-07 18:04:06 +05:30
Somay Chauhan 6143cd60e5 feat: Add booking references API endpoints (#21127)
* feat: Add booking references API endpoints

* refactor: Implement booking-reference service and repositories

* Add description and reorder properties in BookingReference schema

* Refactor booking references service to separate output service
2025-05-07 12:30:53 +00:00
Eunjae Lee ac821f6b13 chore: convert booking_denormalized e2e test to integration test (#21148) 2025-05-07 14:23:25 +02:00
sean-brydon 2bce38fd2a feat(v3): table designs (#21147) 2025-05-07 09:54:36 +00:00
Hariom BalharaandOmar López 390ee79134 Reduce payload size (#21093)
Co-authored-by: Omar López <zomars@me.com>
2025-05-07 03:16:07 +00:00
cfc9f0bfc3 refactor: Consolidate date parameter extraction and booking period check logic (#21142)
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2025-05-06 16:33:22 -07:00
Morgan 58d5f2a17c fix: advanced text filters getBookings with kysely (#21143) 2025-05-06 21:08:14 +00:00
Anwar Sadath 37f271bd74 fix: delete organization user endpoint not working in api v2 (#21136) 2025-05-06 19:10:55 +00:00
devin-ai-integration[bot]andkeith@cal.com d770cfc65e perf: Optimize team bookings query by fetching data for multiple users at once (#21137)
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-05-06 11:47:06 -07:00
Eunjae Leeandsean-brydon ab8556790e feat: add denormalized table for BookingTimeStatus view (#20947)
* feat: add denormalized table for BookingTimeStatus

* Update packages/prisma/migrations/20240320000001_add_booking_time_status_denormalized/migration.sql

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

* add more indexes

* improvement

* update schema

* improve indexes

* apply feedback from mrge

* split update trigger for better perf

* remove unnecessary checks

* split migration into two

* less logging

* address feedback

* add updatedAt and userUsername

* remove timeStatus

* add deletion trigger

* clean up denorm table on row deletion at EventType or users

* add timeStatus back as a part of a new view

* keep user and eventType nullable

* rename triggers

* add tests

* clean up migrations

* update tests

* update tests

* update test clean up

* one more try

* similar updates

* use e2e test instead

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-05-06 20:34:45 +02:00
Hariom Balhara 3d89b37cc6 Adding already released embed version (#21033) 2025-05-06 15:19:47 +00:00
GitHub Actions 78b213321c chore: release v5.2.11
Post release / release (push) Canceled after 0s
2025-05-06 14:43:34 +00:00
Carina WollendorferandCarinaWolli 276ba1179f fix: improvements for scheduling Email reminders (#21129)
* improvements cron job

* improvements emailReminderManager

* order bookings for reminders

* fix cleaning up past cancelled reminders

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-06 15:23:48 +01:00
Hariom BalharaandAnik Dhabal Babu 0109d166a1 Update delegation-credential.md (#21124)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-05-06 13:29:13 +00:00
Morgan 779c014256 fix: get bookings advanced email and attendee filter (#21125)
* fix: get bookings advanced email and attendee filter

* bump libraries

* bump libraries
2025-05-06 09:28:57 +01:00
sean-brydonandUdit Takkar 073faf9e10 feat: Salesforce Event Type App v3 designs (#21100)
* section component

* more section UI

* wip

* core components styled

* all components using sections

* match icons to figma

* small inputs

* fix select small to be the same as small text

* center flex rows

* enforce inputs to be full spanning grid

* tidy up section container for single inputs

* fix select and i18n

* wip

* install available apps

* raw heading

* app install card

* fix app logos width x height

* fix gap

* match design for other button

* fix prop name

* push i18n string

* fix doc snippets

* fix docs

* chore: remove yarn.lock changes

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2025-05-06 08:21:20 +00:00
GitHub Actions d0e39cf2dd chore: release v5.2.10
Post release / release (push) Canceled after 0s
2025-05-06 02:50:01 +00:00
Benny Joo f9488a1866 fix: theme search param crashing booking page (#21120)
* fix

* fix
2025-05-06 00:31:40 +00:00
MorganandKeith Williams d4a3894a9e chore: get bookings with kysely (#21084)
* chore: get bookings with kysely

* fixup! chore: get bookings with kysely

* replace left joins with inner joins

* add missing filter and count with kysely

* main query done

* kysely use connection string

* fix attendee emails operator

* fix immutability and add attendee email filter

* chore: everything working but date filters

* limit offset

* chore: bump platform libraries

* fix bookingseat / attendee user email filter

* refactor, clean, and subquery updatedAt/createdAt

* fix import kysely types path

* fix missing references and payments fields

* create kysely module in apiv2

* fix before created at filter

* fix types

* update yarn.lock

* update yarn.lock

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-05-05 17:21:15 -07:00
Omar López 91288b41cd refactor: centralize feature flag operations in FeaturesRepository (#21032) 2025-05-05 19:06:41 -04:00
GitHub Actions 1aa2f68e73 chore: release v5.2.9
Post release / release (push) Canceled after 0s
2025-05-05 17:55:27 +00:00
Udit Takkar ad707b0ee4 chore: improve error message (#21112) 2025-05-05 16:41:44 +00:00
Alex van Andel 49a2f4d4f7 chore: Add proton.me to email provider list (#21117) 2025-05-05 16:09:43 +00:00
Benny Joo f576979345 perf: remove unnecessary routes from middleware (#21089)
* perf: Remove unnecessary routes from middleware

* remove tests not needed
2025-05-05 11:47:27 -04:00
Keith Williams b45399ab78 chore: Update CODEOWNERS for booking tRPC handlers (#21118) 2025-05-05 15:40:42 +00:00
306dc5e4fb fix: add back round robin data to insights/router-position (#21116)
* add back all data to router-position

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-05-05 15:30:55 +00:00
Anik Dhabal Babu 605304b93b fix: availability view error (#21115)
* fix: avaiability view error

* Update list.handler.ts
2025-05-05 14:41:17 +00:00
amritandTushar Bhatt d6a17b2b1e feat: booking URL validation to accept links without explicit mention of https:// (#21110)
* fix: trigger lingo.dev by removing duplicate value

* feat: allow urls without an explicit https://

* fixed the test that ensures malformed URLs like "http:/nope" are rejected

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-05 12:49:19 +00:00
Yadong (Adam) Zhang 4bee875a8c build(packages/features): resolved type error when building apps/web. (#21094) 2025-05-05 11:20:05 +00:00
Anik Dhabal Babu fe4ae3ae62 fix: {Organizer} placeholder in event title not updating on reschedule (#21034) 2025-05-05 10:22:29 +00:00
Anik Dhabal Babu a245578ff2 fix: meeting link fails when manually reassign (#21106)
* fix: meeting link fails when manually reassign

* update

* Update booking.ts
2025-05-05 09:32:56 +00:00
sean-brydonandEunjae Lee f5ec696e15 fix: button fixes v3 (#20557)
* fix button styling

* add button usage

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-05-05 09:31:14 +00:00
sean-brydonandUdit Takkar ec9384858b move form builder email input away from routing form widget (#21077)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-05-05 09:05:01 +00:00
Eunjae Lee 47613f796e feat: add membership creation date to Organization Member List table (CAL-5406) (#21008)
* feat: add membership creation date to Organization Member List table (CAL-5406)

This reverts commit 5aaca2b7aa.

* fill createdAt
2025-05-05 10:15:30 +02:00
luzpaz 446c780d85 fix: typos in apps/api/v2 (#21105)
Found via `codespell -q 3 -S "*.svg,./apps/web/public/static/locales,./packages/app-store/stripepayment/lib/currencyOptions.ts,./packages/lib/freeEmailDomainCheck/freeEmailDomains.ts" -L afterall,atleast,datea,fo,incase,ist,nam,notin,optionel,perview,reccuring`

Closes #21104
2025-05-04 15:58:37 +00:00
Shashwatt 615968c640 fix: add missing 'add' in profile username tip (#21049) (#21097) 2025-05-04 20:13:55 +05:30
Morgan fc89436b6a chore: bump platform libraries (#21101) 2025-05-04 10:13:28 +00:00
Joe Au-Yeung 3c1015cb11 Get correct userId (#21092) 2025-05-03 00:43:05 +00:00
Anik Dhabal Babu 46c513330c test: retry times for api v2 e2e test in CI (#21087)
* test: test retry times for api v2 test

* Update jest.setup-e2e.ts
2025-05-02 19:42:36 +00:00
sean-brydon ef1598faeb fix return (#21088) 2025-05-02 20:06:58 +01:00
Anik Dhabal Babu 42a6903057 fix: wrong info on cancelled email (#21085) 2025-05-02 17:19:19 +00:00
Morgan 0f0c2b8b92 feat: verified resources endpoint [v2] (#21006)
* feat: verified resources endpoint [v2]

* add tests and fixes

* chore: add endpoint throttler decorator and handling

* chore: code review comments

* chore: code review comments

* chore: code review comments

* bump platform libraries

* chore: add tests and fix issues
2025-05-02 13:37:19 +03:00
VijayandTushar Bhatt c400c36306 fix: between operator for attribute matching (#21031)
* update json logic for raqb between operator

* nit

* add tests

* e2e tests

* after merge: updated tests due to latest changes in routingforms v3

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-02 10:29:48 +00:00
sean-brydon 6410bd2eaf fix: rf v3 tidy up mobile issues (#21078)
* remove incomplete booking from mobile

* add incomplete booking to dropdown on mobile

* padding
2025-05-02 10:01:07 +00:00
Alex van Andel 2ddc5317b8 fix: disabled sms hanging, by calling resolve() (#21070) 2025-05-02 02:23:42 +00:00
Joe Au-Yeung 6fffe3377d refactor: Replace attendee email with user id in idempotency key (#21058)
* Use userId instead of attendee email

* Only write if booking is accepted

* Create `getBookingRequest` function

* Return already requested booking

* Type fixes

* Type fix

* Type fix

* Pass empty seatReferenceId

* Handle no userId

* Update test
2025-05-02 00:46:19 +01:00
GitHub Actions eedef35d02 chore: release v5.2.8
Post release / release (push) Canceled after 0s
2025-05-01 22:09:04 +00:00
Keith Williams 08d232a816 revert: "feat: Ability to set a locked timezone for event type (#20425)" (#21066)
This reverts commit 03240709dc.
2025-05-01 21:51:20 +00:00
Udit TakkarandSyed Ali Shahbaz d9b3f8657f feat: add setting to disable phone only sms notification (#21038)
* feat: add setting to disable phone only sms notification

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

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>

---------

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2025-05-01 18:18:52 +00:00
Eunjae Lee 99dc5837f5 revert: "fix: eventType filter on /insights (#20823)" (#21059)
This reverts commit 45904c07bc.
2025-05-01 15:51:49 +00:00
Tushar Bhatt 5a35540522 feat: show-utm-params (#20733)
* feat:utm

* locale

* yarn.lock

* fix-typecheck

* indentation

* edge-case

* Update apps/web/modules/bookings/views/bookings-single-view.tsx

* Update apps/web/modules/bookings/views/bookings-single-view.tsx
2025-05-01 14:46:42 +00:00
Keith Williams c01fc08f67 chore: Allow Foundation to approve on behalf of Consumer (#21057) 2025-05-01 14:37:35 +00:00
Peer Richelsen 7397722eb3 chore: fixed headline of timelessui (#21051)
* Update page.tsx

* Update apps/ui-playground/app/(home)/page.tsx
2025-05-01 13:23:22 +01:00
d51e03b14b feat: routing v3 design (#20607)
Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-05-01 10:35:17 +01:00
Anik Dhabal Babu c4af180a5b fix: attendeePhoneNumber not appearing in calendar event (#21035) 2025-05-01 07:48:16 +00:00
Hariom Balhara ee5f1dc879 fix unit test bug (#21046) 2025-05-01 07:05:12 +00:00
Rajiv Sahal 15b43176ff uncomment props (#20818) 2025-05-01 11:47:21 +05:30
Anik Dhabal Babu 03240709dc feat: Ability to set a locked timezone for event type (#20425)
* feat: ability to set locked timezone

* update

* Update update-event-type.input.ts

* Update get-event-type-public.output.ts

* Update documentation.json

* revert

* nit
2025-05-01 11:09:57 +05:30
Romit GabaniandHariom Balhara 7ee451ca02 fix: restricting platform user from creating another regular org (#20929)
* restricting platform user from creating another regular org

* changed as suggested, additional check of membership is accepted

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-05-01 03:59:32 +00:00
Alex van Andel 55b066c475 fix: cal.cache not working (#21044)
* fix: cal.cache not working

* Thanks mrge
2025-05-01 00:42:57 +00:00
Anik Dhabal Babu c52bbc4be3 fix: meeting link fails when host requests to reschedule (#20999)
* fix: meeting link fails when host requests to reschedule

* Update EventManager.ts
2025-04-30 17:18:48 +00:00
Morgan 31b1f31d82 chore: format calendar events to remove oauthClientIds (#21040) 2025-04-30 16:01:34 +01:00
Lauris Skraucis ab1c1bb015 fix: allow only platform organisation or non-platform organisation (#20972)
* fix: platform customers cant subscribe to organizations plan

* fix: organization customers cant subscribe to platform plan
2025-04-30 10:33:01 +02:00
45219f4736 fix: cleanup html reports when pr is merged/closed (#20089)
Co-authored-by: anik@cal.com <anik@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-04-29 18:48:05 +00:00
3296255f80 feat: /skip trial for teams (#20753)
* skip team trials

* add loading state

* update mocks

* cleanup

* add import oops

* fix tests files

---------

Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2025-04-29 11:48:41 +00:00
Ronit Raj 81b4651d6e fix: UI Bug - ToggleGroup (#20997)
* fix #20986: UI Bug - ToggleGroup

* some changes

* some changes
2025-04-29 03:01:20 +00:00
Hariom Balhara e3bd90c4f2 fix: Handle calendar-cache with Delegation Credentials
Fixes CAL-5372

# Delegation Credentials with CalendarCache.

Following content is a snapshot of the [internal document](https://calendso.slack.com/docs/T08B8KA2BNF/F08L5JYU3V3)



**Problem-1 :** 

CalendarCache needs SelectedCalendar records to work but SelectedCalendar record is only created when a user connects their calendar and then enables some calendar for conflict checking. Because with Delegation, no manual connection is done by any of the members, we need a way to create SelectedCalendar records automatically.

**Problem-2**

CalendarCache connects to credential(regular credential) which doesn’t exist for Delegation Credential scenario. Also, DelegationCredential is common for all the members(different from Credential which is different for different members) of the organization and we need to identify to which user the CalendarCache belongs.  

**Solution for both problems**
- Create credential records for Delegation Credentials as well - Through Cron(new - we could schedule it every 5mins)
- Now create SelectedCalendar  records for those Credential records -  Through another Cron(new - we could schedule it every 5mins)
- Now CalendarCache records will automatically be created for those SelectedCalendar records -existing cron

## Fixed some Delegation Credentials bugs unrelated to calendar-cache
- If DestinationCalendar wasn't set(which is possible only with Delegation Credentials), then Google Meet wasn't used as a conferencing app - [Added a test]
- If no SelectedCalendar is there but Google Calendar connection exists(possible only with Delegation Credential) then we were not doing conflict checking. It is expected to not do it for Regular Credentials, but for Delegation Credential we must check for conflict in that case too [Added a test]
- Earlier if a user has Regular Credential as well as Delegation Credential for the same external id which is the member email(say member1@acme.com) then availability were retrieved twice because we weren't deduplicating credentials as it wasn't a trivial thing to do. Now that is being done.


**Env Variables:**
Note this PR doesn't introduce any new env variable. The existing env variable has been added to .env.example. But if this env variable isn't already set, it must be set.

`CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY={SAME_AS_SET_FOR_V2_API}`

**Deployment Plan:**
1. Add Observability for SelectedCalendar when _error_ field is set
2. Follow https://github.com/calcom/cal.com/blob/calendar-cache-dwd-support/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/delegation-credential/delegation-credential.md#setting-up-delegation-credential-for-google-calendar-api to enable Delegation Credential for i.cal.com
3. Note that to be able to see the option to enable Delegation Credential for an organization, you need to enable `teamFeature` and `feature` for `delegation-credential`

## Automation Tests
- Introduced tests for calendar-cache.repository.ts
   - Tests all methods of the repository
- Added more tests for handleNewBooking/delegation-credential flow.
   - Added test to verify the bug fix when no DestinationCalendar exists and Google Meet should be used still 
- Added more tests for Google Calendar/CalendarService targeting DelegationCredential
- Added more tests for getCalendarsEvents. 
    - To test the new logic of calling getAvailability still if there are no selectedCalendars in case of Delegation Credential
    - Also introduced tests for `getAvailabitlityWithTimezones` which was an existing function but now has some new changes.
- Added tests for deduplication logic in CalendarManager.ts

## How to Test
Enable Calendar Cache and Delegation Credential feature for acme org through `features` and `teamFeatures` tables.
- Enable Delegation Credential for acme org
- Enable atleast 1 calendar for conflict checking for one of the users(say owner1)
- Ensure GOOGLE_WEBHOOK_TOKEN is set in .env file
- Ensure GOOGLE_WEBHOOK_URL is set to ngrok url of webapp in .env file
- Hit cron endpoint `curl http://localhost:3000/api/calendar-cache/cron\?apiKey\={API_KEY}` that would cache the freebusy result for the selected calendars



Followup 
- https://github.com/calcom/cal.com/pull/20698
- https://github.com/calcom/cal.com/pull/18619/files#r2046795643
2025-04-28 18:11:29 -03:00
Joe Au-YeungandOmar López 17f1932467 refactor: Use Salesforce GraphQL for account ownership routing (#20655)
Co-authored-by: Omar López <zomars@me.com>
2025-04-28 20:54:58 +00:00
Anik Dhabal Babu 1b04fe82a7 fix: hide orgnizer email in cal video sidebar (#21011) 2025-04-28 16:34:32 +00:00
Carina WollendorferandCarinaWolli ffb36509ff fix: schedule unscheduled emails with tasker in cron job (#20748)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-04-28 08:56:13 -07:00
Benny Joo dc361f7879 perf: SSR - /event-types and /event-types/[type] (#20952)
* add revalidation

* ssr for event-types

* wip

* wip

* fix types

* fix skeleton

* better error component

* make skeleton better

* no need to export

* call revalidations early

* remove revalidations if not needed

* address comments

* address
2025-04-28 16:58:53 +02:00
Udit Takkar 0b59a5d184 fix: encoding bug (#21004)
* fix: encoding bug

* fix: use escapeValue
2025-04-28 14:56:12 +00:00
Anik Dhabal Babu 54c17eb827 fix: unable to create meeting with ms teams (#20993)
* fix: few details not populate

* fix: unable to create meeting with ms teams

* Update CrmService.ts

* update
2025-04-28 13:41:02 +00:00
Anik Dhabal Babu bd1142ea7a fix: wrong translation value (#17923)
* feat: ability to remove attendees from booking page

* update

* fix
2025-04-28 13:16:18 +00:00
Hariom Balhara 26051833cf fix: Skip already completed org onboarding, as webhook request can come when invoice is paid again next month (#20984)
* there is nothing to do for repeat invoice payment

* there is nothing to do for repeat invoice payment
2025-04-28 18:36:14 +05:30
Somay Chauhan 1d8bc6ca49 feat: added hideOrganizerEmail to api/v2/event-types (#21005)
* feat: added `hideOrganizerEmail` to api/v2/event-types

* updated docs
2025-04-28 15:39:51 +03:00
Rajiv SahalandMorgan fbe8cf8223 fix: cancel platform subscription on dashboard (#20787)
* add new endpoint to cancel user subscription

* remove comments

* add delete button to frontend

* update correct request method

* fixup

* frontend for cancelling subscription

* better warning message

* better function name

* update locales

* better error handling in case stripe api call fails

* rename teamId to organizationId

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-04-28 08:41:25 +01:00
Anik Dhabal Babu ef8b3e83ac fix: few details not populate (#20987) 2025-04-28 04:47:56 +00:00
devin-ai-integration[bot]andhariom@cal.com 9d228a1d96 Remove 30 seats minimum text from organization setup page (#20983)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
2025-04-28 08:30:03 +05:30
Benny Joo 57df25505d perf: workflows—remove data fetching for metadata (#20734) 2025-04-26 12:21:11 +00:00
Hariom Balhara 31cbea0e23 Do not retrieve already persisted data as well (#20982) 2025-04-26 17:32:08 +05:30
Syed Ali Shahbaz edd80c056c Update sms-manager.ts (#20985) 2025-04-26 11:56:30 +00:00
Hariom Balhara 6ebf0db389 Handle acme.cal.local root path (#20981) 2025-04-26 08:35:23 -03:00
GitHub Actions 6f4e51b859 chore: release v5.2.7
Post release / release (push) Canceled after 0s
2025-04-26 01:52:58 +00:00
Omar López 4f5d0db792 docs: add JSDoc documentation to FeaturesRepository class and methods (#20980) 2025-04-26 01:44:37 +00:00
Keith Williams 8733bcaf7a perf: Add credentialId index to selectedCalendar (#20979) 2025-04-26 01:40:28 +00:00
Keith Williams 5aaca2b7aa revert: "feat: add membership creation date to Organization Member List table (CAL-5406) (#20595)" (#20978)
This reverts commit d21f7ccbc8.
2025-04-26 00:46:49 +00:00
Alex van Andel eacd05bf3b perf: Improve performance of period limits (#20974)
* perf: Improve performance of period limits

* Type fixes

* Fixed correct check for instanceof -> isDayjs
2025-04-25 21:07:18 +01:00
Hariom Balhara d51e0182b5 fix: dont persist last booking response, temporarily (#20975)
* dont persist last booking response

* remove dead code to fix ts error
2025-04-25 23:07:48 +05:30
Benny Joo 220897ba86 chore: better code - migrate getServerSideProps file directly to RSC for /apps (#20926)
* migrate getServerSideProps file directly to RSC for /apps

* fix
2025-04-25 06:41:21 -07:00
Benny Joo 78c0203a45 chore: leverage error.tsx in app router (#20955) 2025-04-25 13:55:52 +01:00
Syed Ali Shahbaz 720fd90066 fix: Don't delete Cal video room upon requesting reschedule (#20969)
* Update requestReschedule.handler.ts

* Update requestReschedule.handler.ts
2025-04-25 08:30:44 -03:00
Omar López ca8ee749a2 fix: double end-date parsing when saving availability (#20963)
* fix: double end-date parsing when saving availablity

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

* fix: cache hits in current and next month

* Update CalendarService.test.ts

---------

Signed-off-by: Omar López <zomars@me.com>
2025-04-24 19:33:55 -03:00
Anik Dhabal Babu ce2d3a4b00 fix: added order by (#20959) 2025-04-24 19:21:21 +00:00
Anik Dhabal Babu 2923684c7c fix: exclude email from replyTo (#20953)
* fix: exclude email from replyTo

* update

* Update scheduleEmailReminders.ts
2025-04-24 19:20:45 +00:00
Alex van Andel 879ae597be fix: Offset is required but not passed everywhere, default to zero. (#20917) 2025-04-24 16:46:19 +01:00
d21f7ccbc8 feat: add membership creation date to Organization Member List table (CAL-5406) (#20595)
* feat: add membership creation date to Organization Member List table (CAL-5406)

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

* feat: add migration for membership creation date (CAL-5406)

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

* feat: make createdAt and updatedAt nullable (CAL-5406)

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

* feat: add updatedAt column to Organization Member List table (CAL-5406)

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

* fix: use type assertion to access createdAt and updatedAt fields (CAL-5406)

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

* fix: display N/A for null date values (CAL-5406)

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

* fix: use proper type assertions for createdAt and updatedAt fields (CAL-5406)

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

* fix: add createdAt and updatedAt to UserTableUser mock in test (CAL-5406)

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

* feat: add PostgreSQL trigger for membership timestamps (CAL-5406)

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

* fix: use empty string instead of N/A and add translations for column headers (CAL-5406)

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

* add i18n text

* clean up type issue

* feat: add translation keys for column headers (CAL-5406)

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

* disable sort

* remove duplicated i18n texts

* feat: add filters for lastActiveAt, createdAt, and updatedAt (CAL-5406)

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

* support date range filter

* fix date range for end date

* hide columns by default

* revert wrong change

* add missing selects

* fix e2e test

* fix: remove PostgreSQL trigger and let application handle timestamps (CAL-5406)

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

* feat: add application-level timestamp handling for Membership model (CAL-5406)

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

* add more timestamp handling

* refactor: use Prisma's built-in decorators for Membership timestamps (CAL-5406)

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

* refactor: remove application-level timestamp handling in favor of Prisma decorators (CAL-5406)

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

* refactor: remove more application-level timestamp handling in favor of Prisma decorators (CAL-5406)

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

* fix e2e test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-04-24 10:48:06 -03:00
462 changed files with 22956 additions and 4842 deletions
+5 -1
View File
@@ -443,4 +443,8 @@ NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
NEXT_PUBLIC_HEAD_SCRIPTS=
NEXT_PUBLIC_BODY_SCRIPTS=
DATABASE_CHUNK_SIZE=
DATABASE_CHUNK_SIZE=
# Service Account Encryption Key for encrypting/decrypting service account keys
# You can use: `openssl rand -base64 24` to generate one
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=
+7 -5
View File
@@ -1,9 +1,9 @@
/**/package.json @calcom/Foundation
/apps/api/**/* @calcom/Platform @calcom/Foundation
/apps/ui-playground/**/* @calcom/Consumer
/apps/web/components/**/* @calcom/Consumer
/apps/web/modules/**/* @calcom/Consumer
/apps/web/**/layout.tsx @calcom/Foundation
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
/apps/web/components/**/* @calcom/Consumer @calcom/Foundation
/apps/web/modules/**/* @calcom/Consumer @calcom/Foundation
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
/apps/web/lib/**/* @calcom/Foundation
/apps/web/middleware.ts @calcom/Foundation
/deploy/**/* @calcom/Foundation
@@ -26,5 +26,7 @@
/packages/lib/slots.ts @calcom/Foundation
/packages/platform/**/* @calcom/Platform @calcom/Foundation
/packages/prisma/**/* @calcom/Foundation
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
/packages/ui/**/* @calcom/Consumer
/packages/ui/**/* @calcom/Consumer @calcom/Foundation
+37
View File
@@ -0,0 +1,37 @@
on:
workflow_call:
pull_request:
types: [closed]
permissions:
contents: write
issues: write
pull-requests: write
jobs:
cleanup-report:
runs-on: ubuntu-latest
steps:
- name: Checkout GitHub Pages Branch
uses: actions/checkout@v4
with:
repository: calcom/test-results
ref: gh-pages
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: Set Git User
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Cleanup HTML reports on PR close/merge
env:
BRANCH_REPORTS_DIR: reports/${{ github.event.pull_request.head.ref }}
run: |
if [ -d "$BRANCH_REPORTS_DIR" ]; then
rm -rf "$BRANCH_REPORTS_DIR"
git add .
git commit -m "workflow: remove all reports for branch ${{ github.event.pull_request.head.ref }} (PR closed/merged)"
git push
else
echo "No reports found for branch ${{ github.event.pull_request.head.ref }}. Nothing to clean up."
fi
+11
View File
@@ -219,6 +219,17 @@ jobs:
uses: ./.github/workflows/publish-report.yml
secrets: inherit
cleanup-report:
name: Cleanup HTML report
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && (github.event.pull_request.merged == true || github.event.pull_request.state == 'closed') }}
permissions:
contents: write
issues: write
pull-requests: write
needs: [changes, check-label, publish-report]
uses: ./.github/workflows/cleanup-report.yml
secrets: inherit
required:
needs:
[
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
View File
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+15
View File
@@ -0,0 +1,15 @@
Possible issue:
- We won't be able to use already built calendar-cache when Delegation Credential is enabled because existing CalendarCache entries don't have userId set.
## Approach of always using SelectedCalendar.credentialId for CalendarCache even for DelegationCredentials
- Delete Credentials when DelegationCredential is disabled
TO Test
- New members beyond the batch size are processed
@@ -0,0 +1,37 @@
/**
* Constructs the WHERE clause for Prisma booking findMany operation.
*
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
*
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
*/
export function buildWhereClause(userId: number | null, attendeeEmails: string[], userIds: number[] = []) {
const filterByAttendeeEmails = attendeeEmails.length > 0;
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
let whereClause = {};
if (filterByAttendeeEmails) {
whereClause = {
AND: [
userFilter,
{
attendees: {
some: {
email: { in: attendeeEmails },
},
},
},
],
};
} else {
whereClause = {
...userFilter,
};
}
return whereClause;
}
+79 -57
View File
@@ -1,4 +1,4 @@
import type { Prisma } from "@prisma/client";
import type { Prisma, Booking } from "@prisma/client";
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
@@ -6,6 +6,7 @@ import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { withMiddleware } from "~/lib/helpers/withMiddleware";
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
import {
getAccessibleUsers,
retrieveOrgScopedAccessibleUsers,
@@ -151,58 +152,6 @@ type GetAdminArgsType = {
userId: number;
};
/**
* Constructs the WHERE clause for Prisma booking findMany operation.
*
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
*
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
*/
function buildWhereClause(
userId: number | null,
attendeeEmails: string[],
userIds: number[] = [],
userEmails: string[] = []
) {
const filterByAttendeeEmails = attendeeEmails.length > 0;
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
let whereClause = {};
if (filterByAttendeeEmails) {
whereClause = {
AND: [
userFilter,
{
attendees: {
some: {
email: { in: attendeeEmails },
},
},
},
],
};
} else {
whereClause = {
OR: [
userFilter,
{
attendees: {
some: {
email: { in: userEmails },
},
},
},
],
};
}
return whereClause;
}
export async function handler(req: NextApiRequest) {
const {
userId,
@@ -238,6 +187,7 @@ export async function handler(req: NextApiRequest) {
? [queryFilterForAttendeeEmails.attendeeEmail]
: [];
const filterByAttendeeEmails = attendeeEmails.length > 0;
let userEmailsToFilterBy: string[] = [];
/** Only admins can query other users */
if (isSystemWideAdmin) {
@@ -251,7 +201,8 @@ export async function handler(req: NextApiRequest) {
userId,
};
const { userId: argUserId, userIds, userEmails } = await handleSystemWideAdminArgs(systemWideAdminArgs);
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
userEmailsToFilterBy = userEmails;
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
}
} else if (isOrganizationOwnerOrAdmin) {
let requestedUserIds = [userId];
@@ -265,7 +216,8 @@ export async function handler(req: NextApiRequest) {
userId,
};
const { userId: argUserId, userIds, userEmails } = await handleOrgWideAdminArgs(orgWideAdminArgs);
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
userEmailsToFilterBy = userEmails;
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
} else {
const user = await prisma.user.findUnique({
where: { id: userId },
@@ -276,7 +228,7 @@ export async function handler(req: NextApiRequest) {
if (!user) {
throw new HttpError({ message: "User not found", statusCode: 404 });
}
args.where = buildWhereClause(userId, attendeeEmails, [], []);
args.where = buildWhereClause(userId, attendeeEmails, []);
}
if (dateFrom) {
@@ -317,7 +269,77 @@ export async function handler(req: NextApiRequest) {
}
}
const data = await prisma.booking.findMany(args);
let data: Booking[] = [];
if (!filterByAttendeeEmails && userEmailsToFilterBy.length > 0) {
const queryOne = prisma.booking.findMany(args);
const whereClauseForQueryTwo: Prisma.BookingWhereInput = {
attendees: {
some: {
email: { in: userEmailsToFilterBy },
},
},
};
if (dateFrom) {
whereClauseForQueryTwo.startTime = { gte: dateFrom };
}
if (dateTo) {
whereClauseForQueryTwo.endTime = { lte: dateTo };
}
if (status === "upcoming") {
whereClauseForQueryTwo.startTime = { gte: new Date().toISOString() };
}
const argsForQueryTwo: Prisma.BookingFindManyArgs = {
...args,
where: whereClauseForQueryTwo,
};
const queryTwo = prisma.booking.findMany(argsForQueryTwo);
const [resultOne, resultTwo] = await Promise.all([queryOne, queryTwo]);
const bookingMap = new Map();
[...resultOne, ...resultTwo].forEach((booking) => {
bookingMap.set(booking.id, booking);
});
const dedupedResults = Array.from(bookingMap.values());
if (args.orderBy) {
let sortField: keyof Booking;
let sortDirection: "asc" | "desc" = "asc";
if (typeof args.orderBy === "object" && !Array.isArray(args.orderBy)) {
const orderByKey = Object.keys(args.orderBy)[0] as keyof typeof args.orderBy;
sortField = orderByKey as keyof Booking;
sortDirection = args.orderBy[orderByKey] as "asc" | "desc";
} else {
sortField = "id";
}
const sortOrder = sortDirection === "desc" ? -1 : 1;
dedupedResults.sort((a, b) => {
const aValue = a[sortField];
const bValue = b[sortField];
if (aValue < bValue) return -1 * sortOrder;
if (aValue > bValue) return 1 * sortOrder;
return 0;
});
}
if (args.take !== undefined && args.skip !== undefined) {
data = dedupedResults.slice(args.skip, args.skip + args.take);
} else {
data = dedupedResults;
}
} else {
data = await prisma.booking.findMany(args);
}
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
}
@@ -185,7 +185,12 @@ async function verifyCredentialsAndGetId({
currentCredentialId: number | null;
}) {
if (parsedBody.integration && parsedBody.externalId) {
const calendarCredentials = getCalendarCredentialsWithoutDelegation(userCredentials);
const calendarCredentials = getCalendarCredentialsWithoutDelegation(
userCredentials.map((cred) => ({
...cred,
delegationCredentialId: null,
}))
);
const { connectedCalendars } = await getConnectedCalendars(
calendarCredentials,
+6 -1
View File
@@ -24,7 +24,12 @@ import { membershipCreateBodySchema, schemaMembershipPublic } from "~/lib/valida
*/
async function postHandler(req: NextApiRequest) {
const data = membershipCreateBodySchema.parse(req.body);
const args: Prisma.MembershipCreateArgs = { data };
const args: Prisma.MembershipCreateArgs = {
data: {
createdAt: new Date(),
...data,
},
};
await checkPermissions(req);
@@ -2,6 +2,7 @@ import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, it } from "vitest";
import { ZodError } from "zod";
import prisma from "@calcom/prisma";
@@ -206,8 +207,155 @@ describe("GET /api/bookings", async () => {
console.log("bookings=>", responseData.bookings);
responseData.bookings.forEach((booking) => {
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
if (booking.id === 19) expect(booking.eventType?.team).toBe(null);
if (booking.id === 19) {
// The team field can be either null or undefined due to nullish() in the schema
expect(booking.eventType?.team === null || booking.eventType?.team === undefined).toBe(true);
}
});
});
});
describe("Date filtering", () => {
it("filters bookings by dateFrom", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
dateFrom: "2023-01-01T00:00:00Z",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
responseData.bookings.forEach((booking) => {
expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(
new Date("2023-01-01T00:00:00Z").getTime()
);
});
});
it("filters bookings by dateTo", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
dateTo: "2024-12-31T23:59:59Z",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
responseData.bookings.forEach((booking) => {
expect(new Date(booking.endTime).getTime()).toBeLessThanOrEqual(
new Date("2024-12-31T23:59:59Z").getTime()
);
});
});
});
describe("Sorting and ordering", () => {
it("sorts bookings by createdAt in descending order", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
sortBy: "createdAt",
order: "desc",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
const timestamps = responseData.bookings.map((b) => new Date(b.createdAt).getTime());
expect(timestamps).toEqual([...timestamps].sort((a, b) => b - a));
});
});
describe("Multiple attendee email filtering", () => {
it("filters bookings by multiple attendee emails", async () => {
const attendeeEmails = ["test1@example.com", "test2@example.com"];
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
attendeeEmail: attendeeEmails,
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
responseData.bookings.forEach((booking) => {
const bookingAttendeeEmails = booking.attendees?.map((a) => a.email);
expect(bookingAttendeeEmails?.some((email) => attendeeEmails.includes(email))).toBe(true);
});
});
});
describe("Error cases", () => {
it("throws error for invalid status parameter", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
status: "invalid_status",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
await expect(handler(req)).rejects.toThrow(ZodError);
});
});
describe("Result merging", () => {
it("does not return duplicate bookings when merging results from multiple queries", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
const testUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const testUserBooking = await prisma.booking.findFirstOrThrow({
where: { userId: testUser.id },
include: { attendees: true },
});
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
userId: testUser.id, // This will make userEmailsToFilterBy contain the test user's email
},
pagination: {
take: 100,
skip: 0,
},
});
req.isSystemWideAdmin = true;
req.userId = adminUser.id;
const responseData = await handler(req);
const bookingIds = responseData.bookings.map((booking) => booking.id);
const uniqueBookingIds = new Set(bookingIds);
expect(uniqueBookingIds.size).toBe(bookingIds.length);
if (uniqueBookingIds.size !== bookingIds.length) {
const counts = bookingIds.reduce((acc, id) => {
acc[id] = (acc[id] || 0) + 1;
return acc;
}, {} as Record<number, number>);
const duplicates = Object.entries(counts)
.filter(([_, count]) => count > 1)
.map(([id]) => id);
console.log(`Found duplicate booking IDs: ${duplicates.join(", ")}`);
}
});
});
});
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
describe("buildWhereClause", () => {
it("should return empty object when no filters are provided", () => {
const result = buildWhereClause(null, [], []);
expect(result).toEqual({});
});
it("should filter by userId when only userId is provided", () => {
const userId = 123;
const result = buildWhereClause(userId, [], []);
expect(result).toEqual({ userId: 123 });
});
it("should filter by userIds when userIds array is provided", () => {
const userIds = [1, 2, 3];
const result = buildWhereClause(null, [], userIds);
expect(result).toEqual({ userId: { in: [1, 2, 3] } });
});
it("should filter by attendee emails when provided", () => {
const attendeeEmails = ["test@example.com", "user@example.com"];
const result = buildWhereClause(null, attendeeEmails, []);
expect(result).toEqual({
AND: [
{},
{
attendees: {
some: {
email: { in: ["test@example.com", "user@example.com"] },
},
},
},
],
});
});
it("should combine userId and attendee email filters", () => {
const userId = 123;
const attendeeEmails = ["test@example.com"];
const result = buildWhereClause(userId, attendeeEmails, []);
expect(result).toEqual({
AND: [
{ userId: 123 },
{
attendees: {
some: {
email: { in: ["test@example.com"] },
},
},
},
],
});
});
it("should combine userIds and attendee email filters", () => {
const userIds = [1, 2, 3];
const attendeeEmails = ["test@example.com"];
const result = buildWhereClause(null, attendeeEmails, userIds);
expect(result).toEqual({
AND: [
{ userId: { in: [1, 2, 3] } },
{
attendees: {
some: {
email: { in: ["test@example.com"] },
},
},
},
],
});
});
});
+1
View File
@@ -11,6 +11,7 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
"reporters": ["default", "jest-summarizing-reporter"],
"workerIdleMemoryLimit": "512MB",
"maxWorkers": 8
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.176",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.196",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -9,6 +9,7 @@ import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { BillingModule } from "@/modules/billing/billing.module";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { KyselyModule } from "@/modules/kysely/kysely.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
@Module({
imports: [
PrismaModule,
KyselyModule,
RedisModule,
TokensModule,
BillingModule,
@@ -13,6 +13,7 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { BillingService } from "@/modules/billing/services/billing.service";
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
@@ -99,6 +100,7 @@ export class BookingsController_2024_04_15 {
constructor(
private readonly oAuthFlowService: OAuthFlowService,
private readonly prismaReadService: PrismaReadService,
private readonly kyselyReadService: KyselyReadService,
private readonly oAuthClientRepository: OAuthClientRepository,
private readonly billingService: BillingService,
private readonly config: ConfigService,
@@ -129,6 +131,7 @@ export class BookingsController_2024_04_15 {
ctx: {
user: { email: user.email, id: user.id, orgId: profile?.organizationId },
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
kysely: this.kyselyReadService.kysely,
},
});
@@ -0,0 +1,20 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class BookingReferencesRepository_2024_08_13 {
constructor(private readonly dbRead: PrismaReadService) {}
async getBookingReferences(bookingId: number) {
return this.dbRead.prisma.bookingReference.findMany({
where: {
bookingId,
},
select: {
type: true,
uid: true,
id: true,
},
});
}
}
@@ -1,8 +1,11 @@
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
@@ -17,6 +20,7 @@ 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 { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { KyselyModule } from "@/modules/kysely/kysely.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
@@ -36,6 +40,7 @@ import { Module } from "@nestjs/common";
@Module({
imports: [
PrismaModule,
KyselyModule,
RedisModule,
TokensModule,
BillingModule,
@@ -56,6 +61,7 @@ import { Module } from "@nestjs/common";
BookingsService_2024_08_13,
InputBookingsService_2024_08_13,
OutputBookingsService_2024_08_13,
OutputBookingReferencesService_2024_08_13,
BookingsRepository_2024_08_13,
EventTypesRepository_2024_06_14,
BookingSeatRepository,
@@ -69,6 +75,8 @@ import { Module } from "@nestjs/common";
OrganizationsTeamsRepository,
OrganizationsRepository,
ErrorsBookingsService_2024_08_13,
BookingReferencesService_2024_08_13,
BookingReferencesRepository_2024_08_13,
],
controllers: [BookingsController_2024_08_13],
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
@@ -1,15 +1,19 @@
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
import { CalendarLinksOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
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 { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UsersService } from "@/modules/users/services/users.service";
@@ -82,7 +86,8 @@ export class BookingsController_2024_08_13 {
constructor(
private readonly bookingsService: BookingsService_2024_08_13,
private readonly usersService: UsersService
private readonly usersService: UsersService,
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
) {}
@Post("/")
@@ -386,4 +391,25 @@ export class BookingsController_2024_08_13 {
data: calendarLinks,
};
}
@Get("/:bookingUid/references")
@PlatformPlan("SCALE")
@UseGuards(ApiAuthGuard, BookingUidGuard)
@Permissions([BOOKING_READ])
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get 'Booking References' for a booking",
})
@HttpCode(HttpStatus.OK)
async getBookingReferences(
@Param("bookingUid") bookingUid: string,
@GetUser("id") userId: number
): Promise<BookingReferencesOutput_2024_08_13> {
const bookingReferences = await this.bookingReferencesService.getBookingReferences(bookingUid, userId);
return {
status: SUCCESS_STATUS,
data: bookingReferences,
};
}
}
@@ -385,7 +385,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -430,7 +430,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -459,7 +459,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -502,7 +502,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -583,7 +583,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.rating).toEqual(bookingInThePast.rating);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -642,7 +642,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
await bookingsRepositoryFixture.deleteById(mockBooking.id);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -0,0 +1,39 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum, IsNumber, IsString } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class BookingReference {
@IsString()
@ApiProperty({
description: "The type of the booking reference",
})
type!: string;
@IsString()
@ApiProperty({
description: "The external uid of the booking reference",
})
externalUid!: string;
@IsNumber()
@ApiProperty({
description: "The id of the booking reference",
})
id!: number;
}
export class BookingReferencesOutput_2024_08_13 {
@ApiProperty({
description: "The status of the request, always 'success' for successful responses",
example: SUCCESS_STATUS,
})
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
description: "Booking References",
type: [BookingReference],
})
data!: BookingReference[];
}
@@ -0,0 +1,41 @@
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
@Injectable()
export class BookingReferencesService_2024_08_13 {
constructor(
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly bookingReferencesRepository: BookingReferencesRepository_2024_08_13,
private readonly outputBookingReferencesService: OutputBookingReferencesService_2024_08_13
) {}
async getBookingReferences(bookingUid: string, userId: number) {
const booking = await this.bookingsRepository.getByUidWithUser(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
}
if (booking.user?.id !== userId) {
throw new BadRequestException(`Booking with uid ${bookingUid} does not belong to user`);
}
const bookingReferences = await this.bookingReferencesRepository.getBookingReferences(booking.id);
return this.outputBookingReferencesService.getOutputBookingReferences(bookingReferences);
}
async getOrgBookingReferences(bookingUid: string) {
const booking = await this.bookingsRepository.getByUidWithUser(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
}
const bookingReferences = await this.bookingReferencesRepository.getBookingReferences(booking.id);
return this.outputBookingReferencesService.getOutputBookingReferences(bookingReferences);
}
}
@@ -7,6 +7,7 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.
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 { KyselyReadService } from "@/modules/kysely/kysely-read.service";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
@@ -78,6 +79,7 @@ export class BookingsService_2024_08_13 {
private readonly bookingSeatRepository: BookingSeatRepository,
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly prismaReadService: PrismaReadService,
private readonly kyselyReadService: KyselyReadService,
private readonly billingService: BillingService,
private readonly usersService: UsersService,
private readonly usersRepository: UsersRepository,
@@ -385,6 +387,7 @@ export class BookingsService_2024_08_13 {
ctx: {
user,
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
kysely: this.kyselyReadService.kysely,
},
sort: this.inputService.transformGetBookingsSort(queryParams),
});
@@ -0,0 +1,18 @@
import { Injectable } from "@nestjs/common";
interface IBookingReference {
type: string;
uid: string;
id: number;
}
@Injectable()
export class OutputBookingReferencesService_2024_08_13 {
getOutputBookingReferences(bookingReferences: IBookingReference[]) {
return bookingReferences.map((bookingReference) => ({
type: bookingReference.type,
externalUid: bookingReference.uid,
id: bookingReference.id,
}));
}
}
@@ -22,7 +22,7 @@ import {
RecurringBookingOutput_2024_08_13,
SeatedAttendee,
} from "@calcom/platform-types";
import { Booking, BookingSeat } from "@calcom/prisma/client";
import { Booking, BookingReference, BookingSeat } from "@calcom/prisma/client";
export const bookingResponsesSchema = z
.object({
@@ -119,6 +119,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
teamId: null,
appId: APPLE_CALENDAR_ID,
invalid: false,
delegationCredentialId: null,
};
const dav = new CalendarService({
@@ -35,6 +35,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
teamId: null,
appId: ICS_CALENDAR,
invalid: false,
delegationCredentialId: null,
};
try {
@@ -490,6 +490,7 @@ describe("Event types Endpoints", () => {
requiresBookerEmailVerification: false,
hideCalendarNotes: false,
hideCalendarEventDetails: false,
hideOrganizerEmail: false,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
@@ -529,6 +530,7 @@ describe("Event types Endpoints", () => {
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(createdEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
body.lockTimeZoneToggleOnBookingPage
);
@@ -610,6 +612,7 @@ describe("Event types Endpoints", () => {
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
@@ -1009,6 +1012,7 @@ describe("Event types Endpoints", () => {
requiresBookerEmailVerification: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
hideOrganizerEmail: true,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
@@ -1073,6 +1077,7 @@ describe("Event types Endpoints", () => {
);
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(updatedEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
body.lockTimeZoneToggleOnBookingPage
);
@@ -1093,6 +1098,7 @@ describe("Event types Endpoints", () => {
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
eventType.hideOrganizerEmail = updatedEventType.hideOrganizerEmail;
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
eventType.color = updatedEventType.color;
eventType.bookingFields = updatedEventType.bookingFields;
@@ -1163,6 +1169,7 @@ describe("Event types Endpoints", () => {
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
@@ -1204,6 +1211,7 @@ describe("Event types Endpoints", () => {
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
@@ -87,6 +87,7 @@ type Input = Pick<
| "destinationCalendar"
| "useEventTypeDestinationCalendarEmail"
| "hideCalendarEventDetails"
| "hideOrganizerEmail"
>;
@Injectable()
@@ -123,6 +124,7 @@ export class OutputEventTypesService_2024_06_14 {
seatsShowAttendees,
useEventTypeDestinationCalendarEmail,
hideCalendarEventDetails,
hideOrganizerEmail,
} = databaseEventType;
const locations = this.transformLocations(databaseEventType.locations);
@@ -197,6 +199,7 @@ export class OutputEventTypesService_2024_06_14 {
destinationCalendar,
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
hideCalendarEventDetails,
hideOrganizerEmail,
};
}
@@ -0,0 +1,4 @@
import { RateLimitType } from "@/lib/throttler-guard";
import { Reflector } from "@nestjs/core";
export const Throttle = Reflector.createDecorator<RateLimitType>();
+16 -2
View File
@@ -1,5 +1,6 @@
import { getEnv } from "@/env";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
@@ -22,7 +23,7 @@ const rateLimitSchema = z.object({
ttl: z.number(),
blockDuration: z.number(),
});
type RateLimitType = z.infer<typeof rateLimitSchema>;
export type RateLimitType = z.infer<typeof rateLimitSchema>;
const rateLimitsSchema = z.array(rateLimitSchema);
const sixtySecondsMs = 60 * 1000;
@@ -52,7 +53,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
const { context } = requestProps;
const throttleOptions = this.reflector.get(Throttle, context.getHandler());
const request = context.switchToHttp().getRequest<Request>();
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
const response = context.switchToHttp().getResponse<Response>();
@@ -63,6 +64,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
);
if (throttleOptions) {
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
}
if (tracker.startsWith("api_key_")) {
return this.handleApiKeyRequest(tracker, response);
} else {
@@ -70,6 +75,15 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
}
}
private async handleApiEndpointThrottle(tracker: string, options: RateLimitType, response: Response) {
const { isBlocked } = await this.incrementRateLimit(`${tracker}_${options.name}`, options, response);
if (isBlocked) {
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
}
return true;
}
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
@@ -20,6 +20,7 @@ import {
HttpCode,
HttpStatus,
Logger,
Delete,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiExcludeController } from "@nestjs/swagger";
@@ -96,6 +97,19 @@ export class BillingController {
};
}
@Delete("/:organizationId/unsubscribe")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@MembershipRoles(["OWNER", "ADMIN"])
async cancelTeamSubscriptionInStripe(
@Param("organizationId") organizationId: number
): Promise<ApiResponse> {
await this.billingService.cancelTeamSubscription(organizationId);
return {
status: "success",
};
}
@Post("/webhook")
@HttpCode(HttpStatus.OK)
async stripeWebhook(
@@ -7,6 +7,7 @@ import { OrganizationsRepository } from "@/modules/organizations/index/organizat
import { StripeService } from "@/modules/stripe/stripe.service";
import { InjectQueue } from "@nestjs/bull";
import {
BadRequestException,
Injectable,
InternalServerErrorException,
Logger,
@@ -313,6 +314,28 @@ export class BillingService implements OnModuleDestroy {
}
}
async cancelTeamSubscription(teamId: number) {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
const customerId = teamWithBilling?.platformBilling?.customerId;
if (!customerId) {
throw new NotFoundException("No customer id found for team in Stripe");
}
if (!teamWithBilling?.platformBilling || !teamWithBilling?.platformBilling.subscriptionId) {
throw new NotFoundException("Team plan not found");
}
try {
await this.stripeService
.getStripe()
.subscriptions.cancel(teamWithBilling?.platformBilling?.subscriptionId);
} catch (error) {
this.logger.log(error, "error while cancelling team subscription in stripe");
throw new BadRequestException("Failed to cancel team subscription");
}
}
async onModuleDestroy() {
try {
await this.billingQueue.close();
@@ -11,6 +11,7 @@ import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/
import { RouterModule } from "@/modules/router/router.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
import { VerifiedResourcesModule } from "@/modules/verified-resources/verified-resources.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@@ -33,6 +34,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
OrganizationsUsersBookingsModule,
OrganizationsBookingsModule,
OrganizationsRoutingFormsModule,
VerifiedResourcesModule,
RouterModule,
],
})
@@ -0,0 +1,36 @@
import type { OnModuleDestroy } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { DeduplicateJoinsPlugin, Kysely, ParseJSONResultsPlugin, PostgresDialect } from "kysely";
import { Pool } from "pg";
import type { DB } from "@calcom/kysely/types";
@Injectable()
export class KyselyReadService implements OnModuleDestroy {
private logger = new Logger("KyselyReadService");
public kysely: Kysely<DB>;
constructor(readonly configService: ConfigService) {
const dbUrl = configService.get("db.readUrl", { infer: true });
const pool = new Pool({ connectionString: dbUrl });
// 3. Create the Dialect, passing the configured pool instance
const dialect = new PostgresDialect({
pool: pool, // Use the pool instance created above
});
this.kysely = new Kysely<DB>({
dialect,
plugins: [new ParseJSONResultsPlugin(), new DeduplicateJoinsPlugin()],
});
}
async onModuleDestroy() {
try {
await this.kysely.destroy();
} catch (error) {
this.logger.error(error);
}
}
}
@@ -0,0 +1,36 @@
import type { OnModuleDestroy } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Kysely, ParseJSONResultsPlugin, PostgresDialect, DeduplicateJoinsPlugin } from "kysely";
import { Pool } from "pg";
import type { DB } from "@calcom/kysely/types";
@Injectable()
export class KyselyWriteService implements OnModuleDestroy {
private logger = new Logger("KyselyWriteService");
public kysely: Kysely<DB>;
constructor(readonly configService: ConfigService) {
const dbUrl = configService.get("db.writeUrl", { infer: true });
const pool = new Pool({ connectionString: dbUrl });
// 3. Create the Dialect, passing the configured pool instance
const dialect = new PostgresDialect({
pool: pool, // Use the pool instance created above
});
this.kysely = new Kysely<DB>({
dialect,
plugins: [new ParseJSONResultsPlugin(), new DeduplicateJoinsPlugin()],
});
}
async onModuleDestroy() {
try {
await this.kysely.destroy();
} catch (error) {
this.logger.error(error);
}
}
}
@@ -0,0 +1,9 @@
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
import { KyselyWriteService } from "@/modules/kysely/kysely-write.service";
import { Module } from "@nestjs/common";
@Module({
providers: [KyselyReadService, KyselyWriteService],
exports: [KyselyReadService, KyselyWriteService],
})
export class KyselyModule {}
@@ -63,6 +63,7 @@ export class MembershipsRepository {
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
const membership = await this.dbRead.prisma.membership.create({
data: {
createdAt: new Date(),
role,
teamId,
userId,
@@ -340,6 +340,7 @@ describe("Organizations Event Types Endpoints", () => {
requiresBookerEmailVerification: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
hideOrganizerEmail: true,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
@@ -371,6 +372,7 @@ describe("Organizations Event Types Endpoints", () => {
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
expect(data.color).toEqual(body.color);
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
@@ -1099,6 +1101,7 @@ describe("Organizations Event Types Endpoints", () => {
requiresBookerEmailVerification: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
hideOrganizerEmail: true,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
@@ -1130,6 +1133,7 @@ describe("Organizations Event Types Endpoints", () => {
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
expect(data.color).toEqual(body.color);
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
@@ -70,6 +70,7 @@ type Input = Pick<
| "eventName"
| "useEventTypeDestinationCalendarEmail"
| "hideCalendarEventDetails"
| "hideOrganizerEmail"
| "team"
>;
@@ -126,36 +126,6 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
await app.init();
});
afterAll(async () => {
await prismaWriteService.prisma.app_RoutingForms_FormResponse.deleteMany({
where: {
formId: routingForm.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.apiKey.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: team.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: org.id,
},
});
await app.close();
});
describe(`GET /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
it("should not get routing form responses for non existing org", async () => {
return request(app.getHttpServer())
@@ -261,4 +231,44 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
});
});
});
afterAll(async () => {
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
where: {
id: routingFormResponse.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
where: {
id: routingFormResponse2.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.apiKey.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: team.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: org.id,
},
});
await prismaWriteService.prisma.user.delete({
where: {
id: user.id,
},
});
await app.close();
});
});
@@ -1,13 +1,17 @@
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import {
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
OPTIONAL_API_KEY_HEADER,
API_KEY_OR_ACCESS_TOKEN_HEADER,
} from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
@@ -20,7 +24,7 @@ import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SUCCESS_STATUS, BOOKING_READ } from "@calcom/platform-constants";
import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
@Controller({
@@ -33,7 +37,10 @@ import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsTeamsBookingsController {
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
constructor(
private readonly bookingsService: BookingsService_2024_08_13,
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
) {}
@Get("/")
@ApiOperation({ summary: "Get organization team bookings" })
@@ -56,4 +63,32 @@ export class OrganizationsTeamsBookingsController {
data: bookings,
};
}
@Get("/:bookingUid/references")
@PlatformPlan("SCALE")
@Roles("TEAM_ADMIN")
@Permissions([BOOKING_READ])
@UseGuards(
ApiAuthGuard,
BookingUidGuard,
IsOrgGuard,
RolesGuard,
IsTeamInOrg,
PlatformPlanGuard,
IsAdminAPIEnabledGuard
)
@ApiOperation({
summary: "Get 'Booking References' for a booking",
})
@HttpCode(HttpStatus.OK)
async getBookingReferences(
@Param("bookingUid") bookingUid: string
): Promise<BookingReferencesOutput_2024_08_13> {
const bookingReferences = await this.bookingReferencesService.getOrgBookingReferences(bookingUid);
return {
status: SUCCESS_STATUS,
data: bookingReferences,
};
}
}
@@ -1,4 +1,8 @@
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsTeamsBookingsController } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.controller";
@@ -10,7 +14,14 @@ import { Module } from "@nestjs/common";
@Module({
imports: [BookingsModule_2024_08_13, PrismaModule, StripeModule, RedisModule, MembershipsModule],
providers: [OrganizationsRepository, OrganizationsTeamsRepository],
providers: [
OrganizationsRepository,
OrganizationsTeamsRepository,
BookingReferencesService_2024_08_13,
BookingReferencesRepository_2024_08_13,
BookingsRepository_2024_08_13,
OutputBookingReferencesService_2024_08_13,
],
controllers: [OrganizationsTeamsBookingsController],
})
export class OrganizationsTeamsBookingsModule {}
@@ -69,7 +69,11 @@ export class OrganizationsTeamsMembershipsRepository {
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
return this.dbWrite.prisma.membership.create({
data: { ...data, teamId: teamId },
data: {
createdAt: new Date(),
...data,
teamId: teamId,
},
include: { user: { select: MembershipUserSelect } },
});
}
@@ -35,7 +35,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
let org: Team;
let orgTeam: Team;
let routingFormResponseId: string;
let routingFormResponseId: number;
const authEmail = `organizations-teams-routing-forms-responses-user-${randomString()}@api.com`;
let user: User;
let apiKeyString: string;
@@ -43,8 +43,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
let routingFormId: string;
const routingFormResponses = [
{
id: 1,
formFillerId: "cm78tvkvd0001kh8jq0tu5iq9",
formFillerId: `${randomString()}`,
response: {
"participant-field": {
label: "participant",
@@ -184,7 +183,6 @@ describe("Organizations Teams Routing Forms Responses", () => {
const responseData = responseBody.data;
expect(responseData).toBeDefined();
expect(responseData.length).toEqual(1);
expect(responseData[0].id).toEqual(routingFormResponses[0].id);
expect(responseData[0].response).toEqual(routingFormResponses[0].response);
expect(responseData[0].formFillerId).toEqual(routingFormResponses[0].formFillerId);
expect(responseData[0].createdAt).toEqual(routingFormResponses[0].createdAt.toISOString());
@@ -248,6 +246,8 @@ describe("Organizations Teams Routing Forms Responses", () => {
});
afterAll(async () => {
await routingFormsRepositoryFixture.deleteResponse(routingFormResponseId);
await routingFormsRepositoryFixture.delete(routingFormId);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
@@ -6,6 +6,7 @@ import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsRoutingFormInTeam } from "@/modules/auth/guards/routing-forms/is-routing-form-in-team.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { GetRoutingFormResponsesParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
@@ -29,6 +30,7 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
IsTeamInOrg,
IsRoutingFormInTeam,
PlatformPlanGuard,
RolesGuard,
IsAdminAPIEnabledGuard
)
@ApiHeader(API_KEY_HEADER)
@@ -0,0 +1,236 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
import {
TeamVerifiedEmailOutput,
TeamVerifiedEmailsOutput,
} from "@/modules/verified-resources/outputs/verified-email.output";
import {
TeamVerifiedPhoneOutput,
TeamVerifiedPhonesOutput,
} from "@/modules/verified-resources/outputs/verified-phone.output";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { TOTP as TOTPtoMock } from "@otplib/core";
import { User } from "@prisma/client";
import { totp } from "otplib";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.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";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { VerifiedResourcesRepositoryFixtures } from "test/fixtures/repository/verified-resources.repository.fixture";
import { randomString } from "test/utils/randomString";
import { AttendeeVerifyEmail } from "@calcom/platform-libraries/emails";
import { Team } from "@calcom/prisma/client";
jest.spyOn(totp, "generate").mockImplementation(function () {
return "1234";
});
jest.spyOn(TOTPtoMock.prototype, "check").mockImplementation(function () {
return true;
});
jest
.spyOn(AttendeeVerifyEmail.prototype as any, "getNodeMailerPayload")
.mockImplementation(async function () {
return {
to: `testnotrealemail@notreal.com`,
from: `testnotrealemail@notreal.com`,
subject: "No Subject",
html: "<html><body>Mocked Email Content</body></html>",
text: "body",
};
});
describe("Organizations Teams Verified Resources", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let verifiedResourcesRepositoryFixtures: VerifiedResourcesRepositoryFixtures;
let teamsRepositoryFixture: TeamRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let orgTeam: Team;
const emailToVerify = `org-team-e2e-verified-resources-${randomString()}@example.com`;
const phoneToVerify = "+37255556666";
const authEmail = `organizations-verified-resources-responses-user-${randomString()}@api.com`;
let user: User;
let apiKeyString: string;
let verifiedEmailId: number;
let verifiedPhoneId: number;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
}).compile();
verifiedResourcesRepositoryFixtures = new VerifiedResourcesRepositoryFixtures(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: `organizations-verified-resources-responses-organization-${randomString()}`,
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: authEmail,
username: authEmail,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = keyString;
orgTeam = await teamsRepositoryFixture.create({
name: `organizations-verified-resources-responses-team-${randomString()}`,
isOrganization: false,
parent: { connect: { id: org.id } },
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: orgTeam.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: authEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
verifiedPhoneId = (
await verifiedResourcesRepositoryFixtures.createPhone({
phoneNumber: phoneToVerify,
user: { connect: { id: user.id } },
team: {
connect: {
id: orgTeam.id,
},
},
})
).id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should trigger email verification code", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/verification-code/request`
)
.send({ email: emailToVerify } satisfies RequestEmailVerificationInput)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200);
});
it("should verify email", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/verification-code/verify`
)
.send({ email: emailToVerify, code: "1234" } satisfies VerifyEmailInput)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((res) => {
const response = res.body as TeamVerifiedEmailOutput;
verifiedEmailId = response.data.id;
expect(response.data.email).toEqual(emailToVerify);
expect(response.data.teamId).toEqual(orgTeam.id);
});
});
it("should fetch verified email by id", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/${verifiedEmailId}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((res) => {
const response = res.body as TeamVerifiedEmailOutput;
expect(response.data.email).toEqual(emailToVerify);
expect(response.data.teamId).toEqual(orgTeam.id);
});
});
it("should fetch verified number by id", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/phones/${verifiedPhoneId}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((res) => {
const response = res.body as TeamVerifiedPhoneOutput;
expect(response.data.phoneNumber).toEqual(phoneToVerify);
expect(response.data.teamId).toEqual(orgTeam.id);
});
});
it("should fetch verified emails", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((res) => {
const response = res.body as TeamVerifiedEmailsOutput;
expect(response.data.find((verifiedEmail) => verifiedEmail.email === emailToVerify)).toBeDefined();
expect(response.data.find((verifiedEmail) => verifiedEmail.teamId === orgTeam.id)).toBeDefined();
});
});
it("should fetch verified phones", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/phones`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((res) => {
const response = res.body as TeamVerifiedPhonesOutput;
expect(
response.data.find((verifiedPhone) => verifiedPhone.phoneNumber === phoneToVerify)
).toBeDefined();
expect(response.data.find((verifiedPhone) => verifiedPhone.teamId === orgTeam.id)).toBeDefined();
});
});
afterAll(async () => {
await verifiedResourcesRepositoryFixtures.deleteEmailById(verifiedEmailId);
await verifiedResourcesRepositoryFixtures.deletePhoneById(verifiedPhoneId);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
@@ -0,0 +1,256 @@
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
import { RequestPhoneVerificationInput } from "@/modules/verified-resources/inputs/request-phone-verification.input";
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
import { VerifyPhoneInput } from "@/modules/verified-resources/inputs/verify-phone.input";
import { RequestEmailVerificationOutput } from "@/modules/verified-resources/outputs/request-email-verification-output";
import { RequestPhoneVerificationOutput } from "@/modules/verified-resources/outputs/request-phone-verification-output";
import {
TeamVerifiedEmailOutput,
TeamVerifiedEmailOutputData,
TeamVerifiedEmailsOutput,
} from "@/modules/verified-resources/outputs/verified-email.output";
import {
TeamVerifiedPhoneOutput,
TeamVerifiedPhoneOutputData,
TeamVerifiedPhonesOutput,
} from "@/modules/verified-resources/outputs/verified-phone.output";
import { VerifiedResourcesService } from "@/modules/verified-resources/services/verified-resources.service";
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId/verified-resources",
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiTags("Organization Team Verified Resources")
export class OrgTeamsVerifiedResourcesController {
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
@ApiOperation({
summary: "Request Email Verification Code",
description: `Sends a verification code to the email.`,
})
@Roles("TEAM_ADMIN")
@Throttle({
limit: 3,
ttl: 60000,
blockDuration: 60000,
name: "org_teams_verified_resources_emails_requests",
})
@PlatformPlan("ESSENTIALS")
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/emails/verification-code/request")
@HttpCode(HttpStatus.OK)
async requestEmailVerificationCode(
@Body() body: RequestEmailVerificationInput,
@GetUser("username") username: string,
@GetUser("locale") locale: string
): Promise<RequestEmailVerificationOutput> {
const verificationCodeRequest = await this.verifiedResourcesService.requestEmailVerificationCode(
{ username, locale },
body.email
);
return {
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
};
}
@ApiOperation({
summary: "Request Phone Number Verification Code",
description: `Sends a verification code to the phone number.`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@Throttle({
limit: 3,
ttl: 60000,
blockDuration: 60000,
name: "org_teams_verified_resources_phones_requests",
})
@Post("/phones/verification-code/request")
@HttpCode(HttpStatus.OK)
async requestPhoneVerificationCode(
@Body() body: RequestPhoneVerificationInput
): Promise<RequestPhoneVerificationOutput> {
const verificationCodeRequest = await this.verifiedResourcesService.requestPhoneVerificationCode(
body.phone
);
return {
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
};
}
@ApiOperation({
summary: "Verify an email for an org team.",
description: `Use code to verify an email`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/emails/verification-code/verify")
@HttpCode(HttpStatus.OK)
async verifyEmail(
@Body() body: VerifyEmailInput,
@GetUser("id") userId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<TeamVerifiedEmailOutput> {
const verifiedEmail = await this.verifiedResourcesService.verifyEmail(
userId,
body.email,
body.code,
teamId
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
};
}
@ApiOperation({
summary: "Verify a phone number for an org team.",
description: `Use code to verify a phone number`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/phones/verification-code/verify")
@Throttle({
limit: 3,
ttl: 60000,
blockDuration: 60000,
name: "org_teams_verified_resources_phones_verify",
})
@HttpCode(HttpStatus.OK)
async verifyPhoneNumber(
@Body() body: VerifyPhoneInput,
@GetUser("id") userId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<TeamVerifiedPhoneOutput> {
const verifiedPhone = await this.verifiedResourcesService.verifyPhone(
userId,
body.phone,
body.code,
teamId
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhone),
};
}
@ApiOperation({
summary: "Get list of verified emails of an org team.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/emails")
@HttpCode(HttpStatus.OK)
async getVerifiedEmails(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() pagination: SkipTakePagination
): Promise<TeamVerifiedEmailsOutput> {
const verifiedEmails = await this.verifiedResourcesService.getTeamVerifiedEmails(
teamId,
pagination?.skip ?? 0,
pagination?.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: verifiedEmails.map((verifiedEmail) => plainToClass(TeamVerifiedEmailOutputData, verifiedEmail)),
};
}
@ApiOperation({
summary: "Get list of verified phone numbers of an org team.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@PlatformPlan("ESSENTIALS")
@Get("/phones")
@Roles("TEAM_ADMIN")
@HttpCode(HttpStatus.OK)
async getVerifiedPhoneNumbers(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() pagination: SkipTakePagination
): Promise<TeamVerifiedPhonesOutput> {
const verifiedPhoneNumbers = await this.verifiedResourcesService.getTeamVerifiedPhoneNumbers(
teamId,
pagination?.skip ?? 0,
pagination?.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: verifiedPhoneNumbers.map((verifiedPhoneNumber) =>
plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber)
),
};
}
@ApiOperation({
summary: "Get verified email of an org team by id.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/emails/:id")
@HttpCode(HttpStatus.OK)
async getVerifiedEmailById(
@Param("id") id: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<TeamVerifiedEmailOutput> {
const verifiedEmail = await this.verifiedResourcesService.getTeamVerifiedEmailById(teamId, id);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
};
}
@ApiOperation({
summary: "Get verified phone number of an org team by id.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/phones/:id")
@HttpCode(HttpStatus.OK)
async getVerifiedPhoneById(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("id") id: number
): Promise<TeamVerifiedPhoneOutput> {
const verifiedPhoneNumber = await this.verifiedResourcesService.getTeamVerifiedPhoneNumberById(
teamId,
id
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber),
};
}
}
@@ -38,7 +38,7 @@ export class OrganizationsUsersRepository {
member: {
teamId: orgId,
...(teamIds && { user: { teams: { some: { teamId: { in: teamIds } } } } }),
// Filter to only get users which have ALL of the assigned attribue options
// Filter to only get users which have ALL of the assigned attribute options
...(attributeQueryOperator === "AND" && {
AND: assignedOptionIds.map((optionId) => ({
AttributeToUser: { some: { attributeOptionId: optionId } },
@@ -46,11 +46,11 @@ export class OrganizationsUsersRepository {
}),
},
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
// Filter to get users which have AT LEAST ONE of the assigned attribue options
// Filter to get users which have AT LEAST ONE of the assigned attribute options
...(attributeQueryOperator === "OR" && {
attributeOption: { id: { in: assignedOptionIds } },
}),
// Filter to get users that have NONE the assigned attribue options
// Filter to get users that have NONE the assigned attribute options
...(attributeQueryOperator === "NONE" && {
NOT: {
attributeOption: { id: { in: assignedOptionIds } },
@@ -141,7 +141,16 @@ export class OrganizationsUsersRepository {
return await this.dbWrite.prisma.user.delete({
where: {
id: userId,
organizationId: orgId,
OR: [
{ organizationId: orgId },
{
profiles: {
some: {
organizationId: orgId,
},
},
},
],
},
include: {
profiles: {
@@ -312,6 +312,7 @@ describe("Organizations Event Types Endpoints", () => {
requiresBookerEmailVerification: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
hideOrganizerEmail: true,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
@@ -343,6 +344,7 @@ describe("Organizations Event Types Endpoints", () => {
expect(data.requiresBookerEmailVerification).toEqual(body.requiresBookerEmailVerification);
expect(data.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(data.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
expect(data.color).toEqual(body.color);
@@ -112,7 +112,7 @@ export class TeamsMembershipsController {
@Roles("TEAM_ADMIN")
@Patch("/:membershipId")
@ApiOperation({ summary: "Create a membership" })
@ApiOperation({ summary: "Update membership" })
async updateTeamMembership(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("membershipId", ParseIntPipe) membershipId: number,
@@ -21,7 +21,11 @@ export class TeamsMembershipsRepository {
async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) {
return this.dbWrite.prisma.membership.create({
data: { ...data, teamId: teamId },
data: {
createdAt: new Date(),
...data,
teamId: teamId,
},
include: { user: { select: MembershipUserSelect } },
});
}
@@ -0,0 +1,229 @@
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
import { RequestPhoneVerificationInput } from "@/modules/verified-resources/inputs/request-phone-verification.input";
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
import { VerifyPhoneInput } from "@/modules/verified-resources/inputs/verify-phone.input";
import { RequestEmailVerificationOutput } from "@/modules/verified-resources/outputs/request-email-verification-output";
import { RequestPhoneVerificationOutput } from "@/modules/verified-resources/outputs/request-phone-verification-output";
import {
TeamVerifiedEmailOutput,
TeamVerifiedEmailOutputData,
TeamVerifiedEmailsOutput,
} from "@/modules/verified-resources/outputs/verified-email.output";
import {
TeamVerifiedPhoneOutput,
TeamVerifiedPhoneOutputData,
TeamVerifiedPhonesOutput,
} from "@/modules/verified-resources/outputs/verified-phone.output";
import { VerifiedResourcesService } from "@/modules/verified-resources/services/verified-resources.service";
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/teams/:teamId/verified-resources",
})
@UseGuards(ApiAuthGuard, RolesGuard)
@ApiTags("Teams Verified Resources")
export class TeamsVerifiedResourcesController {
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
@ApiOperation({
summary: "Request Email Verification Code",
description: `Sends a verification code to the Email.`,
})
@Roles("TEAM_ADMIN")
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/emails/verification-code/request")
@Throttle({ limit: 5, ttl: 60000, blockDuration: 60000, name: "teams_verified_resources_emails_requests" })
@HttpCode(HttpStatus.OK)
async requestEmailVerificationCode(
@Body() body: RequestEmailVerificationInput,
@GetUser("username") username: string,
@GetUser("locale") locale: string
): Promise<RequestEmailVerificationOutput> {
const verificationCodeRequest = await this.verifiedResourcesService.requestEmailVerificationCode(
{ username, locale },
body.email
);
return {
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
};
}
@ApiOperation({
summary: "Request Phone Number Verification Code",
description: `Sends a verification code to the phone number.`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@Post("/phones/verification-code/request")
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "teams_verified_resources_phones_requests" })
@HttpCode(HttpStatus.OK)
async requestPhoneVerificationCode(
@Body() body: RequestPhoneVerificationInput
): Promise<RequestPhoneVerificationOutput> {
const verificationCodeRequest = await this.verifiedResourcesService.requestPhoneVerificationCode(
body.phone
);
return {
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
};
}
@ApiOperation({
summary: "Verify an email for a team.",
description: `Use code to verify an email`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@Post("/emails/verification-code/verify")
@HttpCode(HttpStatus.OK)
async verifyEmail(
@Body() body: VerifyEmailInput,
@GetUser("id") userId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<TeamVerifiedEmailOutput> {
const verifiedEmail = await this.verifiedResourcesService.verifyEmail(
userId,
body.email,
body.code,
teamId
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
};
}
@ApiOperation({
summary: "Verify a phone number for an org team.",
description: `Use code to verify a phone number`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@Post("/phones/verification-code/verify")
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "teams_verified_resources_phones_verify" })
@HttpCode(HttpStatus.OK)
async verifyPhoneNumber(
@Body() body: VerifyPhoneInput,
@GetUser("id") userId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<TeamVerifiedPhoneOutput> {
const verifiedPhone = await this.verifiedResourcesService.verifyPhone(
userId,
body.phone,
body.code,
teamId
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhone),
};
}
@ApiOperation({
summary: "Get list of verified emails of a team.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@Get("/emails")
@HttpCode(HttpStatus.OK)
async getVerifiedEmails(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() pagination: SkipTakePagination
): Promise<TeamVerifiedEmailsOutput> {
const verifiedEmails = await this.verifiedResourcesService.getTeamVerifiedEmails(
teamId,
pagination?.skip ?? 0,
pagination?.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: verifiedEmails.map((verifiedEmail) => plainToClass(TeamVerifiedEmailOutputData, verifiedEmail)),
};
}
@ApiOperation({
summary: "Get list of verified phone numbers of a team.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/phones")
@Roles("TEAM_ADMIN")
@HttpCode(HttpStatus.OK)
async getVerifiedPhoneNumbers(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() pagination: SkipTakePagination
): Promise<TeamVerifiedPhonesOutput> {
const verifiedPhoneNumbers = await this.verifiedResourcesService.getTeamVerifiedPhoneNumbers(
teamId,
pagination?.skip ?? 0,
pagination?.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: verifiedPhoneNumbers.map((verifiedPhoneNumber) =>
plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber)
),
};
}
@ApiOperation({
summary: "Get verified email of a team by id.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@Get("/emails/:id")
@HttpCode(HttpStatus.OK)
async getVerifiedEmailById(
@Param("id") id: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<TeamVerifiedEmailOutput> {
const verifiedEmail = await this.verifiedResourcesService.getTeamVerifiedEmailById(teamId, id);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
};
}
@ApiOperation({
summary: "Get verified phone number of a team by id.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@Get("/phones/:id")
@HttpCode(HttpStatus.OK)
async getVerifiedPhoneById(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("id") id: number
): Promise<TeamVerifiedPhoneOutput> {
const verifiedPhoneNumber = await this.verifiedResourcesService.getTeamVerifiedPhoneNumberById(
teamId,
id
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber),
};
}
}
@@ -149,7 +149,7 @@ export class TokensRepository {
this.dbWrite.prisma.refreshToken.create({
data: {
// note(Lauris): I am leaving userId because it was before adding ownerId to standardize payload like in the createOAuthTokens function for
// backwards compatability.
// backwards compatibility.
secret: this.jwtService.signRefreshToken({
clientId,
ownerId: tokenUserId,
@@ -0,0 +1,196 @@
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
import { RequestPhoneVerificationInput } from "@/modules/verified-resources/inputs/request-phone-verification.input";
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
import { VerifyPhoneInput } from "@/modules/verified-resources/inputs/verify-phone.input";
import { RequestEmailVerificationOutput } from "@/modules/verified-resources/outputs/request-email-verification-output";
import { RequestPhoneVerificationOutput } from "@/modules/verified-resources/outputs/request-phone-verification-output";
import {
UserVerifiedEmailOutput,
UserVerifiedEmailOutputData,
UserVerifiedEmailsOutput,
} from "@/modules/verified-resources/outputs/verified-email.output";
import {
UserVerifiedPhoneOutput,
UserVerifiedPhoneOutputData,
UserVerifiedPhonesOutput,
} from "@/modules/verified-resources/outputs/verified-phone.output";
import { VerifiedResourcesService } from "@/modules/verified-resources/services/verified-resources.service";
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, UseGuards } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/verified-resources",
})
@UseGuards(ApiAuthGuard)
@ApiTags("Verified Resources")
export class UserVerifiedResourcesController {
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
@ApiOperation({
summary: "Request Email Verification Code",
description: `Sends a verification code to the email.`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/emails/verification-code/request")
@HttpCode(HttpStatus.OK)
@Throttle({ limit: 5, ttl: 60000, blockDuration: 60000, name: "users_verified_resources_emails_requests" })
async requestEmailVerificationCode(
@Body() body: RequestEmailVerificationInput,
@GetUser("username") username: string,
@GetUser("locale") locale: string
): Promise<RequestEmailVerificationOutput> {
const verificationCodeRequest = await this.verifiedResourcesService.requestEmailVerificationCode(
{ username, locale },
body.email
);
return {
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
};
}
@ApiOperation({
summary: "Request Phone Number Verification Code",
description: `Sends a verification code to the phone number.`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/phones/verification-code/request")
@HttpCode(HttpStatus.OK)
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "users_verified_resources_phones_requests" })
async requestPhoneVerificationCode(
@Body() body: RequestPhoneVerificationInput
): Promise<RequestPhoneVerificationOutput> {
const verificationCodeRequest = await this.verifiedResourcesService.requestPhoneVerificationCode(
body.phone
);
return {
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
};
}
@ApiOperation({
summary: "Verify an email.",
description: `Use code to verify an email`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/emails/verification-code/verify")
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "users_verified_resources_phones_verify" })
@HttpCode(HttpStatus.OK)
async verifyEmail(
@Body() body: VerifyEmailInput,
@GetUser("id") userId: number
): Promise<UserVerifiedEmailOutput> {
const verifiedEmail = await this.verifiedResourcesService.verifyEmail(userId, body.email, body.code);
return {
status: SUCCESS_STATUS,
data: plainToClass(UserVerifiedEmailOutputData, verifiedEmail),
};
}
@ApiOperation({
summary: "Verify a phone number.",
description: `Use code to verify a phone number`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/phones/verification-code/verify")
@HttpCode(HttpStatus.OK)
async verifyPhoneNumber(
@Body() body: VerifyPhoneInput,
@GetUser("id") userId: number
): Promise<UserVerifiedPhoneOutput> {
const verifiedPhone = await this.verifiedResourcesService.verifyPhone(userId, body.phone, body.code);
return {
status: SUCCESS_STATUS,
data: plainToClass(UserVerifiedPhoneOutputData, verifiedPhone),
};
}
@ApiOperation({
summary: "Get list of verified emails.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/emails")
@HttpCode(HttpStatus.OK)
async getVerifiedEmails(
@GetUser("id") userId: number,
@Query() pagination: SkipTakePagination
): Promise<UserVerifiedEmailsOutput> {
const verifiedEmails = await this.verifiedResourcesService.getUserVerifiedEmails(
userId,
pagination?.skip ?? 0,
pagination?.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: verifiedEmails.map((verifiedEmail) => plainToClass(UserVerifiedEmailOutputData, verifiedEmail)),
};
}
@ApiOperation({
summary: "Get list of verified phone numbers.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/phones")
@HttpCode(HttpStatus.OK)
async getVerifiedPhoneNumbers(
@GetUser("id") userId: number,
@Query() pagination: SkipTakePagination
): Promise<UserVerifiedPhonesOutput> {
const verifiedPhoneNumbers = await this.verifiedResourcesService.getUserVerifiedPhoneNumbers(
userId,
pagination?.skip ?? 0,
pagination?.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: verifiedPhoneNumbers.map((verifiedPhoneNumber) =>
plainToClass(UserVerifiedPhoneOutputData, verifiedPhoneNumber)
),
};
}
@ApiOperation({
summary: "Get verified email by id.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/emails/:id")
@HttpCode(HttpStatus.OK)
async getVerifiedEmailById(
@GetUser("id") userId: number,
@Param("id") id: number
): Promise<UserVerifiedEmailOutput> {
const verifiedEmail = await this.verifiedResourcesService.getUserVerifiedEmailById(userId, id);
return {
status: SUCCESS_STATUS,
data: plainToClass(UserVerifiedEmailOutputData, verifiedEmail),
};
}
@ApiOperation({
summary: "Get verified phone number by id.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/phones/:id")
@HttpCode(HttpStatus.OK)
async getVerifiedPhoneById(
@GetUser("id") userId: number,
@Param("id") id: number
): Promise<UserVerifiedPhoneOutput> {
const verifiedPhoneNumber = await this.verifiedResourcesService.getUserVerifiedPhoneNumberById(
userId,
id
);
return {
status: SUCCESS_STATUS,
data: plainToClass(UserVerifiedPhoneOutputData, verifiedPhoneNumber),
};
}
}
@@ -0,0 +1,14 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsEmail } from "class-validator";
export class RequestEmailVerificationInput {
@ApiProperty({
type: String,
description: "Email to verify.",
example: "acme@example.com",
})
@IsEmail()
@Expose()
email!: string;
}
@@ -0,0 +1,14 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsPhoneNumber } from "class-validator";
export class RequestPhoneVerificationInput {
@ApiProperty({
type: String,
description: "Phone number to verify.",
example: "+372 5555 6666",
})
@IsPhoneNumber()
@Expose()
phone!: string;
}
@@ -0,0 +1,23 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsEmail, IsString } from "class-validator";
export class VerifyEmailInput {
@ApiProperty({
type: String,
description: "Email to verify.",
example: "example@acme.com",
})
@IsEmail()
@Expose()
email!: string;
@ApiProperty({
type: String,
description: "verification code sent to the email to verify",
example: "1ABG2C",
})
@Expose()
@IsString()
code!: string;
}
@@ -0,0 +1,23 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsEmail, IsPhoneNumber, IsString } from "class-validator";
export class VerifyPhoneInput {
@ApiProperty({
type: String,
description: "phone number to verify.",
example: "+37255556666",
})
@IsPhoneNumber()
@Expose()
phone!: string;
@ApiProperty({
type: String,
description: "verification code sent to the phone number to verify",
example: "1ABG2C",
})
@Expose()
@IsString()
code!: string;
}
@@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class RequestEmailVerificationOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
}
@@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class RequestPhoneVerificationOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
}
@@ -0,0 +1,135 @@
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import {
IsArray,
IsEnum,
IsNotEmptyObject,
IsNumber,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class UserVerifiedEmailOutputData {
@Expose()
@IsNumber()
@ApiProperty({
description: "The unique identifier for the verified email.",
example: 789,
})
id!: number;
@Expose()
@IsString()
@ApiProperty({
description: "The verified email address.",
example: "user@example.com",
format: "email",
})
email!: string;
@Expose()
@IsNumber()
@ApiProperty({
description: "The ID of the associated user, if applicable.",
example: 45,
})
userId!: number;
}
export class TeamVerifiedEmailOutputData {
@Expose()
@IsNumber()
@ApiProperty({
description: "The unique identifier for the verified email.",
example: 789,
})
id!: number;
@Expose()
@IsString()
@ApiProperty({
description: "The verified email address.",
example: "user@example.com",
format: "email",
})
email!: string;
@Expose()
@IsNumber()
@ApiProperty({
description: "The ID of the associated team, if applicable.",
example: 89,
})
teamId!: number;
@Expose()
@IsNumber()
@IsOptional()
@ApiProperty({
description: "The ID of the associated user, if applicable.",
example: 45,
nullable: true,
required: false,
})
userId?: number | null;
}
export class TeamVerifiedEmailOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@Type(() => TeamVerifiedEmailOutputData)
data!: TeamVerifiedEmailOutputData;
}
export class UserVerifiedEmailOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@Type(() => UserVerifiedEmailOutputData)
data!: UserVerifiedEmailOutputData;
}
export class TeamVerifiedEmailsOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@IsArray()
@ValidateNested({ each: true })
@Type(() => TeamVerifiedEmailOutputData)
data!: TeamVerifiedEmailOutputData[];
}
export class UserVerifiedEmailsOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UserVerifiedEmailOutputData)
data!: UserVerifiedEmailOutputData[];
}
@@ -0,0 +1,135 @@
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import {
IsArray,
IsEnum,
IsNotEmptyObject,
IsNumber,
IsString,
ValidateNested,
IsOptional,
} from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class UserVerifiedPhoneOutputData {
@Expose()
@IsNumber()
@ApiProperty({
description: "The unique identifier for the verified email.",
example: 789,
})
id!: number;
@Expose()
@IsString()
@ApiProperty({
description: "The verified phone number.",
example: "+37255556666",
format: "phone",
})
phoneNumber!: string;
@Expose()
@IsNumber()
@ApiProperty({
description: "The ID of the associated user, if applicable.",
example: 45,
})
userId!: number;
}
export class TeamVerifiedPhoneOutputData {
@Expose()
@IsNumber()
@ApiProperty({
description: "The unique identifier for the verified email.",
example: 789,
})
id!: number;
@Expose()
@IsString()
@ApiProperty({
description: "The verified phone number.",
example: "+37255556666",
format: "phone",
})
phoneNumber!: string;
@Expose()
@IsNumber()
@IsOptional()
@ApiProperty({
description: "The ID of the associated user, if applicable.",
example: 45,
nullable: true,
required: false,
})
userId?: number | null;
@Expose()
@IsNumber()
@ApiProperty({
description: "The ID of the associated team, if applicable.",
example: 89,
})
teamId!: number;
}
export class UserVerifiedPhoneOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@Type(() => UserVerifiedPhoneOutputData)
data!: UserVerifiedPhoneOutputData;
}
export class TeamVerifiedPhoneOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@Type(() => TeamVerifiedPhoneOutputData)
data!: TeamVerifiedPhoneOutputData;
}
export class UserVerifiedPhonesOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UserVerifiedPhoneOutputData)
data!: UserVerifiedPhoneOutputData[];
}
export class TeamVerifiedPhonesOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@IsArray()
@ValidateNested({ each: true })
@Type(() => TeamVerifiedPhoneOutputData)
data!: TeamVerifiedPhoneOutputData[];
}
@@ -0,0 +1,120 @@
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
import { UsersVerifiedResourcesRepository } from "@/modules/verified-resources/users-verified-resources.repository";
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
import {
verifyPhoneNumber,
sendVerificationCode as sendPhoneVerificationCode,
} from "@calcom/platform-libraries";
import { sendEmailVerificationByCode, verifyEmailCodeHandler } from "@calcom/platform-libraries/emails";
@Injectable()
export class VerifiedResourcesService {
constructor(
private readonly usersVerifiedResourcesRepository: UsersVerifiedResourcesRepository,
private readonly teamsVerifiedResourcesRepository: TeamsVerifiedResourcesRepository
) {}
async requestEmailVerificationCode(user: { username: string; locale: string }, email: string) {
const res = await sendEmailVerificationByCode({
email: email,
language: user.locale,
username: user.username,
isVerifyingEmail: true,
});
if (res.ok && !res.skipped) {
return true;
}
if (!res.ok) {
throw new BadRequestException("Email could not be verified.");
}
if (res.skipped) {
throw new ConflictException("Email is already verified.");
}
return true;
}
async requestPhoneVerificationCode(phone: string) {
try {
await sendPhoneVerificationCode(phone);
} catch (err) {
throw new BadRequestException("Could not send verification code to this phone number.");
}
return true;
}
async verifyPhone(userId: number, phone: string, code: string, teamId?: number) {
const result = await verifyPhoneNumber(phone, code, userId, teamId);
if (result) {
return teamId
? await this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumber(userId, phone, teamId)
: await this.usersVerifiedResourcesRepository.getUserVerifiedPhoneNumber(userId, phone);
}
throw new BadRequestException("Could not verify phone number.");
}
async verifyEmail(userId: number, email: string, code: string, teamId?: number) {
const isValidToken = await verifyEmailCodeHandler({
ctx: { user: { id: userId } },
input: { email, code, teamId },
});
if (isValidToken) {
const verifiedEmail = teamId
? await this.teamsVerifiedResourcesRepository.getTeamVerifiedEmail(userId, email, teamId)
: await this.usersVerifiedResourcesRepository.getUserVerifiedEmail(userId, email);
return verifiedEmail;
}
throw new BadRequestException("Invalid Verification Code");
}
async getUserVerifiedEmailById(userId: number, id: number) {
return this.usersVerifiedResourcesRepository.getUserVerifiedEmailById(userId, id);
}
async getTeamVerifiedEmailById(teamId: number, id: number) {
return this.teamsVerifiedResourcesRepository.getTeamVerifiedEmailById(id, teamId);
}
async getVerifiedEmail(userId: number, email: string) {
return this.usersVerifiedResourcesRepository.getUserVerifiedEmail(userId, email);
}
async getUserVerifiedEmails(userId: number, skip = 0, take = 250) {
return this.usersVerifiedResourcesRepository.getUserVerifiedEmails(userId, skip, take);
}
async getTeamVerifiedEmails(teamId: number, skip = 0, take = 250) {
return this.teamsVerifiedResourcesRepository.getTeamVerifiedEmails(teamId, skip, take);
}
async getUserVerifiedPhoneNumberById(userId: number, id: number) {
return this.usersVerifiedResourcesRepository.getUserVerifiedPhoneNumberById(userId, id);
}
async getTeamVerifiedPhoneNumberById(teamId: number, id: number) {
return this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumberById(id, teamId);
}
async getUserVerifiedPhoneNumber(userId: number, phoneNumber: string) {
return this.usersVerifiedResourcesRepository.getUserVerifiedEmail(userId, phoneNumber);
}
async getTeamVerifiedPhoneNumber(userId: number, phoneNumber: string, teamId: number) {
return this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumber(userId, phoneNumber, teamId);
}
async getUserVerifiedPhoneNumbers(userId: number, skip = 0, take = 250) {
return this.usersVerifiedResourcesRepository.getUserVerifiedPhoneNumbers(userId, skip, take);
}
async getTeamVerifiedPhoneNumbers(teamId: number, skip = 0, take = 250) {
return this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumbers(teamId, skip, take);
}
}
@@ -0,0 +1,66 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class TeamsVerifiedResourcesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getTeamVerifiedEmails(teamId: number, skip = 0, take = 250) {
return this.dbRead.prisma.verifiedEmail.findMany({
where: {
teamId,
},
skip,
take,
});
}
async getTeamVerifiedPhoneNumbers(teamId: number, skip = 0, take = 250) {
return this.dbRead.prisma.verifiedNumber.findMany({
where: {
teamId,
},
skip,
take,
});
}
async getTeamVerifiedEmail(userId: number, email: string, teamId: number) {
return this.dbRead.prisma.verifiedEmail.findFirstOrThrow({
where: {
userId,
email,
teamId,
},
});
}
async getTeamVerifiedPhoneNumber(userId: number, phone: string, teamId: number) {
return this.dbRead.prisma.verifiedNumber.findFirstOrThrow({
where: {
userId,
phoneNumber: phone,
teamId,
},
});
}
async getTeamVerifiedEmailById(id: number, teamId: number) {
return this.dbRead.prisma.verifiedEmail.findFirst({
where: {
id,
teamId,
},
});
}
async getTeamVerifiedPhoneNumberById(id: number, teamId: number) {
return this.dbRead.prisma.verifiedNumber.findFirst({
where: {
id,
teamId,
},
});
}
}
@@ -0,0 +1,64 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class UsersVerifiedResourcesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getUserVerifiedEmails(userId: number, skip = 0, take = 250) {
return this.dbRead.prisma.verifiedEmail.findMany({
where: {
userId,
},
skip,
take,
});
}
async getUserVerifiedPhoneNumber(userId: number, phone: string) {
return this.dbRead.prisma.verifiedNumber.findFirstOrThrow({
where: {
userId,
phoneNumber: phone,
},
});
}
async getUserVerifiedPhoneNumbers(userId: number, skip = 0, take = 250) {
return this.dbRead.prisma.verifiedNumber.findMany({
where: {
userId,
},
skip,
take,
});
}
async getUserVerifiedEmail(userId: number, email: string) {
return this.dbRead.prisma.verifiedEmail.findFirstOrThrow({
where: {
userId,
email,
},
});
}
async getUserVerifiedEmailById(userId: number, id: number) {
return this.dbRead.prisma.verifiedEmail.findFirst({
where: {
userId,
id,
},
});
}
async getUserVerifiedPhoneNumberById(userId: number, id: number) {
return this.dbRead.prisma.verifiedNumber.findFirst({
where: {
userId,
id,
},
});
}
}
@@ -0,0 +1,39 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { OrgTeamsVerifiedResourcesController } from "@/modules/organizations/teams/verified-resources/org-teams-verified-resources.controller";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeService } from "@/modules/stripe/stripe.service";
import { TeamsVerifiedResourcesController } from "@/modules/teams/verified-resources/teams-verified-resources.controller";
import { UsersRepository } from "@/modules/users/users.repository";
import { UserVerifiedResourcesController } from "@/modules/verified-resources/controllers/users-verified-resources.controller";
import { VerifiedResourcesService } from "@/modules/verified-resources/services/verified-resources.service";
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
import { UsersVerifiedResourcesRepository } from "@/modules/verified-resources/users-verified-resources.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, RedisModule],
controllers: [
UserVerifiedResourcesController,
TeamsVerifiedResourcesController,
OrgTeamsVerifiedResourcesController,
],
providers: [
VerifiedResourcesService,
UsersVerifiedResourcesRepository,
TeamsVerifiedResourcesRepository,
MembershipsRepository,
OrganizationsTeamsRepository,
OrganizationsRepository,
StripeService,
AppsRepository,
CredentialsRepository,
UsersRepository,
],
exports: [VerifiedResourcesService],
})
export class VerifiedResourcesModule {}
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,7 @@ export class MembershipRepositoryFixture {
}
async create(data: Prisma.MembershipCreateInput) {
return this.prismaWriteClient.membership.create({ data });
return this.prismaWriteClient.membership.create({ data: { createdAt: new Date(), ...data } });
}
async delete(membershipId: Membership["id"]) {
@@ -30,7 +30,7 @@ export class MembershipRepositoryFixture {
async addUserToOrg(user: User, org: Team, role: MembershipRole, accepted: boolean) {
const membership = await this.prismaWriteClient.membership.create({
data: { teamId: org.id, userId: user.id, role, accepted },
data: { createdAt: new Date(), teamId: org.id, userId: user.id, role, accepted },
});
await this.prismaWriteClient.user.update({ where: { id: user.id }, data: { organizationId: org.id } });
return membership;
@@ -1,7 +1,7 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma, App_RoutingForms_Form } from "@prisma/client";
import { Prisma, App_RoutingForms_Form, App_RoutingForms_FormResponse } from "@prisma/client";
export class RoutingFormsRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
@@ -23,4 +23,10 @@ export class RoutingFormsRepositoryFixture {
async delete(routingFormId: App_RoutingForms_Form["id"]) {
return this.prismaWriteClient.app_RoutingForms_Form.delete({ where: { id: routingFormId } });
}
async deleteResponse(routingFormResponseId: App_RoutingForms_FormResponse["id"]) {
return this.prismaWriteClient.app_RoutingForms_FormResponse.delete({
where: { id: routingFormResponseId },
});
}
}
@@ -0,0 +1,26 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma } from "@prisma/client";
export class VerifiedResourcesRepositoryFixtures {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async createPhone(data: Prisma.VerifiedNumberCreateInput) {
return this.prismaWriteClient.verifiedNumber.create({ data });
}
async deleteEmailById(id: number) {
return this.prismaWriteClient.verifiedEmail.delete({ where: { id } });
}
async deletePhoneById(id: number) {
return this.prismaWriteClient.verifiedNumber.delete({ where: { id } });
}
}
+1
View File
@@ -0,0 +1 @@
jest.retryTimes(process.env.CI ? 2 : 0); // Global retry for all tests
+2 -2
View File
@@ -2,9 +2,9 @@ export default function Page() {
return (
<div className="font-cal -mt-16 flex min-h-screen w-screen items-center justify-center border pt-16 text-center ">
<div>
<h1 className="text-7xl font-bold uppercase leading-none tracking-tight md:text-8xl">
<h1 className="text-7xl leading-none md:text-8xl">
Open UI for absolutely
<br /> Everyone
<br /> everyone
</h1>
</div>
</div>
+1 -1
View File
@@ -24,7 +24,7 @@ export const RenderComponentWithSnippet: React.FC<PropsWithChildren<Props>> = (p
// },
// });
return (
<div className="border-gray-6 bg-gray-1 rounded-lg border">
<div className="border-gray-6 bg-default rounded-lg border">
<div className={className("not-prose p-8 xl:p-12", props.className)}>{props.children}</div>
<div className="bg-gray-3 border-gray-6 flex items-center justify-start border-b border-t p-2">
@@ -65,3 +65,34 @@ Buttons can be grouped together using the ButtonGroup component. The ButtonGroup
#### Combined
<ButtonGroupSizesCombined/>
## Usage
```tsx
// Primary button (default)
<Button>Primary Button</Button>
// Secondary button
<Button color="secondary">Secondary Button</Button>
// Minimal button
<Button color="minimal">Minimal Button</Button>
// Destructive button
<Button color="destructive">Destructive Button</Button>
// With start icon
<Button StartIcon="plus">Add Item</Button>
// With end icon
<Button EndIcon="chevronRight">View More</Button>
// With link
<Button href="/some-path">Navigate</Button>
// Different sizes
<Button size="xs">Extra Small</Button>
<Button size="sm">Small</Button>
<Button size="base">Base (Default)</Button>
<Button size="lg">Large</Button>
```
@@ -0,0 +1,58 @@
"use client";
import { RenderComponentWithSnippet } from "@/app/components/render";
import { useState } from "react";
import { Switch } from "@calcom/ui/components/form";
import { Section } from "@calcom/ui/components/section";
export const BasicExample = () => {
const [isOpen, setIsOpen] = useState(true);
return (
<RenderComponentWithSnippet>
<Section>
<Section.Header
title="Basic Section"
description="This is a basic section with a title and description">
<Switch size="sm" checked={isOpen} onCheckedChange={setIsOpen} />
</Section.Header>
{isOpen && (
<Section.Content>
<p>This is the main content of the section.</p>
</Section.Content>
)}
</Section>
</RenderComponentWithSnippet>
);
};
export const BasicExampleSnippet = `
"use client";
import { useState } from "react";
import { Section } from "@calcom/ui/components/section";
import { Switch } from "@calcom/ui/components/form";
const BasicSection = () => {
const [isOpen, setIsOpen] = useState(true);
return (
<Section>
<Section.Header
title="Basic Section"
description="This is a basic section with a title and description"
>
<Section.HeaderRight>
<Switch size="sm" checked={isOpen} onCheckedChange={setIsOpen} />
</Section.HeaderRight>
</Section.Header>
{isOpen && (
<Section.Content>
<p>This is the main content of the section.</p>
</Section.Content>
)}
</Section>
);
};
`;
@@ -0,0 +1,40 @@
---
title: Section
---
import { Section } from "@calcom/ui/components/section"
import { RenderComponentWithSnippet } from "@/app/components/render"
import { Row } from "@/app/components/row"
import { BasicExample } from "./section.basic"
import { WithIconExample } from "./section.withIcon"
import { SubSectionExample } from "./section.subsection"
## Basic Usage
The Section component provides a structured way to organize content with a consistent visual hierarchy. It includes several subcomponents for different parts of the section.
<BasicExample/>
## With Icon
Sections can include an icon in the header to provide visual context.
<WithIconExample/>
## SubSections
Sections can contain subsections for organizing related content in a nested structure.
<SubSectionExample/>
## Components
The Section component is composed of several subcomponents:
- `Section` - The root container
- `Section.Header` - Container for the section header
- `Section.Title` - The section title
- `Section.Description` - Optional description text
- `Section.Content` - The main content area
- `Section.SubSection` - A nested section
- `Section.SubSectionHeader` - Header for a subsection
- `Section.SubSectionContent` - Content area for a subsection
@@ -0,0 +1,151 @@
"use client";
import { RenderComponentWithSnippet } from "@/app/components/render";
import { useState } from "react";
import { Input, Switch } from "@calcom/ui/components/form";
import { Section } from "@calcom/ui/components/section";
export const SubSectionExample = () => {
const [isMainOpen, setIsMainOpen] = useState(true);
const [isFirstSubsectionOpen, setIsFirstSubsectionOpen] = useState(true);
const [isSecondSubsectionOpen, setIsSecondSubsectionOpen] = useState(true);
return (
<RenderComponentWithSnippet>
<Section>
<Section.Header
icon="calendar"
title="Section with Subsections"
description="This section contains nested subsections">
<Switch size="sm" checked={isMainOpen} onCheckedChange={setIsMainOpen} />
</Section.Header>
{isMainOpen && (
<Section.Content>
<Section.SubSection>
<Section.SubSectionHeader icon="zap" title="Automatic Sync (Justify Start)" justify="start">
<Switch size="sm" />
</Section.SubSectionHeader>
</Section.SubSection>
<Section.SubSection>
<Section.SubSectionHeader icon="user" title="Create new records for guests added">
<Switch
size="sm"
checked={isFirstSubsectionOpen}
onCheckedChange={setIsFirstSubsectionOpen}
/>
</Section.SubSectionHeader>
{isFirstSubsectionOpen && (
<Section.SubSectionContent>
<p>This is the content of the first subsection.</p>
</Section.SubSectionContent>
)}
</Section.SubSection>
<Section.SubSection>
<Section.SubSectionHeader icon="settings" title="Open Settings">
<Switch
size="sm"
checked={isSecondSubsectionOpen}
onCheckedChange={setIsSecondSubsectionOpen}
/>
</Section.SubSectionHeader>
{isSecondSubsectionOpen && (
<Section.SubSectionContent>
<div className="flex gap-3 px-3 py-1.5">
<div className="text-subtle w-full text-sm font-medium">Field Name</div>
<div className="text-subtle w-full text-sm font-medium">Field Type</div>
<div className="text-subtle w-full text-sm font-medium">Value</div>
<div className="text-subtle w-full text-sm font-medium">When to Write</div>
</div>
<Section.SubSectionNested>
<div className="flex gap-3">
<Input value="Customer Name" className="w-full" />
<Input value="Text" className="w-full" />
<Input value="John Doe" className="w-full" />
<Input value="On Create" className="w-full" />
</div>
</Section.SubSectionNested>
</Section.SubSectionContent>
)}
</Section.SubSection>
</Section.Content>
)}
</Section>
</RenderComponentWithSnippet>
);
};
export const SubSectionExampleSnippet = `
"use client";
import { useState } from "react";
import { Section } from "@calcom/ui/components/section";
import { Switch } from "@calcom/ui/components/form";
const SectionWithSubsections = () => {
const [isMainOpen, setIsMainOpen] = useState(true);
const [isFirstSubsectionOpen, setIsFirstSubsectionOpen] = useState(true);
const [isSecondSubsectionOpen, setIsSecondSubsectionOpen] = useState(true);
return (
<Section>
<Section.Header
icon="calendar"
title="Section with Subsections"
description="This section contains nested subsections">
<Switch size="sm" checked={isMainOpen} onCheckedChange={setIsMainOpen} />
</Section.Header>
{isMainOpen && (
<Section.Content>
<Section.SubSection>
<Section.SubSectionHeader icon="zap" title="Automatic Sync (Justify Start)" justify="start">
<Switch size="sm" />
</Section.SubSectionHeader>
</Section.SubSection>
<Section.SubSection>
<Section.SubSectionHeader icon="user" title="Create new records for guests added">
<Switch
size="sm"
checked={isFirstSubsectionOpen}
onCheckedChange={setIsFirstSubsectionOpen}
/>
</Section.SubSectionHeader>
{isFirstSubsectionOpen && (
<Section.SubSectionContent>
<p>This is the content of the first subsection.</p>
</Section.SubSectionContent>
)}
</Section.SubSection>
<Section.SubSection>
<Section.SubSectionHeader icon="settings" title="Open Settings">
<Switch
size="sm"
checked={isSecondSubsectionOpen}
onCheckedChange={setIsSecondSubsectionOpen}
/>
</Section.SubSectionHeader>
{isSecondSubsectionOpen && (
<Section.SubSectionContent>
<div className="flex gap-3 px-3 py-1.5">
<div className="text-subtle w-full text-sm font-medium">Field Name</div>
<div className="text-subtle w-full text-sm font-medium">Field Type</div>
<div className="text-subtle w-full text-sm font-medium">Value</div>
<div className="text-subtle w-full text-sm font-medium">When to Write</div>
</div>
<Section.SubSectionNested>
<div className="flex gap-3">
<Input value="Customer Name" className="w-full" />
<Input value="Text" className="w-full" />
<Input value="John Doe" className="w-full" />
<Input value="On Create" className="w-full" />
</div>
</Section.SubSectionNested>
</Section.SubSectionContent>
)}
</Section.SubSection>
</Section.Content>
)}
</Section>
);
};
`;
@@ -0,0 +1,63 @@
"use client";
import { RenderComponentWithSnippet } from "@/app/components/render";
import { useState } from "react";
import { Switch } from "@calcom/ui/components/form";
import { Section } from "@calcom/ui/components/section";
export const WithIconExample = () => {
const [isOpen, setIsOpen] = useState(true);
return (
<RenderComponentWithSnippet>
<Section>
<Section.Header
icon="calendar"
title="Section with Icon"
description="This section includes an icon in the header. Toggle to see auto-animation.">
<Switch size="sm" checked={isOpen} onCheckedChange={setIsOpen} />
</Section.Header>
{isOpen && (
<Section.Content>
<p>
This section demonstrates how to include an icon in the header and uses FormKit auto-animate for
smooth transitions when toggling content visibility.
</p>
</Section.Content>
)}
</Section>
</RenderComponentWithSnippet>
);
};
export const WithIconExampleSnippet = `
"use client";
import { useState } from "react";
import { Section } from "@calcom/ui/components/section";
import { Switch } from "@calcom/ui/components/form";
const SectionWithIcon = () => {
const [isOpen, setIsOpen] = useState(true);
return (
<Section>
<Section.Header
icon="calendar"
title="Section with Icon"
description="This section includes an icon in the header. Toggle to see auto-animation.">
<Switch size="sm" checked={isOpen} onCheckedChange={setIsOpen} />
</Section.Header>
{isOpen && (
<Section.Content>
<p>
This section demonstrates how to include an icon in the header and uses FormKit auto-animate for
smooth transitions when toggling content visibility.
</p>
</Section.Content>
)}
</Section>
);
};
`;
+9
View File
@@ -1,5 +1,14 @@
# @calcom/web
## 5.2.8
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.5.3
- @calcom/embed-react@1.5.3
- @calcom/embed-snippet@1.3.3
## 4.8.7
### Patch Changes
@@ -0,0 +1,9 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert } from "@calcom/ui/components/alert";
export default function Error() {
const { t } = useLocale();
return <Alert severity="error" title={t("something_went_wrong")} />;
}
@@ -1,7 +1,6 @@
import { createRouterCaller } from "app/_trpc/context";
import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { notFound } from "next/navigation";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
@@ -25,43 +24,40 @@ export const generateMetadata = async () => {
const Page = async ({ searchParams: _searchParams }: PageProps) => {
const searchParams = await _searchParams;
const t = await getTranslate();
try {
const [meCaller, availabilityCaller] = await Promise.all([
createRouterCaller(meRouter),
createRouterCaller(availabilityRouter),
]);
const [me, availabilities] = await Promise.all([meCaller.get(), availabilityCaller.list()]);
const organizationId = me.organization?.id ?? me.profiles[0]?.organizationId;
const isOrgPrivate = organizationId
? await OrganizationRepository.checkIfPrivate({
orgId: organizationId,
})
: false;
const canViewTeamAvailability = me.organization?.isOrgAdmin || !isOrgPrivate;
const [meCaller, availabilityCaller] = await Promise.all([
createRouterCaller(meRouter),
createRouterCaller(availabilityRouter),
]);
return (
<ShellMainAppDir
heading={t("availability")}
subtitle={t("configure_availability")}
CTA={
<AvailabilityCTA
toggleGroupOptions={[
{ value: "mine", label: t("my_availability") },
...(canViewTeamAvailability ? [{ value: "team", label: t("team_availability") }] : []),
]}
/>
}>
{searchParams?.type === "team" && canViewTeamAvailability ? (
<AvailabilitySliderTable userTimeFormat={me?.timeFormat ?? null} isOrg={!!organizationId} />
) : (
<AvailabilityList availabilities={availabilities ?? { schedules: [] }} me={me} />
)}
</ShellMainAppDir>
);
} catch (error) {
notFound();
}
const [me, availabilities] = await Promise.all([meCaller.get(), availabilityCaller.list()]);
const organizationId = me.organization?.id ?? me.profiles[0]?.organizationId;
const isOrgPrivate = organizationId
? await OrganizationRepository.checkIfPrivate({
orgId: organizationId,
})
: false;
const canViewTeamAvailability = me.organization?.isOrgAdmin || !isOrgPrivate;
return (
<ShellMainAppDir
heading={t("availability")}
subtitle={t("configure_availability")}
CTA={
<AvailabilityCTA
toggleGroupOptions={[
{ value: "mine", label: t("my_availability") },
...(canViewTeamAvailability ? [{ value: "team", label: t("team_availability") }] : []),
]}
/>
}>
{searchParams?.type === "team" && canViewTeamAvailability ? (
<AvailabilitySliderTable userTimeFormat={me?.timeFormat ?? null} isOrg={!!organizationId} />
) : (
<AvailabilityList availabilities={availabilities ?? { schedules: [] }} me={me} />
)}
</ShellMainAppDir>
);
};
export default Page;
@@ -0,0 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
export async function revalidateEventTypesList() {
revalidatePath("/event-types");
}
@@ -0,0 +1,9 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert } from "@calcom/ui/components/alert";
export default function Error() {
const { t } = useLocale();
return <Alert severity="error" title={t("something_went_wrong")} />;
}
@@ -0,0 +1,5 @@
import { EventTypesSkeleton } from "./skeleton";
export default function Loading() {
return <EventTypesSkeleton />;
}
@@ -8,6 +8,7 @@ import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
@@ -32,19 +33,27 @@ const Page = async ({ params, searchParams }: PageProps) => {
}
const t = await getTranslate();
const caller = await createRouterCaller(eventTypesRouter);
const filters = getTeamsFiltersFromQuery(_searchParams);
const initialData = await caller.getUserEventGroups({ filters });
const [meCaller, eventTypesCaller] = await Promise.all([
createRouterCaller(meRouter),
createRouterCaller(eventTypesRouter),
]);
const [me, userEventGroupsData] = await Promise.all([
meCaller.get(),
eventTypesCaller.getUserEventGroups({ filters }),
]);
return (
<ShellMainAppDir
heading={t("event_types_page_title")}
subtitle={t("event_types_page_subtitle")}
CTA={<EventTypesCTA initialData={initialData} filters={filters} />}>
<EventTypes initialData={initialData} filters={filters} />
CTA={<EventTypesCTA userEventGroupsData={userEventGroupsData} />}>
<EventTypes userEventGroupsData={userEventGroupsData} user={me} />
</ShellMainAppDir>
);
};
export default Page;
export const revalidate = 3600; // 1 hour in seconds
@@ -0,0 +1,15 @@
"use client";
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import { EventTypesSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader";
import { useLocale } from "@calcom/lib/hooks/useLocale";
export function EventTypesSkeleton() {
const { t } = useLocale();
return (
<ShellMainAppDir heading={t("event_types_page_title")} subtitle={t("event_types_page_subtitle")}>
<EventTypesSkeletonLoader />
</ShellMainAppDir>
);
}
@@ -1,10 +1,12 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getServerSideProps } from "@lib/apps/getServerSideProps";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { UserRepository } from "@calcom/lib/server/repository/user";
import type { AppCategories } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import AppsPage from "~/apps/apps-view";
@@ -18,13 +20,43 @@ export const generateMetadata = async () => {
);
};
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async () => {
const req = buildLegacyRequest(await headers(), await cookies());
const session = await getServerSession({ req });
let appStore, userAdminTeamsIds: number[];
if (session?.user?.id) {
const userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id);
userAdminTeamsIds = userAdminTeams?.teams?.map(({ team }) => team.id) ?? [];
appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeamsIds);
} else {
appStore = await getAppRegistry();
userAdminTeamsIds = [];
}
const ServerPage = async ({ params, searchParams }: PageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(context);
const categoryQuery = appStore.map(({ categories }) => ({
categories: categories || [],
}));
const categories = categoryQuery.reduce((c, app) => {
for (const category of app.categories) {
c[category] = c[category] ? c[category] + 1 : 1;
}
return c;
}, {} as Record<string, number>);
return <AppsPage {...props} />;
const props = {
categories: Object.entries(categories)
.map(([name, count]): { name: AppCategories; count: number } => ({
name: name as AppCategories,
count,
}))
.sort(function (a, b) {
return b.count - a.count;
}),
appStore,
userAdminTeams: userAdminTeamsIds,
};
return <AppsPage {...props} isAdmin={session?.user?.role === "ADMIN"} />;
};
export default ServerPage;
@@ -13,7 +13,8 @@ export function AppHomePageSkeleton() {
isPublic
heading={t("app_store")}
subtitle={t("app_store_description")}
headerClassName="sm:hidden lg:block hidden">
headerClassName="sm:hidden lg:block hidden"
isAdmin={false}>
<SkeletonContainer>
<div className="flex flex-col gap-y-8">
{/* Featured Categories section */}
@@ -9,14 +9,11 @@ import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import type { OnboardingPageProps } from "~/apps/installation/[[...step]]/step-view";
import Page from "~/apps/installation/[[...step]]/step-view";
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const { appMetadata } = await getData(legacyCtx);
export const generateMetadata = async ({ params }: PageProps) => {
const stepParam = (await params).step;
const step = stepParam && Array.isArray(stepParam) ? stepParam.join("/") : "";
return await _generateMetadata(
(t) => `${t("install")} ${appMetadata?.name ?? ""}`,
(t) => t("app_installation"),
() => "",
undefined,
undefined,
@@ -23,7 +23,6 @@ const normalizePages = (pages: string[] | string | undefined) => {
export const generateMetadata = async ({ params }: { params: Promise<{ pages: string[] }> }) => {
const { mainPage } = normalizePages((await params).pages);
return await _generateMetadata(
// TODO: Need to show the actual form name instead of "Form"
(t) => (mainPage === "routing-link" ? `Form | Cal.com Forms` : `${t("routing_forms")} | Cal.com Forms`),
() => "",
undefined,
@@ -48,15 +47,16 @@ const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
</FormProvider>
);
if (mainPage === "routing-link") {
return <FinalComponent />;
// TODO: we should really be using layout for this.
if (mainPage === "forms") {
return (
<Shell withoutMain>
<FinalComponent />
</Shell>
);
}
return (
<Shell withoutMain>
<FinalComponent />
</Shell>
);
return <FinalComponent />;
};
export default ServerPage;
@@ -0,0 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
export async function revalidateEventTypeEditPage(id: number) {
revalidatePath(`/event-types/${id}`);
}
@@ -0,0 +1,14 @@
"use client";
import Shell from "@calcom/features/shell/Shell";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert } from "@calcom/ui/components/alert";
export default function Error() {
const { t } = useLocale();
return (
<Shell>
<Alert severity="error" title={t("something_went_wrong")} />
</Shell>
);
}
@@ -0,0 +1,5 @@
import { EventTypeEditPageSkeleton } from "./skeleton";
export default function Loading() {
return <EventTypeEditPageSkeleton />;
}

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