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 13183 additions and 3730 deletions

No files matched your search

+10 -2
View File
@@ -169,8 +169,7 @@ STRIPE_WEBHOOK_SECRET=
STRIPE_WEBHOOK_SECRET_APPS= STRIPE_WEBHOOK_SECRET_APPS=
STRIPE_PRIVATE_KEY= STRIPE_PRIVATE_KEY=
STRIPE_CLIENT_ID= STRIPE_CLIENT_ID=
PAYMENT_FEE_FIXED=
PAYMENT_FEE_PERCENTAGE=
# Use for internal Public API Keys and optional # Use for internal Public API Keys and optional
API_KEY_PREFIX=cal_ 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 # Set the following value to true if you wish to enable Team Impersonation
NEXT_PUBLIC_TEAM_IMPERSONATION=false NEXT_PUBLIC_TEAM_IMPERSONATION=false
@@ -346,3 +349,8 @@ NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
# Ratelimiting via unkey # 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 **/**/public
packages/prisma/zod packages/prisma/zod
apps/web/public/embed 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: inputs:
DATABASE_URL: DATABASE_URL:
required: false required: false
default: "postgresql://postgres:@localhost:5432/calendso" default: "postgresql://postgres:postgres@localhost:5432/calendso"
path: path:
required: false required: false
default: "backups/backup.sql" 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 runs-on: buildjet-4vcpu-ubuntu-2204
services: services:
postgres: postgres:
image: postgres:13.13 image: postgres:13
credentials: credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports: ports:
- 5432:5432 - 5432:5432
mailhog: mailhog:
+7 -1
View File
@@ -10,13 +10,19 @@ jobs:
runs-on: buildjet-4vcpu-ubuntu-2204 runs-on: buildjet-4vcpu-ubuntu-2204
services: services:
postgres: postgres:
image: postgres:13.13 image: postgres:13
credentials: credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports: ports:
- 5432:5432 - 5432:5432
strategy: strategy:
+7 -1
View File
@@ -10,13 +10,19 @@ jobs:
runs-on: buildjet-4vcpu-ubuntu-2204 runs-on: buildjet-4vcpu-ubuntu-2204
services: services:
postgres: postgres:
image: postgres:13.13 image: postgres:13
credentials: credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports: ports:
- 5432:5432 - 5432:5432
mailhog: mailhog:
+7 -1
View File
@@ -10,13 +10,19 @@ jobs:
runs-on: buildjet-4vcpu-ubuntu-2204 runs-on: buildjet-4vcpu-ubuntu-2204
services: services:
postgres: postgres:
image: postgres:13.13 image: postgres:13
credentials: credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports: ports:
- 5432:5432 - 5432:5432
mailhog: mailhog:
+8 -1
View File
@@ -56,6 +56,13 @@ jobs:
uses: ./.github/workflows/production-build.yml uses: ./.github/workflows/production-build.yml
secrets: inherit 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: build-without-database:
name: Production build (without database) name: Production build (without database)
needs: [changes] needs: [changes]
@@ -99,7 +106,7 @@ jobs:
secrets: inherit secrets: inherit
required: 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() if: always()
runs-on: buildjet-4vcpu-ubuntu-2204 runs-on: buildjet-4vcpu-ubuntu-2204
steps: steps:
@@ -30,6 +30,8 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }} 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: jobs:
build: build:
+9 -1
View File
@@ -34,6 +34,8 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }} 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: jobs:
build: build:
@@ -42,13 +44,19 @@ jobs:
timeout-minutes: 30 timeout-minutes: 30
services: services:
postgres: postgres:
image: postgres:13.13 image: postgres:13
credentials: credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports: ports:
- 5432:5432 - 5432:5432
steps: steps:
+1
View File
@@ -17,3 +17,4 @@ packages/prisma/zod
packages/prisma/enums packages/prisma/enums
apps/web/public/embed apps/web/public/embed
apps/api/v2/swagger/documentation.json 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. - 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. - We greatly value new feature ideas. To ensure consistency in the product's direction, they undergo review and approval.
## Priorities ## Priorities
<table> <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: 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`: If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`:
```bash ```bash
git checkout HEAD~1 yarn.lock git checkout HEAD~1 yarn.lock
git commit -m "Revert yarn.lock changes" 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`: If you've pushed the commit with the `yarn.lock`:
1. Correct the commit locally using the above method. 1. Correct the commit locally using the above method.
2. Carefully force push: 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**: 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: - Find the commit hash before the `yarn.lock` was unintentionally committed. You can do this by viewing the Git log:
```bash ```bash
git log yarn.lock git log yarn.lock
``` ```
- Once you have identified the commit hash, use it to checkout the previous version of `yarn.lock`: - Once you have identified the commit hash, use it to checkout the previous version of `yarn.lock`:
```bash ```bash
git checkout <commit_hash> yarn.lock git checkout <commit_hash> yarn.lock
``` ```
2. **Commit the Reverted Version**: 2. **Commit the Reverted Version**:
- After checking out the previous version of the `yarn.lock`, commit this change: - After checking out the previous version of the `yarn.lock`, commit this change:
```bash ```bash
git commit -m "Revert yarn.lock to its state before unintended changes" git commit -m "Revert yarn.lock to its state before unintended changes"
``` ```
3. **Proceed with Caution**: 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: - 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 ```bash
git pull origin <your-branch-name> git pull origin <your-branch-name>
``` ```
- Then push the updated branch: - Then push the updated branch:
```bash ```bash
git push origin <your-branch-name> 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. Here is what you need to be able to run Cal.com.
- Node.js (Version: >=18.x) - Node.js (Version: >=18.x)
- PostgreSQL - PostgreSQL (Version: >=13.x)
- Yarn _(recommended)_ - 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). > 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) ![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 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> </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}`); const response = await page.goto(`http://i.cal.com/${users[0].username}+${users[1].username}`);
expect(response?.status()).toBe(200); 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[0].name);
expect(await page.locator('[data-testid="event-meta"]').textContent()).toContain(users[1].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 # Get it in console.cal.com
CALCOM_LICENSE_KEY="" 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 type { NextMiddleware } from "next-api-middleware";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { API_KEY_RATE_LIMIT } from "@calcom/lib/rateLimit";
export const rateLimitApiKey: NextMiddleware = async (req, res, next) => { export const rateLimitApiKey: NextMiddleware = async (req, res, next) => {
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" }); 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, identifier: req.query.apiKey as string,
rateLimitingType: "api", rateLimitingType: "api",
onRateLimiterResponse: (response) => { 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-Remaining", response.remaining);
res.setHeader("X-RateLimit-Reset", response.reset); res.setHeader("X-RateLimit-Reset", response.reset);
}, },
+16
View File
@@ -47,6 +47,22 @@ const nextConfig = {
source: "/v:version/:rest*", source: "/v:version/:rest*",
destination: "/api/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. // This redirects requests to api/v*/ to /api/ passing version as a query parameter.
{ {
source: "/api/v:version/:rest*", source: "/api/v:version/:rest*",
@@ -93,6 +93,7 @@ async function checkPermissions<T extends BaseEventTypeCheckPermissions>(
await canAccessTeamEventOrThrow(req, { await canAccessTeamEventOrThrow(req, {
in: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER], in: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER],
}); });
return true;
} }
if (eventType?.userId === req.userId) return true; // is owner. if (eventType?.userId === req.userId) return true; // is owner.
throw new HttpError({ statusCode: 403, message: "Forbidden" }); throw new HttpError({ statusCode: 403, message: "Forbidden" });
@@ -1,7 +1,7 @@
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import type { NextApiRequest } from "next"; 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 { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server"; import { defaultResponder } from "@calcom/lib/server";
@@ -105,10 +105,11 @@ export async function patchHandler(req: NextApiRequest) {
}; };
delete data.slug; delete data.slug;
if (IS_TEAM_BILLING_ENABLED) { if (IS_TEAM_BILLING_ENABLED) {
const checkoutSession = await purchaseTeamSubscription({ const checkoutSession = await purchaseTeamOrOrgSubscription({
teamId: _team.id, teamId: _team.id,
seats: _team.members.length, seats: _team.members.length,
userId, userId,
pricePerSeat: null,
}); });
if (!checkoutSession.url) if (!checkoutSession.url)
throw new TRPCError({ 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 { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer";
import stripe from "@calcom/app-store/stripepayment/lib/server"; 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 { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error"; import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server"; import { defaultResponder } from "@calcom/lib/server";
@@ -197,8 +198,9 @@ const generateTeamCheckoutSession = async ({
customer_update: { customer_update: {
address: "auto", address: "auto",
}, },
// Disabled when testing locally as usually developer doesn't setup Tax in Stripe Test mode
automatic_tax: { automatic_tax: {
enabled: true, enabled: IS_PRODUCTION,
}, },
metadata: { metadata: {
pendingPaymentTeamId, pendingPaymentTeamId,
+2 -1
View File
@@ -7,7 +7,8 @@
"paths": { "paths": {
"~/*": ["*"], "~/*": ["*"],
"@prisma/client/*": ["@calcom/prisma/client/*"] "@prisma/client/*": ["@calcom/prisma/client/*"]
} },
"experimentalDecorators": true
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",
+1 -1
View File
@@ -7,7 +7,7 @@ LOG_LEVEL=
NEXTAUTH_SECRET= NEXTAUTH_SECRET=
DATABASE_URL= DATABASE_URL=
JWT_SECRET= JWT_SECRET=
SENTRY_DNS= SENTRY_DSN=
# KEEP THIS EMPTY, DISABLE SENTRY CLIENT INSIDE OF LIBRARIES USED BY APIv2 # KEEP THIS EMPTY, DISABLE SENTRY CLIENT INSIDE OF LIBRARIES USED BY APIv2
NEXT_PUBLIC_SENTRY_DSN= NEXT_PUBLIC_SENTRY_DSN=
+1 -1
View File
@@ -7,7 +7,7 @@
"plugins": [ "plugins": [
{ {
"name": "@nestjs/swagger", "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"); private logger = new Logger("HTTP");
use(request: Request, response: Response, next: NextFunction): void { 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") || ""; const userAgent = request.get("user-agent") || "";
response.on("close", () => { 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({ 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")), port: Number(getEnv("API_PORT", "5555")),
path: getEnv("API_URL", "http://localhost"), path: getEnv("API_URL", "http://localhost"),
url: `${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`, }/api/v2`,
}, },
db: { 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 { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { CreateReccuringBookingInput } from "@/ee/bookings/inputs/create-reccuring-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 { 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 { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { import {
Controller, Controller,
Post, Post,
@@ -11,12 +14,23 @@ import {
InternalServerErrorException, InternalServerErrorException,
Body, Body,
HttpException, HttpException,
Param,
Get,
Query,
NotFoundException,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { User } from "@prisma/client";
import { Request } from "express"; import { Request } from "express";
import { NextApiRequest } from "next/types"; import { NextApiRequest } from "next/types";
import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
getAllUserBookings,
getBookingInfo,
handleCancelBooking,
getBookingForReschedule,
} from "@calcom/platform-libraries";
import { import {
handleNewBooking, handleNewBooking,
BookingResponse, BookingResponse,
@@ -24,7 +38,9 @@ import {
handleNewRecurringBooking, handleNewRecurringBooking,
handleInstantMeeting, handleInstantMeeting,
} from "@calcom/platform-libraries"; } from "@calcom/platform-libraries";
import { GetBookingsInput, CancelBookingInput } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types"; import { ApiResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@Controller({ @Controller({
path: "ee/bookings", path: "ee/bookings",
@@ -34,7 +50,65 @@ import { ApiResponse } from "@calcom/platform-types";
export class BookingsController { export class BookingsController {
private readonly logger = new Logger("ee bookings controller"); 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("/") @Post("/")
@Permissions([BOOKING_WRITE]) @Permissions([BOOKING_WRITE])
@@ -56,6 +130,30 @@ export class BookingsController {
throw new InternalServerErrorException("Could not create booking."); 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") @Post("/reccuring")
@Permissions([BOOKING_WRITE]) @Permissions([BOOKING_WRITE])
async createReccuringBooking( async createReccuringBooking(
@@ -1,5 +1,5 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input"; 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"; 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 { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { UserWithProfile } from "@/modules/users/users.repository"; 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 { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ConnectedDestinationCalendars } from "@calcom/platform-libraries"; import { ConnectedDestinationCalendars } from "@calcom/platform-libraries";
@@ -16,8 +16,6 @@ import { EventBusyDate } from "@calcom/types/Calendar";
}) })
@UseGuards(AccessTokenGuard) @UseGuards(AccessTokenGuard)
export class CalendarsController { export class CalendarsController {
private readonly logger = new Logger("ee overlay calendars controller");
constructor(private readonly calendarsService: CalendarsService) {} constructor(private readonly calendarsService: CalendarsService) {}
@Get("/busy-times") @Get("/busy-times")
@@ -1,6 +1,8 @@
import { bootstrap } from "@/app"; import { bootstrap } from "@/app";
import { AppModule } from "@/app.module"; import { AppModule } from "@/app.module";
import { EventTypesModule } from "@/ee/event-types/event-types.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 { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
@@ -61,7 +63,7 @@ describe("Event types Endpoints", () => {
let eventTypesRepositoryFixture: EventTypesRepositoryFixture; let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
const userEmail = "event-types-test-e2e@api.com"; const userEmail = "event-types-test-e2e@api.com";
const name = "bob"; const name = "bob the builder";
const username = name; const username = name;
let eventType: EventType; let eventType: EventType;
let user: User; let user: User;
@@ -86,6 +88,7 @@ describe("Event types Endpoints", () => {
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef); userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" }); organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id); oAuthClient = await createOAuthClient(organization.id);
@@ -95,17 +98,6 @@ describe("Event types Endpoints", () => {
username, username,
}); });
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
eventType = await eventTypesRepositoryFixture.create(
{
length: 60,
title: "peer coding session",
slug: "peer-coding",
},
user.id
);
await app.init(); await app.init();
}); });
@@ -129,6 +121,50 @@ describe("Event types Endpoints", () => {
expect(user).toBeDefined(); 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 () => { it(`/GET/:id`, async () => {
const response = await request(app.getHttpServer()) const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}`) .get(`/api/v2/event-types/${eventType.id}`)
@@ -154,10 +190,8 @@ describe("Event types Endpoints", () => {
.expect(200); .expect(200);
const responseBody: ApiSuccessResponse<EventTypesByViewer> = response.body; const responseBody: ApiSuccessResponse<EventTypesByViewer> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined(); 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).toBeDefined(); expect(responseBody.data.eventTypeGroups).toBeDefined();
expect(responseBody.data.eventTypeGroups[0]).toBeDefined(); expect(responseBody.data.eventTypeGroups[0]).toBeDefined();
@@ -178,7 +212,6 @@ describe("Event types Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined(); expect(responseBody.data).toBeDefined();
console.log("asap responseBody.data", responseBody.data);
expect(responseBody.data).toBeDefined(); expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(1); expect(responseBody.data.length).toEqual(1);
expect(responseBody.data[0].id).toEqual(eventType.id); expect(responseBody.data[0].id).toEqual(eventType.id);
@@ -192,10 +225,18 @@ describe("Event types Endpoints", () => {
.expect(404); .expect(404);
}); });
it("should delete schedule", async () => {
return request(app.getHttpServer()).delete(`/api/v2/event-types/${eventType.id}`).expect(200);
});
afterAll(async () => { afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id); await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.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 { try {
await userRepositoryFixture.delete(user.id); await userRepositoryFixture.delete(user.id);
} catch (e) { } catch (e) {
@@ -1,25 +1,41 @@
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input"; 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 { 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 { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UserWithProfile } from "@/modules/users/users.repository"; import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Param, Post, Body, NotFoundException } from "@nestjs/common"; import {
import { EventType } from "@prisma/client"; 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 { 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 { getEventTypesByViewer } from "@calcom/platform-libraries";
import { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
@Controller({ @Controller({
path: "event-types", path: "event-types",
version: "2", version: "2",
}) })
@UseGuards(PermissionsGuard) @UseGuards(PermissionsGuard)
@DocsTags("Event types")
export class EventTypesController { export class EventTypesController {
constructor(private readonly eventTypesService: EventTypesService) {} constructor(private readonly eventTypesService: EventTypesService) {}
@@ -29,8 +45,8 @@ export class EventTypesController {
async createEventType( async createEventType(
@Body() body: CreateEventTypeInput, @Body() body: CreateEventTypeInput,
@GetUser() user: UserWithProfile @GetUser() user: UserWithProfile
): Promise<ApiResponse<EventType>> { ): Promise<CreateEventTypeOutput> {
const eventType = await this.eventTypesService.createUserEventType(user.id, body); const eventType = await this.eventTypesService.createUserEventType(user, body);
return { return {
status: SUCCESS_STATUS, status: SUCCESS_STATUS,
@@ -43,12 +59,9 @@ export class EventTypesController {
@UseGuards(AccessTokenGuard) @UseGuards(AccessTokenGuard)
async getEventType( async getEventType(
@Param("eventTypeId") eventTypeId: string, @Param("eventTypeId") eventTypeId: string,
@ForAtom() forAtom: boolean,
@GetUser() user: UserWithProfile @GetUser() user: UserWithProfile
): Promise<ApiSuccessResponse<EventType | AtomEventType>> { ): Promise<GetEventTypeOutput> {
const eventType = forAtom const eventType = await this.eventTypesService.getUserEventTypeForAtom(user, Number(eventTypeId));
? await this.eventTypesService.getUserEventTypeForAtom(user, Number(eventTypeId))
: await this.eventTypesService.getUserEventType(user.id, Number(eventTypeId));
if (!eventType) { if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`); throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
@@ -63,7 +76,7 @@ export class EventTypesController {
@Get("/") @Get("/")
@Permissions([EVENT_TYPE_READ]) @Permissions([EVENT_TYPE_READ])
@UseGuards(AccessTokenGuard) @UseGuards(AccessTokenGuard)
async getEventTypes(@GetUser() user: UserWithProfile): Promise<ApiSuccessResponse<EventTypesByViewer>> { async getEventTypes(@GetUser() user: UserWithProfile): Promise<GetEventTypesOutput> {
const eventTypes = await getEventTypesByViewer({ const eventTypes = await getEventTypesByViewer({
id: user.id, id: user.id,
profile: { profile: {
@@ -79,9 +92,7 @@ export class EventTypesController {
@Get("/:username/public") @Get("/:username/public")
@Permissions([EVENT_TYPE_READ]) @Permissions([EVENT_TYPE_READ])
async getPublicEventTypes( async getPublicEventTypes(@Param("username") username: string): Promise<GetEventTypesPublicOutput> {
@Param("username") username: string
): Promise<ApiSuccessResponse<EventTypesPublic>> {
const eventTypes = await this.eventTypesService.getEventTypesPublicByUsername(username); const eventTypes = await this.eventTypesService.getEventTypesPublicByUsername(username);
return { return {
@@ -89,4 +100,41 @@ export class EventTypesController {
data: eventTypes, 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 { EventTypesService } from "@/ee/event-types/services/event-types.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module"; import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.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 { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module"; import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
@Module({ @Module({
imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule], imports: [PrismaModule, MembershipsModule, TokensModule, UsersModule, SelectedCalendarsModule],
providers: [EventTypesRepository, EventTypesService], providers: [EventTypesRepository, EventTypesService],
controllers: [EventTypesController], controllers: [EventTypesController],
exports: [EventTypesService, EventTypesRepository], exports: [EventTypesService, EventTypesRepository],
@@ -10,7 +10,10 @@ import { getEventTypeById } from "@calcom/platform-libraries";
export class EventTypesRepository { export class EventTypesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} 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({ return this.dbWrite.prisma.eventType.create({
data: { data: {
...body, ...body,
@@ -59,4 +62,19 @@ export class EventTypesRepository {
async getEventTypeById(eventTypeId: number) { async getEventTypeById(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({ where: { id: eventTypeId } }); 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 { export class CreateEventTypeInput {
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
length!: number; length!: number;
@IsString() @IsString()
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
slug!: string; slug!: string;
@IsString() @IsString()
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
title!: string; 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 { DEFAULT_EVENT_TYPES } from "@/ee/event-types/constants/constants";
import { EventTypesRepository } from "@/ee/event-types/event-types.repository"; import { EventTypesRepository } from "@/ee/event-types/event-types.repository";
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input"; 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 { 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 { 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 { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
import { EventType } from "@calcom/prisma/client";
@Injectable() @Injectable()
export class EventTypesService { export class EventTypesService {
constructor( constructor(
private readonly eventTypesRepository: EventTypesRepository, private readonly eventTypesRepository: EventTypesRepository,
private readonly membershipsRepository: MembershipsRepository, 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) { async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput) {
return this.eventTypesRepository.createUserEventType(userId, body); 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) { 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) { async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
@@ -30,7 +77,18 @@ export class EventTypesService {
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId) ? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false; : 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> { async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
@@ -43,11 +101,7 @@ export class EventTypesService {
} }
async createUserDefaultEventTypes(userId: number) { async createUserDefaultEventTypes(userId: number) {
const thirtyMinutes = DEFAULT_EVENT_TYPES.thirtyMinutes; const { sixtyMinutes, sixtyMinutesVideo, thirtyMinutes, thirtyMinutesVideo } = DEFAULT_EVENT_TYPES;
const thirtyMinutesVideo = DEFAULT_EVENT_TYPES.thirtyMinutesVideo;
const sixtyMinutes = DEFAULT_EVENT_TYPES.sixtyMinutes;
const sixtyMinutesVideo = DEFAULT_EVENT_TYPES.sixtyMinutesVideo;
const defaultEventTypes = await Promise.all([ const defaultEventTypes = await Promise.all([
this.eventTypesRepository.createUserEventType(userId, thirtyMinutes), this.eventTypesRepository.createUserEventType(userId, thirtyMinutes),
@@ -58,4 +112,53 @@ export class EventTypesService {
return defaultEventTypes; 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 gcalCredentials: Credential;
let accessTokenSecret: string; let accessTokenSecret: string;
let refreshTokenSecret: string; let refreshTokenSecret: string;
beforeAll(async () => { beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter], providers: [PrismaExceptionFilter, HttpExceptionFilter],
@@ -82,16 +83,16 @@ describe("Platform Gcal Endpoints", () => {
expect(user).toBeDefined(); 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()) 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`) .set("Authorization", `Bearer invalid_access_token`)
.expect(401); .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()) 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("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI) .set("Origin", CLIENT_REDIRECT_URI)
.expect(200); .expect(200);
@@ -99,50 +100,50 @@ describe("Platform Gcal Endpoints", () => {
expect(data.authUrl).toBeDefined(); 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()) await request(app.getHttpServer())
.get( .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); .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()) await request(app.getHttpServer())
.get( .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); .expect(400);
}); });
it(`/GET/platform/gcal/oauth/save: without origin`, async () => { it(`/GET/ee/gcal/oauth/save: without origin`, async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.get( .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); .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()) await request(app.getHttpServer())
.get(`/api/v2/platform/gcal/check`) .get(`/api/v2/ee/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`) .set("Authorization", `Bearer ${accessTokenSecret}`)
.expect(400); .expect(400);
}); });
it(`/GET/platform/gcal/check without access token`, async () => { it(`/GET/ee/gcal/check without access token`, async () => {
await request(app.getHttpServer()).get(`/api/v2/platform/gcal/check`).expect(401); 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()) await request(app.getHttpServer())
.get(`/api/v2/platform/gcal/check`) .get(`/api/v2/ee/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`) .set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI) .set("Origin", CLIENT_REDIRECT_URI)
.expect(400); .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( gcalCredentials = await credentialsRepositoryFixture.create(
"google_calendar", "google_calendar",
{}, {},
@@ -150,7 +151,7 @@ describe("Platform Gcal Endpoints", () => {
"google-calendar" "google-calendar"
); );
await request(app.getHttpServer()) await request(app.getHttpServer())
.get(`/api/v2/platform/gcal/check`) .get(`/api/v2/ee/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`) .set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI) .set("Origin", CLIENT_REDIRECT_URI)
.expect(200); .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 { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
@@ -40,22 +39,21 @@ const CALENDAR_SCOPES = [
]; ];
@Controller({ @Controller({
path: "platform/gcal", path: "ee/gcal",
version: "2", version: "2",
}) })
export class GcalController { export class GcalController {
private readonly logger = new Logger("Platform Gcal Provider"); private readonly logger = new Logger("Platform Gcal Provider");
constructor( constructor(
private readonly appRepository: AppsRepository,
private readonly credentialRepository: CredentialsRepository, private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository, private readonly tokensRepository: TokensRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository, private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly config: ConfigService, 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") @Get("/oauth/auth-url")
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
+2 -2
View File
@@ -1,6 +1,6 @@
import { GcalController } from "@/ee/gcal/gcal.controller"; import { GcalController } from "@/ee/gcal/gcal.controller";
import { AppsRepository } from "@/modules/apps/apps.repository"; 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 { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module"; import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -11,7 +11,7 @@ import { ConfigService } from "@nestjs/config";
@Module({ @Module({
imports: [PrismaModule, TokensModule, OAuthClientModule], imports: [PrismaModule, TokensModule, OAuthClientModule],
providers: [AppsRepository, ConfigService, CredentialsRepository, SelectedCalendarsRepository, GcalService], providers: [AppsRepository, ConfigService, CredentialsRepository, SelectedCalendarsRepository, GCalService],
controllers: [GcalController], controllers: [GcalController],
}) })
export class GcalModule {} export class GcalModule {}
@@ -65,7 +65,7 @@ describe("Me Endpoints", () => {
it("should get user associated with access token", async () => { it("should get user associated with access token", async () => {
return request(app.getHttpServer()) return request(app.getHttpServer())
.get("/api/v2/me") .get("/api/v2/ee/me")
.expect(200) .expect(200)
.then((response) => { .then((response) => {
const responseBody: ApiSuccessResponse<UserResponse> = response.body; const responseBody: ApiSuccessResponse<UserResponse> = response.body;
@@ -84,7 +84,7 @@ describe("Me Endpoints", () => {
const body: UpdateManagedPlatformUserInput = { timeZone: "Europe/Rome" }; const body: UpdateManagedPlatformUserInput = { timeZone: "Europe/Rome" };
return request(app.getHttpServer()) return request(app.getHttpServer())
.patch("/api/v2/me") .patch("/api/v2/ee/me")
.send(body) .send(body)
.expect(200) .expect(200)
.then(async (response) => { .then(async (response) => {
@@ -108,19 +108,22 @@ describe("Me Endpoints", () => {
it("should not update user associated with access token given invalid timezone", async () => { it("should not update user associated with access token given invalid timezone", async () => {
const bodyWithIncorrectTimeZone: UpdateManagedPlatformUserInput = { timeZone: "Narnia/Woods" }; 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 () => { it("should not update user associated with access token given invalid time format", async () => {
const bodyWithIncorrectTimeFormat: UpdateManagedPlatformUserInput = { timeFormat: 100 }; 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 () => { it("should not update user associated with access token given invalid week start", async () => {
const bodyWithIncorrectWeekStart: UpdateManagedPlatformUserInput = { weekStart: "waba luba dub dub" }; 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 () => { afterAll(async () => {
+1 -1
View File
@@ -12,7 +12,7 @@ import { UserResponse, userSchemaResponse } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types"; import { ApiResponse } from "@calcom/platform-types";
@Controller({ @Controller({
path: "me", path: "ee/me",
version: "2", version: "2",
}) })
@UseGuards(AccessTokenGuard, PermissionsGuard) @UseGuards(AccessTokenGuard, PermissionsGuard)
@@ -1,7 +1,6 @@
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UserWithProfile } from "@/modules/users/users.repository"; import { UserWithProfile } from "@/modules/users/users.repository";
import { import {
BadRequestException, BadRequestException,
@@ -9,7 +8,6 @@ import {
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Logger,
NotFoundException, NotFoundException,
Param, Param,
UnauthorizedException, UnauthorizedException,
@@ -20,16 +18,11 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types"; import { ApiResponse } from "@calcom/platform-types";
@Controller({ @Controller({
path: "platform/provider", path: "ee/provider",
version: "2", version: "2",
}) })
export class CalProviderController { export class CalProviderController {
private readonly logger = new Logger("Platform Provider Controller"); constructor(private readonly oauthClientRepository: OAuthClientRepository) {}
constructor(
private readonly tokensRepository: TokensRepository,
private readonly oauthClientRepository: OAuthClientRepository
) {}
@Get("/:clientId") @Get("/:clientId")
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@@ -21,7 +21,6 @@ import {
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ScheduleWithAvailabilitiesForWeb } from "@calcom/platform-libraries"; import type { ScheduleWithAvailabilitiesForWeb } from "@calcom/platform-libraries";
import type { CityTimezones } from "@calcom/platform-libraries";
import { updateScheduleHandler } from "@calcom/platform-libraries"; import { updateScheduleHandler } from "@calcom/platform-libraries";
import type { UpdateScheduleOutputType } from "@calcom/platform-libraries"; import type { UpdateScheduleOutputType } from "@calcom/platform-libraries";
import { ApiSuccessResponse, ScheduleResponse, UpdateScheduleInput } from "@calcom/platform-types"; 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") @Get("/:scheduleId")
@Permissions([SCHEDULE_READ]) @Permissions([SCHEDULE_READ])
async getSchedule( async getSchedule(
@@ -6,8 +6,6 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { Schedule } from "@prisma/client"; import { Schedule } from "@prisma/client";
import { cityTimezonesHandler } from "@calcom/platform-libraries";
@Injectable() @Injectable()
export class SchedulesService { export class SchedulesService {
constructor( constructor(
@@ -96,8 +94,4 @@ export class SchedulesService {
throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`); 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; NEXTAUTH_SECRET: string;
DATABASE_URL: string; DATABASE_URL: string;
JWT_SECRET: string; JWT_SECRET: string;
SENTRY_DNS: string; SENTRY_DSN: string;
LOG_LEVEL: keyof typeof logLevels; LOG_LEVEL: keyof typeof logLevels;
REDIS_URL: string; REDIS_URL: string;
}; };
@@ -1,4 +1,5 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common"; import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common";
import { Request } from "express";
import { ERROR_STATUS } from "@calcom/platform-constants"; import { ERROR_STATUS } from "@calcom/platform-constants";
import { Response } from "@calcom/platform-types"; import { Response } from "@calcom/platform-types";
@@ -10,10 +11,14 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
catch(exception: HttpException, host: ArgumentsHost) { catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest(); const request = ctx.getRequest<Request>();
const statusCode = exception.getStatus(); const statusCode = exception.getStatus();
this.logger.error(`Http Exception Filter: ${exception?.message}`, { this.logger.error(`Http Exception Filter: ${exception?.message}`, {
exception, exception,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
}); });
response.status(statusCode).json({ response.status(statusCode).json({
status: ERROR_STATUS, status: ERROR_STATUS,
@@ -7,6 +7,7 @@ import {
PrismaClientUnknownRequestError, PrismaClientUnknownRequestError,
PrismaClientValidationError, PrismaClientValidationError,
} from "@prisma/client/runtime/library"; } from "@prisma/client/runtime/library";
import { Request } from "express";
import { ERROR_STATUS, INTERNAL_SERVER_ERROR } from "@calcom/platform-constants"; import { ERROR_STATUS, INTERNAL_SERVER_ERROR } from "@calcom/platform-constants";
import { Response } from "@calcom/platform-types"; import { Response } from "@calcom/platform-types";
@@ -31,9 +32,13 @@ export class PrismaExceptionFilter implements ExceptionFilter {
catch(error: PrismaError, host: ArgumentsHost) { catch(error: PrismaError, host: ArgumentsHost) {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest(); const request = ctx.getRequest<Request>();
this.logger.error(`PrismaError: ${error.message}`, { this.logger.error(`PrismaError: ${error.message}`, {
error, error,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
}); });
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
status: ERROR_STATUS, status: ERROR_STATUS,
@@ -1,6 +1,7 @@
import { ArgumentsHost, Catch, Logger, HttpStatus } from "@nestjs/common"; import { ArgumentsHost, Catch, Logger, HttpStatus } from "@nestjs/common";
import { BaseExceptionFilter } from "@nestjs/core"; import { BaseExceptionFilter } from "@nestjs/core";
import * as Sentry from "@sentry/node"; import * as Sentry from "@sentry/node";
import { Request } from "express";
import { ERROR_STATUS, INTERNAL_SERVER_ERROR } from "@calcom/platform-constants"; import { ERROR_STATUS, INTERNAL_SERVER_ERROR } from "@calcom/platform-constants";
import { Response } from "@calcom/platform-types"; import { Response } from "@calcom/platform-types";
@@ -12,10 +13,14 @@ export class SentryFilter extends BaseExceptionFilter {
handleUnknownError(exception: any, host: ArgumentsHost): void { handleUnknownError(exception: any, host: ArgumentsHost): void {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest(); const request = ctx.getRequest<Request>();
this.logger.error(`Sentry Exception Filter: ${exception?.message}`, { this.logger.error(`Sentry Exception Filter: ${exception?.message}`, {
exception, exception,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
}); });
// capture if client has been init // capture if client has been init
@@ -1,4 +1,5 @@
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common"; import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import { Request } from "express";
import { ERROR_STATUS } from "@calcom/platform-constants"; import { ERROR_STATUS } from "@calcom/platform-constants";
import { TRPCError } from "@calcom/platform-libraries"; import { TRPCError } from "@calcom/platform-libraries";
@@ -11,7 +12,8 @@ export class TRPCExceptionFilter implements ExceptionFilter {
catch(exception: TRPCError, host: ArgumentsHost) { catch(exception: TRPCError, host: ArgumentsHost) {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest(); const request = ctx.getRequest<Request>();
let statusCode = 500; let statusCode = 500;
switch (exception.code) { switch (exception.code) {
case "UNAUTHORIZED": case "UNAUTHORIZED":
@@ -38,9 +40,15 @@ export class TRPCExceptionFilter implements ExceptionFilter {
statusCode = 500; statusCode = 500;
break; break;
} }
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, { this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
exception, exception,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
}); });
response.status(statusCode).json({ response.status(statusCode).json({
status: ERROR_STATUS, status: ERROR_STATUS,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -1,5 +1,6 @@
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common"; import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch, HttpStatus, Logger } from "@nestjs/common"; import { Catch, HttpStatus, Logger } from "@nestjs/common";
import { Request } from "express";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { BAD_REQUEST, ERROR_STATUS } from "@calcom/platform-constants"; import { BAD_REQUEST, ERROR_STATUS } from "@calcom/platform-constants";
@@ -12,10 +13,16 @@ export class ZodExceptionFilter implements ExceptionFilter {
catch(error: ZodError, host: ArgumentsHost) { catch(error: ZodError, host: ArgumentsHost) {
const ctx = host.switchToHttp(); const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest(); const request = ctx.getRequest<Request>();
this.logger.error(`ZodError: ${error.message}`, { this.logger.error(`ZodError: ${error.message}`, {
error, error,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
}); });
response.status(HttpStatus.BAD_REQUEST).json({ response.status(HttpStatus.BAD_REQUEST).json({
status: ERROR_STATUS, status: ERROR_STATUS,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -1,6 +1,6 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common"; 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(); const request = ctx.switchToHttp().getRequest();
return request.query.for === "atom"; return request.query.for === "atom";
}); });
@@ -1,4 +1,11 @@
import { UserWithProfile } from "@/modules/users/users.repository";
export class BaseStrategy { export class BaseStrategy {
success!: (user: unknown) => void; success!: (user: unknown) => void;
error!: (error: Error) => 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"; import { z } from "zod";
@Injectable() @Injectable()
export class GcalService { export class GCalService {
private logger = new Logger("GcalService"); private logger = new Logger("GcalService");
private gcalResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
constructor(private readonly appsRepository: AppsRepository) {} constructor(private readonly appsRepository: AppsRepository) {}
async getOAuthClient(redirectUri: string) { async getOAuthClient(redirectUri: string) {
@@ -17,9 +19,7 @@ export class GcalService {
throw new NotFoundException(); throw new NotFoundException();
} }
const { client_id, client_secret } = z const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys);
.object({ client_id: z.string(), client_secret: z.string() })
.parse(app.keys);
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri); const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
return oAuth2Client; return oAuth2Client;
@@ -38,7 +38,7 @@ export class OrganizationRolesGuard implements CanActivate {
async isPlatform(organizationId: number) { async isPlatform(organizationId: number) {
const isPlatform = await this.organizationsService.isPlatform(organizationId); const isPlatform = await this.organizationsService.isPlatform(organizationId);
if (!isPlatform) { 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 { UsersRepository } from "@/modules/users/users.repository";
import { Injectable, UnauthorizedException } from "@nestjs/common"; import { Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
@@ -7,7 +7,7 @@ import type { Request } from "express";
import { getToken } from "next-auth/jwt"; import { getToken } from "next-auth/jwt";
@Injectable() @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) { constructor(private readonly userRepository: UsersRepository, private readonly config: ConfigService) {
super(); super();
} }
@@ -25,7 +25,7 @@ export class NextAuthStrategy extends PassportStrategy(BaseStrategy, "next-auth"
throw new UnauthorizedException("Email not found in the authentication token."); 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) { if (!user) {
throw new UnauthorizedException("User associated with the authentication token email not found."); 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 { PlatformEndpointsModule } from "@/ee/platform-endpoints-module";
import { EventsModule } from "@/modules/events/events.module"; import { EventsModule } from "@/modules/events/events.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.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 type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
@Module({ @Module({
imports: [EventsModule, OAuthClientModule, PlatformEndpointsModule], imports: [EventsModule, OAuthClientModule, PlatformEndpointsModule, TimezoneModule],
}) })
export class EndpointsModule implements NestModule { export class EndpointsModule implements NestModule {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -22,7 +22,10 @@ export class EventsController {
queryParams.eventSlug, queryParams.eventSlug,
queryParams.isTeamEvent, queryParams.isTeamEvent,
queryParams.org || null, 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 { return {
data: event, data: event,
@@ -179,6 +179,21 @@ describe("OAuth Client Users Endpoints", () => {
).toBeTruthy(); ).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 () => { it(`/GET/:id`, async () => {
const response = await request(app.getHttpServer()) const response = await request(app.getHttpServer())
.get(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`) .get(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
@@ -20,11 +20,12 @@ import {
Patch, Patch,
BadRequestException, BadRequestException,
Delete, Delete,
Query,
} from "@nestjs/common"; } from "@nestjs/common";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { SUCCESS_STATUS } from "@calcom/platform-constants"; import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types"; import { ApiResponse, Pagination } from "@calcom/platform-types";
@Controller({ @Controller({
path: "oauth-clients/:clientId/users", path: "oauth-clients/:clientId/users",
@@ -39,6 +40,27 @@ export class OAuthClientUsersController {
private readonly oauthRepository: OAuthClientRepository 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("/") @Post("/")
@UseGuards(OAuthClientCredentialsGuard) @UseGuards(OAuthClientCredentialsGuard)
async createUser( 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 { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto";
import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input"; import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { import {
Body, Body,
Controller, Controller,
@@ -57,9 +58,10 @@ export class OAuthClientsController {
type: CreateOAuthClientResponseDto, type: CreateOAuthClientResponseDto,
}) })
async createOAuthClient( async createOAuthClient(
@GetUser("organizationId") organizationId: number, @GetUser() user: UserWithProfile,
@Body() body: CreateOAuthClientInput @Body() body: CreateOAuthClientInput
): Promise<CreateOAuthClientResponseDto> { ): Promise<CreateOAuthClientResponseDto> {
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
this.logger.log( this.logger.log(
`For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}` `For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}`
); );
@@ -77,9 +79,9 @@ export class OAuthClientsController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER]) @Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION }) @DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClients( async getOAuthClients(@GetUser() user: UserWithProfile): Promise<GetOAuthClientsResponseDto> {
@GetUser("organizationId") organizationId: number const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
): Promise<GetOAuthClientsResponseDto> {
const clients = await this.oauthClientRepository.getOrganizationOAuthClients(organizationId); const clients = await this.oauthClientRepository.getOrganizationOAuthClients(organizationId);
return { status: SUCCESS_STATUS, data: clients }; 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 { TokensRepository } from "@/modules/tokens/tokens.repository";
import { CreateManagedPlatformUserInput } from "@/modules/users/inputs/create-managed-platform-user.input"; import { CreateManagedPlatformUserInput } from "@/modules/users/inputs/create-managed-platform-user.input";
import { UsersRepository } from "@/modules/users/users.repository"; import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common"; import { BadRequestException, Injectable } from "@nestjs/common";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import * as crypto from "crypto"; import * as crypto from "crypto";
@@ -24,24 +24,24 @@ export class OAuthClientUsersService {
) { ) {
let user: User; let user: User;
if (!organizationId) { if (!organizationId) {
const username = generateShortHash(body.email, oAuthClientId); throw new BadRequestException("You cannot create a managed user outside of an organization");
user = await this.userRepository.create(body, username, oAuthClientId, isPlatformManaged);
} else { } else {
const [_, emailDomain] = body.email.split("@"); const [username, emailDomain] = body.email.split("@");
const email = `${username}+${oAuthClientId}@${emailDomain}`;
user = ( user = (
await createNewUsersConnectToOrgIfExists({ await createNewUsersConnectToOrgIfExists({
usernamesOrEmails: [body.email], usernamesOrEmails: [email],
input: { input: {
teamId: organizationId, teamId: organizationId,
role: "MEMBER", role: "MEMBER",
usernameOrEmail: [body.email], usernameOrEmail: [email],
isOrg: true, isOrg: true,
language: "en", language: "en",
}, },
parentId: null, parentId: null,
autoAcceptEmailDomain: emailDomain, autoAcceptEmailDomain: "never-auto-accept-email-domain-for-managed-users",
connectionInfoMap: { connectionInfoMap: {
[body.email]: { [email]: {
orgId: organizationId, orgId: organizationId,
autoAccept: true, autoAccept: true,
}, },
@@ -1,20 +1,18 @@
import { TokenExpiredException } from "@/modules/auth/guards/access-token/token-expired.exception"; import { TokenExpiredException } from "@/modules/auth/guards/access-token/token-expired.exception";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { TokensRepository } from "@/modules/tokens/tokens.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"; import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
@Injectable() @Injectable()
export class OAuthFlowService { export class OAuthFlowService {
private logger = new Logger("OAuthFlowService");
constructor( constructor(
private readonly tokensRepository: TokensRepository, private readonly tokensRepository: TokensRepository,
private readonly oAuthClientRepository: OAuthClientRepository private readonly oAuthClientRepository: OAuthClientRepository //private readonly redisService: RedisIOService
) //private readonly redisService: RedisIOService ) {}
{}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async propagateAccessToken(accessToken: string) { async propagateAccessToken(accessToken: string) {
// this.logger.log("Propagating access token to redis", accessToken); // this.logger.log("Propagating access token to redis", accessToken);
// TODO propagate // TODO propagate
@@ -48,6 +46,7 @@ export class OAuthFlowService {
return true; return true;
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
private async readFromCache(secret: string) { private async readFromCache(secret: string) {
return { status: "CACHE_MISS" }; return { status: "CACHE_MISS" };
} }
@@ -7,7 +7,7 @@ import { PrismaClient } from "@prisma/client";
export class PrismaReadService implements OnModuleInit { export class PrismaReadService implements OnModuleInit {
public prisma: PrismaClient; public prisma: PrismaClient;
constructor(private readonly configService: ConfigService) { constructor(readonly configService: ConfigService) {
const dbUrl = configService.get("db.readUrl", { infer: true }); const dbUrl = configService.get("db.readUrl", { infer: true });
this.prisma = new PrismaClient({ this.prisma = new PrismaClient({
@@ -7,7 +7,7 @@ import { PrismaClient } from "@prisma/client";
export class PrismaWriteService implements OnModuleInit { export class PrismaWriteService implements OnModuleInit {
public prisma: PrismaClient; public prisma: PrismaClient;
constructor(private readonly configService: ConfigService) { constructor(readonly configService: ConfigService) {
const dbUrl = configService.get("db.writeUrl", { infer: true }); const dbUrl = configService.get("db.writeUrl", { infer: true });
this.prisma = new PrismaClient({ 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 { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
@Module({ @Module({
imports: [], imports: [PrismaModule],
providers: [SelectedCalendarsRepository], providers: [SelectedCalendarsRepository],
exports: [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) { 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 refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([ const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
this.dbWrite.prisma.accessToken.create({ this.dbWrite.prisma.accessToken.create({
@@ -101,7 +101,7 @@ export class TokensRepository {
} }
async refreshOAuthTokens(clientId: string, refreshTokenSecret: string, tokenUserId: number) { 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(); const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -21,7 +21,7 @@ export class UsersRepository {
) { ) {
this.formatInput(user); this.formatInput(user);
return this.dbRead.prisma.user.create({ return this.dbWrite.prisma.user.create({
data: { data: {
...user, ...user,
username, username,
@@ -34,7 +34,7 @@ export class UsersRepository {
} }
async addToOAuthClient(userId: number, oAuthClientId: string) { async addToOAuthClient(userId: number, oAuthClientId: string) {
return this.dbRead.prisma.user.update({ return this.dbWrite.prisma.user.update({
data: { data: {
platformOAuthClients: { platformOAuthClients: {
connect: { id: oAuthClientId }, 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) { async findByUsername(username: string) {
return this.dbRead.prisma.user.findFirst({ return this.dbRead.prisma.user.findFirst({
where: { 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) { async update(userId: number, updateData: UpdateManagedPlatformUserInput) {
this.formatInput(updateData); this.formatInput(updateData);
+607 -44
View File
@@ -22,7 +22,40 @@
"/api/v2/events/public": { "/api/v2/events/public": {
"get": { "get": {
"operationId": "EventsController_getPublicEvent", "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": { "responses": {
"200": { "200": {
"description": "", "description": "",
@@ -38,6 +71,31 @@
} }
}, },
"/api/v2/oauth-clients/{clientId}/users": { "/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": { "post": {
"operationId": "OAuthClientUsersController_createUser", "operationId": "OAuthClientUsersController_createUser",
"parameters": [ "parameters": [
@@ -55,7 +113,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/CreateUserInput" "$ref": "#/components/schemas/CreateManagedPlatformUserInput"
} }
} }
} }
@@ -133,7 +191,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/UpdateUserInput" "$ref": "#/components/schemas/UpdateManagedPlatformUserInput"
} }
} }
} }
@@ -500,12 +558,15 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"type": "object" "$ref": "#/components/schemas/CreateEventTypeOutput"
} }
} }
} }
} }
} },
"tags": [
"Event types"
]
}, },
"get": { "get": {
"operationId": "EventTypesController_getEventTypes", "operationId": "EventTypesController_getEventTypes",
@@ -516,12 +577,15 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"type": "object" "$ref": "#/components/schemas/GetEventTypesOutput"
} }
} }
} }
} }
} },
"tags": [
"Event types"
]
} }
}, },
"/api/v2/event-types/{eventTypeId}": { "/api/v2/event-types/{eventTypeId}": {
@@ -543,12 +607,81 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "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": { "/api/v2/event-types/{username}/public": {
@@ -570,15 +703,18 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "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": { "get": {
"operationId": "GcalController_redirect", "operationId": "GcalController_redirect",
"parameters": [ "parameters": [
@@ -605,7 +741,7 @@
} }
} }
}, },
"/api/v2/platform/gcal/oauth/save": { "/api/v2/ee/gcal/oauth/save": {
"get": { "get": {
"operationId": "GcalController_save", "operationId": "GcalController_save",
"parameters": [ "parameters": [
@@ -640,7 +776,7 @@
} }
} }
}, },
"/api/v2/platform/gcal/check": { "/api/v2/ee/gcal/check": {
"get": { "get": {
"operationId": "GcalController_check", "operationId": "GcalController_check",
"parameters": [], "parameters": [],
@@ -658,7 +794,7 @@
} }
} }
}, },
"/api/v2/platform/provider/{clientId}": { "/api/v2/ee/provider/{clientId}": {
"get": { "get": {
"operationId": "CalProviderController_verifyClientId", "operationId": "CalProviderController_verifyClientId",
"parameters": [ "parameters": [
@@ -685,7 +821,7 @@
} }
} }
}, },
"/api/v2/platform/provider/{clientId}/access-token": { "/api/v2/ee/provider/{clientId}/access-token": {
"get": { "get": {
"operationId": "CalProviderController_verifyAccessToken", "operationId": "CalProviderController_verifyAccessToken",
"parameters": [ "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}": { "/api/v2/schedules/{scheduleId}": {
"get": { "get": {
"operationId": "SchedulesController_getSchedule", "operationId": "SchedulesController_getSchedule",
@@ -870,7 +988,7 @@
} }
} }
}, },
"/api/v2/me": { "/api/v2/ee/me": {
"get": { "get": {
"operationId": "MeController_getMe", "operationId": "MeController_getMe",
"parameters": [], "parameters": [],
@@ -895,7 +1013,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/UpdateUserInput" "$ref": "#/components/schemas/UpdateManagedPlatformUserInput"
} }
} }
} }
@@ -951,6 +1069,22 @@
} }
}, },
"/api/v2/ee/bookings": { "/api/v2/ee/bookings": {
"get": {
"operationId": "BookingsController_getBookings",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
}
},
"post": { "post": {
"operationId": "BookingsController_createBooking", "operationId": "BookingsController_createBooking",
"parameters": [], "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": { "/api/v2/ee/bookings/reccuring": {
"post": { "post": {
"operationId": "BookingsController_createReccuringBooking", "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": { "info": {
@@ -1112,7 +1355,7 @@
"servers": [], "servers": [],
"components": { "components": {
"schemas": { "schemas": {
"CreateUserInput": { "CreateManagedPlatformUserInput": {
"type": "object", "type": "object",
"properties": { "properties": {
"email": { "email": {
@@ -1124,9 +1367,6 @@
"timeFormat": { "timeFormat": {
"type": "number" "type": "number"
}, },
"defaultScheduleId": {
"type": "number"
},
"weekStart": { "weekStart": {
"type": "string" "type": "string"
}, },
@@ -1138,7 +1378,7 @@
"email" "email"
] ]
}, },
"UpdateUserInput": { "UpdateManagedPlatformUserInput": {
"type": "object", "type": "object",
"properties": { "properties": {
"email": { "email": {
@@ -1187,11 +1427,11 @@
"properties": { "properties": {
"status": { "status": {
"type": "string", "type": "string",
"example": "success",
"enum": [ "enum": [
"success", "success",
"error" "error"
] ],
"example": "success"
}, },
"data": { "data": {
"example": { "example": {
@@ -1230,7 +1470,8 @@
"example": 3 "example": 3
}, },
"logo": { "logo": {
"type": "object", "type": "string",
"nullable": true,
"example": "https://example.com/logo.png" "example": "https://example.com/logo.png"
}, },
"redirectUris": { "redirectUris": {
@@ -1393,7 +1634,251 @@
"refreshToken" "refreshToken"
] ]
}, },
"EventTypeLocation": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "link"
},
"link": {
"type": "string",
"example": "https://masterchief.com/argentina/flan/video/9129412"
}
},
"required": [
"type"
]
},
"CreateEventTypeInput": { "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", "type": "object",
"properties": { "properties": {
"length": { "length": {
@@ -1405,14 +1890,88 @@
}, },
"title": { "title": {
"type": "string" "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": [ "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", "length",
"slug", "slug",
"title" "title"
] ]
}, },
"DeleteEventTypeOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/DeleteData"
}
},
"required": [
"status",
"data"
]
},
"CreateAvailabilityInput": { "CreateAvailabilityInput": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1526,6 +2085,10 @@
"hashedLink" "hashedLink"
] ]
}, },
"CancelBookingInput": {
"type": "object",
"properties": {}
},
"ReserveSlotInput": { "ReserveSlotInput": {
"type": "object", "type": "object",
"properties": {} "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({ return this.prismaWriteClient.eventType.create({
data: { data: {
...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 { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport"; import { PassportStrategy } from "@nestjs/passport";
@Injectable() @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) { constructor(private readonly email: string, private readonly userRepository: UsersRepository) {
super(); super();
} }
async authenticate() { async authenticate() {
try { try {
const user = await this.userRepository.findByEmail(this.email); const user = await this.userRepository.findByEmailWithProfile(this.email);
if (!user) { if (!user) {
throw new Error("User with the provided email not found"); 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", LOG_LEVEL: "trace",
REDIS_URL: "redis://localhost:9199", REDIS_URL: "redis://localhost:9199",
}; };
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
process.env = { process.env = {
...env, ...env,
...process.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 { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { X } from "@calcom/ui/components/icon"; import { Icon } from "@calcom/ui";
export default function AddToHomescreen() { export default function AddToHomescreen() {
const { t } = useLocale(); const { t } = useLocale();
@@ -40,7 +40,7 @@ export default function AddToHomescreen() {
type="button" type="button"
className="-mr-1 flex rounded-md p-2 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white"> 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> <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> </button>
</div> </div>
</div> </div>
+2 -3
View File
@@ -9,8 +9,7 @@ import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery"; import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { Badge, ListItemText, Avatar } from "@calcom/ui"; import { Avatar, Badge, Icon, ListItemText } from "@calcom/ui";
import { AlertCircle } from "@calcom/ui/components/icon";
type ShouldHighlight = type ShouldHighlight =
| { | {
@@ -105,7 +104,7 @@ export default function AppListCard(props: AppListCardProps) {
<ListItemText component="p">{description}</ListItemText> <ListItemText component="p">{description}</ListItemText>
{invalidCredential && ( {invalidCredential && (
<div className="flex gap-x-2 pt-2"> <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"> <ListItemText component="p" className="whitespace-pre-wrap text-red-500">
{t("invalid_credential")} {t("invalid_credential")}
</ListItemText> </ListItemText>
+7 -8
View File
@@ -3,40 +3,39 @@
import { ShellMain } from "@calcom/features/shell/Shell"; import { ShellMain } from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips"; import { UpgradeTip } from "@calcom/features/tips";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, ButtonGroup } from "@calcom/ui"; import { Button, ButtonGroup, Icon } from "@calcom/ui";
import { BarChart, CreditCard, Globe, Lock, Paintbrush, Users } from "@calcom/ui/components/icon";
export default function EnterprisePage() { export default function EnterprisePage() {
const { t } = useLocale(); const { t } = useLocale();
const features = [ 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"), title: t("branded_subdomain"),
description: t("branded_subdomain_description"), 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"), title: t("org_insights"),
description: t("org_insights_description"), 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"), title: t("extensive_whitelabeling"),
description: t("extensive_whitelabeling_description"), 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"), title: t("unlimited_teams"),
description: t("unlimited_teams_description"), 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"), title: t("unified_billing"),
description: t("unified_billing_description"), 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"), title: t("advanced_managed_events"),
description: t("advanced_managed_events_description"), description: t("advanced_managed_events_description"),
}, },
@@ -6,12 +6,11 @@ import { trpc } from "@calcom/trpc/react";
import { import {
Button, Button,
Dropdown, Dropdown,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownItem, DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@calcom/ui"; } from "@calcom/ui";
import { Plus } from "@calcom/ui/components/icon";
import { QueryCell } from "@lib/QueryCell"; import { QueryCell } from "@lib/QueryCell";
@@ -42,7 +41,7 @@ const AdditionalCalendarSelector = ({ isPending }: AdditionalCalendarSelectorPro
return ( return (
<Dropdown modal={false}> <Dropdown modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button StartIcon={Plus} color="secondary" {...(isPending && { loading: isPending })}> <Button StartIcon="plus" color="secondary" {...(isPending && { loading: isPending })}>
{t("add")} {t("add")}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -50,7 +49,7 @@ const AdditionalCalendarSelector = ({ isPending }: AdditionalCalendarSelectorPro
{options.map((data) => ( {options.map((data) => (
<DropdownMenuItem key={data.slug} className="focus:outline-none"> <DropdownMenuItem key={data.slug} className="focus:outline-none">
{data.slug === "add-new" ? ( {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")} {t("install_new_calendar_app")}
</DropdownItem> </DropdownItem>
) : ( ) : (
+6 -7
View File
@@ -11,17 +11,16 @@ import type { AppCategories } from "@calcom/prisma/enums";
import { trpc, type RouterOutputs } from "@calcom/trpc"; import { trpc, type RouterOutputs } from "@calcom/trpc";
import type { App } from "@calcom/types/App"; import type { App } from "@calcom/types/App";
import { import {
Alert,
Button,
Dropdown, Dropdown,
DropdownItem, DropdownItem,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
List, List,
showToast, showToast,
Button,
DropdownMenuItem,
Alert,
} from "@calcom/ui"; } from "@calcom/ui";
import { MoreHorizontal, Trash, Video } from "@calcom/ui/components/icon";
import AppListCard from "@components/AppListCard"; import AppListCard from "@components/AppListCard";
@@ -82,7 +81,7 @@ export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppL
<div className="flex justify-end"> <div className="flex justify-end">
<Dropdown modal={false}> <Dropdown modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button StartIcon={MoreHorizontal} variant="icon" color="secondary" /> <Button StartIcon="ellipsis" variant="icon" color="secondary" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
{!appIsDefault && variant === "conferencing" && !item.credentialOwner?.teamId && ( {!appIsDefault && variant === "conferencing" && !item.credentialOwner?.teamId && (
@@ -90,7 +89,7 @@ export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppL
<DropdownItem <DropdownItem
type="button" type="button"
color="secondary" color="secondary"
StartIcon={Video} StartIcon="video"
onClick={() => { onClick={() => {
const locationType = getEventLocationTypeFromApp(item?.locationOption?.value ?? ""); const locationType = getEventLocationTypeFromApp(item?.locationOption?.value ?? "");
if (locationType?.linkType === "static") { if (locationType?.linkType === "static") {
@@ -214,7 +213,7 @@ function ConnectOrDisconnectIntegrationMenuItem(props: {
color="destructive" color="destructive"
onClick={() => handleDisconnect(credentialId, teamId)} onClick={() => handleDisconnect(credentialId, teamId)}
disabled={isGlobal} disabled={isGlobal}
StartIcon={Trash}> StartIcon="trash">
{t("remove_app")} {t("remove_app")}
</DropdownItem> </DropdownItem>
</DropdownMenuItem> </DropdownMenuItem>
+9 -10
View File
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import type { IframeHTMLAttributes } from "react"; 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 useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/components"; 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 { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import type { App as AppType } from "@calcom/types/App"; import type { App as AppType } from "@calcom/types/App";
import { Badge, Button, showToast, SkeletonButton, SkeletonText } from "@calcom/ui"; import { Badge, Button, Icon, SkeletonButton, SkeletonText, showToast } from "@calcom/ui";
import { BookOpen, Check, ExternalLink, File, Flag, Mail, Shield } from "@calcom/ui/components/icon";
import { InstallAppButtonChild } from "./InstallAppButtonChild"; import { InstallAppButtonChild } from "./InstallAppButtonChild";
@@ -196,7 +195,7 @@ export const AppPage = ({
isGlobal || isGlobal ||
(existingCredentials.length > 0 && allowedMultipleInstalls ? ( (existingCredentials.length > 0 && allowedMultipleInstalls ? (
<div className="flex space-x-3"> <div className="flex space-x-3">
<Button StartIcon={Check} color="secondary" disabled> <Button StartIcon="check" color="secondary" disabled>
{existingCredentials.length > 0 {existingCredentials.length > 0
? t("active_install", { count: existingCredentials.length }) ? t("active_install", { count: existingCredentials.length })
: t("default")} : t("default")}
@@ -323,7 +322,7 @@ export const AppPage = ({
rel="noreferrer" rel="noreferrer"
className="text-emphasis text-sm font-normal no-underline hover:underline" className="text-emphasis text-sm font-normal no-underline hover:underline"
href={docs}> 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")} {t("documentation")}
</a> </a>
</li> </li>
@@ -335,7 +334,7 @@ export const AppPage = ({
rel="noreferrer" rel="noreferrer"
className="text-emphasis font-normal no-underline hover:underline" className="text-emphasis font-normal no-underline hover:underline"
href={website}> 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://", "")} {website.replace("https://", "")}
</a> </a>
</li> </li>
@@ -347,7 +346,7 @@ export const AppPage = ({
rel="noreferrer" rel="noreferrer"
className="text-emphasis font-normal no-underline hover:underline" className="text-emphasis font-normal no-underline hover:underline"
href={`mailto:${email}`}> 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} {email}
</a> </a>
@@ -360,7 +359,7 @@ export const AppPage = ({
rel="noreferrer" rel="noreferrer"
className="text-emphasis font-normal no-underline hover:underline" className="text-emphasis font-normal no-underline hover:underline"
href={tos}> 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")} {t("terms_of_service")}
</a> </a>
</li> </li>
@@ -372,7 +371,7 @@ export const AppPage = ({
rel="noreferrer" rel="noreferrer"
className="text-emphasis font-normal no-underline hover:underline" className="text-emphasis font-normal no-underline hover:underline"
href={privacy}> 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")} {t("privacy_policy")}
</a> </a>
</li> </li>
@@ -383,7 +382,7 @@ export const AppPage = ({
{t("every_app_published", { appName: APP_NAME, companyName: COMPANY_NAME })} {t("every_app_published", { appName: APP_NAME, companyName: COMPANY_NAME })}
</span> </span>
<a className="mt-2 block text-xs text-red-500" href={`mailto:${SUPPORT_MAIL_ADDRESS}`}> <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> </a>
</div> </div>
</div> </div>
@@ -11,13 +11,12 @@ import {
Alert, Alert,
Button, Button,
EmptyScreen, EmptyScreen,
List,
AppSkeletonLoader as SkeletonLoader,
ShellSubHeading,
Label, Label,
List,
ShellSubHeading,
AppSkeletonLoader as SkeletonLoader,
showToast, showToast,
} from "@calcom/ui"; } from "@calcom/ui";
import { Calendar } from "@calcom/ui/components/icon";
import { QueryCell } from "@lib/QueryCell"; import { QueryCell } from "@lib/QueryCell";
import useRouterQuery from "@lib/hooks/useRouterQuery"; import useRouterQuery from "@lib/hooks/useRouterQuery";
@@ -267,7 +266,7 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
</> </>
) : ( ) : (
<EmptyScreen <EmptyScreen
Icon={Calendar} Icon="calendar"
headline={t("no_category_apps", { headline={t("no_category_apps", {
category: t("calendar").toLowerCase(), category: t("calendar").toLowerCase(),
})} })}
Loaded 100 of 595 files, more files were not shown because too many files have changed in this diff. Show more