Compare commits

...
150 Commits
Author SHA1 Message Date
Keith Williams 97b2816897 v4.0.6 (#14865)
release.yml / release (push) Canceled after 0s
2024-05-02 22:23:46 +00:00
Erik 00372b6b3b chore: Discard unused Platform stripe events (#14822)
* chore: discard unused webhooks

* chore: discard early
2024-05-02 16:31:50 +00:00
Hariom Balhara 6c8fced247 fix: Snippet path in preview (#14830)
* fix: Snippet path in preview

* Add test
2024-05-02 16:03:57 +00:00
Erik 52813b01e6 chore: don't try to increase usage for legacy plans (#14856) 2024-05-02 15:24:42 +00:00
Erik 77a98d96c8 chore: attach unique request ids (#14857) 2024-05-02 15:24:24 +00:00
Peer RichelsenandUdit Takkar 676eac357f chore: fixed enterprise warning and description (#14791)
* fixed enterprise warning and description

* chore: remove fullstop

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-05-02 15:14:13 +00:00
Joe Au-Yeung 5bafd1b264 chore: Allow disabling attendee emails if SMS_ATTENDEE is a part of the workflow (#14806)
* Allow disabling confirmation emails to attendees if workflow contains SMS

* Update copy
2024-05-02 10:44:08 -04:00
0391bfe887 test: add unit tests for sms sending (#14737)
* add tests for workflow sms

* fix type error

* improvements

* add tests for team workflow

* rename test

* add emailsToReceive everywhere

* fix type errors

* code clean up

* fix fresh-booking.test.ts

* add destination email to emailsToReceive

* remove unused webhooks

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-05-02 10:42:38 -04:00
Peer Richelsen c18407d75a full screen ratings (#14839) 2024-05-02 09:43:28 +00:00
Erik a631286770 chore: Add default to match migration for Platform Billing (#14847) 2024-05-02 05:29:32 +00:00
Carina WollendorferandCarinaWolli dd78741158 fix e2e test (#14845)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-05-01 20:45:40 +00:00
Peer Richelsen 8f69811e25 chore: Update README.md (#14819) 2024-05-01 21:18:53 +02:00
Erik 3a63a8b228 chore: Bring back next-18next.config.js (#14841)
* chore: bring back next-18next.config.js

* chore: bring back next-18next.config.js
2024-05-01 10:23:03 -03:00
Somay ChauhanandPeer Richelsen ef98105503 feat: send organization_id to intercom (#14811)
* send organization_id to intercom

* send org slug instead of org id

* fix: type errors

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-04-30 21:38:18 +00:00
Erik a18f1eb382 v4.0.5 (#14820)
release.yml / release (push) Canceled after 0s
2024-04-30 21:23:51 +00:00
Joe Au-Yeung 2ab6315a8e Add two more shards to E2E pipeline (#14818) 2024-04-30 20:31:17 +00:00
Joe Au-Yeungandexception 258ebe0420 chore: Update yarn.lock file (#14812)
* Update yarn.lock file

* chore: update lockfile

---------

Co-authored-by: exception <erik@erosemberg.com>
2024-04-30 20:05:29 +00:00
Erik 1cfd2f7053 feat: Billing Service (#14756)
* chore: Billing Service

* chore: others

* chore: don't type

* chore: typo

* chore: use proper wh secret

* chore: updates

* chore: lockfile

* chore: docs and comment out

* chore: updates and include plan

* chore: config

* chore: bring back alias

* chore: update lockfile

* chore: refactor

* chore: default none plan in db

* chore: read from env vars

* chore: feedback

* chore: remove json config

* chore: proper type
2024-04-30 20:02:04 +00:00
Hariom Balhara a51024e915 hotfix-conditional-valid-resource-owner-check (#14814) 2024-04-30 15:05:00 +00:00
Hariom BalharaandJoe Au-Yeung 900e90cd2c fix: With Credential Sync enabled, fix expired google calendar token handling - Refresh wasn't working (#14788)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-04-29 20:37:18 +00:00
Somay Chauhan 46ffeaee82 feat: add additional attributes to intercom (#14794)
* feat: add additional attributes to intercom

* remove console.log

* added is_premium
2024-04-29 19:50:07 +02:00
Alex van Andel 22425bb693 Revert "fix: invalid Cancelled / Rescheduled downstream calendar block (#14700)" (#14800)
This reverts commit 0306ad737f.
2024-04-29 13:31:02 -03:00
Syed Ali Shahbaz ad0b8a668c fix: Unbork Team POST API (#14803)
* Add newly added mandatory fields to partial

* --WIP
2024-04-29 17:10:10 +01:00
bbad2c9d31 fix: v2 ts config and errors (#14799)
* fix: exclude platform examples

* fix: v2 event-types TS errors

* fix: nest entry file

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-04-29 16:06:25 +00:00
Amit SharmaandUdit Takkar 8b28a6f5df feat: when creating workflow from within event-type, preselect it (#14774)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-29 14:23:28 +00:00
Chris 93bb37702c feat: Add app store entry for Clic (CheckIn application) (#14754)
* feat: create app

* feat: create app

* feat: initial commit
2024-04-29 13:26:13 +00:00
Alex van Andel 4a8f251d89 fix: #14769 (#14777) 2024-04-27 18:14:37 +02:00
lambertrocherandlambert cf44919cdd fix missing french translation for keys round_robin_emailed_you_and_attendees and round_robin_emailed_you_and_attendees_recurring (#14773)
Co-authored-by: lambert <lambert.rocher@jellysmack.com>
2024-04-27 01:19:56 +00:00
Morgan 024e40b26b chore: api v2 tsconfig manual path to internal packages (#14771) 2024-04-26 20:40:30 -03:00
Nishan SinghandKeith Williams 7c354321be feat: Add linting rule to recursively check for Prisma includes using… (#13652)
* feat: Add linting rule to recursively check for Prisma includes using only 'true'

* Fixing linting errors

* More linting fixes

* Disabled linting for forms

* Update recommended.ts

* Removed ignores

* Fixed warning

* Type fix

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-04-26 21:16:16 +00:00
Abhiuday Gupta fc16ec59d3 fix: confirm booking doesn't respect minimum booking notice (#14577)
Signed-off-by: Abhiuday <contact.abhiuday@gmail.com>
2024-04-26 11:58:11 -04:00
Hariom Balhara ab26481738 fix child element click popup (#14749) 2024-04-26 14:37:27 +00:00
Amit SharmaandUdit Takkar 2fbc06314e feat: Hide recurring tab when payments are enabled (#14731)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-26 10:18:29 -04:00
sean-brydonandUdit Takkar 386006fad0 feat: date range picker (#14713)
* feat: add DateRangePicker.tsx and Calendar.tsx to match DS

* chore:cleanup

* tests: remove as these are duplicate tests from the library themselves

* fix: mobile sizes

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-26 13:38:42 +00:00
ceb555460e feat: Platform Rate limiting + access token caching (#14560)
* feat: Platform Rate limiting + access token caching

* chore: changes

* chore: cleanup

* chore: remove yarnlock changes

* chore: yarnlcok

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-04-26 13:14:13 +00:00
Lauris Skraucis c28870e029 refactor: booker atom unmount reset duration query param (#14766) 2024-04-26 14:01:50 +03:00
Syed Ali Shahbaz 0650898302 feat: Add ratings to insights (#14687) 2024-04-26 11:58:06 +01:00
Lauris Skraucis d004815a68 feat: v2 managed user create default schedule given timeZone (#14727)
* schedules service: function to create default user schedule

* managed user: create default schedule if timeZone provided

* swagger update
2024-04-26 09:20:29 +00:00
Morgan 5d76bd6b9c v4.0.4 (#14764)
release.yml / release (push) Canceled after 0s
2024-04-26 09:21:07 +03:00
Erik d02947f887 chore: SDK cleanup (#14763) 2024-04-25 23:22:47 +00:00
Erik a3a95a1496 chore: SDK readme (#14761) 2024-04-25 20:26:46 +00:00
Hariom BalharaandJoe Au-Yeung d47c6b3fdb fix: Credential Syncing Improvements (#14588)
* Add example app to test credential sync

* Fixes

* Changes to normalize flow of GoogleCalendar and Zoom

* Add unit tests

* PR Feedback

* credential-sync-more-tests-and-more-apps

* Fix yarn.lock

* Clear cache

* Add test

* Fix yarn.lock

* Fix 204 handling

* Fix yarn.lock

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-04-25 12:47:17 -04:00
Morgan ebbb1dcce3 fix: oAuthClient Creation Form (#14750) 2024-04-25 13:51:03 +00:00
Morgan 8fbb1ed426 chore: bump atoms 1.0.35 and wrap context (#14744) 2024-04-25 11:47:30 +03:00
Omar López d8adc8eeb1 fix: Check permissions for team OOO entries (#14736) 2024-04-24 19:24:59 +00:00
Lauris Skraucis a7bd4aee85 fix: booker atom month reset (#14732) 2024-04-24 14:36:29 +00:00
Lauris SkraucisandMorgan 82fcda4b42 fix: booker atom reschedule data (#14714)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-04-24 13:54:05 +03:00
Lauris SkraucisandMorgan b3b7a09cfb fix: booker atom not resetting month query param (#14715)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-04-24 07:35:33 +00:00
Omar López 60ae817f30 chore: v4.0.3 (#14725)
release.yml / release (push) Canceled after 0s
Bump version
2024-04-23 22:53:24 +00:00
Udit Takkar 74166fc2cb fix: settings layout for org admin (#14723) 2024-04-23 21:53:17 +00:00
Patrick Göler von Ravensburg fb997045f2 feat: Add app store entry for "Autocheckin" (Challenge/Bounty) (#14683) 2024-04-23 21:52:03 +00:00
Keith Williams 73a79cf29b fix: Improve French translations (#14718) 2024-04-23 17:35:56 -04:00
Omar López 54a1663a8b v4.0.2 (#14724) 2024-04-23 21:16:14 +00:00
883d745784 feat: SMS/Whatsapp to attendee for teams (#14648)
* remove event type requires confirmation

* allow sms sending also for team (frontend)

* allow text to attendee for team (backend)

* add rate limiting for SMS (WIP)

* add functionality to lock and unlock SMS sending (+admin UI view)

* rename to smsLockState

* add ability to lock users and teams

* don't send sms if locked

* add missing imports

* fix rate limit namespace

* add translation

* fix type error

* remove prop from UsersTable

* add smsLockState to buildUser

* code clean up

* create seperate sms rate limit function

* fix type error

* add missing userId and teamId to sendSMS

* code improvements

* add User Team type

* fix marking as reviewed

* check monthly sms limit only for user

* don't lock orgs

* fix type error

* fix avatars

* fix type error

* fix type error

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-23 19:48:44 +00:00
571e93a19a chore: EventManager add cancelEvent method (#14261)
* fix timezone display on booking page to reflect event availability timezone

* migrate fetching event owner's schedule to server side

* migrate fetching event owner's schedule to server side

* fix e2e test errors

* Add WEBAPP_URL_FOR_OAUTH to salesforce auth

* In event manager constructor include "_crm" credentials as calendar creds

* Change crm apps to type to end with `_crm`

* Move sendgrid out of CRM

* Add zoho bigin to CRM apps

* When getting apps, use slug

* Add `crm` variants

* Hubspot Oauth use `WEBAPP_URL_FOR_OAUTH`

* Refactor creating credentials

* Fix empty CRM page

* Use credentials with `_crm`

* Abstract getAppCategoryTitle

* Add integration.handler changes

* When searching for credential, look for current credentials in class

* On cancel, delete 3rd party events

* Fix tests

* Type fix

* Type fixes

* Remove apiDeletes

* Type fixes

* Specific typing

---------

Co-authored-by: Shaik-Sirajuddin <sirajuddinshaik30gmail.com>
Co-authored-by: Shaik-Sirajuddin <sirajudddinshaik30@gmail.com>
Co-authored-by: Shaik-Sirajuddin <89742297+Shaik-Sirajuddin@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2024-04-23 17:54:45 +00:00
Peer RichelsenandUdit Takkar d16428da6c changed translation (#14710)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-23 16:14:12 +00:00
sean-brydon 8c5438663c feat: improve error handling for signup events (#14668)
* feat: improve error handling for signup events

* chore: fix typo
2024-04-23 16:13:10 +00:00
Hariom Balhara ed76d163cf fix: Add navigatedToBooker event and fix infinite loop of events (#14694)
## What does this PR do?

1. cal.com/sales page is a special case where embed-iframe exists in top as well as in iframe. This causes a looping of events somehow due to embed-iframe firing events and those being received by the top embed-iframe.
2. Added new `navigatedToBooker` event to know when the user lands on booker page.


Corresponding PR in website that uses `navigatedToBooker` event  and has a demo as well https://github.com/calcom/website/pull/741

Earlier added `routed` event seems to happen too early

## Type of change
- Bug fix (non-breaking change which fixes an issue)

## How should this be tested?
- Simply navigate to booker and wait for the event.

## Mandatory Tasks
- [x] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.

## Tests
- Didn't add any tests for this as the changed seems straightforward

## Checklist
- I haven't added tests that prove my fix is effective or that my feature works
2024-04-23 10:36:12 +01:00
9215725dad fix: browser back button not working (#13345)
* fixed the browser back button not woking

* fix back button

* added shouldReplace param to query-params helper.

* revert yarn.lock

* fix: test

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-04-22 18:42:54 -04:00
Alex van Andel 00131668aa fix: Avatar API isn't ran at signup handler (#14675)
release.yml / release (push) Canceled after 0s
2024-04-22 12:13:15 -03:00
Alex van Andel 0306ad737f fix: invalid Cancelled / Rescheduled downstream calendar block (#14700)
* fix: invalid Cancelled / Rescheduled downstream calendar block

* Adjust test so no calendar is connected

* Remove the googleCalendar from multiple tests
2024-04-22 16:10:39 +01:00
20dfc34e70 fix: Managed event isn’t showing in the upcoming bookings when filtered (#14552)
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-22 10:55:26 -04:00
Somay Chauhan b7e8b252d2 fix: send completed_onboarding to intercom (#14703) 2024-04-22 14:12:02 +00:00
74f09c332b feat: updated the useage of listCurrent in the app and use org role from session (#14653)
* feat: include org role in org session data

* update contents

* fix: type err

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-04-22 14:07:43 +00:00
sean-brydonandHariom 3ce6c23603 fix: Update visibility of team members based on user role (#14629)
* fix: team memebrs being hidden for admin when org is private and they are not org admin

* chore: remove console.loog

* chore: make permissionCheck more readable

* test: push e2e test for normal privacy banner in orgs

* test: add a few more edge cases

* chore: negate names

* feat: update func to make more sense

* Fix

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-04-22 13:45:28 +00:00
Soumik PatraandUdit Takkar ad3ee14501 fix: Date Override Modal Trims Date Picker (#14680)
* [CAL-3500] Date Override Modal Trims Date Picker #14658

* file

* type fix

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-22 18:53:16 +05:30
Somay ChauhanandUdit Takkar 1931758fc7 fix: Private settings of the event disabled after making changes to your event (#14628)
* fix: Private settings of the event disabled after making changes to your event

* add test

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-22 11:53:50 +00:00
Kiss Benedek MátéandUdit Takkar fe551af145 Add missing dollar sign (#14681)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-22 11:33:07 +00:00
9fdaaed100 fix: show toast in center of screen (#14690)
* fix: show toast in center of screen

* fix: show toast in center of screen

---------

Co-authored-by: Nur Ovezova <nur.ovezova@techguilds.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-22 10:34:29 +00:00
Udit Takkar c0f8a355e3 fix: managed type url in orgs (#14698) 2024-04-22 09:38:58 +01:00
Peer Richelsen df95ec4ce8 fixed credit card icon (#14678) 2024-04-20 14:46:23 +01:00
Alex van Andel a15cad4d03 chore: Removing away (DB related, not slot related) from code (#14673)
* chore: Removing away (DB related, not slot related) from code

* Remove more isAway/away, eg. references
2024-04-20 02:23:34 +01:00
Udit Takkar 8904f20eb8 fix: get llm response schema (#14670) 2024-04-19 18:09:16 +00:00
Carina WollendorferandCarinaWolli 0ee81d0739 fix: remove away view (#14672)
* remove away view

* remove yellow away dot

* remove Away from Booker

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-04-19 17:40:35 +00:00
Peer Richelsen c0bc6f8ccb Update common.json (#14647) 2024-04-19 16:50:38 +00:00
Hariom Balhara 1f1cd1e236 Add self serve variable in tests (#14665) 2024-04-19 12:32:22 +01:00
sean-brydon dbbd78fb26 feat: include org role in org session data (#14652)
* feat: include org role in org session data

* update contents

* remove: files
2024-04-19 12:32:04 +01:00
Somay Chauhan f6e45842fd fix: only boot intercom if .env variable is set (#14656) 2024-04-19 09:04:02 +01:00
Alex van Andel d25c043046 chore: Remove all avatar/logo references (#14532)
* chore: Remove all avatar/logo references

* Updated user profile to only use avatarUrl

* fix: minor style issue

* chore: Remove redundant includeTeamLogo

* fix: Use right avatar default in event type list

* fix: placeholder avatar team profile, target

* chore: Add logoUrl/avatarUrl to JWT

* fix: Bunch of org avatar issues, fix members list

* Fix logoUrl on org pages

* More type fixes

* Hopefully final type fixes

* Another round of type fixes

* fix: UserForm ts

* fix: Handle as return types, not input types

* fix: Remove profile and add avatarUrl

* fix: notFound as const

* fix: Seeder avatarUrl params

* revert: Migration changes for easier recovery

* fix: Add explicit type to builder as avatar is now set

* Add logoUrl to unpublished entity

* Avatar test out of scope here

* fix: Use the new avatarUrl after save

* chore: Removed getOrgAvatarUrl/getTeamAvatarUrl

* fix: Update sidebar image immediately after change

* Unable to safe unnamed user, so default

* fix: Unpublished page, add organization test

* Add more tests
2024-04-18 21:54:16 -07:00
5695ba7c13 feat: Tasker (#14180)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-04-18 11:56:25 -07:00
Morgan eeb0322ce1 fix: managed users check if exist (#14649) 2024-04-18 18:09:56 +00:00
MorganandLauris Skraucis 480977c2b5 fix: api v2 - platform users can disable emails and display their links (#14604)
* fix: disable emails with oauthCLient settings and hide empty links

* fixup! fix: disable emails with oauthCLient settings and hide empty links

* feat: enable to display booking success page link in emails

* fixup! feat: enable to display booking success page link in emails

* fixup! Merge branch 'fix-no-email-booking-hide-empty-links' of github.com:calcom/cal.com into fix-no-email-booking-hide-empty-links

* fixup! fixup! Merge branch 'fix-no-email-booking-hide-empty-links' of github.com:calcom/cal.com into fix-no-email-booking-hide-empty-links

* fixup! Merge branch 'main' into fix-no-email-booking-hide-empty-links

* fixup! Merge branch 'main' into fix-no-email-booking-hide-empty-links

* fixup! Merge branch 'fix-no-email-booking-hide-empty-links' of github.com:calcom/cal.com into fix-no-email-booking-hide-empty-links

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2024-04-18 12:43:17 +00:00
Lauris Skraucis 8b2e46a6fa fix: v2 e2e tests (#14655)
* fix: oauth-clients-users e2e -> auth was changed from access token to client secret header

* fix: Gcal e2e

* remove gcal module

* simplify gcal override
2024-04-18 12:13:31 +00:00
Morgan 842be660b0 chore: atoms 1.0.30 (#14650) 2024-04-18 10:33:39 +00:00
Hariom BalharaandJoe Au-Yeung 4f69c0e502 fix: Namespaced react embeds not working with Floating and Element Click Popups (#13386)
* Fix embed queue

* Fix getCalApi namespace use

* Fixes

* Add more e2e

* Fix test

* Fix syntax error in generated code

* Make getCalApi argument optional

* Fix types

* Add more namespacing tests

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-04-18 14:41:39 +05:30
be128181e2 feat: booker atom custom location (#14635)
* frontend: handle booking locationUrl

* backend: pass booking locationUrl to handleNewBooking

* examples app: display booking location if provided

* fix: v2 not starting: contains -> includes

* backend: pass booking locationUrl to handleNewBooking

* swagger docs update

* examples app: dont display where of booking if does not start with http

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-04-18 08:26:36 +00:00
Morgan 0aa1628925 chore: atoms 1.0.29 + fix typing issues (#14633) 2024-04-18 07:30:34 +00:00
Omar López fd58f669d0 hotfix: add linting exception for website (#14645) 2024-04-17 19:51:42 +00:00
Lauris Skraucis 51927f82c0 fix: v2 app.rewrites.middleware.ts (#14626) 2024-04-17 16:48:52 +00:00
Alex van Andel 0f8a0eaf4a fix: 500 on forbidden showing up in logs (#14636) 2024-04-17 16:00:21 +00:00
b9ac22d4ed feat: AI Transcribe (#14140)
* chore: transcribe

* chore: provider

* chore: use daily provider

* chore: rebase

* chore: remove types file

* chore: remove recoil

* chore: progress

* chore: add recoil

* chore: save progress

* chore: save progress

* fix: type err

* fix: err

* fix: save progress

* feat: finish emails

* chore: add new env variable

* chore: fix type err

* chore: turbo

* chore: improvements

---------

Co-authored-by: Udit Takkar <udit222001@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>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-04-17 11:09:11 -04:00
ebca5c6409 fix: use 'success' value for better accuracy (#14624)
* fix: use 'success' value for better accuracy

* add success to test

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-04-17 14:01:13 +00:00
sean-brydonandUdit Takkar 6ef35e4dba chore: settings design match (#14630)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-17 09:35:18 -04:00
Carina Wollendorfer 8bb5200dd8 fix: add new styling for emoji links (#14481) 2024-04-17 12:30:14 +00:00
sean-brydon 4aeb6b7c9b fix: Hide org tab when you are team admin (#14627)
* fix: hide-tab-on-fe

* fix: backend pulling back info if the user dont have perms
2024-04-17 10:38:12 +01:00
Keith Williams ae64d7ba82 fix: API v2 recurring spelling (#14620) 2024-04-16 18:58:36 -03:00
Keith Williams 9dd2dbff19 v4.0.1 (#14618)
release.yml / release (push) Canceled after 0s
2024-04-16 21:05:18 +00:00
Joe Au-Yeung cebbffe230 fix: Minimum fix for N Calendar Invites (#14617)
* Remove pushing team member destination calendars

* Only create the event on the first destination calendar

* Only create event on the first destination calendar
2024-04-16 20:05:40 +00:00
Morgan e3e464abfa fix: booker atoms using next/router (#14612)
* fix: booker OOO using next/router

* fixup! fix: booker OOO using next/router
2024-04-16 18:37:24 +00:00
268438b0ff fix: rescheduling daily (#13736)
* fix: rescheduling daily

* fix: e2e test

* chore: test

* add location to default event type

* revert fix

* chore: add env variable

* fix: sort alphabetically

* test: checking

* chore: use another user

* chore: try adding location

* chore: undo

* chore: add variable

* chore: commented

* F

* chore: comment

* test: add unit test

* fix: type err

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2024-04-16 12:57:34 -04:00
Hariom Balhara 355c8de5e7 Set exclusive tax behaviour (#14574) 2024-04-16 13:18:08 +01:00
e6056bc4a4 fix: change max width to avoid overflow in dialog when creating new event type (#14198)
* fix: change max width to avoid overflow when creating new event dialog

* style: make max with responsive at md breakpoint

* chore: add tooltip

---------

Co-authored-by: xylonx <x956200361@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-04-16 10:48:10 +00:00
Omar López f2eebe95bd chore: Adds deprecated-imports-next-router (#14486) 2024-04-16 11:46:45 +01:00
sean-brydonandJoe Au-Yeung 3084013269 fix: always load via SSR (#14476)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-04-16 11:37:03 +01:00
68568e5a25 feat: round robin: show avatar and name of person on success page (#14245)
* feat: round robin: show avatar and name of person on success page

* fix: add background border

* fix: e2e tests

* fix: remove avatar on select, use proper border variables

* fix: bookings-list e2e round robin title

* chore: change name

---------

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>
2024-04-16 11:36:11 +01:00
Lauris Skraucis 0eae0bb8fe refactor: remove /ee/ url segment from atoms hooks (#14602) 2024-04-16 07:29:12 -03:00
Somay Chauhan b61820d354 fix: custom attributes not being synced in intercom (#14600)
* fix: custom attributes not being synced in intercom

* fix: ssrInit
2024-04-16 11:03:45 +01:00
MorganandBailey Pumfleet 1606272850 chore: bump atoms (#14585)
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2024-04-16 00:17:04 -03:00
Kaleem ea04fe1c9b fix: remove @ts-ignore (#14582) 2024-04-16 07:44:00 +05:30
Peer RichelsenandAlex van Andel 4c50b6a8af fix: hide "upgrade org banner" for isPlatform (#14522)
* not great but does the job

* comments

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-04-16 02:41:07 +01:00
sean-brydon 39aa848e9e fix: update sub-team slug - update tempOrgRedirect (#14523) 2024-04-16 01:14:41 +00:00
Peer Richelsen 64c56c1b17 Delete apps/platform directory (#14520) 2024-04-16 01:12:54 +00:00
MorganandRyukemeister 8e883a6b10 feat: update platform oauth client form (#14542)
* feat: update platform oauth client form

* fixup! feat: update platform oauth client form

* add tooltip to mention permissions cannot be modified

---------

Co-authored-by: Ryukemeister <sahalrajiv-extc@atharvacoe.ac.in>
2024-04-16 01:55:00 +01:00
Peer Richelsen 9684aa3ad5 for admins min 1 org user. for self-serve 30 (#14567) 2024-04-16 01:53:22 +01:00
sean-brydon 5fede84bf6 feat: use experimental optimizePackageImports (#14587) 2024-04-16 01:52:08 +01:00
Alex van Andel a788e07be1 fix: Re-instate hasEmailBeenChanged before sending email (#14596) 2024-04-15 22:09:06 +00:00
Somay Chauhan 68ffd2248b fix: intercom visible on mobile (#14563) 2024-04-15 21:18:42 +00:00
Carina WollendorferandCarinaWolli 4b12d021e4 remove : from email from name (#14594)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-04-15 20:27:10 +01:00
Alex van Andel 4daf9db9db fix: acme.cal.local crashes due to missing requestedSlug in seed (#14592) 2024-04-15 18:53:41 +01:00
sean-brydon a660b1eb05 fix: conditionally remove fixed prop if we're in embed (#14586)
* fix: conditionally remove fixed prop if we're in embed

* fix: conditionally remove fixed prop if we're in embed
2024-04-15 14:19:25 +01:00
Somay Chauhan 6f2f098e12 fix: removing stripe results in the event-types page loading indefintely (#14583)
* fix: removing stripe results in the event-types page loading indefinitely

* undo yarn.lock
2024-04-15 10:44:47 +00:00
Keith Williams 6a75ef86b0 v4.0.0
release.yml / release (push) Canceled after 0s
2024-04-14 20:02:28 -03:00
Alex van Andel 3d2397879c fix: Remove PageWrapper (#14579)
* fix: Remove PageWrapper

* Remove useLocale
2024-04-14 20:46:08 +00:00
Alex van Andel de474089a7 Revert "fix: Production deploys (#14578)"
This reverts commit 8b31b9aa05.
2024-04-14 16:41:25 +01:00
Keith Williams 8b31b9aa05 fix: Production deploys (#14578) 2024-04-14 15:04:30 +00:00
Crowdin Bot ad41672958 New Crowdin translations by Github Action 2024-04-14 12:51:43 +00:00
Morgan 86f2984b30 fix: only call useMe if access token is set (#14576)
* fix: only call useMe if access token is set

* chore: export use me hook
2024-04-14 12:48:30 +00:00
Morgan 38e62786e4 chore: remove /ee from apiv2 urls (#14568)
* chore: remove /ee from apiv2 urls

* fixup! chore: remove /ee from apiv2 urls

* chore: remove /ee from apiv2 urls

* fixup! chore: remove /ee from apiv2 urls

* fixup! fixup! chore: remove /ee from apiv2 urls

* fixup! fixup! fixup! chore: remove /ee from apiv2 urls
2024-04-13 22:16:37 +00:00
Keith Williams ac3d7c9fae fix: Integrate maintenance mode with current middleware (#14571)
release.yml / release (push) Canceled after 0s
* fix: API v1 maintenance mode

* Removed old middleware

* Refactor to follow same pattern as other middleware

* Added missing import
2024-04-13 20:27:22 +01:00
Rajiv Sahal 98f94ea694 return isPlaatform boolean to detect if user is a platfor user (#14570) 2024-04-13 18:42:25 +05:30
Lauris Skraucis 8ad80f7ac3 refactor: v2 event types endpoint paths (#14545)
* refactor: rename event-types/:username/public to event-types/public/:username

* refactor: move /events/public to /event-types/public/:username/:eventSlug

* fix: remove EVENT_TYPE_READ permission from public GET event-types/public/:username

* docs: add response DTO to generate docs for /public/:username/:slug aka ex. events/public

* tests: public event types endpoints

* refactor: have /public at the end of event-types routes

* correct swagger doc
2024-04-13 12:47:35 +03:00
Morgan 296678046c chore: fix class type and bump atoms version (#14562)
* chore: fix class type and bump version

* fixup! chore: fix class type and bump version

* fixup! fixup! chore: fix class type and bump version

* fixup! Merge branch 'main' into atoms-package-version-1.0.18

* fixup! fixup! Merge branch 'main' into atoms-package-version-1.0.18
2024-04-13 01:21:03 +00:00
Keith Williams fd193e651d revert: "fix: view team bookings (#14079)" (#14561)
This reverts commit f86c448359.
2024-04-13 00:53:05 +00:00
Omar LópezandKeith Williams becad5ee67 chore: adds maintenance mode for API v1 (#14555)
* Create middleware.ts

* Update middleware.ts

* Update middleware.ts

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-04-13 00:16:14 +00:00
Morgan 161dcbdd4c fix: remove prefix /api from apiv2 (#14559) 2024-04-12 23:00:12 +00:00
Crowdin Bot 7ad6652cb0 New Crowdin translations by Github Action 2024-04-12 21:52:02 +00:00
c467167e91 feat: add classNames prop to Booker atom for passing external styles (#14270)
* add classnames prop to pass external styles

* fix import

* add comments

* ignore this commit: tinkering with styling

* update typings

* mostly creating props and passing it down to child components

* remove unnecessary comment

* merge conflicts

* fixup

* fixup

* fix conflicts

* fix merge conflicts

* fix merge conflicts

* fix failing type checks

* type checks fix

* add custom classnames to booker props

* update typings

* pass in props down wherever necessary

* fixup

* still passing down around some more props

* addressing pr feedback

* final fixes

* address feedback

* fixup! address feedback

* fixup! fixup! address feedback

* fixup! fixup! fixup! address feedback

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-04-12 21:49:01 +00:00
sean-brydon 18093824d8 fix: update playwright config to allow permissions (#14526) 2024-04-12 14:13:53 -07:00
Crowdin Bot 8102f3c33c New Crowdin translations by Github Action 2024-04-12 21:09:12 +00:00
Joe Au-Yeung 902c441248 fix: Cal Video Links Expire After 14 Days (#14557)
* Set expiry date to 14 days after end time

* Add comment
2024-04-12 17:05:26 -04:00
Morgan e641bb3d57 chore: enable platform emails (#14471)
* feat: enable platform emails

* fixup! feat: enable platform emails

* fixup! fixup! feat: enable platform emails

* fixup! Merge branch 'main' into chore-platform-emails

* fixup! fixup! Merge branch 'main' into chore-platform-emails

* fixup! fixup! fixup! Merge branch 'main' into chore-platform-emails

* chore: add oauth client repo

* chore: enable platform emails using oauth client

* fix: call getConnectedCalendar with dbWrite since it creates destinationCalendar for new credentials

* chore: update doc

* fixup! chore: update doc

* fixup! fixup! chore: update doc
2024-04-12 22:39:01 +03:00
19d938ed60 feat: travel schedules to schedule timezone changes (#14512)
* add Travel Schedule modal

* UI to list schedules

* set shouldDirty

* add backend  to add new travelSchedule

* implement deleting a schedule

* check if schedule is overlapping

* WIP

* fix finding overlapping travel schedule

* only use travelSchedule when default availability is used

* adjust date overrides to timezone schedule

* first version of changeTimeZone cron job

* fix tests by adding travelSchedules

* fixes for cron job api call

* improve unit tests

* add migration

* fix type error

* fix collective-scheduling test

* clean up cron job

* code clean up

* code clean up

* show timezone from travel schedule for date override

* add date override tests

* show tz on date override only in default schedule

* fix deleting old schedules

* minor fixes in cron api handler

* code clean up

* code clean up from feedback

* fix asia/kalkota comment

* fix dark mode

* fix start and end date conversion to utc

* add first unit test for travel schedules

* Fix modal render issue

* show timezone city wtihout _

* fix dark more for datepicker

* reset values after closing dialog

* remove session from middleware

* exit loop early once schedule is found

* fix type error

* add getTravelSchedules handler

* clean up DatePicker

* fix type error

* code clean up

* code clean up

* add indexes

* use deleteMany

* fix icons

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-04-12 17:57:28 +00:00
5561949f9b feat: Delete/Create WebhookScheduledTriggers for existing bookings (#14121)
* feat: Delete/Create WebhookScheduledTriggers for existing bookings

* fix: type check

* fix: early return, query status in prisma, unnecessary trigger type check

* feat: add/delete scheduled webhook triggers for team and user

---------

Co-authored-by: v0ltZzie <samit091848@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-04-12 16:57:59 +00:00
Lauris Skraucis 5e98cca062 fix: v2 create schedule with availabilities (#14551)
* fix: v2 schedule with availabilities creation

* driveby: remove permissions from public event-types endpoint
2024-04-12 15:49:47 +00:00
ac858c9bee fix: [CAL-3374] Says Number is not Verified, but it is Verified (#14531)
* fix: [CAL-3374] Says Number is not Verified, but it is Verified

* clearing error only when verified

* type-checks fixed

* chore: improvement

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-04-12 11:33:24 -04:00
Erik f58f5e4e02 chore: SDK version 1.0.1 (#14524) 2024-04-12 15:31:04 +00:00
Alex van Andel ad844f5e51 fix: Remove hardcoded stripe.. (#14549) 2024-04-12 15:50:02 +01:00
Morgan 53c1bc45d6 fix: atoms package types were not exported (#14546) 2024-04-12 13:16:07 +00:00
Hariom Balhara 0cc93aa87e chore: Publish new versions of embeds (#14543) 2024-04-12 09:44:37 +01:00
Amit Sharma b41b830086 fix: State Inconsistency issue on webhook edit modal (#14544) 2024-04-12 14:08:58 +05:30
644 changed files with 15232 additions and 4590 deletions
+1
View File
@@ -38,6 +38,7 @@ BASECAMP3_USER_AGENT=
DAILY_API_KEY=
DAILY_SCALE_PLAN=''
DAILY_WEBHOOK_SECRET=''
DAILY_MEETING_ENDED_WEBHOOK_SECRET=''
# - GOOGLE CALENDAR/MEET/LOGIN
# Needed to enable Google Calendar integration and Login with Google
+6 -1
View File
@@ -240,7 +240,7 @@ CSP_POLICY=
# Vercel Edge Config
EDGE_CONFIG=
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD=0 # Override the booker to only load X number of days worth of data
# Control time intervals on a user's Schedule availability
@@ -299,6 +299,7 @@ E2E_TEST_CALCOM_GCAL_KEYS=
CALCOM_CREDENTIAL_SYNC_SECRET=""
# This is the header name that will be used to verify the webhook secret. Should be in lowercase
CALCOM_CREDENTIAL_SYNC_HEADER_NAME="calcom-credential-sync-secret"
# This the endpoint from which the token is fetched
CALCOM_CREDENTIAL_SYNC_ENDPOINT=""
# Key should match on Cal.com and your application
# must be 24 bytes for AES256 encryption algorithm
@@ -347,6 +348,10 @@ SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
# api v2
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
# Tasker features
TASKER_ENABLE_WEBHOOKS=0
TASKER_ENABLE_EMAILS=0
# Ratelimiting via unkey
UNKEY_ROOT_KEY=
@@ -35,6 +35,7 @@ env:
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
jobs:
build:
+24
View File
@@ -0,0 +1,24 @@
name: Cron - changeTimeZone
on:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs "At every full hour." (see https://crontab.guru)
- cron: "0 * * * *"
jobs:
cron-scheduleEmailReminders:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_API_KEY }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/changeTimeZone \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
-sSf
+2
View File
@@ -62,6 +62,7 @@ jobs:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
@@ -91,6 +92,7 @@ jobs:
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
+2
View File
@@ -49,6 +49,7 @@ jobs:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
@@ -73,6 +74,7 @@ jobs:
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
+2
View File
@@ -55,6 +55,7 @@ jobs:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
@@ -80,6 +81,7 @@ jobs:
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
+3 -1
View File
@@ -36,7 +36,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5]
shard: [1, 2, 3, 4, 5, 6, 7]
steps:
- uses: docker/login-action@v3
with:
@@ -53,6 +53,7 @@ jobs:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
@@ -82,6 +83,7 @@ jobs:
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
@@ -6,6 +6,7 @@ on:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
@@ -31,6 +32,7 @@ env:
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
jobs:
+2
View File
@@ -6,6 +6,7 @@ on:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
@@ -35,6 +36,7 @@ env:
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
jobs:
+2 -2
View File
@@ -24,7 +24,7 @@
<p align="center">
<a href="https://go.cal.com/discord"><img src="https://img.shields.io/badge/Discord-go.cal.com%2Fdiscord-%234A154B" alt="Join Cal.com Discord"></a>
<a href="https://producthunt.com/posts/calendso"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
<a href="https://www.producthunt.com/products/cal-com"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
<a href="https://status.cal.com"><img height="20px" src="https://betteruptime.com/status-badges/v1/monitor/a9kf.svg" alt="Uptime"></a>
<a href="https://github.com/calcom/cal.com/stargazers"><img src="https://img.shields.io/github/stars/calcom/cal.com" alt="Github Stars"></a>
<a href="https://news.ycombinator.com/item?id=34507672"><img src="https://img.shields.io/badge/Hacker%20News-%231-%23FF6600" alt="Hacker News"></a>
@@ -33,7 +33,7 @@
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-Free-brightgreen" alt="Pricing"></a>
<a href="https://jitsu.com?utm_source=github/calcom/cal.com"><img src="https://img.shields.io/badge/Metrics_tracked_by-JITSU-AA00FF?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACKSURBVHgBrZDRCYAwDEQv6gCOoKO4hOCXI9QVnEZwiY5iF5GaVClaBNtioCSUvCR3tMJaxIfZgW4AGUoEPVwgPZoS0Dmgg3NBVDFNbMIsmYCak3J1jDk9iCQvsKJvkzr71N81Gj6vDT/LU2P6RhY63jcafk3YJEbgeZpiFyc/5HJKv8Ef273NSfABGbQfUZhnOSAAAAAASUVORK5CYII=" alt="Jitsu Tracked"></a>
<img src="https://api.checklyhq.com/v1/badges/checks/5e048048-1b51-47ba-9209-60607507622e?responseTime=true" alt="Checkly Availability" />
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
<a href="https://hub.docker.com/r/calcom/cal.com"><img src="https://img.shields.io/docker/pulls/calcom/cal.com"></a>
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
@@ -0,0 +1,23 @@
import { get } from "@vercel/edge-config";
import type { NextMiddleware } from "next-api-middleware";
const safeGet = async <T = unknown>(key: string): Promise<T | undefined> => {
try {
return get<T>(key);
} catch (error) {
// Don't crash if EDGE_CONFIG env var is missing
}
};
export const config = { matcher: "/:path*" };
export const checkIsInMaintenanceMode: NextMiddleware = async (req, res, next) => {
const isInMaintenanceMode = await safeGet<boolean>("isInMaintenanceMode");
if (isInMaintenanceMode) {
return res
.status(503)
.json({ message: "API is currently under maintenance. Please try again at a later time." });
}
await next();
};
@@ -2,6 +2,7 @@ import { label } from "next-api-middleware";
import { addRequestId } from "./addRequestid";
import { captureErrors } from "./captureErrors";
import { checkIsInMaintenanceMode } from "./checkIsInMaintenanceMode";
import { extendRequest } from "./extendRequest";
import {
HTTP_POST,
@@ -24,6 +25,7 @@ const middleware = {
HTTP_POST,
HTTP_DELETE,
addRequestId,
checkIsInMaintenanceMode,
verifyApiKey,
rateLimitApiKey,
extendRequest,
@@ -36,6 +38,7 @@ type Middleware = keyof typeof middleware;
const middlewareOrder = [
// The order here, determines the order of execution
"checkIsInMaintenanceMode",
"extendRequest",
"captureErrors",
"verifyApiKey",
+3
View File
@@ -6,6 +6,9 @@ export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true })
hideBranding: true,
metadata: true,
pendingPayment: true,
isOrganization: true,
isPlatform: true,
smsLockState: true,
});
const schemaTeamRequiredParams = z.object({
-2
View File
@@ -82,7 +82,6 @@ export const schemaUserBaseBodyParams = User.pick({
brandColor: true,
darkBrandColor: true,
allowDynamicBooking: true,
away: true,
role: true,
// @note: disallowing avatar changes via API for now. We can add it later if needed. User should upload image via UI.
// avatar: true,
@@ -171,7 +170,6 @@ export const schemaUserReadPublic = User.pick({
brandColor: true,
darkBrandColor: true,
allowDynamicBooking: true,
away: true,
createdDate: true,
verified: true,
invitedTo: true,
@@ -1,6 +1,6 @@
import type { NextApiRequest } from "next";
import { minimumTokenResponseSchema } from "@calcom/app-store/_utils/oauth/parseRefreshTokenResponse";
import { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
@@ -63,7 +63,7 @@ async function handler(req: NextApiRequest) {
symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
);
const key = minimumTokenResponseSchema.parse(decryptedKey);
const key = OAuth2UniversalSchema.parse(decryptedKey);
const credential = await prisma.credential.update({
where: {
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { minimumTokenResponseSchema } from "@calcom/app-store/_utils/oauth/parseRefreshTokenResponse";
import { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { HttpError } from "@calcom/lib/http-error";
@@ -70,7 +70,7 @@ async function handler(req: NextApiRequest) {
symmetricDecrypt(encryptedKey, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
);
const key = minimumTokenResponseSchema.parse(decryptedKey);
const key = OAuth2UniversalSchema.parse(decryptedKey);
// Need to get app type
const app = await prisma.app.findUnique({
+1 -1
View File
@@ -7,7 +7,7 @@ import { createMocks } from "node-mocks-http";
import { describe, expect, test, vi } from "vitest";
import dayjs from "@calcom/dayjs";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
import prisma from "@calcom/prisma";
+10 -1
View File
@@ -10,4 +10,13 @@ JWT_SECRET=
SENTRY_DSN=
# KEEP THIS EMPTY, DISABLE SENTRY CLIENT INSIDE OF LIBRARIES USED BY APIv2
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN=
# Stripe Billing
STRIPE_PRICE_ID_STARTER=
STRIPE_PRICE_ID_ESSENTIALS=
STRIPE_PRICE_ID_ENTERPRISE=
STRIPE_API_KEY=
STRIPE_WEBHOOK_SECRET=
WEB_APP_URL=http://localhost:3000/
+1 -1
View File
@@ -12,7 +12,7 @@ module.exports = {
node: true,
jest: true,
},
ignorePatterns: [".eslintrc.js"],
ignorePatterns: [".eslintrc.js", "next-i18next.config.js"],
rules: {
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/explicit-function-return-type": "off",
+1
View File
@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"entryFile": "./apps/api/v2/src/main.js",
"compilerOptions": {
"deleteOutDir": true,
"plugins": [
+5 -3
View File
@@ -11,9 +11,9 @@
"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 & yarn workspace @calcom/platform-libraries build:watch",
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build",
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && nest start --watch",
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/src/main",
"start:prod": "SKIP_DOCS_GENERATION=1 node ./dist/apps/api/v2/src/main.js",
"test": "yarn dev:build && jest",
"test:watch": "yarn dev:build && jest --watch",
"test:cov": "yarn dev:build && jest --coverage",
@@ -37,9 +37,10 @@
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "^5.1.1",
"@nestjs/throttler": "^5.1.2",
"@sentry/node": "^7.86.0",
"@sentry/tracing": "^7.86.0",
"body-parser": "^1.20.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
@@ -56,6 +57,7 @@
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"stripe": "^15.3.0",
"uuid": "^8.3.2",
"winston": "^3.11.0",
"zod": "^3.22.4"
+54 -17
View File
@@ -1,11 +1,21 @@
import { AppLoggerMiddleware } from "@/app.logger.middleware";
import appConfig from "@/config/app";
import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware";
import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware";
import { RawBodyMiddleware } from "@/middleware/body/raw.body.middleware";
import { ResponseInterceptor } from "@/middleware/request-ids/request-id.interceptor";
import { RequestIdMiddleware } from "@/middleware/request-ids/request-id.middleware";
import { AuthModule } from "@/modules/auth/auth.module";
import { EndpointsModule } from "@/modules/endpoints.module";
import { JwtModule } from "@/modules/jwt/jwt.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
import { RedisModule } from "@/modules/redis/redis.module";
import { RedisService } from "@/modules/redis/redis.service";
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_INTERCEPTOR, RouterModule } from "@nestjs/core";
import { seconds, ThrottlerModule } from "@nestjs/throttler";
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
import { AppController } from "./app.controller";
@@ -16,29 +26,56 @@ import { AppController } from "./app.controller";
isGlobal: true,
load: [appConfig],
}),
// ThrottlerModule.forRootAsync({
// imports: [ConfigModule],
// inject: [ConfigService],
// useFactory: (config: ConfigService<AppConfig>) => ({
// throttlers: [
// {
// name: "short",
// ttl: seconds(10),
// limit: 3,
// },
// ],
// storage: new ThrottlerStorageRedisService(config.get("db.redisUrl", { infer: true })),
// }),
// }),
RedisModule,
ThrottlerModule.forRootAsync({
imports: [RedisModule],
inject: [RedisService],
useFactory: (redisService: RedisService) => ({
throttlers: [
{
name: "short",
ttl: seconds(10),
limit: 3,
},
{
name: "medium",
ttl: seconds(30),
limit: 10,
},
],
storage: new ThrottlerStorageRedisService(redisService.redis),
}),
}),
PrismaModule,
EndpointsModule,
AuthModule,
JwtModule,
//register prefix for all routes in EndpointsModule
RouterModule.register([{ path: "/v2", module: EndpointsModule }]),
],
controllers: [AppController],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: ResponseInterceptor,
},
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(AppLoggerMiddleware).forRoutes("*");
consumer
.apply(RawBodyMiddleware)
.forRoutes({
path: "/api/v2/billing/webhook",
method: RequestMethod.POST,
})
.apply(JsonBodyMiddleware)
.forRoutes("*")
.apply(RequestIdMiddleware)
.forRoutes("*")
.apply(AppLoggerMiddleware)
.forRoutes("*")
.apply(RewriterMiddleware)
.forRoutes("/");
}
}
+4 -6
View File
@@ -4,13 +4,15 @@ import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { SentryFilter } from "@/filters/sentry-exception.filter";
import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
import type { ValidationError } from "@nestjs/common";
import { BadRequestException, RequestMethod, ValidationPipe, VersioningType } from "@nestjs/common";
import { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common";
import { HttpAdapterHost } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import * as Sentry from "@sentry/node";
import * as cookieParser from "cookie-parser";
import helmet from "helmet";
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { TRPCExceptionFilter } from "./filters/trpc-exception.filter";
export const bootstrap = (app: NestExpressApplication): NestExpressApplication => {
@@ -26,7 +28,7 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
app.enableCors({
origin: "*",
methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"],
allowedHeaders: ["Accept", "Authorization", "Content-Type", "Origin"],
allowedHeaders: [X_CAL_CLIENT_ID, X_CAL_SECRET_KEY, "Accept", "Authorization", "Content-Type", "Origin"],
maxAge: 86_400,
});
@@ -58,10 +60,6 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalFilters(new TRPCExceptionFilter());
app.setGlobalPrefix("api", {
exclude: [{ path: "health", method: RequestMethod.GET }],
});
app.use(cookieParser());
return app;
+8 -1
View File
@@ -14,7 +14,7 @@ const loadConfig = (): AppConfig => {
process.env.API_PORT && getEnv("NODE_ENV", "development") === "development"
? `:${Number(getEnv("API_PORT", "5555"))}`
: ""
}/api/v2`,
}/v2`,
},
db: {
readUrl: getEnv("DATABASE_READ_URL"),
@@ -24,6 +24,13 @@ const loadConfig = (): AppConfig => {
next: {
authSecret: getEnv("NEXTAUTH_SECRET"),
},
stripe: {
apiKey: getEnv("STRIPE_API_KEY"),
webhookSecret: getEnv("STRIPE_WEBHOOK_SECRET"),
},
app: {
baseUrl: getEnv("WEB_APP_URL", "https://app.cal.com"),
},
};
};
+7
View File
@@ -15,4 +15,11 @@ export type AppConfig = {
next: {
authSecret: string;
};
stripe: {
apiKey: string;
webhookSecret: string;
};
app: {
baseUrl: string;
};
};
@@ -1,13 +1,16 @@
import { BookingsController } from "@/ee/bookings/controllers/bookings.controller";
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";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, TokensModule],
providers: [TokensRepository, OAuthFlowService],
imports: [PrismaModule, RedisModule, TokensModule, BillingModule],
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository],
controllers: [BookingsController],
})
export class BookingsModule {}
@@ -115,7 +115,7 @@ describe("Bookings Endpoints", () => {
};
return request(app.getHttpServer())
.post("/api/v2/ee/bookings")
.post("/v2/bookings")
.send(body)
.expect(201)
.then(async (response) => {
@@ -138,7 +138,7 @@ describe("Bookings Endpoints", () => {
it("should get bookings", async () => {
return request(app.getHttpServer())
.get("/api/v2/ee/bookings?filters[status]=upcoming")
.get("/v2/bookings?filters[status]=upcoming")
.then((response) => {
console.log("asap responseBody", JSON.stringify(response.body, null, 2));
const responseBody: GetBookingsOutput = response.body;
@@ -159,7 +159,7 @@ describe("Bookings Endpoints", () => {
it("should get booking", async () => {
return request(app.getHttpServer())
.get(`/api/v2/ee/bookings/${createdBooking.uid}`)
.get(`/v2/bookings/${createdBooking.uid}`)
.then((response) => {
const responseBody: GetBookingOutput = response.body;
const bookingInfo = responseBody.data;
@@ -199,7 +199,7 @@ describe("Bookings Endpoints", () => {
// };
// return request(app.getHttpServer())
// .post("/api/v2/ee/bookings/reccuring")
// .post("/v2/bookings/recurring")
// .send(body)
// .expect(201)
// .then((response) => {
@@ -231,7 +231,7 @@ describe("Bookings Endpoints", () => {
// };
// return request(app.getHttpServer())
// .post("/api/v2/ee/bookings/instant")
// .post("/v2/bookings/instant")
// .send(body)
// .expect(201)
// .then((response) => {
@@ -250,7 +250,7 @@ describe("Bookings Endpoints", () => {
};
return request(app.getHttpServer())
.post(`/api/v2/ee/bookings/${bookingId}/cancel`)
.post(`/v2/bookings/${bookingId}/cancel`)
.send(body)
.expect(201)
.then((response) => {
@@ -1,11 +1,13 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { CreateReccuringBookingInput } from "@/ee/bookings/inputs/create-reccuring-booking.input";
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { BillingService } from "@/modules/billing/services/billing.service";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import {
@@ -15,6 +17,7 @@ import {
Req,
InternalServerErrorException,
Body,
Headers,
HttpException,
Param,
Get,
@@ -27,6 +30,7 @@ import { User } from "@prisma/client";
import { Request } from "express";
import { NextApiRequest } from "next/types";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { BOOKING_READ, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
getAllUserBookings,
@@ -45,18 +49,42 @@ import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-t
import { ApiResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
type BookingRequest = Request & {
userId?: number;
};
type OAuthRequestParams = {
platformClientId: string;
platformRescheduleUrl: string;
platformCancelUrl: string;
platformBookingUrl: string;
platformBookingLocation?: string;
arePlatformEmailsEnabled: boolean;
};
const DEFAULT_PLATFORM_PARAMS = {
platformClientId: "",
platformCancelUrl: "",
platformRescheduleUrl: "",
platformBookingUrl: "",
arePlatformEmailsEnabled: false,
platformBookingLocation: undefined,
};
@Controller({
path: "ee/bookings",
path: "/bookings",
version: "2",
})
@UseGuards(PermissionsGuard)
@DocsTags("Bookings")
export class BookingsController {
private readonly logger = new Logger("ee bookings controller");
private readonly logger = new Logger("BookingsController");
constructor(
private readonly oAuthFlowService: OAuthFlowService,
private readonly prismaReadService: PrismaReadService
private readonly prismaReadService: PrismaReadService,
private readonly oAuthClientRepository: OAuthClientRepository,
private readonly billingService: BillingService
) {}
@Get("/")
@@ -117,39 +145,45 @@ export class BookingsController {
@Post("/")
async createBooking(
@Req() req: Request & { userId?: number },
@Body() _: CreateBookingInput
@Req() req: BookingRequest,
@Body() body: CreateBookingInput,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<unknown>> {
req.userId = (await this.getOwnerId(req)) ?? -1;
req.body = { ...req.body, noEmail: true };
const oAuthClientId = clientId?.toString();
const locationUrl = body.locationUrl;
try {
const booking = await handleNewBooking(req as unknown as NextApiRequest & { userId?: number });
const booking = await handleNewBooking(
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
);
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
return {
status: SUCCESS_STATUS,
data: booking,
};
} catch (err) {
handleBookingErrors(err);
this.handleBookingErrors(err);
}
throw new InternalServerErrorException("Could not create booking.");
}
@Post("/:bookingId/cancel")
async cancelBooking(
@Req() req: Request & { userId?: number },
@Req() req: BookingRequest,
@Param("bookingId") bookingId: string,
@Body() body: CancelBookingInput
@Body() _: CancelBookingInput,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse> {
const oAuthClientId = clientId?.toString();
if (bookingId) {
req.userId = (await this.getOwnerId(req)) ?? -1;
req.body = { ...body, id: parseInt(bookingId) };
try {
await handleCancelBooking(req as unknown as NextApiRequest & { userId?: number });
req.body.id = parseInt(bookingId);
await handleCancelBooking(await this.createNextApiBookingRequest(req, oAuthClientId));
return {
status: SUCCESS_STATUS,
};
} catch (err) {
handleBookingErrors(err);
this.handleBookingErrors(err);
}
} else {
throw new NotFoundException("Booking ID is required.");
@@ -157,49 +191,56 @@ export class BookingsController {
throw new InternalServerErrorException("Could not cancel booking.");
}
@Post("/reccuring")
async createReccuringBooking(
@Req() req: Request & { userId?: number },
@Body() _: CreateReccuringBookingInput[]
@Post("/recurring")
async createRecurringBooking(
@Req() req: BookingRequest,
@Body() _: CreateRecurringBookingInput[],
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<BookingResponse[]>> {
req.userId = (await this.getOwnerId(req)) ?? -1;
req.body = { ...req.body, noEmail: true };
const oAuthClientId = clientId?.toString();
try {
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
req as unknown as NextApiRequest & { userId?: number }
await this.createNextApiBookingRequest(req, oAuthClientId)
);
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
return {
status: SUCCESS_STATUS,
data: createdBookings,
};
} catch (err) {
handleBookingErrors(err, "recurring");
this.handleBookingErrors(err, "recurring");
}
throw new InternalServerErrorException("Could not create recurring booking.");
}
@Post("/instant")
async createInstantBooking(
@Req() req: Request & { userId?: number },
@Body() _: CreateBookingInput
@Req() req: BookingRequest,
@Body() _: CreateBookingInput,
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>>> {
const oAuthClientId = clientId?.toString();
req.userId = (await this.getOwnerId(req)) ?? -1;
req.body = { ...req.body, noEmail: true };
try {
const instantMeeting = await handleInstantMeeting(
req as unknown as NextApiRequest & { userId?: number }
await this.createNextApiBookingRequest(req, oAuthClientId)
);
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
return {
status: SUCCESS_STATUS,
data: instantMeeting,
};
} catch (err) {
handleBookingErrors(err, "instant");
this.handleBookingErrors(err, "instant");
}
throw new InternalServerErrorException("Could not create instant booking.");
}
async getOwnerId(req: Request): Promise<number | undefined> {
private async getOwnerId(req: Request): Promise<number | undefined> {
try {
const accessToken = req.get("Authorization")?.replace("Bearer ", "");
if (accessToken) {
@@ -209,19 +250,52 @@ export class BookingsController {
this.logger.error(err);
}
}
}
function handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void {
const errMsg = `Error while creating ${type ? type + " " : ""}booking.`;
if (err instanceof HttpError) {
const httpError = err as HttpError;
throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500);
private async getOAuthClientsParams(clientId: string): Promise<OAuthRequestParams> {
const res = DEFAULT_PLATFORM_PARAMS;
try {
const client = await this.oAuthClientRepository.getOAuthClient(clientId);
// fetch oAuthClient from db and use data stored in db to set these values
if (client) {
res.platformClientId = clientId;
res.platformCancelUrl = client.bookingCancelRedirectUri ?? "";
res.platformRescheduleUrl = client.bookingRescheduleRedirectUri ?? "";
res.platformBookingUrl = client.bookingRedirectUri ?? "";
res.arePlatformEmailsEnabled = client.areEmailsEnabled ?? false;
}
return res;
} catch (err) {
this.logger.error(err);
return res;
}
}
if (err instanceof Error) {
const error = err as Error;
throw new InternalServerErrorException(error?.message ?? errMsg);
private async createNextApiBookingRequest(
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 });
req.body = { ...req.body, noEmail: !oAuthParams.arePlatformEmailsEnabled };
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
}
throw new InternalServerErrorException(errMsg);
private handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void {
const errMsg = `Error while creating ${type ? type + " " : ""}booking.`;
if (err instanceof HttpError) {
const httpError = err as HttpError;
throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500);
}
if (err instanceof Error) {
const error = err as Error;
throw new InternalServerErrorException(error?.message ?? errMsg);
}
throw new InternalServerErrorException(errMsg);
}
}
@@ -98,4 +98,8 @@ export class CreateBookingInput {
@Type(() => Response)
responses!: Response;
@IsString()
@IsOptional()
locationUrl?: string;
}
@@ -3,7 +3,7 @@ import { IsBoolean, IsNumber, IsOptional } from "class-validator";
import type { AppsStatus } from "@calcom/platform-libraries";
export class CreateReccuringBookingInput extends CreateBookingInput {
export class CreateRecurringBookingInput extends CreateBookingInput {
@IsBoolean()
@IsOptional()
noEmail?: boolean;
@@ -11,7 +11,7 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CalendarBusyTimesInput } from "@calcom/platform-types";
@Controller({
path: "ee/calendars",
path: "/calendars",
version: "2",
})
@UseGuards(AccessTokenGuard)
@@ -3,6 +3,7 @@ import {
CredentialsWithUserEmail,
} from "@/modules/credentials/credentials.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersRepository } from "@/modules/users/users.repository";
import {
Injectable,
@@ -23,7 +24,8 @@ export class CalendarsService {
constructor(
private readonly usersRepository: UsersRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly dbRead: PrismaReadService
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService
) {}
async getCalendars(userId: number) {
@@ -35,7 +37,7 @@ export class CalendarsService {
return getConnectedDestinationCalendars(
userWithCalendars,
false,
this.dbRead.prisma as unknown as PrismaClient
this.dbWrite.prisma as unknown as PrismaClient
);
}
@@ -3,7 +3,9 @@ import { AppModule } from "@/app.module";
import { EventTypesModule } from "@/ee/event-types/event-types.module";
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
import { GetEventTypePublicOutput } from "@/ee/event-types/outputs/get-event-type-public.output";
import { GetEventTypeOutput } from "@/ee/event-types/outputs/get-event-type.output";
import { GetEventTypesPublicOutput } from "@/ee/event-types/outputs/get-event-types-public.output";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
@@ -64,7 +66,7 @@ describe("Event types Endpoints", () => {
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
const userEmail = "event-types-test-e2e@api.com";
const name = "bob the builder";
const name = "bob-the-builder";
const username = name;
let eventType: EventType;
let user: User;
@@ -183,6 +185,41 @@ describe("Event types Endpoints", () => {
expect(responseBody.data.eventType.userId).toEqual(user.id);
});
it(`/GET/:username/public`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${username}/public`)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: GetEventTypesPublicOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(1);
expect(responseBody.data?.[0]?.id).toEqual(eventType.id);
expect(responseBody.data?.[0]?.title).toEqual(eventType.title);
expect(responseBody.data?.[0]?.slug).toEqual(eventType.slug);
expect(responseBody.data?.[0]?.length).toEqual(eventType.length);
});
it(`/GET/:username/:eventSlug/public`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${username}/${eventType.slug}/public`)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: GetEventTypePublicOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.id).toEqual(eventType.id);
expect(responseBody.data?.title).toEqual(eventType.title);
expect(responseBody.data?.slug).toEqual(eventType.slug);
expect(responseBody.data?.length).toEqual(eventType.length);
});
it(`/GET/`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types`)
@@ -202,7 +239,7 @@ describe("Event types Endpoints", () => {
expect(responseBody.data.profiles?.[0]?.name).toEqual(name);
});
it(`/GET/:username/public`, async () => {
it(`/GET/public/:username/`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${username}/public`)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
@@ -1,16 +1,19 @@
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { GetPublicEventTypeQueryParams } from "@/ee/event-types/inputs/get-public-event-type-query-params.input";
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
import { CreateEventTypeOutput } from "@/ee/event-types/outputs/create-event-type.output";
import { DeleteEventTypeOutput } from "@/ee/event-types/outputs/delete-event-type.output";
import { GetEventTypePublicOutput } from "@/ee/event-types/outputs/get-event-type-public.output";
import { GetEventTypeOutput } from "@/ee/event-types/outputs/get-event-type.output";
import { GetEventTypesPublicOutput } from "@/ee/event-types/outputs/get-event-types-public.output";
import { GetEventTypesOutput } from "@/ee/event-types/outputs/get-event-types.output";
import { GetEventTypesData, GetEventTypesOutput } from "@/ee/event-types/outputs/get-event-types.output";
import { UpdateEventTypeOutput } from "@/ee/event-types/outputs/update-event-type.output";
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
@@ -24,11 +27,15 @@ import {
HttpCode,
HttpStatus,
Delete,
Query,
InternalServerErrorException,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { getPublicEvent } from "@calcom/platform-libraries";
import { getEventTypesByViewer } from "@calcom/platform-libraries";
import { PrismaClient } from "@calcom/prisma";
@Controller({
path: "event-types",
@@ -37,7 +44,10 @@ import { getEventTypesByViewer } from "@calcom/platform-libraries";
@UseGuards(PermissionsGuard)
@DocsTags("Event types")
export class EventTypesController {
constructor(private readonly eventTypesService: EventTypesService) {}
constructor(
private readonly eventTypesService: EventTypesService,
private readonly prismaReadService: PrismaReadService
) {}
@Post("/")
@Permissions([EVENT_TYPE_WRITE])
@@ -86,12 +96,40 @@ export class EventTypesController {
return {
status: SUCCESS_STATUS,
data: eventTypes,
data: eventTypes as GetEventTypesData,
};
}
@Get("/:username/:eventSlug/public")
async getPublicEventType(
@Param("username") username: string,
@Param("eventSlug") eventSlug: string,
@Query() queryParams: GetPublicEventTypeQueryParams
): Promise<GetEventTypePublicOutput> {
try {
const event = await getPublicEvent(
username.toLowerCase(),
eventSlug,
queryParams.isTeamEvent,
queryParams.org || null,
this.prismaReadService.prisma as unknown as PrismaClient,
// We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license
// If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
true
);
return {
data: event,
status: SUCCESS_STATUS,
};
} catch (err) {
if (err instanceof Error) {
throw new NotFoundException(err.message);
}
}
throw new InternalServerErrorException("Could not find public event.");
}
@Get("/:username/public")
@Permissions([EVENT_TYPE_READ])
async getPublicEventTypes(@Param("username") username: string): Promise<GetEventTypesPublicOutput> {
const eventTypes = await this.eventTypesService.getEventTypesPublicByUsername(username);
@@ -5,6 +5,7 @@ import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { getEventTypeById } from "@calcom/platform-libraries";
import type { PrismaClient } from "@calcom/prisma";
@Injectable()
export class EventTypesRepository {
@@ -48,9 +49,7 @@ export class EventTypesRepository {
currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId,
eventTypeId,
userId: user.id,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbRead.prisma,
prisma: this.dbRead.prisma as unknown as PrismaClient,
isUserOrganizationAdmin,
isTrpcCall: true,
});
@@ -2,16 +2,7 @@ import { ApiProperty } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsBoolean, IsOptional, IsString } from "class-validator";
export class GetPublicEventInput {
@IsString()
@Transform(({ value }: { value: string }) => value.toLowerCase())
@ApiProperty({ required: true })
username!: string;
@IsString()
@ApiProperty({ required: true })
eventSlug!: string;
export class GetPublicEventTypeQueryParams {
@Transform(({ value }: { value: string }) => value === "true")
@IsBoolean()
@IsOptional()
@@ -59,7 +59,8 @@ export class EventTypeOutput {
@IsInt()
@ApiHideProperty()
position!: number;
@IsOptional()
position?: number;
@IsInt()
@ApiHideProperty()
@@ -71,7 +72,8 @@ export class EventTypeOutput {
@IsInt()
@ApiHideProperty()
profileId!: number | null;
@IsOptional()
profileId?: number | null;
@IsInt()
@ApiHideProperty()
@@ -83,7 +85,8 @@ export class EventTypeOutput {
@IsInt()
@ApiHideProperty()
parentId!: number | null;
@IsOptional()
parentId?: number | null;
@IsOptional()
@IsArray()
@@ -176,7 +179,8 @@ export class EventTypeOutput {
@IsInt()
@ApiHideProperty()
scheduleId!: number | null;
@IsOptional()
scheduleId?: number | null;
@IsNumber()
@ApiHideProperty()
@@ -0,0 +1,355 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsBoolean,
IsInt,
IsOptional,
IsString,
IsUrl,
ValidateNested,
IsArray,
IsObject,
IsNumber,
IsEnum,
} from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Location {
@IsString()
type!: string;
}
class Source {
@IsString()
id!: string;
@IsString()
type!: string;
@IsString()
label!: string;
}
class OptionInput {
@IsString()
type!: string;
@IsBoolean()
@IsOptional()
required?: boolean;
@IsString()
@IsOptional()
placeholder?: string;
}
class BookingField {
@IsString()
name!: string;
@IsString()
type!: string;
@IsOptional()
@IsString()
defaultLabel?: string;
@IsString()
@IsOptional()
label?: string;
@IsString()
@IsOptional()
placeholder?: string;
@IsBoolean()
@IsOptional()
required?: boolean;
@IsOptional()
getOptionsAt?: string;
@IsObject()
@IsOptional()
optionsInputs?: { [key: string]: OptionInput };
@IsBoolean()
@IsOptional()
hideWhenJustOneOption?: boolean;
@IsString()
@IsOptional()
editable?: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => Source)
@IsOptional()
sources?: Source[];
}
class Organization {
@IsInt()
id!: number;
@IsString()
@IsOptional()
slug?: string | null;
@IsString()
name!: string;
@IsOptional()
metadata!: Record<string, any>;
}
class Profile {
@IsString()
username!: string | null;
@IsInt()
id!: number | null;
@IsInt()
@IsOptional()
userId?: number;
@IsString()
@IsOptional()
uid?: string;
@IsOptional()
@IsString()
name?: string;
@IsInt()
organizationId!: number | null;
@ValidateNested()
@Type(() => Organization)
organization?: Organization | null;
@IsString()
upId!: string;
@IsString()
@IsOptional()
image?: string;
@IsString()
@IsOptional()
brandColor?: string;
@IsString()
@IsOptional()
darkBrandColor?: string;
@IsString()
@IsOptional()
theme?: string;
@IsOptional()
bookerLayouts?: any;
}
class Owner {
@IsInt()
id!: number;
@IsString()
@IsOptional()
avatarUrl?: string | null;
@IsString()
username!: string | null;
@IsString()
name!: string | null;
@IsString()
weekStart!: string;
@IsString()
@IsOptional()
brandColor?: string | null;
@IsString()
@IsOptional()
darkBrandColor?: string | null;
@IsString()
@IsOptional()
theme?: string | null;
@IsOptional()
metadata!: any;
@IsInt()
@IsOptional()
defaultScheduleId?: number | null;
@IsString()
nonProfileUsername!: string | null;
@ValidateNested()
@Type(() => Profile)
profile!: Profile;
}
class User {
@IsString()
username!: string | null;
@IsString()
name!: string | null;
@IsString()
weekStart!: string;
@IsInt()
organizationId?: number;
@IsString()
@IsOptional()
avatarUrl?: string | null;
@ValidateNested()
profile!: Profile;
@IsString()
bookerUrl!: string;
}
class Schedule {
@IsInt()
id!: number;
@IsString()
timeZone!: string | null;
}
class PublicEventTypeOutput {
@IsInt()
id!: number;
@IsString()
title!: string;
@IsString()
description!: string;
@IsString()
@IsOptional()
eventName?: string | null;
@IsString()
slug!: string;
@IsBoolean()
isInstantEvent!: boolean;
@IsOptional()
aiPhoneCallConfig?: any;
@IsOptional()
schedulingType?: any;
@IsInt()
length!: number;
@IsArray()
@ValidateNested({ each: true })
@Type(() => Location)
locations!: Location[];
@IsArray()
customInputs!: any[];
@IsBoolean()
disableGuests!: boolean;
@IsObject()
metadata!: object | null;
@IsBoolean()
lockTimeZoneToggleOnBookingPage!: boolean;
@IsBoolean()
requiresConfirmation!: boolean;
@IsBoolean()
requiresBookerEmailVerification!: boolean;
@IsOptional()
recurringEvent?: any;
@IsNumber()
price!: number;
@IsString()
currency!: string;
@IsOptional()
seatsPerTimeSlot?: number | null;
@IsBoolean()
seatsShowAvailabilityCount!: boolean | null;
@IsArray()
@ValidateNested({ each: true })
@Type(() => BookingField)
bookingFields!: BookingField[];
@IsOptional()
team?: any;
@IsOptional()
@IsUrl()
successRedirectUrl?: string | null;
@IsArray()
workflows!: any[];
@IsArray()
hosts!: any[];
@ValidateNested()
@Type(() => Owner)
owner!: Owner | null;
@ValidateNested()
@Type(() => Schedule)
schedule!: Schedule | null;
@IsBoolean()
hidden!: boolean;
@IsBoolean()
assignAllTeamMembers!: boolean;
@IsOptional()
bookerLayouts?: any;
@IsArray()
@ValidateNested({ each: true })
@Type(() => User)
users!: User[];
@IsObject()
entity!: object;
@IsBoolean()
isDynamic!: boolean;
}
export class GetEventTypePublicOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested({ each: true })
@Type(() => PublicEventTypeOutput)
@IsArray()
data!: PublicEventTypeOutput | null;
}
@@ -12,7 +12,7 @@ class EventTypeGroup {
eventTypes!: EventTypeOutput[];
}
class GetEventTypesData {
export class GetEventTypesData {
@ValidateNested({ each: true })
@Type(() => EventTypeGroup)
@IsArray()
@@ -2,6 +2,7 @@ import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/constants/constants";
import { EventTypesRepository } from "@/ee/event-types/event-types.repository";
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
import { EventTypeOutput } from "@/ee/event-types/outputs/event-type.output";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
@@ -22,7 +23,7 @@ export class EventTypesService {
private readonly dbWrite: PrismaWriteService
) {}
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput) {
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput): Promise<EventTypeOutput> {
await this.checkCanCreateEventType(user.id, body);
const eventTypeUser = await this.getUserToCreateEvent(user);
const { eventType } = await createEventType({
@@ -34,7 +35,7 @@ export class EventTypesService {
prisma: this.dbWrite.prisma,
},
});
return eventType;
return eventType as EventTypeOutput;
}
async checkCanCreateEventType(userId: number, body: CreateEventTypeInput) {
@@ -88,7 +89,7 @@ export class EventTypesService {
}
this.checkUserOwnsEventType(user.id, eventType.eventType);
return eventType;
return eventType as { eventType: EventTypeOutput };
}
async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
@@ -126,9 +127,13 @@ export class EventTypesService {
},
});
const { eventType } = await this.getUserEventTypeForAtom(user, eventTypeId);
const eventType = await this.getUserEventTypeForAtom(user, eventTypeId);
return eventType;
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
return eventType.eventType;
}
async checkCanUpdateEventType(userId: number, eventTypeId: number) {
@@ -1,5 +1,6 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
@@ -18,6 +19,20 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
const CLIENT_REDIRECT_URI = "http://localhost:5555";
class CalendarsServiceMock {
async getCalendars() {
return {
connectedCalendars: [
{
integration: {
type: "google_calendar",
},
},
],
};
}
}
describe("Platform Gcal Endpoints", () => {
let app: INestApplication;
@@ -42,6 +57,8 @@ describe("Platform Gcal Endpoints", () => {
.useValue({
canActivate: () => true,
})
.overrideProvider(CalendarsService)
.useClass(CalendarsServiceMock)
.compile();
app = moduleRef.createNestApplication();
@@ -85,14 +102,14 @@ describe("Platform Gcal Endpoints", () => {
it(`/GET/ee/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/ee/gcal/oauth/auth-url`)
.get(`/v2/gcal/oauth/auth-url`)
.set("Authorization", `Bearer invalid_access_token`)
.expect(401);
});
it(`/GET/ee/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/ee/gcal/oauth/auth-url`)
.get(`/v2/gcal/oauth/auth-url`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
@@ -103,7 +120,7 @@ describe("Platform Gcal Endpoints", () => {
it(`/GET/ee/gcal/oauth/save: without oauth code`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/ee/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
});
@@ -111,7 +128,7 @@ describe("Platform Gcal Endpoints", () => {
it(`/GET/ee/gcal/oauth/save: without access token`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/ee/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
`/v2/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
});
@@ -119,25 +136,25 @@ describe("Platform Gcal Endpoints", () => {
it(`/GET/ee/gcal/oauth/save: without origin`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/ee/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
});
it(`/GET/ee/gcal/check with access token but without origin`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/ee/gcal/check`)
.get(`/v2/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.expect(400);
});
it(`/GET/ee/gcal/check without access token`, async () => {
await request(app.getHttpServer()).get(`/api/v2/ee/gcal/check`).expect(401);
await request(app.getHttpServer()).get(`/v2/gcal/check`).expect(401);
});
it(`/GET/ee/gcal/check with access token and origin but no credentials`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/ee/gcal/check`)
.get(`/v2/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(400);
@@ -151,7 +168,7 @@ describe("Platform Gcal Endpoints", () => {
"google-calendar"
);
await request(app.getHttpServer())
.get(`/api/v2/ee/gcal/check`)
.get(`/v2/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
+2 -2
View File
@@ -44,7 +44,7 @@ const CALENDAR_SCOPES = [
// Controller for the GCalConnect Atom
@Controller({
path: "ee/gcal",
path: "/gcal",
version: "2",
})
@DocsTags("Google Calendar")
@@ -60,7 +60,7 @@ export class GcalController {
private readonly calendarsService: CalendarsService
) {}
private redirectUri = `${this.config.get("api.url")}/ee/gcal/oauth/save`;
private redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`;
@Get("/oauth/auth-url")
@HttpCode(HttpStatus.OK)
@@ -71,7 +71,7 @@ describe("Me Endpoints", () => {
it("should get user associated with access token", async () => {
return request(app.getHttpServer())
.get("/api/v2/ee/me")
.get("/v2/me")
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<UserResponse> = response.body;
@@ -90,7 +90,7 @@ describe("Me Endpoints", () => {
const body: UpdateManagedUserInput = { timeZone: "Europe/Rome" };
return request(app.getHttpServer())
.patch("/api/v2/ee/me")
.patch("/v2/me")
.send(body)
.expect(200)
.then(async (response) => {
@@ -114,22 +114,19 @@ describe("Me Endpoints", () => {
it("should not update user associated with access token given invalid timezone", async () => {
const bodyWithIncorrectTimeZone: UpdateManagedUserInput = { timeZone: "Narnia/Woods" };
return request(app.getHttpServer()).patch("/api/v2/ee/me").send(bodyWithIncorrectTimeZone).expect(400);
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectTimeZone).expect(400);
});
it("should not update user associated with access token given invalid time format", async () => {
const bodyWithIncorrectTimeFormat: UpdateManagedUserInput = { timeFormat: 100 };
return request(app.getHttpServer())
.patch("/api/v2/ee/me")
.send(bodyWithIncorrectTimeFormat)
.expect(400);
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectTimeFormat).expect(400);
});
it("should not update user associated with access token given invalid week start", async () => {
const bodyWithIncorrectWeekStart: UpdateManagedUserInput = { weekStart: "waba luba dub dub" };
return request(app.getHttpServer()).patch("/api/v2/ee/me").send(bodyWithIncorrectWeekStart).expect(400);
return request(app.getHttpServer()).patch("/v2/me").send(bodyWithIncorrectWeekStart).expect(400);
});
afterAll(async () => {
+1 -1
View File
@@ -14,7 +14,7 @@ import { PROFILE_READ, PROFILE_WRITE, SUCCESS_STATUS } from "@calcom/platform-co
import { userSchemaResponse } from "@calcom/platform-types";
@Controller({
path: "ee/me",
path: "/me",
version: "2",
})
@UseGuards(AccessTokenGuard, PermissionsGuard)
@@ -20,7 +20,7 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@Controller({
path: "ee/provider",
path: "/provider",
version: "2",
})
@DocsTags("Cal provider")
@@ -23,6 +23,7 @@ import {
UseGuards,
} from "@nestjs/common";
import { ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
import { Throttle } from "@nestjs/throttler";
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { UpdateScheduleInput } from "@calcom/platform-types";
@@ -70,6 +71,7 @@ export class SchedulesController {
@Get("/:scheduleId")
@Permissions([SCHEDULE_READ])
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
async getSchedule(
@GetUser() user: UserWithProfile,
@Param("scheduleId") scheduleId: number
@@ -24,6 +24,16 @@ export class SchedulesService {
private readonly usersRepository: UsersRepository
) {}
async createUserDefaultSchedule(userId: number, timeZone: string) {
const schedule = {
isDefault: true,
name: "Default schedule",
timeZone,
};
return this.createUserSchedule(userId, schedule);
}
async createUserSchedule(userId: number, schedule: CreateScheduleInput) {
const availabilities = schedule.availabilities?.length
? schedule.availabilities
+3
View File
@@ -12,6 +12,9 @@ export type Environment = {
SENTRY_DSN: string;
LOG_LEVEL: keyof typeof logLevels;
REDIS_URL: string;
STRIPE_API_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
WEB_APP_URL: string;
};
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
@@ -13,13 +13,17 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const statusCode = exception.getStatus();
const requestId = request.headers["X-Request-Id"];
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
requestId,
});
response.status(statusCode).json({
status: ERROR_STATUS,
timestamp: new Date().toISOString(),
@@ -33,12 +33,15 @@ export class PrismaExceptionFilter implements ExceptionFilter {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const requestId = request.headers["X-Request-Id"];
this.logger.error(`PrismaError: ${error.message}`, {
error,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
requestId,
});
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
status: ERROR_STATUS,
@@ -14,6 +14,7 @@ export class SentryFilter extends BaseExceptionFilter {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const requestId = request.headers["X-Request-Id"];
this.logger.error(`Sentry Exception Filter: ${exception?.message}`, {
exception,
@@ -21,6 +22,7 @@ export class SentryFilter extends BaseExceptionFilter {
headers: request.headers,
url: request.url,
method: request.method,
requestId,
});
// capture if client has been init
@@ -41,12 +41,15 @@ export class TRPCExceptionFilter implements ExceptionFilter {
break;
}
const requestId = request.headers["X-Request-Id"];
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
requestId,
});
response.status(statusCode).json({
@@ -14,6 +14,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const requestId = request.headers["X-Request-Id"];
this.logger.error(`ZodError: ${error.message}`, {
error,
@@ -21,6 +22,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
headers: request.headers,
url: request.url,
method: request.method,
requestId,
});
response.status(HttpStatus.BAD_REQUEST).json({
+5 -1
View File
@@ -16,6 +16,7 @@ import { loggerConfig } from "./lib/logger";
const run = async () => {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: WinstonModule.createLogger(loggerConfig()),
bodyParser: false,
});
const logger = new Logger("App");
@@ -23,10 +24,13 @@ const run = async () => {
try {
bootstrap(app);
const port = app.get(ConfigService<AppConfig, true>).get("api.port", { infer: true });
generateSwagger(app);
if (process.env.SKIP_DOCS_GENERATION !== "1") {
void generateSwagger(app);
}
await app.listen(port);
logger.log(`Application started on port: ${port}`);
} catch (error) {
console.error(error);
logger.error("Application crashed", {
error,
});
@@ -0,0 +1,18 @@
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response } from "express";
@Injectable()
export class RewriterMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => void) {
if (req.url.startsWith("/api/v2")) {
req.url = req.url.replace("/api/v2", "/v2");
}
if (req.url.startsWith("/v2/ee")) {
req.url = req.url.replace("/v2/ee", "/v2");
}
if (req.url.includes("reccuring")) {
req.url = req.url.replace("reccuring", "recurring");
}
next();
}
}
@@ -0,0 +1,10 @@
import { Injectable, NestMiddleware } from "@nestjs/common";
import * as bodyParser from "body-parser";
import type { Request, Response } from "express";
@Injectable()
export class JsonBodyMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => any) {
bodyParser.json()(req, res, next);
}
}
@@ -0,0 +1,10 @@
import { Injectable, NestMiddleware } from "@nestjs/common";
import * as bodyParser from "body-parser";
import type { Request, Response } from "express";
@Injectable()
export class RawBodyMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => any) {
bodyParser.raw({ type: "*/*" })(req, res, next);
}
}
@@ -0,0 +1,16 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Request, Response } from "express";
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const ctx = context.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
return next.handle();
}
}
@@ -0,0 +1,11 @@
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
import { v4 as uuid } from "uuid";
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
req.headers["X-Request-Id"] = uuid();
next();
}
}
+2 -1
View File
@@ -6,13 +6,14 @@ import { ApiKeyAuthStrategy } from "@/modules/auth/strategies/api-key-auth/api-k
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { RedisModule } from "@/modules/redis/redis.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
import { PassportModule } from "@nestjs/passport";
@Module({
imports: [PassportModule, ApiKeyModule, UsersModule, MembershipsModule, TokensModule],
imports: [PassportModule, RedisModule, ApiKeyModule, UsersModule, MembershipsModule, TokensModule],
providers: [
ApiKeyAuthStrategy,
NextAuthGuard,
@@ -27,7 +27,10 @@ export class AccessTokenStrategy extends PassportStrategy(BaseStrategy, "access-
throw new UnauthorizedException(INVALID_ACCESS_TOKEN);
}
await this.oauthFlowService.validateAccessToken(accessToken);
const accessTokenValid = await this.oauthFlowService.validateAccessToken(accessToken);
if (!accessTokenValid) {
throw new UnauthorizedException(INVALID_ACCESS_TOKEN);
}
const client = await this.tokensRepository.getAccessTokenClient(accessToken);
if (!client) {
@@ -19,12 +19,17 @@ export class CreateAvailabilityInput {
}
function transformStringToDate(value: string, key: string): Date {
const parts = value.split(":");
// note(Lauris): incoming value is ISO8061 e.g. 2025-0412T13:17:56.324Z
const dateTimeParts = value.split("T");
const timePart = dateTimeParts[1].split(".")[0]; // Removes milliseconds
const parts = timePart.split(":");
if (parts.length !== 3) {
throw new BadRequestException(`Invalid ${key} format. Expected format: HH:MM:SS. Received ${value}`);
throw new BadRequestException(
`Invalid time format. Expected format(ISO8061): 2025-0412T13:17:56.324Z. Received: ${value}`
);
}
const [hours, minutes, seconds] = parts.map(Number);
if (hours < 0 || hours > 23) {
@@ -0,0 +1,17 @@
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingController } from "@/modules/billing/controllers/billing.controller";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { BillingService } from "@/modules/billing/services/billing.service";
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 { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, StripeModule, MembershipsModule, OrganizationsModule],
providers: [BillingConfigService, BillingService, BillingRepository],
exports: [BillingService, BillingRepository],
controllers: [BillingController],
})
export class BillingModule {}
@@ -0,0 +1,36 @@
import { PlatformPlan } from "@/modules/billing/types";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class BillingRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
getBillingForTeam = (teamId: number) =>
this.dbRead.prisma.platformBilling.findUnique({
where: {
id: teamId,
},
});
async updateTeamBilling(
teamId: number,
billingStart: number,
billingEnd: number,
plan: PlatformPlan,
subscription?: string
) {
return this.dbWrite.prisma.platformBilling.update({
where: {
id: teamId,
},
data: {
billingCycleStart: billingStart,
billingCycleEnd: billingEnd,
subscriptionId: subscription,
plan: plan.toString(),
},
});
}
}
@@ -0,0 +1,137 @@
import { AppConfig } from "@/config/type";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
import { BillingService } from "@/modules/billing/services/billing.service";
import { PlatformPlan } from "@/modules/billing/types";
import {
BadRequestException,
Body,
Controller,
Get,
Param,
Post,
Req,
UseGuards,
Headers,
HttpCode,
HttpStatus,
Logger,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiExcludeController } from "@nestjs/swagger";
import { Request } from "express";
import { Stripe } from "stripe";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "/billing",
version: "2",
})
@ApiExcludeController(true)
export class BillingController {
private readonly stripeWhSecret: string;
private logger = new Logger("Billing Controller");
constructor(
private readonly billingService: BillingService,
private readonly configService: ConfigService<AppConfig>
) {
this.stripeWhSecret = configService.get("stripe.webhookSecret", { infer: true }) ?? "";
}
@Get("/:teamId/check")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@Roles(["OWNER", "ADMIN"])
async checkTeamBilling(
@Param("teamId") teamId: number
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
const { status, plan } = await this.billingService.getBillingData(teamId);
return {
status: "success",
data: {
valid: status === "valid",
plan,
},
};
}
@Post("/:teamId/subscribe")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@Roles(["OWNER", "ADMIN"])
async subscribeTeamToStripe(
@Param("teamId") teamId: number,
@Body() input: SubscribeToPlanInput
): Promise<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
const { status } = await this.billingService.getBillingData(teamId);
if (status === "valid") {
throw new BadRequestException("This team is already subscribed to a plan.");
}
const { action, url } = await this.billingService.createSubscriptionForTeam(teamId, input.plan);
if (action === "redirect") {
return {
status: "success",
data: {
action: "redirect",
url,
},
};
}
return {
status: "success",
};
}
@Post("/webhook")
@HttpCode(HttpStatus.OK)
async stripeWebhook(
@Req() request: Request,
@Headers("stripe-signature") stripeSignature: string
): Promise<ApiResponse> {
const event = await this.billingService.stripeService.stripe.webhooks.constructEventAsync(
request.body,
stripeSignature,
this.stripeWhSecret
);
if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") {
const subscription = event.data.object as Stripe.Subscription;
if (!subscription.metadata?.teamId) {
return {
status: "success",
};
}
const teamId = Number.parseInt(subscription.metadata.teamId);
const plan = subscription.metadata.plan;
if (!plan || !teamId) {
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
return {
status: "success",
};
}
await this.billingService.setSubscriptionForTeam(
teamId,
subscription,
PlatformPlan[plan as keyof typeof PlatformPlan]
);
return {
status: "success",
};
}
return {
status: "success",
};
}
}
@@ -0,0 +1,7 @@
import { PlatformPlan } from "@/modules/billing/types";
import { IsEnum } from "class-validator";
export class SubscribeToPlanInput {
@IsEnum(PlatformPlan)
plan!: PlatformPlan;
}
@@ -0,0 +1,5 @@
export class CheckPlatformBillingResponseDto {
valid!: boolean;
plan?: string;
}
@@ -0,0 +1,4 @@
export class SubscribeTeamToBillingResponseDto {
url?: string;
action?: "redirect";
}
@@ -0,0 +1,23 @@
import { PlatformPlan } from "@/modules/billing/types";
import { Injectable } from "@nestjs/common";
@Injectable()
export class BillingConfigService {
private readonly config: Map<PlatformPlan, string>;
constructor() {
this.config = new Map<PlatformPlan, string>();
const planKeys = Object.keys(PlatformPlan).filter((key) => isNaN(Number(key)));
for (const key of planKeys) {
this.config.set(
PlatformPlan[key as keyof typeof PlatformPlan],
process.env[`STRIPE_PRICE_ID_${key}`] ?? ""
);
}
}
get(plan: PlatformPlan): string | undefined {
return this.config.get(plan);
}
}
@@ -0,0 +1,140 @@
import { AppConfig } from "@/config/type";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { PlatformPlan } from "@/modules/billing/types";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { StripeService } from "@/modules/stripe/stripe.service";
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { DateTime } from "luxon";
import Stripe from "stripe";
@Injectable()
export class BillingService {
private logger = new Logger("BillingService");
private readonly webAppUrl: string;
constructor(
private readonly teamsRepository: OrganizationsRepository,
public readonly stripeService: StripeService,
private readonly billingRepository: BillingRepository,
private readonly configService: ConfigService<AppConfig>,
private readonly billingConfigService: BillingConfigService
) {
this.webAppUrl = configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
}
async getBillingData(teamId: number) {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
if (teamWithBilling?.platformBilling) {
if (!teamWithBilling?.platformBilling.subscriptionId) {
return { team: teamWithBilling, status: "no_subscription", plan: "none" };
}
return { team: teamWithBilling, status: "valid", plan: teamWithBilling.platformBilling.plan };
} else {
return { team: teamWithBilling, status: "no_billing", plan: "none" };
}
}
async createSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
let brandNewBilling = false;
let customerId = teamWithBilling?.platformBilling?.customerId;
if (!teamWithBilling?.platformBilling) {
brandNewBilling = true;
customerId = await this.teamsRepository.createNewBillingRelation(teamId);
this.logger.log("Team had no Stripe Customer ID, created one for them.", {
id: teamId,
stripeId: customerId,
});
}
if (brandNewBilling || !teamWithBilling?.platformBilling?.subscriptionId) {
const { url } = await this.stripeService.stripe.checkout.sessions.create({
customer: customerId,
line_items: [
{
price: this.billingConfigService.get(plan),
quantity: 1,
},
],
success_url: `${this.webAppUrl}/settings/platform/oauth-clients`,
cancel_url: `${this.webAppUrl}/settings/platform/oauth-clients`,
mode: "subscription",
metadata: {
teamId: teamId.toString(),
plan: plan.toString(),
},
subscription_data: {
metadata: {
teamId: teamId.toString(),
plan: plan.toString(),
},
},
});
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
return { action: "redirect", url };
}
return { action: "none" };
}
async setSubscriptionForTeam(teamId: number, subscription: Stripe.Subscription, plan: PlatformPlan) {
const billingCycleStart = DateTime.now().get("day");
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
return this.billingRepository.updateTeamBilling(
teamId,
billingCycleStart,
billingCycleEnd,
plan,
subscription.id
);
}
async increaseUsageForTeam(teamId: number) {
try {
const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
if (!billingSubscription || !billingSubscription?.subscriptionId) {
this.logger.error("Team did not have stripe subscription associated to it", {
teamId,
});
return void 0;
}
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
billingSubscription.subscriptionId
);
const item = stripeSubscription.items.data[0];
// legacy plans are licensed, we cannot create usage records against them
if (item.price?.recurring?.usage_type === "licensed") {
return void 0;
}
await this.stripeService.stripe.subscriptionItems.createUsageRecord(item.id, {
action: "increment",
quantity: 1,
timestamp: "now",
});
} catch (error) {
// don't fail the request, log it.
this.logger.error("Failed to increase usage for team", {
teamId: teamId,
error,
});
}
}
async increaseUsageByClientId(clientId: string) {
const team = await this.teamsRepository.findTeamIdFromClientId(clientId);
if (!team.id) return Promise.resolve(); // noop resolution.
return this.increaseUsageForTeam(team?.id);
}
}
+5
View File
@@ -0,0 +1,5 @@
export enum PlatformPlan {
STARTER = "STARTER",
ESSENTIALS = "ESSENTIALS",
ENTERPRISE = "ENTERPRISE",
}
@@ -27,7 +27,7 @@ export class CredentialsRepository {
invalid: false,
},
where: {
id: credential?.id,
id: credential?.id ?? 0,
},
});
}
+2 -2
View File
@@ -1,12 +1,12 @@
import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module";
import { EventsModule } from "@/modules/events/events.module";
import { BillingModule } from "@/modules/billing/billing.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@Module({
imports: [EventsModule, OAuthClientModule, PlatformEndpointsModule, TimezoneModule],
imports: [OAuthClientModule, BillingModule, PlatformEndpointsModule, TimezoneModule],
})
export class EndpointsModule implements NestModule {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -1,43 +0,0 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Controller, Get, NotFoundException, InternalServerErrorException, Query } from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { getPublicEvent } from "@calcom/platform-libraries";
import type { PublicEventType } from "@calcom/platform-libraries";
import { ApiResponse, GetPublicEventInput } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@Controller({
path: "events",
version: "2",
})
@DocsTags("Event types")
export class EventsController {
constructor(private readonly prismaReadService: PrismaReadService) {}
@Get("/public")
async getPublicEvent(@Query() queryParams: GetPublicEventInput): Promise<ApiResponse<PublicEventType>> {
try {
const event = await getPublicEvent(
queryParams.username,
queryParams.eventSlug,
queryParams.isTeamEvent,
queryParams.org || null,
this.prismaReadService.prisma as unknown as PrismaClient,
// We should be fine allowing unpublished orgs events to be servable through platform because Platform access is behind license
// If there is ever a need to restrict this, we can introduce a new query param `fromRedirectOfNonOrgLink`
true
);
return {
data: event,
status: SUCCESS_STATUS,
};
} catch (err) {
if (err instanceof Error) {
throw new NotFoundException(err.message);
}
}
throw new InternalServerErrorException("Could not find public event.");
}
}
@@ -1,9 +0,0 @@
import { EventsController } from "@/modules/events/controllers/events.controller";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
controllers: [EventsController],
})
export class EventsModule {}
@@ -17,6 +17,7 @@ import { PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
@@ -75,10 +76,12 @@ describe("OAuth Client Users Endpoints", () => {
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
let postResponseData: CreateUserResponse;
const userEmail = "oauth-client-user@gmail.com";
const userTimeZone = "Europe/Rome";
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
@@ -93,6 +96,7 @@ describe("OAuth Client Users Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
@@ -134,6 +138,7 @@ describe("OAuth Client Users Endpoints", () => {
it(`/POST`, async () => {
const requestBody: CreateManagedUserInput = {
email: userEmail,
timeZone: userTimeZone,
};
const response = await request(app.getHttpServer())
@@ -158,6 +163,7 @@ describe("OAuth Client Users Endpoints", () => {
await userConnectedToOAuth(responseBody.data.user.email);
await userHasDefaultEventTypes(responseBody.data.user.id);
await userHasDefaultSchedule(responseBody.data.user.id, responseBody.data.user.defaultScheduleId);
});
async function userConnectedToOAuth(userEmail: string) {
@@ -181,6 +187,17 @@ describe("OAuth Client Users Endpoints", () => {
).toBeTruthy();
}
async function userHasDefaultSchedule(userId: number, scheduleId: number | null) {
expect(scheduleId).toBeDefined();
expect(scheduleId).not.toBeNull();
const user = await userRepositoryFixture.get(userId);
expect(user?.defaultScheduleId).toEqual(scheduleId);
const schedule = scheduleId ? await schedulesRepositoryFixture.getById(scheduleId) : null;
expect(schedule?.userId).toEqual(userId);
}
it(`/GET: return list of managed users`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0`)
@@ -199,7 +216,7 @@ describe("OAuth Client Users Endpoints", () => {
it(`/GET/:id`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
.set("Authorization", `Bearer ${postResponseData.accessToken}`)
.set("x-cal-secret-key", oAuthClient.secret)
.set("Origin", `${CLIENT_REDIRECT_URI}`)
.expect(200);
@@ -216,7 +233,7 @@ describe("OAuth Client Users Endpoints", () => {
const response = await request(app.getHttpServer())
.patch(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
.set("Authorization", `Bearer ${postResponseData.accessToken}`)
.set("x-cal-secret-key", oAuthClient.secret)
.set("Origin", `${CLIENT_REDIRECT_URI}`)
.send(body)
.expect(200);
@@ -231,7 +248,7 @@ describe("OAuth Client Users Endpoints", () => {
it(`/DELETE/:id`, () => {
return request(app.getHttpServer())
.delete(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
.set("Authorization", `Bearer ${postResponseData.accessToken}`)
.set("x-cal-secret-key", oAuthClient.secret)
.set("Origin", `${CLIENT_REDIRECT_URI}`)
.expect(200);
});
@@ -76,11 +76,6 @@ export class OAuthClientUsersController {
this.logger.log(
`Creating user with data: ${JSON.stringify(body, null, 2)} for OAuth Client with ID ${oAuthClientId}`
);
const existingUser = await this.userRepository.findByEmail(body.email);
if (existingUser) {
throw new BadRequestException("A user with the provided email already exists.");
}
const client = await this.oauthRepository.getOAuthClient(oAuthClientId);
const isPlatformManaged = true;
@@ -8,6 +8,7 @@ import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/o
import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto";
import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Body,
@@ -22,6 +23,7 @@ import {
Logger,
UseGuards,
NotFoundException,
BadRequestException,
} from "@nestjs/common";
import {
ApiTags as DocsTags,
@@ -47,7 +49,10 @@ Second, make sure that the logged in user has organizationId set to pass the Org
export class OAuthClientsController {
private readonly logger = new Logger("OAuthClientController");
constructor(private readonly oauthClientRepository: OAuthClientRepository) {}
constructor(
private readonly oauthClientRepository: OAuthClientRepository,
private readonly teamsRepository: OrganizationsRepository
) {}
@Post("/")
@HttpCode(HttpStatus.CREATED)
@@ -65,6 +70,12 @@ export class OAuthClientsController {
this.logger.log(
`For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}`
);
const organization = await this.teamsRepository.findByIdIncludeBilling(organizationId);
if (!organization?.platformBilling || !organization?.platformBilling?.subscriptionId) {
throw new BadRequestException("Team is not subscribed, cannot create an OAuth Client.");
}
const { id, secret } = await this.oauthClientRepository.createOAuthClient(organizationId, body);
return {
status: SUCCESS_STATUS,
@@ -2,7 +2,7 @@ import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsIn, ValidateNested, IsNotEmptyObject, IsString } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { SUCCESS_STATUS, ERROR_STATUS, REDIRECT_STATUS } from "@calcom/platform-constants";
class DataDto {
@ApiProperty({
@@ -34,3 +34,9 @@ export class CreateOAuthClientResponseDto {
@Type(() => DataDto)
data!: DataDto;
}
export class CreateOauthClientRedirect {
status!: typeof REDIRECT_STATUS;
url!: string;
}
@@ -1,5 +1,7 @@
import { EventTypesModule } from "@/ee/event-types/event-types.module";
import { SchedulesModule } from "@/ee/schedules/schedules.module";
import { AuthModule } from "@/modules/auth/auth.module";
import { BillingModule } from "@/modules/billing/billing.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller";
import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller";
@@ -10,6 +12,8 @@ import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersModule } from "@/modules/users/users.module";
@@ -19,12 +23,16 @@ import { Global, Module } from "@nestjs/common";
@Module({
imports: [
PrismaModule,
RedisModule,
AuthModule,
UsersModule,
TokensModule,
MembershipsModule,
EventTypesModule,
OrganizationsModule,
StripeModule,
BillingModule,
SchedulesModule,
],
providers: [
OAuthClientRepository,
@@ -59,7 +59,7 @@ export class OAuthClientRepository {
}
async getOAuthClientWithRefreshSecret(clientId: string, clientSecret: string, refreshToken: string) {
return await this.dbRead.prisma.platformOAuthClient.findFirst({
return this.dbRead.prisma.platformOAuthClient.findFirst({
where: {
id: clientId,
secret: clientSecret,
@@ -1,4 +1,5 @@
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
@@ -6,15 +7,15 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries";
import { slugify } from "@calcom/platform-libraries";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries";
@Injectable()
export class OAuthClientUsersService {
constructor(
private readonly userRepository: UsersRepository,
private readonly tokensRepository: TokensRepository,
private readonly eventTypesService: EventTypesService
private readonly eventTypesService: EventTypesService,
private readonly schedulesService: SchedulesService
) {}
async createOauthClientUser(
@@ -23,7 +24,7 @@ export class OAuthClientUsersService {
isPlatformManaged: boolean,
organizationId?: number
) {
const existsWithEmail = await this.userExistsWithEmail(oAuthClientId, body.email);
const existsWithEmail = await this.managedUserExistsWithEmail(oAuthClientId, body.email);
if (existsWithEmail) {
throw new BadRequestException("User with the provided e-mail already exists.");
}
@@ -62,8 +63,14 @@ export class OAuthClientUsersService {
oAuthClientId,
user.id
);
await this.eventTypesService.createUserDefaultEventTypes(user.id);
if (body.timeZone) {
const defaultSchedule = await this.schedulesService.createUserDefaultSchedule(user.id, body.timeZone);
user.defaultScheduleId = defaultSchedule.id;
}
return {
user,
tokens: {
@@ -73,7 +80,7 @@ export class OAuthClientUsersService {
};
}
async userExistsWithEmail(oAuthClientId: string, email: string) {
async managedUserExistsWithEmail(oAuthClientId: string, email: string) {
const oAuthEmail = this.getOAuthUserEmail(oAuthClientId, email);
const user = await this.userRepository.findByEmail(oAuthEmail);
return !!user;
@@ -92,8 +99,6 @@ export class OAuthClientUsersService {
getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
const [username, emailDomain] = userEmail.split("@");
const email = `${username}+${oAuthClientId}@${emailDomain}`;
return email;
return `${username}+${oAuthClientId}@${emailDomain}`;
}
}
@@ -1,33 +1,70 @@
import { TokenExpiredException } from "@/modules/auth/guards/access-token/token-expired.exception";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
import { BadRequestException, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
import { DateTime } from "luxon";
import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
@Injectable()
export class OAuthFlowService {
private logger = new Logger("OAuthFlowService");
constructor(
private readonly tokensRepository: TokensRepository,
private readonly oAuthClientRepository: OAuthClientRepository //private readonly redisService: RedisIOService
private readonly oAuthClientRepository: OAuthClientRepository,
private readonly redisService: RedisService
) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async propagateAccessToken(accessToken: string) {
// this.logger.log("Propagating access token to redis", accessToken);
// TODO propagate
//this.redisService.redis.hset("access_tokens", accessToken,)
return void 0;
try {
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
let expiry = await this.tokensRepository.getAccessTokenExpiryDate(accessToken);
if (!expiry) {
this.logger.warn(`Token for ${ownerId} had no expiry time, assuming it's new.`);
expiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
}
const cacheKey = this._generateActKey(accessToken);
await this.redisService.redis.hmset(cacheKey, {
ownerId: ownerId,
expiresAt: expiry?.toJSON(),
});
await this.redisService.redis.expireat(cacheKey, Math.floor(expiry.getTime() / 1000));
} catch (err) {
this.logger.error("Access Token Propagation Failed, falling back to DB...", err);
}
}
async getOwnerId(accessToken: string) {
return this.tokensRepository.getAccessTokenOwnerId(accessToken);
const cacheKey = this._generateActKey(accessToken);
try {
const ownerId = await this.redisService.redis.get(cacheKey);
if (ownerId) {
return Number.parseInt(ownerId);
}
} catch (err) {
this.logger.warn("Cache#getOwnerId fetch failed, falling back to DB...");
}
const ownerIdFromDb = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
if (!ownerIdFromDb) throw new Error("Invalid Access Token, not present in Redis or DB");
// await in case of race conditions, but void it's return since cache writes shouldn't halt execution.
void (await this.redisService.redis.setex(cacheKey, 3600, ownerIdFromDb)); // expires in 1 hour
return ownerIdFromDb;
}
async validateAccessToken(secret: string) {
// status can be "CACHE_HIT" or "CACHE_MISS", MISS will most likely mean the token has expired
// but we need to check the SQL db for it anyways.
const { status } = await this.readFromCache(secret);
const { status, cacheKey } = await this.readFromCache(secret);
if (status === "CACHE_HIT") {
return true;
@@ -43,12 +80,23 @@ export class OAuthFlowService {
throw new TokenExpiredException();
}
// we can't use a Promise#all or similar here because we care about execution order
// however we can't allow caches to fail a validation hence the results are voided.
void (await this.redisService.redis.hmset(cacheKey, { expiresAt: tokenExpiresAt.toJSON() }));
void (await this.redisService.redis.expireat(cacheKey, Math.floor(tokenExpiresAt.getTime() / 1000)));
return true;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
private async readFromCache(secret: string) {
return { status: "CACHE_MISS" };
const cacheKey = this._generateActKey(secret);
const tokenData = await this.redisService.redis.hgetall(cacheKey);
if (tokenData && new Date() < new Date(tokenData.expiresAt)) {
return { status: "CACHE_HIT", cacheKey };
}
return { status: "CACHE_MISS", cacheKey };
}
async exchangeAuthorizationToken(
@@ -77,7 +125,7 @@ export class OAuthFlowService {
authorizationToken.owner.id
);
await this.tokensRepository.invalidateAuthorizationToken(authorizationToken.id);
void this.propagateAccessToken(accessToken); // voided as we don't need to await
void this.propagateAccessToken(accessToken); // void result, ignored.
return {
accessToken,
@@ -113,4 +161,8 @@ export class OAuthFlowService {
refreshToken: refreshToken.secret,
};
}
private _generateActKey(accessToken: string) {
return `act_${accessToken}`;
}
}
@@ -1,11 +1,12 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
imports: [PrismaModule, StripeModule],
providers: [OrganizationsRepository, OrganizationsService],
exports: [OrganizationsService],
exports: [OrganizationsService, OrganizationsRepository],
})
export class OrganizationsModule {}
@@ -1,9 +1,16 @@
import { PlatformPlan } from "@/modules/billing/types";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { StripeService } from "@/modules/stripe/stripe.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationsRepository {
constructor(private readonly dbRead: PrismaReadService) {}
constructor(
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private readonly stripeService: StripeService
) {}
async findById(organizationId: number) {
return this.dbRead.prisma.team.findUnique({
@@ -12,4 +19,54 @@ export class OrganizationsRepository {
},
});
}
async findByIdIncludeBilling(orgId: number) {
return this.dbRead.prisma.team.findUnique({
where: {
id: orgId,
},
include: {
platformBilling: true,
},
});
}
async createNewBillingRelation(orgId: number, plan?: PlatformPlan) {
const { id } = await this.stripeService.stripe.customers.create({
metadata: {
createdBy: "oauth_client_no_csid", // mark in case this is needed in the future.
},
});
await this.dbWrite.prisma.team.update({
where: {
id: orgId,
},
data: {
platformBilling: {
create: {
customerId: id,
plan: plan ? plan.toString() : "none",
},
},
},
});
return id;
}
async findTeamIdFromClientId(clientId: string) {
return this.dbRead.prisma.team.findFirstOrThrow({
where: {
platformOAuthClient: {
some: {
id: clientId,
},
},
},
select: {
id: true,
},
});
}
}
@@ -0,0 +1,10 @@
import { Injectable } from "@nestjs/common";
import { ThrottlerGuard } from "@nestjs/throttler";
@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
// TODO: adapt if required for CF / AWS / FlightControl proxying.
protected async getTracker(req: Record<string, any>): Promise<string> {
return req.ips.length ? req.ips[0] : req.ip;
}
}
@@ -0,0 +1,8 @@
import { RedisService } from "@/modules/redis/redis.service";
import { Module } from "@nestjs/common";
@Module({
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}
@@ -0,0 +1,20 @@
import { AppConfig } from "@/config/type";
import { Injectable, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Redis } from "ioredis";
@Injectable()
export class RedisService implements OnModuleDestroy {
public redis: Redis;
constructor(readonly configService: ConfigService<AppConfig>) {
const dbUrl = configService.get<string>("db.redisUrl", { infer: true });
if (!dbUrl) throw new Error("Misconfigured Redis, halting.");
this.redis = new Redis(dbUrl);
}
async onModuleDestroy() {
await this.redis.disconnect();
}
}
@@ -0,0 +1,10 @@
import { StripeService } from "@/modules/stripe/stripe.service";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [ConfigModule],
exports: [StripeService],
providers: [StripeService],
})
export class StripeModule {}
@@ -0,0 +1,15 @@
import { AppConfig } from "@/config/type";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import Stripe from "stripe";
@Injectable()
export class StripeService {
public stripe: Stripe;
constructor(configService: ConfigService<AppConfig>) {
this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", {
apiVersion: "2020-08-27",
});
}
}
@@ -65,6 +65,7 @@ export class TokensRepository {
// discard.
}
}
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -14,7 +14,11 @@
"jsx": "react-jsx",
"paths": {
"@/*": ["./src/*"],
"@prisma/client/*": ["@calcom/prisma/client/*"]
"@prisma/client/*": ["@calcom/prisma/client/*"],
"@calcom/platform-libraries": ["../../../packages/platform/libraries/index.ts"],
"@calcom/platform-constants": ["../../../packages/platform/constants/index.ts"],
"@calcom/platform-types": ["../../../packages/platform/types/index.ts"],
"@calcom/platform-utils": ["../../../packages/platform/utils/index.ts"]
},
"incremental": true,
"skipLibCheck": true,
@@ -23,6 +27,17 @@
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
},
"exclude": ["./dist", "next-i18next.config.js"],
"include": ["./**/*.ts", "../../../packages/types/*.d.ts"]
"exclude": ["./dist", "./node_modules", "next-i18next.config.js"],
"include": [
"./**/*.ts",
"../../../packages/types/*.d.ts",
"../../../packages/platform/libraries/**/*.ts",
"../../../packages/platform/libraries/**/*.d.ts",
"../../../packages/platform/constants/**/*.ts",
"../../../packages/platform/constants/**/*.d.ts",
"../../../packages/platform/types/**/*.ts",
"../../../packages/platform/types/**/*.d.ts",
"../../../packages/platform/utils/**/*.ts",
"../../../packages/platform/utils/**/*.d.ts"
]
}
-1
View File
@@ -1 +0,0 @@
Hello World
-1
View File
@@ -1 +0,0 @@
## Cal.com Platform Example (https://platform.cal.com)
+9
View File
@@ -1,5 +1,14 @@
# @calcom/web
## 3.9.9
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.4.0
- @calcom/embed-react@1.4.0
- @calcom/embed-snippet@1.2.0
## 3.1.3
### Patch Changes
+1
View File
@@ -0,0 +1 @@
export { GET } from "@calcom/features/tasker/api/cleanup";
+1
View File
@@ -0,0 +1 @@
export { GET } from "@calcom/features/tasker/api/cron";

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