Compare commits

...
126 Commits
Author SHA1 Message Date
GitHub Actions a518c7152a chore: release v4.9.1-rc1
Post release / release (push) Canceled after 0s
2025-01-16 15:53:45 +00:00
Benny Joo c37a8a587b fix: add decoding logic to app router params (#18712)
* decode app router params in buildLegacyCtx

* add helpful comments

* add comment

* address comment
2025-01-16 15:14:03 +00:00
Alex van Andel b7f7c33afc feature: Allow non-calcom domains to run team slug pages (#18711) 2025-01-16 09:44:14 -03:00
Lauris Skraucis 1d449ecc6b fix: v2 booking guests (#18690)
* fix: v2 booking guests

* test
2025-01-16 11:20:18 +02:00
Lauris Skraucis f582faed2b fix: Booking atom phone booking field and booking fields order (#18678)
* chore: examples app make managed user admin

* fix: phone field not appearing

* fix: booking fields order

* feat: local libraries setup script

* chore: add log when parsing booking field fails

* fix: backwards compatbile name booking field

* chore: libraries publish support scripts

* chore: bump libraries

* docs

* fix: phone default field return label and placeholder

* fix: e2e tests
2025-01-16 11:10:06 +02:00
GitHub Actions 79a8ab79f8 chore: release v4.9.0
Post release / release (push) Canceled after 0s
2025-01-16 01:26:26 +00:00
Benny Joo ce87de4fc7 fix: Revert - chore: app router - all sub-pages in /apps #16976 (#18702)
* revert

* add incomplete-booking
2025-01-15 21:12:18 -03:00
Calcom BotandReplexica b128579729 feat: update translations via @replexica (#18699)
Co-authored-by: Replexica <support@replexica.com>
2025-01-15 20:38:44 +00:00
Benny Joo 3cc5d9c065 fix: revert app router error pages (#18696) 2025-01-15 16:47:30 -03:00
Benny Joo 7897e14273 fix: add back global-error.tsx (#18692) 2025-01-15 16:55:43 +00:00
GitHub Actions c581456586 chore: release v4.8.19
Post release / release (push) Canceled after 0s
2025-01-15 15:30:52 +00:00
36dd3e9e6c fix: disable custom templates for free plan (#18635)
* add upgrade badge

* adjust update handler

* code clean up

* clean up template code

* only handle email templates

* fixes

* code clean up

* fix workflow template zod type

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-01-15 15:25:14 +00:00
Alex van Andel f137aee436 Remove export keyword @ route - only default allowed (#18682) 2025-01-15 14:33:02 +00:00
Eunjae Lee 4f494d5a32 refactor: use DataTable for /bookings (#18589)
* refactor: use DataTable for /bookings

* remove unnecessary animation (for better scroll performance)

* add consistent search box
2025-01-15 14:29:50 +00:00
Benny Joo cce6e83578 chore: migrate /org team pages and team type pages and user type page (#18668) 2025-01-15 09:21:21 +00:00
Lauris Skraucis c21ba636d2 fix: Booker atom name booking field (#18653)
* feat: split name booking field in v2

* fix: booker atom handle split name

* fix: after and before start dates bookings filter

* chore: bump platform libraries
2025-01-15 14:08:06 +05:30
Benny Joo 5ccf5e6be8 chore: migrate /team booking pages (#18656)
* wip

* remove pages/team/[slug]

* fix tests

* fix tests 2
2025-01-14 23:48:41 +00:00
Joe Au-YeungandAlex van Andel 66b3e735d8 feat: Routing form submitted but no booking - Salesforce actions (#18616)
* Add booking incomplete actions table

* Add incomplete booking page tab

* Add `getincompleteBookingSettings` trpc endpoints

* Add incomplete booking page

* Abstract enabled apps array

* Handle no enabled credentials and no actions

* Add enabled field to incomplete booking action db record

* Add new write record entries

* UI add separation between switch and inputs

* Fix typo

* clean up

* Add saveIncompleteBookingSettings endpoint

* Save incomplete booking settings

* Fix language around when to write to field

* Add `credentialId` to action record

* Choose which credential to assign to action

* Save credential to action

* Revert "Save credential to action"

This reverts commit ba6a1c808baed54f6d3d4c6155cf192c813d0696.

* Revert "Choose which credential to assign to action"

This reverts commit 968f6e5295d098c64abe95268afd1fa139a175ba.

* Revert "Add `credentialId` to action record"

This reverts commit 579f9ff4167b65aa987659e4e8f8c26f9e773317.

* Add credentialId to action record - rewrite migration file

* Revert "Add credentialId to action record - rewrite migration file"

This reverts commit 2843a92c61820e7f7a1614a557d3f7ea96342107.

* Revert "Add booking incomplete actions table"

This reverts commit 7ec75bef4a0f0a9d07be0142da64c49d739439ea.

* Revert "Add enabled field to incomplete booking action db record"

This reverts commit d279a1da05819eafa8fc5d664e3be733e0687e8d.

* Write migration in single commit

* Rename table

* Rename table - remove underscores

* Remove credential relationship

* Type fix - changing table name

* Fix table name

* Change writeToRecordObject to object

* Salesforce add incomplete booking, write to record

* Add incomplete booking actions to `triggerFormSubmittedNoEventWebhooks`

* Remove console.log

* Type fixes

* Type fixes

* Iterate if incompleteBookingActions

* Choose which credential to assign to action

* Save credential to action

* Fix getServerSideProp changes

* Type fix

* Type fix

* Type fix

* Type fix

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-01-14 23:17:16 +00:00
Praash 7a8b9ededb fix: html render from calendar invite (#18577) 2025-01-14 20:40:31 +00:00
Benny Joo 501c49f369 perf: no need for two query fetches for d link (private booking page) (#18659)
* return eventData

* use eventData and do not do extra query fetch
2025-01-14 17:15:56 +00:00
Amit Sharma 536604455b fix: BUG: adding filters to settings/teams/[team-id]/members page throws error (#18631) 2025-01-14 16:30:54 +00:00
MorganandPeer Richelsen cfb76a9689 chore: platform libraries readme publish reasons (#18646)
* chore: platform libraries readme publish reasons

* fixup! chore: platform libraries readme publish reasons

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-01-14 16:28:34 +00:00
Udit TakkarandPeer Richelsen 30180edc0b fix: phone number in confirmation email (#18642)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-01-14 16:27:24 +00:00
MorganandCarina Wollendorfer 2aa3d1d4e0 feat: get org ooo entries and filters/sort (#18645)
* feat: get org ooo entries and filters/sort

* remove console.log

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2025-01-14 15:33:22 +00:00
Eunjae Lee 21694c039a perf: improve routing insights view (2) (#18566) 2025-01-14 12:28:39 -03:00
Benny Joo a8c29b5459 chore: log error to Sentry in app router error page (#18637) 2025-01-14 15:23:07 +00:00
Anik Dhabal Babu 6cd7454f43 fix: /booking page datefilter (#18654)
* fix: /booking page datefilter

* Update get.handler.ts

* Update get.handler.ts

* shit
2025-01-14 15:14:28 +00:00
Calcom BotandReplexica 99b2419dd7 feat: update translations via @replexica (#18598)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2025-01-14 13:47:22 +00:00
Benny Joo f6d0a2ca09 fix: potential fix for flaky layout shift (#18651)
* potential fix: layout shift

* fix lint error
2025-01-14 13:36:20 +00:00
Alex van Andel f5ab9bd40b Allow lint to error but continue (unblock pipeline) 2025-01-14 13:12:24 +00:00
Benny JooandAlex van Andel 07085b9ffb chore: clean up config.matcher in middleware (#18638)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-01-14 10:24:36 +00:00
Benny Joo 14151b827f fix: make ESLint work and fix lint errors that were undetected before (#18639)
* fix eslint config

* fix

* add it to dev dep

* fix

* sync eslint version

* force ts-node to compile our ESLint plugin's TS files into CommonJS (which ESLint requires)

* fix some lint errors

* fix lint errors

* remove duplicate classname

* make @typescript-eslint/ban-types a warn for packages/trpc files

* fix lint errors in trpc

* fix lint errors in trpc - 2

* fix

* fix

* fix lint warnings
2025-01-14 08:58:38 +00:00
Kartik Saini 28b757f19c feat: do not show automation webhooks in webhook list (#18607) 2025-01-14 10:55:22 +05:30
Alex van Andel 68455db860 fix: main lint errors (#18634)
* fix: error-page.tsx related lint errors

* fix: lint no continue-on-error

* Adding more lint fixes

* Bring back annotate code linting results

* Bring back linting continue-on-error

* Slimline lint
2025-01-13 22:01:20 +00:00
Udit Takkar 96ff88d266 fix: disable sending sms when email is present (#18632)
* fix: disable sending sms when email is present

* fix: update test
2025-01-13 20:38:44 +00:00
GitHub Actions f88a91dad0 chore: release v4.8.18
Post release / release (push) Canceled after 0s
2025-01-13 18:12:32 +00:00
Keith WilliamsandAlex van Andel 35059d7727 revert: "feat: bulk shorten links with dub.links.createMany (#18539)" (#18587)
This reverts commit 4902c6a0f8.

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-01-13 18:07:27 +00:00
Carina WollendorferandCarinaWolli 68f76e2acc disable emails to all guests (#18628)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-01-13 15:58:43 +00:00
Benny Joo 80aac06ff3 chore: restore error pages for pages router (#18625) 2025-01-13 12:42:00 +00:00
sean-brydon 0f47528ba2 fix: parsing teamId (#18623) 2025-01-13 12:16:59 +00:00
Benny Joo 113dc5fca6 chore: try using custom 404 in pages/_error (#18622) 2025-01-13 11:35:10 +00:00
Benny Joo 0befdde5d7 chore: gracefully handle 404s from pages router's dynamic pages + tests (#18618)
* restore pages/_error

* set custom header in pages/_error

* handle it in middleware

* add test

* remove logs

* better test description
2025-01-13 15:46:56 +05:30
Benny Joo b3ed6e39e3 chore: redirect to /500 if pathname does not exist + better error handling (#18615)
* fix lint error

* fix booking page and better error handling
2025-01-13 00:04:36 +00:00
sean-brydon e07b1eda32 Async False (#18611) 2025-01-12 20:31:43 +00:00
Benny Joo 8262894bde fix: app/not-found cannot be a static page (#18610) 2025-01-12 20:21:03 +01:00
Benny Joo a3d7bcd22e feat: render custom error page for unexpected sever error + remove pages/_error (#18606)
* remove page/_error

* refactor app/error
2025-01-12 14:12:02 +01:00
Omar LópezandBenny Joo 62b753afba chore: app router 404 page (#18597)
* wip

* wip

* fix not found page

* render middleware for /settings pages

* fix

* remove global-error page

* add metadata to not-found page

* make not-found page static

* remove 404

* adding not-found to middleware is not necessary

* add every routes to config.matcher

* fix test

* fix style

* use i18n string

* fix tests

* fix

* fix

* revert unneeded changes

* fix

* fix

* fix

* fix style

* fix

* remove 404

* remove log

* fix

* fix

* fix

* fix

* better naming

* parallel testing

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-01-11 02:42:08 +00:00
bb40b2f6bf chore: app router - all sub-pages in /apps (#16976)
* chore: apps/[slug] remove pages router

* remove apps/[slug] pages from /future

* chore: apps/installed remove pages router

* chore: apps/installation remove pages router

* remove Head element

* fix metadata

* fix test

* fix another test

* chore: apps/categories remove pages router

* revert unneeded changes

* update middleware

* Remove <Head>

* remove unused import and code

* remove unused import and code again

* fix

* fix category page

* add split icon

* add /routing paths to middleware matcher

* wip

* remove HeadSeo from App.tsx

* clean up head-seo test

* add generateAppMetadata

* use generateAppMetadata in apps/[slug] page

* delete file

* remove log

* fix

* fix

* fix apps/installed pages

* fix cateogires pages

* fix

* fix imports

* wip

* fix

* fix

* fix metadata

* fix

* redirect /apps/routing-forms to /routing

* replace all usages of /apps/routing-forms to /routing

* better naming

* /routing -> /routing/forms

* fix

* fix

* fix

* fix

* remove backPath as it is irrelevant when withoutMain is true

* fix type checks

* fix type check in apps/[slug]

* refactors

* fix

* fix test

* fix

* fix

* fix

* Replace multiple leading slashes with a single slash

* migrate routing-forms too

* add re routing

* fix

* add redirection

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-01-10 20:16:11 -05:00
NizzyandOmar López f628dc83a8 detailed customer card (#18511)
Co-authored-by: Omar López <zomars@me.com>
2025-01-10 23:51:27 +00:00
sean-brydonandOmar López 2fd2578504 feat: Round Robin weights future members toggle (#17782)
Co-authored-by: Omar López <zomars@me.com>
2025-01-10 14:24:19 -07:00
Syed Ali ShahbazandCarinaWolli a29407349d docs: fix error introduced in #18578 (#18593)
* Fix error

* remove indent

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-01-10 19:48:12 +00:00
Joe Au-YeungandAnik Dhabal Babu e7df1ef493 fix: Create dub links if valid url (#18588)
* Create dub links if valid

* Map through filtered links

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-01-10 15:58:16 +00:00
Kartik Saini b652ae7714 fix: wrong Meeting Ended payload in zapier (#18202)
* fix: wrong Meeting Ended payload in zapier

* fix
2025-01-10 15:55:35 +01:00
Peer Richelsen cec1c9ee46 feat: granola.ai app (#18585)
* new granola app

* granola app
2025-01-10 14:47:18 +00:00
GitHub Actions 3813fd70f7 chore: release v4.8.16
Post release / release (push) Canceled after 0s
2025-01-10 14:01:39 +00:00
Morgan dabd3b0fc3 chore: improve ooo reason documentation apiv2 (#18582) 2025-01-10 13:20:39 +00:00
Calcom BotandReplexica 95f9a02d29 feat: update translations via @replexica (#18584)
Co-authored-by: Replexica <support@replexica.com>
2025-01-10 13:19:47 +00:00
Somay Chauhan f26d2998cb feat: added pagination to platform managed users table (#18523)
* feat: added pagination to platform managed users table

* removed console

* implemented feedback, removed unused components

* added label

* Update managed-users-view.tsx

* Update managed-users-view.tsx
2025-01-10 12:53:20 +00:00
Somay Chauhan f558f8aca5 fix: broken platform members table (#18583) 2025-01-10 12:46:48 +00:00
Syed Ali ShahbazandPeer Richelsen a6dd5185ea docs: fix footer socials in /help and other broken pages (#18578)
* Update mint.json

* fix broken links

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-01-10 12:50:45 +01:00
Anik Dhabal Babu 3da7694002 fix: flaky e2e test in saml.e2e (#18559)
* chore: skip failure e2e test

* Update .env

* Update manage-booking-questions.e2e.ts

* Update manage-booking-questions.e2e.ts

* Update manage-booking-questions.e2e.ts

* Update manage-booking-questions.e2e.ts

* Update manage-booking-questions.e2e.ts

* Update manage-booking-questions.e2e.ts

* Update .env

* Update manage-booking-questions.e2e.ts

* fix

* fix flake

* Update saml.e2e.ts
2025-01-10 17:17:12 +05:30
Eunjae Lee 1ffb376789 fix: improve filters at /bookings (#18581)
* fix: improve filters at /bookings

* fix type
2025-01-10 11:10:10 +00:00
Anik Dhabal BabuandUdit Takkar 8761b91f71 fix: unpublished org is visble as an option in workflow (#18522)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-01-10 10:49:08 +00:00
Nizzy 1dbef7f8f6 fix: billing plain chat working (#18530) 2025-01-10 10:12:30 +00:00
Anik Dhabal BabuandPeer Richelsen d4d5ac2b28 fix: date filter for the same day showing appointments of the day before (#18579)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-01-10 15:42:24 +05:30
Omar López 88c8ad8b5c chore: fix SVG warning on render (#18572) 2025-01-10 10:00:43 +00:00
Benny Joo 0a08680084 cleanup: remove-headseo-from-migrated-app-router-components (#18576) 2025-01-10 10:35:37 +01:00
Anik Dhabal Babu 65828545b9 fix: wip (#18574) 2025-01-10 00:38:04 -05:00
Omar López 3adb8e84fc chore: Webhook should timeout fast on e2e (#18570) 2025-01-09 20:52:55 -03:00
2a29155641 chore: Upgrade jsforce to 3.6.2 and use jsforce-node (#18363)
* Upgrade jsforce to 3.6.2

* Refactor connecting to Salesforce

* Revert yarn.lock changes

* Migrate callback endpoint

* Add jsforce node dependency

* Migrate add endpoint

* Import

* Import package into crmService

* Use new package types

* Type fix

* Update vite config

* Push updated lockfile

* Attempt to bump platform/libraries to unlock jsforce

* Also update lockfile, naturally

* bump platform libraries

* Update vite.config

* Add jsforce to vite config

* fixup! Merge branch 'main' into upgrade-jsforce-v3

* fixup! fixup! Merge branch 'main' into upgrade-jsforce-v3

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-01-09 20:24:06 +00:00
Steven TeyandPeer Richelsen 4902c6a0f8 feat: bulk shorten links with dub.links.createMany (#18539)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-01-09 20:04:55 +00:00
Joe Au-YeungandUdit Takkar a22fd0c151 chore: Salesforce - optimize getContact method [CAL-4940] (#18289)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-01-09 12:14:49 -07:00
Peer Richelsen b6bd0eda43 Documentation edits made through Mintlify web editor 2025-01-09 19:59:04 +01:00
mintlify[bot]andPeer Richelsen 25d052e8f0 mintlify help edits (#18562)
* Documentation edits made through Mintlify web editor

* Documentation edits made through Mintlify web editor

* Delete help/calendars/how-to-add-apple-calendar.mdx

* Delete help/calendars/how-to-add-google-calendar.mdx

---------

Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-01-09 19:56:22 +01:00
mintlify[bot] f933432d2b Documentation edits made through Mintlify web editor (#18565)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-01-09 19:52:49 +01:00
Benny JooandAnik Dhabal Babu ccf1d4430a chore: clean up some code in app router (#18541)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-01-09 10:47:21 -07:00
2fd8d24204 perf: Improve routing insights view (#18557)
* perf: Improve routing insights view

* fix: remove old join

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-01-09 16:42:26 +00:00
Syed Ali Shahbaz b4c18f7bcc Update README.md (#18524) 2025-01-09 16:33:02 +00:00
sean-brydon 23c93d75ca fix: Handle booker UI busy or skip on timeslot (#18553) 2025-01-09 16:29:42 +00:00
Benny Joo 91526d0bc7 chore: unique key missing for child (#18542) 2025-01-09 16:20:05 +00:00
Anik Dhabal Babu a9f95937c4 chore: temporarily skip a failing E2E test (#18560) 2025-01-09 15:53:12 +00:00
Nizzy 74580157ba fix: added videos and fixed iframe sizes for help page docs (#18558) 2025-01-09 16:37:59 +01:00
mintlify[bot] fcdbcceac0 feat: Mintlify /help edits (#18555)
* Documentation edits made through Mintlify web editor

* Documentation edits made through Mintlify web editor

* Documentation edits made through Mintlify web editor

* Documentation edits made through Mintlify web editor

---------

Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-01-09 15:36:56 +01:00
Peer Richelsen aa7c924c2a Documentation edits made through Mintlify web editor 2025-01-09 13:23:51 +01:00
Peer Richelsen 3406ca72bc Documentation edits made through Mintlify web editor 2025-01-09 13:22:18 +01:00
Peer Richelsen c29a2fc7cb Documentation edits made through Mintlify web editor 2025-01-09 13:20:03 +01:00
Peer Richelsen 0a4c7bcecc Documentation edits made through Mintlify web editor 2025-01-09 13:19:26 +01:00
Peer Richelsen 803dfa008c Documentation edits made through Mintlify web editor 2025-01-09 13:17:24 +01:00
Peer Richelsen 0eb6f1beb6 chore: mintlify updates (#18552) 2025-01-09 13:05:32 +01:00
Peer Richelsen edd2e5e584 chore: fixed help logo (#18551) 2025-01-09 12:57:09 +01:00
mintlify[bot] 99a99b4b7e Documentation edits made through Mintlify web editor (#18543) 2025-01-09 09:45:21 +01:00
Somay ChauhanandRajiv Sahal 2ba6f4cb24 fix: payment not working in booker (#18540)
* fix: payment not working in booker

* Update transformApiEventTypeForAtom.ts

---------

Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2025-01-09 07:32:13 +00:00
Udit TakkarandBailey Pumfleet 8a8c870513 chore: change identifier (#18518)
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2025-01-08 23:39:56 -05:00
Peer Richelsen eba335c903 chore: moved docs into mintlify (#18536) 2025-01-08 23:01:53 +01:00
GitHub Actions 9b2161cc19 chore: release v4.8.15
Post release / release (push) Canceled after 0s
2025-01-08 18:53:07 +00:00
Morgan b997c656df feat: api v2 org ooo crud (#18499)
* feat: api v2 org ooo crud

* chore: add guard isUserOOO on ooo controller

* chore: add checks for ooo

* fixup! chore: add checks for ooo

* added e2e tests

* fixup! added e2e tests
2025-01-08 10:50:58 -05:00
Udit Takkarandsupalarry c9c5493de6 feat: support phone based booking api (#17635)
* chore: save progress

* feat: support phone based booking using API v2

* chore: change name of the variable

* fix: update event types and booking fields for improved validation and new properties

- Updated imports in input and output event types services to use the correct library version.
- Added new `avatarUrl` and `successRedirectUrl` properties to the Swagger documentation for user and booking schemas.
- Enhanced booking fields validation by changing the `required` property to a boolean type.
- Improved descriptions and examples in the OpenAPI specifications for better clarity.
- Refactored booking fields input and output classes to include new properties and ensure proper validation.

* fix: booking with phone number

* test: add event type update test

* test: add test for creating booking

* chore: update unit test

* fix: tests

* fix: add turbo

* chore: remove emial

* refactor: make email optional and move it to service

* fix: test

* chore: bump platform-libraries

* fix: e2e test

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
2025-01-08 21:16:10 +05:30
Anwar Sadath 20a5775f31 fix: Description not rendered as html in select event types screen when installing an app (#18354) 2025-01-08 15:03:34 +00:00
Omar López b9b0a86b32 feat: Add auto payment methods for paid booking on Stripe (#18513) 2025-01-08 07:55:14 -07:00
Abhishek Singh ad93066cf6 feat-insihts-app (#18461) 2025-01-08 14:24:20 +00:00
Benny Joo e6d76b630c chore: Revert "chore: app router - /team, /org, /[user] booking pages (excl. embeds) (#18186) (#18526) 2025-01-08 11:19:45 -03:00
Eunjae Lee b941b716d5 fix: add index to AssignmentReason (#18520) 2025-01-08 12:32:28 +00:00
Eunjae LeeandUdit Takkar 1ab9622416 feat: add DataTableWrapper (#18503)
* feat: data table wrapper

* feat: add DataTableWrapper

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-01-08 09:12:55 +00:00
Benny Joo 15156c2225 perf: use react svg cache from root level (#18506) 2025-01-08 09:29:50 +01:00
Benny JooandPeer Richelsen cc202ed847 chore: app router - /event-types pages (#18181)
* refactor event type web wrapper

* finish migration

* move event-types folder out of future

* remove vars

* remove pages router

* fix

* fix

* fix test

* remove test tag

* refactor

* remove completely

* add withoutSeo prop for event type web page

* remove HeadSeo usage from event types listing view

* make onboarding e2e test faster

* remove useIntercom

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-01-07 20:39:05 -05:00
Joe Au-Yeung 79d51a4989 chore: Add Salesforce tests to getContacts method (#18225) 2025-01-07 14:14:34 -07:00
Calcom BotandReplexica 53a846d10c feat: update translations via @replexica (#18496)
Co-authored-by: Replexica <support@replexica.com>
2025-01-07 20:53:16 +00:00
GitHub Actions 1619f2daf2 chore: release v4.8.14
Post release / release (push) Canceled after 0s
2025-01-07 20:06:08 +00:00
Alex van Andel 57bd0b3df3 fix: type + build errors (#18502) 2025-01-07 18:35:06 +01:00
Udit Takkar e359d13d9a Revert "fix: improve useInViewObserver hook to trigger when observer is alrea…" (#18504)
This reverts commit 22f92f1ee3.
2025-01-07 16:41:55 +00:00
Hariom Balhara 3b7cb542ed fix checklyt tests (#18501) 2025-01-07 12:58:39 +00:00
Alex van Andel fd7f6c32ae chore: Remove all instances of useIntercom (#18500) 2025-01-07 07:43:46 -05:00
Hariom Balhara 06d8b98b15 fix: 404 on the public form page for a sub-team form when the owner itself was removed from the org (#18495)
* wip

* fix: 404 for a form of a member that has been removed.

- Moved existing authorization check to isAuthorizedToViewFormOnOrgDomain function to handle form access control
- Add comprehensive test suite for org domain authorization scenarios
- Refactor getServerSideProps to use the new authorization function
- Improve code organization by separating domain-specific authorization logic

Test Coverage:
- Non-org domain access
- Org member's form access
- Sub-team form access within org
- Access denial for non-org members/teams
2025-01-07 12:13:19 +00:00
Benny Joo 0c69c5dfea remove MainLayout / MainLayoutAppDir (#18498) 2025-01-07 12:13:00 +00:00
Benny Joo 00f7d8be0d feat: bookings - render layout only once (#18490) 2025-01-07 11:37:17 +00:00
Hariom Balhara 2e9beb8513 fix: Make it easier to manage group options and also improve attribute options performance with 100s of options (#18486) 2025-01-07 11:16:52 +00:00
Omar López b6c6fa7365 Update repository.ts (#18482) 2025-01-07 11:10:52 +00:00
Benny Joo c623785ec2 chore: remove unused code from SideBar (#18487) 2025-01-07 10:56:09 +00:00
Alex van Andel 6a381cdc79 perf: Remove zod from edge runtime (#18494) 2025-01-07 10:47:48 +00:00
Benny Joo 9d4496133e feat: Prevent re-renders (moving SessionProvider to Root level for both app router / pages router) (#18493) 2025-01-07 11:46:08 +01:00
Benny Joo 1452672495 withEmbedSsr is missing in a embed route (#18483) 2025-01-07 07:28:55 -03:00
Lauris Skraucis 9159c591ff fix: v2 slots timeZone parameter (#18488)
* fix: v2 slots timeZone parameter

* test: v2 slots
2025-01-07 15:24:20 +05:30
Benny Joo 408aee5851 insights layout (#18489) 2025-01-07 09:02:52 +00:00
Vishnu 29bbada77a fix: cancel workflows on cancel booking (#17871)
* feat: cancel scheduled email from sendGrid while cancelling the booking

* fix: remove deleteScheduledSend in scheduledEmailReminder cron api

* fix: use cancelScheduledEmailSend

* chore: remove cancelling email workflow in deleteScheduledEmailReminder

* refactor: bring back deleteScheduleSend

* fix: get only past reminders
2025-01-07 00:06:57 -05:00
PraashandHariom 6178092878 fix: Cannot read properties of undefined (reading 'timeZone') (#18107)
Co-authored-by: Hariom <hariombalhara@gmail.com>
2025-01-07 04:17:17 +00:00
Hariom BalharaandOmar López 6f531d0be5 feat: Single domain setup when only one org in the system (#18383)
Co-authored-by: Omar López <zomars@me.com>
2025-01-07 09:28:44 +05:30
475 changed files with 11931 additions and 3419 deletions
+6 -2
View File
@@ -355,8 +355,6 @@ E2E_TEST_OIDC_USER_PASSWORD=
# provide a value between 0 and 100 to ensure the percentage of traffic
# redirected from the legacy to the future pages
AB_TEST_BUCKET_PROBABILITY=50
# whether we redirect to the future/event-types from event-types or not
APP_ROUTER_EVENT_TYPES_ENABLED=0
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
APP_ROUTER_APPS_SLUG_ENABLED=0
APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
@@ -365,6 +363,7 @@ APP_ROUTER_APPS_CATEGORIES_ENABLED=0
# whether we redirect to the future/apps/categories/[category] from /apps/categories/[category] or not
APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=0
APP_ROUTER_APPS_ENABLED=0
APP_ROUTER_TEAM_ENABLED=0
APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED=0
APP_ROUTER_AUTH_LOGIN_ENABLED=0
APP_ROUTER_AUTH_LOGOUT_ENABLED=0
@@ -415,3 +414,8 @@ REPLEXICA_API_KEY=
# Comma-separated list of DSyncData.directoryId to log SCIM API requests for. It can be enabled temporarily for debugging the requests being sent to SCIM server.
DIRECTORY_IDS_TO_LOG=
# Set this when Cal.com is used to serve only one organization's booking pages
# Read more about it in the README.md
NEXT_PUBLIC_SINGLE_ORG_SLUG=
+2 -12
View File
@@ -12,16 +12,6 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- name: Run Linting with Reports
run: yarn lint:report
- name: Run Lint
run: yarn lint
continue-on-error: true
- name: Merge lint reports
run: jq -s '[.[]]|flatten' lint-results/*.json &> lint-results/eslint_report.json
- name: Upload ESLint report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: lint-results
path: lint-results
+6
View File
@@ -0,0 +1,6 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
+6
View File
@@ -0,0 +1,6 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+7
View File
@@ -0,0 +1,7 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+9
View File
@@ -485,6 +485,15 @@ Don't code but still want to contribute? Join our [Discussions](https://github.c
- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported.
## Single Org Mode
If you want to have booker.yourcompany.com to be the domain used for both dashboard(e.g. https://booker.yourcompany.com/event-types) and booking pages(e.g. https://booker.yourcompany.com/john.joe/15min).
- Set the `NEXT_PUBLIC_SINGLE_ORG_SLUG` environment variable to the slug of the organization you want to use. `NEXT_PUBLIC_SINGLE_ORG_SLUG=booker`
- Set the `NEXT_PUBLIC_WEBAPP_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBAPP_URL=https://booker.yourcompany.com`.
- Set the `NEXT_PUBLIC_WEBSITE_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBSITE_URL=https://booker.yourcompany.com`.
- Set the `NEXTAUTH_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXTAUTH_URL=https://booker.yourcompany.com`.
Note: It causes root to serve the dashboard and not the organization profile page which shows all bookable users in the organization.
## Integrations
### Obtaining the Google API Credentials
+5 -5
View File
@@ -11,15 +11,15 @@ test.describe("Org", () => {
await expectPageToBeRenderedWithEmbedSsr(page);
});
test("Org User(Peer) Page should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/peer/embed");
test("Org User(Rick) Page should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/team-rick/embed");
expect(response?.status()).toBe(200);
await expect(page.locator("text=Peer Richelsen")).toBeVisible();
await expect(page.locator("text=Rick in i.cal.com")).toBeVisible();
await expectPageToBeRenderedWithEmbedSsr(page);
});
test("Org User Event(peer/meet) Page should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/peer/meet/embed");
test("Org User Event(/team-rick/test-event) Page should be embeddable", async ({ page }) => {
const response = await page.goto("https://i.cal.com/team-rick/test-event/embed");
expect(response?.status()).toBe(200);
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
+2 -1
View File
@@ -29,7 +29,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.79",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.85",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -59,6 +59,7 @@
"googleapis": "^84.0.0",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"jsforce": "^1.11.0",
"luxon": "^3.4.4",
"nest-winston": "^1.9.4",
"next-auth": "^4.22.1",
@@ -59,6 +59,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
let team1EventTypeId: number;
let team2EventTypeId: number;
let phoneOnlyEventTypeId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
@@ -205,6 +206,83 @@ describe("Bookings Endpoints 2024-08-13", () => {
team1EventTypeId = team1EventType.id;
const phoneOnlyEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team1.id },
},
title: "Phone Only Event Type",
slug: "phone-only-event-type",
length: 15,
assignAllTeamMembers: false,
hosts: {
connectOrCreate: [
{
where: {
userId_eventTypeId: {
userId: teamUser.id,
eventTypeId: team1EventTypeId,
},
},
create: {
userId: teamUser.id,
isFixed: true,
},
},
],
},
bookingFields: [
{
name: "name",
type: "name",
label: "your name",
sources: [{ id: "default", type: "default", label: "Default" }],
variant: "fullName",
editable: "system",
required: true,
defaultLabel: "your_name",
variantsConfig: {
variants: {
fullName: {
fields: [{ name: "fullName", type: "text", label: "your name", required: true }],
},
},
},
},
{
name: "email",
type: "email",
label: "your email",
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system",
required: false,
defaultLabel: "email_address",
},
{
name: "attendeePhoneNumber",
type: "phone",
label: "phone_number",
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
editable: "user",
required: true,
placeholder: "",
},
{
name: "rescheduleReason",
type: "textarea",
views: [{ id: "reschedule", label: "Reschedule View" }],
sources: [{ id: "default", type: "default", label: "Default" }],
editable: "system-but-optional",
required: false,
defaultLabel: "reason_for_reschedule",
defaultPlaceholder: "reschedule_placeholder",
},
],
locations: [],
});
phoneOnlyEventTypeId = phoneOnlyEventType.id;
const team2EventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
@@ -322,6 +400,60 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
it("should create a phone based booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
eventTypeId: phoneOnlyEventTypeId,
attendee: {
name: "alice",
phoneNumber: "+919876543210",
timeZone: "Europe/Madrid",
language: "es",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(teamUser.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 15, 15, 0)).toISOString());
expect(data.duration).toEqual(15);
expect(data.eventTypeId).toEqual(phoneOnlyEventTypeId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: "919876543210@sms.cal.com",
phoneNumber: body.attendee.phoneNumber,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should create a team 2 booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
@@ -398,7 +530,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(data.length).toEqual(2);
expect(data[0].eventTypeId).toEqual(team1EventTypeId);
});
});
@@ -436,7 +568,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data.length).toEqual(3);
expect(data.find((booking) => booking.eventTypeId === team1EventTypeId)).toBeDefined();
expect(data.find((booking) => booking.eventTypeId === team2EventTypeId)).toBeDefined();
});
@@ -332,6 +332,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
metadata: {
userId: "100",
},
guests: ["bob@gmail.com"],
};
const beforeCreate = new Date();
@@ -376,7 +377,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: body.attendee.name,
email: body.attendee.email,
...body.bookingFieldsResponses,
guests: body.guests,
});
expect(data.guests).toEqual(body.guests);
// Check createdAt date is between the time of the request and after the request
const createdAtDate = new Date(data.createdAt);
@@ -13,7 +13,6 @@ import { Request } from "express";
import { z } from "zod";
import {
handleNewBooking,
handleNewRecurringBooking,
getAllUserBookings,
handleInstantMeeting,
@@ -23,6 +22,7 @@ import {
handleMarkNoShow,
confirmBookingHandler,
} from "@calcom/platform-libraries";
import { handleNewBooking } from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
CreateBookingInput,
@@ -146,6 +146,8 @@ export class InputBookingsService_2024_08_13 {
);
const endTime = startTime.plus({ minutes: lengthInMinutes });
const guests = inputBooking.guests;
return {
start: startTime.toISO(),
end: endTime.toISO(),
@@ -154,15 +156,22 @@ export class InputBookingsService_2024_08_13 {
language: inputBooking.attendee.language || "en",
metadata: inputBooking.metadata || {},
hasHashedBookingLink: false,
guests: inputBooking.guests,
guests,
// note(Lauris): responses with name and email are required by the handleNewBooking
responses: inputBooking.bookingFieldsResponses
? {
...inputBooking.bookingFieldsResponses,
name: inputBooking.attendee.name,
email: inputBooking.attendee.email,
email: inputBooking.attendee.email ?? "",
attendeePhoneNumber: inputBooking.attendee.phoneNumber,
guests,
}
: { name: inputBooking.attendee.name, email: inputBooking.attendee.email },
: {
name: inputBooking.attendee.name,
email: inputBooking.attendee.email ?? "",
attendeePhoneNumber: inputBooking.attendee.phoneNumber,
guests,
},
};
}
@@ -246,6 +255,8 @@ export class InputBookingsService_2024_08_13 {
inputBooking.attendee.timeZone
);
const guests = inputBooking.guests;
for (let i = 0; i < repeatsTimes; i++) {
const endTime = startTime.plus({ minutes: eventType.length });
@@ -258,15 +269,16 @@ export class InputBookingsService_2024_08_13 {
language: inputBooking.attendee.language || "en",
metadata: inputBooking.metadata || {},
hasHashedBookingLink: false,
guests: inputBooking.guests,
guests,
// note(Lauris): responses with name and email are required by the handleNewBooking
responses: inputBooking.bookingFieldsResponses
? {
...inputBooking.bookingFieldsResponses,
name: inputBooking.attendee.name,
email: inputBooking.attendee.email,
guests,
}
: { name: inputBooking.attendee.name, email: inputBooking.attendee.email },
: { name: inputBooking.attendee.name, email: inputBooking.attendee.email, guests },
schedulingType: eventType.schedulingType,
});
@@ -72,6 +72,7 @@ type DatabaseBooking = Booking & {
email: string;
timeZone: string;
locale: string | null;
phoneNumber?: string | null;
noShow: boolean | null;
bookingSeat?: BookingSeat | null;
}[];
@@ -121,6 +122,7 @@ export class OutputBookingsService_2024_08_13 {
timeZone: attendee.timeZone,
language: attendee.locale,
absent: !!attendee.noShow,
phoneNumber: attendee.phoneNumber ?? undefined,
})),
guests: bookingResponses.guests,
location,
@@ -123,7 +123,6 @@ describe("Event types Endpoints", () => {
slug: "location",
required: false,
hidden: false,
disableOnPrefill: false,
};
const defaultResponseBookingFieldTitle = {
@@ -452,9 +451,6 @@ describe("Event types Endpoints", () => {
{ ...defaultResponseBookingFieldName, ...nameBookingField },
{ ...defaultResponseBookingFieldEmail },
{ ...defaultResponseBookingFieldLocation },
{ ...defaultResponseBookingFieldTitle },
{ ...defaultResponseBookingFieldGuests },
{ ...defaultResponseBookingFieldRescheduleReason },
{
type: "select",
label: "select which language you want to learn",
@@ -466,7 +462,10 @@ describe("Event types Endpoints", () => {
hidden: false,
isDefault: false,
},
{ ...defaultResponseBookingFieldTitle },
{ ...defaultResponseBookingFieldNotes },
{ ...defaultResponseBookingFieldGuests },
{ ...defaultResponseBookingFieldRescheduleReason },
];
expect(createdEventType.bookingFields).toEqual(expectedBookingFields);
@@ -942,9 +941,6 @@ describe("Event types Endpoints", () => {
{ ...defaultResponseBookingFieldName, ...nameBookingField },
{ ...defaultResponseBookingFieldEmail },
{ ...defaultResponseBookingFieldLocation },
{ ...defaultResponseBookingFieldTitle },
{ ...defaultResponseBookingFieldGuests },
{ ...defaultResponseBookingFieldRescheduleReason },
{
type: "select",
label: "select which language you want to learn",
@@ -956,7 +952,10 @@ describe("Event types Endpoints", () => {
hidden: false,
isDefault: false,
},
{ ...defaultResponseBookingFieldTitle },
{ ...defaultResponseBookingFieldNotes, ...notesBookingField },
{ ...defaultResponseBookingFieldGuests },
{ ...defaultResponseBookingFieldRescheduleReason },
];
expect(updatedEventType.bookingFields).toEqual(expectedBookingFields);
@@ -1163,10 +1162,7 @@ describe("Event types Endpoints", () => {
type: "radioInput",
slug: "location",
required: false,
disableOnPrefill: false,
hidden: false,
label: "",
placeholder: "",
},
{ isDefault: true, required: true, slug: "title", type: "text", disableOnPrefill: false, hidden: true },
{
@@ -1185,7 +1181,14 @@ describe("Event types Endpoints", () => {
disableOnPrefill: false,
hidden: false,
},
{ isDefault: true, type: "phone", slug: "attendeePhoneNumber", required: false, hidden: true },
{
disableOnPrefill: false,
isDefault: true,
type: "phone",
slug: "attendeePhoneNumber",
required: false,
hidden: true,
},
{
isDefault: true,
required: false,
@@ -1610,7 +1613,6 @@ describe("Event types Endpoints", () => {
type: "radioInput",
slug: "location",
required: false,
disableOnPrefill: false,
hidden: false,
},
{
@@ -1621,6 +1623,14 @@ describe("Event types Endpoints", () => {
disableOnPrefill: false,
hidden: true,
},
{
isDefault: true,
type: "textarea",
slug: "notes",
required: false,
disableOnPrefill: false,
hidden: false,
},
{
isDefault: true,
type: "multiemail",
@@ -1637,14 +1647,6 @@ describe("Event types Endpoints", () => {
disableOnPrefill: false,
hidden: false,
},
{
isDefault: true,
type: "textarea",
slug: "notes",
required: false,
disableOnPrefill: false,
hidden: false,
},
]);
});
});
@@ -128,6 +128,7 @@ export class OutputEventTypesService_2024_06_14 {
const bookingFields = databaseEventType.bookingFields
? this.transformBookingFields(databaseEventType.bookingFields)
: this.getDefaultBookingFields(isOrgTeamEvent);
const recurrence = this.transformRecurringEvent(databaseEventType.recurringEvent);
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
const users = this.transformUsers(databaseEventType.users || []);
@@ -242,19 +243,7 @@ export class OutputEventTypesService_2024_06_14 {
}
}
const fields = [...transformBookingFieldsInternalToApi(knownBookingFields), ...unknownBookingFields];
// ensure additional notes are always at the end
return fields.sort((a, b) => {
if (!a?.slug || !b?.slug) return 0;
if (a.slug === "notes" && b.slug !== "notes") {
return 1;
}
if (b.slug === "notes" && a.slug !== "notes") {
return -1;
}
return 0;
});
return [...transformBookingFieldsInternalToApi(knownBookingFields), ...unknownBookingFields];
}
getDefaultBookingFields(isOrgTeamEvent: boolean) {
@@ -0,0 +1,30 @@
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
@Injectable()
export class IsUserOOO implements CanActivate {
constructor(private oooRepo: UserOOORepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const oooId: string = request.params.oooId;
const userId: string = request.params.userId;
if (!userId) {
throw new ForbiddenException("No user id found in request params.");
}
if (!oooId) {
throw new ForbiddenException("No ooo entry id found in request params.");
}
const ooo = await this.oooRepo.getUserOOOByIdAndUserId(Number(oooId), Number(userId));
if (ooo) {
return true;
}
throw new ForbiddenException("This OOO entry does not belong to this user.");
}
}
@@ -0,0 +1,128 @@
import { BadRequestException } from "@nestjs/common";
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsDate, IsInt, IsOptional, IsString, IsEnum, isDate } from "class-validator";
import { SkipTakePagination } from "@calcom/platform-types";
export enum OutOfOfficeReason {
UNSPECIFIED = "unspecified",
VACATION = "vacation",
TRAVEL = "travel",
SICK_LEAVE = "sick",
PUBLIC_HOLIDAY = "public_holiday",
}
export type OutOfOfficeReasonType = `${OutOfOfficeReason}`;
const isDateString = (dateString: string) => {
try {
const isoDateRegex = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{3})?Z$/;
return isoDateRegex.test(dateString);
} catch {
throw new BadRequestException("Invalid Date.");
}
};
export class CreateOutOfOfficeEntryDto {
@Transform(({ value }: { value: string }) => {
if (isDateString(value)) {
const date = new Date(value);
date.setUTCHours(0, 0, 0, 0);
return date;
}
throw new BadRequestException("Invalid Date.");
})
@IsDate()
@ApiProperty({
description: "The start date and time of the out of office period in ISO 8601 format in UTC timezone.",
example: "2023-05-01T00:00:00.000Z",
})
start!: Date;
@Transform(({ value }: { value: string }) => {
if (isDateString(value)) {
const date = new Date(value);
date.setUTCHours(23, 59, 59, 999);
return date;
}
throw new BadRequestException("Invalid Date.");
})
@IsDate()
@ApiProperty({
description: "The end date and time of the out of office period in ISO 8601 format in UTC timezone.",
example: "2023-05-10T23:59:59.999Z",
})
end!: Date;
@IsString()
@IsOptional()
@ApiPropertyOptional({
description: "Optional notes for the out of office entry.",
example: "Vacation in Hawaii",
})
notes?: string;
@IsInt()
@IsOptional()
@ApiPropertyOptional({
description: "The ID of the user covering for the out of office period, if applicable.",
example: 2,
})
toUserId?: number;
@IsEnum(OutOfOfficeReason)
@IsOptional()
@ApiPropertyOptional({
description: "the reason for the out of office entry, if applicable",
example: "vacation",
enum: OutOfOfficeReason,
})
reason?: OutOfOfficeReasonType;
}
export class UpdateOutOfOfficeEntryDto extends PartialType(CreateOutOfOfficeEntryDto) {}
export enum SortOrder {
asc = "asc",
desc = "desc",
}
type SortOrderType = keyof typeof SortOrder;
export class GetOutOfOfficeEntryFiltersDTO extends SkipTakePagination {
@IsOptional()
@IsEnum(SortOrder, {
message: 'SortStart must be either "asc" or "desc".',
})
@ApiProperty({
required: false,
description: "Sort results by their start time in ascending or descending order.",
example: "?sortStart=asc OR ?sortStart=desc",
enum: SortOrder,
})
sortStart?: SortOrderType;
@IsOptional()
@IsEnum(SortOrder, {
message: 'SortEnd must be either "asc" or "desc".',
})
@ApiProperty({
required: false,
description: "Sort results by their end time in ascending or descending order.",
example: "?sortEnd=asc OR ?sortEnd=desc",
enum: SortOrder,
})
sortEnd?: SortOrderType;
}
export class GetOrgUsersOutOfOfficeEntryFiltersDTO extends GetOutOfOfficeEntryFiltersDTO {
@IsString()
@IsOptional()
@ApiProperty({
type: String,
required: false,
description: "Filter ooo entries by the user email address. user must be within your organization.",
example: "example@domain.com",
})
email?: string;
}
@@ -0,0 +1,100 @@
import { OutOfOfficeReason } from "@/modules/ooo/inputs/ooo.input";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsInt, IsEnum, ValidateNested, IsString, IsDateString, IsOptional } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class UserOooOutputDto {
@IsInt()
@Expose()
@ApiProperty({
description: "The ID of the user.",
example: 2,
})
readonly userId!: number;
@IsInt()
@IsOptional()
@ApiPropertyOptional({
description: "The ID of the user covering for the out of office period, if applicable.",
example: 2,
})
@Expose()
readonly toUserId?: number;
@IsInt()
@Expose()
@ApiProperty({
description: "The ID of the ooo entry.",
example: 2,
})
readonly id!: number;
@IsString()
@Expose()
@ApiProperty({
description: "The UUID of the ooo entry.",
example: 2,
})
readonly uuid!: string;
@IsDateString()
@ApiProperty({
description: "The start date and time of the out of office period in ISO 8601 format in UTC timezone.",
example: "2023-05-01T00:00:00.000Z",
})
@Expose()
start!: Date;
@IsDateString()
@ApiProperty({
description: "The end date and time of the out of office period in ISO 8601 format in UTC timezone.",
example: "2023-05-10T23:59:59.999Z",
})
@Expose()
end!: Date;
@IsString()
@IsOptional()
@ApiPropertyOptional({
description: "Optional notes for the out of office entry.",
example: "Vacation in Hawaii",
})
@Expose()
notes?: string;
@IsEnum(OutOfOfficeReason)
@IsOptional()
@ApiPropertyOptional({
description: "the reason for the out of office entry, if applicable",
example: "vacation",
enum: OutOfOfficeReason,
})
@Expose()
reason?: OutOfOfficeReason;
}
export class UserOooOutputResponseDto {
@Expose()
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => UserOooOutputDto)
data!: UserOooOutputDto;
}
export class UserOoosOutputResponseDto {
@Expose()
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => UserOooOutputDto)
data!: UserOooOutputDto[];
}
@@ -0,0 +1,107 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
import { v4 as uuidv4 } from "uuid";
import { Prisma } from "@calcom/prisma/client";
import { PrismaWriteService } from "../../prisma/prisma-write.service";
type OOOInputData = Omit<Prisma.OutOfOfficeEntryCreateInput, "user" | "toUser" | "reason" | "uuid"> & {
toUserId?: number;
userId: number;
reasonId?: number;
};
@Injectable()
export class UserOOORepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createUserOOO(data: OOOInputData) {
const uuid = uuidv4();
return this.dbWrite.prisma.outOfOfficeEntry.create({
data: { ...data, uuid },
include: { reason: true },
});
}
async updateUserOOO(oooId: number, data: Partial<OOOInputData>) {
return this.dbWrite.prisma.outOfOfficeEntry.update({
where: { id: oooId },
data,
include: { reason: true },
});
}
async getUserOOOById(oooId: number) {
return this.dbRead.prisma.outOfOfficeEntry.findFirst({
where: { id: oooId },
include: { reason: true },
});
}
async getUserOOOByIdAndUserId(oooId: number, userId: number) {
return this.dbRead.prisma.outOfOfficeEntry.findFirst({
where: { id: oooId, userId },
include: { reason: true },
});
}
async getUserOOOPaginated(
userId: number,
skip: number,
take: number,
sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" }
) {
return this.dbRead.prisma.outOfOfficeEntry.findMany({
where: { userId },
skip,
take,
include: { reason: true },
...(sort?.sortStart && { orderBy: { start: sort.sortStart } }),
...(sort?.sortEnd && { orderBy: { end: sort.sortEnd } }),
});
}
async deleteUserOOO(oooId: number) {
return this.dbWrite.prisma.outOfOfficeEntry.delete({
where: { id: oooId },
include: { reason: true },
});
}
async findExistingOooRedirect(userId: number, start: Date, end: Date, toUserId?: number) {
const existingOutOfOfficeEntry = await this.dbRead.prisma.outOfOfficeEntry.findFirst({
select: {
userId: true,
toUserId: true,
},
where: {
...(toUserId && { userId: toUserId }),
toUserId: userId,
// Check for time overlap or collision
OR: [
// Outside of range
{
AND: [{ start: { lte: end } }, { end: { gte: start } }],
},
// Inside of range
{
AND: [{ start: { gte: start } }, { end: { lte: end } }],
},
],
},
});
return existingOutOfOfficeEntry;
}
async getOooByUserIdAndTime(userId: number, start: Date, end: Date) {
return await this.dbRead.prisma.outOfOfficeEntry.findFirst({
where: {
userId,
start,
end,
},
});
}
}
@@ -0,0 +1,142 @@
import {
CreateOutOfOfficeEntryDto,
UpdateOutOfOfficeEntryDto,
OutOfOfficeReason,
GetOutOfOfficeEntryFiltersDTO,
SortOrder,
} from "@/modules/ooo/inputs/ooo.input";
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
import { OutOfOfficeEntry } from "@calcom/prisma/client";
const OOO_REASON_ID_TO_REASON = {
1: OutOfOfficeReason["UNSPECIFIED"],
2: OutOfOfficeReason["VACATION"],
3: OutOfOfficeReason["TRAVEL"],
4: OutOfOfficeReason["SICK_LEAVE"],
5: OutOfOfficeReason["PUBLIC_HOLIDAY"],
};
const OOO_REASON_TO_REASON_ID = {
[OutOfOfficeReason["UNSPECIFIED"]]: 1,
[OutOfOfficeReason["VACATION"]]: 2,
[OutOfOfficeReason["TRAVEL"]]: 3,
[OutOfOfficeReason["SICK_LEAVE"]]: 4,
[OutOfOfficeReason["PUBLIC_HOLIDAY"]]: 5,
};
@Injectable()
export class UserOOOService {
constructor(
private readonly oooRepository: UserOOORepository,
private readonly usersRepository: UsersRepository
) {}
formatOooReason(ooo: OutOfOfficeEntry) {
return {
...ooo,
reason: ooo.reasonId
? OOO_REASON_ID_TO_REASON[ooo.reasonId as keyof typeof OOO_REASON_ID_TO_REASON]
: OOO_REASON_ID_TO_REASON[1],
};
}
isStartBeforeEnd(start?: Date, end?: Date) {
if ((end && !start) || (start && !end)) {
throw new BadRequestException("Please specify both ooo start and end time.");
}
if (start && end) {
if (start.getTime() > end.getTime()) {
throw new BadRequestException("Start date must be before end date.");
}
}
return true;
}
async checkUserEligibleForRedirect(userId: number, toUserId?: number) {
if (toUserId) {
const user = await this.usersRepository.findUserOOORedirectEligible(userId, toUserId);
if (!user) {
throw new BadRequestException("Cannot redirect to this user.");
}
}
}
async checkExistingOooRedirect(userId: number, start?: Date, end?: Date, toUserId?: number) {
if (start && end) {
const existingOooRedirect = await this.oooRepository.findExistingOooRedirect(
userId,
start,
end,
toUserId
);
if (existingOooRedirect) {
throw new BadRequestException("Booking redirect infinite not allowed.");
}
}
}
async checkDuplicateOOOEntry(userId: number, start?: Date, end?: Date) {
if (start && end) {
const duplicateEntry = await this.oooRepository.getOooByUserIdAndTime(userId, start, end);
if (duplicateEntry) {
throw new ConflictException("Ooo entry already exists.");
}
}
}
checkRedirectToSelf(userId: number, toUserId?: number) {
if (toUserId && toUserId === userId) {
throw new BadRequestException("Cannot redirect to self.");
}
}
async checkIsValidOOO(userId: number, ooo: CreateOutOfOfficeEntryDto | UpdateOutOfOfficeEntryDto) {
this.isStartBeforeEnd(ooo.start, ooo.end);
await this.checkExistingOooRedirect(userId, ooo.start, ooo.end, ooo.toUserId);
await this.checkDuplicateOOOEntry(userId, ooo.start, ooo.end);
await this.checkRedirectToSelf(userId, ooo.toUserId);
await this.checkUserEligibleForRedirect(userId, ooo.toUserId);
}
async createUserOOO(userId: number, body: CreateOutOfOfficeEntryDto) {
await this.checkIsValidOOO(userId, body);
const { reason, ...rest } = body;
const ooo = await this.oooRepository.createUserOOO({
...rest,
userId,
reasonId: OOO_REASON_TO_REASON_ID[reason ?? OutOfOfficeReason["UNSPECIFIED"]],
});
return this.formatOooReason(ooo);
}
async updateUserOOO(userId: number, oooId: number, body: UpdateOutOfOfficeEntryDto) {
await this.checkIsValidOOO(userId, body);
const { reason, ...rest } = body;
const data = reason
? { ...rest, reasonId: OOO_REASON_TO_REASON_ID[reason ?? OutOfOfficeReason["UNSPECIFIED"]] }
: rest;
const ooo = await this.oooRepository.updateUserOOO(oooId, data);
return this.formatOooReason(ooo);
}
async deleteUserOOO(oooId: number) {
const ooo = await this.oooRepository.deleteUserOOO(oooId);
return this.formatOooReason(ooo);
}
async getUserOOOPaginated(
userId: number,
skip: number,
take: number,
sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" }
) {
const ooos = await this.oooRepository.getUserOOOPaginated(userId, skip, take, sort);
return ooos.map((ooo) => this.formatOooReason(ooo));
}
}
@@ -577,6 +577,100 @@ describe("Organizations Event Types Endpoints", () => {
});
});
it("should be able to configure phone-only event type", async () => {
const body: UpdateTeamEventTypeInput_2024_06_14 = {
bookingFields: [
{
type: "email",
required: false,
label: "Email",
},
{
type: "phone",
slug: "attendeePhoneNumber",
required: true,
label: "Phone number",
hidden: true,
},
],
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${collectiveEventType.id}`)
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.bookingFields).toEqual([
{
isDefault: true,
type: "name",
slug: "name",
required: true,
disableOnPrefill: false,
},
{
isDefault: true,
type: "email",
slug: "email",
required: false,
label: "Email",
disableOnPrefill: false,
},
{
isDefault: true,
type: "radioInput",
slug: "location",
required: false,
hidden: false,
},
{
isDefault: true,
type: "phone",
slug: "attendeePhoneNumber",
required: true,
hidden: true,
label: "Phone number",
disableOnPrefill: false,
},
{
isDefault: true,
type: "text",
slug: "title",
required: true,
disableOnPrefill: false,
hidden: true,
},
{
isDefault: true,
type: "textarea",
slug: "notes",
required: false,
disableOnPrefill: false,
hidden: false,
},
{
isDefault: true,
type: "multiemail",
slug: "guests",
required: false,
disableOnPrefill: false,
hidden: false,
},
{
isDefault: true,
type: "textarea",
slug: "rescheduleReason",
required: false,
disableOnPrefill: false,
hidden: false,
},
]);
});
});
it("should assign all members to managed event-type", async () => {
const body: UpdateTeamEventTypeInput_2024_06_14 = {
assignAllTeamMembers: true,
@@ -679,13 +773,19 @@ describe("Organizations Event Types Endpoints", () => {
expect(fetchedEventType.bookingFields).toEqual([
{ isDefault: true, required: true, slug: "name", type: "name", disableOnPrefill: false },
{ isDefault: true, required: true, slug: "email", type: "email", disableOnPrefill: false },
{ isDefault: true, type: "phone", slug: "attendeePhoneNumber", required: false, hidden: true },
{
disableOnPrefill: false,
isDefault: true,
type: "phone",
slug: "attendeePhoneNumber",
required: false,
hidden: true,
},
{
isDefault: true,
type: "radioInput",
slug: "location",
required: false,
disableOnPrefill: false,
hidden: false,
},
{
@@ -696,6 +796,14 @@ describe("Organizations Event Types Endpoints", () => {
disableOnPrefill: false,
hidden: true,
},
{
isDefault: true,
required: false,
slug: "notes",
type: "textarea",
disableOnPrefill: false,
hidden: false,
},
{
isDefault: true,
required: false,
@@ -712,14 +820,6 @@ describe("Organizations Event Types Endpoints", () => {
disableOnPrefill: false,
hidden: false,
},
{
isDefault: true,
required: false,
slug: "notes",
type: "textarea",
disableOnPrefill: false,
hidden: false,
},
]);
});
});
@@ -0,0 +1,142 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
import { IsUserOOO } from "@/modules/ooo/guards/is-user-ooo";
import {
CreateOutOfOfficeEntryDto,
UpdateOutOfOfficeEntryDto,
GetOutOfOfficeEntryFiltersDTO,
GetOrgUsersOutOfOfficeEntryFiltersDTO,
} from "@/modules/ooo/inputs/ooo.input";
import {
UserOooOutputDto,
UserOooOutputResponseDto,
UserOoosOutputResponseDto,
} from "@/modules/ooo/outputs/ooo.output";
import { UserOOOService } from "@/modules/ooo/services/ooo.service";
import { OrgUsersOOOService } from "@/modules/organizations/controllers/users/ooo/services/organization-users-ooo.service";
import {
Controller,
UseGuards,
Get,
Post,
Patch,
Delete,
Param,
ParseIntPipe,
Body,
UseInterceptors,
Query,
} from "@nestjs/common";
import { ClassSerializerInterceptor } from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToInstance } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@Controller({
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@UseInterceptors(ClassSerializerInterceptor)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@UseGuards(IsOrgGuard)
@DocsTags("Orgs / Users / OOO")
export class OrganizationsUsersOOOController {
constructor(
private readonly userOOOService: UserOOOService,
private readonly orgUsersOOOService: OrgUsersOOOService
) {}
@Get("/users/:userId/ooo")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@ApiOperation({ summary: "Get all ooo entries of a user" })
async getOrganizationUserOOO(
@Param("userId", ParseIntPipe) userId: number,
@Query() query: GetOutOfOfficeEntryFiltersDTO
): Promise<UserOoosOutputResponseDto> {
const { skip, take, ...rest } = query ?? { skip: 0, take: 250 };
const ooos = await this.userOOOService.getUserOOOPaginated(userId, skip ?? 0, take ?? 250, rest);
return {
status: SUCCESS_STATUS,
data: ooos.map((ooo) => plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" })),
};
}
@Post("/users/:userId/ooo")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg)
@ApiOperation({ summary: "Create an ooo entry for user" })
async createOrganizationUserOOO(
@Param("userId", ParseIntPipe) userId: number,
@Body() input: CreateOutOfOfficeEntryDto
): Promise<UserOooOutputResponseDto> {
const ooo = await this.userOOOService.createUserOOO(userId, input);
return {
status: SUCCESS_STATUS,
data: plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" }),
};
}
@Patch("/users/:userId/ooo/:oooId")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg, IsUserOOO)
@ApiOperation({ summary: "Update ooo entry of a user" })
async updateOrganizationUserOOO(
@Param("userId", ParseIntPipe) userId: number,
@Param("oooId", ParseIntPipe) oooId: number,
@Body() input: UpdateOutOfOfficeEntryDto
): Promise<UserOooOutputResponseDto> {
const ooo = await this.userOOOService.updateUserOOO(userId, oooId, input);
return {
status: SUCCESS_STATUS,
data: plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" }),
};
}
@Delete("/users/:userId/ooo/:oooId")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrg, IsUserOOO)
@ApiOperation({ summary: "Delete ooo entry of a user" })
async deleteOrganizationUserOOO(
@Param("oooId", ParseIntPipe) oooId: number
): Promise<UserOooOutputResponseDto> {
const ooo = await this.userOOOService.deleteUserOOO(oooId);
return {
status: SUCCESS_STATUS,
data: plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" }),
};
}
@Get("/ooo")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@ApiOperation({ summary: "Get all OOO entries of org users" })
async getOrganizationUsersOOO(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() query: GetOrgUsersOutOfOfficeEntryFiltersDTO
): Promise<UserOoosOutputResponseDto> {
const { skip, take, email, ...rest } = query ?? { skip: 0, take: 250 };
const ooos = await this.orgUsersOOOService.getOrgUsersOOOPaginated(orgId, skip ?? 0, take ?? 250, rest, {
email,
});
return {
status: SUCCESS_STATUS,
data: ooos.map((ooo) => plainToInstance(UserOooOutputDto, ooo, { strategy: "excludeAll" })),
};
}
}
@@ -0,0 +1,446 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { UserOooOutputDto } from "@/modules/ooo/outputs/ooo.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { Team } from "@calcom/prisma/client";
describe("Organizations User OOO Endpoints", () => {
describe("User Authentication - User is Org Admin", () => {
let app: INestApplication;
let oooCreatedViaApiId: number;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let org: Team;
let team: Team;
let falseTestOrg: Team;
let falseTestTeam: Team;
const userEmail = "org-admin-ooo-controller-e222e@api.com";
let userAdmin: User;
const teammate1Email = "teammate111ooo@team.com";
const teammate2Email = "teammate221ooo@team.com";
const falseTestUserEmail = "false-user-ooo@false-team.com";
let teammate1: User;
let teammate2: User;
let falseTestUser: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
userAdmin = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
role: "ADMIN",
});
teammate1 = await userRepositoryFixture.create({
email: teammate1Email,
username: teammate1Email,
});
teammate2 = await userRepositoryFixture.create({
email: teammate2Email,
username: teammate2Email,
});
falseTestUser = await userRepositoryFixture.create({
email: falseTestUserEmail,
username: falseTestUserEmail,
});
org = await organizationsRepositoryFixture.create({
name: "Test Organization ooo",
isOrganization: true,
});
falseTestOrg = await organizationsRepositoryFixture.create({
name: "False test org ooo",
isOrganization: true,
});
team = await teamsRepositoryFixture.create({
name: "Test org team ooo",
isOrganization: false,
parent: { connect: { id: org.id } },
});
falseTestTeam = await teamsRepositoryFixture.create({
name: "Outside org team ooo",
isOrganization: false,
parent: { connect: { id: falseTestOrg.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${userAdmin.id}`,
username: userEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: userAdmin.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${teammate1.id}`,
username: teammate1Email,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: teammate1.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: userAdmin.id } },
team: { connect: { id: org.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: teammate1.id } },
team: { connect: { id: team.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: teammate2.id } },
team: { connect: { id: team.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: falseTestUser.id } },
team: { connect: { id: falseTestTeam.id } },
accepted: true,
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(userAdmin).toBeDefined();
expect(org).toBeDefined();
});
it("should create a ooo entry without redirect", async () => {
const body = {
start: "2025-05-01T01:00:00.000Z",
end: "2025-05-10T13:59:59.999Z",
notes: "ooo numero uno",
};
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`)
.send(body)
.expect(201)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data as UserOooOutputDto;
expect(data.reason).toEqual("unspecified");
expect(data.userId).toEqual(teammate1.id);
expect(data.start).toEqual("2025-05-01T00:00:00.000Z");
expect(data.end).toEqual("2025-05-10T23:59:59.999Z");
oooCreatedViaApiId = data.id;
});
});
it("should create a ooo entry with redirect", async () => {
const body = {
start: "2025-08-01T01:00:00.000Z",
end: "2025-10-10T13:59:59.999Z",
notes: "ooo numero dos with redirect",
toUserId: teammate2.id,
};
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`)
.send(body)
.expect(201)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data as UserOooOutputDto;
expect(data.reason).toEqual("unspecified");
expect(data.userId).toEqual(teammate1.id);
expect(data.start).toEqual("2025-08-01T00:00:00.000Z");
expect(data.end).toEqual("2025-10-10T23:59:59.999Z");
expect(data.toUserId).toEqual(teammate2.id);
});
});
it("should fail to create a ooo entry with start after end", async () => {
const body = {
start: "2025-07-01T00:00:00.000Z",
end: "2025-05-10T23:59:59.999Z",
notes: "ooo numero uno duplicate",
};
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`)
.send(body)
.expect(400);
});
it("should fail to create a duplicate ooo entry", async () => {
const body = {
start: "2025-05-01T00:00:00.000Z",
end: "2025-05-10T23:59:59.999Z",
notes: "ooo numero uno duplicate",
};
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`)
.send(body)
.expect(409);
});
it("should fail to create an ooo entry that redirects to self", async () => {
const body = {
start: "2025-05-02T00:00:00.000Z",
end: "2025-05-03T23:59:59.999Z",
notes: "ooo infinite redirect",
toUserId: teammate1.id,
};
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`)
.send(body)
.expect(400);
});
it("should fail to create an ooo entry that redirects to member outside of org", async () => {
const body = {
start: "2025-05-02T00:00:00.000Z",
end: "2025-05-03T23:59:59.999Z",
notes: "ooo invalid redirect",
toUserId: falseTestUser.id,
};
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`)
.send(body)
.expect(400);
});
it("should update a ooo entry without redirect", async () => {
const body = {
start: "2025-06-01T01:00:00.000Z",
end: "2025-06-10T13:59:59.999Z",
notes: "ooo numero uno",
reason: "vacation",
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`)
.send(body)
.expect(200)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data as UserOooOutputDto;
expect(data.reason).toEqual("vacation");
expect(data.userId).toEqual(teammate1.id);
expect(data.start).toEqual("2025-06-01T00:00:00.000Z");
expect(data.end).toEqual("2025-06-10T23:59:59.999Z");
});
});
it("should fail to update a ooo entry with redirect outside of org", async () => {
const body = {
toUserId: falseTestUser.id,
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`)
.send(body)
.expect(400);
});
it("should fail to update a ooo entry with start after end ", async () => {
const body = {
start: "2025-07-01T00:00:00.000Z",
end: "2025-05-10T23:59:59.999Z",
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`)
.send(body)
.expect(400);
});
it("should fail to update a ooo entry with redirect to self ", async () => {
const body = {
toUserId: teammate1.id,
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`)
.send(body)
.expect(400);
});
it("should fail to update a ooo entry with duplicate time", async () => {
const body = {
start: "2025-06-01T01:00:00.000Z",
end: "2025-06-10T13:59:59.999Z",
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`)
.send(body)
.expect(409);
});
it("should update a ooo entry without redirect", async () => {
const body = {
toUserId: teammate2.id,
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`)
.send(body)
.expect(200)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data as UserOooOutputDto;
expect(data.reason).toEqual("vacation");
expect(data.toUserId).toEqual(teammate2.id);
expect(data.userId).toEqual(teammate1.id);
expect(data.start).toEqual("2025-06-01T00:00:00.000Z");
expect(data.end).toEqual("2025-06-10T23:59:59.999Z");
});
});
it("should get 2 ooo entries", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/ooo?sortEnd=desc&email=${teammate1Email}`)
.expect(200)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data as UserOooOutputDto[];
expect(data.length).toEqual(2);
const oooUno = data.find((ooo) => ooo.id === oooCreatedViaApiId);
expect(oooUno).toBeDefined();
if (oooUno) {
expect(oooUno.reason).toEqual("vacation");
expect(oooUno.toUserId).toEqual(teammate2.id);
expect(oooUno.userId).toEqual(teammate1.id);
expect(oooUno.start).toEqual("2025-06-01T00:00:00.000Z");
expect(oooUno.end).toEqual("2025-06-10T23:59:59.999Z");
}
// test sort
expect(data[1].id).toEqual(oooCreatedViaApiId);
});
});
it("should get 2 ooo entries", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`)
.expect(200)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data as UserOooOutputDto[];
expect(data.length).toEqual(2);
const oooUno = data.find((ooo) => ooo.id === oooCreatedViaApiId);
expect(oooUno).toBeDefined();
if (oooUno) {
expect(oooUno.reason).toEqual("vacation");
expect(oooUno.toUserId).toEqual(teammate2.id);
expect(oooUno.userId).toEqual(teammate1.id);
expect(oooUno.start).toEqual("2025-06-01T00:00:00.000Z");
expect(oooUno.end).toEqual("2025-06-10T23:59:59.999Z");
}
});
});
it("should delete ooo entry", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo/${oooCreatedViaApiId}`)
.expect(200);
});
it("user should have 1 ooo entries", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users/${teammate1.id}/ooo`)
.expect(200)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data as UserOooOutputDto[];
expect(data.length).toEqual(1);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(userAdmin.email);
await userRepositoryFixture.deleteByEmail(teammate1.email);
await userRepositoryFixture.deleteByEmail(teammate2.email);
await userRepositoryFixture.deleteByEmail(falseTestUser.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(falseTestTeam.id);
await organizationsRepositoryFixture.delete(org.id);
await organizationsRepositoryFixture.delete(falseTestOrg.id);
await app.close();
});
});
});
@@ -0,0 +1,33 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrgUsersOOORepository {
constructor(private readonly dbRead: PrismaReadService) {}
async getOrgUsersOOOPaginated(
orgId: number,
skip: number,
take: number,
sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" },
filters?: { email?: string }
) {
console.log({ sort, filters });
return this.dbRead.prisma.outOfOfficeEntry.findMany({
where: {
user: {
...(filters?.email && { email: filters.email }),
profiles: {
some: {
organizationId: orgId,
},
},
},
},
skip,
take,
include: { reason: true },
...(sort?.sortStart && { orderBy: { start: sort.sortStart } }),
...(sort?.sortEnd && { orderBy: { end: sort.sortEnd } }),
});
}
}
@@ -0,0 +1,26 @@
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
import { UserOOOService } from "@/modules/ooo/services/ooo.service";
import { OrgUsersOOORepository } from "@/modules/organizations/controllers/users/ooo/repositories/organizations-users-ooo.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrgUsersOOOService {
constructor(
private readonly oooRepository: UserOOORepository,
private readonly oooUserService: UserOOOService,
private readonly usersRepository: UsersRepository,
private readonly orgUsersOOORepository: OrgUsersOOORepository
) {}
async getOrgUsersOOOPaginated(
orgId: number,
skip: number,
take: number,
sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" },
filters?: { email?: string }
) {
const ooos = await this.orgUsersOOORepository.getOrgUsersOOOPaginated(orgId, skip, take, sort, filters);
return ooos.map((ooo) => this.oooUserService.formatOooReason(ooo));
}
}
@@ -3,6 +3,8 @@ import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/
import { EmailModule } from "@/modules/email/email.module";
import { EmailService } from "@/modules/email/email.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
import { UserOOOService } from "@/modules/ooo/services/ooo.service";
import { OrganizationsOptionsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes-options.controller";
import { OrganizationsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes.controller";
import { OrganizationsEventTypesController } from "@/modules/organizations/controllers/event-types/organizations-event-types.controller";
@@ -12,6 +14,9 @@ import { OrganizationsSchedulesController } from "@/modules/organizations/contro
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller";
import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.controller";
import { OrganizationsUsersOOOController } from "@/modules/organizations/controllers/users/ooo/organizations-users-ooo-controller";
import { OrgUsersOOORepository } from "@/modules/organizations/controllers/users/ooo/repositories/organizations-users-ooo.repository";
import { OrgUsersOOOService } from "@/modules/organizations/controllers/users/ooo/services/organization-users-ooo.service";
import { OrganizationsUsersController } from "@/modules/organizations/controllers/users/organizations-users.controller";
import { OrganizationsWebhooksController } from "@/modules/organizations/controllers/webhooks/organizations-webhooks.controller";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
@@ -86,6 +91,10 @@ import { Module } from "@nestjs/common";
WebhooksRepository,
WebhooksService,
OutputTeamEventTypesResponsePipe,
UserOOOService,
UserOOORepository,
OrgUsersOOOService,
OrgUsersOOORepository,
],
exports: [
OrganizationsService,
@@ -118,6 +127,7 @@ import { Module } from "@nestjs/common";
OrganizationsOptionsAttributesController,
OrganizationsWebhooksController,
OrganizationsTeamsSchedulesController,
OrganizationsUsersOOOController,
],
})
export class OrganizationsModule {}
@@ -0,0 +1,490 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SlotsModule } from "@/modules/slots/slots.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { SelectedSlotsRepositoryFixture } from "test/fixtures/repository/selected-slots.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomNumber } from "test/utils/randomNumber";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
const expectedSlotsUTC = {
slots: {
"2050-09-05": [
{ time: "2050-09-05T07:00:00.000Z" },
{ time: "2050-09-05T08:00:00.000Z" },
{ time: "2050-09-05T09:00:00.000Z" },
{ time: "2050-09-05T10:00:00.000Z" },
{ time: "2050-09-05T11:00:00.000Z" },
{ time: "2050-09-05T12:00:00.000Z" },
{ time: "2050-09-05T13:00:00.000Z" },
{ time: "2050-09-05T14:00:00.000Z" },
],
"2050-09-06": [
{ time: "2050-09-06T07:00:00.000Z" },
{ time: "2050-09-06T08:00:00.000Z" },
{ time: "2050-09-06T09:00:00.000Z" },
{ time: "2050-09-06T10:00:00.000Z" },
{ time: "2050-09-06T11:00:00.000Z" },
{ time: "2050-09-06T12:00:00.000Z" },
{ time: "2050-09-06T13:00:00.000Z" },
{ time: "2050-09-06T14:00:00.000Z" },
],
"2050-09-07": [
{ time: "2050-09-07T07:00:00.000Z" },
{ time: "2050-09-07T08:00:00.000Z" },
{ time: "2050-09-07T09:00:00.000Z" },
{ time: "2050-09-07T10:00:00.000Z" },
{ time: "2050-09-07T11:00:00.000Z" },
{ time: "2050-09-07T12:00:00.000Z" },
{ time: "2050-09-07T13:00:00.000Z" },
{ time: "2050-09-07T14:00:00.000Z" },
],
"2050-09-08": [
{ time: "2050-09-08T07:00:00.000Z" },
{ time: "2050-09-08T08:00:00.000Z" },
{ time: "2050-09-08T09:00:00.000Z" },
{ time: "2050-09-08T10:00:00.000Z" },
{ time: "2050-09-08T11:00:00.000Z" },
{ time: "2050-09-08T12:00:00.000Z" },
{ time: "2050-09-08T13:00:00.000Z" },
{ time: "2050-09-08T14:00:00.000Z" },
],
"2050-09-09": [
{ time: "2050-09-09T07:00:00.000Z" },
{ time: "2050-09-09T08:00:00.000Z" },
{ time: "2050-09-09T09:00:00.000Z" },
{ time: "2050-09-09T10:00:00.000Z" },
{ time: "2050-09-09T11:00:00.000Z" },
{ time: "2050-09-09T12:00:00.000Z" },
{ time: "2050-09-09T13:00:00.000Z" },
{ time: "2050-09-09T14:00:00.000Z" },
],
},
};
const expectedSlotsRome = {
slots: {
"2050-09-05": [
{ time: "2050-09-05T09:00:00.000+02:00" },
{ time: "2050-09-05T10:00:00.000+02:00" },
{ time: "2050-09-05T11:00:00.000+02:00" },
{ time: "2050-09-05T12:00:00.000+02:00" },
{ time: "2050-09-05T13:00:00.000+02:00" },
{ time: "2050-09-05T14:00:00.000+02:00" },
{ time: "2050-09-05T15:00:00.000+02:00" },
{ time: "2050-09-05T16:00:00.000+02:00" },
],
"2050-09-06": [
{ time: "2050-09-06T09:00:00.000+02:00" },
{ time: "2050-09-06T10:00:00.000+02:00" },
{ time: "2050-09-06T11:00:00.000+02:00" },
{ time: "2050-09-06T12:00:00.000+02:00" },
{ time: "2050-09-06T13:00:00.000+02:00" },
{ time: "2050-09-06T14:00:00.000+02:00" },
{ time: "2050-09-06T15:00:00.000+02:00" },
{ time: "2050-09-06T16:00:00.000+02:00" },
],
"2050-09-07": [
{ time: "2050-09-07T09:00:00.000+02:00" },
{ time: "2050-09-07T10:00:00.000+02:00" },
{ time: "2050-09-07T11:00:00.000+02:00" },
{ time: "2050-09-07T12:00:00.000+02:00" },
{ time: "2050-09-07T13:00:00.000+02:00" },
{ time: "2050-09-07T14:00:00.000+02:00" },
{ time: "2050-09-07T15:00:00.000+02:00" },
{ time: "2050-09-07T16:00:00.000+02:00" },
],
"2050-09-08": [
{ time: "2050-09-08T09:00:00.000+02:00" },
{ time: "2050-09-08T10:00:00.000+02:00" },
{ time: "2050-09-08T11:00:00.000+02:00" },
{ time: "2050-09-08T12:00:00.000+02:00" },
{ time: "2050-09-08T13:00:00.000+02:00" },
{ time: "2050-09-08T14:00:00.000+02:00" },
{ time: "2050-09-08T15:00:00.000+02:00" },
{ time: "2050-09-08T16:00:00.000+02:00" },
],
"2050-09-09": [
{ time: "2050-09-09T09:00:00.000+02:00" },
{ time: "2050-09-09T10:00:00.000+02:00" },
{ time: "2050-09-09T11:00:00.000+02:00" },
{ time: "2050-09-09T12:00:00.000+02:00" },
{ time: "2050-09-09T13:00:00.000+02:00" },
{ time: "2050-09-09T14:00:00.000+02:00" },
{ time: "2050-09-09T15:00:00.000+02:00" },
{ time: "2050-09-09T16:00:00.000+02:00" },
],
},
};
const expectedSlotsUTCRange = {
slots: {
"2050-09-05": [
{ startTime: "2050-09-05T07:00:00.000Z", endTime: "2050-09-05T08:00:00.000Z" },
{ startTime: "2050-09-05T08:00:00.000Z", endTime: "2050-09-05T09:00:00.000Z" },
{ startTime: "2050-09-05T09:00:00.000Z", endTime: "2050-09-05T10:00:00.000Z" },
{ startTime: "2050-09-05T10:00:00.000Z", endTime: "2050-09-05T11:00:00.000Z" },
{ startTime: "2050-09-05T11:00:00.000Z", endTime: "2050-09-05T12:00:00.000Z" },
{ startTime: "2050-09-05T12:00:00.000Z", endTime: "2050-09-05T13:00:00.000Z" },
{ startTime: "2050-09-05T13:00:00.000Z", endTime: "2050-09-05T14:00:00.000Z" },
{ startTime: "2050-09-05T14:00:00.000Z", endTime: "2050-09-05T15:00:00.000Z" },
],
"2050-09-06": [
{ startTime: "2050-09-06T07:00:00.000Z", endTime: "2050-09-06T08:00:00.000Z" },
{ startTime: "2050-09-06T08:00:00.000Z", endTime: "2050-09-06T09:00:00.000Z" },
{ startTime: "2050-09-06T09:00:00.000Z", endTime: "2050-09-06T10:00:00.000Z" },
{ startTime: "2050-09-06T10:00:00.000Z", endTime: "2050-09-06T11:00:00.000Z" },
{ startTime: "2050-09-06T11:00:00.000Z", endTime: "2050-09-06T12:00:00.000Z" },
{ startTime: "2050-09-06T12:00:00.000Z", endTime: "2050-09-06T13:00:00.000Z" },
{ startTime: "2050-09-06T13:00:00.000Z", endTime: "2050-09-06T14:00:00.000Z" },
{ startTime: "2050-09-06T14:00:00.000Z", endTime: "2050-09-06T15:00:00.000Z" },
],
"2050-09-07": [
{ startTime: "2050-09-07T07:00:00.000Z", endTime: "2050-09-07T08:00:00.000Z" },
{ startTime: "2050-09-07T08:00:00.000Z", endTime: "2050-09-07T09:00:00.000Z" },
{ startTime: "2050-09-07T09:00:00.000Z", endTime: "2050-09-07T10:00:00.000Z" },
{ startTime: "2050-09-07T10:00:00.000Z", endTime: "2050-09-07T11:00:00.000Z" },
{ startTime: "2050-09-07T11:00:00.000Z", endTime: "2050-09-07T12:00:00.000Z" },
{ startTime: "2050-09-07T12:00:00.000Z", endTime: "2050-09-07T13:00:00.000Z" },
{ startTime: "2050-09-07T13:00:00.000Z", endTime: "2050-09-07T14:00:00.000Z" },
{ startTime: "2050-09-07T14:00:00.000Z", endTime: "2050-09-07T15:00:00.000Z" },
],
"2050-09-08": [
{ startTime: "2050-09-08T07:00:00.000Z", endTime: "2050-09-08T08:00:00.000Z" },
{ startTime: "2050-09-08T08:00:00.000Z", endTime: "2050-09-08T09:00:00.000Z" },
{ startTime: "2050-09-08T09:00:00.000Z", endTime: "2050-09-08T10:00:00.000Z" },
{ startTime: "2050-09-08T10:00:00.000Z", endTime: "2050-09-08T11:00:00.000Z" },
{ startTime: "2050-09-08T11:00:00.000Z", endTime: "2050-09-08T12:00:00.000Z" },
{ startTime: "2050-09-08T12:00:00.000Z", endTime: "2050-09-08T13:00:00.000Z" },
{ startTime: "2050-09-08T13:00:00.000Z", endTime: "2050-09-08T14:00:00.000Z" },
{ startTime: "2050-09-08T14:00:00.000Z", endTime: "2050-09-08T15:00:00.000Z" },
],
"2050-09-09": [
{ startTime: "2050-09-09T07:00:00.000Z", endTime: "2050-09-09T08:00:00.000Z" },
{ startTime: "2050-09-09T08:00:00.000Z", endTime: "2050-09-09T09:00:00.000Z" },
{ startTime: "2050-09-09T09:00:00.000Z", endTime: "2050-09-09T10:00:00.000Z" },
{ startTime: "2050-09-09T10:00:00.000Z", endTime: "2050-09-09T11:00:00.000Z" },
{ startTime: "2050-09-09T11:00:00.000Z", endTime: "2050-09-09T12:00:00.000Z" },
{ startTime: "2050-09-09T12:00:00.000Z", endTime: "2050-09-09T13:00:00.000Z" },
{ startTime: "2050-09-09T13:00:00.000Z", endTime: "2050-09-09T14:00:00.000Z" },
{ startTime: "2050-09-09T14:00:00.000Z", endTime: "2050-09-09T15:00:00.000Z" },
],
},
};
const expectedSlotsRomeRange = {
slots: {
"2050-09-05": [
{ startTime: "2050-09-05T09:00:00.000+02:00", endTime: "2050-09-05T10:00:00.000+02:00" },
{ startTime: "2050-09-05T10:00:00.000+02:00", endTime: "2050-09-05T11:00:00.000+02:00" },
{ startTime: "2050-09-05T11:00:00.000+02:00", endTime: "2050-09-05T12:00:00.000+02:00" },
{ startTime: "2050-09-05T12:00:00.000+02:00", endTime: "2050-09-05T13:00:00.000+02:00" },
{ startTime: "2050-09-05T13:00:00.000+02:00", endTime: "2050-09-05T14:00:00.000+02:00" },
{ startTime: "2050-09-05T14:00:00.000+02:00", endTime: "2050-09-05T15:00:00.000+02:00" },
{ startTime: "2050-09-05T15:00:00.000+02:00", endTime: "2050-09-05T16:00:00.000+02:00" },
{ startTime: "2050-09-05T16:00:00.000+02:00", endTime: "2050-09-05T17:00:00.000+02:00" },
],
"2050-09-06": [
{ startTime: "2050-09-06T09:00:00.000+02:00", endTime: "2050-09-06T10:00:00.000+02:00" },
{ startTime: "2050-09-06T10:00:00.000+02:00", endTime: "2050-09-06T11:00:00.000+02:00" },
{ startTime: "2050-09-06T11:00:00.000+02:00", endTime: "2050-09-06T12:00:00.000+02:00" },
{ startTime: "2050-09-06T12:00:00.000+02:00", endTime: "2050-09-06T13:00:00.000+02:00" },
{ startTime: "2050-09-06T13:00:00.000+02:00", endTime: "2050-09-06T14:00:00.000+02:00" },
{ startTime: "2050-09-06T14:00:00.000+02:00", endTime: "2050-09-06T15:00:00.000+02:00" },
{ startTime: "2050-09-06T15:00:00.000+02:00", endTime: "2050-09-06T16:00:00.000+02:00" },
{ startTime: "2050-09-06T16:00:00.000+02:00", endTime: "2050-09-06T17:00:00.000+02:00" },
],
"2050-09-07": [
{ startTime: "2050-09-07T09:00:00.000+02:00", endTime: "2050-09-07T10:00:00.000+02:00" },
{ startTime: "2050-09-07T10:00:00.000+02:00", endTime: "2050-09-07T11:00:00.000+02:00" },
{ startTime: "2050-09-07T11:00:00.000+02:00", endTime: "2050-09-07T12:00:00.000+02:00" },
{ startTime: "2050-09-07T12:00:00.000+02:00", endTime: "2050-09-07T13:00:00.000+02:00" },
{ startTime: "2050-09-07T13:00:00.000+02:00", endTime: "2050-09-07T14:00:00.000+02:00" },
{ startTime: "2050-09-07T14:00:00.000+02:00", endTime: "2050-09-07T15:00:00.000+02:00" },
{ startTime: "2050-09-07T15:00:00.000+02:00", endTime: "2050-09-07T16:00:00.000+02:00" },
{ startTime: "2050-09-07T16:00:00.000+02:00", endTime: "2050-09-07T17:00:00.000+02:00" },
],
"2050-09-08": [
{ startTime: "2050-09-08T09:00:00.000+02:00", endTime: "2050-09-08T10:00:00.000+02:00" },
{ startTime: "2050-09-08T10:00:00.000+02:00", endTime: "2050-09-08T11:00:00.000+02:00" },
{ startTime: "2050-09-08T11:00:00.000+02:00", endTime: "2050-09-08T12:00:00.000+02:00" },
{ startTime: "2050-09-08T12:00:00.000+02:00", endTime: "2050-09-08T13:00:00.000+02:00" },
{ startTime: "2050-09-08T13:00:00.000+02:00", endTime: "2050-09-08T14:00:00.000+02:00" },
{ startTime: "2050-09-08T14:00:00.000+02:00", endTime: "2050-09-08T15:00:00.000+02:00" },
{ startTime: "2050-09-08T15:00:00.000+02:00", endTime: "2050-09-08T16:00:00.000+02:00" },
{ startTime: "2050-09-08T16:00:00.000+02:00", endTime: "2050-09-08T17:00:00.000+02:00" },
],
"2050-09-09": [
{ startTime: "2050-09-09T09:00:00.000+02:00", endTime: "2050-09-09T10:00:00.000+02:00" },
{ startTime: "2050-09-09T10:00:00.000+02:00", endTime: "2050-09-09T11:00:00.000+02:00" },
{ startTime: "2050-09-09T11:00:00.000+02:00", endTime: "2050-09-09T12:00:00.000+02:00" },
{ startTime: "2050-09-09T12:00:00.000+02:00", endTime: "2050-09-09T13:00:00.000+02:00" },
{ startTime: "2050-09-09T13:00:00.000+02:00", endTime: "2050-09-09T14:00:00.000+02:00" },
{ startTime: "2050-09-09T14:00:00.000+02:00", endTime: "2050-09-09T15:00:00.000+02:00" },
{ startTime: "2050-09-09T15:00:00.000+02:00", endTime: "2050-09-09T16:00:00.000+02:00" },
{ startTime: "2050-09-09T16:00:00.000+02:00", endTime: "2050-09-09T17:00:00.000+02:00" },
],
},
};
describe("Slots Endpoints", () => {
describe("Individual user slots", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let schedulesService: SchedulesService_2024_06_11;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let selectedSlotsRepositoryFixture: SelectedSlotsRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
const userEmail = `slots-${randomNumber()}-controller-e2e@api.com`;
const userName = "bob";
let user: User;
let eventTypeId: number;
let eventTypeSlug: string;
let reservedSlotUid: string;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [
AppModule,
PrismaModule,
UsersModule,
TokensModule,
SchedulesModule_2024_06_11,
SlotsModule,
],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_06_11>(SchedulesService_2024_06_11);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
selectedSlotsRepositoryFixture = new SelectedSlotsRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
name: "bob slot",
username: userName,
});
// nxte(Lauris): this creates default schedule monday to friday from 9AM to 5PM in Europe/Rome timezone
await schedulesService.createUserDefaultSchedule(user.id, "Europe/Rome");
const event = await eventTypesRepositoryFixture.create(
{ title: "frisbee match", slug: "frisbee-match", length: 60 },
user.id
);
eventTypeId = event.id;
eventTypeSlug = event.slug;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should get slots in UTC by event type id", async () => {
return request(app.getHttpServer())
.get(`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10`)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
status: SUCCESS_STATUS,
data: expectedSlotsUTC,
});
});
});
it("should get slots in specified time zone by event type id", async () => {
return request(app.getHttpServer())
.get(
`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10&timeZone=Europe/Rome`
)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
status: SUCCESS_STATUS,
data: expectedSlotsRome,
});
});
});
it("should get slots in UTC by event type id in range format", async () => {
return request(app.getHttpServer())
.get(
`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10&slotFormat=range`
)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
status: SUCCESS_STATUS,
data: expectedSlotsUTCRange,
});
});
});
it("should get slots in specified time zone by event type id in range format", async () => {
return request(app.getHttpServer())
.get(
`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10&timeZone=Europe/Rome&slotFormat=range`
)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
status: SUCCESS_STATUS,
data: expectedSlotsRomeRange,
});
});
});
it("should get slots in UTC by event type slug", async () => {
return request(app.getHttpServer())
.get(
`/api/v2/slots/available?eventTypeSlug=${eventTypeSlug}&usernameList[]=${userName}&startTime=2050-09-05&endTime=2050-09-10`
)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
status: SUCCESS_STATUS,
data: expectedSlotsUTC,
});
});
});
it("should get slots in specified time zone by event type slug", async () => {
return request(app.getHttpServer())
.get(
`/api/v2/slots/available?eventTypeSlug=${eventTypeSlug}&usernameList[]=${userName}&startTime=2050-09-05&endTime=2050-09-10&timeZone=Europe/Rome`
)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
status: SUCCESS_STATUS,
data: expectedSlotsRome,
});
});
});
it("should reserve a slot and it should not appear in available slots", async () => {
const slotStartTime = "2050-09-05T10:00:00.000Z";
const reserveResponse = await request(app.getHttpServer())
.post(`/api/v2/slots/reserve`)
.send({
eventTypeId,
slotUtcStartDate: slotStartTime,
slotUtcEndDate: "2050-09-05T11:00:00.000Z",
})
.expect(201);
const reserveResponseBody = reserveResponse.body;
expect(reserveResponseBody.status).toEqual(SUCCESS_STATUS);
const uid: string = reserveResponseBody.data;
expect(uid).toBeDefined();
if (!uid) {
throw new Error("Reserved slot uid is undefined");
}
reservedSlotUid = uid;
const response = await request(app.getHttpServer())
.get(`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10`)
.expect(200);
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots.slots);
expect(days.length).toEqual(5);
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC.slots["2050-09-05"].filter(
(slot) => slot.time !== slotStartTime
);
expect(slots).toEqual({
slots: { ...expectedSlotsUTC.slots, "2050-09-05": expectedSlotsUTC2050_09_05 },
});
});
it("should delete reserved slot", async () => {
await request(app.getHttpServer())
.delete(`/api/v2/slots/selected-slot?uid=${reservedSlotUid}`)
.expect(200);
});
it("should do a booking and slot should not be available at that time", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
await bookingsRepositoryFixture.create({
uid: `booking-uid-${eventTypeId}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
const response = await request(app.getHttpServer())
.get(`/api/v2/slots/available?eventTypeId=${eventTypeId}&startTime=2050-09-05&endTime=2050-09-10`)
.expect(200);
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots.slots);
expect(days.length).toEqual(5);
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC.slots["2050-09-05"].filter(
(slot) => slot.time !== startTime
);
expect(slots).toEqual({
slots: { ...expectedSlotsUTC.slots, "2050-09-05": expectedSlotsUTC2050_09_05 },
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await selectedSlotsRepositoryFixture.deleteByUId(reservedSlotUid);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -1,11 +1,11 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { SlotsOutputService } from "@/modules/slots/services/slots-output.service";
import { SlotsService } from "@/modules/slots/services/slots.service";
import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common";
import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger";
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SlotFormat } from "@calcom/platform-enums";
import { getAvailableSlots } from "@calcom/platform-libraries";
import type { AvailableSlotsType } from "@calcom/platform-libraries";
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
@@ -17,7 +17,10 @@ import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
})
@DocsTags("Slots")
export class SlotsController {
constructor(private readonly slotsService: SlotsService) {}
constructor(
private readonly slotsService: SlotsService,
private readonly slotsOutputService: SlotsOutputService
) {}
@Post("/reserve")
@ApiCreatedResponse({
@@ -159,19 +162,17 @@ export class SlotsController {
},
});
const transformedSlots =
query.slotFormat === SlotFormat.Range
? await this.slotsService.formatSlots(
availableSlots,
query.duration,
query.eventTypeId,
query.slotFormat
)
: availableSlots.slots;
const { slots } = await this.slotsOutputService.getOutputSlots(
availableSlots,
query.duration,
query.eventTypeId,
query.slotFormat,
query.timeZone
);
return {
data: {
slots: transformedSlots,
slots,
},
status: SUCCESS_STATUS,
};
@@ -0,0 +1,96 @@
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
import { Injectable, BadRequestException } from "@nestjs/common";
import { DateTime } from "luxon";
import { SlotFormat } from "@calcom/platform-enums";
type TimeSlots = { slots: Record<string, { time: string }[]> };
type RangeSlots = { slots: Record<string, { startTime: string; endTime: string }[]> };
@Injectable()
export class SlotsOutputService {
constructor(private readonly eventTypesRepository: EventTypesRepository_2024_04_15) {}
async getOutputSlots(
availableSlots: TimeSlots,
duration?: number,
eventTypeId?: number,
slotFormat?: SlotFormat,
timeZone?: string
): Promise<TimeSlots | RangeSlots> {
if (!slotFormat) {
return timeZone ? this.setTimeZone(availableSlots, timeZone) : availableSlots;
}
const formattedSlots = await this.formatSlots(availableSlots, duration, eventTypeId, slotFormat);
return timeZone ? this.setTimeZoneRange(formattedSlots, timeZone) : formattedSlots;
}
private setTimeZone(slots: TimeSlots, timeZone: string): TimeSlots {
const formattedSlots = Object.entries(slots.slots).reduce((acc, [date, daySlots]) => {
acc[date] = daySlots.map((slot) => ({
time: DateTime.fromISO(slot.time).setZone(timeZone).toISO() || "unknown-time",
}));
return acc;
}, {} as Record<string, { time: string }[]>);
return { slots: formattedSlots };
}
private setTimeZoneRange(slots: RangeSlots, timeZone: string): RangeSlots {
const formattedSlots = Object.entries(slots.slots).reduce((acc, [date, daySlots]) => {
acc[date] = daySlots.map((slot) => ({
startTime: DateTime.fromISO(slot.startTime).setZone(timeZone).toISO() || "unknown-start-time",
endTime: DateTime.fromISO(slot.endTime).setZone(timeZone).toISO() || "unknown-end-time",
}));
return acc;
}, {} as Record<string, { startTime: string; endTime: string }[]>);
return { slots: formattedSlots };
}
private async formatSlots(
availableSlots: TimeSlots,
duration?: number,
eventTypeId?: number,
slotFormat?: SlotFormat
): Promise<RangeSlots> {
if (slotFormat && !Object.values(SlotFormat).includes(slotFormat)) {
throw new BadRequestException("Invalid slot format. Must be either 'range' or 'time'");
}
const slotDuration = await this.getDuration(duration, eventTypeId);
const slots = Object.entries(availableSlots.slots).reduce<
Record<string, { startTime: string; endTime: string }[]>
>((acc, [date, slots]) => {
acc[date] = (slots as { time: string }[]).map((slot) => {
const startTime = new Date(slot.time);
const endTime = new Date(startTime.getTime() + slotDuration * 60000);
return {
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
};
});
return acc;
}, {});
return { slots };
}
private async getDuration(duration?: number, eventTypeId?: number): Promise<number> {
if (duration) {
return duration;
}
if (eventTypeId) {
const eventType = await this.eventTypesRepository.getEventTypeWithDuration(eventTypeId);
if (!eventType) {
throw new Error("Event type not found");
}
return eventType.length;
}
throw new Error("duration or eventTypeId is required");
}
}
@@ -1,6 +1,7 @@
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
import { SlotsRepository } from "@/modules/slots/slots.repository";
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { DateTime } from "luxon";
import { v4 as uuid } from "uuid";
import { SlotFormat } from "@calcom/platform-enums";
@@ -55,51 +56,4 @@ export class SlotsService {
const event = await this.eventTypeRepo.getEventTypeById(eventTypeId);
return !!event?.teamId;
}
async getEventTypeWithDuration(eventTypeId: number) {
return await this.eventTypeRepo.getEventTypeWithDuration(eventTypeId);
}
async getDuration(duration?: number, eventTypeId?: number): Promise<number> {
if (duration) {
return duration;
}
if (eventTypeId) {
const eventType = await this.eventTypeRepo.getEventTypeWithDuration(eventTypeId);
if (!eventType) {
throw new Error("Event type not found");
}
return eventType.length;
}
throw new Error("duration or eventTypeId is required");
}
async formatSlots(
availableSlots: { slots: Record<string, { time: string }[]> },
duration?: number,
eventTypeId?: number,
slotFormat?: SlotFormat
): Promise<Record<string, { startTime: string; endTime: string }[]>> {
if (slotFormat && !Object.values(SlotFormat).includes(slotFormat)) {
throw new BadRequestException("Invalid slot format. Must be either 'range' or 'time'");
}
const slotDuration = await this.getDuration(duration, eventTypeId);
return Object.entries(availableSlots.slots).reduce<
Record<string, { startTime: string; endTime: string }[]>
>((acc, [date, slots]) => {
acc[date] = (slots as { time: string }[]).map((slot) => {
const startTime = new Date(slot.time);
const endTime = new Date(startTime.getTime() + slotDuration * 60000);
return {
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
};
});
return acc;
}, {});
}
}
@@ -1,13 +1,14 @@
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SlotsController } from "@/modules/slots/controllers/slots.controller";
import { SlotsOutputService } from "@/modules/slots/services/slots-output.service";
import { SlotsService } from "@/modules/slots/services/slots.service";
import { SlotsRepository } from "@/modules/slots/slots.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, EventTypesModule_2024_04_15],
providers: [SlotsRepository, SlotsService],
providers: [SlotsRepository, SlotsService, SlotsOutputService],
controllers: [SlotsController],
exports: [SlotsService],
})
@@ -1,12 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsOptional, Validate } from "class-validator";
import { SkipTakePagination } from "@calcom/platform-types";
import { IsNumber, IsOptional, Max, Min, Validate } from "class-validator";
import { IsEmailStringOrArray } from "../validators/isEmailStringOrArray";
export class GetUsersInput extends SkipTakePagination {
export class GetUsersInput {
@ApiProperty({ required: false, description: "The number of items to return", example: 10 })
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@Min(1)
@Max(1000)
@IsOptional()
take?: number;
@ApiProperty({ required: false, description: "The number of items to skip", example: 0 })
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@Min(0)
@IsOptional()
skip?: number;
@IsOptional()
@Validate(IsEmailStringOrArray)
@Transform(({ value }: { value: string | string[] }) => {
@@ -265,4 +265,27 @@ export class UsersRepository {
where: { id: userId },
});
}
async findUserOOORedirectEligible(userId: number, toTeamUserId: number) {
return await this.dbRead.prisma.user.findUnique({
where: {
id: toTeamUserId,
teams: {
some: {
team: {
members: {
some: {
userId: userId,
accepted: true,
},
},
},
},
},
},
select: {
id: true,
},
});
}
}
+361 -12
View File
@@ -2516,6 +2516,8 @@
"description": "The number of items to return",
"example": 10,
"schema": {
"minimum": 1,
"maximum": 1000,
"type": "number"
}
},
@@ -2526,6 +2528,7 @@
"description": "The number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"type": "number"
}
},
@@ -2684,6 +2687,256 @@
]
}
},
"/v2/organizations/{orgId}/users/{userId}/ooo": {
"get": {
"operationId": "OrganizationsUsersOOOController_getOrganizationUserOOO",
"summary": "Get all ooo entries of a user",
"parameters": [
{
"name": "userId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"schema": {
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"example": 0,
"schema": {
"type": "number"
}
},
{
"name": "sortStart",
"required": false,
"in": "query",
"description": "Sort results by their start time in ascending or descending order.",
"example": "?sortStart=asc OR ?sortStart=desc",
"schema": {
"enum": [
"asc",
"desc"
],
"type": "string"
}
},
{
"name": "sortEnd",
"required": false,
"in": "query",
"description": "Sort results by their end time in ascending or descending order.",
"example": "?sortEnd=asc OR ?sortEnd=desc",
"schema": {
"enum": [
"asc",
"desc"
],
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Orgs / Users / OOO"
]
},
"post": {
"operationId": "OrganizationsUsersOOOController_createOrganizationUserOOO",
"summary": "Create an ooo entry for user",
"parameters": [
{
"name": "userId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateOutOfOfficeEntryDto"
}
}
}
},
"responses": {
"201": {
"description": ""
}
},
"tags": [
"Orgs / Users / OOO"
]
}
},
"/v2/organizations/{orgId}/users/{userId}/ooo/{oooId}": {
"patch": {
"operationId": "OrganizationsUsersOOOController_updateOrganizationUserOOO",
"summary": "Update ooo entry of a user",
"parameters": [
{
"name": "userId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "oooId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateOutOfOfficeEntryDto"
}
}
}
},
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Orgs / Users / OOO"
]
},
"delete": {
"operationId": "OrganizationsUsersOOOController_deleteOrganizationUserOOO",
"summary": "Delete ooo entry of a user",
"parameters": [
{
"name": "oooId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Orgs / Users / OOO"
]
}
},
"/v2/organizations/{orgId}/ooo": {
"get": {
"operationId": "OrganizationsUsersOOOController_getOrganizationUsersOOO",
"summary": "Get all OOO entries of org users",
"parameters": [
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"schema": {
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"example": 0,
"schema": {
"type": "number"
}
},
{
"name": "sortStart",
"required": false,
"in": "query",
"description": "Sort results by their start time in ascending or descending order.",
"example": "?sortStart=asc OR ?sortStart=desc",
"schema": {
"enum": [
"asc",
"desc"
],
"type": "string"
}
},
{
"name": "sortEnd",
"required": false,
"in": "query",
"description": "Sort results by their end time in ascending or descending order.",
"example": "?sortEnd=asc OR ?sortEnd=desc",
"schema": {
"enum": [
"asc",
"desc"
],
"type": "string"
}
},
{
"name": "email",
"required": false,
"in": "query",
"description": "Filter ooo entries by the user email address. user must be within your organization.",
"example": "example@domain.com",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Orgs / Users / OOO"
]
}
},
"/v2/organizations/{orgId}/webhooks": {
"get": {
"operationId": "OrganizationsWebhooksController_getAllOrganizationWebhooks",
@@ -5139,7 +5392,7 @@
"name": "eventTypeSlug",
"required": false,
"in": "query",
"description": "Slug of the event type for which slots are being fetched.",
"description": "Slug of the event type for which slots are being fetched. If event slug is provided then username must be provided too as query parameter `usernameList[]=username`",
"schema": {
"type": "string"
}
@@ -5148,7 +5401,8 @@
"name": "usernameList",
"required": false,
"in": "query",
"description": "Only for dynamic events - list of usernames for which slots are being fetched.",
"description": "Only if eventTypeSlug is provided or for dynamic events - list of usernames for which slots are being fetched.",
"example": "usernameList[]=bob",
"schema": {
"type": "array",
"items": {
@@ -7747,7 +8001,7 @@
"type": {
"type": "string",
"example": "name",
"description": "only allowed value for type is `name`"
"description": "only allowed value for type is `name`. Used for having 1 booking field for both first name and last name."
},
"label": {
"type": "string"
@@ -7777,6 +8031,9 @@
"label": {
"type": "string"
},
"required": {
"type": "object"
},
"placeholder": {
"type": "string"
},
@@ -7788,6 +8045,7 @@
"required": [
"type",
"label",
"required",
"placeholder"
]
},
@@ -8487,6 +8745,10 @@
"label": {
"type": "string"
},
"required": {
"type": "object",
"default": true
},
"placeholder": {
"type": "string"
},
@@ -8503,16 +8765,13 @@
"slug": {
"type": "string",
"default": "email"
},
"required": {
"type": "boolean"
}
},
"required": [
"type",
"required",
"isDefault",
"slug",
"required"
"slug"
]
},
"NameDefaultFieldOutput_2024_06_14": {
@@ -8536,7 +8795,7 @@
"boolean"
],
"example": "name",
"description": "only allowed value for type is `name`",
"description": "only allowed value for type is `name`. Used for having 1 booking field for both first name and last name.",
"default": "name"
},
"label": {
@@ -10111,7 +10370,7 @@
},
"customName": {
"type": "string",
"description": "Customizable event name with valid variables: \n {Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name}, \n {Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION}, \n {HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}",
"description": "Customizable event name with valid variables:\n {Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name},\n {Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION},\n {HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}",
"example": "{Event type title} between {Organiser} and {Scheduler}"
},
"destinationCalendar": {
@@ -12537,7 +12796,7 @@
},
"customName": {
"type": "string",
"description": "Customizable event name with valid variables: \n {Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name}, \n {Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION}, \n {HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}",
"description": "Customizable event name with valid variables:\n {Event type title}, {Organiser}, {Scheduler}, {Location}, {Organiser first name},\n {Scheduler first name}, {Scheduler last name}, {Event duration}, {LOCATION},\n {HOST/ATTENDEE}, {HOST}, {ATTENDEE}, {USER}",
"example": "{Event type title} between {Organiser} and {Scheduler}"
},
"destinationCalendar": {
@@ -13448,6 +13707,88 @@
}
}
},
"CreateOutOfOfficeEntryDto": {
"type": "object",
"properties": {
"start": {
"format": "date-time",
"type": "string",
"description": "The start date and time of the out of office period in ISO 8601 format in UTC timezone.",
"example": "2023-05-01T00:00:00.000Z"
},
"end": {
"format": "date-time",
"type": "string",
"description": "The end date and time of the out of office period in ISO 8601 format in UTC timezone.",
"example": "2023-05-10T23:59:59.999Z"
},
"notes": {
"type": "string",
"description": "Optional notes for the out of office entry.",
"example": "Vacation in Hawaii"
},
"toUserId": {
"type": "number",
"description": "The ID of the user covering for the out of office period, if applicable.",
"example": 2
},
"reason": {
"type": "string",
"description": "the reason for the out of office entry, if applicable",
"example": "vacation",
"enum": [
"unspecified",
"vacation",
"travel",
"sick",
"public_holiday"
]
}
},
"required": [
"start",
"end"
]
},
"UpdateOutOfOfficeEntryDto": {
"type": "object",
"properties": {
"start": {
"format": "date-time",
"type": "string",
"description": "The start date and time of the out of office period in ISO 8601 format in UTC timezone.",
"example": "2023-05-01T00:00:00.000Z"
},
"end": {
"format": "date-time",
"type": "string",
"description": "The end date and time of the out of office period in ISO 8601 format in UTC timezone.",
"example": "2023-05-10T23:59:59.999Z"
},
"notes": {
"type": "string",
"description": "Optional notes for the out of office entry.",
"example": "Vacation in Hawaii"
},
"toUserId": {
"type": "number",
"description": "The ID of the user covering for the out of office period, if applicable.",
"example": 2
},
"reason": {
"type": "string",
"description": "the reason for the out of office entry, if applicable",
"example": "vacation",
"enum": [
"unspecified",
"vacation",
"travel",
"sick",
"public_holiday"
]
}
}
},
"StripConnectOutputDto": {
"type": "object",
"properties": {
@@ -14470,6 +14811,11 @@
"description": "The time zone of the attendee.",
"example": "America/New_York"
},
"phoneNumber": {
"type": "string",
"description": "The phone number of the attendee in international format.",
"example": "+919876543210"
},
"language": {
"type": "string",
"enum": [
@@ -14523,7 +14869,6 @@
},
"required": [
"name",
"email",
"timeZone"
]
},
@@ -15131,6 +15476,10 @@
"type": "boolean",
"example": false
},
"phoneNumber": {
"type": "string",
"example": "+1234567890"
},
"seatUid": {
"type": "string",
"example": "3be561a9-31f1-4b8e-aefc-9d9a085f0dd1"
@@ -38,6 +38,11 @@ export class EventTypesRepositoryFixture {
return this.prismaWriteClient.eventType.create({
data: {
...data,
users: {
connect: {
id: userId,
},
},
owner: {
connect: {
id: userId,
@@ -0,0 +1,18 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { SelectedSlots } from "@prisma/client";
export class SelectedSlotsRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async deleteByUId(uid: SelectedSlots["uid"]) {
return this.prismaWriteClient.selectedSlots.deleteMany({ where: { uid } });
}
}
+4 -9
View File
@@ -1,12 +1,10 @@
import { getBucket } from "abTest/utils";
import type { NextMiddleware, NextRequest } from "next/server";
import { NextResponse, URLPattern } from "next/server";
import z from "zod";
import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME } from "@calcom/lib/constants";
const ROUTES: [URLPattern, boolean][] = [
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
["/apps/installed/:category", process.env.APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED === "1"] as const,
["/apps/:slug", process.env.APP_ROUTER_APPS_SLUG_ENABLED === "1"] as const,
["/apps/:slug/setup", process.env.APP_ROUTER_APPS_SLUG_SETUP_ENABLED === "1"] as const,
@@ -20,6 +18,7 @@ const ROUTES: [URLPattern, boolean][] = [
["/auth/error", process.env.APP_ROUTER_AUTH_ERROR_ENABLED === "1"] as const,
["/auth/platform/:path*", process.env.APP_ROUTER_AUTH_PLATFORM_ENABLED === "1"] as const,
["/auth/oauth2/:path*", process.env.APP_ROUTER_AUTH_OAUTH2_ENABLED === "1"] as const,
["/team", process.env.APP_ROUTER_TEAM_ENABLED === "1"] as const,
].map(([pathname, enabled]) => [
new URLPattern({
pathname,
@@ -27,8 +26,6 @@ const ROUTES: [URLPattern, boolean][] = [
enabled,
]);
const bucketSchema = z.union([z.literal("legacy"), z.literal("future")]);
export const abTestMiddlewareFactory =
(next: (req: NextRequest) => Promise<NextResponse<unknown>>): NextMiddleware =>
async (req: NextRequest) => {
@@ -45,11 +42,9 @@ export const abTestMiddlewareFactory =
return response;
}
const safeParsedBucket = override
? { success: true as const, data: "future" as const }
: bucketSchema.safeParse(req.cookies.get(FUTURE_ROUTES_ENABLED_COOKIE_NAME)?.value);
const bucketValue = override ? "future" : req.cookies.get(FUTURE_ROUTES_ENABLED_COOKIE_NAME)?.value;
if (!safeParsedBucket.success) {
if (!bucketValue || !["future", "legacy"].includes(bucketValue)) {
// cookie does not exist or it has incorrect value
const bucket = getBucket();
@@ -68,7 +63,7 @@ export const abTestMiddlewareFactory =
return NextResponse.rewrite(url, response);
}
if (safeParsedBucket.data === "legacy") {
if (bucketValue === "legacy") {
return response;
}
+1
View File
@@ -114,6 +114,7 @@ export const generateMeetingMetadata = async (
) => {
const metadata = await _generateMetadataWithoutImage(getTitle, getDescription, hideBranding, origin);
const image = SEO_IMG_OGIMG + constructMeetingImage(meeting);
return {
...metadata,
openGraph: {
+22 -163
View File
@@ -10,12 +10,18 @@ import { userMetadata } from "@calcom/prisma/zod-utils";
const inputSchema = z.object({
customer: z.object({
name: z.string().optional(),
email: z.string().email(),
username: z.string().optional(),
timeZone: z.string().optional(),
emailVerified: z.boolean().optional(),
identityProvider: z.string().optional(),
twoFactorEnabled: z.boolean().optional(),
lastActiveAt: z.string().optional(),
teamName: z.string().optional(),
teamSlug: z.string().optional(),
isOrganization: z.boolean().optional(),
stripeCustomerId: z.string().optional(),
}),
cardKeys: z.array(z.string()),
});
@@ -53,168 +59,10 @@ async function handler(request: Request) {
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Email",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: customer.email || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Email Verified?",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: customer.emailVerified ? "Yes" : "No",
badgeColor: customer.emailVerified ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Username",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: customer.username || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "User ID",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Time Zone",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: customer.timeZone || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Two Factor Enabled?",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: customer.twoFactorEnabled ? "Yes" : "No",
badgeColor: customer.twoFactorEnabled ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Identity Provider",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: customer.identityProvider || "Unknown",
},
},
],
componentText: {
textSize: "L",
textColor: "MUTED",
text: "User does not exist!",
},
},
],
@@ -223,18 +71,29 @@ async function handler(request: Request) {
});
}
// Fetch team details including userId and team name
const teamMemberships = await UserRepository.findTeamsByUserId({ userId: user.id });
const firstTeam = teamMemberships.teams[0] ?? null;
// Parse user metadata
const parsedMetadata = userMetadata.parse(user.metadata);
const cards = await Promise.all(
cardExamples.map(async (cardFn) => {
return cardFn(
user.name || "Unknown",
user.email,
user.id.toString(),
user.username || "Unknown",
user.timeZone,
user.emailVerified,
user.twoFactorEnabled
user.twoFactorEnabled,
user.identityProvider,
user.lastActiveAt,
firstTeam?.name || "Unknown",
firstTeam?.slug || "Unknown",
firstTeam?.isOrganization || false,
(user.metadata as { stripeCustomerId?: string })?.stripeCustomerId || "Unknown"
);
})
);
@@ -23,8 +23,8 @@ const querySchema = z.object({
const getSchedule = cache((id: number) => ScheduleRepository.findScheduleById({ id }));
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const parsed = querySchema.safeParse({ ...params, ...searchParams });
export const generateMetadata = async ({ params }: PageProps) => {
const parsed = querySchema.safeParse(params);
if (!parsed.success) {
notFound();
}
@@ -41,8 +41,8 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
);
};
const Page = async ({ params, searchParams }: PageProps) => {
const parsed = querySchema.safeParse({ ...params, ...searchParams });
const Page = async ({ params }: PageProps) => {
const parsed = querySchema.safeParse(params);
if (!parsed.success) {
notFound();
}
+3 -4
View File
@@ -1,6 +1,5 @@
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { redirect } from "next/navigation";
import { z } from "zod";
@@ -21,8 +20,8 @@ export const generateStaticParams = async () => {
return validStatuses.map((status) => ({ status }));
};
const Page = async ({ params, searchParams }: PageProps) => {
const parsed = querySchema.safeParse({ ...params, ...searchParams });
const Page = ({ params }: PageProps) => {
const parsed = querySchema.safeParse(params);
if (!parsed.success) {
redirect("/bookings/upcoming");
}
@@ -30,4 +29,4 @@ const Page = async ({ params, searchParams }: PageProps) => {
return <BookingsList status={parsed.data.status} />;
};
export default WithLayout({ ServerPage: Page })<"P">;
export default Page;
+5
View File
@@ -0,0 +1,5 @@
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "~/bookings/views/bookings-listing-view";
export default WithLayout({ getLayout });
+3 -16
View File
@@ -4,9 +4,6 @@ import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventRepository } from "@calcom/lib/server/repository/event";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/d/[link]/[slug]/getServerSideProps";
import { type PageProps } from "@lib/d/[link]/[slug]/getServerSideProps";
@@ -17,21 +14,11 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const pageProps = await getData(legacyCtx);
const { booking, user: username, slug: eventSlug, isTeamEvent, isBrandingHidden } = pageProps;
const { booking, eventData, isBrandingHidden } = pageProps;
const rescheduleUid = booking?.uid;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(legacyCtx.req);
const org = isValidOrgDomain ? currentOrgDomain : null;
const event = await EventRepository.getPublicEvent({
username,
eventSlug,
isTeamEvent,
org,
fromRedirectOfNonOrgLink: legacyCtx.query.orgRedirection === "true",
});
const profileName = event?.profile?.name ?? "";
const title = event?.title ?? "";
const profileName = eventData?.profile?.name ?? "";
const title = eventData?.title ?? "";
return await _generateMetadata(
(t) => `${rescheduleUid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`,
(t) => `${rescheduleUid ? t("reschedule") : ""} ${title}`,
+1 -3
View File
@@ -1,8 +1,6 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import EnterprisePage from "@components/EnterprisePage";
export const generateMetadata = async () =>
@@ -11,4 +9,4 @@ export const generateMetadata = async () =>
(t) => t("create_your_org_description")
);
export default WithLayout({ getLayout, Page: EnterprisePage })<"P">;
export default WithLayout({ Page: EnterprisePage })<"P">;
@@ -4,11 +4,10 @@ import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { EventType } from "@calcom/atoms/monorepo";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/event-types/[type]/getServerSideProps";
import type { PageProps as EventTypePageProps } from "@lib/event-types/[type]/getServerSideProps";
import EventTypePageWrapper from "~/event-types/views/event-types-single-view";
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
@@ -21,5 +20,5 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
};
const getData = withAppDirSsr(getServerSideProps);
const Page = ({ type, ...rest }: EventTypePageProps) => <EventType {...rest} id={type} isAppDir={true} />;
export default WithLayout({ getLayout: null, getData, Page })<"P">;
export default WithLayout({ getLayout: null, getData, Page: EventTypePageWrapper })<"P">;
@@ -1,6 +1,6 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { generateEventBookingPageMetadata } from "app/generateBookingPageMetadata";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { headers, cookies } from "next/headers";
@@ -18,7 +18,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getData(legacyCtx);
const { booking, user: username, slug: eventSlug, isSEOIndexable, eventData, isBrandingHidden } = props;
const { booking, user: username, slug: eventSlug } = props;
const rescheduleUid = booking?.uid;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(legacyCtx.req, legacyCtx.params?.orgSlug);
@@ -31,21 +31,12 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
});
const profileName = event?.profile?.name ?? "";
const profileImage = event?.profile.image;
const title = event?.title ?? "";
const metadata = await generateEventBookingPageMetadata({
event: {
hidden: event?.hidden ?? false,
title,
users: event?.users ?? [],
},
hideBranding: isBrandingHidden,
orgSlug: eventData?.entity.orgSlug ?? null,
isSEOIndexable: !!isSEOIndexable,
profile: { name: profileName, image: profileImage ?? "" },
isReschedule: !!rescheduleUid,
});
return metadata;
return await _generateMetadata(
(t) => `${rescheduleUid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`,
(t) => `${rescheduleUid ? t("reschedule") : ""} ${title}`
);
};
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
@@ -1,6 +1,6 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { generateUserProfilePageMetadata } from "app/generateBookingPageMetadata";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { headers, cookies } from "next/headers";
@@ -14,25 +14,11 @@ import LegacyPage from "~/users/views/users-public-view";
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams));
const { profile, markdownStrippedBio, isOrgSEOIndexable, entity } = props;
const isOrg = !!profile?.organization;
const allowSEOIndexing = !!(
(!isOrg && profile.allowSEOIndexing) ||
(isOrg && isOrgSEOIndexable && profile.allowSEOIndexing)
const { profile, markdownStrippedBio } = props;
return await _generateMetadata(
() => profile.name,
() => markdownStrippedBio
);
return await generateUserProfilePageMetadata({
profile: {
name: profile.name,
image: profile.image ?? "",
username: profile.username ?? "",
markdownStrippedBio: markdownStrippedBio,
},
event: null,
hideBranding: false,
orgSlug: entity.orgSlug ?? null,
isSEOIndexable: allowSEOIndexing,
});
};
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
@@ -1,9 +1,10 @@
import Page from "@pages/org/[orgSlug]/[user]/[type]/embed";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import { WithLayout } from "app/layoutHOC";
import { Page, type OrgTypePageProps } from "app/org/[orgSlug]/[user]/[type]/page";
import type { OrgTypePageProps } from "app/org/[orgSlug]/[user]/[type]/page";
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
const getEmbedData = withEmbedSsrAppDir<OrgTypePageProps>(getServerSideProps);
export default WithLayout({ getLayout: null, getData: getEmbedData, isBookingPage: true, ServerPage: Page });
export default WithLayout({ getLayout: null, getData: getEmbedData, isBookingPage: true, Page });
@@ -1,9 +1,8 @@
import { getServerSideProps, type PageProps } from "@pages/org/[orgSlug]/[user]";
import Page from "@pages/org/[orgSlug]/[user]/embed";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import { WithLayout } from "app/layoutHOC";
import { Page, type OrgPageProps } from "app/org/[orgSlug]/[user]/page";
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/getServerSideProps";
const getEmbedData = withEmbedSsrAppDir<PageProps>(getServerSideProps);
const getEmbedData = withEmbedSsrAppDir<OrgPageProps>(getServerSideProps);
export default WithLayout({ getLayout: null, getData: getEmbedData, isBookingPage: true, ServerPage: Page });
export default WithLayout({ getLayout: null, getData: getEmbedData, isBookingPage: true, Page });
@@ -0,0 +1,7 @@
import Page, { getServerSideProps, type PageProps } from "@pages/org/[orgSlug]/[user]";
import { withAppDirSsr } from "app/WithAppDirSsr";
import { WithLayout } from "app/layoutHOC";
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default WithLayout({ getLayout: null, getData, Page });
@@ -15,7 +15,7 @@ import Page from "~/org/[orgSlug]/instant-meeting/team/[slug]/[type]/instant-mee
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
const context = buildLegacyCtx(headers(), cookies(), params, searchParams);
const { slug: eventSlug, user: username, isBrandingHidden } = await getData(context);
const { slug: eventSlug, user: username } = await getData(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const org = isValidOrgDomain ? currentOrgDomain : null;
@@ -33,10 +33,9 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
return await _generateMetadata(
() => `${title} | ${profileName}`,
() => `${title}`,
isBrandingHidden
() => `${title}`
);
};
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default WithLayout({ getData, Page, isBookingPage: true })<"P">;
export default WithLayout({ getData, Page })<"P">;
@@ -1,11 +0,0 @@
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
import type { PageProps } from "~/team/type-view";
import LegacyPage from "~/team/type-view";
const getEmbedData = withEmbedSsrAppDir<PageProps>(getServerSideProps);
export default WithLayout({ getLayout: null, getData: getEmbedData, Page: LegacyPage })<"P">;
@@ -1,11 +0,0 @@
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@lib/team/[slug]/getServerSideProps";
import type { PageProps } from "~/team/team-view";
import LegacyPage from "~/team/team-view";
const getEmbedData = withEmbedSsrAppDir<PageProps>(getServerSideProps);
export default WithLayout({ getLayout: null, getData: getEmbedData, Page: LegacyPage })<"P">;
-151
View File
@@ -1,151 +0,0 @@
import type { TFunction } from "next-i18next";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { generateMeetingMetadata } from "./_utils";
type MetadataPayload = {
title: (t: TFunction) => string;
description: (t: TFunction) => string;
meeting: {
title: string;
profile: { name: string; image: string };
users?: { name: string; username: string }[];
};
robots: {
index: boolean;
follow: boolean;
};
};
type UserProfilePageProfile = {
name: string | null;
username: string | null;
image: string | null;
markdownStrippedBio: string | null | undefined;
};
type TeamProfilePageProfile = {
teamName: string | null;
image: string | null;
markdownStrippedBio: string | null | undefined;
};
type EventBookingPageProfile = { name: string; image: string };
type BaseMetadataArgs = {
hideBranding: boolean;
orgSlug: string | null;
isSEOIndexable: boolean;
isReschedule?: boolean;
};
type TeamProfilePageArgs = BaseMetadataArgs & {
profile: TeamProfilePageProfile;
event: null;
};
type UserProfilePageArgs = BaseMetadataArgs & {
profile: UserProfilePageProfile;
event: null;
};
type EventBookingPageArgs = BaseMetadataArgs & {
profile: EventBookingPageProfile;
event: {
title: string;
hidden: boolean;
users: { name: string | null; username: string | null }[];
};
};
export const generateEventBookingPageMetadata = async (props: EventBookingPageArgs) => {
const { profile, isReschedule, hideBranding, orgSlug, isSEOIndexable, event } = props;
const fullOrigin = getOrgFullOrigin(orgSlug);
const payload: MetadataPayload = {
title: (t) => `${isReschedule ? t("reschedule") : ""} ${event.title} | ${profile.name}`,
description: (t) => `${isReschedule ? t("reschedule") : ""} ${event.title}`,
meeting: {
title: event.title,
profile: { name: profile.name, image: profile.image },
users: event.users.map((user) => ({
name: `${user.name}`,
username: `${user.username}`,
})),
},
robots: {
index: !(event.hidden || !isSEOIndexable),
follow: !(event.hidden || !isSEOIndexable),
},
};
return {
...(await generateMeetingMetadata(
payload.meeting,
payload.title,
payload.description,
hideBranding,
fullOrigin
)),
robots: payload.robots,
};
};
export const generateUserProfilePageMetadata = async (props: UserProfilePageArgs) => {
const { profile, hideBranding, orgSlug, isSEOIndexable } = props;
const fullOrigin = getOrgFullOrigin(orgSlug);
const payload: MetadataPayload = {
title: () => profile.name ?? "",
description: () => profile.markdownStrippedBio ?? "",
meeting: {
title: profile.markdownStrippedBio ?? "",
profile: { name: profile.name ?? "", image: profile.image ?? "" },
users: [{ name: profile.name ?? "", username: profile.username ?? "" }],
},
robots: {
index: isSEOIndexable,
follow: isSEOIndexable,
},
};
return {
...(await generateMeetingMetadata(
payload.meeting,
payload.title,
payload.description,
hideBranding,
fullOrigin
)),
robots: payload.robots,
};
};
export const generateTeamProfilePageMetadata = async (props: TeamProfilePageArgs) => {
const { profile, hideBranding, orgSlug, isSEOIndexable } = props;
const fullOrigin = getOrgFullOrigin(orgSlug);
const payload: MetadataPayload = {
title: (t) => profile.teamName || t("nameless_team"),
description: () => profile.markdownStrippedBio ?? "",
meeting: {
title: profile.markdownStrippedBio ?? "",
profile: { name: `${profile.teamName}`, image: profile.image ?? "" },
},
robots: {
index: isSEOIndexable,
follow: isSEOIndexable,
},
};
return {
...(await generateMeetingMetadata(
payload.meeting,
payload.title,
payload.description,
hideBranding,
fullOrigin
)),
robots: payload.robots,
};
};
+5
View File
@@ -0,0 +1,5 @@
import { WithLayout } from "app/layoutHOC";
import { getInsightsLayout } from "~/insights/layout";
export default WithLayout({ getLayout: getInsightsLayout });
+11 -6
View File
@@ -1,10 +1,8 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { notFound } from "next/navigation";
import { getServerSideProps } from "@lib/insights/getServerSideProps";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import type { PageProps } from "~/insights/insights-view";
import InsightsPage from "~/insights/insights-view";
export const generateMetadata = async () =>
@@ -13,6 +11,13 @@ export const generateMetadata = async () =>
(t) => t("insights_subtitle")
);
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default async function Page() {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const insightsEnabled = await getFeatureFlag(prisma, "insights");
export default WithLayout({ getLayout: null, getData, Page: InsightsPage });
if (!insightsEnabled) {
return notFound();
}
return <InsightsPage />;
}
+11 -5
View File
@@ -1,8 +1,7 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { notFound } from "next/navigation";
import { getServerSideProps } from "@lib/insights/getServerSideProps";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import InsightsRoutingPage from "~/insights/insights-routing-view";
@@ -12,6 +11,13 @@ export const generateMetadata = async () =>
(t) => t("insights_subtitle")
);
const getData = withAppDirSsr(getServerSideProps);
export default async function Page() {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const insightsEnabled = await getFeatureFlag(prisma, "insights");
export default WithLayout({ getLayout: null, getData, Page: InsightsRoutingPage });
if (!insightsEnabled) {
return notFound();
}
return <InsightsRoutingPage />;
}
+11 -5
View File
@@ -1,8 +1,7 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { notFound } from "next/navigation";
import { getServerSideProps } from "@lib/insights/getServerSideProps";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import InsightsVirtualQueuesPage from "~/insights/insights-virtual-queues-view";
@@ -12,6 +11,13 @@ export const generateMetadata = async () =>
(t) => t("insights_subtitle")
);
const getData = withAppDirSsr(getServerSideProps);
export default async function Page() {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const insightsEnabled = await getFeatureFlag(prisma, "insights");
export default WithLayout({ getLayout: null, getData, Page: InsightsVirtualQueuesPage });
if (!insightsEnabled) {
return notFound();
}
return <InsightsVirtualQueuesPage />;
}
+4 -7
View File
@@ -1,4 +1,3 @@
import { TrpcProvider } from "app/_trpc/trpc-provider";
import { dir } from "i18next";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
@@ -11,6 +10,7 @@ import { IconSprites } from "@calcom/ui";
import { prepareRootMetadata } from "@lib/metadata";
import "../styles/globals.css";
import { Providers } from "./providers";
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
const calFont = localFont({
@@ -26,10 +26,7 @@ export const generateMetadata = () => prepareRootMetadata();
const getInitialProps = async (url: string) => {
const { pathname, searchParams } = new URL(url);
const isEmbedSnippetGeneratorPath = pathname.startsWith("/event-types");
const isEmbed =
(pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null) &&
!isEmbedSnippetGeneratorPath;
const isEmbed = pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null;
const embedColorScheme = searchParams?.get("ui.color-scheme");
const req = { headers: headers(), cookies: cookies() };
@@ -122,7 +119,6 @@ export default async function RootLayout({ children }: { children: React.ReactNo
--font-cal: ${calFont.style.fontFamily.replace(/\'/g, "")};
}
`}</style>
<IconSprites />
</head>
<body
className="dark:bg-darkgray-50 bg-subtle antialiased"
@@ -138,6 +134,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
}
: {}
}>
<IconSprites />
{!!process.env.NEXT_PUBLIC_BODY_SCRIPTS && (
<script
nonce={nonce}
@@ -148,7 +145,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
}}
/>
)}
<TrpcProvider>{children}</TrpcProvider>
<Providers>{children}</Providers>
</body>
</html>
);
@@ -1,9 +1,11 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { generateEventBookingPageMetadata } from "app/generateBookingPageMetadata";
import { generateMeetingMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
@@ -23,28 +25,36 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const rescheduleUid = booking?.uid;
const profileName = eventData?.profile?.name ?? "";
const profileImage = eventData?.profile.image ?? "";
return await generateEventBookingPageMetadata({
const profileImage = eventData?.profile.image;
const title = eventData?.title ?? "";
const meeting = {
title,
profile: { name: profileName, image: profileImage },
event: {
title: eventData?.title ?? "",
hidden: eventData?.hidden ?? false,
users: [
...(eventData?.users || []).map((user) => ({
name: `${user.name}`,
username: `${user.username}`,
})),
],
users: [
...(eventData?.users || []).map((user) => ({
name: `${user.name}`,
username: `${user.username}`,
})),
],
};
const metadata = await generateMeetingMetadata(
meeting,
(t) => `${rescheduleUid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`,
(t) => `${rescheduleUid ? t("reschedule") : ""} ${title}`,
isBrandingHidden,
getOrgFullOrigin(eventData?.entity.orgSlug ?? null)
);
return {
...metadata,
robots: {
follow: !(eventData?.hidden || !isSEOIndexable),
index: !(eventData?.hidden || !isSEOIndexable),
},
hideBranding: isBrandingHidden,
orgSlug: eventData?.entity.orgSlug ?? null,
isSEOIndexable: !!isSEOIndexable,
isReschedule: !!rescheduleUid,
});
};
};
export const Page = async (props: OrgTypePageProps) => {
const Page = async (props: OrgTypePageProps) => {
if ((props as TeamTypePageProps)?.teamId) {
return <TeamTypePage {...(props as TeamTypePageProps)} />;
}
@@ -1,64 +0,0 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import {
generateTeamProfilePageMetadata,
generateUserProfilePageMetadata,
} from "app/generateBookingPageMetadata";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { getOrgOrTeamAvatar } from "@calcom/lib/defaultAvatarImage";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/getServerSideProps";
import type { PageProps as TeamPageProps } from "~/team/team-view";
import TeamPage from "~/team/team-view";
import UserPage from "~/users/views/users-public-view";
import type { PageProps as UserPageProps } from "~/users/views/users-public-view";
export type OrgPageProps = UserPageProps | TeamPageProps;
const getData = withAppDirSsr<OrgPageProps>(getServerSideProps);
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getData(legacyCtx);
if ((props as TeamPageProps)?.team) {
const { team, markdownStrippedBio, isSEOIndexable, currentOrgDomain } = props as TeamPageProps;
return await generateTeamProfilePageMetadata({
profile: {
teamName: team.name,
image: getOrgOrTeamAvatar(team),
markdownStrippedBio: markdownStrippedBio,
},
event: null,
hideBranding: false,
orgSlug: currentOrgDomain ?? null,
isSEOIndexable: !!isSEOIndexable,
});
} else {
const { profile, markdownStrippedBio, isOrgSEOIndexable, entity } = props as UserPageProps;
const isOrg = !!profile?.organization;
const allowSEOIndexing =
(!isOrg && profile.allowSEOIndexing) || (isOrg && isOrgSEOIndexable && profile.allowSEOIndexing);
return await generateUserProfilePageMetadata({
profile: { name: profile.name, username: profile.username, image: profile.image, markdownStrippedBio },
hideBranding: false,
orgSlug: entity.orgSlug ?? null,
isSEOIndexable: !!allowSEOIndexing,
event: null,
});
}
};
export const Page = async (props: OrgPageProps) => {
if ((props as TeamPageProps)?.team) {
return <TeamPage {...(props as TeamPageProps)} />;
}
return <UserPage {...(props as UserPageProps)} />;
};
export default WithLayout({ getLayout: null, getData, ServerPage: Page, isBookingPage: true });
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { TrpcProvider } from "app/_trpc/trpc-provider";
import { SessionProvider } from "next-auth/react";
import CacheProvider from "react-inlinesvg/provider";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<TrpcProvider>
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
<CacheProvider>{children}</CacheProvider>
</TrpcProvider>
</SessionProvider>
);
}
@@ -0,0 +1,14 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import ManagedUsersView from "~/settings/platform/managed-users/managed-users-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("platform_members"),
(t) => t("platform_members_description")
);
export default WithLayout({
Page: ManagedUsersView,
});
+33 -32
View File
@@ -1,52 +1,53 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { generateEventBookingPageMetadata } from "app/generateBookingPageMetadata";
import { generateMeetingMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventRepository } from "@calcom/lib/server/repository/event";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
import LegacyPage, { type PageProps as LegacyPageProps } from "~/team/type-view";
import LegacyPage from "~/team/type-view";
import type { PageProps as LegacyPageProps } from "~/team/type-view";
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getData(legacyCtx);
const { user: username, slug: eventSlug, booking, isSEOIndexable, eventData, isBrandingHidden } = props;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(legacyCtx.req, legacyCtx.params?.orgSlug);
const { booking, isSEOIndexable, eventData, isBrandingHidden } = props;
const event = await EventRepository.getPublicEvent({
username,
eventSlug,
isTeamEvent: true,
org: isValidOrgDomain ? currentOrgDomain : null,
fromRedirectOfNonOrgLink: legacyCtx.query.orgRedirection === "true",
});
const profileName = eventData?.profile?.name ?? "";
const profileImage = eventData?.profile.image;
const title = eventData?.title ?? "";
const meeting = {
title,
profile: { name: profileName, image: profileImage },
users: [
...(eventData?.users || []).map((user) => ({
name: `${user.name}`,
username: `${user.username}`,
})),
],
};
return await generateEventBookingPageMetadata({
profile: {
name: event?.profile?.name ?? "",
image: event?.profile.image ?? "",
const metadata = await generateMeetingMetadata(
meeting,
(t) => `${booking?.uid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`,
(t) => `${booking?.uid ? t("reschedule") : ""} ${title}`,
isBrandingHidden,
getOrgFullOrigin(eventData.entity.orgSlug ?? null)
);
return {
...metadata,
robots: {
follow: !(eventData?.hidden || !isSEOIndexable),
index: !(eventData?.hidden || !isSEOIndexable),
},
event: {
title: event?.title ?? "",
hidden: event?.hidden ?? false,
users: [
...(event?.users || []).map((user) => ({
name: `${user.name}`,
username: `${user.username}`,
})),
],
},
hideBranding: isBrandingHidden,
orgSlug: eventData?.entity.orgSlug ?? null,
isSEOIndexable,
isReschedule: !!booking,
});
};
};
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
export default WithLayout({
+21 -9
View File
@@ -1,9 +1,10 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps as _PageProps } from "app/_types";
import { generateTeamProfilePageMetadata } from "app/generateBookingPageMetadata";
import { generateMeetingMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getOrgOrTeamAvatar } from "@calcom/lib/defaultAvatarImage";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
@@ -17,17 +18,28 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
buildLegacyCtx(headers(), cookies(), params, searchParams)
);
return await generateTeamProfilePageMetadata({
const meeting = {
title: markdownStrippedBio ?? "",
profile: {
teamName: team.name,
name: `${team.name}`,
image: getOrgOrTeamAvatar(team),
markdownStrippedBio: markdownStrippedBio,
},
event: null,
hideBranding: false,
orgSlug: currentOrgDomain ?? null,
isSEOIndexable: !!isSEOIndexable,
});
};
const metadata = await generateMeetingMetadata(
meeting,
(t) => team.name || t("nameless_team"),
(t) => team.name || t("nameless_team"),
false,
getOrgFullOrigin(currentOrgDomain ?? null)
);
return {
...metadata,
robots: {
follow: isSEOIndexable,
index: isSEOIndexable,
},
};
};
const getData = withAppDirSsr<PageProps>(getServerSideProps);
@@ -2,6 +2,8 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps as _PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { notFound } from "next/navigation";
import { z } from "zod";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
@@ -10,10 +12,17 @@ import { getServerSideProps } from "@lib/video/meeting-not-started/[uid]/getServ
import type { PageProps } from "~/videos/views/videos-meeting-not-started-single-view";
import MeetingNotStarted from "~/videos/views/videos-meeting-not-started-single-view";
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
const p = { ...params, ...searchParams };
const querySchema = z.object({
uid: z.string(),
});
export const generateMetadata = async ({ params }: _PageProps) => {
const parsed = querySchema.safeParse(params);
if (!parsed.success) {
notFound();
}
const booking = await BookingRepository.findBookingByUid({
bookingUid: typeof p?.uid === "string" ? p.uid : "",
bookingUid: parsed.data.uid,
});
return await _generateMetadata(
+4 -4
View File
@@ -21,8 +21,8 @@ const querySchema = z.object({
const getWorkflow = cache((id: number) => WorkflowRepository.getById({ id }));
export const generateMetadata = async ({ params, searchParams }: PageProps): Promise<Metadata | null> => {
const parsed = querySchema.safeParse({ ...params, ...searchParams });
export const generateMetadata = async ({ params }: PageProps): Promise<Metadata | null> => {
const parsed = querySchema.safeParse(params);
if (!parsed.success) {
notFound();
}
@@ -38,11 +38,11 @@ export const generateMetadata = async ({ params, searchParams }: PageProps): Pro
export const generateStaticParams = () => [];
const Page = async ({ params, searchParams }: PageProps) => {
const Page = async ({ params }: PageProps) => {
// FIXME: Refactor me once next-auth endpoint is migrated to App Router
// const session = await getServerSessionForAppDir();
// const user = session?.user;
const parsed = querySchema.safeParse({ ...params, ...searchParams });
const parsed = querySchema.safeParse(params);
if (!parsed.success) {
notFound();
}
+3 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { ShellMain } from "@calcom/features/shell/Shell";
import Shell from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, ButtonGroup, Icon } from "@calcom/ui";
@@ -42,7 +42,7 @@ export default function EnterprisePage() {
];
return (
<div>
<ShellMain heading={t("enterprise")} subtitle={t("enterprise_description")}>
<Shell heading={t("enterprise")} subtitle={t("enterprise_description")} withoutSeo={true}>
<UpgradeTip
plan="enterprise"
title={t("create_your_org")}
@@ -63,7 +63,7 @@ export default function EnterprisePage() {
}>
<>Create Org</>
</UpgradeTip>
</ShellMain>
</Shell>
</div>
);
}
@@ -4,6 +4,7 @@ import React from "react";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { ScrollableArea, Badge, Button, Avatar } from "@calcom/ui";
@@ -58,9 +59,13 @@ const EventTypeCard: FC<EventTypeCardProps> = ({
</small>
</div>
{Boolean(description) && (
<div className="text-subtle line-clamp-4 break-words text-sm sm:max-w-[650px] [&>*:not(:first-child)]:hidden [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600">
{description}
</div>
<div
className="text-subtle line-clamp-4 break-words text-sm sm:max-w-[650px] [&>*:not(:first-child)]:hidden [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: markdownToSafeHTML(description),
}}
/>
)}
<div className="mt-2 flex flex-row flex-wrap gap-2">
{Boolean(durations.length) &&
+14 -14
View File
@@ -511,9 +511,9 @@ function BookingListItem(booking: BookingItemProps) {
</DialogFooter>
</DialogContent>
</Dialog>
<tr data-testid="booking-item" className="hover:bg-muted group transition ">
<div data-testid="booking-item" className="hover:bg-muted group w-full">
<div className="flex flex-col sm:flex-row">
<td className="hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell sm:min-w-[12rem]">
<div className="hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell sm:min-w-[12rem]">
<div className="flex h-full items-center">
{eventTypeColor && (
<div className="h-[70%] w-0.5" style={{ backgroundColor: eventTypeColor }} />
@@ -562,8 +562,10 @@ function BookingListItem(booking: BookingItemProps) {
</div>
</Link>
</div>
</td>
<td data-testid="title-and-attendees" className={`w-full px-4${isRejected ? " line-through" : ""}`}>
</div>
<div
data-testid="title-and-attendees"
className={`w-full px-4${isRejected ? " line-through" : ""}`}>
<Link href={bookingLink}>
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
@@ -648,8 +650,8 @@ function BookingListItem(booking: BookingItemProps) {
)}
</div>
</Link>
</td>
<td className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 space-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:space-y-0 sm:pl-0">
</div>
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 space-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:space-y-0 sm:pl-0">
{isUpcoming && !isCancelled ? (
<>
{isPending && <TableActions actions={pendingActions} />}
@@ -674,7 +676,7 @@ function BookingListItem(booking: BookingItemProps) {
<TableActions actions={chargeCardActions} />
</div>
)}
</td>
</div>
</div>
<BookingItemBadges
booking={booking}
@@ -683,7 +685,7 @@ function BookingListItem(booking: BookingItemProps) {
userTimeFormat={userTimeFormat}
userTimeZone={userTimeZone}
/>
</tr>
</div>
{isBookingReroutable(parsedBooking) && (
<RerouteDialog
@@ -712,7 +714,7 @@ const BookingItemBadges = ({
const { t } = useLocale();
return (
<div className="hidden h-9 flex-row pb-4 pl-6 sm:flex">
<div className="hidden h-9 flex-row items-center pb-4 pl-6 sm:flex">
{isPending && (
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
{t("unconfirmed")}
@@ -1251,11 +1253,9 @@ const AssignmentReasonTooltip = ({ assignmentReason }: { assignmentReason: Assig
const badgeTitle = assignmentReasonBadgeTitleMap(assignmentReason.reasonEnum);
return (
<Tooltip content={<p>{assignmentReason.reasonString}</p>}>
<div className="-mt-1">
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
{t(badgeTitle)}
</Badge>
</div>
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
{t(badgeTitle)}
</Badge>
</Tooltip>
);
};
+31 -12
View File
@@ -1,6 +1,9 @@
"use client";
import React from "react";
import { HttpError } from "@calcom/lib/http-error";
import { Button } from "@calcom/ui";
type Props = {
statusCode?: number | null;
@@ -53,21 +56,37 @@ export const ErrorPage: React.FC<Props> = (props) => {
return (
<>
<div className="bg-default min-h-screen px-4">
<main className="mx-auto max-w-xl pb-6 pt-16 sm:pt-24">
<div className="text-center">
<p className="text-emphasis text-sm font-semibold uppercase tracking-wide">{statusCode}</p>
<h1 className="text-emphasis mt-2 text-4xl font-extrabold tracking-tight sm:text-5xl">
<div className="bg-subtle flex h-screen">
<div className="rtl: bg-default m-auto rounded-md p-10 text-right ltr:text-left">
<h1 className="font-cal text-emphasis text-6xl">{statusCode}</h1>
<h2 className="text-emphasis mt-6 max-w-2xl text-2xl font-medium">
It&apos;s not you, it&apos;s us.
</h2>
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">
Something went wrong on our end. Get in touch with our support team, and we&apos;ll get it fixed
right away for you.
</p>
<div className="mb-8 flex flex-col">
<p className="text-default mb-4 max-w-2xl text-sm">
Please provide the following text when contacting support to better help you:
</p>
<pre className="bg-emphasis text-emphasis w-full max-w-2xl whitespace-normal break-words rounded-md p-4">
{message}
</h1>
</pre>
</div>
</main>
{displayDebug && (
<div className="flex-wrap">
<ErrorDebugPanel error={error} />
</div>
)}
<Button href="mailto:support@cal.com">Contact Support</Button>
<Button color="secondary" href="javascript:history.back()" className="ml-2">
Go back
</Button>
</div>
</div>
{displayDebug && (
<div className="flex-wrap">
<ErrorDebugPanel error={error} />
</div>
)}
</>
);
};
@@ -1,33 +0,0 @@
import type { PlatformOAuthClient } from "@calcom/prisma/client";
import { OAuthClientsDropdown } from "@components/settings/platform/dashboard/oauth-client-dropdown";
type ManagedUserHeaderProps = {
oauthClients: PlatformOAuthClient[];
initialClientName: string;
handleChange: (clientId: string, clientName: string) => void;
};
export const ManagedUserHeader = ({
oauthClients,
initialClientName,
handleChange,
}: ManagedUserHeaderProps) => {
return (
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
<div className="flex w-full flex-col">
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
Managed Users
</h1>
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
See all the managed users created by your OAuth client.
</p>
</div>
<OAuthClientsDropdown
oauthClients={oauthClients}
initialClientName={initialClientName}
handleChange={handleChange}
/>
</div>
);
};
@@ -1,39 +0,0 @@
import type { PlatformOAuthClient } from "@calcom/prisma/client";
import type { ManagedUser } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
import { ManagedUserHeader } from "@components/settings/platform/dashboard/managed-user-header";
import { ManagedUserTable } from "@components/settings/platform/dashboard/managed-user-table";
type ManagedUserListProps = {
oauthClients: PlatformOAuthClient[];
managedUsers?: ManagedUser[];
initialClientName: string;
initialClientId: string;
isManagedUserLoading: boolean;
handleChange: (clientId: string, clientName: string) => void;
};
export const ManagedUserList = ({
initialClientName,
initialClientId,
oauthClients,
managedUsers,
isManagedUserLoading,
handleChange,
}: ManagedUserListProps) => {
return (
<div>
<ManagedUserHeader
oauthClients={oauthClients}
initialClientName={initialClientName}
handleChange={handleChange}
/>
<ManagedUserTable
managedUsers={managedUsers}
isManagedUserLoading={isManagedUserLoading}
initialClientId={initialClientId}
/>
</div>
);
};
@@ -1,60 +0,0 @@
import { EmptyScreen } from "@calcom/ui";
import type { ManagedUser } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
type ManagedUserTableProps = {
managedUsers?: ManagedUser[];
isManagedUserLoading: boolean;
initialClientId: string;
};
export const ManagedUserTable = ({
managedUsers,
isManagedUserLoading,
initialClientId,
}: ManagedUserTableProps) => {
const showUsers = !isManagedUserLoading && managedUsers?.length;
return (
<div>
{showUsers ? (
<>
<table className="w-[100%] rounded-lg">
<colgroup className="border-subtle overflow-hidden rounded-b-lg border border-b-0" span={3} />
<tr>
<td className="border-subtle border px-4 py-3 md:text-center">Id</td>
<td className="border-subtle border px-4 py-3 md:text-center">Username</td>
<td className="border-subtle border px-4 py-3 md:text-center">Email</td>
</tr>
{managedUsers.map((user) => {
return (
<tr key={user.id} className="">
<td className="border-subtle overflow-hidden border px-4 py-3 md:text-center">{user.id}</td>
<td className="border-subtle border px-4 py-3 md:text-center">{user.username}</td>
<td className="border-subtle overflow-hidden border px-4 py-3 md:overflow-auto md:text-center">
{user.email}
</td>
</tr>
);
})}
</table>
</>
) : (
<EmptyScreen
limitWidth={false}
headline={
initialClientId == undefined
? "OAuth client is missing. You need to create an OAuth client first in order to create a managed user."
: `OAuth client ${initialClientId} does not have a managed user present.`
}
description={
initialClientId == undefined
? "Refer to the Platform Docs from the sidebar in order to create an OAuth client."
: "Refer to the Platform Docs from the sidebar in order to create a managed user."
}
className="items-center border"
/>
)}
</div>
);
};
@@ -1,52 +0,0 @@
import type { PlatformOAuthClient } from "@calcom/prisma/client";
import {
Button,
Dropdown,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownItem,
} from "@calcom/ui";
type OAuthClientsDropdownProps = {
oauthClients: PlatformOAuthClient[];
initialClientName: string;
handleChange: (clientId: string, clientName: string) => void;
};
export const OAuthClientsDropdown = ({
oauthClients,
initialClientName,
handleChange,
}: OAuthClientsDropdownProps) => {
return (
<div>
{Array.isArray(oauthClients) && oauthClients.length > 0 ? (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button color="secondary" EndIcon="chevron-down">
{initialClientName}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{oauthClients.map((client) => {
return (
<div key={client.id}>
{initialClientName !== client.name ? (
<DropdownMenuItem className="outline-none">
<DropdownItem type="button" onClick={() => handleChange(client.id, client.name)}>
{client.name}
</DropdownItem>
</DropdownMenuItem>
) : (
<></>
)}
</div>
);
})}
</DropdownMenuContent>
</Dropdown>
) : null}
</div>
);
};
+48
View File
@@ -0,0 +1,48 @@
const isSingleOrgModeEnabled = !!process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG;
const orgSlugCaptureGroupName = "orgSlug";
/**
* Returns the leftmost subdomain from a given URL.
* It needs the URL domain to have atleast two dots.
* app.cal.com -> app
* app.company.cal.com -> app
* app.company.com -> app
*/
const getLeftMostSubdomain = (url) => {
if (!url.startsWith("http:") && !url.startsWith("https:")) {
// Make it a valid URL. Mabe we can simply return null and opt-out from orgs support till the use a URL scheme.
url = `https://${url}`;
}
const _url = new URL(url);
const regex = new RegExp(/^([a-z]+\:\/{2})?((?<subdomain>[\w-.]+)\.[\w-]+\.\w+)$/);
//console.log(_url.hostname, _url.hostname.match(regex));
return _url.hostname.match(regex)?.groups?.subdomain || null;
};
const getRegExpNotMatchingLeftMostSubdomain = (url) => {
const leftMostSubdomain = getLeftMostSubdomain(url);
const subdomain = leftMostSubdomain ? `(?!${leftMostSubdomain})[^.]+` : "[^.]+";
return subdomain;
};
// For app.cal.com, it will match all domains that are not starting with "app". Technically we would want to match domains like acme.cal.com, dunder.cal.com and not app.cal.com
const getRegExpThatMatchesAllOrgDomains = (exports.getRegExpThatMatchesAllOrgDomains = ({ webAppUrl }) => {
if (isSingleOrgModeEnabled) {
console.log("Single-Org-Mode enabled - Consider all domains to be org domains");
// It works in combination with next.config.js where in this case we use orgSlug=NEXT_PUBLIC_SINGLE_ORG_SLUG
return `.*`;
}
const subdomainRegExp = getRegExpNotMatchingLeftMostSubdomain(webAppUrl);
return `^(?<${orgSlugCaptureGroupName}>${subdomainRegExp})\\.(?!vercel\.app).*`;
});
const nextJsOrgRewriteConfig = {
// :orgSlug is special value which would get matching group from the regex in orgHostPath
orgSlug: process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG || `:${orgSlugCaptureGroupName}`,
orgHostPath: getRegExpThatMatchesAllOrgDomains({
webAppUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || `https://${process.env.VERCEL_URL}`,
}),
// We disable root path rewrite because we want to serve dashboard on root path
disableRootPathRewrite: isSingleOrgModeEnabled,
};
exports.nextJsOrgRewriteConfig = nextJsOrgRewriteConfig;
-15
View File
@@ -1,15 +0,0 @@
const getDefaultSubdomain = (url) => {
if (!url.startsWith("http:") && !url.startsWith("https:")) {
// Make it a valid URL. Mabe we can simply return null and opt-out from orgs support till the use a URL scheme.
url = `https://${url}`;
}
const _url = new URL(url);
const regex = new RegExp(/^([a-z]+\:\/{2})?((?<subdomain>[\w-.]+)\.[\w-]+\.\w+)$/);
//console.log(_url.hostname, _url.hostname.match(regex));
return _url.hostname.match(regex)?.groups?.subdomain || null;
};
exports.getSubdomainRegExp = (url) => {
const defaultSubdomain = getDefaultSubdomain(url);
const subdomain = defaultSubdomain ? `(?!${defaultSubdomain})[^.]+` : "[^.]+";
return subdomain;
};
@@ -166,6 +166,13 @@ describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => {
schedulingType: SchedulingType.ROUND_ROBIN,
metadata: null,
length: 30,
hosts: [
{
user: {
email: "owner@example.com",
},
},
],
};
it("should return null when email is not provided in query", async () => {
@@ -235,7 +242,7 @@ describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => {
});
it("should return null when CRM owner is not part of event type", async () => {
const ownerEmail = "owner@example.com";
const ownerEmail = "ownerNotInEventType@example.com";
const bookerEmail = "booker@example.com";
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail: ownerEmail });
@@ -331,7 +338,7 @@ describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => {
it("should return null when when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided but the returned email isn't an event member", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
const teamMemberEmail = "ownerNotInEventType@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
const SOME_OTHER_EVENT_ID = 200;
await createHostForEvent({
+16 -25
View File
@@ -2,7 +2,7 @@ import { TooltipProvider } from "@radix-ui/react-tooltip";
import { HydrateClient } from "app/_trpc/HydrateClient";
import { dir } from "i18next";
import type { Session } from "next-auth";
import { SessionProvider, useSession } from "next-auth/react";
import { useSession } from "next-auth/react";
import { EventCollectionProvider } from "next-collect/client";
import type { SSRConfig } from "next-i18next";
import { appWithTranslation } from "next-i18next";
@@ -12,14 +12,12 @@ import type { ReadonlyURLSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import type { ReactNode } from "react";
import { useEffect } from "react";
import CacheProvider from "react-inlinesvg/provider";
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
import { FeatureProvider } from "@calcom/features/flags/context/provider";
import { useFlags } from "@calcom/features/flags/hooks";
import { MetaProvider } from "@calcom/ui";
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
import PlainChat from "@lib/plain/plainChat";
@@ -268,28 +266,21 @@ const AppProviders = (props: PageWrapperProps) => {
const RemainingProviders = (
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
<SessionProvider>
<PlainChat />
<CustomI18nextProvider i18n={props.i18n}>
<TooltipProvider>
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
<CalcomThemeProvider
themeBasis={props.themeBasis}
nonce={props.nonce}
isThemeSupported={/* undefined gets treated as true */ props.isThemeSupported}
isBookingPage={props.isBookingPage || isBookingPage}>
<FeatureFlagsProvider>
<OrgBrandProvider>
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
<CacheProvider>
<MetaProvider>{props.children}</MetaProvider>
</CacheProvider>
</OrgBrandProvider>
</FeatureFlagsProvider>
</CalcomThemeProvider>
</TooltipProvider>
</CustomI18nextProvider>
</SessionProvider>
<PlainChat />
<CustomI18nextProvider i18n={props.i18n}>
<TooltipProvider>
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
<CalcomThemeProvider
themeBasis={props.themeBasis}
nonce={props.nonce}
isThemeSupported={/* undefined gets treated as true */ props.isThemeSupported}
isBookingPage={props.isBookingPage || isBookingPage}>
<FeatureFlagsProvider>
<OrgBrandProvider>{props.children}</OrgBrandProvider>
</FeatureFlagsProvider>
</CalcomThemeProvider>
</TooltipProvider>
</CustomI18nextProvider>
</EventCollectionProvider>
);
const Hydrated = props.dehydratedState ? (
+18 -24
View File
@@ -1,7 +1,7 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { dir } from "i18next";
import type { Session } from "next-auth";
import { SessionProvider, useSession } from "next-auth/react";
import { useSession } from "next-auth/react";
import { EventCollectionProvider } from "next-collect/client";
import type { SSRConfig } from "next-i18next";
import { appWithTranslation } from "next-i18next";
@@ -11,7 +11,6 @@ import dynamic from "next/dynamic";
import type { ParsedUrlQuery } from "querystring";
import type { PropsWithChildren, ReactNode } from "react";
import { useEffect } from "react";
import CacheProvider from "react-inlinesvg/provider";
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
@@ -296,28 +295,23 @@ const AppProviders = (props: AppPropsWithChildren) => {
const RemainingProviders = (
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
<SessionProvider session={pageProps.session ?? undefined}>
<PlainChat />
<CustomI18nextProvider {...propsWithoutNonce}>
<TooltipProvider>
<CalcomThemeProvider
themeBasis={props.pageProps.themeBasis}
nonce={props.pageProps.nonce}
isThemeSupported={props.Component.isThemeSupported}
isBookingPage={props.Component.isBookingPage || isBookingPage}
router={props.router}>
<FeatureFlagsProvider>
<OrgBrandProvider>
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
<CacheProvider>
<MetaProvider>{props.children}</MetaProvider>
</CacheProvider>
</OrgBrandProvider>
</FeatureFlagsProvider>
</CalcomThemeProvider>
</TooltipProvider>
</CustomI18nextProvider>
</SessionProvider>
<PlainChat />
<CustomI18nextProvider {...propsWithoutNonce}>
<TooltipProvider>
<CalcomThemeProvider
themeBasis={props.pageProps.themeBasis}
nonce={props.pageProps.nonce}
isThemeSupported={props.Component.isThemeSupported}
isBookingPage={props.Component.isBookingPage || isBookingPage}
router={props.router}>
<FeatureFlagsProvider>
<OrgBrandProvider>
<MetaProvider>{props.children}</MetaProvider>
</OrgBrandProvider>
</FeatureFlagsProvider>
</CalcomThemeProvider>
</TooltipProvider>
</CustomI18nextProvider>
</EventCollectionProvider>
);
+23 -1
View File
@@ -26,6 +26,25 @@ const buildLegacyCookies = (cookies: ReadonlyRequestCookies) => {
return createProxifiedObject(cookiesObject);
};
function decodeParams(params: Params): Params {
return Object.entries(params).reduce((acc, [key, value]) => {
// Handle array values
if (Array.isArray(value)) {
acc[key] = value.map((item) => decodeURIComponent(item));
return acc;
}
// Handle single values
if (value !== undefined) {
acc[key] = decodeURIComponent(value);
} else {
acc[key] = value;
}
return acc;
}, {} as Params);
}
export const buildLegacyCtx = (
headers: ReadonlyHeaders,
cookies: ReadonlyRequestCookies,
@@ -34,7 +53,10 @@ export const buildLegacyCtx = (
) => {
return {
query: { ...searchParams, ...params },
params,
// decoding is required to be backward compatible with Pages Router
// because Next.js App Router does not auto-decode query params while Pages Router does
// e.g., params: { name: "John%20Doe" } => params: { name: "John Doe" }
params: decodeParams(params),
req: { headers: buildLegacyHeaders(headers), cookies: buildLegacyCookies(cookies) },
res: new Proxy(Object.create(null), {
// const { req, res } = ctx - valid
+8 -2
View File
@@ -1,6 +1,5 @@
import type { IncomingMessage, OutgoingMessage } from "http";
import type { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -47,6 +46,13 @@ const isPagePathRequest = (url: URL) => {
return !isNonPagePathPrefix.test(pathname) && !isFile.test(pathname);
};
function safeParseString(value: unknown): { success: boolean; data?: string } {
if (typeof value === "string") {
return { success: true, data: value };
}
return { success: false };
}
export function csp(req: IncomingMessage | NextRequest | null, res: OutgoingMessage | NextResponse | null) {
if (!req) {
return { nonce: undefined };
@@ -54,7 +60,7 @@ export function csp(req: IncomingMessage | NextRequest | null, res: OutgoingMess
const existingNonce = "cache" in req ? req.headers.get("x-nonce") : req.headers["x-nonce"];
if (existingNonce) {
const existingNoneParsed = z.string().safeParse(existingNonce);
const existingNoneParsed = safeParseString(existingNonce);
return { nonce: existingNoneParsed.success ? existingNoneParsed.data : "" };
}
if (!req.url) {
@@ -121,6 +121,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
return {
props: {
eventData,
entity: eventData.entity,
duration: getMultipleDurationValue(
eventData.metadata?.multipleDuration,
@@ -1,15 +0,0 @@
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
// If feature flag is disabled, return not found on getServerSideProps
export const getServerSideProps = async () => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const insightsEnabled = await getFeatureFlag(prisma, "insights");
if (!insightsEnabled) {
return {
notFound: true,
} as const;
}
return { props: {} };
};
@@ -1,3 +1,4 @@
import { getServerSideProps as GSSUserPage } from "@pages/[user]";
import type { GetServerSidePropsContext } from "next";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
@@ -5,8 +6,6 @@ import prisma from "@calcom/prisma";
import { getServerSideProps as GSSTeamPage } from "@lib/team/[slug]/getServerSideProps";
import { getServerSideProps as GSSUserPage } from "@server/lib/[user]/getServerSideProps";
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const team = await prisma.team.findFirst({
where: {
@@ -5,8 +5,8 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
@@ -92,6 +92,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
select: {
name: true,
username: true,
email: true,
},
},
},
@@ -178,7 +179,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventData,
});
const organizationSettings = OrganizationRepository.utils.getOrganizationSEOSettings(team);
const organizationSettings = getOrganizationSEOSettings(team);
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
if (!eventData) {
@@ -1,13 +1,17 @@
import type { GetServerSidePropsContext } from "next";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import {
getOrganizationSettings,
getVerifiedDomain,
} from "@calcom/features/ee/organizations/lib/orgSettings";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { IS_CALCOM } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import logger from "@calcom/lib/logger";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import slugify from "@calcom/lib/slugify";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import prisma from "@calcom/prisma";
@@ -30,8 +34,13 @@ function getOrgProfileRedirectToVerifiedDomain(
if (!team.isOrganization) {
return null;
}
// when this is not on a Cal.com page we don't auto redirect -
// good for diagnosis purposes.
if (!IS_CALCOM) {
return null;
}
const verifiedDomain = OrganizationRepository.utils.getVerifiedDomain(settings);
const verifiedDomain = getVerifiedDomain(settings);
if (!settings.orgProfileRedirectsToVerifiedDomain || !verifiedDomain) {
return null;
@@ -55,6 +64,7 @@ const getTheLastArrayElement = (value: ReadonlyArray<string> | string | undefine
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const slug = getTheLastArrayElement(context.query.slug) ?? getTheLastArrayElement(context.query.orgSlug);
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(
context.req,
context.params?.orgSlug ?? context.query?.orgSlug
@@ -144,7 +154,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
} as const;
}
const organizationSettings = OrganizationRepository.utils.getOrganizationSettings(team);
const organizationSettings = getOrganizationSettings(team);
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
const redirectToVerifiedDomain = organizationSettings
+11 -9
View File
@@ -22,7 +22,6 @@ const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const url = req.nextUrl;
const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-url", req.url);
if (!url.pathname.startsWith("/api")) {
@@ -155,6 +154,7 @@ export const config = {
matcher: [
"/403",
"/500",
"/icons",
"/d/:path*",
"/more/:path*",
"/maintenance/:path*",
@@ -173,8 +173,7 @@ export const config = {
*/
"/apps/routing_forms/:path*",
"/event-types",
"/future/event-types/",
"/event-types/:path*",
"/apps/installed/:category/",
"/future/apps/installed/:category/",
"/apps/:slug/",
@@ -188,17 +187,20 @@ export const config = {
"/workflows/:path*",
"/getting-started/:path*",
"/apps",
"/bookings/:status/",
"/bookings/:path*",
"/video/:path*",
"/teams",
"/future/teams/",
"/teams/:path*",
"/signup/:path*",
"/settings/:path*",
"/reschedule/:path*",
"/availability/:path*",
"/org/:path*",
"/booking/:path*",
"/routing-forms/:path*",
"/team/:path*",
"/:user/:type/",
"/:user/",
"/org/[orgSlug]/[user]/[type]",
"/org/[orgSlug]/team/[slug]/[type]",
"/org/[orgSlug]/team/[slug]",
"/org/[orgSlug]",
],
};
@@ -237,6 +237,7 @@ export default function AvailabilityPage({ currentOrg }: PageProps) {
title={t("availability")}
description={t("configure_availability")}
hideHeadingOnMobile
withoutSeo={true}
withoutMain={false}
CTA={
<div className="flex gap-2">
@@ -1,8 +1,15 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Fragment, useState } from "react";
import { z } from "zod";
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
createColumnHelper,
} from "@tanstack/react-table";
import type { ReactElement } from "react";
import { Fragment, useMemo, useState } from "react";
import type { z } from "zod";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
import dayjs from "@calcom/dayjs";
@@ -10,20 +17,20 @@ import { FilterToggle } from "@calcom/features/bookings/components/FilterToggle"
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
import { DataTableProvider, DataTableWrapper } from "@calcom/features/data-table";
import Shell from "@calcom/features/shell/Shell";
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { HorizontalTabItemProps, VerticalTabItemProps } from "@calcom/ui";
import { Alert, Button, EmptyScreen, HorizontalTabs } from "@calcom/ui";
import { Alert, EmptyScreen, HorizontalTabs } from "@calcom/ui";
import useMeQuery from "@lib/hooks/useMeQuery";
import BookingListItem from "@components/booking/BookingListItem";
import SkeletonLoader from "@components/booking/SkeletonLoader";
import { validStatuses } from "~/bookings/lib/validStatuses";
import type { validStatuses } from "~/bookings/lib/validStatuses";
type BookingListingStatus = z.infer<NonNullable<typeof filterQuerySchema>>["status"];
type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][0];
@@ -66,7 +73,24 @@ const descriptionByStatus: Record<NonNullable<BookingListingStatus>, string> = {
unconfirmed: "unconfirmed_bookings",
};
export default function Bookings({ status }: { status: (typeof validStatuses)[number] }) {
type BookingsProps = {
status: (typeof validStatuses)[number];
};
export default function Bookings(props: BookingsProps) {
return (
<DataTableProvider>
<BookingsContent {...props} />
</DataTableProvider>
);
}
type RowData = {
booking: BookingOutput;
recurringInfo?: RecurringInfo;
};
function BookingsContent({ status }: BookingsProps) {
const { data: filterQuery } = useFilterQuery();
const { t } = useLocale();
@@ -87,13 +111,32 @@ export default function Bookings({ status }: { status: (typeof validStatuses)[nu
}
);
// Animate page (tab) transitions to look smoothing
const columns = useMemo(() => {
const columnHelper = createColumnHelper<RowData>();
const buttonInView = useInViewObserver(() => {
if (!query.isFetching && query.hasNextPage && query.status === "success") {
query.fetchNextPage();
}
});
return [
columnHelper.display({
id: "custom-view",
cell: (props) => {
const { booking, recurringInfo } = props.row.original;
return (
<BookingListItem
key={booking.id}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfo}
{...booking}
/>
);
},
}),
];
}, [user, status]);
const isEmpty = !query.data?.pages[0]?.bookings.length;
@@ -120,6 +163,27 @@ export default function Bookings({ status }: { status: (typeof validStatuses)[nu
return true;
};
const flatData = useMemo(() => {
return (
query.data?.pages.flatMap((page) =>
page.bookings.filter(filterBookings).map((booking) => ({
booking,
recurringInfo: page.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
),
}))
) || []
);
}, [query.data]);
const table = useReactTable<RowData>({
data: flatData,
columns,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
});
let recurringInfoToday: RecurringInfo | undefined;
const bookingsToday =
@@ -136,115 +200,95 @@ export default function Bookings({ status }: { status: (typeof validStatuses)[nu
})
)[0] || [];
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
return (
<div className="flex flex-col">
<div className="flex flex-row flex-wrap justify-between">
<HorizontalTabs tabs={tabs} />
<FilterToggle setIsFiltersVisible={setIsFiltersVisible} />
</div>
<FiltersContainer isFiltersVisible={isFiltersVisible} />
<main className="w-full">
<div className="flex w-full flex-col">
{query.status === "error" && (
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
)}
{(query.status === "pending" || query.isPaused) && <SkeletonLoader />}
{query.status === "success" && !isEmpty && (
<>
{!!bookingsToday.length && status === "upcoming" && (
<div className="mb-6 pt-2 xl:pt-0">
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
<p className="text-subtle mb-2 text-xs font-medium uppercase leading-4">{t("today")}</p>
<div className="border-subtle overflow-hidden rounded-md border">
<div
className="bg-default divide-subtle w-full max-w-full divide-y"
data-testid="today-bookings">
<Fragment>
{bookingsToday.map((booking: BookingOutput) => (
<BookingListItem
key={booking.id}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfoToday}
{...booking}
/>
))}
</Fragment>
</div>
</div>
</div>
)}
<DataTableWrapper
table={table}
testId={`${status}-bookings`}
bodyTestId="bookings"
hideHeader={true}
isPending={query.isFetching && !flatData}
hasNextPage={query.hasNextPage}
fetchNextPage={query.fetchNextPage}
isFetching={query.isFetching}
variant="compact"
/>
</>
)}
{query.status === "success" && isEmpty && (
<div className="flex items-center justify-center pt-2 xl:pt-0">
<EmptyScreen
Icon="calendar"
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
description={t("no_status_bookings_yet_description", {
status: t(status).toLowerCase(),
description: t(descriptionByStatus[status]),
})}
/>
</div>
)}
</div>
</main>
</div>
);
}
function BookingsStatusLayout({ children }: { children: React.ReactNode }) {
const { t } = useLocale();
return (
<Shell
withoutMain={false}
withoutSeo={true}
hideHeadingOnMobile
heading={t("bookings")}
subtitle={t("bookings_description")}
title={t("bookings")}
description={t("bookings_description")}>
<div className="flex flex-col">
<div className="flex flex-row flex-wrap justify-between">
<HorizontalTabs tabs={tabs} />
<FilterToggle setIsFiltersVisible={setIsFiltersVisible} />
</div>
<FiltersContainer isFiltersVisible={isFiltersVisible} />
<main className="w-full">
<div className="flex w-full flex-col" ref={animationParentRef}>
{query.status === "error" && (
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
)}
{(query.status === "pending" || query.isPaused) && <SkeletonLoader />}
{query.status === "success" && !isEmpty && (
<>
{!!bookingsToday.length && status === "upcoming" && (
<div className="mb-6 pt-2 xl:pt-0">
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
<p className="text-subtle mb-2 text-xs font-medium uppercase leading-4">{t("today")}</p>
<div className="border-subtle overflow-hidden rounded-md border">
<table className="w-full max-w-full table-fixed">
<tbody className="bg-default divide-subtle divide-y" data-testid="today-bookings">
<Fragment>
{bookingsToday.map((booking: BookingOutput) => (
<BookingListItem
key={booking.id}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfoToday}
{...booking}
/>
))}
</Fragment>
</tbody>
</table>
</div>
</div>
)}
<div className="pt-2 xl:pt-0">
<div className="border-subtle overflow-hidden rounded-md border">
<table data-testid={`${status}-bookings`} className="w-full max-w-full table-fixed">
<tbody className="bg-default divide-subtle divide-y" data-testid="bookings">
{query.data.pages.map((page, index) => (
<Fragment key={index}>
{page.bookings.filter(filterBookings).map((booking: BookingOutput) => {
const recurringInfo = page.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
);
return (
<BookingListItem
key={booking.id}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfo}
{...booking}
/>
);
})}
</Fragment>
))}
</tbody>
</table>
</div>
<div className="text-default p-4 text-center" ref={buttonInView.ref}>
<Button
color="minimal"
loading={query.isFetchingNextPage}
disabled={!query.hasNextPage}
onClick={() => query.fetchNextPage()}>
{query.hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
</div>
</>
)}
{query.status === "success" && isEmpty && (
<div className="flex items-center justify-center pt-2 xl:pt-0">
<EmptyScreen
Icon="calendar"
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
description={t("no_status_bookings_yet_description", {
status: t(status).toLowerCase(),
description: t(descriptionByStatus[status]),
})}
/>
</div>
)}
</div>
</main>
</div>
{children}
</Shell>
);
}
export const getLayout = (page: ReactElement) => <BookingsStatusLayout>{page}</BookingsStatusLayout>;
@@ -6,7 +6,7 @@ import { createEvent } from "ics";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Fragment, useEffect, useState } from "react";
import { Toaster } from "react-hot-toast";
import { RRule } from "rrule";
import { z } from "zod";
@@ -680,7 +680,7 @@ export default function Success(props: PageProps) {
const label = field.label || t(field.defaultLabel);
return (
<>
<Fragment key={field.name}>
<div
className="text-emphasis mt-4 font-medium"
// eslint-disable-next-line react/no-danger
@@ -698,7 +698,7 @@ export default function Success(props: PageProps) {
: t("no")
: response.toString()}
</p>
</>
</Fragment>
);
})}
</div>
@@ -2,6 +2,7 @@
import { Booker } from "@calcom/atoms/monorepo";
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { type PageProps } from "@lib/d/[link]/[slug]/getServerSideProps";
@@ -19,6 +20,13 @@ export default function Type({
}: PageProps) {
return (
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
<BookerSeo
username={user}
eventSlug={slug}
rescheduleUid={booking?.uid}
hideBranding={isBrandingHidden}
entity={entity}
/>
<Booker
username={user}
eventSlug={slug}
@@ -3,13 +3,12 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Trans } from "next-i18next";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { FC } from "react";
import { memo, useEffect, useState } from "react";
import { z } from "zod";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
import { EventTypeDescription } from "@calcom/features/eventtypes/components";
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
@@ -19,7 +18,6 @@ import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsF
import Shell from "@calcom/features/shell/Shell";
import { classNames, parseEventTypeColor } from "@calcom/lib";
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
@@ -48,7 +46,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
EmptyScreen,
HeadSeo,
HorizontalTabs,
Icon,
Label,
@@ -61,7 +58,6 @@ import {
UserAvatarGroup,
} from "@calcom/ui";
import type { AppProps } from "@lib/app-providers";
import useMeQuery from "@lib/hooks/useMeQuery";
type GetUserEventGroupsResponse = RouterOutputs["viewer"]["eventTypes"]["getUserEventGroups"];
@@ -257,7 +253,7 @@ export const InfiniteEventTypeList = ({
const { t } = useLocale();
const router = useRouter();
const pathname = usePathname();
const searchParams = useCompatSearchParams();
const searchParams = useSearchParams();
const { copyToClipboard } = useCopy();
const [parent] = useAutoAnimate<HTMLUListElement>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -374,7 +370,7 @@ export const InfiniteEventTypeList = ({
// inject selection data into url for correct router history
const openDuplicateModal = (eventType: InfiniteEventType, group: InfiniteEventTypeGroup) => {
const newSearchParams = new URLSearchParams(searchParams ?? undefined);
const newSearchParams = new URLSearchParams(searchParams?.toString() ?? undefined);
function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) {
if (value) newSearchParams.set(key, value.toString());
if (value === null) newSearchParams.delete(key);
@@ -898,7 +894,7 @@ const InfiniteScrollMain = ({
eventTypeGroups: GetUserEventGroupsResponse["eventTypeGroups"] | undefined;
profiles: GetUserEventGroupsResponse["profiles"] | undefined;
}) => {
const searchParams = useCompatSearchParams();
const searchParams = useSearchParams();
const { data } = useTypedQuery(querySchema);
const orgBranding = useOrgBranding();
@@ -946,16 +942,12 @@ const InfiniteScrollMain = ({
);
};
const EventTypesPage: React.FC & {
PageWrapper?: AppProps["Component"]["PageWrapper"];
getLayout?: AppProps["Component"]["getLayout"];
} = () => {
const EventTypesPage: React.FC = () => {
const { t } = useLocale();
const searchParams = useCompatSearchParams();
const { open } = useIntercom();
const searchParams = useSearchParams();
const { data: user } = useMeQuery();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [showProfileBanner, setShowProfileBanner] = useState(false);
const [_showProfileBanner, setShowProfileBanner] = useState(false);
const orgBranding = useOrgBranding();
const routerQuery = useRouterQuery();
const filters = getTeamsFiltersFromQuery(routerQuery);
@@ -973,9 +965,6 @@ const EventTypesPage: React.FC & {
});
useEffect(() => {
if (searchParams?.get("openIntercom") === "true") {
open();
}
/**
* During signup, if the account already exists, we redirect the user to /event-types instead of onboarding.
* Adding this redirection logic here as well to ensure the user is redirected to the correct redirectUrl.
@@ -1009,18 +998,13 @@ const EventTypesPage: React.FC & {
return (
<Shell
withoutMain={false}
title="Event Types"
description="Create events to share for people to book on your calendar."
title={t("event_types_page_title")}
description={t("event_types_page_subtitle")}
withoutSeo
heading={t("event_types_page_title")}
hideHeadingOnMobile
subtitle={t("event_types_page_subtitle")}
CTA={<CTA profileOptions={profileOptions} isOrganization={!!user?.organizationId} />}>
<HeadSeo
title="Event Types"
description="Create events to share for people to book on your calendar."
/>
<InfiniteScrollMain
profiles={getUserEventGroupsData?.profiles}
eventTypeGroups={getUserEventGroupsData?.eventTypeGroups}
@@ -5,7 +5,7 @@ import { EventType } from "@calcom/atoms/monorepo";
import type { PageProps } from "@lib/event-types/[type]/getServerSideProps";
const EventTypePageWrapper = ({ type, ...rest }: PageProps) => {
return <EventType {...rest} id={type} isAppDir={false} />;
return <EventType {...rest} id={type} />;
};
export default EventTypePageWrapper;
@@ -1,35 +1,35 @@
"use client";
import { FailedBookingsByField, RoutingFormResponsesTable, RoutedToPerPeriod } from "@calcom/features/insights/components";
import {
FailedBookingsByField,
RoutingFormResponsesTable,
RoutedToPerPeriod,
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import InsightsLayout from "./layout";
export default function InsightsRoutingFormResponsesPage() {
const { t } = useLocale();
return (
<InsightsLayout>
<FiltersProvider>
<div className="mb-4 space-y-4">
<RoutingFormResponsesTable />
<FiltersProvider>
<div className="mb-4 space-y-4">
<RoutingFormResponsesTable />
<RoutedToPerPeriod />
<RoutedToPerPeriod />
<FailedBookingsByField />
<FailedBookingsByField />
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
</InsightsLayout>
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
);
}

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