Compare commits

...
109 Commits
Author SHA1 Message Date
Keith Williams 4335c9ab92 v3.9.7
release.yml / release (push) Canceled after 0s
2024-04-09 09:37:32 -03:00
Peer Richelsen 20df7156cf Update common.json (#14472) 2024-04-09 12:23:54 +00:00
ErikandPeer Richelsen 9c35425e57 chore: Release SDK 1.0.0 (#14462)
* chore: dont require version

* fixup: type

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-04-09 11:56:02 +00:00
Hariom Balhara 65df5ac0e4 fix: Allow unpublished org to serve redirected links plus other org onboarding fixes (#14337)
* fix: Allow unpublished org to serve redirected links

* fix: Fix team event link for unpublished org

* fixes

* Rename variable

* Add minimum teams requirement for upgrade

* Fixes

* PR Feedback addressed
2024-04-09 10:33:39 +01:00
Keith Williams 84fdd03603 fix: Return team event type (#14464) 2024-04-08 18:35:41 -03:00
Crowdin Bot e6ec8bdf4d New Crowdin translations by Github Action 2024-04-08 19:17:45 +00:00
Yamini KandKeith Williams 86c8197104 docs: Update README.md regarding POSTGRES db port (#14431)
* Update README.md

Updating the description about POSTGRES DB port

* Update README.md

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-04-08 19:12:11 +00:00
Pat bb13badcf4 Update README.md (#14432)
Add minimum version number to PostgreSQL in 'Getting Started,' to help potential contributors successfully set up environment with local database.

I tried to set up local dev environment on Ubuntu 20.04 with PostgreSQL 12.18 which failed when running 'yarn workspace @calcom/prisma db-migrate'

PostgreSQL version 14.11 worked for me. I saw another dev say v. 13+ would work. Could save folks some troubleshooting time.

This is the error on PostgreSQL 12.18:

Error: P3006 Migration `20240321143215_move_avatars_cols_to_avatar_table` failed to apply cleanly to the shadow database. Error: ERROR: function gen_random_uuid() does not exist HINT: No function matches the given name and argument types. You might need to add explicit type casts. 0: sql_schema_connector::validate_migrations with namespaces=None at schema-engine/connectors/sql-schema-connector/src/lib.rs:324 1: schema_core::state::DevDiagnostic at schema-engine/core/src/state.rs:270
2024-04-08 16:09:43 -03:00
Keith Williams 64b2397fa2 v3.9.6
release.yml / release (push) Canceled after 0s
2024-04-08 12:42:15 -03:00
Kartik SainiandUdit Takkar 90130457e2 fix: Add missing import for Icon component in MultiDropdownSelect.tsx (#14440)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-08 13:44:47 +00:00
Amit Sharma 260521fb28 fix: Empty "Add guests" email input is blocking form submission (#14442) 2024-04-08 13:24:15 +00:00
Hariom Balhara 6390d595f5 fix: Team Invite Email Text formatting (#14434) 2024-04-08 10:15:26 +00:00
Udit Takkar 74dc46f8b2 chore: change timezone format and update webhook (#14433)
* chore: change timezone format

* chore: set guest email and guest company to null

* feat: also check states to get id

* chore: improve code

* chore: fix type erro

* chore: update zod schema
2024-04-08 15:26:31 +05:30
Lauris Skraucis 67262910cd fix: v1 tsconfig decorators (#14454) 2024-04-08 11:51:56 +03:00
Omar López d59e68de6d ci: adds api v1 builds to required checks (#14345) 2024-04-05 23:00:01 +00:00
zomars b50318fbca chore: Adds devops script for getting table sizes 2024-04-05 14:29:50 -07:00
Udit Takkar 1ce7df324d feat: retell AI webhook url (#14430)
* feat: retell AI webhook url

* fix: type error

* chore

* chore: make company and email optional

* chore: remove logs

* chore: type error

* chore: type error

* fix: type err

* fix: pass guest email

* fix: type err

* chore: change date format

* chore: move advanced format
2024-04-05 22:44:57 +05:30
sean-brydonandKeith Williams e3b7264208 fix: filter and listMember handler (#14429)
* fix: filter and listMember handler

* Update packages/trpc/server/routers/viewer/teams/listMembers.handler.ts

Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* Update packages/features/bookings/components/PeopleFilter.tsx

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-04-05 11:51:22 -03:00
Morgan 5e3f6e6c2c chore: bump atoms package version (#14428) 2024-04-05 14:00:22 +00:00
Udit Takkar 5b6c43118e fix: change identity provider on password reset (#14326)
* fix: change identity provider on password reset

* chore: remove check
2024-04-05 10:13:43 +00:00
Amit SharmaandHariom beee545568 fix: "Checkbox" custom field throws error (#14339)
* fix: "Checkbox" custom field throws error

* Add e2e

* Self review feedback

* Fix typo

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-04-05 15:05:48 +05:30
Lauris SkraucisandKeith Williams a597defd03 fix: create platform handler (#14288)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-04-05 07:46:29 +00:00
Morgan e3562cdfb5 fix: scope managed user to client id (#14347) 2024-04-05 09:14:24 +02:00
Morgan 86a7824ec9 fix: remove port in prod url apiv2 (#14343) 2024-04-04 20:25:35 +00:00
Morgan 7001515a44 chore: bump atoms package to 1.0.11 (#14344) 2024-04-04 19:31:45 +00:00
zomars 36044c4ab3 ci: enable settings for app-router canary 2024-04-04 11:53:40 -07:00
Omar López 4933295810 v3.9.5
release.yml / release (push) Canceled after 0s
2024-04-04 11:34:47 -07:00
Carina WollendorferandCarinaWolli ed0cd30092 fix: improve api/cron/webhookTriggers endpoint (#14188)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-04-04 10:01:57 -07:00
Alex van Andel 1132d9d0dc chore: teams filter improvements (#14315)
* Changed translations, many languages were wrong

* Clear localStorage from the useTypedQuery

* Missed one
2024-04-04 09:59:58 -07:00
9bd3a362a2 feat: added Rating Template to Workflows (#14215)
Co-authored-by: alishaz-polymath <alishahbaz7@gmail.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2024-04-04 09:58:52 -07:00
Lauris SkraucisandMorgan 844076badb docs: v2 event types swagger (#14296)
* docs: GET /event-types/:id response

* docs: GET /event-types response

* docs: UPDATE /event-types/:id response

* docs: DELETE /event-types/:id response

* docs: GET /event-types/:username/public response

* docs: prod docs show only event-types, bookings cancel and reschedule, managed users

* fix merge main

* Revert "docs: prod docs show only event-types, bookings cancel and reschedule, managed users"

This reverts commit 96cdc4b4840e3841c6ed27bd3a030d0574aad19e.

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-04-04 16:20:03 +00:00
Lauris Skraucis c0523a31a7 Revert "refactor: OAuth creation form hide apps permissions (#14264)" (#14336)
This reverts commit fe62f9c6de.
2024-04-04 15:24:05 +00:00
Morgan 1fff642860 fix: urls, gcal check, oauth client controller (#14335)
* fix: urls, gcal check, oauth client controller

* fixup! fix: urls, gcal check, oauth client controller
2024-04-04 14:58:15 +00:00
89fe383c9f feat: implement turnstile on signup (#13913)
* wip

* feat: turnstile + removal of trpcState

* fix: use react-turnstile

* fix: i18n hydration errors

* wip: server side validation for cf token

* fix: hide in e2e tests

* feat:throw on error of token

* fix: set correct form values onVerify

* fix: use getIp for remoteIp and use correct header for token input

* chore: update .env.example

* fix: Update .env.example

* Update apps/web/pages/signup.tsx

* chore: fix typo

* fix: typo

* feat: hide until error

* feat: appearance interaction only

* remove cloudflare from e2e

* fix: legacyBehavior

* fix links + wait for load

* fix: signup tests

* test: wait for load state

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-04 10:21:06 -03:00
sean-brydon 4a173c48b8 fix: correct limit amount in api middleware (#14333) 2024-04-04 12:20:26 +00:00
Morgan a72a3e45e9 fix: refresh access token expiry 60m (#14331)
* fix: refresh access token expiry 60m

* fix: exception filters logging request

* fix: never auto accept domain for managed users
2024-04-04 09:07:43 -03:00
a8d8c992a4 chore: added email and company form to cal.ai form (#14306)
* added email and company form

* i18n

* feat: add guest email and company

* chore: update and format prompt

* chore: hint var

* chore: fix err message

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-04-04 11:57:43 +00:00
Hariom Balhara 99f9a121cb fix: Show proper error when API_V2_URL variable is missing (#14269) 2024-04-04 11:51:39 +00:00
Crowdin Bot 6a0e2392fe New Crowdin translations by Github Action 2024-04-04 11:49:00 +00:00
Joe Au-Yeung addec46feb chore: Remove payment keys from appKeysSchema (#14298)
* Remove payment keys from appKeysSchema

* Update .env.example
2024-04-04 11:44:16 +00:00
Crowdin Bot a4fe9a51b5 New Crowdin translations by Github Action 2024-04-04 11:41:42 +00:00
Richard Poelderl 78b8bbb5aa update docs for yarn.lock management (#14230) 2024-04-04 08:37:15 -03:00
Lauris Skraucis d7ece4f583 Revert "chore: downgrade react query atoms (#14328)" (#14332)
This reverts commit 8fc9b9ae9d.
2024-04-04 10:55:15 +00:00
Lauris Skraucis 8fc9b9ae9d chore: downgrade react query atoms (#14328)
* refactor: downgrade atoms react-query to v4

* driveby: prettier format file

* refactor: react-query isPending to isLoading

* chore: bump atoms version

* refactor: isPending -> isLoading

* chore: bump version
2024-04-04 11:28:35 +01:00
Udit Takkar d1175bf7de fix: pass begin_message for updating LLM (#14327) 2024-04-04 11:14:19 +01:00
Crowdin Bot 3900453482 New Crowdin translations by Github Action 2024-04-04 09:46:04 +00:00
Morganandsupalarry 68e7e76b31 feat: list managed users of an oauth client (#14330)
* feat: list managed users with apiv2 api

* refactor: isPending -> isLoading

* Revert "refactor: isPending -> isLoading"

This reverts commit dc2108e05508070185d57633346f3c532458e2a8.

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
2024-04-04 09:41:47 +00:00
zomars 986d196a37 hotfix: lazy icon dev performance 2024-04-03 22:14:30 -07:00
d30fb213d2 fix: Add adminReview option to allow impersonating org members (#14275)
* added layouts for moving teams step

* added admin section for org creation step

* extended org creation to also move existing teams

* wip

* wip

* wip

* further changes

* Add checkout for org in onboarding

* Fix ts errors

* Self review feedback

* Self review addressed

* Fix unit tests

* Fix ts error

* Seans feedback addressed

* feat: fix correct accounts pending

* fix: unit tests for new invite member permissions

* tests: org admin onboarding tests for existing user

* tests: Inital user self serve flow

* chore: update teamAndUserFixture to create X amount of teams

* chore: add testId to card actionButton

* test: add test-Id to continue or checkout button

* tests: add tests for migrating existing teams

* feat: match new designs

* fix: isAdminCheck

* chore: fix pricing copy

* fix: flacky tests

* Fix tests?

* More test fixes

* Check all checkboxes

* Fix type error

* Fix failing test and typescript issues

* Fix unpaid org allowing auto-add users

* Add self-serve flag

* Skip tests

* Add adminReview option

* Fixes

* Add server checks for impersonation

* Improved error

* feat: extract - and disalow updating from org uset list if not reviewed

---------

Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-04-04 04:48:41 +00:00
3f02de8214 chore: icon cleanup (#13770)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-04-03 23:23:57 +00:00
Omar López 7421cea0da fixes: API builds after rename (#14319) 2024-04-04 00:12:09 +01:00
Lauris Skraucis 507daa6a0c chore: publish atoms on npm and bump version (#14309) 2024-04-03 18:37:00 +03:00
Crowdin Bot 61f68af435 New Crowdin translations by Github Action 2024-04-03 13:32:23 +00:00
b3f4cfab32 feat: new org onboarding wizard (#13139)
* added layouts for moving teams step

* added admin section for org creation step

* extended org creation to also move existing teams

* wip

* wip

* wip

* further changes

* Add checkout for org in onboarding

* Fix ts errors

* Self review feedback

* Self review addressed

* Fix unit tests

* Fix ts error

* Seans feedback addressed

* feat: fix correct accounts pending

* fix: unit tests for new invite member permissions

* tests: org admin onboarding tests for existing user

* tests: Inital user self serve flow

* chore: update teamAndUserFixture to create X amount of teams

* chore: add testId to card actionButton

* test: add test-Id to continue or checkout button

* tests: add tests for migrating existing teams

* feat: match new designs

* fix: isAdminCheck

* chore: fix pricing copy

* fix: flacky tests

* Fix tests?

* More test fixes

* Check all checkboxes

* Fix type error

* Fix failing test and typescript issues

* Fix unpaid org allowing auto-add users

* Add self-serve flag

* Skip tests

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2024-04-03 18:53:02 +05:30
Morgan c3b1de498d fix: org guard did not have user org profile + missing db read in selectedCalendarRepo (#14303)
* fix: user did not contain profile with next-auth-guard

* fix: missing dbread instance
2024-04-03 10:49:19 +03:00
Erik cdf059361e chore: Tidy up unused variables, etc (#14289) 2024-04-02 22:06:42 +00:00
Alex van AndelandUdit Takkar 2aada9a7da chore: Move avatars using cursor (#14257)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-02 12:59:39 -07:00
Erik b96786125c v3.9.4
release.yml / release (push) Canceled after 0s
2024-04-02 15:32:21 -03:00
Erik 9451f38cec chore: SDK Fixes and add swagger docs (#14218)
* chore: fixes and add swagger docs

* chore: add script
2024-04-02 18:31:01 +00:00
Keith Williams 306f53dec7 fix: Typo in OAuth clients (#14292)
* fix: Typo in OAuth clients

* Update index.tsx
2024-04-02 15:02:26 -03:00
Erik db865a680f chore: hotfixes (#14297) 2024-04-02 17:48:48 +00:00
Rajiv Sahal aca552c9f7 fix: Booker atom modal not rendering for mobile devices (#14278)
* make sure modal always appears

* separate event form wrappers into two components

* platform hook to fetch event type by id

* testing useGetEventTypeById hook

* add useGetEventTypeById to monorepo exports

* use platform hook for event type by id
2024-04-02 20:41:28 +03:00
Erik b202c4e79f chore: sentry dsn (#14295) 2024-04-02 17:01:00 +00:00
Peer Richelsen 972ce6911a Update Shell.tsx (#14293) 2024-04-02 16:23:03 +01:00
Peer Richelsen 751530b460 Create README.md 2024-04-02 15:25:02 +01:00
3b263e9282 feat: v2 event-types POST, PATCH, DELETE endpoints (#14237)
* create event type: use createEventType from trpc handler

* create event type: simplify handler input and create e2e test

* update event type + test

* delete end point

* check if owner owns event type

* fix: starting v2

* fixes

* remove unused enum

* fix TS issue for update input

* docs refresh

* docs

* docs

* simplify event types docs

* docs refactor

* fix: check if user already has event type with existing slug

* refactor update into a service

* fix tests

* remove unused import

* update create event link

* refactor: create event handler process passed locations

* move input out of folder

* reuse eventtype location for inputs

* docs: example POST event-type return

---------

Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-04-02 15:50:12 +02:00
Hariom BalharaandUdit Takkar 2cbf9e1ac8 fix: Show proper error in case the team can't be published because requestedSlug isn't set (#14283)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-02 12:44:39 +00:00
Udit TakkarandCarina Wollendorfer 6283535d6a chore: add more duration options for multiple duration events (#14162)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-04-02 12:43:24 +00:00
Amit SharmaandUdit Takkar a03ae81509 fix: cancellation seems to be using slug instead of title (#14195)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-02 12:42:48 +00:00
Alex van Andel a7e05aa016 v3.9.3
release.yml / release (push) Canceled after 0s
2024-04-02 13:37:09 +01:00
MorganandAlex van Andel f8cbf8b1c4 chore: rewrite api.cal.com/v2 to api v2 (#14285)
* chore: rewrite api.cal.com/v2 to api v2

* fixup! chore: rewrite api.cal.com/v2 to api v2

* fixup! fixup! chore: rewrite api.cal.com/v2 to api v2

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-04-02 13:23:04 +01:00
Alex van Andel ccc294e567 chore: Skip broken out-of-office tests (#14286) 2024-04-02 11:24:42 +00:00
Crowdin Bot 0be344b1ba New Crowdin translations by Github Action 2024-04-01 12:33:52 +00:00
Shyam Raghuwanshi 7a525e476a fix Bookings Teams filter shows double label (#14239) 2024-04-01 18:00:04 +05:30
Lauris Skraucis fe62f9c6de refactor: OAuth creation form hide apps permissions (#14264) 2024-03-29 10:35:11 +00:00
Crowdin Bot 9608f6518f New Crowdin translations by Github Action 2024-03-29 09:14:37 +00:00
Lauris Skraucis 2f8fa20ec6 refactor: prisma platform tables use pascal case (#14263) 2024-03-29 09:10:28 +00:00
Amit Sharma 6f097b0a74 fix: No handlers found for ics-feed (#14123) 2024-03-28 16:01:27 -03:00
Omar López 159bbb53b6 chore: adds useful SQL scripts for devops (#14068) 2024-03-28 11:36:27 -07:00
Alex van Andel 622160ea17 Revert "feat: Migrate all old avatars to 'avatars' table (#14170)"
This reverts commit 4bfbac6649.
2024-03-28 18:14:58 +00:00
Philipp Dormann 5d543c864f fix(translations/de): typo in limit_booking_only_first_slot (#14244) 2024-03-28 15:09:47 -03:00
Keith Williams ed745623eb fix: Organization check (#14251) 2024-03-28 16:13:10 +00:00
Alex van Andel 29bca76e91 chore: Select organization fields to prevent logo import (#14255)
* chore: Select organization fields to prevent logo import

* teams query actually doesn't use org, keep for backwards compat
2024-03-28 12:54:45 -03:00
Rajiv SahalandMorgan Vernay 33d0fee43e chore: add styling to examples app (#14252)
* add styling to  examples app

* prettier homepage

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-03-28 15:48:51 +00:00
Crowdin Bot bb13de4176 New Crowdin translations by Github Action 2024-03-28 15:41:27 +00:00
Morgan c854dd092c refactor: platform timezone endpoint (#14250)
* refactor: platform timezone endpoint

* fixup! refactor: platform timezone endpoint

* fixup! fixup! refactor: platform timezone endpoint
2024-03-28 12:35:51 -03:00
Crowdin Bot 26d64bd93f New Crowdin translations by Github Action 2024-03-28 14:07:17 +00:00
1025238eee feat: cal.ai enterprise phone calls (#14100)
* added empty layout

* init

* yarn lock due to package changes

* added retell ai to .env

* removed ton of glue code

* nit

* Discard changes to package.json

* Upgrades lucide-react

* minor UI fixes

* nit

* nit

* feat: save progress

* feat: v1

* fix: type error

* feat: change schema

* fix: type error and testr

* chore: update agent

* chore: change default prompt

* feat: feedback and improvements

* fix: type error

* fix: type error

* hidden for now while in trial

* added i18n and removed some comments

* feat: add cal api key

* fix: type error

---------

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-03-28 13:59:01 +00:00
sean-brydonandUdit Takkar eec76ecfe2 feat: implement serverside search over client side (#14234)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-03-28 13:18:16 +00:00
Morgan f5876ae6d6 chore: rename platform /me endpoints (#14249)
* chore: rename platform me endpoints

* fixup! chore: rename platform me endpoints
2024-03-28 12:28:32 +00:00
Morgan ccc8cdc1ff chore: rename platform endpoints provider/gcal (#14248) 2024-03-28 12:04:28 +00:00
Crowdin Bot a1840c9362 New Crowdin translations by Github Action 2024-03-28 08:10:02 +00:00
Morgan 22212e7edb fix: embed env var EnvrionmentPlugin config (#14225)
* fix: embed env var EnvrionmentPlugin config

* fixup! fix: embed env var EnvrionmentPlugin config

* fixup! fixup! fix: embed env var EnvrionmentPlugin config

* fix: auto switch to VERCEL_URL if WEBAPP_URL is undefined
2024-03-28 08:05:13 +00:00
Crowdin Bot 6ffe7005dd New Crowdin translations by Github Action 2024-03-27 18:52:03 +00:00
Crowdin Bot 4890d72e30 New Crowdin translations by Github Action 2024-03-27 18:42:37 +00:00
Crowdin Bot a531541273 New Crowdin translations by Github Action 2024-03-27 18:34:19 +00:00
Peer Richelsen 2bced59b47 chore: added vercel env to turbo repo (#14238) 2024-03-27 11:26:05 -07:00
Richard Poelderl e9554604e7 chore(ci): Add nextjs framework env vors to @calcom/website in turbo.json (#14231) 2024-03-27 14:04:27 +00:00
Crowdin Bot 48c51da853 New Crowdin translations by Github Action 2024-03-27 12:42:07 +00:00
cc2164657c feat: platform endpoints to fetch/cancel/reschedule bookings and hooks (#14164)
* init bookings endpoint

* abstracting functions

* e2e tests for bookings

* hooks for bookings endpoint

* bookings respository fixtures

* typings for booking input

* fixup

* abstract booking info code and use it as handler

* import handlers for bookings endpoint

* add cancel booking input

* add handleCancelBooking handler in platform libraries

* cancel booking endpoint

* abstract call into its own separate fn

* cancel booking hook

* e2e test for cancel booking endpoint

* fix import

* export getBookings function

* move getAllUserBookings into lib

* add bookings folder to package exports

* use getAllUserBookings from lib

* fix import path

* fix: hooks, endpoint and example for cancel / reschedule / list / booking success page

* fix: unit test mock classNames import from lib

* fix: unit test mock classNames import from lib

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-03-27 12:34:54 +00:00
Crowdin Bot a35683667b New Crowdin translations by Github Action 2024-03-27 08:19:25 +00:00
Zachary Jia cf403bebf1 fix: add missing multiple_duration_timeUnit translation in zh-CN (#14212) 2024-03-27 05:15:15 -03:00
Crowdin Bot f3e4bbc753 New Crowdin translations by Github Action 2024-03-26 23:36:45 +00:00
Alex van AndelandOmar López 4bfbac6649 feat: Migrate all old avatars to 'avatars' table (#14170)
Co-authored-by: Omar López <zomars@me.com>
2024-03-26 23:33:15 +00:00
Alex van Andel 3d7fd7eb01 chore: Set Github Actions PostgreSQL password 2024-03-26 22:58:42 +00:00
Crowdin Bot ec42900e25 New Crowdin translations by Github Action 2024-03-26 22:48:13 +00:00
Alex van Andel 032b243248 chore: bump GitHub postgres v2 (#14220)
* Bump all postgres:12 -> postgres:13.13

* Attempt 2, postgres:13 instead of 13.13 and custom options
2024-03-26 22:44:07 +00:00
Crowdin Bot b70b5a5449 New Crowdin translations by Github Action 2024-03-26 21:15:05 +00:00
Crowdin Bot cc5dfb06bd New Crowdin translations by Github Action 2024-03-26 21:11:19 +00:00
595 changed files with 13184 additions and 3731 deletions
+11 -3
View File
@@ -169,8 +169,7 @@ STRIPE_WEBHOOK_SECRET=
STRIPE_WEBHOOK_SECRET_APPS=
STRIPE_PRIVATE_KEY=
STRIPE_CLIENT_ID=
PAYMENT_FEE_FIXED=
PAYMENT_FEE_PERCENTAGE=
# Use for internal Public API Keys and optional
API_KEY_PREFIX=cal_
@@ -213,6 +212,10 @@ E2E_TEST_MAILHOG_ENABLED=
# **********************************************************************************************************
# Cloudflare Turnstile
NEXT_PUBLIC_CLOUDFLARE_SITEKEY=
CLOUDFLARE_TURNSTILE_SECRET=
# Set the following value to true if you wish to enable Team Impersonation
NEXT_PUBLIC_TEAM_IMPERSONATION=false
@@ -345,4 +348,9 @@ SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
# Ratelimiting via unkey
UNKEY_ROOT_KEY=
UNKEY_ROOT_KEY=
# Used for Cal.ai Enterprise Voice AI Agents
# https://retellai.com
RETELL_AI_KEY=
+1
View File
@@ -4,3 +4,4 @@ node_modules
**/**/public
packages/prisma/zod
apps/web/public/embed
packages/ui/components/icon/dynamicIconImports.tsx
+1 -1
View File
@@ -3,7 +3,7 @@ description: "Cache or restore if necessary"
inputs:
DATABASE_URL:
required: false
default: "postgresql://postgres:@localhost:5432/calendso"
default: "postgresql://postgres:postgres@localhost:5432/calendso"
path:
required: false
default: "backups/backup.sql"
@@ -0,0 +1,69 @@
name: Production Build
on:
workflow_call:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
E2E_TEST_CALCOM_QA_EMAIL: ${{ secrets.E2E_TEST_CALCOM_QA_EMAIL }}
E2E_TEST_CALCOM_QA_PASSWORD: ${{ secrets.E2E_TEST_CALCOM_QA_PASSWORD }}
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ secrets.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
E2E_TEST_CALCOM_GCAL_KEYS: ${{ secrets.E2E_TEST_CALCOM_GCAL_KEYS }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
jobs:
build:
name: Build API V1
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
services:
postgres:
image: postgres:13
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-db
- run: |
export NODE_OPTIONS="--max_old_space_size=8192"
yarn turbo run build --filter=@calcom/api...
shell: bash
+7 -1
View File
@@ -10,13 +10,19 @@ jobs:
runs-on: buildjet-4vcpu-ubuntu-2204
services:
postgres:
image: postgres:13.13
image: postgres:13
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
mailhog:
+7 -1
View File
@@ -10,13 +10,19 @@ jobs:
runs-on: buildjet-4vcpu-ubuntu-2204
services:
postgres:
image: postgres:13.13
image: postgres:13
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
strategy:
+7 -1
View File
@@ -10,13 +10,19 @@ jobs:
runs-on: buildjet-4vcpu-ubuntu-2204
services:
postgres:
image: postgres:13.13
image: postgres:13
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
mailhog:
+7 -1
View File
@@ -10,13 +10,19 @@ jobs:
runs-on: buildjet-4vcpu-ubuntu-2204
services:
postgres:
image: postgres:13.13
image: postgres:13
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
mailhog:
+8 -1
View File
@@ -56,6 +56,13 @@ jobs:
uses: ./.github/workflows/production-build.yml
secrets: inherit
build-api-v1:
name: Production build
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/api-v1-production-build.yml
secrets: inherit
build-without-database:
name: Production build (without database)
needs: [changes]
@@ -99,7 +106,7 @@ jobs:
secrets: inherit
required:
needs: [changes, lint, type-check, test, build, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
needs: [changes, lint, type-check, test, build, build-api-v1, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
if: always()
runs-on: buildjet-4vcpu-ubuntu-2204
steps:
@@ -30,6 +30,8 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
jobs:
build:
+9 -1
View File
@@ -34,6 +34,8 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
jobs:
build:
@@ -42,13 +44,19 @@ jobs:
timeout-minutes: 30
services:
postgres:
image: postgres:13.13
image: postgres:13
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
+1
View File
@@ -17,3 +17,4 @@ packages/prisma/zod
packages/prisma/enums
apps/web/public/embed
apps/api/v2/swagger/documentation.json
packages/ui/components/icon/dynamicIconImports.tsx
+13 -2
View File
@@ -13,8 +13,6 @@ Contributions are what makes the open source community such an amazing place to
- Issues from non-core members automatically receive the `🚨 needs approval` label.
- We greatly value new feature ideas. To ensure consistency in the product's direction, they undergo review and approval.
## Priorities
<table>
@@ -178,11 +176,15 @@ If you get errors, be sure to fix them before committing.
Do not commit your `yarn.lock` unless you've made changes to the `package.json`. If you've already committed `yarn.lock` unintentionally, follow these steps to undo:
If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`:
```bash
git checkout HEAD~1 yarn.lock
git commit -m "Revert yarn.lock changes"
```
_NB_: You may have to bypass the pre-commit hook with by appending `--no-verify` to the git commit
If you've pushed the commit with the `yarn.lock`:
1. Correct the commit locally using the above method.
2. Carefully force push:
@@ -194,26 +196,35 @@ If `yarn.lock` was committed a while ago and there have been several commits sin
1. **Checkout a Previous Version**:
- Find the commit hash before the `yarn.lock` was unintentionally committed. You can do this by viewing the Git log:
```bash
git log yarn.lock
```
- Once you have identified the commit hash, use it to checkout the previous version of `yarn.lock`:
```bash
git checkout <commit_hash> yarn.lock
```
2. **Commit the Reverted Version**:
- After checking out the previous version of the `yarn.lock`, commit this change:
```bash
git commit -m "Revert yarn.lock to its state before unintended changes"
```
3. **Proceed with Caution**:
- If you need to push this change, first pull the latest changes from your remote branch to ensure you're not overwriting other recent changes:
```bash
git pull origin <your-branch-name>
```
- Then push the updated branch:
```bash
git push origin <your-branch-name>
```
Lastly, make sure to keep the branches updated (e.g. click the `Update branch` button on GitHub PR).
+2 -2
View File
@@ -113,7 +113,7 @@ To get a local copy up and running, please follow these simple steps.
Here is what you need to be able to run Cal.com.
- Node.js (Version: >=18.x)
- PostgreSQL
- PostgreSQL (Version: >=13.x)
- Yarn _(recommended)_
> If you want to enable any of the available integrations, you may want to obtain additional credentials for each one. More details on this can be found below under the [integrations section](#integrations).
@@ -210,7 +210,7 @@ echo 'NEXT_PUBLIC_DEBUG=1' >> .env
![image](https://user-images.githubusercontent.com/39329182/236612291-51d87f69-6dc1-4a23-bf4d-1ca1754e0a35.png)
5. Now extract all the info and add it to your DATABASE_URL. The url would look something like this
`postgresql://postgres:postgres@localhost:5432/Your-DB-Name`.
`postgresql://postgres:postgres@localhost:5432/Your-DB-Name`. The port is configurable and does not have to be 5432.
</details>
+1 -1
View File
@@ -56,7 +56,7 @@ test.describe("Org", () => {
];
const response = await page.goto(`http://i.cal.com/${users[0].username}+${users[1].username}`);
expect(response?.status()).toBe(200);
expect(await page.locator('[data-testid="event-title"]').textContent()).toBe("Dynamic");
expect(await page.locator('[data-testid="event-title"]').textContent()).toBe("Group Meeting");
expect(await page.locator('[data-testid="event-meta"]').textContent()).toContain(users[0].name);
expect(await page.locator('[data-testid="event-meta"]').textContent()).toContain(users[1].name);
+1
View File
@@ -4,3 +4,4 @@ NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
# Get it in console.cal.com
CALCOM_LICENSE_KEY=""
NEXT_PUBLIC_API_V2_ROOT_URL=http://localhost:5555
+1 -2
View File
@@ -1,7 +1,6 @@
import type { NextMiddleware } from "next-api-middleware";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { API_KEY_RATE_LIMIT } from "@calcom/lib/rateLimit";
export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
@@ -11,7 +10,7 @@ export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
identifier: req.query.apiKey as string,
rateLimitingType: "api",
onRateLimiterResponse: (response) => {
res.setHeader("X-RateLimit-Limit", API_KEY_RATE_LIMIT);
res.setHeader("X-RateLimit-Limit", response.limit);
res.setHeader("X-RateLimit-Remaining", response.remaining);
res.setHeader("X-RateLimit-Reset", response.reset);
},
+16
View File
@@ -47,6 +47,22 @@ const nextConfig = {
source: "/v:version/:rest*",
destination: "/api/v:version/:rest*",
},
{
source: "/api/v2",
destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/health`,
},
{
source: "/api/v2/health",
destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/health`,
},
{
source: "/api/v2/docs/:path*",
destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/docs/:path*`,
},
{
source: "/api/v2/:path*",
destination: `${process.env.NEXT_PUBLIC_API_V2_ROOT_URL}/api/v2/:path*`,
},
// This redirects requests to api/v*/ to /api/ passing version as a query parameter.
{
source: "/api/v:version/:rest*",
@@ -93,6 +93,7 @@ async function checkPermissions<T extends BaseEventTypeCheckPermissions>(
await canAccessTeamEventOrThrow(req, {
in: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER],
});
return true;
}
if (eventType?.userId === req.userId) return true; // is owner.
throw new HttpError({ statusCode: 403, message: "Forbidden" });
@@ -1,7 +1,7 @@
import type { Prisma } from "@prisma/client";
import type { NextApiRequest } from "next";
import { purchaseTeamSubscription } from "@calcom/features/ee/teams/lib/payments";
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
@@ -105,10 +105,11 @@ export async function patchHandler(req: NextApiRequest) {
};
delete data.slug;
if (IS_TEAM_BILLING_ENABLED) {
const checkoutSession = await purchaseTeamSubscription({
const checkoutSession = await purchaseTeamOrOrgSubscription({
teamId: _team.id,
seats: _team.members.length,
userId,
pricePerSeat: null,
});
if (!checkoutSession.url)
throw new TRPCError({
+3 -1
View File
@@ -2,6 +2,7 @@ import type { NextApiRequest } from "next";
import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
@@ -197,8 +198,9 @@ const generateTeamCheckoutSession = async ({
customer_update: {
address: "auto",
},
// Disabled when testing locally as usually developer doesn't setup Tax in Stripe Test mode
automatic_tax: {
enabled: true,
enabled: IS_PRODUCTION,
},
metadata: {
pendingPaymentTeamId,
+2 -1
View File
@@ -7,7 +7,8 @@
"paths": {
"~/*": ["*"],
"@prisma/client/*": ["@calcom/prisma/client/*"]
}
},
"experimentalDecorators": true
},
"include": [
"next-env.d.ts",
+1 -1
View File
@@ -7,7 +7,7 @@ LOG_LEVEL=
NEXTAUTH_SECRET=
DATABASE_URL=
JWT_SECRET=
SENTRY_DNS=
SENTRY_DSN=
# KEEP THIS EMPTY, DISABLE SENTRY CLIENT INSIDE OF LIBRARIES USED BY APIv2
NEXT_PUBLIC_SENTRY_DSN=
+1 -1
View File
@@ -7,7 +7,7 @@
"plugins": [
{
"name": "@nestjs/swagger",
"options": { "dtoFileNameSuffix": ".input.ts", "classValidatorShim": true }
"options": { "dtoFileNameSuffix": [".input.ts", ".output.ts", ".dto.ts"], "classValidatorShim": true }
}
]
}
+1 -1
View File
@@ -8,7 +8,7 @@ export class AppLoggerMiddleware implements NestMiddleware {
private logger = new Logger("HTTP");
use(request: Request, response: Response, next: NextFunction): void {
const { ip, method, protocol, originalUrl, path: url } = request;
const { ip, method, originalUrl } = request;
const userAgent = request.get("user-agent") || "";
response.on("close", () => {
+2 -2
View File
@@ -44,9 +44,9 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
})
);
if (process.env.SENTRY_DNS) {
if (process.env.SENTRY_DSN) {
Sentry.init({
dsn: getEnv("SENTRY_DNS"),
dsn: getEnv("SENTRY_DSN"),
});
}
+3 -1
View File
@@ -11,7 +11,9 @@ const loadConfig = (): AppConfig => {
port: Number(getEnv("API_PORT", "5555")),
path: getEnv("API_URL", "http://localhost"),
url: `${getEnv("API_URL", "http://localhost")}${
process.env.API_PORT ? `:${Number(getEnv("API_PORT", "5555"))}` : ""
process.env.API_PORT && getEnv("NODE_ENV", "development") === "development"
? `:${Number(getEnv("API_PORT", "5555"))}`
: ""
}/api/v2`,
},
db: {
@@ -0,0 +1,236 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { SchedulesRepository } from "@/ee/schedules/schedules.repository";
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import {
getAllUserBookings,
handleNewBooking,
getBookingInfo,
handleNewRecurringBooking,
handleInstantMeeting,
} from "@calcom/platform-libraries";
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
describe("Bookings Endpoints", () => {
describe("User Authenticated", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
let createdBooking: Awaited<ReturnType<typeof handleNewBooking>>;
beforeAll(async () => {
const moduleRef = await withAccessTokenAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, AvailabilitiesModule, UsersModule],
providers: [SchedulesRepository, SchedulesService],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
});
it("should create a booking", async () => {
const bookingStart = "2023-05-25T09:30:00.000Z";
const bookingEnd = "2023-05-25T10:30:00.000Z";
const bookingEventTypeId = 7;
const bookingTimeZone = "Europe/Londom";
const bookingLanguage = "en";
const bookingHashedLink = "";
const bookingMetadata = {};
const body = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
timeZone: bookingTimeZone,
language: bookingLanguage,
metadata: bookingMetadata,
hashedLink: bookingHashedLink,
};
return request(app.getHttpServer())
.post("/api/v2/ee/bookings")
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.user.email).toBeDefined();
expect(responseBody.data.user.email).toEqual(userEmail);
expect(responseBody.data.id).toBeDefined();
expect(responseBody.data.uid).toBeDefined();
expect(responseBody.data.startTime).toEqual(bookingStart);
expect(responseBody.data.eventTypeId).toEqual(bookingEventTypeId);
expect(responseBody.data.user.timeZone).toEqual(bookingTimeZone);
expect(responseBody.data.metadata).toEqual(bookingMetadata);
createdBooking = responseBody.data;
});
});
it("should get bookings", async () => {
return request(app.getHttpServer())
.get("/api/v2/ee/bookings")
.then((response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof getAllUserBookings>>> =
response.body;
const fetchedBooking = responseBody.data.bookings[0];
expect(responseBody.data.bookings.length).toEqual(1);
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(fetchedBooking).toBeDefined();
expect(fetchedBooking.id).toEqual(createdBooking.id);
expect(fetchedBooking.uid).toEqual(createdBooking.uid);
expect(fetchedBooking.startTime).toEqual(createdBooking.startTime);
expect(fetchedBooking.endTime).toEqual(createdBooking.endTime);
expect(fetchedBooking.user?.email).toEqual(createdBooking.user.email);
});
});
it("should get booking", async () => {
return request(app.getHttpServer())
.get(`/api/v2/ee/bookings/${createdBooking.uid}`)
.then((response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof getBookingInfo>>> = response.body;
const { bookingInfo } = responseBody.data;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(bookingInfo?.id).toBeDefined();
expect(bookingInfo?.uid).toBeDefined();
expect(bookingInfo?.id).toEqual(createdBooking.id);
expect(bookingInfo?.uid).toEqual(createdBooking.uid);
expect(bookingInfo?.eventTypeId).toEqual(createdBooking.eventTypeId);
expect(bookingInfo?.startTime).toEqual(createdBooking.startTime);
});
});
it("should create a recurring booking", async () => {
const bookingStart = "2023-05-25T09:30:00.000Z";
const bookingEnd = "2023-05-25T10:30:00.000Z";
const bookingEventTypeId = 7;
const bookingTimeZone = "Europe/Londom";
const bookingLanguage = "en";
const bookingHashedLink = "";
const bookingRecurringCount = 5;
const currentBookingRecurringIndex = 0;
const body = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
timeZone: bookingTimeZone,
language: bookingLanguage,
metadata: {},
hashedLink: bookingHashedLink,
recurringCount: bookingRecurringCount,
currentRecurringIndex: currentBookingRecurringIndex,
};
return request(app.getHttpServer())
.post("/api/v2/ee/bookings/reccuring")
.send(body)
.expect(201)
.then((response) => {
const responseBody: ApiResponse<Awaited<ReturnType<typeof handleNewRecurringBooking>>> =
response.body;
expect(responseBody.status).toEqual("recurring");
});
});
it("should create an instant booking", async () => {
const bookingStart = "2023-05-25T09:30:00.000Z";
const bookingEnd = "2023-05-25T10:30:00.000Z";
const bookingEventTypeId = 7;
const bookingTimeZone = "Europe/Londom";
const bookingLanguage = "en";
const bookingHashedLink = "";
const body = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
timeZone: bookingTimeZone,
language: bookingLanguage,
metadata: {},
hashedLink: bookingHashedLink,
};
return request(app.getHttpServer())
.post("/api/v2/ee/bookings/instant")
.send(body)
.expect(201)
.then((response) => {
const responseBody: ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>> = response.body;
expect(responseBody.status).toEqual("instant");
});
});
it("should cancel a booking", async () => {
const bookingId = createdBooking.id;
const body = {
allRemainingBookings: false,
cancellationReason: "Was fighting some unforseen rescheduling demons",
};
return request(app.getHttpServer())
.post(`/api/v2/ee/bookings/${bookingId}/cancel`)
.send(body)
.expect(201)
.then((response) => {
const responseBody: ApiResponse<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> =
response.body;
expect(bookingId).toBeDefined();
expect(responseBody.status).toEqual(SUCCESS_STATUS);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -1,8 +1,11 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { CreateReccuringBookingInput } from "@/ee/bookings/inputs/create-reccuring-booking.input";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import {
Controller,
Post,
@@ -11,12 +14,23 @@ import {
InternalServerErrorException,
Body,
HttpException,
Param,
Get,
Query,
NotFoundException,
UseGuards,
} from "@nestjs/common";
import { User } from "@prisma/client";
import { Request } from "express";
import { NextApiRequest } from "next/types";
import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
getAllUserBookings,
getBookingInfo,
handleCancelBooking,
getBookingForReschedule,
} from "@calcom/platform-libraries";
import {
handleNewBooking,
BookingResponse,
@@ -24,7 +38,9 @@ import {
handleNewRecurringBooking,
handleInstantMeeting,
} from "@calcom/platform-libraries";
import { GetBookingsInput, CancelBookingInput } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@Controller({
path: "ee/bookings",
@@ -34,7 +50,65 @@ import { ApiResponse } from "@calcom/platform-types";
export class BookingsController {
private readonly logger = new Logger("ee bookings controller");
constructor(private readonly oAuthFlowService: OAuthFlowService) {}
constructor(
private readonly oAuthFlowService: OAuthFlowService,
private readonly prismaReadService: PrismaReadService
) {}
// note(Rajiv): currently this endpoint is atoms only
@Get("/")
@UseGuards(AccessTokenGuard)
async getBookings(
@GetUser() user: User,
@Query() queryParams: GetBookingsInput
): Promise<ApiResponse<unknown>> {
const { filters, cursor, limit } = queryParams;
const bookings = await getAllUserBookings({
bookingListingByStatus: filters.status,
skip: cursor ?? 0,
take: limit ?? 10,
filters,
ctx: {
user: { email: user.email, id: user.id },
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
},
});
return {
status: SUCCESS_STATUS,
data: bookings,
};
}
// note(Rajiv): currently this endpoint is atoms only
@Get("/:bookingUid")
async getBooking(@Param("bookingUid") bookingUid: string): Promise<ApiResponse<unknown>> {
const { bookingInfo } = await getBookingInfo(bookingUid);
if (!bookingInfo) {
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
}
return {
status: SUCCESS_STATUS,
data: bookingInfo,
};
}
// note(Rajiv): currently this endpoint is atoms only
@Get("/:bookingUid/reschedule")
async getBookingForReschedule(@Param("bookingUid") bookingUid: string): Promise<ApiResponse<unknown>> {
const booking = await getBookingForReschedule(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
}
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Post("/")
@Permissions([BOOKING_WRITE])
@@ -56,6 +130,30 @@ export class BookingsController {
throw new InternalServerErrorException("Could not create booking.");
}
@Post("/:bookingId/cancel")
@Permissions([BOOKING_WRITE])
async cancelBooking(
@Req() req: Request & { userId?: number },
@Param("bookingId") bookingId: string,
@Body() body: CancelBookingInput
): Promise<ApiResponse> {
if (bookingId) {
req.userId = await this.getOwnerId(req);
req.body = { ...body, id: parseInt(bookingId) };
try {
await handleCancelBooking(req as unknown as NextApiRequest & { userId?: number });
return {
status: SUCCESS_STATUS,
};
} catch (err) {
handleBookingErrors(err);
}
} else {
throw new NotFoundException("Booking ID is required.");
}
throw new InternalServerErrorException("Could not cancel booking.");
}
@Post("/reccuring")
@Permissions([BOOKING_WRITE])
async createReccuringBooking(
@@ -1,5 +1,5 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { IsBoolean, IsNumber, IsString, IsOptional, IsArray } from "class-validator";
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
import type { AppsStatus } from "@calcom/platform-libraries";
@@ -2,7 +2,7 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, Get, Logger, UseGuards, Query } from "@nestjs/common";
import { Controller, Get, UseGuards, Query } from "@nestjs/common";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ConnectedDestinationCalendars } from "@calcom/platform-libraries";
@@ -16,8 +16,6 @@ import { EventBusyDate } from "@calcom/types/Calendar";
})
@UseGuards(AccessTokenGuard)
export class CalendarsController {
private readonly logger = new Logger("ee overlay calendars controller");
constructor(private readonly calendarsService: CalendarsService) {}
@Get("/busy-times")
@@ -1,6 +1,8 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { EventTypesModule } from "@/ee/event-types/event-types.module";
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
@@ -61,7 +63,7 @@ describe("Event types Endpoints", () => {
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
const userEmail = "event-types-test-e2e@api.com";
const name = "bob";
const name = "bob the builder";
const username = name;
let eventType: EventType;
let user: User;
@@ -86,6 +88,7 @@ describe("Event types Endpoints", () => {
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
@@ -95,17 +98,6 @@ describe("Event types Endpoints", () => {
username,
});
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
eventType = await eventTypesRepositoryFixture.create(
{
length: 60,
title: "peer coding session",
slug: "peer-coding",
},
user.id
);
await app.init();
});
@@ -129,6 +121,50 @@ describe("Event types Endpoints", () => {
expect(user).toBeDefined();
});
it("should create an event type", async () => {
const body: CreateEventTypeInput = {
title: "Test Event Type",
slug: "test-event-type",
description: "A description of the test event type.",
length: 60,
hidden: false,
locations: [
{
type: "Online",
link: "https://example.com/meet",
displayLocationPublicly: true,
},
],
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventType> = response.body;
expect(responseBody.data).toHaveProperty("id");
expect(responseBody.data.title).toEqual(body.title);
eventType = responseBody.data;
});
});
it("should update event type", async () => {
const newTitle = "Updated title";
const body: UpdateEventTypeInput = {
title: newTitle,
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}`)
.send(body)
.expect(200)
.then(async () => {
eventType.title = newTitle;
});
});
it(`/GET/:id`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}`)
@@ -154,10 +190,8 @@ describe("Event types Endpoints", () => {
.expect(200);
const responseBody: ApiSuccessResponse<EventTypesByViewer> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
console.log("asap responseBody.data", responseBody.data);
expect(responseBody.data.eventTypeGroups).toBeDefined();
expect(responseBody.data.eventTypeGroups).toBeDefined();
expect(responseBody.data.eventTypeGroups[0]).toBeDefined();
@@ -178,7 +212,6 @@ describe("Event types Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
console.log("asap responseBody.data", responseBody.data);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(1);
expect(responseBody.data[0].id).toEqual(eventType.id);
@@ -192,10 +225,18 @@ describe("Event types Endpoints", () => {
.expect(404);
});
it("should delete schedule", async () => {
return request(app.getHttpServer()).delete(`/api/v2/event-types/${eventType.id}`).expect(200);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await eventTypesRepositoryFixture.delete(eventType.id);
try {
await eventTypesRepositoryFixture.delete(eventType.id);
} catch (e) {
// Event type might have been deleted by the test
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
@@ -1,25 +1,41 @@
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
import { CreateEventTypeOutput } from "@/ee/event-types/outputs/create-event-type.output";
import { DeleteEventTypeOutput } from "@/ee/event-types/outputs/delete-event-type.output";
import { GetEventTypeOutput } from "@/ee/event-types/outputs/get-event-type.output";
import { GetEventTypesPublicOutput } from "@/ee/event-types/outputs/get-event-types-public.output";
import { GetEventTypesOutput } from "@/ee/event-types/outputs/get-event-types.output";
import { UpdateEventTypeOutput } from "@/ee/event-types/outputs/update-event-type.output";
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
import { ForAtom } from "@/lib/atoms/decorators/for-atom.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Param, Post, Body, NotFoundException } from "@nestjs/common";
import { EventType } from "@prisma/client";
import {
Controller,
UseGuards,
Get,
Param,
Post,
Body,
NotFoundException,
Patch,
HttpCode,
HttpStatus,
Delete,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { EventTypesByViewer } from "@calcom/lib";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { EventType as AtomEventType, EventTypesPublic } from "@calcom/platform-libraries";
import { getEventTypesByViewer } from "@calcom/platform-libraries";
import { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
@Controller({
path: "event-types",
version: "2",
})
@UseGuards(PermissionsGuard)
@DocsTags("Event types")
export class EventTypesController {
constructor(private readonly eventTypesService: EventTypesService) {}
@@ -29,8 +45,8 @@ export class EventTypesController {
async createEventType(
@Body() body: CreateEventTypeInput,
@GetUser() user: UserWithProfile
): Promise<ApiResponse<EventType>> {
const eventType = await this.eventTypesService.createUserEventType(user.id, body);
): Promise<CreateEventTypeOutput> {
const eventType = await this.eventTypesService.createUserEventType(user, body);
return {
status: SUCCESS_STATUS,
@@ -43,12 +59,9 @@ export class EventTypesController {
@UseGuards(AccessTokenGuard)
async getEventType(
@Param("eventTypeId") eventTypeId: string,
@ForAtom() forAtom: boolean,
@GetUser() user: UserWithProfile
): Promise<ApiSuccessResponse<EventType | AtomEventType>> {
const eventType = forAtom
? await this.eventTypesService.getUserEventTypeForAtom(user, Number(eventTypeId))
: await this.eventTypesService.getUserEventType(user.id, Number(eventTypeId));
): Promise<GetEventTypeOutput> {
const eventType = await this.eventTypesService.getUserEventTypeForAtom(user, Number(eventTypeId));
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
@@ -63,7 +76,7 @@ export class EventTypesController {
@Get("/")
@Permissions([EVENT_TYPE_READ])
@UseGuards(AccessTokenGuard)
async getEventTypes(@GetUser() user: UserWithProfile): Promise<ApiSuccessResponse<EventTypesByViewer>> {
async getEventTypes(@GetUser() user: UserWithProfile): Promise<GetEventTypesOutput> {
const eventTypes = await getEventTypesByViewer({
id: user.id,
profile: {
@@ -79,9 +92,7 @@ export class EventTypesController {
@Get("/:username/public")
@Permissions([EVENT_TYPE_READ])
async getPublicEventTypes(
@Param("username") username: string
): Promise<ApiSuccessResponse<EventTypesPublic>> {
async getPublicEventTypes(@Param("username") username: string): Promise<GetEventTypesPublicOutput> {
const eventTypes = await this.eventTypesService.getEventTypesPublicByUsername(username);
return {
@@ -89,4 +100,41 @@ export class EventTypesController {
data: eventTypes,
};
}
@Patch("/:eventTypeId")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(AccessTokenGuard)
@HttpCode(HttpStatus.OK)
async updateEventType(
@Param("eventTypeId") eventTypeId: number,
@Body() body: UpdateEventTypeInput,
@GetUser() user: UserWithProfile
): Promise<UpdateEventTypeOutput> {
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Delete("/:eventTypeId")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(AccessTokenGuard)
async deleteEventType(
@Param("eventTypeId") eventTypeId: number,
@GetUser("id") userId: number
): Promise<DeleteEventTypeOutput> {
const eventType = await this.eventTypesService.deleteEventType(eventTypeId, userId);
return {
status: SUCCESS_STATUS,
data: {
id: eventType.id,
length: eventType.length,
slug: eventType.slug,
title: eventType.title,
},
};
}
}
@@ -3,12 +3,13 @@ import { EventTypesRepository } from "@/ee/event-types/event-types.repository";
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule],
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule],
providers: [EventTypesRepository, EventTypesService],
controllers: [EventTypesController],
exports: [EventTypesService, EventTypesRepository],
@@ -10,7 +10,10 @@ import { getEventTypeById } from "@calcom/platform-libraries";
export class EventTypesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createUserEventType(userId: number, body: CreateEventTypeInput) {
async createUserEventType(
userId: number,
body: Pick<CreateEventTypeInput, "title" | "slug" | "length" | "hidden">
) {
return this.dbWrite.prisma.eventType.create({
data: {
...body,
@@ -59,4 +62,19 @@ export class EventTypesRepository {
async getEventTypeById(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({ where: { id: eventTypeId } });
}
async getUserEventTypeBySlug(userId: number, slug: string) {
return this.dbRead.prisma.eventType.findUnique({
where: {
userId_slug: {
userId: userId,
slug: slug,
},
},
});
}
async deleteEventType(eventTypeId: number) {
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
}
}
@@ -1,13 +1,53 @@
import { IsNumber, IsString, Min } from "class-validator";
import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsString, IsNumber, IsBoolean, IsOptional, ValidateNested, Min, IsArray } from "class-validator";
export const CREATE_EVENT_LENGTH_EXAMPLE = 60;
export const CREATE_EVENT_SLUG_EXAMPLE = "cooking-class";
export const CREATE_EVENT_TITLE_EXAMPLE = "Learn the secrets of masterchief!";
export const CREATE_EVENT_DESCRIPTION_EXAMPLE =
"Discover the culinary wonders of the Argentina by making the best flan ever!";
// note(Lauris): We will gradually expose more properties if any customer needs them.
// Just uncomment any below when requested.
export class CreateEventTypeInput {
@IsNumber()
@Min(1)
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
length!: number;
@IsString()
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
slug!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
title!: string;
@IsOptional()
@IsString()
@DocsProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE })
description?: string;
@IsOptional()
@IsBoolean()
@ApiHideProperty()
hidden?: boolean;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => EventTypeLocation)
@IsArray()
locations?: EventTypeLocation[];
// @ApiHideProperty()
// @IsOptional()
// @IsNumber()
// teamId?: number;
// @ApiHideProperty()
// @IsOptional()
// @IsEnum(SchedulingType)
// schedulingType?: SchedulingType; -> import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type";
}
@@ -0,0 +1,7 @@
export enum Editable {
system = "system",
systemButOptional = "system-but-optional",
systemButHidden = "system-but-hidden",
user = "user",
userReadonly = "user-readonly",
}
@@ -0,0 +1,16 @@
export enum BaseField {
number = "number",
boolean = "boolean",
address = "address",
name = "name",
text = "text",
textarea = "textarea",
email = "email",
phone = "phone",
multiemail = "multiemail",
select = "select",
multiselect = "multiselect",
checkbox = "checkbox",
radio = "radio",
radioInput = "radioInput",
}
@@ -0,0 +1,9 @@
export enum Frequency {
YEARLY = 0,
MONTHLY = 1,
WEEKLY = 2,
DAILY = 3,
HOURLY = 4,
MINUTELY = 5,
SECONDLY = 6,
}
@@ -0,0 +1,5 @@
export enum PeriodType {
UNLIMITED = "UNLIMITED",
ROLLING = "ROLLING",
RANGE = "RANGE",
}
@@ -0,0 +1,5 @@
export enum SchedulingType {
ROUND_ROBIN = "ROUND_ROBIN",
COLLECTIVE = "COLLECTIVE",
MANAGED = "MANAGED",
}
@@ -0,0 +1,41 @@
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { IsString, IsNumber, IsBoolean, IsOptional, IsUrl } from "class-validator";
// note(Lauris): We will gradually expose more properties if any customer needs them.
// Just uncomment any below when requested.
export class EventTypeLocation {
@IsString()
@DocsProperty({ example: "link" })
type!: string;
@IsOptional()
@IsString()
@ApiHideProperty()
address?: string;
@IsOptional()
@IsUrl()
@DocsProperty({ example: "https://masterchief.com/argentina/flan/video/9129412" })
link?: string;
@IsOptional()
@IsBoolean()
@ApiHideProperty()
displayLocationPublicly?: boolean;
@IsOptional()
@IsString()
@ApiHideProperty()
hostPhoneNumber?: string;
@IsOptional()
@IsNumber()
@ApiHideProperty()
credentialId?: number;
@IsOptional()
@IsString()
@ApiHideProperty()
teamName?: string;
}
@@ -0,0 +1,412 @@
import { Editable } from "@/ee/event-types/inputs/enums/editable";
import { BaseField } from "@/ee/event-types/inputs/enums/field-type";
import { Frequency } from "@/ee/event-types/inputs/enums/frequency";
import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
import { Type } from "class-transformer";
import {
IsString,
IsBoolean,
IsOptional,
ValidateNested,
Min,
IsInt,
IsEnum,
IsArray,
IsDate,
IsNumber,
} from "class-validator";
// note(Lauris): We will gradually expose more properties if any customer needs them.
// Just uncomment any below when requested. Go to bottom of file to see UpdateEventTypeInput.
class Option {
@IsString()
value!: string;
@IsString()
label!: string;
}
class Source {
@IsString()
id!: string;
@IsString()
type!: string;
@IsString()
label!: string;
@IsOptional()
@IsString()
editUrl?: string;
@IsOptional()
@IsBoolean()
fieldRequired?: boolean;
}
class View {
@IsString()
id!: string;
@IsString()
label!: string;
@IsOptional()
@IsString()
description?: string;
}
class OptionsInput {
@IsString()
type!: "address" | "text" | "phone";
@IsOptional()
@IsBoolean()
required?: boolean;
@IsOptional()
@IsString()
placeholder?: string;
}
class VariantField {
@IsString()
type!: BaseField;
@IsString()
name!: string;
@IsOptional()
@IsString()
label?: string;
@IsOptional()
@IsString()
labelAsSafeHtml?: string;
@IsOptional()
@IsString()
placeholder?: string;
@IsOptional()
@IsBoolean()
required?: boolean;
}
class Variant {
@ValidateNested({ each: true })
@Type(() => VariantField)
fields!: VariantField[];
}
class VariantsConfig {
variants!: Record<string, Variant>;
}
export class BookingField {
@IsEnum(BaseField)
type!: BaseField;
@IsString()
name!: string;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => Option)
options?: Option[];
@IsOptional()
@IsString()
label?: string;
@IsOptional()
@IsString()
labelAsSafeHtml?: string;
@IsOptional()
@IsString()
defaultLabel?: string;
@IsOptional()
@IsString()
placeholder?: string;
@IsOptional()
@IsBoolean()
required?: boolean;
@IsOptional()
@IsString()
getOptionsAt?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => OptionsInput)
optionsInputs?: Record<string, OptionsInput>;
@IsOptional()
@IsString()
variant?: string;
@IsOptional()
@ValidateNested()
@Type(() => VariantsConfig)
variantsConfig?: VariantsConfig;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => View)
views?: View[];
@IsOptional()
@IsBoolean()
hideWhenJustOneOption?: boolean;
@IsOptional()
@IsBoolean()
hidden?: boolean;
@IsOptional()
@IsEnum(Editable)
editable?: Editable;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => Source)
sources?: Source[];
}
export class RecurringEvent {
@IsDate()
@IsOptional()
dtstart?: Date;
@IsInt()
interval!: number;
@IsInt()
count!: number;
@IsEnum(Frequency)
freq!: Frequency;
@IsDate()
@IsOptional()
until?: Date;
@IsString()
@IsOptional()
tzid?: string;
}
export class IntervalLimits {
@IsNumber()
@IsOptional()
PER_DAY?: number;
@IsNumber()
@IsOptional()
PER_WEEK?: number;
@IsNumber()
@IsOptional()
PER_MONTH?: number;
@IsNumber()
@IsOptional()
PER_YEAR?: number;
}
export class UpdateEventTypeInput {
@IsInt()
@Min(1)
@IsOptional()
length?: number;
@IsString()
@IsOptional()
slug?: string;
@IsString()
@IsOptional()
title?: string;
@IsString()
@IsOptional()
description?: string;
@IsBoolean()
@IsOptional()
hidden?: boolean;
@ValidateNested({ each: true })
@Type(() => EventTypeLocation)
@IsOptional()
locations?: EventTypeLocation[];
// @IsInt()
// @IsOptional()
// position?: number;
// @IsInt()
// @IsOptional()
// offsetStart?: number;
// @IsInt()
// @IsOptional()
// userId?: number;
// @IsInt()
// @IsOptional()
// profileId?: number;
// @IsInt()
// @IsOptional()
// teamId?: number;
// @IsString()
// @IsOptional()
// eventName?: string;
// @IsInt()
// @IsOptional()
// parentId?: number;
// @IsOptional()
// @IsArray()
// @ValidateNested({ each: true })
// @Type(() => BookingField)
// bookingFields?: BookingField[];
// @IsString()
// @IsOptional()
// timeZone?: string;
// @IsEnum(PeriodType)
// @IsOptional()
// periodType?: PeriodType; -> import { PeriodType } from "@/ee/event-types/inputs/enums/period-type";
// @IsDate()
// @IsOptional()
// periodStartDate?: Date;
// @IsDate()
// @IsOptional()
// periodEndDate?: Date;
// @IsInt()
// @IsOptional()
// periodDays?: number;
// @IsBoolean()
// @IsOptional()
// periodCountCalendarDays?: boolean;
// @IsBoolean()
// @IsOptional()
// lockTimeZoneToggleOnBookingPage?: boolean;
// @IsBoolean()
// @IsOptional()
// requiresConfirmation?: boolean;
// @IsBoolean()
// @IsOptional()
// requiresBookerEmailVerification?: boolean;
// @ValidateNested()
// @Type(() => RecurringEvent)
// @IsOptional()
// recurringEvent?: RecurringEvent;
// @IsBoolean()
// @IsOptional()
// disableGuests?: boolean;
// @IsBoolean()
// @IsOptional()
// hideCalendarNotes?: boolean;
// @IsInt()
// @Min(0)
// @IsOptional()
// minimumBookingNotice?: number;
// @IsInt()
// @IsOptional()
// beforeEventBuffer?: number;
// @IsInt()
// @IsOptional()
// afterEventBuffer?: number;
// @IsInt()
// @IsOptional()
// seatsPerTimeSlot?: number;
// @IsBoolean()
// @IsOptional()
// onlyShowFirstAvailableSlot?: boolean;
// @IsBoolean()
// @IsOptional()
// seatsShowAttendees?: boolean;
// @IsBoolean()
// @IsOptional()
// seatsShowAvailabilityCount?: boolean;
// @IsEnum(SchedulingType)
// @IsOptional()
// schedulingType?: SchedulingType; -> import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type";
// @IsInt()
// @IsOptional()
// scheduleId?: number;
// @IsInt()
// @IsOptional()
// price?: number;
// @IsString()
// @IsOptional()
// currency?: string;
// @IsInt()
// @IsOptional()
// slotInterval?: number;
// @IsString()
// @IsOptional()
// @IsUrl()
// successRedirectUrl?: string;
// @ValidateNested()
// @Type(() => IntervalLimits)
// @IsOptional()
// bookingLimits?: IntervalLimits;
// @ValidateNested()
// @Type(() => IntervalLimits)
// @IsOptional()
// durationLimits?: IntervalLimits;
// @IsBoolean()
// @IsOptional()
// isInstantEvent?: boolean;
// @IsBoolean()
// @IsOptional()
// assignAllTeamMembers?: boolean;
// @IsBoolean()
// @IsOptional()
// useEventTypeDestinationCalendarEmail?: boolean;
// @IsInt()
// @IsOptional()
// secondaryEmailId?: number;
}
@@ -0,0 +1,20 @@
import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class CreateEventTypeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: EventTypeOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => EventTypeOutput)
data!: EventTypeOutput;
}
@@ -0,0 +1,38 @@
import {
CREATE_EVENT_LENGTH_EXAMPLE,
CREATE_EVENT_SLUG_EXAMPLE,
CREATE_EVENT_TITLE_EXAMPLE,
} from "@/ee/event-types/inputs/create-event-type.input";
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsInt, IsString } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class DeleteData {
@IsInt()
@DocsProperty({ example: 1 })
id!: number;
@IsInt()
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
length!: number;
@IsString()
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
slug!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
title!: string;
}
export class DeleteEventTypeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Type(() => DeleteData)
data!: DeleteData;
}
@@ -0,0 +1,227 @@
import {
CREATE_EVENT_DESCRIPTION_EXAMPLE,
CREATE_EVENT_LENGTH_EXAMPLE,
CREATE_EVENT_SLUG_EXAMPLE,
CREATE_EVENT_TITLE_EXAMPLE,
} from "@/ee/event-types/inputs/create-event-type.input";
import { PeriodType } from "@/ee/event-types/inputs/enums/period-type";
import { SchedulingType } from "@/ee/event-types/inputs/enums/scheduling-type";
import { EventTypeLocation } from "@/ee/event-types/inputs/event-type-location.input";
import {
BookingField,
IntervalLimits,
RecurringEvent,
} from "@/ee/event-types/inputs/update-event-type.input";
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsDate,
IsEnum,
IsInt,
IsJSON,
IsNumber,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
export class EventTypeOutput {
@IsInt()
@DocsProperty({ example: 1 })
id!: number;
@IsInt()
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
length!: number;
@IsString()
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
slug!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
title!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE })
description!: string | null;
@IsBoolean()
@ApiHideProperty()
hidden!: boolean;
@ValidateNested({ each: true })
@Type(() => EventTypeLocation)
@IsArray()
locations!: EventTypeLocation | null;
@IsInt()
@ApiHideProperty()
position!: number;
@IsInt()
@ApiHideProperty()
offsetStart!: number;
@IsInt()
@ApiHideProperty()
userId!: number | null;
@IsInt()
@ApiHideProperty()
profileId!: number | null;
@IsInt()
@ApiHideProperty()
teamId!: number | null;
@IsString()
@ApiHideProperty()
eventName!: string | null;
@IsInt()
@ApiHideProperty()
parentId!: number | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => BookingField)
@ApiHideProperty()
bookingFields!: BookingField[] | null;
@IsString()
@ApiHideProperty()
timeZone!: string | null;
@IsEnum(PeriodType)
@ApiHideProperty()
periodType!: PeriodType | null;
@IsDate()
@ApiHideProperty()
periodStartDate!: Date | null;
@IsDate()
@ApiHideProperty()
periodEndDate!: Date | null;
@IsInt()
@ApiHideProperty()
periodDays!: number | null;
@IsBoolean()
@ApiHideProperty()
periodCountCalendarDays!: boolean | null;
@IsBoolean()
@ApiHideProperty()
lockTimeZoneToggleOnBookingPage!: boolean;
@IsBoolean()
@ApiHideProperty()
requiresConfirmation!: boolean;
@IsBoolean()
@ApiHideProperty()
requiresBookerEmailVerification!: boolean;
@ValidateNested()
@Type(() => RecurringEvent)
@IsOptional()
@ApiHideProperty()
recurringEvent!: RecurringEvent | null;
@IsBoolean()
@ApiHideProperty()
disableGuests!: boolean;
@IsBoolean()
@ApiHideProperty()
hideCalendarNotes!: boolean;
@IsInt()
@ApiHideProperty()
minimumBookingNotice!: number;
@IsInt()
@ApiHideProperty()
beforeEventBuffer!: number;
@IsInt()
@ApiHideProperty()
afterEventBuffer!: number;
@IsInt()
@ApiHideProperty()
seatsPerTimeSlot!: number | null;
@IsBoolean()
@ApiHideProperty()
onlyShowFirstAvailableSlot!: boolean;
@IsBoolean()
@ApiHideProperty()
seatsShowAttendees!: boolean;
@IsBoolean()
@ApiHideProperty()
seatsShowAvailabilityCount!: boolean;
@IsEnum(SchedulingType)
@ApiHideProperty()
schedulingType!: SchedulingType | null;
@IsInt()
@ApiHideProperty()
scheduleId!: number | null;
@IsNumber()
@ApiHideProperty()
price!: number;
@IsString()
@ApiHideProperty()
currency!: string;
@IsInt()
@ApiHideProperty()
slotInterval!: number | null;
@IsJSON()
@ApiHideProperty()
metadata!: Record<string, any> | null;
@IsString()
@ApiHideProperty()
successRedirectUrl!: string | null;
@ValidateNested()
@Type(() => IntervalLimits)
@IsOptional()
@ApiHideProperty()
bookingLimits!: IntervalLimits;
@ValidateNested()
@Type(() => IntervalLimits)
@ApiHideProperty()
durationLimits!: IntervalLimits;
@IsBoolean()
@ApiHideProperty()
isInstantEvent!: boolean;
@IsBoolean()
@ApiHideProperty()
assignAllTeamMembers!: boolean;
@IsBoolean()
@ApiHideProperty()
useEventTypeDestinationCalendarEmail!: boolean;
@IsInt()
@ApiHideProperty()
secondaryEmailId!: number | null;
}
@@ -0,0 +1,28 @@
import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Data {
@ApiProperty({
type: EventTypeOutput,
})
@ValidateNested()
@Type(() => EventTypeOutput)
eventType!: EventTypeOutput;
}
export class GetEventTypeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: Data,
})
@ValidateNested()
@Type(() => Data)
data!: Data;
}
@@ -0,0 +1,43 @@
import {
CREATE_EVENT_LENGTH_EXAMPLE,
CREATE_EVENT_SLUG_EXAMPLE,
CREATE_EVENT_TITLE_EXAMPLE,
} from "@/ee/event-types/inputs/create-event-type.input";
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsEnum, IsInt, IsString, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class PublicEventType {
@IsInt()
@DocsProperty({ example: 1 })
id!: number;
@IsInt()
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
length!: number;
@IsString()
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
slug!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
title!: string;
@IsString()
description?: string | null;
}
export class GetEventTypesPublicOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested({ each: true })
@Type(() => PublicEventType)
@IsArray()
data!: PublicEventType[];
}
@@ -0,0 +1,31 @@
import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class EventTypeGroup {
@ValidateNested({ each: true })
@Type(() => EventTypeOutput)
@IsArray()
eventTypes!: EventTypeOutput[];
}
class GetEventTypesData {
@ValidateNested({ each: true })
@Type(() => EventTypeGroup)
@IsArray()
eventTypeGroups!: EventTypeGroup[];
}
export class GetEventTypesOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested({ each: true })
@Type(() => GetEventTypesData)
@IsArray()
data!: GetEventTypesData;
}
@@ -0,0 +1,20 @@
import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class UpdateEventTypeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: EventTypeOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => EventTypeOutput)
data!: EventTypeOutput;
}
@@ -1,26 +1,73 @@
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/constants/constants";
import { EventTypesRepository } from "@/ee/event-types/event-types.repository";
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
import { EventType } from "@calcom/prisma/client";
@Injectable()
export class EventTypesService {
constructor(
private readonly eventTypesRepository: EventTypesRepository,
private readonly membershipsRepository: MembershipsRepository,
private readonly usersRepository: UsersRepository
private readonly usersRepository: UsersRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly dbWrite: PrismaWriteService
) {}
async createUserEventType(userId: number, body: CreateEventTypeInput) {
return this.eventTypesRepository.createUserEventType(userId, body);
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput) {
await this.checkCanCreateEventType(user.id, body);
const eventTypeUser = await this.getUserToCreateEvent(user);
const { eventType } = await createEventType({
input: body,
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbWrite.prisma,
},
});
return eventType;
}
async checkCanCreateEventType(userId: number, body: CreateEventTypeInput) {
const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug);
if (existsWithSlug) {
throw new BadRequestException("User already has an event type with this slug.");
}
}
async getUserToCreateEvent(user: UserWithProfile) {
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
const isOrgAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
const profileId = user.movedToProfile?.id || null;
return {
id: user.id,
organizationId: user.organizationId,
organization: { isOrgAdmin },
profile: { id: profileId },
metadata: user.metadata,
};
}
async getUserEventType(userId: number, eventTypeId: number) {
return this.eventTypesRepository.getUserEventType(userId, eventTypeId);
const eventType = await this.eventTypesRepository.getUserEventType(userId, eventTypeId);
if (!eventType) {
return null;
}
this.checkUserOwnsEventType(userId, eventType);
return eventType;
}
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
@@ -30,7 +77,18 @@ export class EventTypesService {
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
return this.eventTypesRepository.getUserEventTypeForAtom(user, isUserOrganizationAdmin, eventTypeId);
const eventType = await this.eventTypesRepository.getUserEventTypeForAtom(
user,
isUserOrganizationAdmin,
eventTypeId
);
if (!eventType) {
return null;
}
this.checkUserOwnsEventType(user.id, eventType.eventType);
return eventType;
}
async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
@@ -43,11 +101,7 @@ export class EventTypesService {
}
async createUserDefaultEventTypes(userId: number) {
const thirtyMinutes = DEFAULT_EVENT_TYPES.thirtyMinutes;
const thirtyMinutesVideo = DEFAULT_EVENT_TYPES.thirtyMinutesVideo;
const sixtyMinutes = DEFAULT_EVENT_TYPES.sixtyMinutes;
const sixtyMinutesVideo = DEFAULT_EVENT_TYPES.sixtyMinutesVideo;
const { sixtyMinutes, sixtyMinutesVideo, thirtyMinutes, thirtyMinutesVideo } = DEFAULT_EVENT_TYPES;
const defaultEventTypes = await Promise.all([
this.eventTypesRepository.createUserEventType(userId, thirtyMinutes),
@@ -58,4 +112,53 @@ export class EventTypesService {
return defaultEventTypes;
}
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput, user: UserWithProfile) {
this.checkCanUpdateEventType(user.id, eventTypeId);
const eventTypeUser = await this.getUserToUpdateEvent(user);
await updateEventType({
input: { id: eventTypeId, ...body },
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbWrite.prisma,
},
});
const { eventType } = await this.getUserEventTypeForAtom(user, eventTypeId);
return eventType;
}
async checkCanUpdateEventType(userId: number, eventTypeId: number) {
const existingEventType = await this.getUserEventType(userId, eventTypeId);
if (!existingEventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
this.checkUserOwnsEventType(userId, existingEventType);
}
async getUserToUpdateEvent(user: UserWithProfile) {
const profileId = user.movedToProfile?.id || null;
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
return { ...user, profile: { id: profileId }, selectedCalendars };
}
async deleteEventType(eventTypeId: number, userId: number) {
const existingEventType = await this.eventTypesRepository.getEventTypeById(eventTypeId);
if (!existingEventType) {
throw new NotFoundException(`Event type with ID=${eventTypeId} does not exist.`);
}
this.checkUserOwnsEventType(userId, existingEventType);
return this.eventTypesRepository.deleteEventType(eventTypeId);
}
checkUserOwnsEventType(userId: number, eventType: Pick<EventType, "id" | "userId">) {
if (userId !== eventType.userId) {
throw new ForbiddenException(`User with ID=${userId} does not own event type with ID=${eventType.id}`);
}
}
}
@@ -32,6 +32,7 @@ describe("Platform Gcal Endpoints", () => {
let gcalCredentials: Credential;
let accessTokenSecret: string;
let refreshTokenSecret: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
@@ -82,16 +83,16 @@ describe("Platform Gcal Endpoints", () => {
expect(user).toBeDefined();
});
it(`/GET/platform/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
it(`/GET/ee/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/platform/gcal/oauth/auth-url`)
.get(`/api/v2/ee/gcal/oauth/auth-url`)
.set("Authorization", `Bearer invalid_access_token`)
.expect(401);
});
it(`/GET/platform/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
it(`/GET/ee/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/platform/gcal/oauth/auth-url`)
.get(`/api/v2/ee/gcal/oauth/auth-url`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
@@ -99,50 +100,50 @@ describe("Platform Gcal Endpoints", () => {
expect(data.authUrl).toBeDefined();
});
it(`/GET/platform/gcal/oauth/save: without oauth code`, async () => {
it(`/GET/ee/gcal/oauth/save: without oauth code`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/platform/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
`/api/v2/ee/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
});
it(`/GET/platform/gcal/oauth/save: without access token`, async () => {
it(`/GET/ee/gcal/oauth/save: without access token`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/platform/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
`/api/v2/ee/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
});
it(`/GET/platform/gcal/oauth/save: without origin`, async () => {
it(`/GET/ee/gcal/oauth/save: without origin`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/platform/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
`/api/v2/ee/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
});
it(`/GET/platform/gcal/check with access token but without origin`, async () => {
it(`/GET/ee/gcal/check with access token but without origin`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/platform/gcal/check`)
.get(`/api/v2/ee/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.expect(400);
});
it(`/GET/platform/gcal/check without access token`, async () => {
await request(app.getHttpServer()).get(`/api/v2/platform/gcal/check`).expect(401);
it(`/GET/ee/gcal/check without access token`, async () => {
await request(app.getHttpServer()).get(`/api/v2/ee/gcal/check`).expect(401);
});
it(`/GET/platform/gcal/check with access token and origin but no credentials`, async () => {
it(`/GET/ee/gcal/check with access token and origin but no credentials`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/platform/gcal/check`)
.get(`/api/v2/ee/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(400);
});
it(`/GET/platform/gcal/check with access token and origin and gcal credentials`, async () => {
it(`/GET/ee/gcal/check with access token and origin and gcal credentials`, async () => {
gcalCredentials = await credentialsRepositoryFixture.create(
"google_calendar",
{},
@@ -150,7 +151,7 @@ describe("Platform Gcal Endpoints", () => {
"google-calendar"
);
await request(app.getHttpServer())
.get(`/api/v2/platform/gcal/check`)
.get(`/api/v2/ee/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
+4 -6
View File
@@ -1,5 +1,4 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { GcalService } from "@/modules/apps/services/gcal.service";
import { GCalService } from "@/modules/apps/services/gcal.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
@@ -40,22 +39,21 @@ const CALENDAR_SCOPES = [
];
@Controller({
path: "platform/gcal",
path: "ee/gcal",
version: "2",
})
export class GcalController {
private readonly logger = new Logger("Platform Gcal Provider");
constructor(
private readonly appRepository: AppsRepository,
private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly config: ConfigService,
private readonly gcalService: GcalService
private readonly gcalService: GCalService
) {}
private redirectUri = `${this.config.get("api.url")}/platform/gcal/oauth/save`;
private redirectUri = `${this.config.get("api.url")}/ee/gcal/oauth/save`;
@Get("/oauth/auth-url")
@HttpCode(HttpStatus.OK)
+2 -2
View File
@@ -1,6 +1,6 @@
import { GcalController } from "@/ee/gcal/gcal.controller";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { GcalService } from "@/modules/apps/services/gcal.service";
import { GCalService } from "@/modules/apps/services/gcal.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -11,7 +11,7 @@ import { ConfigService } from "@nestjs/config";
@Module({
imports: [PrismaModule, TokensModule, OAuthClientModule],
providers: [AppsRepository, ConfigService, CredentialsRepository, SelectedCalendarsRepository, GcalService],
providers: [AppsRepository, ConfigService, CredentialsRepository, SelectedCalendarsRepository, GCalService],
controllers: [GcalController],
})
export class GcalModule {}
@@ -65,7 +65,7 @@ describe("Me Endpoints", () => {
it("should get user associated with access token", async () => {
return request(app.getHttpServer())
.get("/api/v2/me")
.get("/api/v2/ee/me")
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<UserResponse> = response.body;
@@ -84,7 +84,7 @@ describe("Me Endpoints", () => {
const body: UpdateManagedPlatformUserInput = { timeZone: "Europe/Rome" };
return request(app.getHttpServer())
.patch("/api/v2/me")
.patch("/api/v2/ee/me")
.send(body)
.expect(200)
.then(async (response) => {
@@ -108,19 +108,22 @@ describe("Me Endpoints", () => {
it("should not update user associated with access token given invalid timezone", async () => {
const bodyWithIncorrectTimeZone: UpdateManagedPlatformUserInput = { timeZone: "Narnia/Woods" };
return request(app.getHttpServer()).patch("/api/v2/me").send(bodyWithIncorrectTimeZone).expect(400);
return request(app.getHttpServer()).patch("/api/v2/ee/me").send(bodyWithIncorrectTimeZone).expect(400);
});
it("should not update user associated with access token given invalid time format", async () => {
const bodyWithIncorrectTimeFormat: UpdateManagedPlatformUserInput = { timeFormat: 100 };
return request(app.getHttpServer()).patch("/api/v2/me").send(bodyWithIncorrectTimeFormat).expect(400);
return request(app.getHttpServer())
.patch("/api/v2/ee/me")
.send(bodyWithIncorrectTimeFormat)
.expect(400);
});
it("should not update user associated with access token given invalid week start", async () => {
const bodyWithIncorrectWeekStart: UpdateManagedPlatformUserInput = { weekStart: "waba luba dub dub" };
return request(app.getHttpServer()).patch("/api/v2/me").send(bodyWithIncorrectWeekStart).expect(400);
return request(app.getHttpServer()).patch("/api/v2/ee/me").send(bodyWithIncorrectWeekStart).expect(400);
});
afterAll(async () => {
+1 -1
View File
@@ -12,7 +12,7 @@ import { UserResponse, userSchemaResponse } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "me",
path: "ee/me",
version: "2",
})
@UseGuards(AccessTokenGuard, PermissionsGuard)
@@ -1,7 +1,6 @@
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
BadRequestException,
@@ -9,7 +8,6 @@ import {
Get,
HttpCode,
HttpStatus,
Logger,
NotFoundException,
Param,
UnauthorizedException,
@@ -20,16 +18,11 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "platform/provider",
path: "ee/provider",
version: "2",
})
export class CalProviderController {
private readonly logger = new Logger("Platform Provider Controller");
constructor(
private readonly tokensRepository: TokensRepository,
private readonly oauthClientRepository: OAuthClientRepository
) {}
constructor(private readonly oauthClientRepository: OAuthClientRepository) {}
@Get("/:clientId")
@HttpCode(HttpStatus.OK)
@@ -21,7 +21,6 @@ import {
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ScheduleWithAvailabilitiesForWeb } from "@calcom/platform-libraries";
import type { CityTimezones } from "@calcom/platform-libraries";
import { updateScheduleHandler } from "@calcom/platform-libraries";
import type { UpdateScheduleOutputType } from "@calcom/platform-libraries";
import { ApiSuccessResponse, ScheduleResponse, UpdateScheduleInput } from "@calcom/platform-types";
@@ -73,16 +72,6 @@ export class SchedulesController {
};
}
@Get("/time-zones")
async getTimeZones(): Promise<ApiResponse<CityTimezones>> {
const timeZones = await this.schedulesService.getSchedulePossibleTimeZones();
return {
status: SUCCESS_STATUS,
data: timeZones,
};
}
@Get("/:scheduleId")
@Permissions([SCHEDULE_READ])
async getSchedule(
@@ -6,8 +6,6 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { Schedule } from "@prisma/client";
import { cityTimezonesHandler } from "@calcom/platform-libraries";
@Injectable()
export class SchedulesService {
constructor(
@@ -96,8 +94,4 @@ export class SchedulesService {
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`);
}
}
async getSchedulePossibleTimeZones() {
return cityTimezonesHandler();
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ export type Environment = {
NEXTAUTH_SECRET: string;
DATABASE_URL: string;
JWT_SECRET: string;
SENTRY_DNS: string;
SENTRY_DSN: string;
LOG_LEVEL: keyof typeof logLevels;
REDIS_URL: string;
};
@@ -1,4 +1,5 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common";
import { Request } from "express";
import { ERROR_STATUS } from "@calcom/platform-constants";
import { Response } from "@calcom/platform-types";
@@ -10,10 +11,14 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
const request = ctx.getRequest<Request>();
const statusCode = exception.getStatus();
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
});
response.status(statusCode).json({
status: ERROR_STATUS,
@@ -7,6 +7,7 @@ import {
PrismaClientUnknownRequestError,
PrismaClientValidationError,
} from "@prisma/client/runtime/library";
import { Request } from "express";
import { ERROR_STATUS, INTERNAL_SERVER_ERROR } from "@calcom/platform-constants";
import { Response } from "@calcom/platform-types";
@@ -31,9 +32,13 @@ export class PrismaExceptionFilter implements ExceptionFilter {
catch(error: PrismaError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
const request = ctx.getRequest<Request>();
this.logger.error(`PrismaError: ${error.message}`, {
error,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
});
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
status: ERROR_STATUS,
@@ -1,6 +1,7 @@
import { ArgumentsHost, Catch, Logger, HttpStatus } from "@nestjs/common";
import { BaseExceptionFilter } from "@nestjs/core";
import * as Sentry from "@sentry/node";
import { Request } from "express";
import { ERROR_STATUS, INTERNAL_SERVER_ERROR } from "@calcom/platform-constants";
import { Response } from "@calcom/platform-types";
@@ -12,10 +13,14 @@ export class SentryFilter extends BaseExceptionFilter {
handleUnknownError(exception: any, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
const request = ctx.getRequest<Request>();
this.logger.error(`Sentry Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
});
// capture if client has been init
@@ -1,4 +1,5 @@
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import { Request } from "express";
import { ERROR_STATUS } from "@calcom/platform-constants";
import { TRPCError } from "@calcom/platform-libraries";
@@ -11,7 +12,8 @@ export class TRPCExceptionFilter implements ExceptionFilter {
catch(exception: TRPCError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
const request = ctx.getRequest<Request>();
let statusCode = 500;
switch (exception.code) {
case "UNAUTHORIZED":
@@ -38,9 +40,15 @@ export class TRPCExceptionFilter implements ExceptionFilter {
statusCode = 500;
break;
}
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
});
response.status(statusCode).json({
status: ERROR_STATUS,
timestamp: new Date().toISOString(),
@@ -1,5 +1,6 @@
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch, HttpStatus, Logger } from "@nestjs/common";
import { Request } from "express";
import { ZodError } from "zod";
import { BAD_REQUEST, ERROR_STATUS } from "@calcom/platform-constants";
@@ -12,10 +13,16 @@ export class ZodExceptionFilter implements ExceptionFilter {
catch(error: ZodError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest();
const request = ctx.getRequest<Request>();
this.logger.error(`ZodError: ${error.message}`, {
error,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
});
response.status(HttpStatus.BAD_REQUEST).json({
status: ERROR_STATUS,
timestamp: new Date().toISOString(),
@@ -1,6 +1,6 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export const ForAtom = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
export const ForAtom = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.query.for === "atom";
});
@@ -1,4 +1,11 @@
import { UserWithProfile } from "@/modules/users/users.repository";
export class BaseStrategy {
success!: (user: unknown) => void;
error!: (error: Error) => void;
}
export class NextAuthPassportStrategy {
success!: (user: UserWithProfile) => void;
error!: (error: Error) => void;
}
@@ -4,9 +4,11 @@ import { google } from "googleapis";
import { z } from "zod";
@Injectable()
export class GcalService {
export class GCalService {
private logger = new Logger("GcalService");
private gcalResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
constructor(private readonly appsRepository: AppsRepository) {}
async getOAuthClient(redirectUri: string) {
@@ -17,9 +19,7 @@ export class GcalService {
throw new NotFoundException();
}
const { client_id, client_secret } = z
.object({ client_id: z.string(), client_secret: z.string() })
.parse(app.keys);
const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys);
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
return oAuth2Client;
@@ -38,7 +38,7 @@ export class OrganizationRolesGuard implements CanActivate {
async isPlatform(organizationId: number) {
const isPlatform = await this.organizationsService.isPlatform(organizationId);
if (!isPlatform) {
throw new ForbiddenException("Organization is not a platform.");
throw new ForbiddenException("Organization is not a platform (SHP).");
}
}
@@ -1,4 +1,4 @@
import { BaseStrategy } from "@/lib/passport/strategies/types";
import { NextAuthPassportStrategy } from "@/lib/passport/strategies/types";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@@ -7,7 +7,7 @@ import type { Request } from "express";
import { getToken } from "next-auth/jwt";
@Injectable()
export class NextAuthStrategy extends PassportStrategy(BaseStrategy, "next-auth") {
export class NextAuthStrategy extends PassportStrategy(NextAuthPassportStrategy, "next-auth") {
constructor(private readonly userRepository: UsersRepository, private readonly config: ConfigService) {
super();
}
@@ -25,7 +25,7 @@ export class NextAuthStrategy extends PassportStrategy(BaseStrategy, "next-auth"
throw new UnauthorizedException("Email not found in the authentication token.");
}
const user = await this.userRepository.findByEmail(payload.email);
const user = await this.userRepository.findByEmailWithProfile(payload.email);
if (!user) {
throw new UnauthorizedException("User associated with the authentication token email not found.");
}
+2 -1
View File
@@ -1,11 +1,12 @@
import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module";
import { EventsModule } from "@/modules/events/events.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@Module({
imports: [EventsModule, OAuthClientModule, PlatformEndpointsModule],
imports: [EventsModule, OAuthClientModule, PlatformEndpointsModule, TimezoneModule],
})
export class EndpointsModule implements NestModule {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -22,7 +22,10 @@ export class EventsController {
queryParams.eventSlug,
queryParams.isTeamEvent,
queryParams.org || null,
this.prismaReadService.prisma as unknown as PrismaClient
this.prismaReadService.prisma as unknown as PrismaClient,
// We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license
// If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
true
);
return {
data: event,
@@ -179,6 +179,21 @@ describe("OAuth Client Users Endpoints", () => {
).toBeTruthy();
}
it(`/GET: return list of managed users`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0`)
.set("x-cal-secret-key", oAuthClient.secret)
.set("Origin", `${CLIENT_REDIRECT_URI}`)
.expect(200);
const responseBody: ApiSuccessResponse<UserReturned[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toBeGreaterThan(0);
expect(responseBody.data[0].email).toEqual(postResponseData.user.email);
});
it(`/GET/:id`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
@@ -20,11 +20,12 @@ import {
Patch,
BadRequestException,
Delete,
Query,
} from "@nestjs/common";
import { User } from "@prisma/client";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types";
import { ApiResponse, Pagination } from "@calcom/platform-types";
@Controller({
path: "oauth-clients/:clientId/users",
@@ -39,6 +40,27 @@ export class OAuthClientUsersController {
private readonly oauthRepository: OAuthClientRepository
) {}
@Get("/")
@UseGuards(OAuthClientCredentialsGuard)
async getManagedUsers(
@Param("clientId") oAuthClientId: string,
@Query() queryParams: Pagination
): Promise<ApiResponse<User[]>> {
this.logger.log(`getting managed users with data for OAuth Client with ID ${oAuthClientId}`);
const { offset, limit } = queryParams;
const existingUsers = await this.userRepository.findManagedUsersByOAuthClientId(
oAuthClientId,
offset ?? 0,
limit ?? 50
);
return {
status: SUCCESS_STATUS,
data: existingUsers,
};
}
@Post("/")
@UseGuards(OAuthClientCredentialsGuard)
async createUser(
@@ -8,6 +8,7 @@ import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/o
import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto";
import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Body,
Controller,
@@ -57,9 +58,10 @@ export class OAuthClientsController {
type: CreateOAuthClientResponseDto,
})
async createOAuthClient(
@GetUser("organizationId") organizationId: number,
@GetUser() user: UserWithProfile,
@Body() body: CreateOAuthClientInput
): Promise<CreateOAuthClientResponseDto> {
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
this.logger.log(
`For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}`
);
@@ -77,9 +79,9 @@ export class OAuthClientsController {
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClients(
@GetUser("organizationId") organizationId: number
): Promise<GetOAuthClientsResponseDto> {
async getOAuthClients(@GetUser() user: UserWithProfile): Promise<GetOAuthClientsResponseDto> {
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
const clients = await this.oauthClientRepository.getOrganizationOAuthClients(organizationId);
return { status: SUCCESS_STATUS, data: clients };
}
@@ -2,7 +2,7 @@ import { EventTypesService } from "@/ee/event-types/services/event-types.service
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { CreateManagedPlatformUserInput } from "@/modules/users/inputs/create-managed-platform-user.input";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { BadRequestException, Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import * as crypto from "crypto";
@@ -24,24 +24,24 @@ export class OAuthClientUsersService {
) {
let user: User;
if (!organizationId) {
const username = generateShortHash(body.email, oAuthClientId);
user = await this.userRepository.create(body, username, oAuthClientId, isPlatformManaged);
throw new BadRequestException("You cannot create a managed user outside of an organization");
} else {
const [_, emailDomain] = body.email.split("@");
const [username, emailDomain] = body.email.split("@");
const email = `${username}+${oAuthClientId}@${emailDomain}`;
user = (
await createNewUsersConnectToOrgIfExists({
usernamesOrEmails: [body.email],
usernamesOrEmails: [email],
input: {
teamId: organizationId,
role: "MEMBER",
usernameOrEmail: [body.email],
usernameOrEmail: [email],
isOrg: true,
language: "en",
},
parentId: null,
autoAcceptEmailDomain: emailDomain,
autoAcceptEmailDomain: "never-auto-accept-email-domain-for-managed-users",
connectionInfoMap: {
[body.email]: {
[email]: {
orgId: organizationId,
autoAccept: true,
},
@@ -1,20 +1,18 @@
import { TokenExpiredException } from "@/modules/auth/guards/access-token/token-expired.exception";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { BadRequestException, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
@Injectable()
export class OAuthFlowService {
private logger = new Logger("OAuthFlowService");
constructor(
private readonly tokensRepository: TokensRepository,
private readonly oAuthClientRepository: OAuthClientRepository
) //private readonly redisService: RedisIOService
{}
private readonly oAuthClientRepository: OAuthClientRepository //private readonly redisService: RedisIOService
) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async propagateAccessToken(accessToken: string) {
// this.logger.log("Propagating access token to redis", accessToken);
// TODO propagate
@@ -48,6 +46,7 @@ export class OAuthFlowService {
return true;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
private async readFromCache(secret: string) {
return { status: "CACHE_MISS" };
}
@@ -7,7 +7,7 @@ import { PrismaClient } from "@prisma/client";
export class PrismaReadService implements OnModuleInit {
public prisma: PrismaClient;
constructor(private readonly configService: ConfigService) {
constructor(readonly configService: ConfigService) {
const dbUrl = configService.get("db.readUrl", { infer: true });
this.prisma = new PrismaClient({
@@ -7,7 +7,7 @@ import { PrismaClient } from "@prisma/client";
export class PrismaWriteService implements OnModuleInit {
public prisma: PrismaClient;
constructor(private readonly configService: ConfigService) {
constructor(readonly configService: ConfigService) {
const dbUrl = configService.get("db.writeUrl", { infer: true });
this.prisma = new PrismaClient({
@@ -1,9 +1,10 @@
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [],
imports: [PrismaModule],
providers: [SelectedCalendarsRepository],
exports: [SelectedCalendarsRepository],
})
export class CredentialsModule {}
export class SelectedCalendarsModule {}
@@ -16,4 +16,12 @@ export class SelectedCalendarsRepository {
},
});
}
getUserSelectedCalendars(userId: number) {
return this.dbRead.prisma.selectedCalendar.findMany({
where: {
userId,
},
});
}
}
@@ -0,0 +1,24 @@
import { TimezonesService } from "@/modules/timezones/services/timezones.service";
import { Controller, Get } from "@nestjs/common";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { CityTimezones } from "@calcom/platform-libraries";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "timezones",
version: "2",
})
export class TimezonesController {
constructor(private readonly timezonesService: TimezonesService) {}
@Get("/")
async getTimeZones(): Promise<ApiResponse<CityTimezones>> {
const timeZones = await this.timezonesService.getCityTimeZones();
return {
status: SUCCESS_STATUS,
data: timeZones,
};
}
}
@@ -0,0 +1,10 @@
import { Injectable } from "@nestjs/common";
import { cityTimezonesHandler } from "@calcom/platform-libraries";
@Injectable()
export class TimezonesService {
async getCityTimeZones() {
return cityTimezonesHandler();
}
}
@@ -0,0 +1,11 @@
import { TimezonesController } from "@/modules/timezones/controllers/timezones.controller";
import { TimezonesService } from "@/modules/timezones/services/timezones.service";
import { Module } from "@nestjs/common";
@Module({
imports: [],
providers: [TimezonesService],
controllers: [TimezonesController],
exports: [TimezonesService],
})
export class TimezoneModule {}
@@ -48,7 +48,7 @@ export class TokensRepository {
}
async createOAuthTokens(clientId: string, ownerId: number) {
const accessExpiry = DateTime.now().plus({ minute: 1 }).startOf("minute").toJSDate();
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
this.dbWrite.prisma.accessToken.create({
@@ -101,7 +101,7 @@ export class TokensRepository {
}
async refreshOAuthTokens(clientId: string, refreshTokenSecret: string, tokenUserId: number) {
const accessExpiry = DateTime.now().plus({ minute: 1 }).startOf("minute").toJSDate();
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -21,7 +21,7 @@ export class UsersRepository {
) {
this.formatInput(user);
return this.dbRead.prisma.user.create({
return this.dbWrite.prisma.user.create({
data: {
...user,
username,
@@ -34,7 +34,7 @@ export class UsersRepository {
}
async addToOAuthClient(userId: number, oAuthClientId: string) {
return this.dbRead.prisma.user.update({
return this.dbWrite.prisma.user.update({
data: {
platformOAuthClients: {
connect: { id: oAuthClientId },
@@ -83,6 +83,17 @@ export class UsersRepository {
});
}
async findByEmailWithProfile(email: string) {
return this.dbRead.prisma.user.findUnique({
where: {
email,
},
include: {
movedToProfile: true,
},
});
}
async findByUsername(username: string) {
return this.dbRead.prisma.user.findFirst({
where: {
@@ -91,6 +102,21 @@ export class UsersRepository {
});
}
async findManagedUsersByOAuthClientId(oauthClientId: string, cursor: number, limit: number) {
return this.dbRead.prisma.user.findMany({
where: {
platformOAuthClients: {
some: {
id: oauthClientId,
},
},
isPlatformManaged: true,
},
take: limit,
skip: cursor,
});
}
async update(userId: number, updateData: UpdateManagedPlatformUserInput) {
this.formatInput(updateData);
+607 -44
View File
@@ -22,7 +22,40 @@
"/api/v2/events/public": {
"get": {
"operationId": "EventsController_getPublicEvent",
"parameters": [],
"parameters": [
{
"name": "username",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "eventSlug",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "isTeamEvent",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "org",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
@@ -38,6 +71,31 @@
}
},
"/api/v2/oauth-clients/{clientId}/users": {
"get": {
"operationId": "OAuthClientUsersController_getManagedUsers",
"parameters": [
{
"name": "clientId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"post": {
"operationId": "OAuthClientUsersController_createUser",
"parameters": [
@@ -55,7 +113,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateUserInput"
"$ref": "#/components/schemas/CreateManagedPlatformUserInput"
}
}
}
@@ -133,7 +191,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUserInput"
"$ref": "#/components/schemas/UpdateManagedPlatformUserInput"
}
}
}
@@ -500,12 +558,15 @@
"content": {
"application/json": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/CreateEventTypeOutput"
}
}
}
}
}
},
"tags": [
"Event types"
]
},
"get": {
"operationId": "EventTypesController_getEventTypes",
@@ -516,12 +577,15 @@
"content": {
"application/json": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/GetEventTypesOutput"
}
}
}
}
}
},
"tags": [
"Event types"
]
}
},
"/api/v2/event-types/{eventTypeId}": {
@@ -543,12 +607,81 @@
"content": {
"application/json": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/GetEventTypeOutput"
}
}
}
}
}
},
"tags": [
"Event types"
]
},
"patch": {
"operationId": "EventTypesController_updateEventType",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateEventTypeInput"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateEventTypeOutput"
}
}
}
}
},
"tags": [
"Event types"
]
},
"delete": {
"operationId": "EventTypesController_deleteEventType",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteEventTypeOutput"
}
}
}
}
},
"tags": [
"Event types"
]
}
},
"/api/v2/event-types/{username}/public": {
@@ -570,15 +703,18 @@
"content": {
"application/json": {
"schema": {
"type": "object"
"$ref": "#/components/schemas/GetEventTypesPublicOutput"
}
}
}
}
}
},
"tags": [
"Event types"
]
}
},
"/api/v2/platform/gcal/oauth/auth-url": {
"/api/v2/ee/gcal/oauth/auth-url": {
"get": {
"operationId": "GcalController_redirect",
"parameters": [
@@ -605,7 +741,7 @@
}
}
},
"/api/v2/platform/gcal/oauth/save": {
"/api/v2/ee/gcal/oauth/save": {
"get": {
"operationId": "GcalController_save",
"parameters": [
@@ -640,7 +776,7 @@
}
}
},
"/api/v2/platform/gcal/check": {
"/api/v2/ee/gcal/check": {
"get": {
"operationId": "GcalController_check",
"parameters": [],
@@ -658,7 +794,7 @@
}
}
},
"/api/v2/platform/provider/{clientId}": {
"/api/v2/ee/provider/{clientId}": {
"get": {
"operationId": "CalProviderController_verifyClientId",
"parameters": [
@@ -685,7 +821,7 @@
}
}
},
"/api/v2/platform/provider/{clientId}/access-token": {
"/api/v2/ee/provider/{clientId}/access-token": {
"get": {
"operationId": "CalProviderController_verifyAccessToken",
"parameters": [
@@ -774,24 +910,6 @@
}
}
},
"/api/v2/schedules/time-zones": {
"get": {
"operationId": "SchedulesController_getTimeZones",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
}
},
"/api/v2/schedules/{scheduleId}": {
"get": {
"operationId": "SchedulesController_getSchedule",
@@ -870,7 +988,7 @@
}
}
},
"/api/v2/me": {
"/api/v2/ee/me": {
"get": {
"operationId": "MeController_getMe",
"parameters": [],
@@ -895,7 +1013,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUserInput"
"$ref": "#/components/schemas/UpdateManagedPlatformUserInput"
}
}
}
@@ -951,6 +1069,22 @@
}
},
"/api/v2/ee/bookings": {
"get": {
"operationId": "BookingsController_getBookings",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"post": {
"operationId": "BookingsController_createBooking",
"parameters": [],
@@ -978,6 +1112,97 @@
}
}
},
"/api/v2/ee/bookings/{bookingUid}": {
"get": {
"operationId": "BookingsController_getBooking",
"parameters": [
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
}
},
"/api/v2/ee/bookings/{bookingUid}/reschedule": {
"get": {
"operationId": "BookingsController_getBookingForReschedule",
"parameters": [
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
}
},
"/api/v2/ee/bookings/{bookingId}/cancel": {
"post": {
"operationId": "BookingsController_cancelBooking",
"parameters": [
{
"name": "bookingId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CancelBookingInput"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
}
},
"/api/v2/ee/bookings/reccuring": {
"post": {
"operationId": "BookingsController_createReccuringBooking",
@@ -1100,6 +1325,24 @@
}
}
}
},
"/api/v2/timezones": {
"get": {
"operationId": "TimezonesController_getTimeZones",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
}
}
},
"info": {
@@ -1112,7 +1355,7 @@
"servers": [],
"components": {
"schemas": {
"CreateUserInput": {
"CreateManagedPlatformUserInput": {
"type": "object",
"properties": {
"email": {
@@ -1124,9 +1367,6 @@
"timeFormat": {
"type": "number"
},
"defaultScheduleId": {
"type": "number"
},
"weekStart": {
"type": "string"
},
@@ -1138,7 +1378,7 @@
"email"
]
},
"UpdateUserInput": {
"UpdateManagedPlatformUserInput": {
"type": "object",
"properties": {
"email": {
@@ -1187,11 +1427,11 @@
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
],
"example": "success"
},
"data": {
"example": {
@@ -1230,7 +1470,8 @@
"example": 3
},
"logo": {
"type": "object",
"type": "string",
"nullable": true,
"example": "https://example.com/logo.png"
},
"redirectUris": {
@@ -1393,7 +1634,251 @@
"refreshToken"
]
},
"EventTypeLocation": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "link"
},
"link": {
"type": "string",
"example": "https://masterchief.com/argentina/flan/video/9129412"
}
},
"required": [
"type"
]
},
"CreateEventTypeInput": {
"type": "object",
"properties": {
"length": {
"type": "number",
"minimum": 1,
"example": 60
},
"slug": {
"type": "string",
"example": "cooking-class"
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
},
"description": {
"type": "string",
"example": "Discover the culinary wonders of the Argentina by making the best flan ever!"
},
"locations": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EventTypeLocation"
}
}
},
"required": [
"length",
"slug",
"title"
]
},
"EventTypeOutput": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1
},
"length": {
"type": "number",
"example": 60
},
"slug": {
"type": "string",
"example": "cooking-class"
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
},
"description": {
"type": "string",
"nullable": true,
"example": "Discover the culinary wonders of the Argentina by making the best flan ever!"
},
"locations": {
"nullable": true,
"allOf": [
{
"$ref": "#/components/schemas/EventTypeLocation"
}
]
}
},
"required": [
"id",
"length",
"slug",
"title",
"description",
"locations"
]
},
"CreateEventTypeOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/EventTypeOutput"
}
},
"required": [
"status",
"data"
]
},
"Data": {
"type": "object",
"properties": {
"eventType": {
"$ref": "#/components/schemas/EventTypeOutput"
}
},
"required": [
"eventType"
]
},
"GetEventTypeOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/Data"
}
},
"required": [
"status",
"data"
]
},
"EventTypeGroup": {
"type": "object",
"properties": {
"eventTypes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EventTypeOutput"
}
}
},
"required": [
"eventTypes"
]
},
"GetEventTypesData": {
"type": "object",
"properties": {
"eventTypeGroups": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EventTypeGroup"
}
}
},
"required": [
"eventTypeGroups"
]
},
"GetEventTypesOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/GetEventTypesData"
}
},
"required": [
"status",
"data"
]
},
"PublicEventType": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1
},
"length": {
"type": "number",
"example": 60
},
"slug": {
"type": "string",
"example": "cooking-class"
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
},
"description": {
"type": "string",
"nullable": true
}
},
"required": [
"id",
"length",
"slug",
"title"
]
},
"GetEventTypesPublicOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PublicEventType"
}
}
},
"required": [
"status",
"data"
]
},
"UpdateEventTypeInput": {
"type": "object",
"properties": {
"length": {
@@ -1405,14 +1890,88 @@
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"hidden": {
"type": "boolean"
},
"locations": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EventTypeLocation"
}
}
}
},
"UpdateEventTypeOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/EventTypeOutput"
}
},
"required": [
"status",
"data"
]
},
"DeleteData": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1
},
"length": {
"type": "number",
"example": 60
},
"slug": {
"type": "string",
"example": "cooking-class"
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
}
},
"required": [
"id",
"length",
"slug",
"title"
]
},
"DeleteEventTypeOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/DeleteData"
}
},
"required": [
"status",
"data"
]
},
"CreateAvailabilityInput": {
"type": "object",
"properties": {
@@ -1526,6 +2085,10 @@
"hashedLink"
]
},
"CancelBookingInput": {
"type": "object",
"properties": {}
},
"ReserveSlotInput": {
"type": "object",
"properties": {}
@@ -0,0 +1,26 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Booking, User } from "@prisma/client";
export class BookingsRepositoryFixture {
private primaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.primaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async getById(bookingId: Booking["id"]) {
return this.primaReadClient.booking.findFirst({ where: { id: bookingId } });
}
async deleteById(bookingId: Booking["id"]) {
return this.prismaWriteClient.booking.delete({ where: { id: bookingId } });
}
async deleteAllBookings(userId: User["id"], userEmail: User["email"]) {
return this.prismaWriteClient.booking.deleteMany({ where: { userId, userPrimaryEmail: userEmail } });
}
}
@@ -21,7 +21,7 @@ export class EventTypesRepositoryFixture {
});
}
async create(data: CreateEventTypeInput, userId: number) {
async create(data: Pick<CreateEventTypeInput, "title" | "slug" | "length" | "hidden">, userId: number) {
return this.prismaWriteClient.eventType.create({
data: {
...data,
@@ -1,16 +1,16 @@
import { BaseStrategy } from "@/lib/passport/strategies/types";
import { NextAuthPassportStrategy } from "@/lib/passport/strategies/types";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
@Injectable()
export class NextAuthMockStrategy extends PassportStrategy(BaseStrategy, "next-auth") {
export class NextAuthMockStrategy extends PassportStrategy(NextAuthPassportStrategy, "next-auth") {
constructor(private readonly email: string, private readonly userRepository: UsersRepository) {
super();
}
async authenticate() {
try {
const user = await this.userRepository.findByEmail(this.email);
const user = await this.userRepository.findByEmailWithProfile(this.email);
if (!user) {
throw new Error("User with the provided email not found");
}
+2 -1
View File
@@ -9,7 +9,8 @@ const env: Partial<Omit<Environment, "NODE_ENV">> = {
LOG_LEVEL: "trace",
REDIS_URL: "redis://localhost:9199",
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
process.env = {
...env,
...process.env,
+1
View File
@@ -0,0 +1 @@
## Cal.com Platform Example (https://platform.cal.com)
@@ -1,11 +0,0 @@
import LegacyPage, { WrappedSetPasswordPage } from "@pages/settings/organizations/[id]/set-password";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("set_a_password"),
(t) => t("set_a_password_description")
);
export default WithLayout({ Page: LegacyPage, getLayout: WrappedSetPasswordPage });
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { X } from "@calcom/ui/components/icon";
import { Icon } from "@calcom/ui";
export default function AddToHomescreen() {
const { t } = useLocale();
@@ -40,7 +40,7 @@ export default function AddToHomescreen() {
type="button"
className="-mr-1 flex rounded-md p-2 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white">
<span className="sr-only">{t("dismiss")}</span>
<X className="text-inverted h-6 w-6 dark:text-white" aria-hidden="true" />
<Icon name="x" className="text-inverted h-6 w-6 dark:text-white" aria-hidden="true" />
</button>
</div>
</div>
+2 -3
View File
@@ -9,8 +9,7 @@ import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { Badge, ListItemText, Avatar } from "@calcom/ui";
import { AlertCircle } from "@calcom/ui/components/icon";
import { Avatar, Badge, Icon, ListItemText } from "@calcom/ui";
type ShouldHighlight =
| {
@@ -105,7 +104,7 @@ export default function AppListCard(props: AppListCardProps) {
<ListItemText component="p">{description}</ListItemText>
{invalidCredential && (
<div className="flex gap-x-2 pt-2">
<AlertCircle className="h-8 w-8 text-red-500 sm:h-4 sm:w-4" />
<Icon name="circle-alert" className="h-8 w-8 text-red-500 sm:h-4 sm:w-4" />
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
{t("invalid_credential")}
</ListItemText>
+7 -8
View File
@@ -3,40 +3,39 @@
import { ShellMain } from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, ButtonGroup } from "@calcom/ui";
import { BarChart, CreditCard, Globe, Lock, Paintbrush, Users } from "@calcom/ui/components/icon";
import { Button, ButtonGroup, Icon } from "@calcom/ui";
export default function EnterprisePage() {
const { t } = useLocale();
const features = [
{
icon: <Globe className="h-5 w-5 text-red-500" />,
icon: <Icon name="globe" className="h-5 w-5 text-red-500" />,
title: t("branded_subdomain"),
description: t("branded_subdomain_description"),
},
{
icon: <BarChart className="h-5 w-5 text-blue-500" />,
icon: <Icon name="bar-chart" className="h-5 w-5 text-blue-500" />,
title: t("org_insights"),
description: t("org_insights_description"),
},
{
icon: <Paintbrush className="h-5 w-5 text-pink-500" />,
icon: <Icon name="paintbrush" className="h-5 w-5 text-pink-500" />,
title: t("extensive_whitelabeling"),
description: t("extensive_whitelabeling_description"),
},
{
icon: <Users className="h-5 w-5 text-orange-500" />,
icon: <Icon name="users" className="h-5 w-5 text-orange-500" />,
title: t("unlimited_teams"),
description: t("unlimited_teams_description"),
},
{
icon: <CreditCard className="h-5 w-5 text-green-500" />,
icon: <Icon name="credit-card" className="h-5 w-5 text-green-500" />,
title: t("unified_billing"),
description: t("unified_billing_description"),
},
{
icon: <Lock className="h-5 w-5 text-purple-500" />,
icon: <Icon name="lock" className="h-5 w-5 text-purple-500" />,
title: t("advanced_managed_events"),
description: t("advanced_managed_events_description"),
},
@@ -6,12 +6,11 @@ import { trpc } from "@calcom/trpc/react";
import {
Button,
Dropdown,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@calcom/ui";
import { Plus } from "@calcom/ui/components/icon";
import { QueryCell } from "@lib/QueryCell";
@@ -42,7 +41,7 @@ const AdditionalCalendarSelector = ({ isPending }: AdditionalCalendarSelectorPro
return (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button StartIcon={Plus} color="secondary" {...(isPending && { loading: isPending })}>
<Button StartIcon="plus" color="secondary" {...(isPending && { loading: isPending })}>
{t("add")}
</Button>
</DropdownMenuTrigger>
@@ -50,7 +49,7 @@ const AdditionalCalendarSelector = ({ isPending }: AdditionalCalendarSelectorPro
{options.map((data) => (
<DropdownMenuItem key={data.slug} className="focus:outline-none">
{data.slug === "add-new" ? (
<DropdownItem StartIcon={Plus} color="minimal" href="/apps/categories/calendar">
<DropdownItem StartIcon="plus" color="minimal" href="/apps/categories/calendar">
{t("install_new_calendar_app")}
</DropdownItem>
) : (
+6 -7
View File
@@ -11,17 +11,16 @@ import type { AppCategories } from "@calcom/prisma/enums";
import { trpc, type RouterOutputs } from "@calcom/trpc";
import type { App } from "@calcom/types/App";
import {
Alert,
Button,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
List,
showToast,
Button,
DropdownMenuItem,
Alert,
} from "@calcom/ui";
import { MoreHorizontal, Trash, Video } from "@calcom/ui/components/icon";
import AppListCard from "@components/AppListCard";
@@ -82,7 +81,7 @@ export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppL
<div className="flex justify-end">
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" />
<Button StartIcon="ellipsis" variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{!appIsDefault && variant === "conferencing" && !item.credentialOwner?.teamId && (
@@ -90,7 +89,7 @@ export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppL
<DropdownItem
type="button"
color="secondary"
StartIcon={Video}
StartIcon="video"
onClick={() => {
const locationType = getEventLocationTypeFromApp(item?.locationOption?.value ?? "");
if (locationType?.linkType === "static") {
@@ -214,7 +213,7 @@ function ConnectOrDisconnectIntegrationMenuItem(props: {
color="destructive"
onClick={() => handleDisconnect(credentialId, teamId)}
disabled={isGlobal}
StartIcon={Trash}>
StartIcon="trash">
{t("remove_app")}
</DropdownItem>
</DropdownMenuItem>
+9 -10
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import type { IframeHTMLAttributes } from "react";
import React, { useState, useEffect } from "react";
import React, { useEffect, useState } from "react";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/components";
@@ -10,8 +10,7 @@ import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/consta
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { App as AppType } from "@calcom/types/App";
import { Badge, Button, showToast, SkeletonButton, SkeletonText } from "@calcom/ui";
import { BookOpen, Check, ExternalLink, File, Flag, Mail, Shield } from "@calcom/ui/components/icon";
import { Badge, Button, Icon, SkeletonButton, SkeletonText, showToast } from "@calcom/ui";
import { InstallAppButtonChild } from "./InstallAppButtonChild";
@@ -196,7 +195,7 @@ export const AppPage = ({
isGlobal ||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
<div className="flex space-x-3">
<Button StartIcon={Check} color="secondary" disabled>
<Button StartIcon="check" color="secondary" disabled>
{existingCredentials.length > 0
? t("active_install", { count: existingCredentials.length })
: t("default")}
@@ -323,7 +322,7 @@ export const AppPage = ({
rel="noreferrer"
className="text-emphasis text-sm font-normal no-underline hover:underline"
href={docs}>
<BookOpen className="text-subtle -mt-1 mr-1 inline h-4 w-4" />
<Icon name="book-open" className="text-subtle -mt-1 mr-1 inline h-4 w-4" />
{t("documentation")}
</a>
</li>
@@ -335,7 +334,7 @@ export const AppPage = ({
rel="noreferrer"
className="text-emphasis font-normal no-underline hover:underline"
href={website}>
<ExternalLink className="text-subtle -mt-px mr-1 inline h-4 w-4" />
<Icon name="external-link" className="text-subtle -mt-px mr-1 inline h-4 w-4" />
{website.replace("https://", "")}
</a>
</li>
@@ -347,7 +346,7 @@ export const AppPage = ({
rel="noreferrer"
className="text-emphasis font-normal no-underline hover:underline"
href={`mailto:${email}`}>
<Mail className="text-subtle -mt-px mr-1 inline h-4 w-4" />
<Icon name="mail" className="text-subtle -mt-px mr-1 inline h-4 w-4" />
{email}
</a>
@@ -360,7 +359,7 @@ export const AppPage = ({
rel="noreferrer"
className="text-emphasis font-normal no-underline hover:underline"
href={tos}>
<File className="text-subtle -mt-px mr-1 inline h-4 w-4" />
<Icon name="file" className="text-subtle -mt-px mr-1 inline h-4 w-4" />
{t("terms_of_service")}
</a>
</li>
@@ -372,7 +371,7 @@ export const AppPage = ({
rel="noreferrer"
className="text-emphasis font-normal no-underline hover:underline"
href={privacy}>
<Shield className="text-subtle -mt-px mr-1 inline h-4 w-4" />
<Icon name="shield" className="text-subtle -mt-px mr-1 inline h-4 w-4" />
{t("privacy_policy")}
</a>
</li>
@@ -383,7 +382,7 @@ export const AppPage = ({
{t("every_app_published", { appName: APP_NAME, companyName: COMPANY_NAME })}
</span>
<a className="mt-2 block text-xs text-red-500" href={`mailto:${SUPPORT_MAIL_ADDRESS}`}>
<Flag className="inline h-3 w-3" /> {t("report_app")}
<Icon name="flag" className="inline h-3 w-3" /> {t("report_app")}
</a>
</div>
</div>
@@ -11,13 +11,12 @@ import {
Alert,
Button,
EmptyScreen,
List,
AppSkeletonLoader as SkeletonLoader,
ShellSubHeading,
Label,
List,
ShellSubHeading,
AppSkeletonLoader as SkeletonLoader,
showToast,
} from "@calcom/ui";
import { Calendar } from "@calcom/ui/components/icon";
import { QueryCell } from "@lib/QueryCell";
import useRouterQuery from "@lib/hooks/useRouterQuery";
@@ -267,7 +266,7 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
</>
) : (
<EmptyScreen
Icon={Calendar}
Icon="calendar"
headline={t("no_category_apps", {
category: t("calendar").toLowerCase(),
})}

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