Compare commits

...
108 Commits
Author SHA1 Message Date
Keith Williams 6b57dd6794 chore: v4.4.6 (#16507)
release.yml / release (push) Canceled after 0s
2024-09-05 16:21:34 +00:00
Calcom BotandReplexica eb4c58186b feat: update translations (#16508)
Co-authored-by: Replexica <support@replexica.com>
2024-09-05 16:13:40 +00:00
Keith Williams 17e438ab9f chore: Add next batch of languages to AI translations (#16506) 2024-09-06 00:07:26 +09:00
Keith Williams 458e6a3abf perf: Separate OR clauses from Insights counts (#16502)
* perf: Separate OR clauses from Insights counts

* fixed bug with delete statement

* Fixed types

* Reverted unneeded change

* Need to use += so we capture data from all queries
2024-09-05 23:01:54 +09:00
Benny Joo 963d22648b chore: App-router-migration(/availability) add pages / extract components into /module (#16404)
* migrate availability/[schedule]

* add use client directive

* migrate main availability page

* migrate availability/troubleshoot page

* add env var

* extract AvailabilitySettingsWebWrapper into /modules

* fix build

* add missing default

* add missing export

* fix troubleshoot

* use params, not searchParams in generateMetadata

* fix schedule page in app router
2024-09-05 12:37:18 +00:00
fabcf2055b feat: Ability to Hyperlink text in Checkbox on Additional Questions (#15194)
* feat: Ability to Hyperlink text in Checkbox on Additional Questions

* update

* fix and update

* fix type error

* fix and update

* dpdate

* fix type error

* fix unit test

* Update

* fix tests

* revert test

* remove yarn.lock

* remove schema.prisma

* update

* remove log

* revert

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-09-05 09:05:45 +00:00
Lauris Skraucis 633697a812 fix: v2 api fix recurring booking (#16472)
* fix: v2 POST recurring booking

* refactor: have default recurringEventId for recurring booking
2024-09-05 08:37:26 +00:00
sean-brydonandMorgan Vernay 2de33fe1bf feat: attributes v2 api (#16461)
* WIP

* services

* controllers + outputs

* unit tests for repositories

* use hard coded enum type

* use prisma import

* remove redunant input file

* fix packagejson

* rename to spec files

* fix typo in option service file name

* move membership call to org membership service

* move logic to service > repo

* fix membership query

* attribute controller e2e spec

* add options e2e test

* add jest watch for e2e

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-09-05 10:15:12 +02:00
Omar López 36e79bf69c ci: adds basic visual regression testing for icons (#16408) 2024-09-04 16:28:42 -04:00
Joe Au-Yeung 2e36cadce4 fix: Add jitter when Outlook requests are throttled (#16484) 2024-09-04 13:25:31 -07:00
Syed Ali Shahbaz 9ef532019f fix: v1 API Teams access (#16403) 2024-09-04 19:13:38 +00:00
Benny Joo 176dd1a7f3 edit deploy script to enable future/auth (#16492) 2024-09-04 19:09:57 +00:00
e9c71abd6f fix: meeting url missing in workflow email (#16434)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-09-04 17:56:20 +00:00
Imamuzzaki Abu Salam eb472cba36 chore: Remove obsolete docker compose version (#16419) 2024-09-04 13:02:30 -03:00
Benny Joo 7d77554696 chore: App router migration - Add env variables for auth/forgot-password/*, auth/login, auth/logout, auth/new, `auth/saml-idp (#16488)
* Add env variables for auth/forgot-password, auth/login, auth/logout

* Add env variables for auth/new, auth/saml-idp
2024-09-04 15:20:40 +00:00
Morgan 31bfa9e32e fix: api v2 booking controller handle error codes (#16486)
* fix: api v2 booking controller handle error codes

* fixup! fix: api v2 booking controller handle error codes

* fixup! Merge branch 'main' into fix-booking-controller-errors-api-v2
2024-09-04 10:56:00 -03:00
Benny Joo 26be88fc56 chore: App-router-migration /auth/error, /auth/platform, /auth/oauth2 (#16445)
* preparePageMetadata: improve logic for title

* add missing pages: oauth2 / platform

* auth/error: add to app router and extract to /modules
2024-09-04 13:15:19 +00:00
Benny Joo 86cf8feb55 chore: App-router-migration /auth/new and /auth/saml-idp (#16447)
* auth/new: add to app router

* auth/saml-idp: add to app router

* fix
2024-09-04 13:05:38 +00:00
Lauris Skraucis 1edd255846 fix: use dbWrite in OrganizationsTeamsRepository (#16485) 2024-09-04 11:17:13 +00:00
Lauris Skraucis f538877349 chore: v2 bump platform libraries (#16473) 2024-09-04 11:14:05 +00:00
Keith Williams d759669c1e chore: Remove unnecessary log (#16426) 2024-09-04 11:09:10 +00:00
Peer Richelsen 71ce87ca21 Update bug_report.yml (#16089) 2024-09-04 11:05:43 +00:00
Calcom BotandReplexica 7eae004aa2 feat: update translations (#16477)
Co-authored-by: Replexica <support@replexica.com>
2024-09-04 10:39:16 +00:00
Udit TakkarandMorgan 6cd427bc78 feat: availability in instant meeting (#16424)
* chore: save progress

* feat: add isAvailable functionality

* fix: type error

* chore: add in builder

* tests: add unit tests

* chore: improvements

* chore: tests

* chore

* chore: remove log

* fix: tets

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-09-03 14:28:15 +00:00
Darshil MharaurandAmit Sharma b8d67c5a41 fix: Adding yearly title to preview page (#16441)
* initial commit

* added year tag to the occurences field

* Revert yarn.lock

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-09-03 09:34:05 -04:00
Calcom BotandReplexica 42953548b6 feat: update translations (#16475)
Co-authored-by: Replexica <support@replexica.com>
2024-09-03 13:20:21 +00:00
Hariom Balhara 28c631e108 test: Unit Tests for FormBuilderField and BookingFields components (#16162)
* Remove use of location from FormBuilder

* Add tests

* FormBuilderField and BookingFields tests

* More tests

* Remove always true if condition

* Fix ui import mockig that got broken after the last merge
2024-09-03 08:58:34 -04:00
Keith Williams 263a96e660 chore: Add 3 more languages to AI translations (#16469) 2024-09-03 13:34:50 +01:00
Benny Joo d059b1399b chore: App router migration - /auth/forgot-password/*, /auth/login, /auth/logout (#16467)
* migrate auth/forgot-password/*, auth/login, auth/logout

* revert

* fix forgot password

* fix folder structure

* fix logout

* fix middleware

* remove TODO log

* refactor logout
2024-09-03 18:53:16 +09:00
Benny Joo 12a9c74eb6 chore: App router migration - Add method to BookingRepository & Extract /videos/**/* into /modules and /lib (#16466)
* meeting-ended: refactor and extract client code into /modules

* meeting-not-started: refactor and extract client code into /modules

* meeting-not-found: fix getData

* BookingRepository: add a new method

* video: refactor and extract client code into /modules

* refactor

* refactor getServerSideProps

* use findBookingByUid for more getServerSideProps functions

* rename findBookingByUid -> findBookingByUidWithOptionalSelect

* add safety
2024-09-03 18:53:02 +09:00
Keith Williams 6bb60e36fd chore: Log cold starts of /v1/slots (#16468) 2024-09-03 06:54:12 +00:00
Calcom BotandReplexica c1eb2512b6 feat: update translations (#16462)
Co-authored-by: Replexica <support@replexica.com>
2024-09-03 15:43:44 +09:00
Benny Joo 55d75a9786 chore: App router migration (/workflows) fix metadata and remove duplicate file for getStaticProps (#16452)
* Fix metadata

* add use client directive to workflow component
2024-09-02 23:00:48 +05:30
779eb19e7a feat: browser push notifications (#14888)
* feat: web push notifications feature

* Revert yarn.lock changes

* added new env variables requirement in .env.example

* moved useNotifications hook in packages/lib/hooks

* fix: bug

* use i18n

* chore: move to new file

* chore: add yarn.lock

* Update .env.example

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>

* chore: add instruction for brave browser

* fix: tooltip

* chore: use enum

* chore

* small update

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-09-02 14:39:51 +00:00
Hariom BalharaandUdit Takkar 5d8ed130de Fix unconfirmed bookings blocking slots being fetched for all troubleshooters (#16439)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-09-02 16:12:11 +05:30
Calcom BotandReplexica 971d9ff0d8 feat: update translations (#16436)
Co-authored-by: Replexica <support@replexica.com>
2024-08-31 15:17:34 +00:00
Anik Dhabal Babu b6bcc43655 feat: add "somewhere else" as a location option (#15108)
* feat: add somewhere else as a location

* update

* update

* Update BookingFields.tsx

* update

* update

* chore: changes

* fix
2024-08-30 16:38:09 -04:00
Peer Richelsen b576fc8c26 feat: synthflow app (#16420) 2024-08-30 16:38:01 +00:00
Kacper 821843bc6c Fallback to next app default language (en) when user didn't specify locale. (#16425) 2024-08-30 21:37:10 +05:30
Lauris Skraucis d19e3e53f0 fix: recurring booking idempotency key after cancel (#16414)
* fix: recurring booking idempotency key after cancel

* refactor: move logic to updateMany of prisma middleware
2024-08-30 12:07:53 +00:00
Morgan 40fee7af51 chore: reorganize event type files (#16393)
* chore: reorganize event type files

* fixup! chore: reorganize event type files

* fixup! Merge branch 'main' into refactor-event-types-components-folder
2024-08-30 10:41:37 +00:00
Keith Williams f3462776e8 perf: Improve loading existing bookings via API (#16410) 2024-08-30 11:10:44 +01:00
Calcom BotandReplexica 57e9ef7320 feat: update translations (#16401)
Co-authored-by: Replexica <support@replexica.com>
2024-08-29 23:26:01 +00:00
88f46a6b20 fix: Broken reschedule page for migrated user's booking through request-reschedule (#16068)
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
2024-08-29 14:35:32 -07:00
Omar López 17f537774c chore: v4.4.5 (#16402)
release.yml / release (push) Canceled after 0s
2024-08-29 14:26:05 -07:00
3aa6878c27 fix: Set teamMemberEmail server side for CRM RR Skip (#16367)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2024-08-29 14:16:48 -07:00
Benny JooandJoe Au-Yeung ae55e5e264 chore: App Router Migration - Ensure isBookingPage is correctly flagged before being passed to PageWrapper (#16362)
* Ensure adding isBookingPage is done in components in /modules, not in /pages

* Detect isBookingPage prop coming from Page and add it to page wrapper app dir

* fix build

* fix

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-08-29 17:03:02 -04:00
Hariom Balhara 36ecece181 fix: Add version to embed.js and preview.js (#16182) 2024-08-29 20:54:41 +00:00
Hariom Balhara 1a0448910b fix: Flicker b/w Dark and Light theme (#16042) 2024-08-29 13:14:12 -07:00
ad5d645347 feat: Implement editable OOO events feature (#15932)
* feat: Implement edit ooo feature

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Refactor button text and gap between buttons

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix entry create and edit edge cases

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Change "edit ooo event" modal title

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Add tooltips to entry eidt and delete buttons

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Reset form on form close event

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Refactor reset-form function

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: Rename create or edit OOO as suggested

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Refactor component and function names

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Improve ooo events email notifications

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Fix email templates

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* feat: Add e2e test for ooo event edit functionality

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* refactor: Create separate test for edit ooo event

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Remove extra user from redirect user configuration test

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: code refactor

* remove log

* chore: Update ooo-input-schema uuid data type

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* fix: Address feedbacks

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>

* chore: code splitting and refactor

---------

Signed-off-by: Souptik Datta <souptikdatta2001@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-29 17:28:35 +00:00
Calcom BotandReplexica 54f7c8328d feat: update translations (#16400)
Co-authored-by: Replexica <support@replexica.com>
2024-08-29 17:13:06 +00:00
Keith Williams 36a1715ece perf: run parallel bookings queries (#16398) 2024-08-29 16:37:18 +00:00
Keith Williams edaa6b1de8 feat: Add more AI translation languages (#16381)
* feat: Add more AI translation languages

* Removed pull-request: true

* checking out the PRs i18n.json as well

* Update i18n.json

* Update i18n.json
2024-08-29 17:18:48 +01:00
Calcom BotandReplexica f47b2066c4 feat: update translations (#16396)
Co-authored-by: Replexica <support@replexica.com>
2024-08-29 15:58:25 +00:00
Keith Williams 7337b3de1e fix: GitHub access token for Replexica (#16395) 2024-08-29 15:02:05 +00:00
Keith Williams 933ffcbf69 chore: Move Replexica to run on pushes to main (#16394) 2024-08-29 14:22:42 +00:00
Syed Ali ShahbazandMorgan cf50566a10 feat: Org Webhooks API V2 (#16274)
* --init

* add is webhook in org guard

* --

* doc

* e2e

* --feedback

* --

* fix typo

* delete unnecessary file

* fix v2 e2e

* fix v2 e2e

* fix e2e

* fix v2 e2e

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-08-29 17:52:42 +04:00
Sy SagarandAmit Sharma 4424aa5b38 chore: Bulk update desc (#16343)
* chore(locale-en): update bulk modal description

* chore: schedule bulk-modal description

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-08-29 08:16:13 -04:00
Udit TakkarandJoe Au-Yeung 19634073e9 refactor: improve team members page performance (#16155)
* fix: simplify workflow page and improve load time

* chore: use new endpoint

* chore: save progress

* refactor: code

* refactor: remove not requried code

* chore: remove schema

* chore: fix typ

* chore: improve

* chore: change name

* chore: remove unused

* chore: remove page

* refactor: teams page

* feat: add auto scroll

* chore: create validate unique invite

* fix: auth check

* fix: optimistic update

* chore

* fix: add loading

* fix: improvements

* chore: remove

* chore

* chore: fix teams page

* fix: team profile page

* fix: appearance page

* fix: sso view

* fix: type err

* feat: defer loading connected Apps

* fix: type err

* fix: type error

* fix: type err

* fix: connectedApps type

* chore: move

* chore: missing export

* feat: add search by name

* fix: display role change

* fix: use setInfiniteData

* chore: save progress

* test: add unit tests for loading members

* fix: test

* chore: update name

* fix: bugs and improvements

* chore: change variable name

* test: add tests for checkCanAccessMembers

* refactor: performance

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-08-29 13:32:54 +05:30
Keith Williams 4284e86e51 perf: Removed unused attendees count (#16391) 2024-08-29 01:13:48 -04:00
Max Prilutskiy f7c1d62bb8 chore: [skip i18n] switch pr's i18n job to in-branch mode (#16390) 2024-08-29 01:32:03 +00:00
795a228a44 fix: allow all sign in methods when being invited (#13436)
* allow all sign in methods

* changes

* update

* add checks

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-08-28 15:24:13 +00:00
Nicolaos MoscholiosandAnik Dhabal Babu 0190ca6711 fix: use userPhone instead of phone for switch case (#16384)
* fix: use `userPhone` instead of `phone` for switch case

Currently this switch case never gets to the last case ("phone") as the type expected is wrong.

Here the type may have value `userPhone` and not `phone`.

* fix another case

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-28 13:37:02 +00:00
Morgan ee8cc81862 fix: api v2 booking controller with api key (#16358)
* fix: api v2 booking controller with api key

* add test

* fixup! add test

* fixup! Merge branch 'fix-booking-controller-api-key' of github.com:calcom/cal.com into fix-booking-controller-api-key

* fixup! Merge branch 'fix-booking-controller-api-key' of github.com:calcom/cal.com into fix-booking-controller-api-key
2024-08-28 16:05:14 +03:00
Joe Au-Yeung 6b244fc5b5 fix: Move future/workflow/[workflow] getStaticProps into separate file (#16124) 2024-08-28 08:53:32 -04:00
Udit Takkarandsean-brydon 356ecfe2fa chore: display first 3 organizers photo in round robin event (#16331)
* chore: display random organizer photo in round robin event

* fix: avatar on instant booking

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-08-28 12:31:06 +00:00
Keith Williams bd410cf09f chore: Use pull-request feature from Replexica (#16383) 2024-08-28 19:24:56 +09:00
Keith WilliamsandReplexica 3c5ab5e8d2 chore: add replexica to ci (#16306)
* chore: Add replexica for AI i18n

* Add write permission

* moved i18n to repo root

* Added checkout to i18n job

* Added contents:read

* Added actions: write for testing

* Updated contents: write

* feat: add missing translations

* Checking out the i18n files from PR head sha

* Added the rest of the languages

* Added i18n to list of required checks

* Added a comment explaining the custom common.json checkout

* Commented out languages to not crush Replexica usage

* Add a fetch-depth: 2

* Supporting fetch of external fork branch

* Put back the dynamic sha

* Added a check for null so this can run manually and not just on PRs

* Removed comments

* feat: update translations

* Reverted de and fr to re-run with new settings

* feat: update translations

* Reverted de and fr to re-run with new settings

* feat: update translations

* Removed crowdin

* Limited languages to major ones for 1st pass

* feat: update translations

* Fixing es test

* Reverted yarn.lock changes

---------

Co-authored-by: Replexica <support@replexica.com>
2024-08-28 11:16:47 +03:00
Udit Takkar d938e67e65 fix: require confirmation cal video bug (#16376)
* fix: require confirmation cal video bug

* chore: update location

* fix: tests

* chore

* test: add confirm booking test

* chore: add location in test

* fix: types
2024-08-27 22:40:00 +00:00
sean-brydon 6a90518231 use goblin url (#16370) 2024-08-27 22:30:18 +00:00
Benny Joo 47e8907184 chore: App Router Migration - Fix metadata util used in App Router and remove duplicate code (#16346) 2024-08-27 13:28:09 -04:00
Keith Williams d6096f459b chore: v4.4.4 (#16372)
release.yml / release (push) Canceled after 0s
2024-08-27 22:38:34 +09:00
Keith Williams 8839f08257 fix: API v2 E2E Docker rate limiting (#16374) 2024-08-27 12:25:53 +00:00
Hariom Balhara 6104add6bd fix: More Stability for FormBuilder by unit tests (#16129)
* Remove use of location from FormBuilder

* Add tests

* Improve error message in test

* Bust cache
2024-08-27 08:14:19 -04:00
Johannes ZellnerandAnik Dhabal Babu 7aae55f417 fix: Avoid typescript error for Nextjs usePathname() (#16368)
* Avoid typescript error for Nextjs usePathname()

#fixes 16323

* Update Shell.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-27 07:46:55 +00:00
Keith Williams ef1b5067c2 fix: Undefined user in insights data (#16365)
* fix: Null user in insights data

* renaming
2024-08-26 17:43:04 -07:00
Anik Dhabal Babu 4caa095d52 fix: color restrictions issue (#16360)
* fix: color restrictions issue

* Update EventAdvancedTab.tsx
2024-08-27 07:59:09 +09:00
sean-brydonandAlex van Andel c8e20b0c57 feat: block pending meetings for requires confirmation (#16335)
* feat: block pending meetings for requires confirmation

* add i18n

* fix:typecheck

* fix(requires-confirmation-block): Fixes type error

* fix(requires-confirmation-block): Fixes type error

* Tweak to set BookingWhereInput as the explicit type instead of as

* fix: type error

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-08-26 09:54:58 -04:00
Anik Dhabal Babu 2fb4289798 feat: tracking and displaying last used login method (#16356) 2024-08-26 12:13:42 +00:00
6529b49c3c feat: bookingFields identifier supports - and _ characters (#16353)
* now bookingFields identifier supports - and _ characters but no special characters

* fix: unit test

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-26 12:15:02 +02:00
758423ecf9 fix: Text for cookie consent checkbox in various languages (#16355)
* Fix text for cookie consent checkbox in various languages

* update

---------

Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-08-26 09:03:20 +00:00
Somay ChauhanandMorgan Vernay ef11755488 feat: add recurringEvent attribute to api/v2/event-types (#16251)
* feat: added recurringEvent attribute to api/v2/event-types

* changed `recurringEvent` to `recurrence`

* Update CHANGELOG.md

* remove /dist/ import

* change `as` to `satisfies`

* testing some changes

* Revert "testing some changes"

This reverts commit 464d2611ed19dbd6ae390332b4d6dd5884bcae3b.

* Update recurrence.input.ts

* fix: apiv2 tsconfig enums

* Update CHANGELOG.md

* removed dev imports

* Update package.json

* Update api-request.spec.ts

* Update api-request.spec.ts

* added recurrence to update-event-type.input

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-08-26 11:42:35 +03:00
Steve Xu 03051a2bb1 fix: fix some missing translation (#16351) 2024-08-26 10:38:23 +02:00
CodeShakingSheep 7a57f57e99 chore: Make URLs for terms and privacy policy customizable via .env vars (#16350) 2024-08-26 10:34:07 +02:00
b6d31129a3 feat: Add rescheduledBy & canceledBy fields in the DB (#15337)
* feat: Add rescheduledBy & canceledBy fields in the DB

* fix: type check

* fix: type check

* fix: use session user email for reschedule

* fix: unit test

* feat: db field email validation

* feat: rescheduledBy and cancelledBy in webhooks

* revert unrelated changes.

* make session user secondary, default to Anonymous

* if condition not required

* Make cancelledBy optional

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

* update cancel booking type

* fix: update cancelledBy in db when requesting reschedule

* remove default value for fields

* fix: type check

* feat: manage fields via api v1

* fix: add fields in booking read api v1

* test: expand to cover new fields

* fix: use cancelledBy param on booking page

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-08-25 11:31:46 +02:00
Steve XuandAnik Dhabal Babu 34075ee303 fix: multiple_duration_timeUnit_short Chinese translation (#16339)
* fix: multiple_duration_timeUnit_short Chinese translation

* fix: i18n

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-25 09:30:33 +00:00
Steve Xu 89f711d929 fix: fixing i18n key signing_up_terms is lost (#16338)
* fix: fixing i18n key signing_up_terms is lost

* fix: fixing i18n key signing_up_terms is lost
2024-08-25 10:52:30 +02:00
623f0c7d93 fix: visiting organization without access shows empty page (#15103)
* fix : visiting organization without access shows empty page

* feat: add serversideprops for navigating user before page load

* Update packages/features/ee/organizations/pages/settings/getServerSidePropsMembers.tsx

* fix: workaround e2e fix

---------

Co-authored-by: Shaik-Sirajuddin <sirajudddinshaik30@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-08-24 09:05:50 +00:00
Rajiv SahalandMorgan c67877cc51 chore: rename calendar settings atom (#16329)
* update stripe service

* init endpoints for stripe connect atom

* update stripe controller

* update stripe service

* add generic function to create app credential

* update stripe controller

* update stripe service

* add handler to construct redirectUrl

* restructure files for better readibility

* better naming

* replace STRIPE_PRIVATE_KEY with STRIPE_API_KEY

* fix naming

* remove unused query param

* frontend for stripe connect atom

* custom hooks for stripe connect atom

* abstract response schema into separate file

* fixup

* input dto for stripe controller

* update stripe module

* fixing naming

* fix naming

* rename atom from `SelectedCalendarSettings` to `SelectedCalendarsSettings`

* revert stripe controllers commit

* fixup

* bring back stripe module

* remove stripe atom

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-08-23 10:40:22 +00:00
Alex van Andel fd50728169 Revert "fix: uncaught types errors in invite members team handler (#16109)" (#16322)
This reverts commit a091c60618.
2024-08-23 05:40:30 +00:00
Omar López 74567909b2 fix: Migrates E2E reports to external repo (#16326) 2024-08-22 14:28:54 -07:00
sean-brydonandKeith Williams d84f08c998 actually delete the item if no options or input is found (#16298)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-08-22 23:28:49 +09:00
Keith Williams 685eadc30b chore: v4.4.3 (#16321)
release.yml / release (push) Canceled after 0s
2024-08-22 14:28:17 +00:00
Udit TakkarandHariom Balhara c821f15a14 fix: error message when email exists (#16022)
* fix: error message when email exists

* chore: change postion of error

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-08-22 14:20:41 +00:00
Morgan 8a972a74c6 refactor: bulk update modal logic shifted to web wrapper (#16315) 2024-08-22 13:42:43 +00:00
cf76cf7aae feat: Multiple Options Paste and Routing Form improvements related to Select fields (#15706)
* Add handlePaste function and integrate it with options fields

* Prevents function running if user does not paste a list, allows list items to be pasted without deleting following items

* Subs useState for rhf useWatch to fix issues with multiple form fields

* Sets options array as optional property in zod schema

* Assigns default value to options array to fix possibly undefined type errors

* Sets placedholder property as optional

* maps selectText to options on mount

* Removes comma & semicolon delimiting for pasted lists (new line only) & defines regex as a constant

* Sets default value of placeholder to empty string, provides default fallback for component text field if placeholder is omitted.

* Fix type error on placeholder

* Extracts handlePaste to service function & tests thoroughly

* Some improvements and tests arent needed

* More fixes

* Fix reporting as well

* TS fixes

* Do transforms to and fro from label

* Add unit tests

* Unit test reporting

* Test Improvements

* Self review feedback addressed

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-22 13:38:46 +00:00
Keith Williams 4dd762681c chore: Update how E2E test suite checks for Stripe (#16319) 2024-08-22 13:19:50 +00:00
Joe Au-YeungandCarina Wollendorfer 399a23a52f fix: Disable ICS file generation when destination calendar is Outlook (#16247)
* Set the organizer in the organizer field

* Create generateIcsFile

* Add role enum and only disable for organizers

* Move emails to generateIcsFile

* Add new ics func to new emails

* Fix tests

* Type fix

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-08-22 09:49:53 +00:00
Udit Takkar 342f935aba fix: create event type refresh bug (#16301)
* fix: create event type refresh bug

* chore

* fix: type err
2024-08-22 09:43:09 +00:00
27e4210693 fix: atoms icons and build issues (#16248)
* fix: atoms icons and build issues

* fixup! fix: atoms icons and build issues

* fixup! fixup! fix: atoms icons and build issues

* fix import paths

* rename SchedulingType to something more specific

* fix: Invalid define value (must be an entity name or valid JSON syntax)

* chore: atoms build CI job

* chore: enable atoms build in CI

* fix build

* fixup! fix build

* fixup! fixup! fix build

* chore: replace calendar-days icon with calendar

* fixup! Merge branch 'fix-atoms-build-icons' of github.com:calcom/cal.com into fix-atoms-build-icons

* fixup! Merge branch 'fix-atoms-build-icons' of github.com:calcom/cal.com into fix-atoms-build-icons

* fixup! Merge branch 'fix-atoms-build-icons' of github.com:calcom/cal.com into fix-atoms-build-icons

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
2024-08-22 08:32:39 +00:00
Amit SharmaandUdit Takkar 66cb2bcc13 fix: Workflow action 'email to host' only sends to one host of team e… (#14767)
* fix: Workflow action 'email to host' only sends to one host of team event

* add in missing files

* fix host logic

* uncomment working test

* code refactor

* use connected calendar email

* add changes to util file

* fix: extend eventType info in sendCancelledReminders

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-08-22 08:04:31 +00:00
Akash RajpurohitandAnik Dhabal Babu 30c31d3585 feat: ✨ support for adding script url added for umami (#16300)
* feat: ✨ support for adding script url added for umami

This will help self hosters in setting up umami.

* feat: ✨ set default value for script URL in the form field

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-22 09:39:03 +02:00
Omar López e17303ea88 fix: permissions issue in pr labeler (#16245) 2024-08-22 01:22:07 +01:00
Anik Dhabal BabuandAmit Sharma bebf1d80a7 fix: round robin event rejection problem (#16279)
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-08-21 17:07:30 +00:00
Benny Joo 5bcefb0b3e settings/teams/[id]/sso is not needed (#16288) 2024-08-21 16:47:34 +00:00
Harshit SharmaandAnik Dhabal Babu f52de7331f fix: UI Breaks When Entering a Long Username in Profile Settings (#16304)
* feat: Improve UI for username availability in UsernameTextfield component

* fix: Fixed UI for username availability in UsernameTextfield component

* Update UsernameTextfield.tsx

* fix: FixedUI for username availability in PremiumTextfield and UsernameTextfield components

* fix: Fixed the PremiumTextField.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-08-21 16:17:14 +00:00
MorganandLauris Skraucis 1690770d6a fix: check profiles and orgId from user.profiles apiv2 (#16249)
* fix: check profiles and orgId from user.profiles apiv2

* fixup! fix: check profiles and orgId from user.profiles apiv2

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2024-08-21 15:15:48 +00:00
Rajiv Sahal 7759e6a029 chore: update platform billing plans (#16233)
* update links for no platform plan

* sync platform pricing with one we have in website
2024-08-21 20:14:15 +05:30
583 changed files with 39361 additions and 5799 deletions
+18 -1
View File
@@ -348,6 +348,15 @@ APP_ROUTER_GETTING_STARTED_STEP_ENABLED=0
APP_ROUTER_APPS_ENABLED=0
APP_ROUTER_VIDEO_ENABLED=0
APP_ROUTER_TEAMS_ENABLED=0
APP_ROUTER_AVAILABILITY_ENABLED=0
APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED=0
APP_ROUTER_AUTH_LOGIN_ENABLED=0
APP_ROUTER_AUTH_LOGOUT_ENABLED=0
APP_ROUTER_AUTH_NEW_ENABLED=0
APP_ROUTER_AUTH_SAML_ENABLED=0
APP_ROUTER_AUTH_ERROR_ENABLED=0
APP_ROUTER_AUTH_PLATFORM_ENABLED=0
APP_ROUTER_AUTH_OAUTH2_ENABLED=0
# disable setry server source maps
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
@@ -362,10 +371,18 @@ TASKER_ENABLE_EMAILS=0
# Ratelimiting via unkey
UNKEY_ROOT_KEY=
# Used for Cal.ai Enterprise Voice AI Agents
# https://retellai.com
RETELL_AI_KEY=
# Used to disallow emails as being added as guests on bookings
BLACKLISTED_GUEST_EMAILS=
# Used to allow browser push notifications
# You can use: 'npx web-push generate-vapid-keys' to generate these keys
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# Custom privacy policy / terms URLs (for self-hosters: change to your privacy policy / terms URLs)
NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL=
NEXT_PUBLIC_WEBSITE_TERMS_URL=
+47 -79
View File
@@ -1,81 +1,49 @@
---
name: Bug report
description: "Please make sure you have checked for any duplicate issues."
title: "(bug)"
about: Please make sure you have checked for any duplicate issues
title: ""
labels: ["🐛 bug"]
assignees: []
body:
- type: textarea
id: issue-summary
attributes:
label: Issue Summary
description: A summary of the issue. This needs to be a clear detailed-rich summary.
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to Reproduce
value: |
1. (for example) Went to ...
2. Clicked on...
3. ...
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: other-information
attributes:
label: Other information
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem.
validations:
required: false
- type: checkboxes
id: environment
attributes:
label: Environment
options:
- label: app.cal.com
- label: Self-hosted Cal
- type: textarea
id: desktop-version
attributes:
label: Desktop (please complete the following information)
description: |
examples:
- **OS**: [e.g. iOS]
- **Browser**: [e.g. chrome, safari]
- **Version**: [e.g. 22]
value: |
- OS:
- Node:
- npm:
render: markdown
validations:
required: true
- type: markdown
id: nodejs-version
attributes:
value: |
#### Node.JS version
[e.g. v18.15.0]
- type: markdown
id: anything-else
attributes:
value: |
#### Anything else?
- Screen recording, console logs, network requests: You can make a recording with [Loom](https://www.loom.com).
- Anything else that you think could be an issue?
assignees: ""
---
### Is your proposal related to a problem?
<!--
Provide a clear and concise description of what the problem is.
For example, "I'm always frustrated when..."
-->
(Write your answer here.)
### Describe the solution you'd like
<!--
Provide a clear and concise description of what you want to happen.
-->
(Describe your proposed solution here.)
### Describe alternatives you've considered
<!--
Let us know about other solutions you've tried or researched.
-->
(Write your answer here.)
### Additional context
<!--
Is there anything else you can add about the proposal?
You might want to link to related issues here, if you haven't already.
-->
(Write your answer here.)
### Requirement/Document
<!--
Is there any type of document that could support that feature?
-->
(Share it here.)
+5
View File
@@ -34,6 +34,11 @@ jobs:
uses: ./.github/workflows/api-v2-production-build.yml
secrets: inherit
build-atoms:
name: Production builds
uses: ./.github/workflows/atoms-production-build.yml
secrets: inherit
build:
name: Production builds
uses: ./.github/workflows/production-build-without-database.yml
@@ -0,0 +1,40 @@
name: Atoms production Build
on:
workflow_call:
jobs:
build:
name: Build atoms
permissions:
contents: read
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- name: Cache atoms production build
uses: buildjet/cache@v4
id: cache-atoms-build
env:
cache-name: atoms-build
key-1: ${{ hashFiles('yarn.lock') }}
key-2: ${{ hashFiles('packages/platform/atoms/**.[jt]s', 'packages/platform/atoms/**.[jt]sx', '!**/node_modules') }}
key-3: ${{ github.event.pull_request.number || github.ref }}
# Ensures production-build.yml will always be fresh
key-4: ${{ github.sha }}
with:
path: |
**/dist/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
- run: |
export NODE_OPTIONS="--max_old_space_size=8192"
if [ ${{ steps.cache-atoms-build.outputs.cache-hit }} == 'true' ]; then
echo "Cache hit for atoms build. Skipping build."
else
rm -rf packages/platform/atoms/node_modules
yarn install
yarn workspace @calcom/atoms run build
fi
shell: bash
+7
View File
@@ -4,6 +4,11 @@ on:
delete:
branches-ignore: [main, gh-pages]
permissions:
contents: write
issues: write
pull-requests: write
# ensures that currently running Playwright workflow of deleted branch gets cancelled
concurrency:
group: ${{ github.event.ref }}
@@ -20,7 +25,9 @@ jobs:
- name: Checkout GitHub Pages Branch
uses: actions/checkout@v2
with:
repository: calcom/test-results
ref: gh-pages
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: Set Git User
# see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
run: |
-42
View File
@@ -1,42 +0,0 @@
name: Crowdin Action
on:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
synchronize-with-crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: crowdin action
uses: crowdin/github-action@v1.13.0
with:
# upload sources
upload_sources: true
# upload translations (& auto-approve 'em)
upload_translations_args: '--auto-approve-imported'
upload_translations: true
push_translations: true
# download translations
download_translations: true
# GH config
commit_message: "New Crowdin translations by Github Action"
localization_branch_name: main
create_pull_request: false
env:
GITHUB_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+3
View File
@@ -45,6 +45,9 @@ jobs:
- 5432:5432
redis:
image: redis:latest
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
ports:
- 6379:6379
options: >-
+25
View File
@@ -0,0 +1,25 @@
name: Run i18n AI automation
on:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-main
cancel-in-progress: false
jobs:
i18n:
name: Run i18n
runs-on: buildjet-2vcpu-ubuntu-2204
permissions:
actions: write
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: replexica/replexica@main
env:
GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
with:
api-key: ${{ secrets.CI_REPLEXICA_API_KEY }}
pull-request: true
+9 -1
View File
@@ -14,7 +14,15 @@ jobs:
steps:
- name: Label PR as ready for E2E
if: github.event.review.state == 'approved'
uses: actions-ecosystem/action-add-labels@v1
uses: actions/github-script@v4
with:
script: |
await github.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: process.env.labels.split(', '),
})
env:
github_token: ${{ secrets.READY_FOR_E2E_PAT }}
labels: 'ready-for-e2e'
+7
View File
@@ -132,6 +132,13 @@ jobs:
uses: ./.github/workflows/api-v2-production-build.yml
secrets: inherit
build-atoms:
name: Production builds
needs: [changes, check-label, deps]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/atoms-production-build.yml
secrets: inherit
build:
name: Production builds
needs: [changes, check-label, deps]
+3 -1
View File
@@ -16,7 +16,9 @@ jobs:
- name: Checkout GitHub Pages Branch
uses: actions/checkout@v4
with:
repository: calcom/test-results
ref: gh-pages
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: Set Git User
# see: https://github.com/actions/checkout/issues/13#issuecomment-724415212
run: |
@@ -65,7 +67,7 @@ jobs:
- name: Output Report URL as Workflow Annotation
id: url
run: |
FULL_HTML_REPORT_URL=https://calcom.github.io/cal.com/$HTML_REPORT_URL_PATH
FULL_HTML_REPORT_URL=https://calcom.github.io/test-results/$HTML_REPORT_URL_PATH
echo "::notice title=📋 Published Playwright Test Report::$FULL_HTML_REPORT_URL"
echo "link=$FULL_HTML_REPORT_URL" >> $GITHUB_OUTPUT
- name: Find Comment
+6
View File
@@ -14,6 +14,8 @@ const schemaBookingBaseBodyParams = Booking.pick({
startTime: true,
endTime: true,
status: true,
rescheduledBy: true,
cancelledBy: true,
}).partial();
export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(schemaQueryUserId.partial());
@@ -33,6 +35,8 @@ const schemaBookingEditParams = z
title: z.string().optional(),
startTime: iso8601.optional(),
endTime: iso8601.optional(),
cancelledBy: z.string().email({ message: "Invalid Email" }).optional(),
rescheduledBy: z.string().email({ message: "Invalid Email" }).optional(),
// Not supporting responses in edit as that might require re-triggering emails
// responses
})
@@ -88,4 +92,6 @@ export const schemaBookingReadPublic = Booking.extend({
status: true,
responses: true,
fromReschedule: true,
cancelledBy: true,
rescheduledBy: true,
});
+1 -1
View File
@@ -30,7 +30,7 @@ enum locales {
RO = "ro",
NL = "nl",
PT_BR = "pt-BR",
// ES_419 = "es-419", // Disabled until Crowdin reaches at least 80% completion
ES_419 = "es-419",
KO = "ko",
JA = "ja",
PL = "pl",
@@ -30,40 +30,59 @@ async function authMiddleware(req: NextApiRequest) {
}
}
const userWithBookingsAndTeamIds = await prisma.user.findUnique({
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
select: {
email: true,
bookings: true,
teams: {
select: {
teamId: true,
},
});
if (!user) throw new HttpError({ statusCode: 404, message: "User not found" });
const filteredBookings = user?.bookings?.filter((booking) => booking.id === id);
const userIsHost = !!filteredBookings?.length;
const bookingsAsAttendee = prisma.booking.findMany({
where: {
id,
attendees: { some: { email: user.email } },
},
});
const bookingsAsEventTypeOwner = prisma.booking.findMany({
where: {
id,
eventType: {
owner: { id: userId },
},
},
});
const bookingsAsTeamOwnerOrAdmin = prisma.booking.findMany({
where: {
id,
eventType: {
team: {
members: {
some: { userId, role: { in: ["ADMIN", "OWNER"] }, accepted: true },
},
},
},
},
});
if (!userWithBookingsAndTeamIds) throw new HttpError({ statusCode: 404, message: "User not found" });
const [resultOne, resultTwo, resultThree] = await Promise.all([
bookingsAsAttendee,
bookingsAsEventTypeOwner,
bookingsAsTeamOwnerOrAdmin,
]);
const userBookingIds = userWithBookingsAndTeamIds.bookings.map((booking) => booking.id);
const teamBookingsAsOwnerOrAdmin = [...resultOne, ...resultTwo, ...resultThree];
const userHasTeamBookings = !!teamBookingsAsOwnerOrAdmin.length;
if (!userBookingIds.includes(id)) {
const teamBookings = await prisma.booking.findUnique({
where: {
id: id,
eventType: {
team: {
id: {
in: userWithBookingsAndTeamIds.teams.map((team) => team.teamId),
},
},
},
},
});
if (!teamBookings) {
throw new HttpError({ statusCode: 403, message: "You are not authorized" });
}
}
if (!userIsHost && !userHasTeamBookings)
throw new HttpError({ statusCode: 403, message: "You are not authorized" });
}
export default authMiddleware;
@@ -6,6 +6,9 @@ import prisma from "@calcom/prisma";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
import checkParentEventOwnership from "../_utils/checkParentEventOwnership";
import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission";
/**
* @swagger
* /event-types/{id}:
@@ -49,9 +52,18 @@ async function checkPermissions(req: NextApiRequest) {
const { userId, isSystemWideAdmin } = req;
const { id } = schemaQueryIdParseInt.parse(req.query);
if (isSystemWideAdmin) return;
/** Only event type owners can delete it */
const eventType = await prisma.eventType.findFirst({ where: { id, userId } });
const eventType = await prisma.eventType.findFirst({ where: { id } });
if (!eventType) throw new HttpError({ statusCode: 403, message: "Forbidden" });
/** Only event type owners or team owners for team events can delete it */
if (eventType.teamId) return await checkTeamEventEditPermission(req, { teamId: eventType.teamId });
if (eventType.parentId) return await checkParentEventOwnership(req);
if (eventType.userId && eventType.userId !== userId)
throw new HttpError({ statusCode: 403, message: "Forbidden" });
}
export default defaultResponder(deleteHandler);
@@ -18,12 +18,8 @@ export default async function checkParentEventOwnership(req: NextApiRequest) {
/** These are already parsed upstream, we can assume they're good here. */
const parentId = Number(body.parentId);
const parentEventType = await prisma.eventType.findUnique({
where: {
id: parentId,
},
select: {
teamId: true,
},
where: { id: parentId },
select: { teamId: true },
});
if (!parentEventType) {
@@ -44,7 +40,8 @@ export default async function checkParentEventOwnership(req: NextApiRequest) {
where: {
teamId: parentEventType.teamId,
userId: userId,
OR: [{ role: "OWNER" }, { role: "ADMIN" }],
role: { in: ["ADMIN", "OWNER"] },
accepted: true,
},
});
@@ -10,21 +10,17 @@ export default async function checkTeamEventEditPermission(
req: NextApiRequest,
body: Pick<z.infer<typeof schemaEventTypeCreateBodyParams>, "teamId" | "userId">
) {
const { isSystemWideAdmin } = req;
let userId = req.userId;
if (isSystemWideAdmin && body.userId) {
userId = body.userId;
}
if (body.teamId) {
const membership = await prisma.membership.findFirst({
where: {
userId,
userId: req.userId,
teamId: body.teamId,
accepted: true,
role: { in: ["ADMIN", "OWNER"] },
},
});
if (!membership?.role || !["ADMIN", "OWNER"].includes(membership.role)) {
if (!membership) {
throw new HttpError({
statusCode: 403,
message: "No permission to operate on event-type for this team",
+8
View File
@@ -3,6 +3,7 @@ import utc from "dayjs/plugin/utc";
import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
@@ -17,8 +18,15 @@ import { getHTTPStatusCodeFromError } from "@trpc/server/http";
dayjs.extend(utc);
dayjs.extend(timezone);
let isColdStart = true;
async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (isColdStart && IS_PRODUCTION) {
console.log("Cold start of /v1/slots detected");
isColdStart = false;
}
const { usernameList, ...rest } = req.query;
let slugs = usernameList;
if (!Array.isArray(usernameList)) {
@@ -41,7 +41,7 @@ export async function canUserAccessTeamWithRole(
/** If not ADMIN then we check if the actual user belongs to team and matches the required role */
if (!isSystemWideAdmin) args.where = { ...args.where, members: { some: { userId, role } } };
const team = await prisma.team.findFirst(args);
if (!team) throw new HttpError({ statusCode: 401, message: `Unauthorized: ${role.toString()} required` });
if (!team) throw new HttpError({ statusCode: 401, message: `Unauthorized: OWNER or ADMIN role required` });
return team;
}
-3
View File
@@ -1,6 +1,3 @@
# Set the version of docker compose to use
version: "3.9"
# The containers that compose the project
services:
db:
@@ -1,90 +1,332 @@
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, it, expect } from "vitest";
import { describe, it, expect, test } from "vitest";
import type { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import authMiddleware from "../../../pages/api/bookings/[id]/_auth-middleware";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
describe("Bookings auth middleware", () => {
it("Returns 403 when user has no permission to the booking", async () => {
const trialUser = await prisma.user.findFirstOrThrow({ where: { email: "trial@example.com" } });
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: booking.id,
describe("Booking ownership and access in Middleware", () => {
const adminUserId = 1;
const ownerUserId = 2;
const memberUserId = 3;
const orgOwnerUserId = 4;
const adminUserEmail = "admin@example.com";
const ownerUserEmail = "owner@example.com";
const memberUserEmail = "member@example.com";
const orgOwnerUserEmail = "org-owner@example.com";
// mock user data
function buildMockData() {
//Create Users
prismock.user.create({
data: {
id: adminUserId,
username: "admin",
name: "Admin User",
email: adminUserEmail,
},
});
prismock.user.create({
data: {
id: ownerUserId,
username: "owner",
name: "Owner User",
email: ownerUserEmail,
},
});
prismock.user.create({
data: {
id: orgOwnerUserId,
username: "org-owner",
name: "Org Owner",
email: orgOwnerUserEmail,
},
});
prismock.user.create({
data: {
id: memberUserId,
username: "member",
name: "Member User",
email: memberUserEmail,
bookings: {
create: {
id: 2,
uid: "2",
title: "Booking 2",
eventTypeId: 1,
startTime: "2024-08-30T06:45:00.000Z",
endTime: "2024-08-30T07:45:00.000Z",
attendees: {
create: {
name: "Member User",
email: memberUserEmail,
timeZone: "UTC",
},
},
},
},
},
});
//create team
prismock.team.create({
data: {
id: 1,
name: "Team 1",
slug: "team1",
members: {
createMany: {
data: [
{
userId: adminUserId,
role: MembershipRole.ADMIN,
accepted: true,
},
{
userId: ownerUserId,
role: MembershipRole.OWNER,
accepted: true,
},
{
userId: memberUserId,
role: MembershipRole.MEMBER,
accepted: true,
},
],
},
},
},
});
//create Org
prismock.team.create({
data: {
id: 2,
name: "Org",
slug: "org",
isOrganization: true,
children: {
connect: {
id: 1,
},
},
members: {
createMany: {
data: [
{
userId: orgOwnerUserId,
role: MembershipRole.OWNER,
accepted: true,
},
{
userId: memberUserId,
role: MembershipRole.MEMBER,
accepted: true,
},
{
userId: ownerUserId,
role: MembershipRole.MEMBER,
accepted: true,
},
{
userId: adminUserId,
role: MembershipRole.MEMBER,
accepted: true,
},
],
},
},
},
});
//create eventTypes
prismock.eventType.create({
data: {
id: 1,
title: "Event 1",
slug: "event",
length: 60,
bookings: {
connect: {
id: 2,
},
},
},
});
prismock.eventType.create({
data: {
id: 2,
title: "Event 2",
slug: "event",
length: 60,
teamId: 1,
bookings: {
connect: {
id: 1,
},
},
},
});
//link eventType to teams
prismock.eventType.update({
where: {
id: 1,
},
data: {
owner: {
connect: {
id: ownerUserId,
},
},
},
});
prismock.eventType.update({
where: {
id: 2,
},
data: {
team: {
connect: {
id: 1,
},
},
},
});
//link team to org
prismock.team.update({
where: {
id: 1,
},
data: {
parentId: 2,
},
});
// Call Prisma to create booking with attendees
prismock.booking.create({
data: {
id: 1,
uid: "1",
title: "Booking 1",
userId: 1,
startTime: "2024-08-30T06:45:00.000Z",
endTime: "2024-08-30T07:45:00.000Z",
eventTypeId: 2,
attendees: {
create: {
name: "Admin User",
email: adminUserEmail,
timeZone: "UTC",
},
},
},
});
}
req.userId = trialUser.id;
try {
await authMiddleware(req);
} catch (error) {
const httpError = error as HttpError;
expect(httpError.statusCode).toBe(403);
}
test("should not throw error for bookings where user is an attendee", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
id: 2,
},
prisma: prismock,
});
buildMockData();
req.userId = memberUserId;
await expect(authMiddleware(req)).resolves.not.toThrow();
});
it("No error is thrown when user is the booking user", async () => {
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
test("should throw error for bookings where user is not an attendee", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: booking.id,
id: 1,
},
prisma: prismock,
});
buildMockData();
req.userId = memberUserId;
req.userId = proUser.id;
await authMiddleware(req);
await expect(authMiddleware(req)).rejects.toThrow();
});
it("No error is thrown when user is system-wide admin", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
test("should not throw error for booking where user is the event type owner", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: booking.id,
id: 2,
},
prisma: prismock,
});
buildMockData();
req.userId = ownerUserId;
await expect(authMiddleware(req)).resolves.not.toThrow();
});
req.userId = adminUser.id;
test("should not throw error for booking where user is team owner or admin", async () => {
const { req: req1 } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
id: 1,
},
prisma: prismock,
});
const { req: req2 } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
id: 1,
},
prisma: prismock,
});
buildMockData();
req1.userId = adminUserId;
req2.userId = ownerUserId;
await expect(authMiddleware(req1)).resolves.not.toThrow();
await expect(authMiddleware(req2)).resolves.not.toThrow();
});
test("should throw error for booking where user is not team owner or admin", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
id: 1,
},
prisma: prismock,
});
buildMockData();
req.userId = memberUserId;
await expect(authMiddleware(req)).rejects.toThrow();
});
test("should not throw error when user is system-wide admin", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
id: 2,
},
prisma: prismock,
});
buildMockData();
req.userId = adminUserId;
req.isSystemWideAdmin = true;
await authMiddleware(req);
});
it("No error is thrown when user is org-wide admin", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member1-acme@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: memberUser.id } });
it("should throw error when user is org-wide admin", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: booking.id,
id: 1,
},
prisma: prismock,
});
req.userId = adminUser.id;
buildMockData();
req.userId = orgOwnerUserId;
req.isOrganizationOwnerOrAdmin = true;
await authMiddleware(req);
@@ -0,0 +1,94 @@
import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, test, beforeEach, vi } from "vitest";
import { buildEventType } from "@calcom/lib/test/builder";
import { MembershipRole } from "@calcom/prisma/enums";
import handler from "../../../../pages/api/event-types/[id]/_delete";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
describe("DELETE /api/event-types/[id]", () => {
const eventTypeId = 1234567;
const teamId = 9999;
const adminUser = 1111;
const memberUser = 2222;
beforeEach(() => {
vi.resetAllMocks();
// Mocking membership.findFirst
prismaMock.membership.findFirst.mockImplementation(({ where }) => {
const { userId, teamId, accepted, role } = where;
const mockData = [
{ userId: 1111, teamId: teamId, accepted: true, role: MembershipRole.ADMIN },
{ userId: 2222, teamId: teamId, accepted: true, role: MembershipRole.MEMBER },
];
// Return the correct user based on the query conditions
return mockData.find(
(membership) =>
membership.userId === userId &&
membership.teamId === teamId &&
membership.accepted === accepted &&
role.in.includes(membership.role)
);
});
// Mocking eventType.findFirst
prismaMock.eventType.findFirst.mockResolvedValue(
buildEventType({
id: eventTypeId,
teamId,
})
);
// Mocking team.findUnique
prismaMock.team.findUnique.mockResolvedValue({
id: teamId,
members: [
{ userId: memberUser, role: MembershipRole.MEMBER, teamId: teamId },
{ userId: adminUser, role: MembershipRole.ADMIN, teamId: teamId },
],
});
});
describe("Error", async () => {
test("Fails to remove event type if user is not OWNER/ADMIN of team associated with event type", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "DELETE",
body: {},
query: {
id: eventTypeId,
},
});
// Assign userId to the request objects
req.userId = memberUser;
await handler(req, res);
expect(res.statusCode).toBe(403); // Check if the deletion was successful
});
});
describe("Success", async () => {
test("Removes event type if user is owner of team associated with event type", async () => {
// Mocks for DELETE request
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "DELETE",
body: {},
query: {
id: eventTypeId,
},
});
// Assign userId to the request objects
req.userId = adminUser;
await handler(req, res);
expect(res.statusCode).toBe(200); // Check if the deletion was successful
});
});
});
-2
View File
@@ -1,5 +1,3 @@
version: '3.8'
services:
redis:
image: redis:latest
+5 -2
View File
@@ -10,8 +10,9 @@
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch",
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build",
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build ",
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
"dev:no-docker": "yarn dev:build && yarn copy-swagger-module && yarn start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node ./dist/apps/api/v2/src/main.js",
"test": "yarn dev:build && jest",
@@ -19,13 +20,15 @@
"test:cov": "yarn dev:build && jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json",
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch",
"prisma": "yarn workspace @calcom/prisma prisma",
"generate-schemas": "yarn prisma generate && yarn prisma format",
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
},
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.28",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.33",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -1,4 +1,5 @@
import { BookingsController } from "@/ee/bookings/controllers/bookings.controller";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { BillingModule } from "@/modules/billing/billing.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
@@ -10,7 +11,7 @@ import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, RedisModule, TokensModule, BillingModule],
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository],
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository, ApiKeyRepository],
controllers: [BookingsController],
})
export class BookingsModule {}
@@ -14,6 +14,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
@@ -21,7 +22,7 @@ import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { handleNewBooking } from "@calcom/platform-libraries";
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
import { ApiSuccessResponse, ApiResponse, ApiErrorResponse } from "@calcom/platform-types";
describe("Bookings Endpoints", () => {
describe("User Authenticated", () => {
@@ -31,6 +32,8 @@ describe("Bookings Endpoints", () => {
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let apiKeyString: string;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
@@ -56,11 +59,13 @@ describe("Bookings Endpoints", () => {
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = keyString;
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
@@ -139,6 +144,105 @@ describe("Bookings Endpoints", () => {
});
});
it("should fail to create a booking with no_available_users_found_error", async () => {
const bookingStart = "2040-05-21T09:30:00.000Z";
const bookingEnd = "2040-05-21T10:30:00.000Z";
const bookingEventTypeId = eventTypeId;
const bookingTimeZone = "Europe/London";
const bookingLanguage = "en";
const bookingHashedLink = "";
const bookingMetadata = {
timeFormat: "12",
meetingType: "organizer-phone",
};
const bookingResponses = {
name: "tester",
email: "tester@example.com",
location: {
value: "link",
optionValue: "",
},
notes: "test",
guests: [],
};
const body: CreateBookingInput = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
timeZone: bookingTimeZone,
language: bookingLanguage,
metadata: bookingMetadata,
hashedLink: bookingHashedLink,
responses: bookingResponses,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.expect(400)
.then(async (response) => {
const responseBody: ApiErrorResponse = response.body;
expect(responseBody.error.message).toEqual("no_available_users_found_error");
});
});
it("should create a booking with api key to get owner id", async () => {
const bookingStart = "2040-05-22T09:30:00.000Z";
const bookingEnd = "2040-05-22T10:30:00.000Z";
const bookingEventTypeId = eventTypeId;
const bookingTimeZone = "Europe/London";
const bookingLanguage = "en";
const bookingHashedLink = "";
const bookingMetadata = {
timeFormat: "12",
meetingType: "organizer-phone",
};
const bookingResponses = {
name: "tester",
email: "tester@example.com",
location: {
value: "link",
optionValue: "",
},
notes: "test",
guests: [],
};
const body: CreateBookingInput = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
timeZone: bookingTimeZone,
language: bookingLanguage,
metadata: bookingMetadata,
hashedLink: bookingHashedLink,
responses: bookingResponses,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.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.userPrimaryEmail).toBeDefined();
expect(responseBody.data.userPrimaryEmail).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;
});
});
describe("should reschedule a booking", () => {
it("should reschedule with updated start time, end time & metadata", async () => {
const newBookingStart = "2040-05-21T12:30:00.000Z";
@@ -202,18 +306,22 @@ describe("Bookings Endpoints", () => {
.get("/v2/bookings?filters[status]=upcoming")
.then((response) => {
const responseBody: GetBookingsOutput = response.body;
const fetchedBooking = responseBody.data.bookings[0];
expect(responseBody.data.bookings.length).toEqual(1);
expect(responseBody.data.bookings.length).toEqual(2);
const fetchedBooking = responseBody.data.bookings.find(
(booking) => booking.id === createdBooking.id
);
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(userEmail);
if (fetchedBooking) {
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(userEmail);
}
});
});
@@ -4,7 +4,9 @@ import { MarkNoShowInput } from "@/ee/bookings/inputs/mark-no-show.input";
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
import { MarkNoShowOutput } from "@/ee/bookings/outputs/mark-no-show.output";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
@@ -28,10 +30,12 @@ import {
NotFoundException,
UseGuards,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger";
import { User } from "@prisma/client";
import { Request } from "express";
import { NextApiRequest } from "next/types";
import { v4 as uuidv4 } from "uuid";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
@@ -46,6 +50,7 @@ import {
getBookingInfo,
handleCancelBooking,
getBookingForReschedule,
ErrorCode,
} from "@calcom/platform-libraries";
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
@@ -86,7 +91,9 @@ export class BookingsController {
private readonly oAuthFlowService: OAuthFlowService,
private readonly prismaReadService: PrismaReadService,
private readonly oAuthClientRepository: OAuthClientRepository,
private readonly billingService: BillingService
private readonly billingService: BillingService,
private readonly config: ConfigService,
private readonly apiKeyRepository: ApiKeyRepository
) {}
@Get("/")
@@ -238,8 +245,15 @@ export class BookingsController {
): Promise<ApiResponse<BookingResponse[]>> {
const oAuthClientId = clientId?.toString();
try {
const recurringEventId = uuidv4();
for (const recurringEvent of req.body) {
if (!recurringEvent.recurringEventId) {
recurringEvent.recurringEventId = recurringEventId;
}
}
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
await this.createNextApiBookingRequest(req, oAuthClientId)
await this.createNextApiRecurringBookingRequest(req, oAuthClientId)
);
createdBookings.forEach(async (booking) => {
@@ -296,9 +310,17 @@ export class BookingsController {
private async getOwnerId(req: Request): Promise<number | undefined> {
try {
const accessToken = req.get("Authorization")?.replace("Bearer ", "");
if (accessToken) {
return this.oAuthFlowService.getOwnerId(accessToken);
const bearerToken = req.get("Authorization")?.replace("Bearer ", "");
if (bearerToken) {
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
const apiKeyHash = hashAPIKey(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
return keyData?.userId;
} else {
// Access Token
return this.oAuthFlowService.getOwnerId(bearerToken);
}
}
} catch (err) {
this.logger.error(err);
@@ -338,6 +360,19 @@ export class BookingsController {
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
}
private async createNextApiRecurringBookingRequest(
req: BookingRequest,
oAuthClientId?: string,
platformBookingLocation?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
const userId = (await this.getOwnerId(req)) ?? -1;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
Object.assign(req, { userId, ...oAuthParams, platformBookingLocation });
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
}
private handleBookingErrors(
err: Error | HttpError | unknown,
type?: "recurring" | `instant` | "no-show"
@@ -353,6 +388,9 @@ export class BookingsController {
if (err instanceof Error) {
const error = err as Error;
if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) {
throw new HttpException(error.message, 400);
}
throw new InternalServerErrorException(error?.message ?? errMsg);
}
@@ -59,10 +59,6 @@ export class CreateBookingInput {
@IsOptional()
rescheduleUid?: string;
@IsString()
@IsOptional()
recurringEventId?: string;
@IsTimeZone()
timeZone!: string;
@@ -1,5 +1,5 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
import { IsBoolean, IsString, IsNumber, IsOptional } from "class-validator";
import type { AppsStatus } from "@calcom/platform-libraries";
@@ -21,4 +21,8 @@ export class CreateRecurringBookingInput extends CreateBookingInput {
@IsOptional()
@IsNumber()
currentRecurringIndex?: number;
@IsString()
@IsOptional()
recurringEventId?: string;
}
@@ -1,6 +1,7 @@
import { CreateEventTypeInput_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/create-event-type.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
@@ -9,7 +10,11 @@ import type { PrismaClient } from "@calcom/prisma";
@Injectable()
export class EventTypesRepository_2024_04_15 {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
constructor(
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private usersService: UsersService
) {}
async createUserEventType(
userId: number,
@@ -46,7 +51,7 @@ export class EventTypesRepository_2024_04_15 {
eventTypeId: number
) {
return await getEventTypeById({
currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId,
currentOrganizationId: this.usersService.getUserMainOrgId(user),
eventTypeId,
userId: user.id,
prisma: this.dbRead.prisma as unknown as PrismaClient,
@@ -6,6 +6,7 @@ import { EventTypeOutput } from "@/ee/event-types/event-types_2024_04_15/outputs
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 { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
@@ -24,7 +25,8 @@ export class EventTypesService_2024_04_15 {
private readonly membershipsRepository: MembershipsRepository,
private readonly usersRepository: UsersRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly dbWrite: PrismaWriteService
private readonly dbWrite: PrismaWriteService,
private usersService: UsersService
) {}
async createUserEventType(
@@ -53,11 +55,11 @@ export class EventTypesService_2024_04_15 {
}
async getUserToCreateEvent(user: UserWithProfile) {
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
const organizationId = this.usersService.getUserMainOrgId(user);
const isOrgAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
const profileId = user.movedToProfile?.id || null;
const profileId = this.usersService.getUserMainProfile(user)?.id || null;
return {
id: user.id,
role: user.role,
@@ -80,7 +82,7 @@ export class EventTypesService_2024_04_15 {
}
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
const organizationId = this.usersService.getUserMainOrgId(user);
const isUserOrganizationAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
@@ -153,7 +155,7 @@ export class EventTypesService_2024_04_15 {
}
async getUserToUpdateEvent(user: UserWithProfile) {
const profileId = user.movedToProfile?.id || null;
const profileId = this.usersService.getUserMainProfile(user)?.id || null;
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
return { ...user, profile: { id: profileId }, selectedCalendars };
}
@@ -20,13 +20,13 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
import { BookingWindowPeriodInputTypeEnum_2024_06_14, FrequencyInput } from "@calcom/platform-enums";
import {
ApiSuccessResponse,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-types/dist/event-types/event-types_2024_06_14/inputs/enums/booking-window.enum";
import { SchedulingType } from "@calcom/prisma/enums";
describe("Event types Endpoints", () => {
@@ -230,6 +230,11 @@ describe("Event types Endpoints", () => {
value: 30,
rolling: true,
},
recurrence: {
frequency: FrequencyInput.weekly,
interval: 2,
occurrences: 10,
},
};
return request(app.getHttpServer())
@@ -253,6 +258,7 @@ describe("Event types Endpoints", () => {
expect(createdEventType.bookingLimitsDuration).toEqual(body.bookingLimitsDuration);
expect(createdEventType.offsetStart).toEqual(body.offsetStart);
expect(createdEventType.bookingWindow).toEqual(body.bookingWindow);
expect(createdEventType.recurrence).toEqual(body.recurrence);
eventType = responseBody.data;
});
});
@@ -278,6 +284,11 @@ describe("Event types Endpoints", () => {
value: 40,
rolling: false,
},
recurrence: {
frequency: FrequencyInput.monthly,
interval: 4,
occurrences: 10,
},
};
return request(app.getHttpServer())
@@ -303,6 +314,7 @@ describe("Event types Endpoints", () => {
expect(updatedEventType.bookingLimitsDuration).toEqual(body.bookingLimitsDuration);
expect(updatedEventType.offsetStart).toEqual(body.offsetStart);
expect(updatedEventType.bookingWindow).toEqual(body.bookingWindow);
expect(updatedEventType.recurrence).toEqual(body.recurrence);
eventType.title = newTitle;
eventType.scheduleId = secondSchedule.id;
@@ -311,6 +323,7 @@ describe("Event types Endpoints", () => {
eventType.bookingLimitsDuration = updatedEventType.bookingLimitsDuration;
eventType.offsetStart = updatedEventType.offsetStart;
eventType.bookingWindow = updatedEventType.bookingWindow;
eventType.recurrence = updatedEventType.recurrence;
});
});
@@ -351,6 +364,7 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
});
it(`/GET/even-types by username`, async () => {
@@ -381,6 +395,7 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
});
it(`/GET/event-types by username and eventSlug`, async () => {
@@ -406,6 +421,7 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
});
it(`/GET/:id not existing`, async () => {
@@ -1,5 +1,6 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
@@ -25,6 +26,7 @@ type InputEventTransformed = Omit<
| "periodCountCalendarDays"
| "periodStartDate"
| "periodEndDate"
| "recurrence"
> & {
length: number;
slug: string;
@@ -34,7 +36,11 @@ type InputEventTransformed = Omit<
@Injectable()
export class EventTypesRepository_2024_06_14 {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
constructor(
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private usersService: UsersService
) {}
async createUserEventType(userId: number, body: InputEventTransformed) {
return this.dbWrite.prisma.eventType.create({
@@ -80,7 +86,7 @@ export class EventTypesRepository_2024_06_14 {
eventTypeId: number
) {
return await getEventTypeById({
currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId,
currentOrganizationId: this.usersService.getUserMainOrgId(user),
eventTypeId,
userId: user.id,
prisma: this.dbRead.prisma as unknown as PrismaClient,
@@ -46,6 +46,7 @@ export class EventTypesService_2024_06_14 {
periodCountCalendarDays = undefined,
periodStartDate = undefined,
periodEndDate = undefined,
recurrence = undefined,
...bodyTransformed
} = this.inputEventTypesService.transformInputCreateEventType(body);
const { eventType: eventTypeCreated } = await createEventType({
@@ -68,6 +69,7 @@ export class EventTypesService_2024_06_14 {
periodCountCalendarDays,
periodStartDate,
periodEndDate,
recurrence,
...bodyTransformed,
},
ctx: {
@@ -119,11 +121,11 @@ export class EventTypesService_2024_06_14 {
}
async getUserToCreateEvent(user: UserWithProfile) {
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
const organizationId = this.usersService.getUserMainOrgId(user);
const isOrgAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
: false;
const profileId = user.movedToProfile?.id || null;
const profileId = this.usersService.getUserMainProfile(user)?.id || null;
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
return {
id: user.id,
@@ -159,7 +161,7 @@ export class EventTypesService_2024_06_14 {
}
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
const organizationId = this.usersService.getUserMainOrgId(user);
const isUserOrganizationAdmin = organizationId
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
@@ -270,7 +272,7 @@ export class EventTypesService_2024_06_14 {
}
async getUserToUpdateEvent(user: UserWithProfile) {
const profileId = user.movedToProfile?.id || null;
const profileId = this.usersService.getUserMainProfile(user)?.id || null;
const selectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(user.id);
return { ...user, profile: { id: profileId }, selectedCalendars };
}
@@ -5,6 +5,7 @@ import {
transformApiEventTypeLocations,
transformApiEventTypeIntervalLimits,
transformApiEventTypeFutureBookingLimits,
transformApiEventTypeRecurrence,
} from "@calcom/platform-libraries";
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
@@ -25,6 +26,7 @@ export class InputEventTypesService_2024_06_14 {
bookingLimitsCount,
bookingLimitsDuration,
bookingWindow,
recurrence,
...rest
} = inputEventType;
@@ -38,6 +40,7 @@ export class InputEventTypesService_2024_06_14 {
? this.transformInputIntervalLimits(bookingLimitsDuration)
: undefined,
...this.transformInputBookingWindow(bookingWindow),
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
};
return eventType;
@@ -52,6 +55,7 @@ export class InputEventTypesService_2024_06_14 {
bookingLimitsCount,
bookingLimitsDuration,
bookingWindow,
recurrence,
...rest
} = inputEventType;
@@ -66,6 +70,7 @@ export class InputEventTypesService_2024_06_14 {
? this.transformInputIntervalLimits(bookingLimitsDuration)
: undefined,
...this.transformInputBookingWindow(bookingWindow),
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
};
return eventType;
@@ -87,4 +92,8 @@ export class InputEventTypesService_2024_06_14 {
const res = transformApiEventTypeFutureBookingLimits(inputBookingWindow);
return !!res ? res : {};
}
transformInputRecurrignEvent(recurrence: CreateEventTypeInput_2024_06_14["recurrence"]) {
return transformApiEventTypeRecurrence(recurrence);
}
}
@@ -14,6 +14,7 @@ import {
parseBookingLimit,
getResponseEventTypeIntervalLimits,
getResponseEventTypeFutureBookingLimits,
getResponseEventTypeRecurrence,
} from "@calcom/platform-libraries";
import { TransformFutureBookingsLimitSchema_2024_06_14 } from "@calcom/platform-types";
@@ -92,7 +93,7 @@ export class OutputEventTypesService_2024_06_14 {
const bookingFields = databaseEventType.bookingFields
? this.transformBookingFields(BookingFieldsSchema.parse(databaseEventType.bookingFields))
: [];
const recurringEvent = this.transformRecurringEvent(databaseEventType.recurringEvent);
const recurrence = this.transformRecurringEvent(databaseEventType.recurringEvent);
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
const users = this.transformUsers(databaseEventType.users);
const bookingLimitsCount = this.transformIntervalLimits(databaseEventType.bookingLimits);
@@ -114,7 +115,7 @@ export class OutputEventTypesService_2024_06_14 {
description: description || "",
locations,
bookingFields,
recurringEvent,
recurrence,
disableGuests,
slotInterval,
minimumBookingNotice,
@@ -154,7 +155,8 @@ export class OutputEventTypesService_2024_06_14 {
transformRecurringEvent(recurringEvent: any) {
if (!recurringEvent) return null;
return parseRecurringEvent(recurringEvent);
const recurringEventParsed = parseRecurringEvent(recurringEvent);
return getResponseEventTypeRecurrence(recurringEventParsed);
}
transformMetadata(metadata: any) {
@@ -0,0 +1,6 @@
export enum AuthMethods {
"API_KEY" = "api-key",
"ACCESS_TOKEN" = "access-token",
"OAUTH_CLIENT" = "oauth-client",
"NEXT_AUTH" = "next-auth",
}
@@ -0,0 +1,13 @@
import { AuthMethods } from "@/lib/enums/auth-methods";
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export const GetAuthMethod = createParamDecorator<unknown, ExecutionContext>((_data, ctx) => {
const request = ctx.switchToHttp().getRequest();
const authMethod = request.authMethod as AuthMethods;
if (!authMethod) {
throw new Error("GetAuthMethod decorator : auth method not set");
}
return authMethod;
});
@@ -1,6 +1,7 @@
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
@@ -12,13 +13,14 @@ export class OrganizationRolesGuard implements CanActivate {
constructor(
private reflector: Reflector,
private organizationsService: OrganizationsService,
private membershipRepository: MembershipsRepository
private membershipRepository: MembershipsRepository,
private usersService: UsersService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user: UserWithProfile = request.user;
const organizationId = user?.movedToProfile?.organizationId || user?.organizationId;
const organizationId = user ? this.usersService.getUserMainOrgId(user) : null;
if (!user || !organizationId) {
throw new ForbiddenException("No organization associated with the user.");
@@ -0,0 +1,67 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsWebhooksRepository } from "@/modules/organizations/repositories/organizations-webhooks.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
type CachedData = {
org?: Team;
canAccess?: boolean;
};
@Injectable()
export class IsWebhookInOrg implements CanActivate {
constructor(
private organizationsRepository: OrganizationsRepository,
private organizationsWebhooksRepository: OrganizationsWebhooksRepository,
private readonly redisService: RedisService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
let canAccess = false;
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
const webhookId: string = request.params.webhookId;
const organizationId: string = request.params.orgId;
if (!organizationId) {
throw new ForbiddenException("No organization id found in request params.");
}
if (!webhookId) {
throw new ForbiddenException("No webhook id found in request params.");
}
const REDIS_CACHE_KEY = `apiv2:org:${webhookId}:guard:isWebhookInOrg`;
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
if (cachedData) {
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
request.organization = cachedOrg;
return cachedCanAccess;
}
}
const org = await this.organizationsRepository.findById(Number(organizationId));
if (org?.isOrganization) {
const isWebhookInOrg = await this.organizationsWebhooksRepository.findWebhook(
Number(organizationId),
webhookId
);
if (isWebhookInOrg) canAccess = true;
}
if (org) {
await this.redisService.redis.set(
REDIS_CACHE_KEY,
JSON.stringify({ org: org, canAccess } satisfies CachedData),
"EX",
300
);
}
return canAccess;
}
}
@@ -1,4 +1,5 @@
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { AuthMethods } from "@/lib/enums/auth-methods";
import { BaseStrategy } from "@/lib/passport/strategies/types";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { DeploymentsService } from "@/modules/deployments/deployments.service";
@@ -30,7 +31,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
super();
}
async authenticate(request: Request) {
async authenticate(request: Request & { authMethod: AuthMethods }) {
try {
const { params } = request;
const oAuthClientSecret = request.get(X_CAL_SECRET_KEY);
@@ -38,11 +39,15 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
if (oAuthClientId && oAuthClientSecret) {
request.authMethod = AuthMethods["OAUTH_CLIENT"];
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret);
}
if (bearerToken) {
const requestOrigin = request.get("Origin");
request.authMethod = isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
? AuthMethods["API_KEY"]
: AuthMethods["ACCESS_TOKEN"];
return await this.authenticateBearerToken(bearerToken, requestOrigin);
}
@@ -50,6 +55,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
if (nextAuthToken) {
request.authMethod = AuthMethods["NEXT_AUTH"];
return await this.authenticateNextAuth(nextAuthToken);
}
@@ -7,6 +7,7 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { UsersModule } from "@/modules/users/users.module";
import { BullModule } from "@nestjs/bull";
import { Module } from "@nestjs/common";
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
duration: 1000,
},
}),
UsersModule,
],
providers: [BillingConfigService, BillingService, BillingRepository, BillingProcessor],
exports: [BillingService, BillingRepository],
@@ -33,7 +33,7 @@ export class DeploymentsService {
if (!licenseKey) {
return false;
}
const licenseKeyUrl = this.configService.get("api.licenseKeyUrl") + `?key=${licenseKey}`;
const licenseKeyUrl = this.configService.get("api.licenseKeyUrl") + `/${licenseKey}`;
const cachedData = await this.redisService.redis.get(getLicenseCacheKey(licenseKey));
if (cachedData) {
return (JSON.parse(cachedData) as LicenseCheckResponse)?.valid;
@@ -13,6 +13,7 @@ import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-gu
import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import {
@@ -59,7 +60,8 @@ export class OAuthClientsController {
constructor(
private readonly oauthClientRepository: OAuthClientRepository,
private readonly userRepository: UsersRepository,
private readonly teamsRepository: OrganizationsRepository
private readonly teamsRepository: OrganizationsRepository,
private usersService: UsersService
) {}
@Post("/")
@@ -74,7 +76,7 @@ export class OAuthClientsController {
@GetUser() user: UserWithProfile,
@Body() body: CreateOAuthClientInput
): Promise<CreateOAuthClientResponseDto> {
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
const organizationId = this.usersService.getUserMainOrgId(user) as number;
this.logger.log(
`For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}`
);
@@ -100,7 +102,7 @@ export class OAuthClientsController {
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClients(@GetUser() user: UserWithProfile): Promise<GetOAuthClientsResponseDto> {
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
const organizationId = this.usersService.getUserMainOrgId(user) as number;
const clients = await this.oauthClientRepository.getOrganizationOAuthClients(organizationId);
return { status: SUCCESS_STATUS, data: clients };
@@ -1,5 +1,6 @@
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { UsersService } from "@/modules/users/services/users.service";
import {
Injectable,
CanActivate,
@@ -11,12 +12,12 @@ import { Request } from "express";
@Injectable()
export class OAuthClientGuard implements CanActivate {
constructor(private oAuthClientRepository: OAuthClientRepository) {}
constructor(private oAuthClientRepository: OAuthClientRepository, private usersService: UsersService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { user: GetUserReturnType }>();
const user: GetUserReturnType = request.user;
const organizationId = user?.movedToProfile?.organizationId || user?.organizationId;
const organizationId = user ? this.usersService.getUserMainOrgId(user) : null;
const oAuthClientId = request.params.clientId;
if (!oAuthClientId) {
@@ -0,0 +1,250 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input";
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.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 * as request from "supertest";
import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { User, Team, Membership } from "@calcom/prisma/client";
describe("Organizations Attributes Options Endpoints", () => {
describe("User lacks required role", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
const userEmail = "member@attributes-options-api.com";
let user: User;
let org: Team;
let membership: Membership;
let attributeId: string;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "AttributesOptionsCorp",
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
// Create an attribute for testing
attributeId = "test-attribute-id";
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should not be able to create attribute option", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
.send({
name: "Option 1",
value: "option1",
})
.expect(403);
});
it("should not be able to delete attribute option", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/attributes/${attributeId}/options/1`)
.expect(403);
});
afterAll(async () => {
await membershipFixtures.delete(membership.id);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
describe("User has required role", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let attributeRepositoryFixture: AttributeRepositoryFixture;
const userEmail = "admin@attributes-options-api.com";
let user: User;
let org: Team;
let membership: Membership;
let attributeId: string;
let createdOption: any;
const createOptionInput: CreateOrganizationAttributeOptionInput = {
value: "option1",
slug: "option1",
};
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
attributeRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "AttributesOptionsCorp",
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
// Create an attribute for testing
const attribute = await attributeRepositoryFixture.create({
name: "Test Attribute",
team: { connect: { id: org.id } },
type: "TEXT",
slug: "test-attribute",
});
attributeId = attribute.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should create attribute option", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
.send(createOptionInput)
.expect(201)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
createdOption = response.body.data;
expect(createdOption.value).toEqual(createOptionInput.value);
expect(createdOption.slug).toEqual(createOptionInput.slug);
});
});
it("should get attribute options", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const options = response.body.data;
expect(options.length).toEqual(1);
expect(options[0].value).toEqual(createOptionInput.value);
expect(options[0].slug).toEqual(createOptionInput.slug);
});
});
it("should update attribute option", async () => {
const updateOptionInput: UpdateOrganizationAttributeOptionInput = {
value: "updated-option-value",
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/attributes/${attributeId}/options/${createdOption.id}`)
.send(updateOptionInput)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const updatedOption = response.body.data;
expect(updatedOption.value).toEqual(updateOptionInput.value);
});
});
it("should assign attribute option to user", async () => {
const assignInput: AssignOrganizationAttributeOptionToUserInput = {
attributeId: attributeId,
attributeOptionId: createdOption.id,
};
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/attributes/options/${user.id}`)
.send(assignInput)
.expect(201)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data).toBeTruthy();
});
});
it("should get attribute options for user", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/attributes/options/${user.id}`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const userOptions = response.body.data;
expect(userOptions.length).toEqual(1);
expect(userOptions[0].id).toEqual(createdOption.id);
});
});
it("should unassign attribute option from user", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/attributes/options/${user.id}/${createdOption.id}`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data).toBeTruthy();
});
});
it("should delete attribute option", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/attributes/${attributeId}/options/${createdOption.id}`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data).toBeTruthy();
});
});
afterAll(async () => {
await membershipFixtures.delete(membership.id);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
@@ -0,0 +1,213 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.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 * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { User, Team, Membership } from "@calcom/prisma/client";
describe("Organizations Attributes Endpoints", () => {
describe("User lacks required role", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
const userEmail = "member@attributes-api.com";
let user: User;
let org: Team;
let membership: Membership;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "AttributesCorp",
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should not be able to create attribute for org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/attributes`)
.send({
key: "department",
value: "engineering",
})
.expect(403);
});
it("should not be able to delete attribute for org", async () => {
return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/attributes/1`).expect(403);
});
afterAll(async () => {
await membershipFixtures.delete(membership.id);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
describe("User has required role", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
const userEmail = "admin@attributes-api.com";
let user: User;
let org: Team;
let membership: Membership;
let createdAttribute: any;
const createAttributeInput: CreateOrganizationAttributeInput = {
name: "department",
slug: "department",
type: "TEXT",
options: [],
enabled: true,
};
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "AttributesCorp",
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should create attribute for org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/attributes`)
.send(createAttributeInput)
.expect(201)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
createdAttribute = response.body.data;
expect(createdAttribute.type).toEqual(createAttributeInput.type);
expect(createdAttribute.slug).toEqual(createAttributeInput.slug);
expect(createdAttribute.enabled).toEqual(createAttributeInput.enabled);
});
});
it("should get org attributes", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/attributes`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const attributes = response.body.data;
expect(attributes.length).toEqual(1);
expect(attributes[0].name).toEqual(createAttributeInput.name);
expect(attributes[0].slug).toEqual(createAttributeInput.slug);
expect(attributes[0].type).toEqual(createAttributeInput.type);
expect(attributes[0].enabled).toEqual(createAttributeInput.enabled);
});
});
it("should get single org attribute", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/attributes/${createdAttribute.id}`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const attribute = response.body.data;
expect(attribute.name).toEqual(createAttributeInput.name);
expect(attribute.slug).toEqual(createAttributeInput.slug);
expect(attribute.type).toEqual(createAttributeInput.type);
expect(attribute.enabled).toEqual(createAttributeInput.enabled);
});
});
it("should update org attribute", async () => {
const updateAttributeInput: UpdateOrganizationAttributeInput = {
name: "marketing",
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/attributes/${createdAttribute.id}`)
.send(updateAttributeInput)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const updatedAttribute = response.body.data;
expect(updatedAttribute.name).toEqual(updateAttributeInput.name);
});
});
it("should delete org attribute", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/attributes/${createdAttribute.id}`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data).toBeTruthy();
});
});
afterAll(async () => {
await membershipFixtures.delete(membership.id);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
@@ -0,0 +1,173 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input";
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
import {
AssignOptionUserOutput,
UnassignOptionUserOutput,
} from "@/modules/organizations/outputs/attributes/options/assign-option-user.output";
import { CreateAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/create-option.output";
import { DeleteAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/delete-option.output";
import { GetOptionUserOutput } from "@/modules/organizations/outputs/attributes/options/get-option-user.output";
import { GetAllAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/get-option.output";
import { UpdateAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/update-option.output";
import { OrganizationAttributeOptionService } from "@/modules/organizations/services/attributes/organization-attributes-option.service";
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@Controller({
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
export class OrganizationsOptionsAttributesController {
constructor(private readonly organizationsAttributesOptionsService: OrganizationAttributeOptionService) {}
// Creates an attribute option for an attribute
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/attributes/:attributeId/options")
async createOrganizationAttributeOption(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeId") attributeId: string,
@Body() bodyAttribute: CreateOrganizationAttributeOptionInput
): Promise<CreateAttributeOptionOutput> {
const attributeOption =
await this.organizationsAttributesOptionsService.createOrganizationAttributeOption(
orgId,
attributeId,
bodyAttribute
);
return {
status: SUCCESS_STATUS,
data: attributeOption,
};
}
// Deletes an attribute option for an attribute
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Delete("/attributes/:attributeId/options/:optionId")
async deleteOrganizationAttributeOption(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeId") attributeId: string,
@Param("optionId") optionId: string
): Promise<DeleteAttributeOptionOutput> {
const attributeOption =
await this.organizationsAttributesOptionsService.deleteOrganizationAttributeOption(
orgId,
attributeId,
optionId
);
return {
status: SUCCESS_STATUS,
data: attributeOption,
};
}
// Updates an attribute option for an attribute
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Patch("/attributes/:attributeId/options/:optionId")
async updateOrganizationAttributeOption(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeId") attributeId: string,
@Param("optionId") optionId: string,
@Body() bodyAttribute: UpdateOrganizationAttributeOptionInput
): Promise<UpdateAttributeOptionOutput> {
const attributeOption =
await this.organizationsAttributesOptionsService.updateOrganizationAttributeOption(
orgId,
attributeId,
optionId,
bodyAttribute
);
return {
status: SUCCESS_STATUS,
data: attributeOption,
};
}
// Gets all attribute options for an attribute
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
@Get("/attributes/:attributeId/options")
async getOrganizationAttributeOptions(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeId") attributeId: string
): Promise<GetAllAttributeOptionOutput> {
const attributeOptions = await this.organizationsAttributesOptionsService.getOrganizationAttributeOptions(
orgId,
attributeId
);
return {
status: SUCCESS_STATUS,
data: attributeOptions,
};
}
// Assign attribute option to user
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/attributes/options/:userId")
async assignOrganizationAttributeOptionToUser(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("userId", ParseIntPipe) userId: number,
@Body() bodyAttribute: AssignOrganizationAttributeOptionToUserInput
): Promise<AssignOptionUserOutput> {
const attributeOption =
await this.organizationsAttributesOptionsService.assignOrganizationAttributeOptionToUser(
orgId,
userId,
bodyAttribute
);
return {
status: SUCCESS_STATUS,
data: attributeOption,
};
}
// Unassign attribute option from user
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
@Delete("/attributes/options/:userId/:attributeOptionId")
async unassignOrganizationAttributeOptionFromUser(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("userId", ParseIntPipe) userId: number,
@Param("attributeOptionId") attributeOptionId: string
): Promise<UnassignOptionUserOutput> {
const attributeOption =
await this.organizationsAttributesOptionsService.unassignOrganizationAttributeOptionFromUser(
orgId,
userId,
attributeOptionId
);
return {
status: SUCCESS_STATUS,
data: attributeOption,
};
}
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
@Get("/attributes/options/:userId")
async getOrganizationAttributeOptionsForUser(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("userId", ParseIntPipe) userId: number
): Promise<GetOptionUserOutput> {
const attributeOptions =
await this.organizationsAttributesOptionsService.getOrganizationAttributeOptionsForUser(orgId, userId);
return {
status: SUCCESS_STATUS,
data: attributeOptions,
};
}
}
@@ -0,0 +1,129 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
import { CreateOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/create-organization-attributes.output";
import { DeleteOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/delete-organization-attributes.output";
import {
GetOrganizationAttributesOutput,
GetSingleAttributeOutput,
} from "@/modules/organizations/outputs/attributes/get-organization-attributes.output";
import { UpdateOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/update-organization-attributes.output";
import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service";
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
export class OrganizationsAttributesController {
constructor(private readonly organizationsAttributesService: OrganizationAttributesService) {}
// Gets all attributes for an organization
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
@Get("/attributes")
async getOrganizationAttributes(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetOrganizationAttributesOutput> {
const { skip, take } = queryParams;
const attributes = await this.organizationsAttributesService.getOrganizationAttributes(orgId, skip, take);
return {
status: SUCCESS_STATUS,
data: attributes,
};
}
// Gets a single attribute for an organization
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
@Get("/attributes/:attributeId")
async getOrganizationAttribute(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeId") attributeId: string
): Promise<GetSingleAttributeOutput> {
const attribute = await this.organizationsAttributesService.getOrganizationAttribute(orgId, attributeId);
return {
status: SUCCESS_STATUS,
data: attribute,
};
}
// Creates an attribute for an organization
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/attributes")
async createOrganizationAttribute(
@Param("orgId", ParseIntPipe) orgId: number,
@Body() bodyAttribute: CreateOrganizationAttributeInput
): Promise<CreateOrganizationAttributesOutput> {
const attribute = await this.organizationsAttributesService.createOrganizationAttribute(
orgId,
bodyAttribute
);
return {
status: SUCCESS_STATUS,
data: attribute,
};
}
// Updates an attribute for an organization
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Patch("/attributes/:attributeId")
async updateOrganizationAttribute(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeId") attributeId: string,
@Body() bodyAttribute: UpdateOrganizationAttributeInput
): Promise<UpdateOrganizationAttributesOutput> {
const attribute = await this.organizationsAttributesService.updateOrganizationAttribute(
orgId,
attributeId,
bodyAttribute
);
return {
status: SUCCESS_STATUS,
data: attribute,
};
}
// Deletes an attribute for an organization
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Delete("/attributes/:attributeId")
async deleteOrganizationAttribute(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeId") attributeId: string
): Promise<DeleteOrganizationAttributesOutput> {
const attribute = await this.organizationsAttributesService.deleteOrganizationAttribute(
orgId,
attributeId
);
return {
status: SUCCESS_STATUS,
data: attribute,
};
}
}
@@ -17,6 +17,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-enums";
import {
ApiSuccessResponse,
CreateTeamEventTypeInput_2024_06_14,
@@ -24,7 +25,6 @@ import {
TeamEventTypeOutput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import { BookingWindowPeriodInputTypeEnum_2024_06_14 } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/enums/booking-window.enum";
import { Team } from "@calcom/prisma/client";
describe("Organizations Event Types Endpoints", () => {
@@ -1,6 +1,5 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetMembership } from "@/modules/auth/decorators/get-membership/get-membership.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
@@ -36,7 +35,6 @@ import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
import { Membership } from "@calcom/prisma/client";
@Controller({
path: "/v2/organizations/:orgId/memberships",
@@ -89,7 +87,11 @@ export class OrganizationsMembershipsController {
@UseGuards(IsMembershipInOrg)
@Get("/:membershipId")
@HttpCode(HttpStatus.OK)
async getUserSchedule(@GetMembership() membership: Membership): Promise<GetOrgMembership> {
async getOrgMembership(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("membershipId", ParseIntPipe) membershipId: number
): Promise<GetOrgMembership> {
const membership = await this.organizationsMembershipService.getOrgMembership(orgId, membershipId);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
@@ -0,0 +1,147 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { IsWebhookInOrg } from "@/modules/auth/guards/organizations/is-webhook-in-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { OrganizationsWebhooksService } from "@/modules/organizations/services/organizations-webhooks.service";
import { CreateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
import { UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
import {
TeamWebhookOutputDto as OrgWebhookOutputDto,
TeamWebhookOutputResponseDto as OrgWebhookOutputResponseDto,
TeamWebhooksOutputResponseDto as OrgWebhooksOutputResponseDto,
} from "@/modules/webhooks/outputs/team-webhook.output";
import { PartialWebhookInputPipe, WebhookInputPipe } from "@/modules/webhooks/pipes/WebhookInputPipe";
import { WebhookOutputPipe } from "@/modules/webhooks/pipes/WebhookOutputPipe";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import {
Controller,
UseGuards,
Get,
Param,
ParseIntPipe,
Query,
Delete,
Patch,
Post,
Body,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId/webhooks",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Organizations Webhooks")
export class OrganizationsWebhooksController {
constructor(
private organizationsWebhooksService: OrganizationsWebhooksService,
private webhooksService: WebhooksService
) {}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/")
@HttpCode(HttpStatus.OK)
async getAllOrganizationWebhooks(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination
): Promise<OrgWebhooksOutputResponseDto> {
const { skip, take } = queryParams;
const webhooks = await this.organizationsWebhooksService.getWebhooksPaginated(
orgId,
skip ?? 0,
take ?? 250
);
return {
status: SUCCESS_STATUS,
data: webhooks.map((webhook) =>
plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
})
),
};
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/")
@HttpCode(HttpStatus.CREATED)
async createOrganizationWebhook(
@Param("orgId", ParseIntPipe) orgId: number,
@Body() body: CreateWebhookInputDto
): Promise<OrgWebhookOutputResponseDto> {
const webhook = await this.organizationsWebhooksService.createWebhook(
orgId,
new WebhookInputPipe().transform(body)
);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsWebhookInOrg)
@Get("/:webhookId")
@HttpCode(HttpStatus.OK)
async getOrganizationWebhook(@Param("webhookId") webhookId: string): Promise<OrgWebhookOutputResponseDto> {
const webhook = await this.organizationsWebhooksService.getWebhook(webhookId);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsWebhookInOrg)
@Delete("/:webhookId")
@HttpCode(HttpStatus.OK)
async deleteWebhook(@Param("webhookId") webhookId: string): Promise<OrgWebhookOutputResponseDto> {
const webhook = await this.webhooksService.deleteWebhook(webhookId);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsWebhookInOrg)
@Patch("/:webhookId")
@HttpCode(HttpStatus.OK)
async updateOrgWebhook(
@Param("webhookId") webhookId: string,
@Body() body: UpdateWebhookInputDto
): Promise<OrgWebhookOutputResponseDto> {
const webhook = await this.organizationsWebhooksService.updateWebhook(
webhookId,
new PartialWebhookInputPipe().transform(body)
);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
}
@@ -0,0 +1,211 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { UserWithProfile } from "@/modules/users/users.repository";
import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
import {
TeamWebhookOutputResponseDto,
TeamWebhooksOutputResponseDto,
} from "@/modules/webhooks/outputs/team-webhook.output";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { Team, Webhook } from "@calcom/prisma/client";
describe("WebhooksController (e2e)", () => {
let app: INestApplication;
const userEmail = "webhook-controller-e2e@api.com";
let org: Team;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let webhookRepositoryFixture: WebhookRepositoryFixture;
let userRepositoryFixture: UserRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let webhook: TeamWebhookOutputResponseDto["data"];
let otherWebhook: Webhook;
let user: UserWithProfile;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
webhookRepositoryFixture = new WebhookRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
org = await organizationsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
otherWebhook = await webhookRepositoryFixture.create({
id: "2mdfnn2",
subscriberUrl: "https://example.com",
eventTriggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: true,
payloadTemplate: "string",
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
afterAll(async () => {
userRepositoryFixture.deleteByEmail(user.email);
webhookRepositoryFixture.delete(otherWebhook.id);
await app.close();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("/organizations/:orgId/webhooks (POST)", () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/webhooks`)
.send({
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: true,
payloadTemplate: "string",
} satisfies CreateWebhookInputDto)
.expect(201)
.then(async (res) => {
process.stdout.write(JSON.stringify(res.body));
expect(res.body).toMatchObject({
status: "success",
data: {
id: expect.any(String),
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: true,
payloadTemplate: "string",
teamId: org.id,
},
} satisfies TeamWebhookOutputResponseDto);
webhook = res.body.data;
});
});
it("/organizations/:orgId/webhooks (POST) should fail to create a webhook that already has same orgId / subcriberUrl combo", () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/webhooks`)
.send({
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: true,
payloadTemplate: "string",
} satisfies CreateWebhookInputDto)
.expect(409);
});
it("/organizations/:orgId/webhooks/:webhookId (PATCH)", () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/webhooks/${webhook.id}`)
.send({
active: false,
} satisfies UpdateWebhookInputDto)
.expect(200)
.then((res) => {
expect(res.body.data.active).toBe(false);
});
});
it("/organizations/:orgId/webhooks/:webhookId (GET)", () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/webhooks/${webhook.id}`)
.expect(200)
.then((res) => {
expect(res.body).toMatchObject({
status: "success",
data: {
id: expect.any(String),
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: false,
payloadTemplate: "string",
teamId: org.id,
},
} satisfies TeamWebhookOutputResponseDto);
});
});
it("/organizations/:orgId/webhooks/:webhookId (GET) should say forbidden to get a webhook that does not exist", () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/webhooks/90284`).expect(403);
});
it("/organizations/:orgId/webhooks/:webhookId (GET) should fail to get a webhook that does not belong to org", () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/webhooks/${otherWebhook.id}`)
.expect(403);
});
it("/organizations/:orgId/webhooks (GET)", () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/webhooks`)
.expect(200)
.then((res) => {
const responseBody = res.body as TeamWebhooksOutputResponseDto;
responseBody.data.forEach((webhook) => {
expect(webhook.teamId).toBe(org.id);
});
});
});
it("/organizations/:orgId/webhooks/:webhookId (DELETE)", () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/webhooks/${webhook.id}`)
.expect(200)
.then((res) => {
expect(res.body).toMatchObject({
status: "success",
data: {
id: expect.any(String),
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: false,
payloadTemplate: "string",
teamId: org.id,
},
} satisfies TeamWebhookOutputResponseDto);
});
});
it("/organizations/:orgId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not exist", () => {
return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/webhooks/12993`).expect(403);
});
it("/organizations/:orgId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not belong to org", () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/webhooks/${otherWebhook.id}`)
.expect(403);
});
});
@@ -0,0 +1,17 @@
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
export class AssignOrganizationAttributeOptionToUserInput {
@IsOptional()
@IsString()
@IsNotEmpty()
readonly value?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
readonly attributeOptionId?: string;
@IsNotEmpty()
@IsString()
readonly attributeId!: string;
}
@@ -0,0 +1,35 @@
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
import { AttributeType } from "@prisma/client";
import { Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
export class CreateOrganizationAttributeInput {
@IsString()
@IsNotEmpty()
readonly name!: string;
@IsString()
@IsNotEmpty()
readonly slug!: string;
@IsEnum(AttributeType)
@IsNotEmpty()
readonly type!: AttributeType;
@IsArray()
@ValidateNested()
@Type(() => CreateOrganizationAttributeOptionInput)
readonly options!: CreateOrganizationAttributeOptionInput[];
@IsBoolean()
@IsOptional()
readonly enabled?: boolean;
}
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from "class-validator";
export class CreateOrganizationAttributeOptionInput {
@IsString()
@IsNotEmpty()
readonly value!: string;
@IsString()
@IsNotEmpty()
readonly slug!: string;
}
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
export class UpdateOrganizationAttributeOptionInput {
@IsString()
@IsOptional()
readonly value?: string;
@IsString()
@IsOptional()
readonly slug?: string;
}
@@ -0,0 +1,20 @@
import { AttributeType } from "@prisma/client";
import { IsBoolean, IsEnum, IsOptional, IsString } from "class-validator";
export class UpdateOrganizationAttributeInput {
@IsString()
@IsOptional()
readonly name?: string;
@IsString()
@IsOptional()
readonly slug?: string;
@IsEnum(AttributeType)
@IsOptional()
readonly type?: AttributeType;
@IsBoolean()
@IsOptional()
readonly enabled?: boolean;
}
@@ -3,19 +3,27 @@ import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/
import { EmailModule } from "@/modules/email/email.module";
import { EmailService } from "@/modules/email/email.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsOptionsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes-options.controller";
import { OrganizationsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes.controller";
import { OrganizationsEventTypesController } from "@/modules/organizations/controllers/event-types/organizations-event-types.controller";
import { OrganizationsMembershipsController } from "@/modules/organizations/controllers/memberships/organizations-membership.controller";
import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller";
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller";
import { OrganizationsUsersController } from "@/modules/organizations/controllers/users/organizations-users.controller";
import { OrganizationsWebhooksController } from "@/modules/organizations/controllers/webhooks/organizations-webhooks.controller";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/repositories/attributes/organization-attribute-option.repository";
import { OrganizationAttributesRepository } from "@/modules/organizations/repositories/attributes/organization-attribute.repository";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository";
import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/repositories/organizations-teams-memberships.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository";
import { OrganizationsWebhooksRepository } from "@/modules/organizations/repositories/organizations-webhooks.repository";
import { OrganizationAttributeOptionService } from "@/modules/organizations/services/attributes/organization-attributes-option.service";
import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service";
import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/output.service";
@@ -24,11 +32,14 @@ import { OrganizationsSchedulesService } from "@/modules/organizations/services/
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import { OrganizationsUsersService } from "@/modules/organizations/services/organizations-users-service";
import { OrganizationsWebhooksService } from "@/modules/organizations/services/organizations-webhooks.service";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { UsersModule } from "@/modules/users/users.module";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
import { Module } from "@nestjs/common";
@Module({
@@ -60,6 +71,14 @@ import { Module } from "@nestjs/common";
OrganizationsEventTypesRepository,
OrganizationsTeamsMembershipsRepository,
OrganizationsTeamsMembershipsService,
OrganizationAttributesService,
OrganizationAttributeOptionService,
OrganizationAttributeOptionRepository,
OrganizationAttributesRepository,
OrganizationsWebhooksRepository,
OrganizationsWebhooksService,
WebhooksRepository,
WebhooksService,
],
exports: [
OrganizationsService,
@@ -71,6 +90,14 @@ import { Module } from "@nestjs/common";
OrganizationsMembershipService,
OrganizationsTeamsMembershipsRepository,
OrganizationsTeamsMembershipsService,
OrganizationAttributesService,
OrganizationAttributeOptionService,
OrganizationAttributeOptionRepository,
OrganizationAttributesRepository,
OrganizationsWebhooksRepository,
OrganizationsWebhooksService,
WebhooksRepository,
WebhooksService,
],
controllers: [
OrganizationsTeamsController,
@@ -79,6 +106,9 @@ import { Module } from "@nestjs/common";
OrganizationsMembershipsController,
OrganizationsEventTypesController,
OrganizationsTeamsMembershipsController,
OrganizationsAttributesController,
OrganizationsOptionsAttributesController,
OrganizationsWebhooksController,
],
})
export class OrganizationsModule {}
@@ -0,0 +1,71 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, IsInt, IsEnum, IsBoolean } from "class-validator";
export const AttributeType = {
TEXT: "TEXT",
NUMBER: "NUMBER",
SINGLE_SELECT: "SINGLE_SELECT",
MULTI_SELECT: "MULTI_SELECT",
} as const;
export type AttributeType = (typeof AttributeType)[keyof typeof AttributeType];
export class Attribute {
@IsString()
@ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_123" })
id!: string;
@IsInt()
@ApiProperty({
type: Number,
required: true,
description: "The team ID associated with the attribute",
example: 1,
})
teamId!: number;
@ApiProperty({
type: String,
required: true,
description: "The type of the attribute",
enum: AttributeType,
})
@IsEnum(AttributeType)
type!: AttributeType;
@IsString()
@ApiProperty({
type: String,
required: true,
description: "The name of the attribute",
example: "Attribute Name",
})
name!: string;
@IsString()
@ApiProperty({
type: String,
required: true,
description: "The slug of the attribute",
example: "attribute-name",
})
slug!: string;
@IsBoolean()
@ApiProperty({
type: Boolean,
required: true,
description: "Whether the attribute is enabled and displayed on their profile",
example: true,
})
enabled!: boolean;
@IsBoolean()
@ApiProperty({
type: Boolean,
required: false,
description: "Whether users can edit the relation",
example: true,
})
usersCanEditRelation!: boolean;
}
@@ -0,0 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
import { ERROR_STATUS } from "@calcom/platform-constants";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
export class BaseOutputDTO {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
}
@@ -0,0 +1,11 @@
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
export class CreateOrganizationAttributesOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => Attribute)
data!: Attribute;
}
@@ -0,0 +1,11 @@
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
export class DeleteOrganizationAttributesOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => Attribute)
data!: Attribute;
}
@@ -0,0 +1,22 @@
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsBoolean, IsInt, IsString, IsEnum, ValidateNested, IsOptional } from "class-validator";
import { AttributeType } from "@calcom/prisma/client";
export class GetSingleAttributeOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@IsOptional()
@Type(() => Attribute)
data!: Attribute | null;
}
export class GetOrganizationAttributesOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => Attribute)
data!: Attribute[];
}
@@ -0,0 +1,33 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested } from "class-validator";
class AssignOptionUserOutputData {
@IsString()
@ApiProperty({ type: String, required: true, description: "The ID of the option assigned to the user" })
id!: string;
@IsString()
@ApiProperty({ type: Number, required: true, description: "The ID form the org membership for the user" })
memberId!: number;
@IsString()
@ApiProperty({ type: String, required: true, description: "The value of the option" })
attributeOptionId!: string;
}
export class AssignOptionUserOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => AssignOptionUserOutputData)
data!: AssignOptionUserOutputData;
}
export class UnassignOptionUserOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => AssignOptionUserOutputData)
data!: AssignOptionUserOutputData;
}
@@ -0,0 +1,11 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
export class CreateAttributeOptionOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => OptionOutput)
data!: OptionOutput;
}
@@ -0,0 +1,11 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
export class DeleteAttributeOptionOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => OptionOutput)
data!: OptionOutput;
}
@@ -0,0 +1,29 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested } from "class-validator";
class GetOptionUserOutputData {
@IsString()
@ApiProperty({ type: String, required: true, description: "The ID of the option assigned to the user" })
id!: string;
@IsString()
@ApiProperty({ type: String, required: true, description: "The ID of the attribute" })
attributeId!: string;
@IsString()
@ApiProperty({ type: String, required: true, description: "The value of the option" })
value!: string;
@IsString()
@ApiProperty({ type: String, required: true, description: "The slug of the option" })
slug!: string;
}
export class GetOptionUserOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => GetOptionUserOutputData)
data!: GetOptionUserOutputData[];
}
@@ -0,0 +1,11 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
export class GetAllAttributeOptionOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => OptionOutput)
data!: OptionOutput[];
}
@@ -0,0 +1,36 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { ApiProperty } from "@nestjs/swagger";
import { IsString, IsInt, IsEnum, IsBoolean } from "class-validator";
export class OptionOutput {
@IsString()
@ApiProperty({
type: String,
required: true,
description: "The ID of the option",
example: "attr_option_id",
})
id!: string;
@IsString()
@ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_id" })
attributeId!: string;
@IsString()
@ApiProperty({
type: String,
required: true,
description: "The value of the option",
example: "option_value",
})
value!: string;
@IsString()
@ApiProperty({
type: String,
required: true,
description: "The slug of the option",
example: "option-slug",
})
slug!: string;
}
@@ -0,0 +1,11 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
export class UpdateAttributeOptionOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => OptionOutput)
data!: OptionOutput;
}
@@ -0,0 +1,11 @@
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
export class UpdateOrganizationAttributesOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => Attribute)
data!: Attribute;
}
@@ -0,0 +1,160 @@
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { slugify } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationAttributeOptionRepository {
private readonly logger = new Logger("OrganizationAttributeOptionRepository");
constructor(
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private readonly organizationsMembershipsService: OrganizationsMembershipService
) {}
async createOrganizationAttributeOption(
organizationId: number,
attributeId: string,
data: CreateOrganizationAttributeOptionInput
) {
return this.dbWrite.prisma.attributeOption.create({
data: {
...data,
attributeId,
},
});
}
async deleteOrganizationAttributeOption(organizationId: number, attributeId: string, optionId: string) {
try {
const deletedAttributeOption = await this.dbWrite.prisma.attributeOption.delete({
where: {
id: optionId,
attributeId,
},
});
return deletedAttributeOption;
} catch (error: any) {
if (error.code === "P2025") {
// P2025 is the Prisma error code for "Record to delete does not exist."
this.logger.warn(`Attribute option not found: ${optionId}`);
throw new NotFoundException("Attribute option not found");
}
throw error;
}
}
async updateOrganizationAttributeOption(
organizationId: number,
attributeId: string,
optionId: string,
data: UpdateOrganizationAttributeOptionInput
) {
return this.dbWrite.prisma.attributeOption.update({
where: {
id: optionId,
attributeId,
},
data,
});
}
async getOrganizationAttributeOptions(organizationId: number, attributeId: string) {
return this.dbRead.prisma.attributeOption.findMany({
where: {
attributeId,
},
});
}
async getOrganizationAttributeOptionsForUser(organizationId: number, userId: number) {
const options = this.dbRead.prisma.attributeOption.findMany({
where: {
attribute: {
teamId: organizationId,
},
assignedUsers: {
some: {
member: {
userId,
},
},
},
},
});
return options;
}
async assignOrganizationAttributeOptionToUser({
organizationId,
membershipId,
attributeId,
value,
attributeOptionId,
}: {
organizationId: number;
membershipId: number;
attributeId: string;
value?: string;
attributeOptionId?: string;
}) {
let _attributeOptionId = attributeOptionId;
if (value && !attributeOptionId) {
_attributeOptionId = await this.createDynamicAttributeOption(organizationId, attributeId, value);
}
if (!_attributeOptionId) throw new Error("Attribute option not found");
return this.dbWrite.prisma.attributeToUser.create({
data: {
attributeOptionId: _attributeOptionId,
memberId: membershipId,
},
});
}
private async createDynamicAttributeOption(
organizationId: number,
attributeId: string,
value: string
): Promise<string> {
const attributeOption = await this.createOrganizationAttributeOption(organizationId, attributeId, {
value: value,
slug: slugify(value),
});
return attributeOption.id;
}
async unassignOrganizationAttributeOptionFromUser(
organizationId: number,
userId: number,
attributeOptionId: string
) {
const membership = await this.organizationsMembershipsService.getOrgMembershipByUserId(
organizationId,
userId
);
if (!membership) throw new Error("Membership not found");
try {
const deletedAttributeToUser = await this.dbWrite.prisma.attributeToUser.delete({
where: { memberId_attributeOptionId: { memberId: membership.id, attributeOptionId } },
});
return deletedAttributeToUser;
} catch (error: any) {
if (error.code === "P2025") {
// P2025 is the Prisma error code for "Record to delete does not exist."
this.logger.warn(`Attribute option not found: ${attributeOptionId} for user ${userId}`);
throw new NotFoundException("Attribute does not belong to this user");
}
throw error;
}
}
}
@@ -0,0 +1,77 @@
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationAttributesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createOrganizationAttribute(organizationId: number, data: CreateOrganizationAttributeInput) {
const { options, ...attributeData } = data;
const attribute = await this.dbWrite.prisma.attribute.create({
data: {
...attributeData,
teamId: organizationId,
},
});
if (attribute.type === "SINGLE_SELECT" || attribute.type === "MULTI_SELECT") {
// TODO: move this to attribute option service
await this.dbWrite.prisma.attributeOption.createMany({
data: options.map((option) => ({
...option,
attributeId: attribute.id,
})),
});
}
return attribute;
}
async getOrganizationAttribute(organizationId: number, attributeId: string) {
const attribute = await this.dbRead.prisma.attribute.findUnique({
where: {
id: attributeId,
teamId: organizationId,
},
});
return attribute;
}
async getOrganizationAttributes(organizationId: number, skip?: number, take?: number) {
const attributes = await this.dbRead.prisma.attribute.findMany({
where: {
teamId: organizationId,
},
skip,
take,
});
return attributes;
}
async updateOrganizationAttribute(
organizationId: number,
attributeId: string,
data: UpdateOrganizationAttributeInput
) {
const attribute = await this.dbWrite.prisma.attribute.update({
where: {
id: attributeId,
teamId: organizationId,
},
data,
});
return attribute;
}
async deleteOrganizationAttribute(organizationId: number, attributeId: string) {
const attribute = await this.dbWrite.prisma.attribute.delete({
where: {
id: attributeId,
teamId: organizationId,
},
});
return attribute;
}
}
@@ -18,6 +18,15 @@ export class OrganizationsMembershipRepository {
});
}
async findOrgMembershipByUserId(organizationId: number, userId: number) {
return this.dbRead.prisma.membership.findFirst({
where: {
teamId: organizationId,
userId,
},
});
}
async deleteOrgMembership(organizationId: number, membershipId: number) {
return this.dbWrite.prisma.membership.delete({
where: {
@@ -1,11 +1,12 @@
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationsTeamsRepository {
constructor(private readonly dbRead: PrismaReadService) {}
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async findOrgTeam(organizationId: number, teamId: number) {
return this.dbRead.prisma.team.findUnique({
@@ -26,7 +27,7 @@ export class OrganizationsTeamsRepository {
}
async deleteOrgTeam(organizationId: number, teamId: number) {
return this.dbRead.prisma.team.delete({
return this.dbWrite.prisma.team.delete({
where: {
id: teamId,
isOrganization: false,
@@ -36,13 +37,13 @@ export class OrganizationsTeamsRepository {
}
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto) {
return this.dbRead.prisma.team.create({
return this.dbWrite.prisma.team.create({
data: { ...data, parentId: organizationId },
});
}
async createPlatformOrgTeam(organizationId: number, oAuthClientId: string, data: CreateOrgTeamDto) {
return this.dbRead.prisma.team.create({
return this.dbWrite.prisma.team.create({
data: {
...data,
parentId: organizationId,
@@ -61,7 +62,7 @@ export class OrganizationsTeamsRepository {
}
async updateOrgTeam(organizationId: number, teamId: number, data: UpdateOrgTeamDto) {
return this.dbRead.prisma.team.update({
return this.dbWrite.prisma.team.update({
data: { ...data },
where: { id: teamId, parentId: organizationId, isOrganization: false },
});
@@ -0,0 +1,71 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { v4 as uuidv4 } from "uuid";
import { Webhook } from "@calcom/prisma/client";
type WebhookInputData = Pick<
Webhook,
"payloadTemplate" | "eventTriggers" | "subscriberUrl" | "secret" | "active"
>;
@Injectable()
export class OrganizationsWebhooksRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async findWebhookByUrl(organizationId: number, subscriberUrl: string) {
return this.dbRead.prisma.webhook.findFirst({
where: { teamId: organizationId, subscriberUrl },
});
}
async findWebhook(organizationId: number, webhookId: string) {
return this.dbRead.prisma.webhook.findUnique({
where: {
id: webhookId,
teamId: organizationId,
},
});
}
async findWebhooks(organizationId: number) {
return this.dbRead.prisma.webhook.findMany({
where: {
teamId: organizationId,
},
});
}
async deleteWebhook(organizationId: number, webhookId: string) {
return this.dbRead.prisma.webhook.delete({
where: {
id: webhookId,
teamId: organizationId,
},
});
}
async createWebhook(organizationId: number, data: WebhookInputData) {
const id = uuidv4();
return this.dbWrite.prisma.webhook.create({
data: { ...data, id, teamId: organizationId },
});
}
async updateWebhook(organizationId: number, webhookId: string, data: Partial<WebhookInputData>) {
return this.dbRead.prisma.webhook.update({
data: { ...data },
where: { id: webhookId, teamId: organizationId },
});
}
async findWebhooksPaginated(organizationId: number, skip: number, take: number) {
return this.dbRead.prisma.webhook.findMany({
where: {
teamId: organizationId,
},
skip,
take,
});
}
}
@@ -0,0 +1,114 @@
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input";
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/repositories/attributes/organization-attribute-option.repository";
import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service";
import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
const TYPE_SUPPORTS_VALUE = new Set(["TEXT", "NUMBER"]);
@Injectable()
export class OrganizationAttributeOptionService {
private readonly logger = new Logger("OrganizationAttributeOptionService");
constructor(
private readonly organizationAttributeOptionRepository: OrganizationAttributeOptionRepository,
private readonly organizationAttributesService: OrganizationAttributesService,
private readonly organizationsMembershipsService: OrganizationsMembershipService
) {}
async createOrganizationAttributeOption(
organizationId: number,
attributeId: string,
data: CreateOrganizationAttributeOptionInput
) {
return this.organizationAttributeOptionRepository.createOrganizationAttributeOption(
organizationId,
attributeId,
data
);
}
async deleteOrganizationAttributeOption(organizationId: number, attributeId: string, optionId: string) {
return this.organizationAttributeOptionRepository.deleteOrganizationAttributeOption(
organizationId,
attributeId,
optionId
);
}
async updateOrganizationAttributeOption(
organizationId: number,
attributeId: string,
optionId: string,
data: UpdateOrganizationAttributeOptionInput
) {
return this.organizationAttributeOptionRepository.updateOrganizationAttributeOption(
organizationId,
attributeId,
optionId,
data
);
}
async getOrganizationAttributeOptions(organizationId: number, attributeId: string) {
return this.organizationAttributeOptionRepository.getOrganizationAttributeOptions(
organizationId,
attributeId
);
}
async assignOrganizationAttributeOptionToUser(
organizationId: number,
userId: number,
data: AssignOrganizationAttributeOptionToUserInput
) {
const attribute = await this.organizationAttributesService.getOrganizationAttribute(
organizationId,
data.attributeId
);
if (!attribute) {
throw new NotFoundException("Attribute not found");
}
const membership = await this.organizationsMembershipsService.getOrgMembershipByUserId(
organizationId,
userId
);
if (!membership || !membership.accepted)
throw new NotFoundException("User is not a member of the organization");
if (!TYPE_SUPPORTS_VALUE.has(attribute.type) && data.value) {
throw new BadRequestException("Attribute type does not support value");
}
return this.organizationAttributeOptionRepository.assignOrganizationAttributeOptionToUser({
organizationId,
membershipId: membership.id,
value: data.value,
attributeId: data.attributeId,
attributeOptionId: data.attributeOptionId,
});
}
async unassignOrganizationAttributeOptionFromUser(
organizationId: number,
userId: number,
attributeOptionId: string
) {
return this.organizationAttributeOptionRepository.unassignOrganizationAttributeOptionFromUser(
organizationId,
userId,
attributeOptionId
);
}
async getOrganizationAttributeOptionsForUser(organizationId: number, userId: number) {
return this.organizationAttributeOptionRepository.getOrganizationAttributeOptionsForUser(
organizationId,
userId
);
}
}
@@ -0,0 +1,36 @@
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
import { OrganizationAttributesRepository } from "@/modules/organizations/repositories/attributes/organization-attribute.repository";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationAttributesService {
constructor(private readonly organizationAttributesRepository: OrganizationAttributesRepository) {}
async createOrganizationAttribute(organizationId: number, data: CreateOrganizationAttributeInput) {
const attribute = await this.organizationAttributesRepository.createOrganizationAttribute(organizationId, data);
return attribute;
}
async getOrganizationAttribute(organizationId: number, attributeId: string) {
const attribute = await this.organizationAttributesRepository.getOrganizationAttribute(organizationId, attributeId);
return attribute;
}
async getOrganizationAttributes(organizationId: number, skip?: number, take?: number) {
const attributes = await this.organizationAttributesRepository.getOrganizationAttributes(organizationId, skip, take);
return attributes;
}
async updateOrganizationAttribute(organizationId: number, attributeId: string, data: UpdateOrganizationAttributeInput) {
const attribute = await this.organizationAttributesRepository.updateOrganizationAttribute(organizationId, attributeId, data);
return attribute;
}
async deleteOrganizationAttribute(organizationId: number, attributeId: string) {
const attribute = await this.organizationAttributesRepository.deleteOrganizationAttribute(organizationId, attributeId);
return attribute;
}
}
@@ -8,8 +8,8 @@ import {
CreateTeamEventTypeInput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
HostPriority,
SchedulingType,
} from "@calcom/platform-types";
import { SchedulingType } from "@calcom/prisma/client";
@Injectable()
export class InputOrganizationsEventTypesService {
@@ -30,6 +30,7 @@ export class InputOrganizationsEventTypesService {
bookingLimitsDuration,
bookingWindow,
bookingFields,
recurrence,
...rest
} = inputEventType;
@@ -47,6 +48,7 @@ export class InputOrganizationsEventTypesService {
bookingLimitsDuration,
bookingWindow,
bookingFields,
recurrence,
metadata,
};
@@ -47,6 +47,7 @@ export class OrganizationsEventTypesService {
bookingLimitsDuration,
bookingWindow,
bookingFields,
recurrence,
...rest
} = await this.inputService.transformInputCreateTeamEventType(teamId, body);
const { eventType: eventTypeCreated } = await createEventType({
@@ -69,6 +70,7 @@ export class OrganizationsEventTypesService {
bookingLimitsDuration,
bookingWindow,
bookingFields,
recurrence,
...rest,
},
user
@@ -96,7 +98,8 @@ export class OrganizationsEventTypesService {
async getUserToCreateTeamEvent(user: UserWithProfile, organizationId: number) {
const isOrgAdmin = await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId);
const profileId =
this.usersService.getUserProfileByOrgId(user, organizationId)?.id || user.movedToProfileId;
this.usersService.getUserProfileByOrgId(user, organizationId)?.id ||
this.usersService.getUserMainProfile(user)?.id;
return {
id: user.id,
role: user.role,
@@ -16,6 +16,14 @@ export class OrganizationsMembershipService {
return membership;
}
async getOrgMembershipByUserId(organizationId: number, userId: number) {
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
organizationId,
userId
);
return membership;
}
async getPaginatedOrgMemberships(organizationId: number, skip = 0, take = 250) {
const memberships = await this.organizationsMembershipRepository.findOrgMembershipsPaginated(
organizationId,
@@ -0,0 +1,45 @@
import { OrganizationsWebhooksRepository } from "@/modules/organizations/repositories/organizations-webhooks.repository";
import { UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe";
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
import { ConflictException, Injectable, NotFoundException } from "@nestjs/common";
@Injectable()
export class OrganizationsWebhooksService {
constructor(
private readonly organizationsWebhooksRepository: OrganizationsWebhooksRepository,
private readonly webhooksRepository: WebhooksRepository
) {}
async createWebhook(orgId: number, body: PipedInputWebhookType) {
const existingWebhook = await this.organizationsWebhooksRepository.findWebhookByUrl(
orgId,
body.subscriberUrl
);
if (existingWebhook) {
throw new ConflictException("Webhook with this subscriber url already exists for this user");
}
return this.organizationsWebhooksRepository.createWebhook(orgId, {
...body,
payloadTemplate: body.payloadTemplate ?? null,
secret: body.secret ?? null,
});
}
async getWebhooksPaginated(orgId: number, skip: number, take: number) {
return this.organizationsWebhooksRepository.findWebhooksPaginated(orgId, skip, take);
}
async getWebhook(webhookId: string) {
const webhook = await this.webhooksRepository.getWebhookById(webhookId);
if (!webhook) {
throw new NotFoundException(`Webhook (${webhookId}) not found`);
}
return webhook;
}
async updateWebhook(webhookId: string, body: UpdateWebhookInputDto) {
return this.webhooksRepository.updateWebhook(webhookId, body);
}
}
@@ -30,6 +30,10 @@ export class UsersService {
);
}
getUserMainOrgId(user: UserWithProfile) {
return this.getUserMainProfile(user)?.organizationId ?? user.organizationId;
}
getUserProfileByOrgId(user: UserWithProfile, organizationId: number) {
return user.profiles?.find((p) => p.organizationId === organizationId);
}
@@ -1,5 +1,6 @@
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import {
CanActivate,
@@ -17,7 +18,8 @@ import { PlatformOAuthClient, Webhook } from "@calcom/prisma/client";
export class IsOAuthClientWebhookGuard implements CanActivate {
constructor(
private readonly webhooksService: WebhooksService,
private readonly oAuthClientRepository: OAuthClientRepository
private readonly oAuthClientRepository: OAuthClientRepository,
private usersService: UsersService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -27,7 +29,7 @@ export class IsOAuthClientWebhookGuard implements CanActivate {
const user = request.user as GetUserReturnType;
const webhookId = request.params.webhookId;
const oAuthClientId = request.params.clientId;
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
const organizationId = this.usersService.getUserMainOrgId(user);
if (!user) {
throw new ForbiddenException("User not authenticated");
@@ -0,0 +1,37 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsInt, IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { WebhookOutputDto } from "./webhook.output";
export class TeamWebhookOutputDto extends WebhookOutputDto {
@IsInt()
@Expose()
readonly teamId!: number;
}
export class TeamWebhookOutputResponseDto {
@Expose()
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => WebhookOutputDto)
data!: TeamWebhookOutputDto;
}
export class TeamWebhooksOutputResponseDto {
@Expose()
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => WebhookOutputDto)
data!: TeamWebhookOutputDto[];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma, User } from "@prisma/client";
export class AttributeRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async create(data: Prisma.AttributeCreateInput) {
return this.prismaWriteClient.attribute.create({ data });
}
async createOption(data: Prisma.AttributeOptionCreateInput) {
return this.prismaWriteClient.attributeOption.create({ data });
}
async delete(id: string) {
return this.prismaWriteClient.attribute.delete({ where: { id } });
}
async deleteOption(id: string) {
return this.prismaWriteClient.attributeOption.delete({ where: { id } });
}
}
+2 -1
View File
@@ -17,7 +17,8 @@
"@prisma/client/*": ["@calcom/prisma/client/*"],
"@calcom/platform-constants": ["../../../packages/platform/constants/index.ts"],
"@calcom/platform-types": ["../../../packages/platform/types/index.ts"],
"@calcom/platform-utils": ["../../../packages/platform/utils/index.ts"]
"@calcom/platform-utils": ["../../../packages/platform/utils/index.ts"],
"@calcom/platform-enums": ["../../../packages/platform/enums/index.ts"]
},
"incremental": true,
"skipLibCheck": true,
+9
View File
@@ -13,6 +13,14 @@ const ROUTES: [URLPattern, boolean][] = [
["/apps/:slug/setup", process.env.APP_ROUTER_APPS_SLUG_SETUP_ENABLED === "1"] as const,
["/apps/categories", process.env.APP_ROUTER_APPS_CATEGORIES_ENABLED === "1"] as const,
["/apps/categories/:category", process.env.APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED === "1"] as const,
["/auth/forgot-password/:path*", process.env.APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED === "1"] as const,
["/auth/login", process.env.APP_ROUTER_AUTH_LOGIN_ENABLED === "1"] as const,
["/auth/logout", process.env.APP_ROUTER_AUTH_LOGOUT_ENABLED === "1"] as const,
["/auth/new", process.env.APP_ROUTER_AUTH_NEW_ENABLED === "1"] as const,
["/auth/saml-idp", process.env.APP_ROUTER_AUTH_SAML_ENABLED === "1"] as const,
["/auth/error", process.env.APP_ROUTER_AUTH_ERROR_ENABLED === "1"] as const,
["/auth/platform/:path*", process.env.APP_ROUTER_AUTH_PLATFORM_ENABLED === "1"] as const,
["/auth/oauth2/:path*", process.env.APP_ROUTER_AUTH_OAUTH2_ENABLED === "1"] as const,
["/workflows/:path*", process.env.APP_ROUTER_WORKFLOWS_ENABLED === "1"] as const,
["/settings/teams/:path*", process.env.APP_ROUTER_SETTINGS_TEAMS_ENABLED === "1"] as const,
["/getting-started/:step", process.env.APP_ROUTER_GETTING_STARTED_STEP_ENABLED === "1"] as const,
@@ -20,6 +28,7 @@ const ROUTES: [URLPattern, boolean][] = [
["/bookings/:status", process.env.APP_ROUTER_BOOKINGS_STATUS_ENABLED === "1"] as const,
["/video/:path*", process.env.APP_ROUTER_VIDEO_ENABLED === "1"] as const,
["/teams", process.env.APP_ROUTER_TEAMS_ENABLED === "1"] as const,
["/availability", process.env.APP_ROUTER_AVAILABILITY_ENABLED === "1"] as const,
].map(([pathname, enabled]) => [
new URLPattern({
pathname,
+8 -13
View File
@@ -1,24 +1,19 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { type GetServerSidePropsContext } from "next";
import { headers, cookies } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import LegacyPage from "~/users/views/users-type-public-view";
import { getServerSideProps, type PageProps } from "~/users/views/users-type-public-view.getServerSideProps";
import {
getServerSideProps,
type PageProps as LegacyPageProps,
} from "~/users/views/users-type-public-view.getServerSideProps";
export const generateMetadata = async ({
params,
searchParams,
}: {
params: Record<string, string | string[]>;
searchParams: { [key: string]: string | string[] | undefined };
}) => {
const props = await getData(
buildLegacyCtx(headers(), cookies(), params, searchParams) as unknown as GetServerSidePropsContext
);
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams));
const { eventData, booking, user, slug } = props;
const rescheduleUid = booking?.uid;
@@ -42,7 +37,7 @@ export const generateMetadata = async ({
(t) => `${rescheduleUid ? t("reschedule") : ""} ${title}`
);
};
const getData = withAppDirSsr<PageProps>(getServerSideProps);
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
export default WithLayout({
getData,
+3 -11
View File
@@ -1,7 +1,7 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { type GetServerSidePropsContext } from "next";
import { headers, cookies } from "next/headers";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
@@ -11,16 +11,8 @@ import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import LegacyPage from "~/users/views/users-public-view";
import { getServerSideProps, type UserPageProps } from "~/users/views/users-public-view.getServerSideProps";
export const generateMetadata = async ({
params,
searchParams,
}: {
params: Record<string, string | string[]>;
searchParams: { [key: string]: string | string[] | undefined };
}) => {
const props = await getData(
buildLegacyCtx(headers(), cookies(), params, searchParams) as unknown as GetServerSidePropsContext
);
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams));
const { profile, markdownStrippedBio } = props;
return await _generateMetadata(
@@ -13,7 +13,6 @@ import RoutingFormsRoutingConfig, {
} from "@calcom/app-store/routing-forms/pages/app-routing.config";
import TypeformRoutingConfig from "@calcom/app-store/typeform/pages/app-routing.config";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import type { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
@@ -65,21 +64,16 @@ export const generateMetadata = async ({
if (mainPage === "forms") {
return await _generateMetadata(
() => `Forms | ${APP_NAME}`,
() => `Forms`,
() => ""
);
}
const legacyContext = buildLegacyCtx(
headers(),
cookies(),
params,
searchParams
) as unknown as GetServerSidePropsContext;
const legacyContext = buildLegacyCtx(headers(), cookies(), params, searchParams);
const { form } = await getPageProps(legacyContext);
return await _generateMetadata(
() => `${form.name} | ${APP_NAME}`,
() => `${form.name}`,
() => form.description
);
};
@@ -182,12 +176,7 @@ export default async function Page({ params, searchParams }: PageProps) {
const h = headers();
const nonce = h.get("x-nonce") ?? undefined;
const legacyContext = buildLegacyCtx(
h,
cookies(),
params,
searchParams
) as unknown as GetServerSidePropsContext;
const legacyContext = buildLegacyCtx(h, cookies(), params, searchParams);
const props = await getPageProps(legacyContext);
return (
<PageWrapper getLayout={getLayout} requiresLicense={false} nonce={nonce} themeBasis={null} {...props}>
+3 -9
View File
@@ -1,12 +1,12 @@
import Page from "@pages/apps/[slug]/index";
import { Prisma } from "@prisma/client";
import { withAppDirSsg } from "app/WithAppDirSsg";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import type { InferGetStaticPropsType } from "next";
import { cookies, headers } from "next/headers";
import { APP_NAME } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { getStaticProps } from "@lib/apps/[slug]/getStaticProps";
@@ -15,18 +15,12 @@ import { buildLegacyCtx } from "@lib/buildLegacyCtx";
type Y = InferGetStaticPropsType<typeof getStaticProps>;
const getData = withAppDirSsg<Y>(getStaticProps);
export const generateMetadata = async ({
params,
searchParams,
}: {
params: Record<string, string | string[]>;
searchParams: { [key: string]: string | string[] | undefined };
}) => {
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const legacyContext = buildLegacyCtx(headers(), cookies(), params, searchParams);
const res = await getData(legacyContext);
return await _generateMetadata(
() => `${res?.data.name} | ${APP_NAME}`,
() => res?.data.name ?? "",
() => res?.data.description ?? ""
);
};
@@ -5,11 +5,10 @@ import { WithLayout } from "app/layoutHOC";
import type { InferGetServerSidePropsType } from "next";
import { getServerSideProps } from "@calcom/app-store/_pages/setup/_getServerSideProps";
import { APP_NAME } from "@calcom/lib/constants";
export const generateMetadata = async ({ params }: { params: Record<string, string | string[]> }) => {
return await _generateMetadata(
() => `${params.slug} | ${APP_NAME}`,
() => `${params.slug}`,
() => ""
);
};
@@ -11,7 +11,7 @@ import { getStaticProps } from "@lib/apps/categories/[category]/getStaticProps";
export const generateMetadata = async () => {
return await _generateMetadata(
() => `${APP_NAME} | ${APP_NAME}`,
() => `${APP_NAME}`,
() => ""
);
};
+1 -3
View File
@@ -3,13 +3,11 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { APP_NAME } from "@calcom/lib/constants";
import { getServerSideProps } from "@lib/apps/categories/getServerSideProps";
export const generateMetadata = async () => {
return await _generateMetadata(
() => `Categories | ${APP_NAME}`,
() => `Categories`,
() => ""
);
};
@@ -3,13 +3,11 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { APP_NAME } from "@calcom/lib/constants";
import { getServerSideProps } from "@lib/apps/installed/[category]/getServerSideProps";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${t("installed_apps")} | ${APP_NAME}`,
(t) => `${t("installed_apps")}`,
(t) => t("manage_your_connected_apps")
);
};
+19
View File
@@ -0,0 +1,19 @@
import { withAppDirSsg } from "app/WithAppDirSsg";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getStaticProps } from "@server/lib/auth/error/getStaticProps";
import Page from "~/auth/error/error-view";
export const generateMetadata = async () => {
return await _generateMetadata(
() => "Error",
() => ""
);
};
const getData = withAppDirSsg(getStaticProps);
export default WithLayout({ getData, Page, getLayout: null })<"P">;
export const dynamic = "force-static";
@@ -1,9 +1,11 @@
import SetNewUserPassword from "@pages/auth/forgot-password/[id]";
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@server/lib/forgot-password/[id]/getServerSideProps";
import { getServerSideProps } from "@server/lib/auth/forgot-password/[id]/getServerSideProps";
import type { PageProps } from "~/auth/forgot-password/[id]/forgot-password-single-view";
import SetNewUserPassword from "~/auth/forgot-password/[id]/forgot-password-single-view";
export const generateMetadata = async () => {
return await _generateMetadata(
@@ -15,5 +17,5 @@ export const generateMetadata = async () => {
export default WithLayout({
getLayout: null,
Page: SetNewUserPassword,
getData: withAppDirSsr(getServerSideProps),
getData: withAppDirSsr<PageProps>(getServerSideProps),
})<"P">;

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