Compare commits

..
87 Commits
Author SHA1 Message Date
GitHub Actions d208074063 chore: release v5.3.8
Post release / release (push) Canceled after 0s
2025-05-28 21:11:43 +00:00
Joe Au-Yeung 30541da1fa Fix logging (#21593) 2025-05-28 16:30:14 +00:00
Carina WollendorferandCarinaWolli 97af0af0ba fix: only generate sms message bodies when needed (#21566)
* only generate template when needed

* rename function to getSMSMessageWithVariables

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-28 16:01:40 +00:00
Faiz e9b26921da fix: misaligned and undersized tick mark in timezone dropdown (#21550) 2025-05-28 12:37:16 +00:00
Alex van Andel e4434f199d Function return type is illegal, not sure how this passed eslint (#21567) 2025-05-28 13:21:14 +01:00
Joe Au-Yeung f4b0219a0e Variable fix (#21557) 2025-05-28 02:38:20 +01:00
Joe Au-Yeung 6f1eaaa02e fix: Create separate calendar events to hide organizer (#21555)
* Add notes to `EventManager.create`

* Create `CalendarServiceEvent`

* Generate calendar description and remove attendees if event is private in `CalendarManager`

* Process event for reschedule method

* Refactor calendar services

* Clean up comment

* Type fixes

* Type fix

* Type fix
2025-05-27 20:46:26 -04:00
Udit TakkarandAlex van Andel e5868837d5 perf: service worker lifecycle method (#21532)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-05-27 23:12:25 +00:00
GitHub Actions 71a5d8b261 chore: release v5.3.7
Post release / release (push) Canceled after 0s
2025-05-27 22:35:54 +00:00
Alex van Andel dfc022d890 Revert "perf: parallelize getBusyTimes calls to improve performance (#21372)"
This reverts commit a06b2009ab.
2025-05-27 23:30:30 +01:00
GitHub Actions 68cb0cf197 chore: release v5.3.7 2025-05-27 22:28:37 +00:00
Syed Ali ShahbazandUdit Takkar 14592de6c4 fix: Anonymize host email in ics (#21544)
* add virta check for anonymizing host

* move to env var

* move env var to constants

* fix: filter empty string

* fix test

* fix test

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2025-05-27 19:28:03 +01:00
Omar López d41dcc69ad refactor: prevent mixed imports (#21500)
Signed-off-by: Omar López <zomars@me.com>
2025-05-27 11:55:02 -04:00
Eunjae Lee 0a28b5825b fix: date range computation on /insights (#21436)
* fix: date range computation on /insights

* remove log

* error handling

* update test cases

* WIP

* fix implementation

* support weekStart

* clean up

* fix date range presets

* clean up timeView

* fix timeView

* clean up

* fix event trends

* remove unused variables

* fix flaky e2e tests

* fix date range

* update formatting logic
2025-05-27 17:47:40 +02:00
Eunjae Lee 3131318b6a fix: hide organizer info on rescheduled events (#21541)
* fix(email): hide organizer info on rescheduled events

* hide organizer email on bookings-single-view

* replace organizer email with name

* sanitize previous booking only

* clean up code

* fix dumb error
2025-05-27 15:35:16 +00:00
sean-brydonandAlex van Andel 08e35e4698 perf: stop merging routers twice. (#21538)
* move away from merging twice

* move to use loggedIn Router directly

* fix router outputs types

* fix mocks in create button with teasm tests

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-05-27 15:42:14 +01:00
ed4b3f082a feat: cal video settings (#21347)
* feat: cal video settings

* fix: var names

* feat: finish setting

* chore: add enabled

* chore: remove logs

* feat: support redirect url on exit

* feat: finish settings

* refactor: improve UI

* chore: remove logs

* fix: type err

* fix: finish redirect url

* fix: add type

* chore: change schema

* fix: type error

* chore: improvements

* chore: remove index

* feat: add organizer

* chore: remove log

* chore: use safe parse

* fix: add nullable

* refactor: hide toggles if platorm

* chore: update description

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
2025-05-27 10:13:24 -04:00
Rajiv SahalandAnik Dhabal Babu a1007398f6 fix: update stripe webhooks to handle past due subscriptions (#20466)
* handle case for past due subscriptions

* update user subscription again if active

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-05-27 13:06:42 +00:00
Lauris Skraucis 2aafa1c163 feat: v2 managed organizations pagination (#21359)
* feat: getPagination helper

* refactor: bookings use getPagination helper

* feat: getPagination helper

* refactor: group pagination inputs and outputs in types/pagination

* feat: paginated managed orgs

* fix tests

* swagger
2025-05-27 13:50:31 +03:00
FaizandUdit Takkar 008178480a fix: 'Hide from profile' toggle background not sized correctly on mobile (#21535)
* Fix: 'Hide from profile' toggle background not sized correctly on mobile

* fix: changed padding back to original

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-05-26 18:15:29 -04:00
Amit SharmaandPeer Richelsen 2334c3bef8 fix: optimize SEO healthscore (#21509)
Post release / release (push) Canceled after 0s
* compress app store images

* replace http linkages with https

* fix link raycast

* update http links to https

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-05-26 20:24:08 +00:00
GitHub Actions 9542116ec1 chore: release v5.3.6 2025-05-26 19:58:52 +00:00
Anik Dhabal Babu f9c11c2856 fix: (Reschedule) Email Settings being ignored for RR event (#21520)
* fix: delete dilog button not visible

* fix: (Reschedule) Email Settings being ignored for RR event

* update
2025-05-26 16:17:05 +00:00
Hariom BalharaandOmar López 30060f03eb fix: GoogleCalendar - Ensure that Delegation Credentials are able to reuse access_token (#21389)
Co-authored-by: Omar López <zomars@me.com>
2025-05-26 15:19:52 +01:00
Vijay b8ffcef45e fix: Remove global GTM container on private booking page. (#21490)
* add route filter to inject script

* update middleware for pathname as Referer might not work all time

* move d to (booking-page-wrapper) instead of route filter and change in middleware

* add e2e tests
2025-05-26 10:14:24 +05:30
Rajiv Sahal 999882b795 dont pass attribues and facetedTeamValues for platform (#21508) 2025-05-25 22:17:42 +00:00
Omar López bda963b851 refactor: prevents mixed server/clients imports (#21499) 2025-05-25 09:07:49 +00:00
GitHub Actions 8511494d68 chore: release v5.3.5
Post release / release (push) Canceled after 0s
2025-05-25 00:08:53 +00:00
Benny Joo 1c3ad9dad6 refactor: make routing forms code more maintainable (#21478)
* create layout for routing/forms

* refactor

* refators

* fix

* clean up
2025-05-24 22:44:05 +00:00
Anik Dhabal Babu d43afe7dbd fix: e2e test failure in ci (#21510)
* fix: delete dilog button not visible

* fix: global e2e fialure

* update

* tweak
2025-05-24 13:11:36 -04:00
devin-ai-integration[bot]andkeith@cal.com a06b2009ab perf: parallelize getBusyTimes calls to improve performance (#21372)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
2025-05-24 08:43:06 -04:00
Omar López 2a786b4652 Revert "test: fix few e2e flakes (#21408)" (#21501) 2025-05-23 15:30:33 -07:00
Anik Dhabal Babu ef1844557f fix: Host schedule not updating upon updating/deleting default schedule (#20750)
* fix: host schdule not updating

* Update util.ts

* tweak

* Update delete.handler.ts

* add test

* update

* Update util.test.ts
2025-05-23 17:53:40 -04:00
Alex van Andel d1f7b043e8 chore: Improve error handling for avatar API, shouldn't result in timeouts (#21493) 2025-05-23 18:08:04 +00:00
3a8e40d029 feat(calendar): add error tracking with attempts to SelectedCalendar (#21326)
Co-authored-by: hariom@cal.com <hariom@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2025-05-23 16:38:54 +00:00
bca46228e7 feat: upgrade typescript to 5.8.3 (#21449)
* feat: upgrade typescript to 5.8.3

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

* chore: fix TypeScript 5.8.3 compatibility issues

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

* Uncomment `optmizeImage`

* Type fix

* Type fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: joe@cal.com <joe@cal.com>
Co-authored-by: Joe <j.auyeung419@gmail.com>
2025-05-23 13:57:37 +01:00
Omar López d15b24b8ea ci: remove deprecated submodule code (#20731) 2025-05-23 12:25:33 +00:00
Udit TakkarandSyed Ali Shahbaz d9df9b3d01 feat: recording url with 6 months expiry time (#18707)
* feat: store cal video recording on s3

* feat: create new route to fetch download link

* tests: add unit test

* fix: unit test

* fix: type error

* chore: add env var

* fix: open in new tab

* fix: type err

* chore: update test

* fix: import

* chore: use cal video recording token secret

---------

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2025-05-23 13:01:33 +01:00
Lauris Skraucis fc1d6ae402 refactor: platform docs and oauth webhook auth (#21488)
* fix: Cannot read properties of undefined (reading 'id')

* docs: managed users dont have cal.com page

* docs: reassign works only for round robin bookings

* refactor: put OAuthClientWebhooksController behind ApiAuthGuard

* fix: tests

* fix: typo
2025-05-23 14:06:47 +03:00
Peer Richelsen 26b4b7a868 fixed two dark mode app logos (#21482) 2025-05-23 08:08:27 +00:00
Anik Dhabal Babu a839630ca3 test: fix few e2e flakes (#21408)
* fix flakes

* update

* Update booking.e2e.ts

* update

* Update booking.e2e.ts

* Update booking.e2e.ts
2025-05-23 12:40:27 +05:30
Hariom Balhara e21dbb704e fix error in selected-calendars cron for delegation credential (#21465) 2025-05-23 03:37:24 +00:00
GitHub Actions 53a06797a9 chore: release v5.3.4
Post release / release (push) Canceled after 0s
2025-05-22 23:03:42 +00:00
Carina WollendorferandCarinaWolli 99eb743772 add verifiedAt timestamp (#21477)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-22 22:42:25 +00:00
73adfb0cec fix: header in routing forms (#21445)
* fix header

* remove unused variables

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-05-22 19:47:10 +00:00
Rajiv SahalandMorgan 7127c60116 fix: stripe integration in EventTypeSettings atom (#20919)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-05-22 12:18:28 -07:00
Joe Au-Yeung a6d37fd167 feat: Round robin, when attendee books same timeslot return original booking (#21455) 2025-05-22 12:06:28 -07:00
Benny Joo 16ea15076d fix: increase timeout to 800 seconds for workflow reminders (#21476) 2025-05-22 18:31:02 +00:00
Anik Dhabal Babu 041871f1b6 fix: only fixed hosts added (#21407) 2025-05-22 18:14:46 +00:00
Carina WollendorferandCarinaWolli 8c8ae127fe feat: allow custom SMS templates for free users (#21469)
* charge free users for SMS to CA an US

* allow custom template for free users

* fix backend + some UI fixes

* make text editable

* rename back to needsTeamsUpgrade

* add twilio webhook handler tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-22 19:09:49 +01:00
357c82129c feat: Dub App integration (#21321)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-05-22 09:18:45 -07:00
Eunjae Lee 324ac691eb fix: use local date range on /insights (based on profile's timezone) (#21472)
* fix: use local date range on /insights (based on profile's timezone)

* update test case
2025-05-22 16:03:56 +00:00
devin-ai-integration[bot]andeunjae@cal.com da99001a7a - Prefer the implementation in packages/trpc/react/hooks as it's used by most of the codebase (#21468)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-05-22 11:24:58 -04:00
Anik Dhabal Babu bd10d07083 fix: delete dilog button not visible (#21470) 2025-05-22 14:42:35 +00:00
Hariom BalharaandUdit Takkar 0e9b419a07 fix: Delegation Credentials - Use Cal Video if set as the default conferencing app (#21431)
* fix google-meet being used instead of Default Cal Video preference if
set

* Review addressed

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-05-22 08:21:25 -04:00
Hariom Balhara d5bd08d279 improve-delegation-disable-email-communication (#21456) 2025-05-22 09:06:31 +00:00
devin-ai-integration[bot]andeunjae@cal.com 4cc83530f9 refactor(e2e): use filter helper methods instead of raw selectors (#21460)
* refactor(e2e): use filter helper methods instead of raw selectors

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

* refactor(e2e): rename selectFilter to addFilter and update all usages

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-05-22 08:46:51 +00:00
Hariom BalharaandOmar López 4fcf4081d0 feat: Send emails to potentially affected Organization Members on disabling delegation credential (#21276)
* mvp

* Improve UI

* wip

* self-review

* self-review

---------

Co-authored-by: Omar López <zomars@me.com>
2025-05-22 03:28:21 +00:00
GitHub Actions d84d80eea6 chore: release v5.3.3
Post release / release (push) Canceled after 0s
2025-05-22 01:05:29 +00:00
f3e1d67de1 feat: internationalize user table column headers (#21444)
* feat: internationalize user table column headers

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

* revert

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: benny@cal.com <benny@cal.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
2025-05-21 20:10:28 +00:00
Benny Joo 2ce25f54d4 refactor: simplify arg of useBookerLayout hook (#21425) 2025-05-21 10:29:02 -07:00
Benny Joo 25eac5d29b chore: remove last booking response dead code (#21426) 2025-05-21 10:27:52 -07:00
Benny Joo 1710e30719 perf: use server fetched data in instant-meeting and private booking pages (#21424)
* remove useEvent hook

* refactor

* use repository functions

* remove trpc event

* wip

* fix

* remove

* fix

* revert some files

* revert Embed

* revert useEvent hook

* fix

* fix

* revert

* refactor
2025-05-21 10:27:23 -07:00
devin-ai-integration[bot]andeunjae@cal.com 6d970d0b57 fix: improve date selection behavior in DateRangePicker (#21438)
* fix: implement Airbnb-style date selection in DateRangePicker

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

* fix: add behavior for clicking on start/end date in DateRangePicker

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-05-21 16:13:55 +00:00
41a6035a12 fix: replace bookingTimeStatus with bookingTimeStatusDenormalized (#21028)
* test: Add unit tests for buildBaseWhereCondition function

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

* refactor: Export buildBaseWhereCondition function instead of copying it

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

* refactor: Remove implementation detail checks from tests

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

* test: Fix tests to match actual function behavior

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

* test: Fix test issues with buildBaseWhereCondition

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

* test: Update tests to match actual function behavior

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

* fix: Address security issues in buildBaseWhereCondition function

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

* refactor: Simplify buildBaseWhereCondition function

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

* refactor: Simplify code and remove isEmptyResponse property

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

* refactor: Use array of conditions approach for buildBaseWhereCondition

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

* refactor: Make buildBaseWhereCondition implementation consistent and update tests

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

* fix: Fix incorrect usage of buildBaseWhereCondition function

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

* chore: Revert yarn.lock changes

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

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

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

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

This reverts commit 44c21a7396a518c63047b33967cd8af7e6e4f268.

* remove unnecessary if-statement

* fix: replace bookingTimeStatus with bookingTimeStatusDenormalized

* change column

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
2025-05-21 16:10:43 +00:00
Akarsh Jain f84c8239e7 feat(bookings): implement Booking UID filter for bookings data table. (#21380)
Fixes: #21357
2025-05-21 11:51:50 -04:00
sean-brydon 1dca160db0 perf: use app dir to fetch team feature flags in settings (#21433)
* use app dir to fetch team feature flags

* guard against type error if teamId does not exist in teamfeatures

* remove redundant router
2025-05-21 12:41:45 +00:00
Carina WollendorferandCarinaWolli 4ff366cd99 fix: canceling workflow reminders (#21416)
* fix deleting reminders

* schedule reminders if iffy is disabled

* fix unit test

* clean up code

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-21 12:35:31 +00:00
Carina WollendorferandCarinaWolli 21a4711f07 fix: don't schedule past workflow reminders with tasker (#21434)
* don't schedule past reminders

* add test

* fix dates in tests

* add early return

* fix sms test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-21 12:24:34 +00:00
Anik Dhabal Babu f1dd217de7 fix: don’t let the booking page crash on invalid metadata (#21410)
* fix: booking tab crash

* Update zod-utils.ts

* Update zod-utils.ts

* update
2025-05-21 09:44:17 +01:00
Anik Dhabal BabuandCarinaWolli 3e8f0b125d fix: WhatsApp workflows are not working (#21421)
* fix: whatsapp template

* update

* fixes for sending whatsapp messages

* add message service id when scheduling

* add timezone to startTime variable

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-21 08:12:12 +00:00
devin-ai-integration[bot]andeunjae@cal.com 25246ef8ff test: add e2e tests for filters on /insights/routing (#21332)
* test: add e2e tests for insights routing filters

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

* fix: improve stability of insights routing filters e2e tests

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

* fix: address PR feedback for insights routing filters e2e tests

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

* fix: add explicit page navigation to resolve timeout issues in filters.e2e.ts

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

* fix: navigate to routing tab after loading insights page

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

* fix e2e tests

* fix tests

* rename

* fix tests

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-05-20 18:23:57 -04:00
Benny Joo bb98143e2e perf: Server Fetching for platform members and org members pages (#21342)
* remove members page

* refactor to server fetch

* same for /members

* use server fetches for all queires

* fix

* add skeletons

* refactor

* fix loading page

* fix

* fix type checks

* fix details

* refactor platform members page
2025-05-20 16:36:34 -04:00
devin-ai-integration[bot]andeunjae@cal.com 6258ca14d0 feat: add segments prop to DataTableProvider (#21409)
* feat: add segments prop to DataTableProvider

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

* refactor: modify useSegments hook instead of creating a new one

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

* fix: implement PR feedback and fix failing test

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

* clean up

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
2025-05-20 22:30:32 +02:00
Eunjae Lee d0e17e8611 fix: add migration to backfill BookingDenormalized (#21420) 2025-05-20 10:01:09 -07:00
Eunjae Lee 79dc76239b fix: apply transaction to public methods of CreditService (#21356) 2025-05-20 18:20:45 +02:00
Eunjae Lee 95d62354d9 test: update flaky tests (#21419)
* test: update flaky integration tests

* change limit
2025-05-20 14:57:43 +00:00
b9fadf82a7 feat: pbac (#21296)
* inital pbac setup

* fix tests

* docs intro

* more docs

* add comments in docs

* revert pr_todo

* update to use action + custom as defaults

* use enums

* move to change user role + multi check

* add beforeEach import

* docs: add PERMISSIONS.md documenting team/organization role usages

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

* docs: add permission string alternatives for helper functions

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

* feat: enhance permission registry and default role permissions

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

* add routers

* add hooks + rsc. Also adjsuted test to use prismock

* make hooks type safe + add some tests

* add a permission context - trpc router to populate context

* update docs

* explore global and default roles member,admin,owner

* fix tests

* use kysley and abstract uril

* tidy up

* update trpc to use pbac route in endpoint + distinct on teamId

* make transform util

* remove role service dependancy from permission check class

* checkTeamId fall back to orgId if team is parent

* todos

* remove manual migrations to launch softly

* address feedback

* move permission checks to a repo

* move role service database calls to repo. improve mocks

* update tests to have describe import

* fix types

* remove router that is not used to pass build

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-05-20 15:22:52 +01:00
devin-ai-integration[bot]andalex@cal.com 1740e34c79 perf: remove unused submitFeedback tRPC route to reduce type generation load (#21418)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: alex@cal.com <me@alexvanandel.com>
2025-05-20 15:14:51 +01:00
Eunjae Lee 9e09066e99 chore: use correct import path for AppRouter (#21415) 2025-05-20 13:50:20 +00:00
Benny Joo 0a9cfd3a08 perf: cache TRPC queries in /availability RSC (#21398)
* cache trpc queries in /availability

* fix

* fix

* add comments

* use undefined
2025-05-20 08:04:52 +01:00
Benny Joo 0be711ad6e perf: cache TRPC queries in /event-types/[eventTypeId] RSC (#21401)
* cache /event-types details page

* fix
2025-05-20 08:04:01 +01:00
Alex van Andel 1c507b8169 chore: Increase workflows/trpc timeout to 800s (#21397) 2025-05-20 06:44:12 +00:00
81bdb3dc91 fix: Handle invalid non-delegation credential in cron (#21270)
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2025-05-20 03:17:13 +00:00
devin-ai-integration[bot]andkeith@cal.com 5efc7f3856 perf: optimize O(n²) algorithms in slot generation to O(n) or O(n log n) (#21373)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keithwillcode@gmail.com>
2025-05-19 18:40:18 -04:00
Benny Joo 0afecec607 perf: follow up of server query caching in /event-types (#21402) 2025-05-19 19:39:51 +00:00
Benny Joo 920574de65 perf: cache TRPC queries in /event-types RSC (#21381)
* improve trpc funcs

* cache /event-types

* remove unused code

* add comments

* add keys

* improve typing
2025-05-19 18:23:06 +00:00
401 changed files with 12500 additions and 3390 deletions
+8
View File
@@ -188,6 +188,10 @@ TWILIO_TOKEN=
TWILIO_MESSAGING_SID=
TWILIO_PHONE_NUMBER=
TWILIO_WHATSAPP_PHONE_NUMBER=
TWILIO_WHATSAPP_REMINDER_CONTENT_SID=
TWILIO_WHATSAPP_CANCELLED_CONTENT_SID=
TWILIO_WHATSAPP_RESCHEDULED_CONTENT_SID=
TWILIO_WHATSAPP_COMPLETED_CONTENT_SID=
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
NEXT_PUBLIC_SENDER_ID=
TWILIO_VERIFY_SID=
@@ -441,6 +445,10 @@ NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM=0
# Used to enable quick availability checks for x% of all visitors
NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
# Used to generate token for cal video recordings expiry token
# You can use: `openssl rand -base64 32` to generate one
CAL_VIDEO_RECORDING_TOKEN_SECRET=
# Deprecated. Use NEXT_PUBLIC_BODY_SCRIPTS instead. NEXT_PUBLIC_HEAD_SCRIPTS are now injected into the body only.
NEXT_PUBLIC_HEAD_SCRIPTS=
NEXT_PUBLIC_BODY_SCRIPTS=
-21
View File
@@ -1,21 +0,0 @@
name: Submodule Sync
on:
schedule:
# Runs "At minute 15 past every 4th hour." (see https://crontab.guru)
- cron: "15 */4 * * *"
workflow_dispatch: ~
jobs:
submodule-sync:
name: Submodule update
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: Commit
run: |
git config user.email "actions@github.com"
git config user.name "github-actions"
git commit -am "Auto updated submodule references" && git push || echo "No changes to commit"
-6
View File
@@ -1,6 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
echo "Updating submodules recursively"
pwd
git submodule update --init --recursive
View File
View File
+6
View File
@@ -0,0 +1,6 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
+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
+7
View File
@@ -0,0 +1,7 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
View File
+189
View File
@@ -0,0 +1,189 @@
# Cal.com Permission Documentation
This document maps existing role-based permission checks to the new PBAC (Permission-Based Access Control) system's permission strings in the format `resource.action`.
## Permissions by Resource
### Team Permissions
| Permission String | Description | File Path | Line |
|------------------|-------------|-----------|------|
| team.create | Create teams | [packages/trpc/server/routers/viewer/teams/create.handler.ts](packages/trpc/server/routers/viewer/teams/create.handler.ts) | 55-57 |
| team.update | Update team settings | [packages/trpc/server/routers/viewer/teams/update.handler.ts](packages/trpc/server/routers/viewer/teams/update.handler.ts) | 18 |
| team.changeMemberRole | Change role of team members | [packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts](packages/trpc/server/routers/viewer/teams/changeMemberRole.handler.ts) | 18-21 |
| team.remove | Remove members from team | [packages/trpc/server/routers/viewer/teams/removeMember.handler.ts](packages/trpc/server/routers/viewer/teams/removeMember.handler.ts) | 35-36, 51-55 |
| team.invite | Invite members to team | [packages/trpc/server/routers/viewer/teams/invite.handler.ts](packages/trpc/server/routers/viewer/teams/invite.handler.ts) | - |
### Event Type Permissions
| Permission String | Description | File Path | Line |
|------------------|-------------|-----------|------|
| eventType.create | Create event types | [packages/trpc/server/routers/viewer/eventTypes/create.handler.ts](packages/trpc/server/routers/viewer/eventTypes/create.handler.ts) | - |
| eventType.update | Update event types | [packages/trpc/server/routers/viewer/eventTypes/update.handler.ts](packages/trpc/server/routers/viewer/eventTypes/update.handler.ts) | 162-164 |
| eventType.delete | Delete event types | [packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts](packages/trpc/server/routers/viewer/eventTypes/delete.handler.ts) | 13-31 |
### Booking Permissions
| Permission String | Description | File Path | Line |
|------------------|-------------|-----------|------|
| booking.read | Read booking details | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 95-107, 132-134 |
| booking.readTeamBookings | Read team bookings | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 240-252 |
| booking.readOrgBookings | Read organization bookings | [packages/trpc/server/routers/viewer/bookings/get.handler.ts](packages/trpc/server/routers/viewer/bookings/get.handler.ts) | 240-252 |
### Organization Permissions
| Permission String | Description | File Path | Line |
|------------------|-------------|-----------|------|
| organization.read | Read organization details | [packages/trpc/server/routers/viewer/organizations/get.handler.ts](packages/trpc/server/routers/viewer/organizations/get.handler.ts) | - |
| organization.listMembers | List organization members | [packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts](packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts) | 68-76 |
| organization.create | Create organization | [packages/trpc/server/routers/viewer/organizations/create.handler.ts](packages/trpc/server/routers/viewer/organizations/create.handler.ts) | - |
### API Key Permissions
| Permission String | Description | File Path | Line |
|------------------|-------------|-----------|------|
| apiKey.create | Create API keys | [packages/trpc/server/routers/viewer/apiKeys/create.handler.ts](packages/trpc/server/routers/viewer/apiKeys/create.handler.ts) | 25-26 |
| apiKey.findKeyOfType | Find API keys by type | [packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts](packages/trpc/server/routers/viewer/apiKeys/findKeyOfType.handler.ts) | 18-19 |
## Helper Functions and Utilities
The following helper functions are commonly used for permission checks:
| Function | Description | File Path |
|----------|-------------|-----------|
| isTeamAdmin | Checks if user is admin or owner of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L385-L405) |
| isTeamOwner | Checks if user is owner of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L407-L416) |
| isTeamMember | Checks if user is a member of a team | [packages/lib/server/queries/teams/index.ts](packages/lib/server/queries/teams/index.ts#L418-L426) |
| canEditEntity | Checks if user can edit an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L13-L22) |
| canAccessEntity | Checks if user can access an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L24-L34) |
| getEntityPermissionLevel | Gets permission level for an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L36-L73) |
| canCreateEntity | Checks if user can create an entity | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L102-L115) |
| withRoleCanCreateEntity | Checks if role allows entity creation | [packages/lib/entityPermissionUtils.ts](packages/lib/entityPermissionUtils.ts#L119-L121) |
## Permission Level Enums
The codebase uses several enums to define permission levels:
### MembershipRole Enum
```typescript
enum MembershipRole {
OWNER = "OWNER",
ADMIN = "ADMIN",
MEMBER = "MEMBER"
}
```
### Entity Permission Level Enum
```typescript
enum ENTITY_PERMISSION_LEVEL {
NONE,
USER_ONLY_WRITE,
TEAM_READ_ONLY,
TEAM_WRITE
}
```
## Common Permission Patterns
1. **Team Admin/Owner Check**: Many operations require the user to be a team admin or owner.
```typescript
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
```
2. **Team Owner Check**: Some operations (like changing an owner's role) require the user to be a team owner.
```typescript
if (input.role === MembershipRole.OWNER && !(await isTeamOwner(ctx.user?.id, input.teamId)))
throw new TRPCError({ code: "UNAUTHORIZED" });
```
3. **Organization Admin Check**: Operations within an organization require the user to be an organization admin.
```typescript
if (user.profile?.organizationId && !user.organization.isOrgAdmin) {
throw new TRPCError({ code: "FORBIDDEN", message: "org_admins_can_create_new_teams" });
}
```
4. **Entity Permission Check**: Entity operations use permission level checks.
```typescript
const permissionLevel = await getEntityPermissionLevel(entity, userId);
return permissionLevel === ENTITY_PERMISSION_LEVEL.TEAM_WRITE ||
permissionLevel === ENTITY_PERMISSION_LEVEL.USER_ONLY_WRITE;
```
5. **Booking Access Control**: Complex permission checks for retrieving bookings based on user roles and team/organization membership.
```typescript
const membershipIdsWhereUserIsAdminOwner = (
await prisma.membership.findMany({
where: {
userId: user.id,
role: {
in: ["ADMIN", "OWNER"],
},
},
select: {
id: true,
},
})
).map((membership) => membership.id);
```
## Migration to PBAC
When migrating to the new PBAC system, these existing role-based checks should be replaced with permission string checks using the `permissionMatches` function:
```typescript
import { permissionMatches } from "@calcom/features/pbac/types/permission-registry";
// Instead of:
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
// Use:
if (!permissionMatches("team.update", userPermissions)) throw new TRPCError({ code: "UNAUTHORIZED" });
```
## Permission String Alternatives for Helper Functions
The following table provides permission string alternatives for common helper functions:
| Helper Function | Permission String Alternative | Description |
|-----------------|-------------------------------|-------------|
| isTeamAdmin | `team.*` | Grants all team permissions |
| isTeamAdmin | `team.update` | Update team settings |
| isTeamAdmin | `team.invite` | Invite team members |
| isTeamAdmin | `team.remove` | Remove team members |
| isTeamOwner | `team.changeMemberRole` | Change role of team members |
| isTeamOwner | `team.delete` | Delete team |
| isTeamMember | `team.read` | Read-only access to team |
## Entity Permission Functions and Resources
The following functions are used to check permissions for entities with userId and teamId properties:
| Function | Description | Permission String Alternative |
|----------|-------------|-------------------------------|
| canEditEntity | Checks if user can edit an entity | `{resource}.update` |
| canAccessEntity | Checks if user can access an entity | `{resource}.read` |
| getEntityPermissionLevel | Gets permission level for an entity | N/A - Implementation detail |
| canCreateEntity | Checks if user can create an entity | `{resource}.create` |
### Resources Used with Entity Permission Functions
| Resource | canEditEntity Usage | canAccessEntity Usage | Permission String |
|----------|-------------------|---------------------|------------------|
| routingForm | [packages/app-store/routing-forms/trpc/forms.handler.ts](packages/app-store/routing-forms/trpc/forms.handler.ts#L72) | [packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts](packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts#L78) | `routingForm.update`, `routingForm.read` |
| routingForm | [packages/app-store/routing-forms/trpc/formMutation.handler.ts](packages/app-store/routing-forms/trpc/formMutation.handler.ts#L315) | | `routingForm.update` |
| routingForm | [packages/app-store/routing-forms/api/responses/[formId].ts](packages/app-store/routing-forms/api/responses/[formId].ts#L96) | | `routingForm.update` |
These functions can be used with any entity that has userId and teamId properties. When migrating to PBAC, replace these checks with appropriate permission string checks:
```typescript
// Instead of:
if (!(await canEditEntity(form, user.id))) throw new TRPCError({ code: "UNAUTHORIZED" });
// Use:
if (!permissionMatches("routingForm.update", userPermissions)) throw new TRPCError({ code: "UNAUTHORIZED" });
```
This allows for more granular permission control and the creation of custom roles with specific permissions.
@@ -18,6 +18,11 @@ export const schemaSelectedCalendarBodyParams = schemaSelectedCalendarBaseBodyPa
id: true,
// No eventTypeId support in API v1
eventTypeId: true,
/** No watch related fields support in API v1 */
watchAttempts: true,
unwatchAttempts: true,
maxAttempts: true,
});
export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams
+1 -1
View File
@@ -38,7 +38,7 @@
"next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6",
"next-validations": "^0.2.0",
"typescript": "^4.9.4",
"typescript": "^5.8.3",
"tzdata": "^1.0.30",
"uuid": "^8.3.2",
"zod": "^3.22.4"
@@ -38,7 +38,8 @@ describe("GET /api/bookings", async () => {
expect(responseData.bookings.find((b) => b.userId === memberUser.id)).toBeDefined();
expect(groupedUsers.size).toBe(1);
expect(groupedUsers.entries().next().value[0]).toBe(memberUser.id);
const firstEntry = groupedUsers.entries().next().value;
expect(firstEntry?.[0]).toBe(memberUser.id);
});
it("Returns bookings for regular user", async () => {
@@ -89,6 +89,10 @@ describe("POST /api/selected-calendars", () => {
googleChannelResourceUri: null,
googleChannelExpiration: null,
error: null,
lastErrorAt: null,
watchAttempts: 0,
maxAttempts: 3,
unwatchAttempts: 0,
});
await handler(req, res);
@@ -128,6 +132,10 @@ describe("POST /api/selected-calendars", () => {
domainWideDelegationCredentialId: null,
eventTypeId: null,
error: null,
lastErrorAt: null,
watchAttempts: 0,
maxAttempts: 3,
unwatchAttempts: 0,
});
await handler(req, res);
+1 -1
View File
@@ -108,7 +108,7 @@
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.0",
"typescript": "^5.8.2"
"typescript": "^5.8.3"
},
"prisma": {
"schema": "../../../packages/prisma/schema.prisma"
@@ -290,7 +290,8 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Reassign a booking to auto-selected host",
description: "The provided authorization header refers to the owner of the booking.",
description:
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
})
async reassignBooking(
@Param("bookingUid") bookingUid: string,
@@ -311,7 +312,8 @@ export class BookingsController_2024_08_13 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Reassign a booking to a specific host",
description: "The provided authorization header refers to the owner of the booking.",
description:
"Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
})
async reassignBookingToUser(
@Param("bookingUid") bookingUid: string,
@@ -5,6 +5,7 @@ import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servic
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { getPagination } from "@/lib/pagination/pagination";
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";
@@ -108,6 +109,12 @@ export class BookingsService_2024_08_13 {
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
}
if (eventType.schedulingType === "MANAGED") {
throw new BadRequestException(
`Event type with id=${eventType.id} is the parent managed event type that can't be booked. You have to provide the child event type id aka id of event type that has been assigned to one of the users.`
);
}
body.eventTypeId = eventType.id;
if ("instant" in body && body.instant) {
@@ -532,10 +539,13 @@ export class BookingsService_2024_08_13 {
queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user);
}
const skip = Math.abs(queryParams?.skip ?? 0);
const take = Math.abs(queryParams?.take ?? 100);
const fetchedBookings: { bookings: { id: number }[]; totalCount: number } = await getAllUserBookings({
bookingListingByStatus: queryParams.status || [],
skip: queryParams.skip ?? 0,
take: queryParams.take ?? 100,
skip,
take,
filters: {
...this.inputService.transformGetBookingsFilters(queryParams),
...(userIds?.length ? { userIds } : {}),
@@ -588,28 +598,11 @@ export class BookingsService_2024_08_13 {
}
}
const skip = Math.abs(queryParams?.skip ?? 0);
const take = Math.abs(queryParams?.take ?? 100);
const itemsPerPage = take;
const totalPages = itemsPerPage !== 0 ? Math.ceil(fetchedBookings.totalCount / itemsPerPage) : 0;
const currentPage = Math.floor(skip / itemsPerPage) + 1;
const hasNextPage = skip + itemsPerPage < fetchedBookings.totalCount;
const hasPreviousPage = skip > 0;
const pagination = getPagination({ skip, take, totalCount: fetchedBookings.totalCount });
return {
bookings: formattedBookings,
pagination: {
totalItems: fetchedBookings.totalCount,
// clamp remainingItems between 0 and totalCount
remainingItems: Math.min(
Math.max(fetchedBookings.totalCount - (skip + take), 0),
fetchedBookings.totalCount
),
itemsPerPage: itemsPerPage,
currentPage: currentPage,
totalPages: totalPages,
hasNextPage: hasNextPage,
hasPreviousPage: hasPreviousPage,
},
pagination,
};
}
@@ -0,0 +1,101 @@
import { getPagination, clamp } from "./pagination";
describe("getPagination", () => {
it("handles the first page correctly", () => {
const pagination = getPagination({ skip: 0, take: 10, totalCount: 35 });
expect(pagination).toEqual({
returnedItems: 10,
totalItems: 35,
itemsPerPage: 10,
remainingItems: 25,
currentPage: 1,
totalPages: 4,
hasNextPage: true,
hasPreviousPage: false,
});
});
it("handles a middle page correctly", () => {
const pagination = getPagination({ skip: 10, take: 10, totalCount: 35 });
expect(pagination).toEqual({
returnedItems: 10,
totalItems: 35,
itemsPerPage: 10,
remainingItems: 15,
currentPage: 2,
totalPages: 4,
hasNextPage: true,
hasPreviousPage: true,
});
});
it("handles the last page when it is not completely full", () => {
const pagination = getPagination({ skip: 30, take: 10, totalCount: 35 });
expect(pagination).toEqual({
returnedItems: 5,
totalItems: 35,
itemsPerPage: 10,
remainingItems: 0,
currentPage: 4,
totalPages: 4,
hasNextPage: false,
hasPreviousPage: true,
});
});
it("clamps skip values that exceed the total item count", () => {
const pagination = getPagination({ skip: 40, take: 10, totalCount: 35 });
expect(pagination).toEqual({
returnedItems: 0,
totalItems: 35,
itemsPerPage: 10,
remainingItems: 0,
currentPage: 4,
totalPages: 4,
hasNextPage: false,
hasPreviousPage: true,
});
});
it("works when itemsPerPage (take) is zero", () => {
const pagination = getPagination({ skip: 0, take: 0, totalCount: 35 });
expect(pagination).toEqual({
returnedItems: 0,
totalItems: 35,
itemsPerPage: 0,
remainingItems: 35,
currentPage: 0,
totalPages: 0,
hasNextPage: true,
hasPreviousPage: false,
});
});
it("works when the collection is empty", () => {
const pagination = getPagination({ skip: 0, take: 10, totalCount: 0 });
expect(pagination).toEqual({
returnedItems: 0,
totalItems: 0,
itemsPerPage: 10,
remainingItems: 0,
currentPage: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
});
});
});
describe("clamp", () => {
it("returns the value unchanged when it is inside the range", () => {
expect(clamp({ value: 5, min: 1, max: 10 })).toBe(5);
});
it("clamps values below the minimum", () => {
expect(clamp({ value: -3, min: 0, max: 10 })).toBe(0);
});
it("clamps values above the maximum", () => {
expect(clamp({ value: 42, min: 0, max: 10 })).toBe(10);
});
});
@@ -0,0 +1,78 @@
import { PaginationMetaDto } from "@calcom/platform-types";
type Pagination = {
skip: number;
take: number;
totalCount: number;
};
export function getPagination(pagination: Pagination): PaginationMetaDto {
const { skip, take, totalCount } = pagination;
const safeSkip = clamp({ value: skip, min: 0, max: totalCount });
const itemsPerPage = take;
const remainingItems = getRemainingItems(safeSkip, itemsPerPage, totalCount);
const totalPages = getTotalPages(itemsPerPage, totalCount);
const currentPage = getCurrentPage(safeSkip, itemsPerPage, totalPages);
const hasNextPage = getHasNextPage(safeSkip, itemsPerPage, totalCount);
const hasPreviousPage = getHasPreviousPage(safeSkip);
const returnedItems = getReturnedItems(safeSkip, itemsPerPage, totalCount);
return {
returnedItems,
totalItems: totalCount,
itemsPerPage,
remainingItems,
currentPage,
totalPages,
hasNextPage,
hasPreviousPage,
};
}
function getRemainingItems(skip: number, itemsPerPage: number, totalCount: number) {
return clamp({
value: totalCount - (skip + itemsPerPage),
min: 0,
max: totalCount,
});
}
function getTotalPages(itemsPerPage: number, totalCount: number) {
return itemsPerPage !== 0 ? Math.ceil(totalCount / itemsPerPage) : 0;
}
function getCurrentPage(skip: number, itemsPerPage: number, totalPages: number) {
const rawCurrentPage = Math.floor(skip / itemsPerPage) + 1;
if (totalPages === 0) {
return 0;
}
return clamp({ value: rawCurrentPage, min: 1, max: totalPages });
}
function getHasNextPage(skip: number, itemsPerPage: number, totalCount: number) {
return skip + itemsPerPage < totalCount;
}
function getHasPreviousPage(skip: number) {
return skip > 0;
}
function getReturnedItems(skip: number, itemsPerPage: number, totalCount: number) {
const remaining = totalCount - skip;
return clamp({
value: remaining,
min: 0,
max: itemsPerPage,
});
}
type ClampArgs = {
value: number;
min: number;
max: number;
};
export function clamp({ value, min, max }: ClampArgs): number {
return Math.max(min, Math.min(value, max));
}
@@ -226,20 +226,26 @@ export class EventTypesAtomService {
} else {
credentials = credentials.concat(teamAppCredentials);
}
} else {
if (slug !== "stripe") {
// We only add delegationCredentials if the request for location options is for a user because DelegationCredential Credential is applicable to Users only.
const { credentials: allCredentials } =
await enrichUserWithDelegationConferencingCredentialsWithoutOrgId({
user: {
...user,
credentials,
},
});
credentials = allCredentials;
}
}
// We only add delegationCredentials if the request for location options is for a user because DelegationCredential Credential is applicable to Users only.
const { credentials: allCredentials } = await enrichUserWithDelegationConferencingCredentialsWithoutOrgId(
const enabledApps = await getEnabledAppsFromCredentials(
credentials as unknown as CredentialDataWithTeamName[],
{
user: {
...user,
credentials,
},
where: { slug },
}
);
credentials = allCredentials;
const enabledApps = await getEnabledAppsFromCredentials(allCredentials, {
where: { slug },
});
const apps = await Promise.all(
enabledApps
.filter(({ ...app }) => app.slug === slug)
@@ -124,6 +124,9 @@ export class BillingController {
case "checkout.session.completed":
await this.billingService.handleStripeCheckoutEvents(event);
break;
case "customer.subscription.updated":
await this.billingService.handleStripePaymentPastDue(event);
break;
case "customer.subscription.deleted":
await this.billingService.handleStripeSubscriptionDeleted(event);
break;
@@ -216,6 +216,30 @@ export class BillingService implements OnModuleDestroy {
}
}
async handleStripePaymentPastDue(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
const customerId = this.getCustomerIdFromInvoice(invoice);
if (subscriptionId && customerId) {
const existingUserSubscription = await this.stripeService
.getStripe()
.subscriptions.retrieve(subscriptionId);
if (existingUserSubscription.status === "past_due") {
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, true);
}
if (existingUserSubscription.status === "active") {
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, false);
}
}
if (!subscriptionId || !customerId) {
this.logger.log(`SubscriptionId: ${subscriptionId} or customerId: ${customerId} missing`);
}
}
async handleStripeCheckoutEvents(event: Stripe.Event) {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
@@ -21,7 +21,7 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withNextAuth } from "test/utils/withNextAuth";
import { withApiAuth } from "test/utils/withApiAuth";
import { PlatformOAuthClient, Team, Webhook } from "@calcom/prisma/client";
@@ -47,7 +47,7 @@ describe("OAuth client WebhooksController (e2e)", () => {
let webhook: OAuthClientWebhookOutputResponseDto["data"];
beforeAll(async () => {
const moduleRef = await withNextAuth(
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
@@ -1,6 +1,6 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { GetWebhook } from "@/modules/webhooks/decorators/get-webhook-decorator";
import { IsOAuthClientWebhookGuard } from "@/modules/webhooks/guards/is-oauth-client-webhook-guard";
@@ -29,7 +29,7 @@ import { OAuthClientGuard } from "../../guards/oauth-client-guard";
path: "/v2/oauth-clients/:clientId/webhooks",
version: API_VERSIONS_VALUES,
})
@UseGuards(NextAuthGuard, OrganizationRolesGuard, OAuthClientGuard)
@UseGuards(ApiAuthGuard, OrganizationRolesGuard, OAuthClientGuard)
@DocsTags("Platform / Webhooks")
@ApiHeader({
name: X_CAL_SECRET_KEY,
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { SkipTakePagination } from "@calcom/platform-types";
import { Prisma } from "@calcom/prisma/client";
@Injectable()
@@ -34,11 +35,26 @@ export class ManagedOrganizationsRepository {
});
}
async getByManagerOrganizationId(managerOrganizationId: number) {
return this.dbRead.prisma.managedOrganization.findMany({
where: {
managerOrganizationId,
},
});
async getByManagerOrganizationIdPaginated(managerOrganizationId: number, pagination: SkipTakePagination) {
const { skip, take } = pagination;
const where: Prisma.ManagedOrganizationWhereInput = {
managerOrganizationId,
};
const [totalItems, linkRows] = await this.dbRead.prisma.$transaction([
this.dbRead.prisma.managedOrganization.count({ where }),
this.dbRead.prisma.managedOrganization.findMany({
where,
skip,
take,
orderBy: { managedOrganizationId: "asc" },
include: { managedOrganization: true },
}),
]);
const items = linkRows.map((l) => l.managedOrganization);
return { totalItems, items };
}
}
@@ -7,6 +7,7 @@ import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controller
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
import { CreateOrganizationInput } from "@/modules/organizations/organizations/inputs/create-managed-organization.input";
import { UpdateOrganizationInput } from "@/modules/organizations/organizations/inputs/update-managed-organization.input";
import { GetManagedOrganizationsOutput } from "@/modules/organizations/organizations/outputs/get-managed-organizations.output";
import {
ManagedOrganizationWithApiKeyOutput,
ManagedOrganizationOutput,
@@ -304,7 +305,7 @@ describe("Organizations Organizations Endpoints", () => {
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ManagedOrganizationOutput[]> = response.body;
const responseBody: GetManagedOrganizationsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseManagedOrgs = responseBody.data;
expect(responseManagedOrgs?.length).toEqual(1);
@@ -312,6 +313,14 @@ describe("Organizations Organizations Endpoints", () => {
expect(responseManagedOrg?.id).toBeDefined();
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
expect(responseManagedOrg?.metadata).toEqual(managedOrg.metadata);
expect(responseBody.pagination).toBeDefined();
expect(responseBody.pagination.totalItems).toEqual(1);
expect(responseBody.pagination.remainingItems).toEqual(0);
expect(responseBody.pagination.returnedItems).toEqual(1);
expect(responseBody.pagination.itemsPerPage).toEqual(250);
expect(responseBody.pagination.currentPage).toEqual(1);
expect(responseBody.pagination.totalPages).toEqual(1);
});
});
@@ -28,10 +28,12 @@ import {
HttpCode,
HttpStatus,
Delete,
Query,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
const SCALE = "SCALE";
@@ -99,14 +101,15 @@ export class OrganizationsOrganizationsController {
"Requires the user to have at least the 'ORG_ADMIN' role within the organization. Additionally, for platform, the plan must be 'SCALE' or higher to access this endpoint.",
})
async getOrganizations(
@Param("orgId", ParseIntPipe) managerOrganizationId: number
@Param("orgId", ParseIntPipe) managerOrganizationId: number,
@Query() queryPagination: SkipTakePagination
): Promise<GetManagedOrganizationsOutput> {
const organizations = await this.managedOrganizationsService.getManagedOrganizations(
managerOrganizationId
);
const { organizations, pagination: responsePagination } =
await this.managedOrganizationsService.getManagedOrganizations(managerOrganizationId, queryPagination);
return {
status: SUCCESS_STATUS,
data: organizations,
pagination: responsePagination,
};
}
@@ -4,6 +4,7 @@ import { Expose, Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { PaginationMetaDto } from "@calcom/platform-types";
export class GetManagedOrganizationsOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@@ -17,4 +18,9 @@ export class GetManagedOrganizationsOutput {
@ValidateNested({ each: true })
@Type(() => ManagedOrganizationOutput)
data!: ManagedOrganizationOutput[];
@ApiProperty({ type: () => PaginationMetaDto })
@Type(() => PaginationMetaDto)
@ValidateNested()
pagination!: PaginationMetaDto;
}
@@ -1,3 +1,4 @@
import { getPagination } from "@/lib/pagination/pagination";
import { ApiKeysService } from "@/modules/api-keys/services/api-keys.service";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
@@ -11,6 +12,7 @@ import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { slugify } from "@calcom/platform-libraries";
import { SkipTakePagination } from "@calcom/platform-types";
@Injectable()
export class ManagedOrganizationsService {
@@ -99,18 +101,22 @@ export class ManagedOrganizationsService {
return this.managedOrganizationsOutputService.getOutputManagedOrganization(organization);
}
async getManagedOrganizations(managerOrganizationId: number) {
const managedOrganizations = await this.managedOrganizationsRepository.getByManagerOrganizationId(
managerOrganizationId
);
const managedOrganizationsIds = managedOrganizations.map(
(managedOrganization) => managedOrganization.managedOrganizationId
);
async getManagedOrganizations(managerOrganizationId: number, pagination: SkipTakePagination) {
const { items: managedOrganizations, totalItems } =
await this.managedOrganizationsRepository.getByManagerOrganizationIdPaginated(
managerOrganizationId,
pagination
);
const organizations = await this.organizationsRepository.findByIds(managedOrganizationsIds);
return organizations.map((organization) =>
this.managedOrganizationsOutputService.getOutputManagedOrganization(organization)
);
return {
organizations: managedOrganizations.map((managedOrganization) =>
this.managedOrganizationsOutputService.getOutputManagedOrganization(managedOrganization)
),
pagination: getPagination({
...pagination,
totalCount: totalItems,
}),
};
}
async updateManagedOrganization(managedOrganizationId: number, body: UpdateOrganizationInput) {
+283 -117
View File
@@ -446,9 +446,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -456,9 +459,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -654,9 +659,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -664,9 +672,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -1526,6 +1536,16 @@
"type": "string"
}
},
{
"name": "bookingUid",
"required": false,
"in": "query",
"description": "Filter bookings by the booking Uid.",
"example": "2NtaeaVcKfpmSZ4CthFdfk",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeIds",
"required": false,
@@ -1928,9 +1948,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -1938,9 +1961,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -2629,9 +2654,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -2639,9 +2667,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -2707,9 +2737,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -2717,9 +2750,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -2851,9 +2886,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -2861,9 +2899,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -4185,9 +4225,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -4195,9 +4238,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -4271,9 +4316,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -4281,9 +4329,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -5442,6 +5492,16 @@
"type": "string"
}
},
{
"name": "bookingUid",
"required": false,
"in": "query",
"description": "Filter bookings by the booking Uid.",
"example": "2NtaeaVcKfpmSZ4CthFdfk",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeIds",
"required": false,
@@ -5675,9 +5735,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -5685,9 +5748,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -5951,9 +6016,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -5961,9 +6029,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -6388,9 +6458,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -6398,9 +6471,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -6819,6 +6894,16 @@
"type": "string"
}
},
{
"name": "bookingUid",
"required": false,
"in": "query",
"description": "Filter bookings by the booking Uid.",
"example": "2NtaeaVcKfpmSZ4CthFdfk",
"schema": {
"type": "string"
}
},
{
"name": "eventTypeIds",
"required": false,
@@ -7251,7 +7336,7 @@
"post": {
"operationId": "BookingsController_2024_08_13_reassignBooking",
"summary": "Reassign a booking to auto-selected host",
"description": "The provided authorization header refers to the owner of the booking.",
"description": "Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
"parameters": [
{
"name": "cal-api-version",
@@ -7302,7 +7387,7 @@
"post": {
"operationId": "BookingsController_2024_08_13_reassignBookingToUser",
"summary": "Reassign a booking to a specific host",
"description": "The provided authorization header refers to the owner of the booking.",
"description": "Currently only supports reassigning host for round robin bookings. The provided authorization header refers to the owner of the booking.",
"parameters": [
{
"name": "cal-api-version",
@@ -8741,9 +8826,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -8751,9 +8839,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -9012,6 +9102,31 @@
"schema": {
"type": "number"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
],
"responses": {
@@ -9670,9 +9785,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -9680,9 +9798,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -9730,9 +9850,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -9740,9 +9863,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -11573,9 +11698,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -11583,9 +11711,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -11962,9 +12092,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -11972,9 +12105,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -12022,9 +12157,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -12032,9 +12170,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -12342,9 +12482,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -12352,9 +12495,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -12394,9 +12539,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -12404,9 +12552,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
},
@@ -12576,9 +12726,12 @@
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
@@ -12586,9 +12739,11 @@
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
@@ -15029,6 +15184,7 @@
},
"address": {
"type": "string",
"minLength": 1,
"example": "123 Example St, City, Country"
},
"public": {
@@ -21967,6 +22123,66 @@
"data"
]
},
"PaginationMetaDto": {
"type": "object",
"properties": {
"totalItems": {
"type": "number",
"description": "The total number of items available across all pages, matching the query criteria.",
"example": 123,
"minimum": 0
},
"remainingItems": {
"type": "number",
"description": "The number of items remaining to be fetched *after* the current page. Calculated as: `totalItems - (skip + itemsPerPage)`.",
"example": 103,
"minimum": 0
},
"returnedItems": {
"type": "number",
"description": "The number of items returned in the current page.",
"example": 10
},
"itemsPerPage": {
"type": "number",
"description": "The maximum number of items requested per page.",
"example": 10,
"minimum": 1
},
"currentPage": {
"type": "number",
"description": "The current page number being returned.",
"example": 2,
"minimum": 1
},
"totalPages": {
"type": "number",
"description": "The total number of pages available.",
"example": 13,
"minimum": 0
},
"hasNextPage": {
"type": "boolean",
"description": "Indicates if there is a subsequent page available after the current one.",
"example": true
},
"hasPreviousPage": {
"type": "boolean",
"description": "Indicates if there is a preceding page available before the current one.",
"example": true
}
},
"required": [
"totalItems",
"remainingItems",
"returnedItems",
"itemsPerPage",
"currentPage",
"totalPages",
"hasNextPage",
"hasPreviousPage"
]
},
"GetManagedOrganizationsOutput": {
"type": "object",
"properties": {
@@ -21983,11 +22199,15 @@
"items": {
"$ref": "#/components/schemas/ManagedOrganizationOutput"
}
},
"pagination": {
"$ref": "#/components/schemas/PaginationMetaDto"
}
},
"required": [
"status",
"data"
"data",
"pagination"
]
},
"UpdateOrganizationInput": {
@@ -24174,60 +24394,6 @@
"data"
]
},
"PaginationMetaDto": {
"type": "object",
"properties": {
"totalItems": {
"type": "number",
"minimum": 0,
"description": "The total number of items available across all pages, matching the query criteria.",
"example": 123
},
"remainingItems": {
"type": "number",
"minimum": 0,
"description": "The number of items remaining to be fetched *after* the current page. Calculated as: `totalItems - (skip + itemsPerPage)`.",
"example": 103
},
"itemsPerPage": {
"type": "number",
"minimum": 1,
"description": "The maximum number of items requested per page.",
"example": 10
},
"currentPage": {
"type": "number",
"minimum": 1,
"description": "The current page number being returned.",
"example": 2
},
"totalPages": {
"type": "number",
"minimum": 0,
"description": "The total number of pages available.",
"example": 13
},
"hasNextPage": {
"type": "boolean",
"description": "Indicates if there is a subsequent page available after the current one.",
"example": true
},
"hasPreviousPage": {
"type": "boolean",
"description": "Indicates if there is a preceding page available before the current one.",
"example": true
}
},
"required": [
"totalItems",
"remainingItems",
"itemsPerPage",
"currentPage",
"totalPages",
"hasNextPage",
"hasPreviousPage"
]
},
"GetBookingsOutput_2024_08_13": {
"type": "object",
"properties": {
@@ -1,6 +1,8 @@
import { createRouterCaller } from "app/_trpc/context";
import type { PageProps } from "app/_types";
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
@@ -21,16 +23,53 @@ export const generateMetadata = async () => {
);
};
const getCachedMe = unstable_cache(
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
const meCaller = await createRouterCaller(meRouter, await getTRPCContext(headers, cookies));
return await meCaller.get();
},
["viewer.me.get"],
{ revalidate: 3600 } // Cache for 1 hour
);
const getCachedAvailabilities = unstable_cache(
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
const availabilityCaller = await createRouterCaller(
availabilityRouter,
await getTRPCContext(headers, cookies)
);
return await availabilityCaller.list();
},
["viewer.availability.list"],
{ revalidate: 3600 } // Cache for 1 hour
);
const Page = async ({ searchParams: _searchParams }: PageProps) => {
const searchParams = await _searchParams;
const t = await getTranslate();
const _headers = await headers();
const _cookies = await cookies();
const [meCaller, availabilityCaller] = await Promise.all([
createRouterCaller(meRouter),
createRouterCaller(availabilityRouter),
const [me, cachedAvailabilities] = await Promise.all([
getCachedMe(_headers, _cookies),
getCachedAvailabilities(_headers, _cookies),
]);
const [me, availabilities] = await Promise.all([meCaller.get(), availabilityCaller.list()]);
// Transform the data to ensure startTime, endTime, and date are Date objects
// This is because the data is cached and as a result the data is converted to a string
const availabilities = {
...cachedAvailabilities,
schedules: cachedAvailabilities.schedules.map((schedule) => ({
...schedule,
availability: schedule.availability.map((avail) => ({
...avail,
startTime: new Date(avail.startTime),
endTime: new Date(avail.endTime),
date: avail.date ? new Date(avail.date) : null,
})),
})),
};
const organizationId = me.organization?.id ?? me.profiles[0]?.organizationId;
const isOrgPrivate = organizationId
? await OrganizationRepository.checkIfPrivate({
@@ -1,7 +1,8 @@
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import { createRouterCaller } from "app/_trpc/context";
import type { PageProps } from "app/_types";
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
@@ -23,9 +24,40 @@ export const generateMetadata = async () =>
"/event-types"
);
const getCachedMe = unstable_cache(
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
const meCaller = await createRouterCaller(meRouter, await getTRPCContext(headers, cookies));
return await meCaller.get();
},
["viewer.me.get"],
{ revalidate: 3600 } // seconds
);
const getCachedEventGroups = unstable_cache(
async (
headers: ReadonlyHeaders,
cookies: ReadonlyRequestCookies,
filters?: {
teamIds?: number[] | undefined;
userIds?: number[] | undefined;
upIds?: string[] | undefined;
}
) => {
const eventTypesCaller = await createRouterCaller(
eventTypesRouter,
await getTRPCContext(headers, cookies)
);
return await eventTypesCaller.getUserEventGroups({ filters });
},
["viewer.eventTypes.getUserEventGroups"],
{ revalidate: 3600 } // seconds
);
const Page = async ({ params, searchParams }: PageProps) => {
const _searchParams = await searchParams;
const context = buildLegacyCtx(await headers(), await cookies(), await params, _searchParams);
const _headers = await headers();
const _cookies = await cookies();
const context = buildLegacyCtx(_headers, _cookies, await params, _searchParams);
const session = await getServerSession({ req: context.req });
if (!session?.user?.id) {
@@ -34,15 +66,9 @@ const Page = async ({ params, searchParams }: PageProps) => {
const t = await getTranslate();
const filters = getTeamsFiltersFromQuery(_searchParams);
const [meCaller, eventTypesCaller] = await Promise.all([
createRouterCaller(meRouter),
createRouterCaller(eventTypesRouter),
]);
const [me, userEventGroupsData] = await Promise.all([
meCaller.get(),
eventTypesCaller.getUserEventGroups({ filters }),
getCachedMe(_headers, _cookies),
getCachedEventGroups(_headers, _cookies, filters),
]);
return (
@@ -56,4 +82,3 @@ const Page = async ({ params, searchParams }: PageProps) => {
};
export default Page;
export const revalidate = 3600; // 1 hour in seconds
@@ -0,0 +1,7 @@
import type { ReactNode } from "react";
import FormProvider from "./FormProvider";
export default async function Layout({ children }: { children: ReactNode }) {
return <FormProvider>{children}</FormProvider>;
}
@@ -5,29 +5,23 @@ import { cookies, headers } from "next/headers";
import { routingFormsComponents } from "@calcom/app-store/routing-forms/pages/app-routing.client-config";
import type { routingServerSidePropsConfig } from "@calcom/app-store/routing-forms/pages/app-routing.server-config";
import Shell from "@calcom/features/shell/Shell";
import { getServerSideProps } from "@lib/apps/routing-forms/[...pages]/getServerSideProps";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import FormProvider from "./FormProvider";
const normalizePages = (pages: string[] | string | undefined) => {
const normalizedPages = Array.isArray(pages) ? pages : pages?.split("/") ?? [];
return {
mainPage: normalizedPages[0] ?? "forms",
subPages: normalizedPages.slice(1),
};
return normalizedPages[0];
};
export const generateMetadata = async ({ params }: { params: Promise<{ pages: string[] }> }) => {
const { mainPage } = normalizePages((await params).pages);
const mainPage = normalizePages((await params).pages);
return await _generateMetadata(
(t) => (mainPage === "routing-link" ? `Form | Cal.com Forms` : `${t("routing_forms")} | Cal.com Forms`),
() => "",
undefined,
undefined,
`/apps/routing-forms/${(await params).pages.join("/")}`
`/routing/${(await params).pages.join("/")}`
);
};
@@ -38,25 +32,11 @@ const getData = withAppDirSsr<GetServerSidePropsResult>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(context);
const { mainPage } = normalizePages((await params).pages);
const mainPage = normalizePages((await params).pages);
const Component = await routingFormsComponents[mainPage as keyof typeof routingFormsComponents]();
const FinalComponent = () => (
<FormProvider>
<Component {...(props as any)} />
</FormProvider>
);
// TODO: we should really be using layout for this.
if (mainPage === "forms") {
return (
<Shell withoutMain>
<FinalComponent />
</Shell>
);
}
return <FinalComponent />;
return <Component {...(props as any)} />;
};
export default ServerPage;
@@ -0,0 +1,21 @@
import { _generateMetadata } from "app/_utils";
import Forms from "@calcom/app-store/routing-forms/pages/forms/[...appPages]";
export const generateMetadata = async ({ params }: { params: Promise<{ pages: string[] }> }) => {
const pages = (await params).pages;
return await _generateMetadata(
(t) => `${t("routing_forms")} | Cal.com Forms`,
() => "",
undefined,
undefined,
`/routing/forms/${pages?.length > 0 ? pages.join("/") : ""}`
);
};
const ServerPage = async () => {
return <Forms appUrl="/routing" />;
};
export default ServerPage;
@@ -0,0 +1,23 @@
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import Shell from "@calcom/features/shell/Shell";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import FormProvider from "../[...pages]/FormProvider";
export default async function Layout({ children }: { children: ReactNode }) {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
redirect("/auth/login");
}
return (
<Shell withoutMain={true}>
<FormProvider>{children}</FormProvider>
</Shell>
);
}
@@ -1,6 +1,7 @@
import { createRouterCaller } from "app/_trpc/context";
import type { PageProps } from "app/_types";
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
@@ -30,6 +31,15 @@ export const generateMetadata = async () => {
);
};
const getCachedEventType = unstable_cache(
async (eventTypeId: number, headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
const caller = await createRouterCaller(eventTypesRouter, await getTRPCContext(headers, cookies));
return await caller.get({ id: eventTypeId });
},
["viewer.eventTypes.get"],
{ revalidate: 3600 } // Cache for 1 hour
);
const ServerPage = async ({ params }: PageProps) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
@@ -41,8 +51,10 @@ const ServerPage = async ({ params }: PageProps) => {
throw new Error("Invalid Event Type id");
}
const eventTypeId = parsed.data.type;
const caller = await createRouterCaller(eventTypesRouter);
const data = await caller.get({ id: eventTypeId });
const _headers = await headers();
const _cookies = await cookies();
const data = await getCachedEventType(eventTypeId, _headers, _cookies);
if (!data?.eventType) {
throw new Error("This event type does not exist");
}
@@ -10,6 +10,8 @@ import React, { useEffect, useState, useMemo } from "react";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import type { OrganizationBranding } from "@calcom/features/ee/organizations/context/provider";
import type { AppFlags } from "@calcom/features/flags/config";
import { useIsFeatureEnabledForTeam } from "@calcom/features/flags/hooks/useIsFeatureEnabledForTeam";
import Shell from "@calcom/features/shell/Shell";
import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
@@ -27,6 +29,8 @@ import type { VerticalTabItemProps } from "@calcom/ui/components/navigation";
import { VerticalTabItem } from "@calcom/ui/components/navigation";
import { Skeleton } from "@calcom/ui/components/skeleton";
type TeamFeatures = Record<number, Record<keyof AppFlags, boolean>> | null;
const getTabs = (orgBranding: OrganizationBranding | null) => {
const tabs: VerticalTabItemProps[] = [
{
@@ -272,6 +276,7 @@ interface SettingsSidebarContainerProps {
className?: string;
navigationIsOpenedOnMobile?: boolean;
bannersHeight?: number;
teamFeatures?: TeamFeatures;
}
const TeamListCollapsible = () => {
@@ -438,6 +443,7 @@ const SettingsSidebarContainer = ({
className = "",
navigationIsOpenedOnMobile,
bannersHeight,
teamFeatures,
}: SettingsSidebarContainerProps) => {
const searchParams = useCompatSearchParams();
const orgBranding = useOrgBranding();
@@ -449,11 +455,15 @@ const SettingsSidebarContainer = ({
}[]
>();
const session = useSession();
const { data } = trpc.viewer.delegationCredential.check.useQuery(undefined, {
enabled: !!session.data?.user?.org,
const isDelegationCredentialEnabled = useIsFeatureEnabledForTeam({
teamFeatures,
teamId: session.data?.user?.org?.id,
feature: "delegation-credential",
});
const tabsWithPermissions = useTabs({
isDelegationCredentialEnabled: data?.hasDelegationCredential ?? false,
isDelegationCredentialEnabled,
});
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
@@ -727,9 +737,10 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
export type SettingsLayoutProps = {
children: React.ReactNode;
containerClassName?: string;
teamFeatures?: TeamFeatures;
} & ComponentProps<typeof Shell>;
export default function SettingsLayoutAppDirClient({ children, ...rest }: SettingsLayoutProps) {
export default function SettingsLayoutAppDirClient({ children, teamFeatures, ...rest }: SettingsLayoutProps) {
const pathname = usePathname();
const state = useState(false);
const [sideContainerOpen, setSideContainerOpen] = state;
@@ -761,6 +772,7 @@ export default function SettingsLayoutAppDirClient({ children, ...rest }: Settin
<SidebarContainerElement
sideContainerOpen={sideContainerOpen}
setSideContainerOpen={setSideContainerOpen}
teamFeatures={teamFeatures}
/>
}
drawerState={state}
@@ -778,10 +790,18 @@ export default function SettingsLayoutAppDirClient({ children, ...rest }: Settin
);
}
type SidebarContainerElementProps = {
sideContainerOpen: boolean;
bannersHeight?: number;
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
teamFeatures?: TeamFeatures;
};
const SidebarContainerElement = ({
sideContainerOpen,
bannersHeight,
setSideContainerOpen,
teamFeatures,
}: SidebarContainerElementProps) => {
const { t } = useLocale();
return (
@@ -797,13 +817,8 @@ const SidebarContainerElement = ({
<SettingsSidebarContainer
navigationIsOpenedOnMobile={sideContainerOpen}
bannersHeight={bannersHeight}
teamFeatures={teamFeatures}
/>
</>
);
};
type SidebarContainerElementProps = {
sideContainerOpen: boolean;
bannersHeight?: number;
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
@@ -3,6 +3,8 @@ import { redirect } from "next/navigation";
import React from "react";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { AppFlags } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -16,9 +18,22 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
return redirect("/auth/login");
}
let teamFeatures: Record<number, Record<keyof AppFlags, boolean>> | null = null;
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
if (session?.user.org) {
const featuresRepository = new FeaturesRepository();
const features = await featuresRepository.getTeamFeatures(session.user.org.id);
if (features) {
teamFeatures = {
[session.user.org.id]: features,
};
}
}
return (
<>
<SettingsLayoutAppDirClient {...props} />
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures} />
</>
);
}
@@ -0,0 +1,19 @@
import { getTranslate } from "app/_utils";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import Shell from "@calcom/features/shell/Shell";
export default async function Layout({ children }: { children: React.ReactNode }) {
const t = await getTranslate();
return (
<Shell
withoutMain={false}
heading={t("organization_members")}
subtitle={t("organization_description")}
headerClassName="hidden md:block"
actions={<div className={`mb-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
{children}
</Shell>
);
}
@@ -0,0 +1,5 @@
import { UserListTableSkeleton } from "@calcom/features/users/components/UserTable/UserListTableSkeleton";
export default function Loading() {
return <UserListTableSkeleton />;
}
@@ -1,6 +1,10 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import MembersPage from "~/members/members-view";
import { attributesRouter } from "@calcom/trpc/server/routers/viewer/attributes/_router";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
import { MembersView } from "~/members/members-view";
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
await _generateMetadata(
@@ -11,8 +15,20 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
`/settings/organizations/${(await params).id}/members`
);
const ServerPageWrapper = () => {
return <MembersPage />;
const ServerPageWrapper = async () => {
const [orgCaller, attributesCaller] = await Promise.all([
createRouterCaller(viewerOrganizationsRouter),
createRouterCaller(attributesRouter),
]);
const [org, teams, facetedTeamValues, attributes] = await Promise.all([
orgCaller.listCurrent(),
orgCaller.getTeams(),
orgCaller.getFacetedValues(),
attributesCaller.list(),
]);
return (
<MembersView org={org} teams={teams} facetedTeamValues={facetedTeamValues} attributes={attributes} />
);
};
export default ServerPageWrapper;
@@ -0,0 +1,20 @@
import SettingsLayoutAppDirClient from "app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient";
import { getTranslate } from "app/_utils";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export default async function Layout({ children }: { children: React.ReactNode }) {
const t = await getTranslate();
return (
<SettingsLayoutAppDirClient containerClassName="lg:max-w-screen-2xl">
<SettingsHeader
title={t("organization_members")}
description={t("organization_description")}
ctaClassName={CTA_CONTAINER_CLASS_NAME}>
{children}
</SettingsHeader>
</SettingsLayoutAppDirClient>
);
}
@@ -0,0 +1,5 @@
import { UserListTableSkeleton } from "@calcom/features/users/components/UserTable/UserListTableSkeleton";
export default function Loading() {
return <UserListTableSkeleton />;
}
@@ -1,9 +1,10 @@
import SettingsLayoutAppDir from "app/(use-page-wrapper)/settings/(settings-layout)/layout";
import { _generateMetadata, getTranslate } from "app/_utils";
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import LegacyPage from "@calcom/features/ee/organizations/pages/members";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { attributesRouter } from "@calcom/trpc/server/routers/viewer/attributes/_router";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
import { MembersView } from "~/members/members-view";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -15,17 +16,20 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const t = await getTranslate();
const [orgCaller, attributesCaller] = await Promise.all([
createRouterCaller(viewerOrganizationsRouter),
createRouterCaller(attributesRouter),
]);
const [org, teams, facetedTeamValues, attributes] = await Promise.all([
orgCaller.listCurrent(),
orgCaller.getTeams(),
orgCaller.getFacetedValues(),
attributesCaller.list(),
]);
const children = (
<SettingsHeader
title={t("organization_members")}
description={t("organization_description")}
ctaClassName={CTA_CONTAINER_CLASS_NAME}>
<LegacyPage />
</SettingsHeader>
return (
<MembersView org={org} teams={teams} facetedTeamValues={facetedTeamValues} attributes={attributes} />
);
return await SettingsLayoutAppDir({ children, containerClassName: "lg:max-w-screen-2xl" });
};
export default Page;
@@ -0,0 +1,33 @@
import { getTranslate } from "app/_utils";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import Shell from "@calcom/features/shell/Shell";
import { Button } from "@calcom/ui/components/button";
export default async function Layout({ children }: { children: React.ReactNode }) {
const t = await getTranslate();
return (
<Shell
heading={
<div className="flex">
<h1>Member management</h1>
<Button
tooltip="Only teammates invited as admins can create OAuth clients while teammates invited as members have read only access"
tooltipSide="right"
className="mx-2 hover:bg-transparent"
color="minimal"
variant="icon"
StartIcon="info"
/>
</div>
}
title={t("platform_members")}
subtitle={t("platform_members_description")}
withoutMain={false}
isPlatformUser={true}
actions={<div className={CTA_CONTAINER_CLASS_NAME} />}>
{children}
</Shell>
);
}
@@ -0,0 +1,5 @@
import { UserListTableSkeleton } from "@calcom/features/users/components/UserTable/UserListTableSkeleton";
export default function Loading() {
return <UserListTableSkeleton />;
}
@@ -1,6 +1,8 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import PlatformMembersView from "@calcom/features/ee/platform/pages/settings/members";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -11,8 +13,11 @@ export const generateMetadata = async () =>
"/settings/platform/members"
);
const ServerPageWrapper = () => {
return <PlatformMembersView />;
const ServerPageWrapper = async () => {
const [orgCaller] = await Promise.all([createRouterCaller(viewerOrganizationsRouter)]);
const [org, teams] = await Promise.all([orgCaller.listCurrent(), orgCaller.getTeams()]);
return <PlatformMembersView org={org} teams={teams} />;
};
export default ServerPageWrapper;
+6 -4
View File
@@ -1,20 +1,22 @@
import type { ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
import { cookies, headers } from "next/headers";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { createContext } from "@calcom/trpc/server/createContext";
import { createCallerFactory } from "@calcom/trpc/server/trpc";
import type { TRPCContext } from "@calcom/trpc/types/server/createContext";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import type { AnyRouter } from "@trpc/server";
const getTRPCContext = async () => {
const legacyReq = buildLegacyRequest(await headers(), await cookies());
export const getTRPCContext = async (_headers?: ReadonlyHeaders, _cookies?: ReadonlyRequestCookies) => {
const legacyReq = buildLegacyRequest(_headers ?? (await headers()), _cookies ?? (await cookies()));
return await createContext({ req: legacyReq, res: {} as any }, getServerSession);
};
export async function createRouterCaller<TRouter extends AnyRouter>(router: TRouter) {
const trpcContext = await getTRPCContext();
export async function createRouterCaller<TRouter extends AnyRouter>(router: TRouter, context?: TRPCContext) {
const trpcContext = context ? context : await getTRPCContext();
const createCaller = createCallerFactory<TRouter>(router);
return createCaller(trpcContext);
}
+3
View File
@@ -12,3 +12,6 @@ export type PageProps = {
};
export type LayoutProps = { params: Promise<Params>; children: React.ReactElement };
export { type ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
export { type ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
@@ -0,0 +1,201 @@
import prismock from "../../../../../../../tests/libs/__mocks__/prisma";
import { describe, it, expect, beforeEach } from "vitest";
import type { MembershipRole } from "@calcom/prisma/enums";
import { handleCreateCredentials } from "../route";
type OrgParams = { id: number };
const createOrg = async ({ id }: OrgParams) =>
await prismock.team.create({ data: { id, name: `Org${id}`, isOrganization: true } });
type UserParams = { id: number; email: string };
const createUser = async ({ id, email }: UserParams) => await prismock.user.create({ data: { id, email } });
type WorkspacePlatformParams = { id: number; name?: string; slug?: string; description?: string };
const createWorkspacePlatform = async ({ id, name, slug, description }: WorkspacePlatformParams) =>
await prismock.workspacePlatform.create({
data: {
id,
name: name ?? "Google",
slug: slug ?? "google",
description: description ?? "Test platform",
defaultServiceAccountKey: {},
},
});
type DelegationCredentialParams = {
id: string;
orgId: number;
domain: string;
workspacePlatformId: number;
};
const createDelegationCredential = async ({
id,
orgId,
domain,
workspacePlatformId,
}: DelegationCredentialParams) =>
await prismock.delegationCredential.create({
data: {
id,
enabled: true,
domain,
organizationId: orgId,
workspacePlatformId,
serviceAccountKey: {
client_email: "svc@example.com",
private_key: "pk",
client_id: "cid",
},
},
});
type CredentialParams = { id?: number; userId?: number; delegationCredentialId?: string; key?: object };
const createCredential = async ({
id = 1,
userId = 1,
delegationCredentialId = "delegation-credential-1",
key = {},
}: CredentialParams = {}) =>
await prismock.credential.create({
data: {
id,
type: "google_calendar",
key,
userId,
appId: "google_calendar",
delegationCredentialId,
},
});
type MembershipParams = { teamId: number; userId: number; role?: MembershipRole };
const createMembership = async ({ teamId, userId, role = "MEMBER" }: MembershipParams) =>
await prismock.membership.create({ data: { teamId, userId, accepted: true, role } });
// Helper to assert credentials in DB match expected (partial, order-insensitive)
type PartialCredential = Partial<Awaited<ReturnType<typeof prismock.credential.create>>>;
async function expectCredentials(expected: PartialCredential[]) {
const actual = await prismock.credential.findMany({});
expect(actual.length).toBe(expected.length);
expect(actual).toEqual(expect.arrayContaining(expected.map((exp) => expect.objectContaining(exp))));
}
describe("Delegation Credentials: Organization-wide Google Calendar Access", () => {
beforeEach(() => {
prismock.delegationCredential.deleteMany();
prismock.credential.deleteMany();
prismock.user.deleteMany();
prismock.team.deleteMany();
prismock.workspacePlatform.deleteMany();
prismock.membership.deleteMany();
});
it("shows a helpful message when no Delegation Credentials are enabled", async () => {
const result = await handleCreateCredentials();
expect(result).toEqual({
message: "No enabled delegation credentials found",
success: 0,
failures: 0,
});
await expectCredentials([]);
});
it("creates credentials for all eligible organization members when Delegation Credential is enabled", async () => {
const org = await createOrg({ id: 1 });
const workspacePlatform = await createWorkspacePlatform({ id: 1 });
const user1 = await createUser({ id: 1, email: "alice@example.com" });
const user2 = await createUser({ id: 2, email: "bob@example.com" });
await createMembership({ teamId: org.id, userId: user1.id });
await createMembership({ teamId: org.id, userId: user2.id });
await createDelegationCredential({
id: "delegation-credential-1",
orgId: org.id,
domain: "example.com",
workspacePlatformId: workspacePlatform.id,
});
const result = await handleCreateCredentials();
expect(result.success).toBe(2);
expect(result.failures).toBe(0);
await expectCredentials([
{ userId: user1.id, delegationCredentialId: "delegation-credential-1", type: "google_calendar" },
{ userId: user2.id, delegationCredentialId: "delegation-credential-1", type: "google_calendar" },
]);
});
it("skips credential creation for unsupported workspace platforms", async () => {
const org = await createOrg({ id: 1 });
// Create a non-google workspace platform
const outlookPlatform = await createWorkspacePlatform({
id: 2,
name: "Outlook",
slug: "outlook",
description: "Test",
});
const user = await createUser({ id: 1, email: "user1@outlook.com" });
await createMembership({ teamId: org.id, userId: user.id });
await createDelegationCredential({
id: "delegation-credential-2",
orgId: org.id,
domain: "outlook.com",
workspacePlatformId: outlookPlatform.id,
});
const result = await handleCreateCredentials();
expect(result.success).toBe(0);
await expectCredentials([]);
});
it("does not create duplicate credentials for users who already have one", async () => {
const org = await createOrg({ id: 1 });
const workspacePlatform = await createWorkspacePlatform({ id: 1 });
const user = await createUser({ id: 1, email: "user1@example.com" });
await createMembership({ teamId: org.id, userId: user.id });
await createDelegationCredential({
id: "delegation-credential-1",
orgId: org.id,
domain: "example.com",
workspacePlatformId: workspacePlatform.id,
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: "delegation-credential-1" });
const result = await handleCreateCredentials();
expect(result.success).toBe(0);
await expectCredentials([
{ userId: user.id, delegationCredentialId: "delegation-credential-1", type: "google_calendar" },
]);
});
it("processes only a batch of members at a time if there are more than the batch size", async () => {
const org = await createOrg({ id: 1 });
const workspacePlatform = await createWorkspacePlatform({
id: 1,
name: "Google",
slug: "google",
description: "Test platform",
});
// Simulate more than 100 delegated members
const users = [];
for (let i = 1; i <= 120; i++) {
const user = await createUser({ id: i, email: `user${i}@example.com` });
users.push(user);
await createMembership({ teamId: org.id, userId: user.id });
}
await createDelegationCredential({
id: "delegation-credential-1",
orgId: org.id,
domain: "example.com",
workspacePlatformId: workspacePlatform.id,
});
const result = await handleCreateCredentials();
expect(result.success).toBe(100); // Only batch size processed
await expectCredentials(
users.slice(0, 100).map((u) => ({
userId: u.id,
delegationCredentialId: "delegation-credential-1",
type: "google_calendar",
}))
);
});
});
+8 -2
View File
@@ -14,7 +14,7 @@ import { DelegationCredentialRepository } from "@calcom/lib/server/repository/de
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
const log = logger.getSubLogger({ prefix: ["CreateCredentials"] });
const moduleLogger = logger.getSubLogger({ prefix: ["[api]", "[delegation]", "[credentials/cron]"] });
const batchSizeToCreateCredentials = 100;
const validateRequest = (req: NextRequest) => {
const url = new URL(req.url);
@@ -24,9 +24,12 @@ const validateRequest = (req: NextRequest) => {
}
};
async function handleCreateCredentials() {
export async function handleCreateCredentials() {
const log = moduleLogger.getSubLogger({ prefix: ["CreateCredentials"] });
const delegationCredentials = await DelegationCredentialRepository.findAllEnabledIncludeDelegatedMembers();
log.info(`Found ${delegationCredentials.length} enabled delegation credentials to create credentials for`);
if (!delegationCredentials.length) {
return {
message: "No enabled delegation credentials found",
@@ -107,9 +110,12 @@ async function handleCreateCredentials() {
}
async function handleDeleteCredentials() {
const log = moduleLogger.getSubLogger({ prefix: ["DeleteCredentials"] });
const delegationCredentials =
await DelegationCredentialRepository.findAllDisabledAndIncludeNextBatchOfMembersToProcess();
log.info(`Found ${delegationCredentials.length} delegation credentials to delete credentials for`);
if (!delegationCredentials.length) {
return {
message: "No disabled delegation credentials found",
@@ -95,7 +95,7 @@ async function postHandler(request: NextRequest) {
const userIdsFromTeams = team.members.map((u) => u.userId);
// Booking Events
const whereConditional: Prisma.BookingTimeStatusWhereInput = {
const whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput = {
OR: [
{
teamId: team.id,
@@ -122,7 +122,7 @@ async function postHandler(request: NextRequest) {
EventData["Cancelled"] = countGroupedByStatus["cancelled"];
// Most Booked Event Type
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
const bookingWhere: Prisma.BookingTimeStatusDenormalizedWhereInput = {
createdAt: {
gte: dayjs(firstDateOfMonth).startOf("day").toDate(),
lte: dayjs(new Date()).endOf("day").toDate(),
@@ -141,7 +141,7 @@ async function postHandler(request: NextRequest) {
],
};
const bookingsFromSelected = await prisma.bookingTimeStatus.groupBy({
const bookingsFromSelected = await prisma.bookingTimeStatusDenormalized.groupBy({
by: ["eventTypeId"],
where: bookingWhere,
_count: {
@@ -234,7 +234,7 @@ async function postHandler(request: NextRequest) {
});
// Most booked members
const bookingsFromTeam = await prisma.bookingTimeStatus.groupBy({
const bookingsFromTeam = await prisma.bookingTimeStatusDenormalized.groupBy({
by: ["userId"],
where: bookingWhere,
_count: {
@@ -0,0 +1,335 @@
import prismock from "../../../../../../../tests/libs/__mocks__/prisma";
import "@calcom/lib/server/__mocks__/serviceAccountKey";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/CalendarAppError";
import { handleCreateSelectedCalendars, isSameEmail } from "../route";
// Mock GoogleCalendarService
const fetchPrimaryCalendarMock = vi.fn();
vi.mock("@calcom/app-store/googlecalendar/lib/CalendarService", () => {
return {
default: vi.fn().mockImplementation(() => ({
fetchPrimaryCalendar: fetchPrimaryCalendarMock,
})),
};
});
type OrgParams = { id?: number };
const createOrg = async ({ id = 1 }: OrgParams = {}) =>
await prismock.team.create({ data: { id, name: `Org${id}`, isOrganization: true } });
type UserParams = { id?: number; email?: string };
const createUser = async ({ id = 1, email = `user${id}@example.com` }: UserParams = {}) =>
await prismock.user.create({ data: { id, email } });
type WorkspacePlatformParams = { id?: number };
const createWorkspacePlatform = async ({ id = 1 }: WorkspacePlatformParams = {}) =>
await prismock.workspacePlatform.create({
data: {
id,
name: "Google",
slug: "google",
description: "Test platform",
defaultServiceAccountKey: {},
},
});
type DelegationCredentialParams = { id: string; orgId: number; domain: string };
const createDelegationCredential = async ({ id, orgId, domain }: DelegationCredentialParams) =>
await prismock.delegationCredential.create({
data: {
id,
enabled: true,
domain: domain ?? "example.com",
organizationId: orgId ?? 1,
workspacePlatformId: 1,
serviceAccountKey: {
client_email: "svc@example.com",
private_key: "pk",
client_id: "cid",
},
},
});
type CredentialParams = {
id: number;
userId: number;
delegationCredentialId: string | null;
key?: object;
};
const createCredential = async ({ id, userId, delegationCredentialId, key = {} }: CredentialParams) =>
await prismock.credential.create({
data: {
id,
type: "google_calendar",
key,
userId,
appId: "google_calendar",
delegationCredentialId,
},
});
type SelectedCalendarParams = {
userId?: number;
externalId?: string;
error?: string | null;
id?: string;
delegationCredentialId?: string | null;
credentialId?: number;
};
const createSelectedCalendar = async ({
userId = 1,
externalId = "user1@example.com",
error = null,
id = `${userId}-sc`,
delegationCredentialId = null,
credentialId = 1,
}: SelectedCalendarParams = {}) =>
await prismock.selectedCalendar.create({
data: {
id,
integration: "google_calendar",
externalId,
userId,
delegationCredentialId,
credentialId,
error,
},
});
// Helper to assert selected calendars in DB match expected (partial, order-insensitive)
type PartialSelectedCalendar = Partial<Awaited<ReturnType<typeof prismock.selectedCalendar.create>>>;
async function expectSelectedCalendars(expected: PartialSelectedCalendar[]) {
const actual = await prismock.selectedCalendar.findMany({});
expect(actual.length).toBe(expected.length);
expect(actual).toEqual(expect.arrayContaining(expected.map((exp) => expect.objectContaining(exp))));
}
describe("handleCreateSelectedCalendars integration", () => {
beforeEach(() => {
prismock.delegationCredential.deleteMany();
prismock.credential.deleteMany();
prismock.selectedCalendar.deleteMany();
prismock.user.deleteMany();
prismock.team.deleteMany();
prismock.workspacePlatform.deleteMany();
fetchPrimaryCalendarMock.mockReset();
});
it("shows a helpful message when no Delegation Credentials are set up in the system", async () => {
const result = await handleCreateSelectedCalendars();
expect(result).toEqual({
message: "No delegation credentials found",
success: 0,
failures: 0,
});
});
it("automatically creates a Selected Calendar for every eligible user when Delegation Credential is enabled and user matches domain", async () => {
await createOrg({ id: 1 });
await createWorkspacePlatform({ id: 1 });
const user = await createUser({ id: 1, email: "user1@example.com" });
const delegationCredential = await createDelegationCredential({
id: "delegation-credential-1",
orgId: 1,
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1);
expect(result.failures).toBe(0);
await expectSelectedCalendars([
{ userId: user.id, externalId: "user1@example.com", delegationCredentialId: delegationCredential.id },
]);
});
describe("when the user's primary calendar ID does not match their email address", () => {
it("creates a Selected Calendar with the primaryCalendarId as the externalId if user's email is not a plus based variant of primaryCalendarId", async () => {
await createOrg({ id: 1 });
await createWorkspacePlatform({ id: 1 });
const user = await createUser({ id: 1, email: "user1@example.com" });
const delegationCredential = await createDelegationCredential({
id: "delegation-credential-1",
orgId: 1,
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1);
expect(result.failures).toBe(0);
await expectSelectedCalendars([
{
userId: user.id,
externalId: "notuser@example.com",
delegationCredentialId: delegationCredential.id,
},
]);
});
it("creates a Selected Calendar with the user's email as the externalId if user's email is a plus based variant of primaryCalendarId", async () => {
await createOrg({ id: 1 });
await createWorkspacePlatform({ id: 1 });
const user = await createUser({ id: 1, email: "user1+test@example.com" });
const delegationCredential = await createDelegationCredential({
id: "delegation-credential-1",
orgId: 1,
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1);
expect(result.failures).toBe(0);
await expectSelectedCalendars([
{ userId: user.id, externalId: user.email, delegationCredentialId: delegationCredential.id },
]);
});
});
it("does not create duplicate Selected Calendars for users who already have a valid one", async () => {
await createOrg({ id: 1 });
await createWorkspacePlatform({ id: 1 });
const user = await createUser({ id: 1, email: "user1@example.com" });
const delegationCredential = await createDelegationCredential({
id: "delegation-credential-1",
orgId: 1,
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
await createSelectedCalendar({
userId: user.id,
externalId: "user1@example.com",
delegationCredentialId: delegationCredential.id,
credentialId: 1,
});
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(0);
await expectSelectedCalendars([
{ userId: user.id, externalId: "user1@example.com", delegationCredentialId: delegationCredential.id },
]);
});
it("replaces a Selected Calendar if the existing one has an error, ensuring users always have a working calendar connection", async () => {
await createOrg({ id: 1 });
await createWorkspacePlatform({ id: 1 });
const user = await createUser({ id: 1, email: "user1@example.com" });
const delegationCredential = await createDelegationCredential({
id: "delegation-credential-1",
orgId: 1,
domain: "example.com",
});
// Create a Delegation User Credential
await createCredential({
id: 2,
userId: user.id,
delegationCredentialId: delegationCredential.id,
});
// Create a Regular User Credential
const regularCredential = await createCredential({
id: 1,
userId: user.id,
delegationCredentialId: null,
});
// Create a SelectedCalendar attached to regular credential
await createSelectedCalendar({
userId: user.id,
externalId: "user1@example.com",
error: "some error" as unknown as null,
credentialId: regularCredential.id,
});
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1);
await expectSelectedCalendars([
{
userId: user.id,
externalId: "user1@example.com",
delegationCredentialId: delegationCredential.id,
error: null,
},
]);
});
describe("when GoogleCalendarService throws error", () => {
it("creates SelectedCalendar with user.email when CalendarAppDelegationCredentialInvalidGrantError is thrown", async () => {
await createOrg({ id: 1 });
await createWorkspacePlatform({ id: 1 });
const user = await createUser({ id: 1, email: "user1@example.com" });
const delegationCredential = await createDelegationCredential({
id: "delegation-credential-1",
orgId: 1,
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockRejectedValue(
new CalendarAppDelegationCredentialInvalidGrantError("some error")
);
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(1);
await expectSelectedCalendars([
{ userId: user.id, externalId: "user1@example.com", delegationCredentialId: delegationCredential.id },
]);
});
it("does not create SelectedCalendar when some other error than CalendarAppDelegationCredentialInvalidGrantError is thrown", async () => {
await createOrg({ id: 1 });
await createWorkspacePlatform({ id: 1 });
const user = await createUser({ id: 1, email: "user1@example.com" });
const delegationCredential = await createDelegationCredential({
id: "delegation-credential-1",
orgId: 1,
domain: "example.com",
});
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
fetchPrimaryCalendarMock.mockRejectedValue(new Error("some error"));
const result = await handleCreateSelectedCalendars();
expect(result.success).toBe(0);
await expectSelectedCalendars([]);
});
});
});
describe("isSameEmail", () => {
it("returns true if variant email is a plus based variant of main email", () => {
expect(
isSameEmail({
mainEmail: "user1@example.com",
variantEmail: "user1+test@example.com",
emailProvider: "google",
})
).toBe(true);
});
it("returns false if variant email is different from main email", () => {
expect(
isSameEmail({
mainEmail: "user1@example.com",
variantEmail: "user1@domain.com",
emailProvider: "google",
})
).toBe(false);
});
it("returns true if variant email is same as main email but with different casing", () => {
expect(
isSameEmail({
mainEmail: "User1@example.com",
variantEmail: "user1+test@Example.com",
emailProvider: "google",
})
).toBe(true);
});
});
+224 -96
View File
@@ -7,6 +7,7 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/CalendarAppError";
import { findUniqueDelegationCalendarCredential } from "@calcom/lib/delegationCredential/server";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
@@ -19,7 +20,7 @@ import type { Ensure } from "@calcom/types/utils";
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
const limitOnQueryingGoogleCalendar = 50;
const log = logger.getSubLogger({ prefix: ["[api] selected-calendars/cron"] });
const log = logger.getSubLogger({ prefix: ["[api]", "[delegation]", "[selected-calendars/cron]"] });
const validateRequest = (req: NextRequest) => {
const url = new URL(req.url);
const apiKey = req.headers.get("authorization") || url.searchParams.get("apiKey");
@@ -28,13 +29,230 @@ const validateRequest = (req: NextRequest) => {
}
};
async function handleCreateSelectedCalendars() {
export function isSameEmail({
mainEmail,
variantEmail,
emailProvider,
}: {
mainEmail: string;
variantEmail: string;
emailProvider: "google";
}) {
if (emailProvider !== "google") {
return mainEmail.toLowerCase() === variantEmail.toLowerCase();
}
const variantEmailParts = variantEmail.split("@");
const variantEmailUserName = variantEmailParts[0];
const variantEmailDomain = variantEmailParts[1];
const variantEmailUserNameWithoutPlus = variantEmailUserName.split("+")[0];
return mainEmail.toLowerCase() === `${variantEmailUserNameWithoutPlus}@${variantEmailDomain}`.toLowerCase();
}
type DelegationUserCredential = Awaited<
ReturnType<typeof CredentialRepository.findAllDelegationByTypeIncludeUserAndTake>
>[number];
type DelegationUserCredentialWithEnsuredUser = Ensure<DelegationUserCredential, "user">;
async function getDelegationUserCredentialsToProcess(delegationUserCredentials: DelegationUserCredential[]) {
const delegationUserCredentialsToProcess = await Promise.all(
delegationUserCredentials
.filter(
(delegationUserCredential): delegationUserCredential is DelegationUserCredentialWithEnsuredUser =>
delegationUserCredential.user !== null
)
.map(async (delegationUserCredential) => {
const userEmail = delegationUserCredential.user.email;
// Consider existing SelectedCalendars for this user, regardless of credentialId as we want to reuse the existing SelectedCalendar(which has their googleChannel related column set) after Delegation Credential has been enabled
// It is also important because SelectedCalendar has composite unique constraint on userId, externalId and integration and thus we couldn't have the same values for different credentialId
const selectedCalendar = await SelectedCalendarRepository.findFirst({
where: {
userId: delegationUserCredential.user.id,
externalId: userEmail,
integration: "google_calendar",
},
});
if (selectedCalendar) {
if (selectedCalendar.delegationCredentialId === delegationUserCredential.delegationCredentialId) {
// In case this SelectedCalendar has an error, we would let CalendarCache cron handle it
log.info(
`Found an existing SelectedCalendar for userId: ${delegationUserCredential.user.id} and delegationCredentialId: ${delegationUserCredential.delegationCredentialId}`
);
return null;
}
if (!selectedCalendar.error) {
log.info(`Found a reusable SelectedCalendar for userId: ${delegationUserCredential.user.id}`);
return null;
} else {
log.error(
`Found reusable Non-Delegation SelectedCalendar for ${delegationUserCredential.user.id} but it has an error: '${selectedCalendar.error}', so deleting it, new one will be created`
);
// We found a SelectedCalendar attached to non-delegation credential, so we shouldn't reuse it as the non-delegation credential attached to it could be invalid
// So, we delete it so that SelectedCalendarRepository allow creating a similar one with Delegation Credential
// Also, we don't delete the possibly invalid credential due to false alarm and we would need that intact in case we disable Delegation Credential
// Because credential isn't deleted, the corresponding CalendarCache entry would still exist and thus we will end up having two CalendarCache entries for same key and userId with different credentialId
await SelectedCalendarRepository.deleteById({ id: selectedCalendar.id });
return delegationUserCredential;
}
}
return delegationUserCredential;
})
);
return delegationUserCredentialsToProcess.filter(
(credential): credential is DelegationUserCredentialWithEnsuredUser => credential !== null
);
}
async function getCalendarService(delegationUserCredential: DelegationUserCredentialWithEnsuredUser) {
const credentialForCalendarService = await findUniqueDelegationCalendarCredential({
userId: delegationUserCredential.user.id,
delegationCredentialId: delegationUserCredential.delegationCredentialId,
});
if (!credentialForCalendarService) {
log.error(
`Credential not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.user.id}`
);
return null;
}
if (
!credentialForCalendarService.delegatedTo ||
!credentialForCalendarService.delegatedTo.serviceAccountKey ||
!credentialForCalendarService.delegatedTo.serviceAccountKey.client_email
) {
log.error(
`Invalid delegatedTo for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}`,
safeStringify({
delegatedToSet: !!credentialForCalendarService.delegatedTo,
serviceAccountKeySet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey,
clientEmailSet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey?.client_email,
})
);
return null;
}
const googleCalendarService = new GoogleCalendarService(
credentialForCalendarService as CredentialForCalendarServiceWithEmail
);
return googleCalendarService;
}
async function fetchPrimaryCalendarId({
googleCalendarService,
delegationUserCredential,
}: {
googleCalendarService: GoogleCalendarService;
delegationUserCredential: DelegationUserCredentialWithEnsuredUser;
}) {
let primaryCalendarId;
const userEmail = delegationUserCredential.user.email;
try {
const primaryCalendar = await googleCalendarService.fetchPrimaryCalendar();
primaryCalendarId = primaryCalendar?.id;
} catch (error) {
log.error(
`Error fetching primary calendar for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`,
safeStringify(error)
);
if (error instanceof CalendarAppDelegationCredentialInvalidGrantError) {
// It is known to happen in case john+test@acme.com is the user email and the actual email is john@acme.com. Google rejects the authorization for john+test@acme.com
log.info(
`Error seems to be that the email isn't present in the third party workspace, further attempts would fail too, so we assume the user's email as the primary calendar id to skip this user in future cron runs`
);
// If we are unable to fetch the primary calendar, we assume the user's email as the primary calendar id
// It ensures that this calendar is skipped in the future cron runs
primaryCalendarId = userEmail;
}
}
return primaryCalendarId;
}
/**
* Creates a SelectedCalendar record corresponding to Delegation User Credential - that would normally use primary calendar Id as the externalId but in some cases, it would use user's email as the externalId
*/
async function processDelegationUserCredential(
delegationUserCredential: DelegationUserCredentialWithEnsuredUser
) {
try {
const userEmail = delegationUserCredential.user.email;
const googleCalendarService = await getCalendarService(delegationUserCredential);
if (!googleCalendarService) {
return;
}
const primaryCalendarId = await fetchPrimaryCalendarId({
googleCalendarService,
delegationUserCredential,
});
if (!primaryCalendarId) {
log.error(
`External calendar not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`
);
throw new Error("externalCalendarId could not be determined");
}
let externalCalendarId = primaryCalendarId;
if (primaryCalendarId !== userEmail) {
const areEmailsSame = isSameEmail({
emailProvider: "google",
mainEmail: primaryCalendarId,
variantEmail: userEmail,
});
if (areEmailsSame) {
log.info(
`Email mismatch for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}. Using userEmail as the externalCalendarId as it is a plus based variant of primaryCalendarId`
);
// We have found that for some plus based email addresses(like john+test@acme.com) fetching primary calendar is successful and the primaryCalendarId comes back as john@acme.com
// Using userEmail as the externalCalendarId would ensure that the SelectedCalendar isn't attempted to be re-created and the SelectedCalendar record is still logically correct
externalCalendarId = userEmail;
} else {
// Unknown reason for mismatch, so we log it.
log.warn(
`Email mismatch for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}, credentialId: ${delegationUserCredential.id} and userId: ${delegationUserCredential.userId}`,
safeStringify({
externalCalendarId,
userEmail,
})
);
}
}
// This creation call isn't expected to fail as we have already checked for the existence of the SelectedCalendar by userEmail
await SelectedCalendarRepository.create({
integration: "google_calendar",
externalId: externalCalendarId,
userId: delegationUserCredential.user.id,
delegationCredentialId: delegationUserCredential.delegationCredentialId,
credentialId: delegationUserCredential.id,
});
log.debug(`Created SelectedCalendar for user ${delegationUserCredential.userId}`);
} catch (error) {
log.error(`Error processing credential ${delegationUserCredential.id}:`, safeStringify(error));
throw error;
}
}
export async function handleCreateSelectedCalendars() {
// These are in DB delegation user credentials in contrast to in-memory delegation user credentials that are used elsewhere
const allDelegationUserCredentials = await CredentialRepository.findAllDelegationByTypeIncludeUserAndTake({
type: "google_calendar",
take: limitOnQueryingGoogleCalendar,
});
log.info(`Found ${allDelegationUserCredentials.length} delegation user credentials to process`);
if (!allDelegationUserCredentials.length) {
const message = "No delegation credentials found";
log.info(message);
@@ -57,106 +275,16 @@ async function handleCreateSelectedCalendars() {
for (const [delegationCredentialId, delegationUserCredentials] of Object.entries(
groupedDelegationUserCredentials
)) {
// First, create an array of promises that check if each credential should be processed
const checkedDelegationUserCredentials = await Promise.all(
delegationUserCredentials.map(async (delegationUserCredential) => {
if (!delegationUserCredential.user) {
log.error(`Credential ${delegationUserCredential.id} has no user`);
return null;
}
// Get all selectedCalendars for this user, regardless of credentialId as we want to reuse the existing SelectedCalendar(which has their googleChannel related column set) after Delegation Credential has been enabled
const hasSelectedCalendarForUserEmail = !!(await SelectedCalendarRepository.findFirst({
where: {
userId: delegationUserCredential.user.id,
externalId: delegationUserCredential.user.email,
},
}));
if (hasSelectedCalendarForUserEmail) {
log.info(`Found a reusable SelectedCalendar for ${delegationUserCredential.user.id}`);
return null;
}
return delegationUserCredential;
})
log.info(`Processing delegation user credentials for delegationCredentialId: ${delegationCredentialId}`);
const delegationUserCredentialsToProcess = await getDelegationUserCredentialsToProcess(
delegationUserCredentials
);
const delegationUserCredentialsToProcess = checkedDelegationUserCredentials.filter(
(credential): credential is Ensure<(typeof delegationUserCredentials)[number], "user"> =>
credential !== null
);
log.info(
`Found ${delegationUserCredentialsToProcess.length} delegationUserCredentials to process for delegationCredentialId: ${delegationCredentialId}`
);
const results = await Promise.allSettled(
delegationUserCredentialsToProcess.map(async (delegationUserCredential) => {
try {
const credentialForCalendarService = await findUniqueDelegationCalendarCredential({
userId: delegationUserCredential.user.id,
delegationCredentialId: delegationUserCredential.delegationCredentialId,
});
if (!credentialForCalendarService) {
log.error(
`Credential not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.user.id}`
);
return;
}
if (
!credentialForCalendarService.delegatedTo ||
!credentialForCalendarService.delegatedTo.serviceAccountKey ||
!credentialForCalendarService.delegatedTo.serviceAccountKey.client_email
) {
log.error(
`Invalid delegatedTo for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}`,
safeStringify({
delegatedToSet: !!credentialForCalendarService.delegatedTo,
serviceAccountKeySet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey,
clientEmailSet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey?.client_email,
})
);
return;
}
const googleCalendarService = new GoogleCalendarService(
credentialForCalendarService as CredentialForCalendarServiceWithEmail
);
let primaryCalendar;
try {
primaryCalendar = await googleCalendarService.fetchPrimaryCalendar();
} catch (error) {
log.error(
`Error fetching primary calendar for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.userId}`,
safeStringify(error)
);
return;
}
if (!primaryCalendar || !primaryCalendar.id) {
log.error(
`Primary calendar not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.userId}`
);
return;
}
await SelectedCalendarRepository.create({
integration: "google_calendar",
externalId: primaryCalendar.id,
userId: delegationUserCredential.user.id,
delegationCredentialId: delegationUserCredential.delegationCredentialId,
credentialId: delegationUserCredential.id,
});
log.debug(`Created SelectedCalendar for user ${delegationUserCredential.userId}`);
} catch (error) {
log.error(`Error processing credential ${delegationUserCredential.id}:`, safeStringify(error));
throw error;
}
})
delegationUserCredentialsToProcess.map(processDelegationUserCredential)
);
const successCount = results.filter((r) => r.status === "fulfilled").length;
const failureCount = results.filter((r) => r.status === "rejected").length;
+2 -1
View File
@@ -192,11 +192,12 @@ async function getHandler(request: NextRequest) {
try {
const response = await fetch(filteredLogo);
const arrayBuffer = await response.arrayBuffer();
let buffer = Buffer.from(arrayBuffer);
let buffer: Buffer = Buffer.from(arrayBuffer);
// If we need to resize the team logos (via Next.js' built-in image processing)
if (teamLogos[logoDefinition.source] && logoDefinition.w) {
const { detectContentType, optimizeImage } = await import("next/dist/server/image-optimizer");
buffer = await optimizeImage({
buffer,
contentType: detectContentType(buffer) ?? "image/jpeg",
@@ -9,15 +9,16 @@ import {
sendDailyVideoRecordingEmails,
sendDailyVideoTranscriptEmails,
} from "@calcom/emails/daily-video-emails";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import {
getAllTranscriptsAccessLinkFromMeetingId,
getDownloadLinkOfCalVideoByRecordingId,
submitBatchProcessorTranscriptionJob,
} from "@calcom/lib/videoClient";
import { generateVideoToken } from "@calcom/lib/videoTokens";
import prisma from "@calcom/prisma";
import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking";
import { getBookingReference } from "@calcom/web/lib/daily-webhook/getBookingReference";
@@ -26,7 +27,6 @@ import {
meetingEndedSchema,
recordingReadySchema,
batchProcessorJobFinishedSchema,
downloadLinkSchema,
testRequestSchema,
} from "@calcom/web/lib/daily-webhook/schema";
import {
@@ -44,10 +44,9 @@ const computeSignature = (hmacSecret: string, reqBody: any, webhookTimestampHead
return computed_signature;
};
const getDownloadLinkOfCalVideo = async (recordingId: string) => {
const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId);
const downloadLinkResponse = downloadLinkSchema.parse(response);
const downloadLink = downloadLinkResponse.download_link;
const getProxyDownloadLinkOfCalVideo = async (recordingId: string) => {
const token = generateVideoToken(recordingId);
const downloadLink = `${WEBAPP_URL}/api/video/recording?token=${token}`;
return downloadLink;
};
@@ -110,7 +109,7 @@ export async function postHandler(request: NextRequest) {
},
});
const downloadLink = await getDownloadLinkOfCalVideo(recording_id);
const downloadLink = await getProxyDownloadLinkOfCalVideo(recording_id);
const teamId = await getTeamIdFromEventType({
eventType: {
@@ -192,7 +191,7 @@ export async function postHandler(request: NextRequest) {
const evt = await getCalendarEvent(booking);
const recording = await getDownloadLinkOfCalVideo(input.recordingId);
const recording = await getProxyDownloadLinkOfCalVideo(input.recordingId);
const batchProcessorJobAccessLink = await getBatchProcessorJobAccessLink(id);
await triggerTranscriptionGeneratedWebhook({
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { describe, expect, test, vi, afterEach } from "vitest";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
import { verifyVideoToken } from "@calcom/lib/videoTokens";
import { GET } from "../route";
vi.mock("@calcom/lib/videoClient", () => ({
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
}));
vi.mock("@calcom/lib/videoTokens", () => ({
verifyVideoToken: vi.fn(),
}));
describe("GET /api/video/recording", () => {
afterEach(() => {
vi.resetAllMocks();
});
test("returns 401 when token is missing", async () => {
const request = new Request("http://example.com/api/video/recording");
const response = await GET(request);
expect(response.status).toBe(401);
expect(await response.text()).toBe("Missing token");
});
test("returns 401 when token is invalid", async () => {
vi.mocked(verifyVideoToken).mockReturnValue({ valid: false });
const request = new Request("http://example.com/api/video/recording?token=invalid_token");
const response = await GET(request);
expect(response.status).toBe(401);
expect(await response.text()).toBe("Invalid or expired token");
});
test("returns 404 when recording is not found", async () => {
vi.mocked(verifyVideoToken).mockReturnValue({ valid: true, recordingId: "test_id" });
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue(undefined);
const request = new Request("http://example.com/api/video/recording?token=valid_token");
const response = await GET(request);
expect(response.status).toBe(404);
expect(await response.text()).toBe("Recording not found");
});
test("redirects to download link when recording exists", async () => {
vi.mocked(verifyVideoToken).mockReturnValue({ valid: true, recordingId: "test_id" });
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({
download_link: "https://example.com/download",
});
const request = new Request("http://example.com/api/video/recording?token=valid_token");
const response = await GET(request);
expect(response instanceof NextResponse).toBe(true);
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("https://example.com/download");
});
test("returns 500 when getting recording fails", async () => {
vi.mocked(verifyVideoToken).mockReturnValue({ valid: true, recordingId: "test_id" });
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockRejectedValue(new Error("Failed to get recording"));
const request = new Request("http://example.com/api/video/recording?token=valid_token");
const response = await GET(request);
expect(response.status).toBe(500);
expect(await response.text()).toBe("Failed to get recording");
});
});
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
import { verifyVideoToken } from "@calcom/lib/videoTokens";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const token = searchParams.get("token");
if (!token) {
return new Response("Missing token", { status: 401 });
}
const verification = verifyVideoToken(token);
if (!verification.valid || !verification.recordingId) {
return new Response("Invalid or expired token", { status: 401 });
}
try {
const recordingLink = await getDownloadLinkOfCalVideoByRecordingId(verification.recordingId);
if (!recordingLink) {
return new Response("Recording not found", { status: 404 });
}
return NextResponse.redirect(recordingLink.download_link, { status: 302 });
} catch (error) {
console.error("Failed to get recording:", error);
return new Response("Failed to get recording", { status: 500 });
}
}
@@ -3,7 +3,7 @@ import { useState } from "react";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import type { AppRouter } from "@calcom/trpc/types/server/routers/_app";
import { Button } from "@calcom/ui/components/button";
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
import {
@@ -87,7 +87,9 @@ function buildParsedBooking(booking: BookingItemProps) {
>)
: null;
const bookingMetadata = bookingMetadataSchema.parse(booking.metadata ?? null);
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
return {
...booking,
eventType: bookingEventType,
@@ -129,7 +131,7 @@ function BookingListItem(booking: BookingItemProps) {
};
});
const noShowMutation = trpc.viewer.markNoShow.useMutation({
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
showToast(data.message, "success");
// Invalidate and refetch the bookings query to update the UI
@@ -936,7 +938,7 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
const [openDropdown, setOpenDropdown] = useState(false);
const { copyToClipboard, isCopied } = useCopy();
const noShowMutation = trpc.viewer.markNoShow.useMutation({
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
showToast(data.message, "success");
utils.viewer.bookings.invalidate();
@@ -1028,7 +1030,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
};
});
const { t } = useLocale();
const noShowMutation = trpc.viewer.markNoShow.useMutation({
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
showToast(t(data.message), "success");
},
@@ -1131,7 +1133,7 @@ const NoShowAttendeesDialog = ({
}))
);
const noShowMutation = trpc.viewer.markNoShow.useMutation({
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
const newValue = data.attendees[0];
setNoShowAttendees((old) =>
@@ -91,6 +91,11 @@ export function NotificationSoundHandler() {
// Don't automatically initialize - wait for user interaction
return () => {
stopSound();
if (audioContextRef.current && audioContextRef.current.state !== "closed") {
audioContextRef.current.close();
}
audioContextRef.current = null;
audioBufferRef.current = null;
};
}, []);
@@ -44,7 +44,7 @@ const obtainNewUsernameChangeCondition = ({
}: {
userIsPremium: boolean;
isNewUsernamePremium: boolean;
stripeCustomer: RouterOutputs["viewer"]["stripeCustomer"] | undefined;
stripeCustomer: RouterOutputs["viewer"]["loggedInViewerRouter"]["stripeCustomer"] | undefined;
}) => {
if (!userIsPremium && isNewUsernamePremium) {
return UsernameChangeStatusEnum.UPGRADE;
@@ -72,7 +72,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
const [markAsError, setMarkAsError] = useState(false);
const recentAttemptPaymentStatus = searchParams?.get("recentAttemptPaymentStatus");
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
const { data: stripeCustomer } = trpc.viewer.stripeCustomer.useQuery();
const { data: stripeCustomer } = trpc.viewer.loggedInViewerRouter.stripeCustomer.useQuery();
const isCurrentUsernamePremium =
user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
const [isInputUsernamePremium, setIsInputUsernamePremium] = useState(false);
@@ -94,6 +94,7 @@ const getEventTypes = async (userId: number, teamIds?: number[]) => {
userId: true,
destinationCalendar: true,
bookingFields: true,
calVideoSettings: true,
});
let eventTypeGroups: TEventTypeGroup[] | null = [];
@@ -1,7 +1,6 @@
import type { GetServerSidePropsResult, GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import { routingServerSidePropsConfig } from "@calcom/app-store/routing-forms/pages/app-routing.server-config";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import prisma from "@calcom/prisma";
@@ -33,13 +32,6 @@ export async function getServerSideProps(
const session = await getServerSession({ req });
const user = session?.user;
const app = await getAppWithMetadata({ slug: "routing-forms" });
if (!app) {
return {
notFound: true,
};
}
const result = await getServerSideProps(context as AppGetServerSidePropsContext, prisma, user);
@@ -53,7 +45,7 @@ export async function getServerSideProps(
return {
props: {
appUrl: app.simplePath || `/apps/routing-forms`,
appUrl: "/routing",
...result.props,
},
};
@@ -1,5 +1,4 @@
import type { EmbedProps } from "app/WithEmbedSSR";
import { createRouterCaller } from "app/_trpc/context";
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
@@ -7,11 +6,11 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventRepository } from "@calcom/lib/server/repository/event";
import { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/enums";
import { publicViewerRouter } from "@calcom/trpc/server/routers/publicViewer/_router";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -112,17 +111,16 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const isTeamEvent = !!hashedLink.eventType?.team?.id;
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const caller = await createRouterCaller(publicViewerRouter);
const eventData = await caller.event({
username: name,
eventSlug: slug,
isTeamEvent,
org,
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
});
const eventData = await EventRepository.getPublicEvent(
{
username: name,
eventSlug: slug,
isTeamEvent,
org,
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
},
session?.user?.id
);
if (!eventData) {
return notFound;
@@ -15,7 +15,7 @@ import { describe, afterEach, test, vi, beforeEach, beforeAll, expect } from "vi
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
import { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "@calcom/app-store/dailyvideo/lib";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
import { WEBAPP_URL } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -70,9 +70,9 @@ vi.mock("@calcom/app-store/dailyvideo/lib", () => {
};
});
vi.mock("@calcom/lib/videoClient", () => {
vi.mock("@calcom/lib/videoTokens", () => {
return {
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
generateVideoToken: vi.fn().mockReturnValue("MOCK_TOKEN"),
};
});
@@ -189,7 +189,6 @@ describe("Handler: /api/recorded-daily-video", () => {
const bookingUid = "n5Wv3eHgconAED2j4gcVhP";
const iCalUID = `${bookingUid}@Cal.com`;
const subscriberUrl = "http://my-webhook.example.com";
const recordingDownloadLink = "https://download-link.com";
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const booker = getBooker({
@@ -262,9 +261,6 @@ describe("Handler: /api/recorded-daily-video", () => {
vi.mocked(getRoomNameFromRecordingId).mockResolvedValue("MOCK_ID");
vi.mocked(getBatchProcessorJobAccessLink).mockResolvedValue(TRANSCRIPTION_ACCESS_LINK);
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({
download_link: recordingDownloadLink,
});
const { req } = createMocks({
method: "POST",
@@ -291,7 +287,7 @@ describe("Handler: /api/recorded-daily-video", () => {
uid: bookingUid,
downloadLinks: {
transcription: TRANSCRIPTION_ACCESS_LINK.transcription,
recording: recordingDownloadLink,
recording: `${WEBAPP_URL}/api/video/recording?token=MOCK_TOKEN`,
},
organizer: {
email: organizer.email,
+1 -1
View File
@@ -1,4 +1,4 @@
import useMeQuery from "./useMeQuery";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
export const useCurrentUserId = () => {
const query = useMeQuery();
-13
View File
@@ -1,13 +0,0 @@
import { trpc } from "@calcom/trpc/react";
export function useMeQuery() {
const meQuery = trpc.viewer.me.get.useQuery(undefined, {
retry(failureCount) {
return failureCount > 3;
},
});
return meQuery;
}
export default useMeQuery;
@@ -1,13 +1,13 @@
import { createRouterCaller } from "app/_trpc/context";
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventRepository } from "@calcom/lib/server/repository/event";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { publicViewerRouter } from "@calcom/trpc/server/routers/publicViewer/_router";
const paramsSchema = z.object({
type: z.string().transform((s) => slugify(s)),
@@ -38,18 +38,24 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
}
const org = isValidOrgDomain ? currentOrgDomain : null;
if (!org) {
return {
notFound: true,
} as const;
}
const session = await getServerSession({ req: context.req });
const eventData = await EventRepository.getPublicEvent(
{
username: teamSlug,
eventSlug: meetingSlug,
isTeamEvent: true,
org,
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
},
session?.user?.id
);
const caller = await createRouterCaller(publicViewerRouter);
const eventData = await caller.event({
username: teamSlug,
eventSlug: meetingSlug,
isTeamEvent: true,
org,
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
});
if (!eventData || !org) {
if (!eventData) {
return {
notFound: true,
} as const;
+72 -5
View File
@@ -12,12 +12,62 @@ import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
import { CAL_VIDEO_MEETING_LINK_FOR_TESTING } from "@calcom/lib/constants";
import { isENVDev } from "@calcom/lib/env";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
type CalVideoSettings = {
disableRecordingForGuests: boolean;
disableRecordingForOrganizer: boolean;
};
const shouldEnableRecordButton = ({
hasTeamPlan,
calVideoSettings,
isOrganizer,
}: {
hasTeamPlan: boolean;
calVideoSettings?: CalVideoSettings | null;
isOrganizer: boolean;
}) => {
if (!hasTeamPlan) return false;
if (!calVideoSettings) return true;
if (isOrganizer) {
return !calVideoSettings.disableRecordingForOrganizer;
}
return !calVideoSettings.disableRecordingForGuests;
};
const checkIfUserIsHost = async ({
booking,
sessionUserId,
}: {
booking: {
user: { id: number } | null;
eventTypeId: number | undefined;
};
sessionUserId?: number;
}) => {
if (!sessionUserId) return false;
if (!booking.eventTypeId) {
return booking.user?.id === sessionUserId;
}
const eventType = await EventTypeRepository.findByIdWithUserAccess({
id: booking.eventTypeId,
userId: sessionUserId,
});
// If eventType exists, it means user is either owner, host or user
return !eventType;
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { req } = context;
@@ -107,13 +157,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
});
const sessionUserId = !!session?.user?.impersonatedBy ? session.user.impersonatedBy.id : session?.user.id;
const isOrganizer = await checkIfUserIsHost({
booking: {
eventTypeId: bookingObj.eventType?.id,
user: bookingObj.user,
},
sessionUserId,
});
// set meetingPassword for guests
if (sessionUserId !== bookingObj.user?.id) {
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken(
videoReferencePassword,
sessionUserId
);
if (!isOrganizer) {
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken({
meetingToken: videoReferencePassword,
userId: sessionUserId,
});
bookingObj.references.forEach((bookRef) => {
bookRef.meetingPassword = guestMeetingPassword;
@@ -141,6 +198,12 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
: false;
const showRecordingButton = shouldEnableRecordButton({
hasTeamPlan: !!hasTeamPlan,
calVideoSettings: bookingObj.eventType?.calVideoSettings,
isOrganizer: sessionUserId === bookingObj.user?.id,
});
return {
props: {
meetingUrl: videoReference.meetingUrl ?? "",
@@ -161,6 +224,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
calVideoLogo,
displayLogInOverlay,
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
showRecordingButton,
rediectAttendeeToOnExit: isOrganizer
? undefined
: bookingObj.eventType?.calVideoSettings?.redirectUrlOnExit,
},
};
}
@@ -37,7 +37,13 @@ import { STEPS } from "~/apps/installation/[[...step]]/constants";
export type TEventType = EventTypeAppSettingsComponentProps["eventType"] &
Pick<
EventTypeModel,
"metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position" | "destinationCalendar"
| "metadata"
| "schedulingType"
| "slug"
| "requiresConfirmation"
| "position"
| "destinationCalendar"
| "calVideoSettings"
> & {
selected: boolean;
locations: LocationFormValues["locations"];
@@ -9,10 +9,10 @@ import { APP_NAME } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Icon } from "@calcom/ui/components/icon";
import { Select } from "@calcom/ui/components/form";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { Select } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
export default function Authorize() {
const { t } = useLocale();
@@ -39,7 +39,8 @@ export default function Authorize() {
}
);
const { data, isPending: isPendingProfiles } = trpc.viewer.teamsAndUserProfilesQuery.useQuery();
const { data, isPending: isPendingProfiles } =
trpc.viewer.loggedInViewerRouter.teamsAndUserProfilesQuery.useQuery();
const generateAuthCodeMutation = trpc.viewer.oAuth.generateAuthCode.useMutation({
onSuccess: (data) => {
@@ -21,14 +21,13 @@ import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Alert } from "@calcom/ui/components/alert";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation";
import { HorizontalTabs } from "@calcom/ui/components/navigation";
import type { VerticalTabItemProps } from "@calcom/ui/components/navigation";
import useMeQuery from "@lib/hooks/useMeQuery";
import BookingListItem from "@components/booking/BookingListItem";
import SkeletonLoader from "@components/booking/SkeletonLoader";
@@ -115,6 +114,7 @@ function BookingsContent({ status }: BookingsProps) {
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
const attendeeName = useFilterValue("attendeeName", ZTextFilterValue);
const attendeeEmail = useFilterValue("attendeeEmail", ZTextFilterValue);
const bookingUid = useFilterValue("bookingUid", ZTextFilterValue)?.data?.operand as string | undefined;
const { limit, offset } = useDataTable();
@@ -128,6 +128,7 @@ function BookingsContent({ status }: BookingsProps) {
userIds,
attendeeName,
attendeeEmail,
bookingUid,
afterStartDate: dateRange?.startDate
? dayjs(dateRange?.startDate).startOf("day").toISOString()
: undefined,
@@ -214,6 +215,21 @@ function BookingsContent({ status }: BookingsProps) {
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.uid, {
id: "bookingUid",
header: t("booking_uid"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
textOptions: {
allowedOperators: ["equals"],
},
},
},
}),
columnHelper.display({
id: "customView",
cell: (props) => {
@@ -336,6 +352,7 @@ function BookingsContent({ status }: BookingsProps) {
attendeeName: false,
attendeeEmail: false,
dateRange: false,
bookingUid: false,
},
},
getCoreRowModel: getCoreRowModel(),
@@ -217,6 +217,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const internalNotes = await getInternalNotePresets(eventType.team?.id ?? eventType.parent?.teamId ?? null);
// Filter out organizer information if hideOrganizerEmail is true
const sanitizedPreviousBooking =
eventType.hideOrganizerEmail &&
previousBooking &&
previousBooking.rescheduledBy === bookingInfo.user?.email
? { ...previousBooking, rescheduledBy: bookingInfo.user?.name }
: previousBooking;
return {
props: {
orgSlug: currentOrgDomain,
@@ -227,7 +235,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId),
dynamicEventName: bookingInfo?.eventType?.eventName || "",
bookingInfo,
previousBooking,
previousBooking: sanitizedPreviousBooking,
paymentStatus: payment,
...(tz && { tz }),
userTimeFormat,
@@ -129,7 +129,9 @@ export default function Success(props: PageProps) {
rescheduleLocation = bookingInfo.responses.location.optionValue;
}
const parsedBookingMetadata = bookingMetadataSchema.parse(bookingInfo?.metadata || {});
const parsed = bookingMetadataSchema.safeParse(bookingInfo?.metadata ?? null);
const parsedBookingMetadata = parsed.success ? parsed.data : null;
const bookingWithParsedMetadata = {
...bookingInfo,
metadata: parsedBookingMetadata,
@@ -26,7 +26,7 @@ function ConnectAndJoin() {
const session = useSession();
const isUserPartOfOrg = session.status === "authenticated" && !!session.data.user?.org;
const mutation = trpc.viewer.connectAndJoin.useMutation({
const mutation = trpc.viewer.loggedInViewerRouter.connectAndJoin.useMutation({
onSuccess: (res) => {
if (res.meetingUrl && !res.isBookingAlreadyAcceptedBySomeoneElse) {
router.push(res.meetingUrl);
@@ -18,11 +18,13 @@ export default function Type({
duration,
hashedLink,
durationConfig,
eventData,
}: PageProps) {
return (
<BookingPageErrorBoundary>
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
<Booker
eventData={eventData}
username={user}
eventSlug={slug}
bookingData={booking}
@@ -259,7 +259,7 @@ export const InfiniteEventTypeList = ({
const [privateLinkCopyIndices, setPrivateLinkCopyIndices] = useState<Record<string, number>>({});
const utils = trpc.useUtils();
const mutation = trpc.viewer.eventTypeOrder.useMutation({
const mutation = trpc.viewer.loggedInViewerRouter.eventTypeOrder.useMutation({
onError: async (err) => {
console.error(err.message);
// REVIEW: Should we invalidate the entire router or just the `getByViewer` query?
@@ -758,7 +758,7 @@ export const InfiniteEventTypeList = ({
)}
<DropdownMenuSeparator />
{!isManagedEventType && (
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2 transition">
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between rounded-b-lg px-4 py-2 transition">
<Skeleton
as={Label}
htmlFor="hiddenSwitch"
+17 -16
View File
@@ -1,24 +1,25 @@
"use client";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import MembersView from "@calcom/features/ee/organizations/pages/members";
import Shell from "@calcom/features/shell/Shell";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { UserListTable } from "@calcom/features/users/components/UserTable/UserListTable";
import type { UserListTableProps } from "@calcom/features/users/components/UserTable/UserListTable";
import { useLocale } from "@calcom/lib/hooks/useLocale";
const MembersPage: React.FC = () => {
export const MembersView = (props: UserListTableProps) => {
const { t } = useLocale();
const isOrgAdminOrOwner = props.org && checkAdminOrOwner(props.org.user.role);
const canLoggedInUserSeeMembers =
(props.org?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !props.org?.isPrivate;
return (
<Shell
withoutMain={false}
title={t("organization_members")}
description={t("organization_description")}
heading={t("organization_members")}
subtitle={t("organization_description")}
headerClassName="hidden md:block"
actions={<div className={`mb-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
<MembersView />
</Shell>
<LicenseRequired>
<div>{canLoggedInUserSeeMembers && <UserListTable {...props} />}</div>
{!canLoggedInUserSeeMembers && (
<div className="border-subtle rounded-xl border p-6" data-testid="members-privacy-warning">
<h2 className="text-default">{t("only_admin_can_see_members_of_org")}</h2>
</div>
)}
</LicenseRequired>
);
};
export default MembersPage;
@@ -12,13 +12,24 @@ import BookingPageErrorBoundary from "@components/error/BookingPageErrorBoundary
export type Props = inferSSRProps<typeof getServerSideProps> & EmbedProps;
function Type({ slug, user, booking, isEmbed, isBrandingHidden, entity, eventTypeId, duration }: Props) {
function Type({
slug,
user,
booking,
isEmbed,
isBrandingHidden,
entity,
eventTypeId,
duration,
eventData,
}: Props) {
return (
<BookingPageErrorBoundary>
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
<Booker
username={user}
eventSlug={slug}
eventData={eventData}
bookingData={booking}
hideBranding={isBrandingHidden}
isTeamEvent
@@ -121,7 +121,7 @@ const ProfileView = () => {
}
},
});
const unlinkConnectedAccountMutation = trpc.viewer.unlinkConnectedAccount.useMutation({
const unlinkConnectedAccountMutation = trpc.viewer.loggedInViewerRouter.unlinkConnectedAccount.useMutation({
onSuccess: async (res) => {
showToast(t(res.message), "success");
utils.viewer.me.invalidate();
@@ -131,7 +131,7 @@ const ProfileView = () => {
},
});
const addSecondaryEmailMutation = trpc.viewer.addSecondaryEmail.useMutation({
const addSecondaryEmailMutation = trpc.viewer.loggedInViewerRouter.addSecondaryEmail.useMutation({
onSuccess: (res) => {
setShowSecondaryEmailModalOpen(false);
setNewlyAddedSecondaryEmail(res?.data?.email);
@@ -3,6 +3,7 @@
import type { DailyCall } from "@daily-co/daily-js";
import DailyIframe from "@daily-co/daily-js";
import { DailyProvider } from "@daily-co/daily-react";
import { useDailyEvent } from "@daily-co/daily-react";
import { useState, useEffect, useRef } from "react";
import dayjs from "@calcom/dayjs";
@@ -33,6 +34,8 @@ export default function JoinCall(props: PageProps) {
calVideoLogo,
displayLogInOverlay,
loggedInUserName,
showRecordingButton,
rediectAttendeeToOnExit,
} = props;
const [daily, setDaily] = useState<DailyCall | null>(null);
@@ -65,12 +68,16 @@ export default function JoinCall(props: PageProps) {
...(typeof meetingPassword === "string" && { token: meetingPassword }),
...(hasTeamPlan && {
customTrayButtons: {
recording: {
label: "Record",
tooltip: "Start or stop recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
...(showRecordingButton
? {
recording: {
label: "Record",
tooltip: "Start or stop recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
}
: {}),
transcription: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
@@ -126,7 +133,7 @@ export default function JoinCall(props: PageProps) {
</div>
{displayLogInOverlay && <LogInOverlay isLoggedIn={!!loggedInUserName} bookingUid={booking.uid} />}
<VideoMeetingInfo booking={booking} />
<VideoMeetingInfo booking={booking} rediectAttendeeToOnExit={rediectAttendeeToOnExit} />
</DailyProvider>
);
}
@@ -255,16 +262,23 @@ export function LogInOverlay(props: LogInOverlayProps) {
interface VideoMeetingInfo {
booking: PageProps["booking"];
rediectAttendeeToOnExit?: string | null;
}
export function VideoMeetingInfo(props: VideoMeetingInfo) {
const [open, setOpen] = useState(false);
const { booking } = props;
const { booking, rediectAttendeeToOnExit } = props;
const { t } = useLocale();
const endTime = new Date(booking.endTime);
const startTime = new Date(booking.startTime);
useDailyEvent("left-meeting", () => {
if (rediectAttendeeToOnExit) {
window.location.href = rediectAttendeeToOnExit;
}
});
return (
<>
<aside
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.3.2",
"version": "5.3.8",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -194,7 +194,7 @@
"tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.6",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
"typescript": "^5.8.3"
},
"nextBundleAnalysis": {
"budget": 358400,
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { loggedInViewerRouter } from "@calcom/trpc/server/routers/loggedInViewer/_router";
export default createNextApiHandler(loggedInViewerRouter);
+4
View File
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { permissionsRouter } from "@calcom/trpc/server/routers/viewer/pbac/_router";
export default createNextApiHandler(permissionsRouter);
+10 -9
View File
@@ -76,7 +76,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const creditService = new CreditService();
if (countryCode === "US" || countryCode === "CA") {
// SMS to US and CA are free
// SMS to US and CA are free for teams
let teamIdToCharge = parsedTeamId;
if (!teamIdToCharge && parsedUserId) {
@@ -92,15 +92,16 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
teamIdToCharge = teamMembership?.teamId;
}
await creditService.chargeCredits({
teamId: teamIdToCharge,
userId: !teamIdToCharge ? parsedUserId : undefined,
bookingUid: parsedBookingUid,
smsSid,
credits: 0,
});
if (teamIdToCharge) {
await creditService.chargeCredits({
teamId: teamIdToCharge,
bookingUid: parsedBookingUid,
smsSid,
credits: 0,
});
return res.status(200).send(`SMS to US and CA are free. Credits set to 0`);
return res.status(200).send(`SMS to US and CA are free for teams. Credits set to 0`);
}
}
let orgId;
+61
View File
@@ -2,6 +2,7 @@ import { expect } from "@playwright/test";
import { JSDOM } from "jsdom";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
import { randomString } from "@calcom/lib/random";
import { SchedulingType } from "@calcom/prisma/client";
import type { Schedule, TimeRange } from "@calcom/types/schedule";
@@ -694,3 +695,63 @@ test("Should throw error when both seatsPerTimeSlot and recurringEvent are set",
"Could not book the meeting. Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring."
);
});
test.describe("GTM container", () => {
test.beforeEach(async ({ page, users }) => {
await users.create();
});
test("global GTM should not be loaded on private booking link", async ({ page, users, emails, prisma }) => {
const [user] = users.get();
const eventType = await user.getFirstEventAsOwner();
const eventWithPrivateLink = await prisma.eventType.update({
where: {
id: eventType.id,
},
data: {
hashedLink: {
create: [
{
link: generateHashedLink(eventType.id),
},
],
},
},
include: {
hashedLink: true,
},
});
const getScheduleRespPromise = page.waitForResponse(
(response) => response.url().includes("getSchedule") && response.status() === 200
);
await page.goto(`/d/${eventWithPrivateLink.hashedLink[0]?.link}/${eventWithPrivateLink.slug}`);
await page.waitForLoadState("domcontentloaded");
await getScheduleRespPromise;
const injectedScript = page.locator('script[id="injected-body-script"]');
await expect(injectedScript).not.toBeAttached();
});
test("global GTM should be loaded on non-booking pages", async ({ page, users }) => {
test.skip(!process.env.NEXT_PUBLIC_BODY_SCRIPTS, "Skipping test as NEXT_PUBLIC_BODY_SCRIPTS is not set");
const [user] = users.get();
await user.apiLogin();
// Go to /insights page and wait for one of the common API call to complete
const eventsByStatusRespPromise = page.waitForResponse(
(response) => response.url().includes("getEventTypesFromGroup") && response.status() === 200
);
await page.goto(`/insights`);
await page.waitForLoadState("domcontentloaded");
await eventsByStatusRespPromise;
const injectedScript = page.locator('script[id="injected-body-script"]');
await expect(injectedScript).toBeAttached();
const scriptContent = await injectedScript.textContent();
expect(scriptContent).toContain("googletagmanager");
});
});
+9 -12
View File
@@ -5,6 +5,7 @@ import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/client";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { addFilter, openFilter } from "./filter-helpers";
import { createTeamEventType } from "./fixtures/users";
import type { Fixtures } from "./lib/fixtures";
import { test } from "./lib/fixtures";
@@ -76,9 +77,8 @@ test.describe("Bookings", () => {
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
await page.locator('[data-testid="filter-popover-trigger-dateRange"]').click();
await addFilter(page, "dateRange");
await openFilter(page, "dateRange");
await expect(page.locator('[data-testid="date-range-options-c"]')).toBeHidden();
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeHidden();
@@ -243,9 +243,8 @@ test.describe("Bookings", () => {
await page.goto(`/bookings/past`);
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
await page.locator('[data-testid="filter-popover-trigger-dateRange"]').click();
await addFilter(page, "dateRange");
await openFilter(page, "dateRange");
await expect(page.locator('[data-testid="date-range-options-c"]')).toBeVisible();
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeVisible();
@@ -354,9 +353,8 @@ test.describe("Bookings", () => {
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-userId"]').click();
await page.locator('[data-testid="filter-popover-trigger-userId"]').click();
await addFilter(page, "userId");
await openFilter(page, "userId");
const bookingsGetResponse2 = page.waitForResponse(
(response) => response.url().includes("/api/trpc/bookings/get?batch=1") && response.status() === 200
@@ -461,9 +459,8 @@ test.describe("Bookings", () => {
await page.goto("/bookings/upcoming", { waitUntil: "domcontentloaded" });
await bookingsGetResponse1;
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-userId"]').click();
await page.locator('[data-testid="filter-popover-trigger-userId"]').click();
await addFilter(page, "userId");
await openFilter(page, "userId");
await page
.locator(`[data-testid="select-filter-options-userId"] [role="option"]:has-text("${anotherUser}")`)
.click();
+57 -6
View File
@@ -1,26 +1,77 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
// Helper function to get text within a specific table column
export const getByTableColumnText = (page: Page, columnId: string, text: string) =>
page.locator(`[data-testid="data-table-td-${columnId}"]`).getByText(text);
/**
* Select a filter from the filter dropdown
* Add a filter from the filter dropdown
*/
export async function selectFilter(page: Page, columnId: string) {
export async function addFilter(page: Page, columnId: string) {
await page.getByTestId("add-filter-button").click();
await page.getByTestId(`add-filter-item-${columnId}`).click();
}
export async function openFilter(page: Page, columnId: string) {
await page.getByTestId(`filter-popover-trigger-${columnId}`).click();
}
/**
* Apply a filter with a specific value
*/
export async function applyFilter(page: Page, columnId: string, value: string) {
export async function applySelectFilter(page: Page, columnId: string, value: string) {
const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`);
if (!(await existingFilter.isVisible())) {
await selectFilter(page, columnId);
await addFilter(page, columnId);
}
await openFilter(page, columnId);
await selectOptionValue(page, columnId, value);
await page.keyboard.press("Escape");
}
await page.getByTestId(`filter-popover-trigger-${columnId}`).click();
export async function selectOptionValue(page: Page, columnId: string, value: string) {
await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).click();
}
export async function applyTextFilter(page: Page, columnId: string, operator: string, operand?: string) {
const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`);
if (!(await existingFilter.isVisible())) {
await addFilter(page, columnId);
}
await openFilter(page, columnId);
await page.getByTestId(`text-filter-options-select-${columnId}`).click();
await page
.locator(`[data-testid="text-filter-options-${columnId}"] [id^="react-select-"]`)
.getByText(operator)
.first()
.click();
if (operand) {
await page.keyboard.press("Tab");
await page.keyboard.type(operand);
}
await page.keyboard.press("Enter");
await page.keyboard.press("Escape");
}
export async function applyNumberFilter(page: Page, columnId: string, operator: string, operand: number) {
const existingFilter = page.getByTestId(`filter-popover-trigger-${columnId}`);
if (!(await existingFilter.isVisible())) {
await addFilter(page, columnId);
}
await openFilter(page, columnId);
await page.getByTestId(`number-filter-options-select-${columnId}`).click();
await page
.locator(`[data-testid="number-filter-options-${columnId}"] [id^="react-select-"]`)
.getByText(operator)
.first()
.click();
await page.keyboard.press("Tab");
await page.keyboard.type(String(operand));
await page.keyboard.press("Enter");
await page.keyboard.press("Escape");
}
+17 -16
View File
@@ -3,13 +3,14 @@ import { expect } from "@playwright/test";
import { MembershipRole } from "@calcom/prisma/enums";
import {
applyFilter,
applySelectFilter,
createFilterSegment,
selectSegment,
deleteSegment,
listSegments,
clearFilters,
openSegmentSubmenu,
getByTableColumnText,
} from "./filter-helpers";
import { test } from "./lib/fixtures";
@@ -56,22 +57,22 @@ test.describe("Filter Segment Functionality", () => {
const segmentName = "Admin Users";
await test.step("Can apply and save a role filter as a segment", async () => {
await applyFilter(page, "role", "admin");
await applySelectFilter(page, "role", "admin");
await expect(page.getByText(adminUser.email)).toBeVisible();
await expect(page.getByText(memberUser.email)).toBeHidden();
await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible();
await expect(getByTableColumnText(page, "member", memberUser.email)).toBeHidden();
await createFilterSegment(page, segmentName);
await clearFilters(page);
await expect(page.getByText(adminUser.email)).toBeVisible();
await expect(page.getByText(memberUser.email)).toBeVisible();
await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible();
await expect(getByTableColumnText(page, "member", memberUser.email)).toBeVisible();
await selectSegment(page, segmentName);
await expect(page.getByText(adminUser.email)).toBeVisible();
await expect(page.getByText(memberUser.email)).toBeHidden();
await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible();
await expect(getByTableColumnText(page, "member", memberUser.email)).toBeHidden();
});
await test.step("Can delete a filter segment", async () => {
@@ -110,7 +111,7 @@ test.describe("Filter Segment Functionality", () => {
const dataTable = page.getByTestId("user-list-data-table");
await expect(dataTable).toBeVisible();
await applyFilter(page, "role", "admin");
await applySelectFilter(page, "role", "admin");
const segmentName = "Admin Users Persistent";
await createFilterSegment(page, segmentName);
@@ -119,8 +120,8 @@ test.describe("Filter Segment Functionality", () => {
await selectSegment(page, segmentName);
await expect(page.getByText(adminUser.email)).toBeVisible();
await expect(page.getByText(memberUser.email)).toBeHidden();
await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible();
await expect(getByTableColumnText(page, "member", memberUser.email)).toBeHidden();
await deleteSegment(page, segmentName);
});
@@ -158,7 +159,7 @@ test.describe("Filter Segment Functionality", () => {
const segmentName = "Team Admin Filter";
await test.step("Can create a team scope filter segment", async () => {
await applyFilter(page, "role", "admin");
await applySelectFilter(page, "role", "admin");
await createFilterSegment(page, segmentName, {
teamScope: true,
@@ -167,8 +168,8 @@ test.describe("Filter Segment Functionality", () => {
await clearFilters(page);
await selectSegment(page, segmentName);
await expect(page.getByText(adminUser.email)).toBeVisible();
await expect(page.getByText(memberUser.email)).toBeHidden();
await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible();
await expect(getByTableColumnText(page, "member", memberUser.email)).toBeHidden();
});
await test.step("Regular member can see but not modify team segments", async () => {
@@ -193,8 +194,8 @@ test.describe("Filter Segment Functionality", () => {
await expect(dataTable).toBeVisible();
await selectSegment(page, "Team Admin Filter");
await expect(page.getByText(adminUser.email)).toBeVisible();
await expect(page.getByText(memberUser.email)).toBeHidden();
await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible();
await expect(getByTableColumnText(page, "member", memberUser.email)).toBeHidden();
await openSegmentSubmenu(page, segmentName);
await expect(
+1 -1
View File
@@ -395,7 +395,7 @@ export const createUsersFixture = (
},
{
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
action: { type: "externalRedirectUrl", value: "https://cal.com" },
action: { type: "externalRedirectUrl", value: "https://cal.com/peer" },
queryValue: {
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
type: "group",
@@ -0,0 +1,706 @@
import { expect } from "@playwright/test";
import { v4 as uuidv4 } from "uuid";
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import {
addFilter,
openFilter,
clearFilters,
applySelectFilter,
applyTextFilter,
applyNumberFilter,
selectOptionValue,
getByTableColumnText,
} from "./filter-helpers";
import { test } from "./lib/fixtures";
test.afterEach(({ users }) => users.deleteAll());
test.describe.configure({ mode: "parallel" });
test.describe("Insights > Routing Filters", () => {
test("formId filter: should filter by selected routing form", async ({
page,
users,
routingForms,
prisma,
}) => {
const owner = await users.create(undefined, {
hasTeam: true,
isUnpublished: true,
isOrg: true,
hasSubteam: true,
});
await owner.apiLogin();
const membership = await owner.getFirstTeamMembership();
const formName1 = "Test Form 1";
const form1 = await routingForms.create({
name: formName1,
userId: owner.id,
teamId: membership.teamId,
fields: [
{
type: "text",
label: "Name",
identifier: "name",
required: true,
},
],
});
const formName2 = "Test Form 2";
const form2 = await routingForms.create({
name: formName2,
userId: owner.id,
teamId: membership.teamId,
fields: [
{
type: "text",
label: "Email",
identifier: "email",
required: true,
},
],
});
await page.goto(`/insights/routing`);
await openFilter(page, "formId");
await selectOptionValue(page, "formId", formName1);
await page.keyboard.press("Escape");
await expect(page.getByText(formName1)).toBeVisible();
await expect(page.getByText(formName2)).toBeHidden();
await openFilter(page, "formId");
await selectOptionValue(page, "formId", formName2);
await page.keyboard.press("Escape");
await expect(page.getByText(formName2)).toBeVisible();
await expect(page.getByText(formName1)).toBeHidden();
});
test("bookingUserId filter: should filter by user", async ({ page, users, routingForms, prisma }) => {
const owner = await users.create(
{ name: "owner" },
{
hasTeam: true,
isUnpublished: true,
isOrg: true,
hasSubteam: true,
}
);
const membership = await owner.getFirstTeamMembership();
const user1Name = "User One";
const user1 = await users.create({ name: user1Name });
const user2 = await users.create({ name: "User Two" });
await prisma.membership.createMany({
data: [
{
teamId: membership.teamId,
userId: user1.id,
role: MembershipRole.MEMBER,
accepted: true,
},
{
teamId: membership.teamId,
userId: user2.id,
role: MembershipRole.MEMBER,
accepted: true,
},
],
});
await owner.apiLogin();
const formName = "Filter User Test Form";
const form = await routingForms.create({
name: formName,
userId: owner.id,
teamId: membership.teamId,
fields: [
{
type: "text",
label: "Name",
identifier: "name",
required: true,
},
],
});
const booking1 = await prisma.booking.create({
data: {
uid: `booking-${uuidv4()}`,
title: "Test Booking 1",
startTime: new Date(),
endTime: new Date(Date.now() + 60 * 60 * 1000),
userId: user1.id,
status: BookingStatus.ACCEPTED,
},
});
const booking2 = await prisma.booking.create({
data: {
uid: `booking-${uuidv4()}`,
title: "Test Booking 2",
startTime: new Date(),
endTime: new Date(Date.now() + 60 * 60 * 1000),
userId: user2.id,
status: BookingStatus.ACCEPTED,
},
});
const fieldId = (form.fields as any)[0].id;
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-1",
formId: form.id,
response: {
[fieldId]: {
label: "Name",
value: "John Doe",
},
},
routedToBookingUid: booking1.uid,
},
});
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-2",
formId: form.id,
response: {
[fieldId]: {
label: "Name",
value: "Jane Smith",
},
},
routedToBookingUid: booking2.uid,
},
});
await page.goto(`/insights/routing`);
await applySelectFilter(page, "bookingUserId", user1Name);
await expect(getByTableColumnText(page, fieldId, "John Doe")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Jane Smith")).toBeHidden();
await prisma.booking.delete({ where: { id: booking1.id } });
await prisma.booking.delete({ where: { id: booking2.id } });
});
// We will add it back when we enable the bookingAttendees filter on /insights/routing
test.skip("bookingAttendees filter: should filter by attendee name or email", async ({
page,
users,
routingForms,
prisma,
}) => {
const owner = await users.create(undefined, {
hasTeam: true,
isUnpublished: true,
isOrg: true,
hasSubteam: true,
});
await owner.apiLogin();
const membership = await owner.getFirstTeamMembership();
const formName = "Attendee Filter Test Form";
const form = await routingForms.create({
name: formName,
userId: owner.id,
teamId: membership.teamId,
fields: [
{
type: "text",
label: "Name",
identifier: "name",
required: true,
},
],
});
const fieldId = (form.fields as any)[0].id;
const booking1 = await prisma.booking.create({
data: {
uid: `booking-${uuidv4()}`,
title: "Test Booking 1",
startTime: new Date(),
endTime: new Date(Date.now() + 60 * 60 * 1000),
userId: owner.id,
status: BookingStatus.ACCEPTED,
attendees: {
create: [
{
email: "john.doe@example.com",
name: "John Doe",
timeZone: "UTC",
},
],
},
},
});
const booking2 = await prisma.booking.create({
data: {
uid: `booking-${uuidv4()}`,
title: "Test Booking 2",
startTime: new Date(),
endTime: new Date(Date.now() + 60 * 60 * 1000),
userId: owner.id,
status: BookingStatus.ACCEPTED,
attendees: {
create: [
{
email: "jane.smith@example.com",
name: "Jane Smith",
timeZone: "UTC",
},
],
},
},
});
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-1",
formId: form.id,
response: { [fieldId]: { label: "Name", value: "Response 1" } },
routedToBookingUid: booking1.uid,
},
});
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-2",
formId: form.id,
response: { [fieldId]: { label: "Name", value: "Response 2" } },
routedToBookingUid: booking2.uid,
},
});
await page.goto(`/insights/routing`);
await addFilter(page, "bookingAttendees");
await openFilter(page, "bookingAttendees");
await page.getByPlaceholder("Filter attendees by name or email").fill("John");
await page.keyboard.press("Enter");
await expect(getByTableColumnText(page, fieldId, "Response 1")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Response 2")).toBeHidden();
await clearFilters(page);
await addFilter(page, "bookingAttendees");
await openFilter(page, "bookingAttendees");
await page.getByPlaceholder("Filter attendees by name or email").fill("jane.smith@example");
await page.keyboard.press("Enter");
await expect(getByTableColumnText(page, fieldId, "Response 2")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Response 1")).toBeHidden();
await clearFilters(page);
await addFilter(page, "bookingAttendees");
await openFilter(page, "bookingAttendees");
await page.getByPlaceholder("Filter attendees by name or email").fill("JANE");
await page.keyboard.press("Enter");
await expect(getByTableColumnText(page, fieldId, "Response 2")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Response 1")).toBeHidden();
await prisma.booking.delete({ where: { id: booking1.id } });
await prisma.booking.delete({ where: { id: booking2.id } });
});
test("TEXT field: should filter with different text operators", async ({
page,
users,
routingForms,
prisma,
}) => {
const owner = await users.create(undefined, {
hasTeam: true,
isUnpublished: true,
isOrg: true,
hasSubteam: true,
});
await owner.apiLogin();
const membership = await owner.getFirstTeamMembership();
const textFieldId = uuidv4();
const formName = "Text Filter Test Form";
const form = await routingForms.create({
name: formName,
userId: owner.id,
teamId: membership.teamId,
fields: [
{
type: "text",
label: "Description",
identifier: "description",
required: true,
},
],
});
const fieldId = (form.fields as any)[0].id;
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-1",
formId: form.id,
response: {
[fieldId]: {
label: "Description",
value: "This is a test description",
},
},
},
});
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-2",
formId: form.id,
response: {
[fieldId]: {
label: "Description",
value: "Another description for testing",
},
},
},
});
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-3",
formId: form.id,
response: {
[fieldId]: {
label: "Description",
value: "",
},
},
},
});
await page.goto(`/insights/routing`);
await applyTextFilter(page, fieldId, "Contains", "test");
await expect(getByTableColumnText(page, fieldId, "This is a test description")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Another description for testing")).toBeVisible();
await clearFilters(page);
await applyTextFilter(page, fieldId, "Is", "This is a test description");
await expect(getByTableColumnText(page, fieldId, "This is a test description")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Another description for testing")).toBeHidden();
await clearFilters(page);
await applyTextFilter(page, fieldId, "Is empty");
await expect(getByTableColumnText(page, fieldId, "This is a test description")).toBeHidden();
await expect(getByTableColumnText(page, fieldId, "Another description for testing")).toBeHidden();
await clearFilters(page);
await applyTextFilter(page, fieldId, "Contains", "TEST");
await expect(getByTableColumnText(page, fieldId, "This is a test description")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Another description for testing")).toBeVisible();
});
test("NUMBER field: should filter with different number operators", async ({
page,
users,
routingForms,
prisma,
}) => {
const owner = await users.create(undefined, {
hasTeam: true,
isUnpublished: true,
isOrg: true,
hasSubteam: true,
});
await owner.apiLogin();
const membership = await owner.getFirstTeamMembership();
const formName = "Number Filter Test Form";
const form = await routingForms.create({
name: formName,
userId: owner.id,
teamId: membership.teamId,
fields: [
{
type: "number",
label: "Rating",
identifier: "rating",
required: true,
},
],
});
const fieldId = (form.fields as any)[0].id;
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-1",
formId: form.id,
response: {
[fieldId]: {
label: "Rating",
value: 1,
},
},
},
});
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-2",
formId: form.id,
response: {
[fieldId]: {
label: "Rating",
value: 3,
},
},
},
});
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-3",
formId: form.id,
response: {
[fieldId]: {
label: "Rating",
value: 5,
},
},
},
});
await page.goto(`/insights/routing`);
await applyNumberFilter(page, fieldId, "=", 3);
await expect(getByTableColumnText(page, fieldId, "3")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "1")).toBeHidden();
await expect(getByTableColumnText(page, fieldId, "5")).toBeHidden();
await clearFilters(page);
await applyNumberFilter(page, fieldId, ">", 3);
await expect(getByTableColumnText(page, fieldId, "5")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "1")).toBeHidden();
await expect(getByTableColumnText(page, fieldId, "3")).toBeHidden();
await clearFilters(page);
await applyNumberFilter(page, fieldId, "≤", 3);
await expect(getByTableColumnText(page, fieldId, "1")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "3")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "5")).toBeHidden();
});
test("SINGLE_SELECT and MULTI_SELECT fields: should filter correctly", async ({
page,
users,
routingForms,
prisma,
}) => {
const owner = await users.create(undefined, {
hasTeam: true,
isUnpublished: true,
isOrg: true,
hasSubteam: true,
});
await owner.apiLogin();
const membership = await owner.getFirstTeamMembership();
const singleSelectFieldId = uuidv4();
const multiSelectFieldId = uuidv4();
const formName = "Select Filter Test Form";
const locationOptionId1 = uuidv4();
const locationOptionId2 = uuidv4();
const skillOptionId1 = uuidv4();
const skillOptionId2 = uuidv4();
const skillOptionId3 = uuidv4();
const form = await routingForms.create({
name: formName,
userId: owner.id,
teamId: membership.teamId,
fields: [
{
type: "select",
label: "Location",
identifier: "location",
required: true,
options: [
{
id: locationOptionId1,
label: "New York",
},
{
id: locationOptionId2,
label: "London",
},
],
},
{
type: "multiselect",
label: "Skills",
identifier: "skills",
required: true,
options: [
{
id: skillOptionId1,
label: "JavaScript",
},
{
id: skillOptionId2,
label: "TypeScript",
},
{
id: skillOptionId3,
label: "React",
},
],
},
],
});
const locationFieldId = (form.fields as any)[0].id;
const skillFieldId = (form.fields as any)[1].id;
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-1",
formId: form.id,
response: {
[locationFieldId]: {
label: "Location",
value: locationOptionId1,
},
[skillFieldId]: {
label: "Skills",
value: [skillOptionId1, skillOptionId2],
},
},
},
});
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-2",
formId: form.id,
response: {
[locationFieldId]: {
label: "Location",
value: locationOptionId2,
},
[skillFieldId]: {
label: "Skills",
value: [skillOptionId2, skillOptionId3],
},
},
},
});
await page.goto(`/insights/routing`);
await applySelectFilter(page, locationFieldId, "New York");
await expect(getByTableColumnText(page, locationFieldId, "New York")).toBeVisible();
await expect(getByTableColumnText(page, locationFieldId, "London")).toBeHidden();
await clearFilters(page);
await applySelectFilter(page, skillFieldId, "JavaScript");
await applySelectFilter(page, skillFieldId, "TypeScript");
await expect(getByTableColumnText(page, skillFieldId, "JavaScript")).toBeVisible();
await expect(getByTableColumnText(page, skillFieldId, "TypeScript")).toBeVisible();
await expect(getByTableColumnText(page, skillFieldId, "React")).toBeHidden();
});
test("createdAt filter: should filter by date range", async ({ page, users, routingForms, prisma }) => {
const owner = await users.create(undefined, {
hasTeam: true,
isUnpublished: true,
isOrg: true,
hasSubteam: true,
});
await owner.apiLogin();
const membership = await owner.getFirstTeamMembership();
const formName = "Date Range Filter Test Form";
const form = await routingForms.create({
name: formName,
userId: owner.id,
teamId: membership.teamId,
fields: [
{
type: "text",
label: "Name",
identifier: "name",
required: true,
},
],
});
const fieldId = (form.fields as any)[0].id;
const oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 10);
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-old",
formId: form.id,
response: {
[fieldId]: {
label: "Name",
value: "Old Response",
},
},
createdAt: oldDate,
},
});
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "test-filler-recent",
formId: form.id,
response: {
[fieldId]: {
label: "Name",
value: "Recent Response",
},
},
},
});
await page.goto(`/insights/routing`);
await openFilter(page, "createdAt");
await page.getByTestId("date-range-options-w").click(); // Last 7 Days
await page.keyboard.press("Escape");
await expect(getByTableColumnText(page, fieldId, "Recent Response")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Old Response")).toBeHidden();
await openFilter(page, "createdAt");
await page.getByTestId("date-range-options-t").click(); // Last 30 Days
await page.keyboard.press("Escape");
await expect(getByTableColumnText(page, fieldId, "Recent Response")).toBeVisible();
await expect(getByTableColumnText(page, fieldId, "Old Response")).toBeVisible();
});
});
+4 -4
View File
@@ -3,6 +3,7 @@ import { expect } from "@playwright/test";
import { randomString } from "@calcom/lib/random";
import prisma from "@calcom/prisma";
import { addFilter, openFilter, clearFilters } from "./filter-helpers";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
@@ -202,14 +203,13 @@ test.describe("Insights", async () => {
await page.keyboard.press("Escape");
// Choose User filter item from dropdown
await page.getByTestId("add-filter-button").click();
await page.getByTestId("add-filter-item-bookingUserId").click();
await addFilter(page, "bookingUserId");
// Wait for the URL to include bookingUserId
await page.waitForURL((url) => url.toString().includes("bookingUserId"));
// Click User filter to see a user list
await page.getByTestId("filter-popover-trigger-bookingUserId").click();
await openFilter(page, "bookingUserId");
await page
.locator('[data-testid="select-filter-options-bookingUserId"]')
@@ -226,7 +226,7 @@ test.describe("Insights", async () => {
// press escape button to close the filter
await page.keyboard.press("Escape");
await page.getByTestId("clear-filters-button").click();
await clearFilters(page);
await expect(page.url()).not.toContain("bookingUserId");
});
@@ -333,7 +333,7 @@ test.describe("Bookings", () => {
// Second booking - should get a different host
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await bookTimeSlot(page, { email: "attendee1@test.com" });
await expect(page.getByTestId("success-page")).toBeVisible();
const secondHost = await page.getByTestId("booking-host-name").textContent();
expect(secondHost).not.toBeNull();
@@ -342,7 +342,7 @@ test.describe("Bookings", () => {
// Third booking - should get a different host
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await bookTimeSlot(page, { email: "attendee2@test.com" });
await expect(page.getByTestId("success-page")).toBeVisible();
const thirdHost = await page.getByTestId("booking-host-name").textContent();
expect(thirdHost).not.toBeNull();
@@ -352,7 +352,7 @@ test.describe("Bookings", () => {
// Fourth booking - should get a different host
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await bookTimeSlot(page, { email: "attendee3@test.com" });
await expect(page.getByTestId("success-page")).toBeVisible();
const fourthHost = await page.getByTestId("booking-host-name").textContent();
expect(fourthHost).not.toBeNull();
+7 -10
View File
@@ -7,6 +7,7 @@ import { randomString } from "@calcom/lib/random";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { addFilter, openFilter } from "./filter-helpers";
import { test } from "./lib/fixtures";
import { localize } from "./lib/localize";
import { submitAndWaitForResponse } from "./lib/testUtils";
@@ -657,8 +658,7 @@ test.describe("Out of office", () => {
await page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
await addFilter(page, "dateRange");
await expect(
page.locator('[data-testid="filter-popover-trigger-dateRange"]', { hasText: "Last 7 Days" }).first()
).toBeVisible();
@@ -672,9 +672,8 @@ test.describe("Out of office", () => {
await page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
await page.locator('[data-testid="filter-popover-trigger-dateRange"]').click();
await addFilter(page, "dateRange");
await openFilter(page, "dateRange");
await expect(page.locator('[data-testid="date-range-options-tdy"]')).toBeVisible(); //Today
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeVisible(); //Last 7 Days
@@ -777,11 +776,10 @@ test.describe("Out of office", () => {
//Default filter 'Last 7 Days' when DateRange Filter is selected
await test.step("Default filter - 'Last 7 Days'", async () => {
await page.locator('[data-testid="add-filter-button"]').click();
await addFilter(page, "dateRange");
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
await entriesListRespPromise;
//1 OOO record should be visible for member3, end=currentDate-4days
@@ -839,14 +837,13 @@ test.describe("Out of office", () => {
//Select 'Last 30 Days'
await test.step("select 'Last 30 Days'", async () => {
await page.locator('[data-testid="add-filter-button"]').click();
await addFilter(page, "dateRange");
const entriesListRespPromise1 = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
await entriesListRespPromise1;
await page.locator('[data-testid="filter-popover-trigger-dateRange"]').click();
await openFilter(page, "dateRange");
const entriesListRespPromise2 = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
+12 -12
View File
@@ -25,18 +25,7 @@ self.addEventListener("push", async (event) => {
});
existingNotifications.forEach((notification) => {
const options = {
body: notification.body,
icon: notification.icon,
badge: notification.badge,
data: notification.data,
silent: notification.silent,
vibrate: notification.vibrate,
requireInteraction: notification.requireInteraction,
tag: notification.tag,
};
self.registration.showNotification(notification.title, options);
notification.close();
});
// Special handling for instant meetings
@@ -87,6 +76,8 @@ self.addEventListener("notificationclick", (event) => {
});
});
};
event.waitUntil(stopSound());
}
if (!event.action) {
@@ -111,4 +102,13 @@ self.addEventListener('install', (event) => {
self.addEventListener('activate', (event) => {
console.log('Service Worker activated.');
event.waitUntil(
(async () => {
const notifications = await self.registration.getNotifications();
notifications.forEach(notification => notification.close());
return self.clients.claim();
})()
);
});
@@ -44,6 +44,10 @@
"event_cancelled_subject": "Canceled: {{title}} at {{date}}",
"event_reassigned_subject": "Reassigned: {{title}} at {{date}}",
"event_request_declined": "Your event request has been declined",
"customize_calvideo_settings": "Customize Cal Video Settings",
"calvideo_settings_description": "Configure video meeting preferences including recording permissions for hosts and attendees.",
"disable_recording_for_guests": "Disable recording for guests",
"disable_recording_for_organizer": "Disable recording for organizer",
"event_request_declined_recurring": "Your recurring event request has been declined",
"event_request_cancelled": "Your scheduled event was canceled",
"event_request_reassigned": "Your scheduled event was reassigned",
@@ -1599,6 +1603,7 @@
"team_url": "Team URL",
"team_members": "Team members",
"more": "More",
"and_count_more": "and {{count}} more",
"more_page_footer": "We view the mobile application as an extension of the web application. If you are performing any complicated actions, please refer back to the web application.",
"workflow_example_1": "Send SMS reminder 24 hours before event starts to attendee",
"workflow_example_2": "Send custom SMS when event is rescheduled to attendee",
@@ -1779,6 +1784,7 @@
"number_sms_notifications": "Phone number (SMS notifications)",
"attendee_email_variable": "Attendee email",
"attendee_email_info": "The person booking's email",
"booking_uid": "Booking UID",
"kbar_search_placeholder": "Type a command or search...",
"invalid_credential": "It looks like permissions expired or were revoked for {{appName}}.",
"invalid_credential_action": "Reinstall app",
@@ -2004,6 +2010,7 @@
"add_a_booking_question": "Add a question",
"identifier": "Identifier",
"duplicate_email": "Email is duplicate",
"enter_redirect_url_on_exit_description": "Enter a redirect URL to which the attendee will be sent once they leave the meeting.",
"booking_with_payment_cancelled": "Paying for this event is no longer possible",
"booking_with_payment_cancelled_already_paid": "A refund for this booking payment is on its way.",
"booking_with_payment_cancelled_refunded": "This booking payment has been refunded.",
@@ -2909,6 +2916,7 @@
"routing_form_insights_booking_at": "Booking At",
"routing_form_insights_submitted_at": "Submitted At",
"copy_redirect_url": "Copy Redirect URL",
"redirect_url_on_exit": "Redirect URL on Exit",
"total_bookings_per_period": "Total Bookings per period",
"routed_to_per_period": "Routed to per period",
"per_day": "Per day",
@@ -3220,5 +3228,7 @@
"requires_credits": "Requires credits",
"requires_credits_tooltip": "You need enough credits in your account to use this feature",
"available_credits": "Available credits",
"members_affected_by_disabling_delegation_credential": "Affected members",
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

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