Compare commits

...
118 Commits
Author SHA1 Message Date
GitHub Actions f0d2a1a051 chore: release v5.0.0
Post release / release (push) Canceled after 0s
2025-02-15 10:26:34 +00:00
Tushar Bhatt 62c7627775 fix: removing-all-members-toggle-for-fh (#19277)
* fix : removing all members toggle for fh

* ux enhancements

* adding placeholder
2025-02-15 10:14:56 +00:00
be53a20429 feat: Use Folders to organize SMS links on Dub (#19248)
* feat: Use Folders to organize SMS links on Dub

* Update yarn.lock

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2025-02-15 10:06:28 +00:00
Benny JooandAnik Dhabal Babu 5c6f52f1d4 perf: separate client vs server code from insights layout (#19285)
* add UpgradeTipWrapper

* refactor insights/layout.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-02-15 06:43:37 -03:00
Carina WollendorferandCarinaWolli 0e9e3e5012 add ireland country code (#19117)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-02-15 06:28:44 -03:00
5d709cf63c feat: v3 of UI components (#19136)
* ui playground

* add types to ui

* push avatar ignore ts and eslint errors

* feat: V3 UI avatar  (#18997)

* avatar playground

* add overflow-hidden back for loading indicator

* update lock

* feat: v3/badge  (#18999)

* badge intro

* fix text on small badge

* add dark mode warning

* feat: v3/button (#19014)

* TOC

* wip buttons

* shadows

* secondary

* semantic colours

* minimal buttons

* update disabled states

* checkbox

* dropdown v3

* load icons

* fix design sections

* fix icon gap

* fix

* remove client component from rsc

* use peer as avatar

* gif

* fix button gap - dropdown iocn

* support start icon in dropdowns

* add alert demo + v3 component + darken semantic text

* base inputs and text field

* wip input

* input field addon/suffix

* toast page

* wip select

* match designs for input and select rounded

* fix

* fix select isloading transform

* fix multi icons transition rotation

* fix group heading

* range slider demo + range slider

* range slider popover design

* v3 dialog

* fix slider thumb size

* wrap in suspense for useSearchParam difference in next14+

* base of tabs + horizontal tabs

* fix select sizing to match input 36px

* fix dark mode hover on button

* target domState class for light/dark so we work on portal elements

* fix default bg on popover

* lightmode toasts + eleveation token

* empty screen

* intro to split button concept

* mass colour updates

* intro to themes

* remove i18n from label with name

* bouncy buttons

* feat: small select

* nav item dumb component

* full demo

* settings demo

* fix tabs

* fix mt

* fix dropdown feedback from loom

* alert fixes mentioned in loom

* push font-cal

* move to fumadocs

* fix avatar

* colors table

* add typeography

* icon + spacing guide

* add border radius and shadow tables

* fix colours and border radius

* remove prose undline via fumas fork

* fix icon buttons sizes

* button group component

* fix horizontal tabs

* tab fixes

* switches

* pagination component

* show toasts components raw

* sync with airtable

* add use client

* 10px on input again

* 10px in select also

* fix input height

* fix files merge

* fix loading state for icon buttons

* fix button fab

* Enable addon to now overflow to next line in DS

* use cal font for dialog text

* add avatar square variant

* fix select padding and menu size

* fix type errors

* fix type errors

* add switch small size and fix type errors

* fix ref types on input

* update web styles

* remove addonFilled to fix type errors

* push dialog fixes

* fix checkbox display

* remove prose from alert examples

* remove prose from all our component examples

* badge rounded-lg on lg size

* update colors

* force w/h on icon buttons instead of inheriting

* remove border-focus

* remove border-focus from tailwind config

* update tokens to match new colours

* add new border subtle tokens and add to toasts

* fix lg button padding

* fix and drastically improve rtl rests

* multi select use badges

* use badge design for select pills

* fix avatar alignment

* fix tests and remove redudant ones

* fix tests and remove redudant ones

* update atoms globals css

* extract tokens.css

* extract app.css

* force flex for atoms

* add @calcom/ui/css

* revert ui playground styles abstraction

* fix input focus states

* fix focus states for buttons

* feat: v3/app work (#19233)

* globals.css for app + event types listing search

* few app wide NITS

* fix select foucs within styling

* fix formbuilder switch styles

* fix disabled state of nested inputs

* workflow fixes

* webhook form fixes

* i18n on tabs info

* booking filters + tab

* fix filters spacing

* make first item so we get correct border radius if not default

* round radio group in create event types + make button small

* profile page

* update editor br

* button nits in existing places in the app

* fix timezone select and multilist in guest form

* fix text area on public booking page

* size small event types create

* margin on routing filter

* filters improvments

* routing insights fixes

* fix date filter range

* ooo fixes

* fix teams/members bulk actions

* create team UI

* i18n in useTabs instead of in tab component now

* Add event type setup border to availability

* fix bulk actions on org members table

* onboarding fixes

* fix input types with ref stripe comparision

* i18n fixes for platform

* update globals of atoms

* fix form builder firstname/lastname switch + correct footer useage

* fix select

* Revert "add @calcom/ui/css"

This reverts commit 0dd8f9012907a278cfede4a98f289cb0744c9244.

* revert tokens css

* Revert "extract app.css"

This reverts commit 10b998604816ad3310c24b36a02a638dabc5be24.

* linear nits

* workflow dropdown

* fix page theme heading sizes to match cards

* fix stepper for team creation

* adjust margin to FilterSearchField

* fix ooo icon

* fix booker and dark mode due to darkgray 50

* more dark mode fixes

* add size prop

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Morgan Vernay <morgan@cal.com>

* remove ui-playground

* fix grow missing from merge

* add search icon data-test-id back to search

* fix FormAction style

* fix datatestid for apps and tabs

* change selector for team invitation e2e

* fix use data-testid from item instaed of using names

* rename upper case O

* fix unit tests data-testid

* fix unit tests data-testid

* more data-testid changes

* add data-testid to apps tab

* data-testid to installed appPage

* fix embed tests

* fix filter-popover-trigger

* use text-error and use dataId for locator

* more e2e improvment

* pass in single testId as button is now duplicate

* show field-error instead

* add test to eventtype tabs

* Add css vars to embed too\

* add scrollbar and change dialog overlay colour

* fix type

* use attribute instead of class for tab detection

* await promise

* use new tokens for sprite generation

* add correct testing Ids to e2e instead of button:text

* use testIds for creation of orgs

* Webhook receiver wait for 10 seconds

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2025-02-14 16:31:21 -05:00
MorganandBailey Pumfleet 0f67faeef3 chore: calendars controller v2 docs (#19291)
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2025-02-14 17:54:23 +00:00
1ff4f557f6 fix: flaky test (#19294)
Post release / release (push) Canceled after 0s
* fix: flaky test

* fix another test

* update

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: unknown <adhabal2002@gmail.com>
2025-02-14 16:25:54 +00:00
GitHub Actions 7e2c0bd29e chore: release v4.9.13 2025-02-14 16:25:10 +00:00
Alex van Andel 5b59cf9d9c revert: Reapply "fix: Multiple rr-hosts combine to create erroneous availabil… (#19263)
* Reapply "fix: Multiple rr-hosts combine to create erroneous availability (#18772)"

This reverts commit 5dbc6d9ca6.

* Only thing left is a diff to show which slots are removed

* Add logging around time slot results

* Setup logging through axiom

* .... forgot

* fix: type errors

* Only log when there are differences

* Updated from old which had a failing test to new logic
2025-02-14 12:34:56 -03:00
Lauris Skraucis d5420eda00 chore: disable v2 booking seats safe parse error logs (#19292) 2025-02-14 10:59:10 +00:00
c17ac67b3f feat: add CSAT to V2 bookings return (#19278)
* init

* test: v2 bookings returns rating

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2025-02-14 09:47:54 +00:00
VijayandPeer Richelsen b2763a6274 fix: Default people filter in bookings list for admin (#18035)
* default people filter for admin

* chore

* type check

* added test case

* made test concise to reduce flakiness

* add timeout to wait for second get response before counting

* update timeout

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-02-14 10:08:57 +01:00
Morgan 08b6f60407 fix: missing await router controller (#19276) 2025-02-13 21:38:43 +00:00
466b3e4c08 fix: Salesforce - if no contact throw Error (#19271)
* Throw error if no contact is passed to createEvent

* chore: update error message

* chore

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-02-13 16:01:46 -05:00
GitHub Actions 401209810a chore: release v4.9.12
Post release / release (push) Canceled after 0s
2025-02-13 17:53:44 +00:00
Benny Joo 9f34a840d0 chore: Refactor to remove getServerSideProps for /teams route (#19286)
* remove getServerSideProps

* refactor teams route
2025-02-13 17:46:56 +00:00
Udit Takkar 37f1535e1a refactor: remove depreceated fields (#19279)
* refactor: remove depreceated fields

* chore: use v2 endpoint

* chore: update V2 Api type

* chore
2025-02-13 17:34:33 +02:00
Lauris Skraucis 5dc81e8677 feat: v2 slots new version (#18758)
* refactor: version old slots

* feat: init new slots endpoints

* chore: range format

* fix: duratin

* fix: duratin

* test: slot releaseAt

* refactor: reserve slot response

* refactor variable name

* docs: have new slots controller in docs

* feat: crud for slots reservations

* refactor: use exclude all for response

* docs

* chore: slots input service

* refactor mini

* refactor: remove unused imports

* docs

* handle orgSlug for dynamic events

* refactor: correct name

* docs

* add optional organizationSlug to BySlug search

* refactor: slot output format

* refactor: return seated slot info

* docs

* rename functions

* refactor: slots seated response

* fix: handle same username in org and non org

* refactor: test format

* fix: allow reservationDuration only for authed requests

* chore: check if auth user has permissions to define custom reservation duration

* refactor: split e2e test files

* refactor: invalid request exception codes

* chore: old slots controller uses all old versions
2025-02-13 14:17:00 +00:00
Eunjae Lee 5a166ee430 fix: remove section on /bookings if no data exists (#19261) 2025-02-13 11:40:17 +01:00
Hariom BalharaandHariom Balhara f6f964d495 feat: Use new /routing URL for routing forms (#19235)
* feat: Use new /routing URL for routing forms

* Fixes

* Fix bug

* precent crash

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmgmail.com>
2025-02-13 15:13:13 +05:30
Joe Au-Yeung 6364e85293 Handle cancelled status (#19272) 2025-02-13 12:59:41 +05:30
GitHub Actions 46add2bebc chore: release v4.9.11
Post release / release (push) Canceled after 0s
2025-02-12 19:22:01 +00:00
Syed Ali Shahbaz 8ada940dc7 hotfix (#19266) 2025-02-12 19:16:52 +00:00
d027f8004a perf: Move PlainChat up the hierarchy to remove rerenders (#19255)
* perf: Move PlainChat up the hierarchy to remove rerenders

* rmeove yarn lock

---------

Co-authored-by: Nizzy <nizabizaher@gmail.com>
Co-authored-by: nizzy <140507264+nizzyabi@users.noreply.github.com>
2025-02-12 19:13:54 +00:00
Udit Takkar cc2f16411b feat: add country code (#19264) 2025-02-12 20:09:37 +01:00
nizzy 15b58c1c13 fix: added youtube videos to tips.tsx sidebar (#19245)
* added youtube videos to tips.tsx sidebar

* replace video links with dubco go.cal.com links for tracking
2025-02-12 21:04:38 +05:30
Eunjae Lee 2d12ea7372 fix: path to impersonation page on side bar (#19260) 2025-02-12 13:43:30 +00:00
Eunjae Lee 06bf9cfbfe fix: indicate hidden columns on filter dropdown (#19259) 2025-02-12 13:33:10 +00:00
Alex van Andel 5dbc6d9ca6 Revert "fix: Multiple rr-hosts combine to create erroneous availability (#18772)"
This reverts commit f5dc22f145.
2025-02-12 13:15:04 +00:00
Morgan 486e3a386e chore: salesforce for Router / Booker atoms and api v2 (#19217)
* wip

* small refactor

* small refactor

* fixup! Merge branch 'main' into enable-salesforce-router-apiv2-atom

* crm props

* fixup! crm props

* bump platform libraries

* bump platform libraries
2025-02-12 15:04:38 +02:00
Tushar Bhatt 3a29327761 fix: overlapping-dropdown-bookings-page (#19216) 2025-02-12 10:45:47 +00:00
Benny Joo 4f226cd653 chore: remove unneeded props from AuthContainer and make its usages cleaner (#19191)
* refactor AuthContainer by removing app-dir related props

* fix
2025-02-12 05:19:16 -05:00
Benny Joo 207804bd97 perf: remove WithLayout HOC from private booking page and booking embed (#19189) 2025-02-12 11:15:08 +01:00
Benny Joo 7969789d60 wip (#19190) 2025-02-12 05:06:53 -05:00
Lauris Skraucis 67fb0e6719 fix: v2 slots not returning attendees and bookingUid info (#18858)
* fix: v2 slots not returning attendees and bookingUid info

* rename file

* finish merge main
2025-02-12 15:06:52 +05:30
Anik Dhabal Babuandsean-brydon 7f390b147a fix: CTA is hidden on mobile (#19241)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-02-12 09:26:08 +00:00
Benny Joo 1dc869971e fix: small ui bug in 404 pages (#19254) 2025-02-12 08:30:00 +00:00
Joe Au-YeungandUdit Takkar 3fe83bb45c fix: Salesforce bugs (#19250)
* incompleteBooking only search for leads if contact is not found

* Fix logic

* Abstract to functions

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-02-12 08:24:57 +00:00
Benny Joo 4fbeff31e7 fix: decode params used in query object in buildLegacyCtx (#19253)
* fix: decode params used in query object in buildLegacyCtx

* add tests

* fix
2025-02-12 07:58:46 +00:00
GitHub Actions e800341485 chore: release v4.9.10
Post release / release (push) Canceled after 0s
2025-02-11 19:48:09 +00:00
Alex van Andel f5dc22f145 fix: Multiple rr-hosts combine to create erroneous availability (#18772)
* wip: Open draft PR with passing test (should fail)

* Fix aggregatedAvailability by not merging rr host availability

* Added test for validating fixed host behaviour

* Add test for combined rr and fixed hosts

* Check date-ranges arent returned multiple times when offered by multiple rr hosts

* Also sort date ranges

* Adding failing test that should result in 5 slots, but returns 7 as it returns the same slots twice.

* Slots returns duplicate slots for overlapping date ranges

* As mergeOverlappingDateRanges enabled the unified start time, we need to reproduce this

* Do not 'prettify' slots to the same degree if they fall on different days

* Added test to prove an end date before start also works

* Use a string instead of number as key

* chore: Deprecate organizerTimeZone, effectively unused

* Adjust all dateRanges similarly

* Bring back eventTimeZone

* Remove adjustDateRanges in favour of slot generation changes

* Convert to tz after all logic is finished.

* clearer code flow

* Remove console.log + add failing test due to inverse order

* Address issue with inversion when date ranges are given out of order
2025-02-11 19:35:56 +00:00
Keith WilliamsandJoe Au-Yeung 4fd31db6ee perf: Use a limit for searching website in Salesforce (#19244)
* perf: Avoid leading wildcard for Salesforce

* use limit of 1 since we need 1 record

* use LOWER to check exact website match

* Revert the usage of LOWER since we need wider searches

* Add limits to queries

* Update packages/app-store/salesforce/lib/CrmService.ts

* Remove unneeded LIMIT statements

* Update packages/app-store/salesforce/lib/CrmService.ts

* Apply suggestions from code review

* Update packages/app-store/salesforce/lib/CrmService.ts

---------

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2025-02-11 19:17:23 +00:00
Keith Williams f58d3cf837 revert: "fix: bookings for guests added by organizer get cancelled wh… (#19243)
This reverts commit e29b42a02b.
2025-02-11 14:53:03 +00:00
Keith Williams fc79ea898a chore: Update CODEOWNERS to include more booking files (#19240) 2025-02-11 15:08:28 +01:00
Eunjae Lee 5a602f10e6 fix: select "Your account" for members by default on insights (#19236) 2025-02-11 12:39:40 +00:00
Kartik SainiandTushar Bhatt e29b42a02b fix: bookings for guests added by organizer get cancelled when rescheduled (#17820)
* fix: bookings for guests added by organizer get cancelled when rescheduled

* chore

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-02-11 17:56:09 +05:30
Benny Joo cdb57eaa59 fix: get user loggedin if accouont is found + add better error handling for new user creation (#19229) 2025-02-11 11:41:18 +00:00
Peer Richelsen 662ba8dfc6 chore: added impersonation to sidebar for admins (#19228) 2025-02-11 11:39:09 +00:00
Hariom BalharaandHariom Balhara 552d88ad6d Fallback if embedLibUrl is empty string (#19223)
Co-authored-by: Hariom Balhara <hariombalhara@gmgmail.com>
2025-02-11 14:56:31 +05:30
Lauris Skraucis 08bcc77e2c feat: v2 GET bookings updated at filters and sort (#19133)
* fix: make Booking.updatedAt update automatically

* feat: return updatedAt by bookings endpoints

* feat: GET bookings beforeUpdatedAt, afterUpdatedAt, sortUpdated params

* docs: api

* chore: bump platform-libraries

* refactor: sortUpdated -> sortUpdatedAt query param

* chore: delete wrong migration
2025-02-11 08:39:50 +00:00
anushka-0099 c211f181fc fix: the wprd on confirm booking button in japanese (#19230) 2025-02-11 04:16:05 +00:00
Rajiv SahalandMorgan Vernay cc6016aef3 chore: atoms and docs update (#19137)
* hide docs for old google connect endpoint

* update docs for atoms

* fix css styles for week view

* update v2 reference

* add dutch translations in cal provider

* fix: add nl i18n in BaseCalProvider

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2025-02-10 20:05:28 +00:00
Pavan NaikandPavan 77d0fa9ff8 fix: the set as default apps issue with links #19176 (#19198)
* fix the set as default apps issue with links

* Allow (. _ -) in the Mirotalk link

---------

Co-authored-by: Pavan <pavannaik20@gmail.com>
2025-02-10 20:29:22 +01:00
Eunjae LeeandBenny Joo 720136fcf9 fix: replace filter implementations on /insights (#19033)
* fix: replace filter implementations on /insights

* add authorization for teamIds

* replace all the implementations

* provide eventTypeId

* remove log

* fix teamId for isAll

* fix Download button

* remove legacy implementations

* clean up

* nullish check

* remove unusable filters

* revert style

* fix type error

* fix e2e test

* fix type error

* fix type error

* fix e2e

* extract util method

* fix type error

* fix type error

* fix type error

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-02-10 20:28:48 +01:00
Benny Joo 9bf3732717 fix: RSC boundary error from auth/oauth2/authorize (#19226) 2025-02-10 19:18:25 +00:00
GitHub Actions cd721308d0 chore: release v4.9.9
Post release / release (push) Canceled after 0s
2025-02-10 17:03:41 +00:00
Tushar Bhatt 74c292cc57 feat: remove tabs when only user and no team (#19222) 2025-02-10 16:32:08 +00:00
Alex van Andel 8e26882b2d fix: TS error related to non-existant ReasonEnum 2025-02-10 16:08:19 +00:00
Joe Au-Yeung a775bec902 fix: Disable round robin reassignment if reroute is available (#19192)
* If booking is routed from form, disable round robin reassign

* Pass `bookingFromRoutingForm` prop from `BookingListItem`

* Add `ReassignType` enum

* Refactor `isBookingReroutable`
2025-02-10 15:05:18 +00:00
amrit 75ff5f6fb1 fix: update hungarian translation for booking and cancellation texts (#19218) 2025-02-10 14:27:43 +01:00
Morgan 7e20b74674 chore: move getTeamMemberEmailFromCrm to package/lib (#19174) 2025-02-10 13:02:29 +00:00
Peer Richelsen 111f292c0d chore: removed props.hideHeadingOnMobile (#19208)
* removed props.hideHeadingOnMobile

* made sure CTA works
2025-02-10 11:06:16 +00:00
Peer Richelsen 52775dd27c chore: changed "routing" icon (#19211)
* changed routing icon

* new routing badge
2025-02-10 08:54:32 +00:00
Calcom BotandLingo.dev 668304a7cd feat: update translations via @lingodotdev (#19214)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-02-10 08:49:41 +00:00
Mohammad Aquib 378917ba19 fix: typo in conferencing.repository.ts filename (#19161) (#19173)
* fix: typo in conferencing.repository.ts filename

* fix: rename conferencing.respository.ts to conferencing.repository.ts
2025-02-10 07:56:43 +00:00
luzpaz 15334ff20b fix: typos in apps/web (#19194)
Found via `codespell -q 3 -S "*.svg,./apps/web/public/static/locales,./packages/app-store/stripepayment/lib/currencyOptions.ts,./packages/lib/freeEmailDomainCheck/freeEmailDomains.ts" -L afterall,atleast,datea,fo,incase,ist,nam,notin,optionel,perview,reccuring`

Closes #19193
2025-02-10 03:35:17 +00:00
Udit TakkarandhbjORbj 76c09ca7bf perf: fetch only by id (#19154)
* perf: fetch only by id

* remove getServerSession

---------

Co-authored-by: hbjORbj <sldisek783@gmail.com>
2025-02-10 02:14:57 +05:30
Peer Richelsen 0fb0974769 feat: move apps to more (#19206) 2025-02-09 20:36:23 +01:00
Benny Joo 2840d64197 chore: migrate user booking pages to App Router (#19069)
* add user pages to config.matcher in middleware

* add app router user booking pages

* remove HeadSeo from users-public-view component

* remove pages router user booking pages

* fix type check

* fix embed

* fix test

* chore: remove BookerSEO from users-type-public-view

* fix
2025-02-07 16:52:56 -05:00
Benny Joo 77a00b3e8c chore: migrate orgDomain instant meeting route to App Router (#19067)
* Remove BookerSeo from instant-meeting-view

* remove route from pages router

* make eventData passed from getServerSideProps

* refactor route in app router and move it outside of /future
2025-02-07 21:51:35 +00:00
Tushar Bhatt cd04b17113 fix: placeholder added (#19184) 2025-02-07 20:19:55 +00:00
Joe Au-Yeung cbf2604029 chore: SF logging (#19180)
* More logging

* More logging

* Clean up logging
2025-02-07 13:01:23 -05:00
Eunjae Lee 403cfebb89 fix: resize columns when column visibility changes (#19172)
* fix: resize columns when column visibility changes

* fill initial column size if missing

* use different variable
2025-02-07 17:12:42 +00:00
Eunjae Lee 5d2c8a49ba fix: null check for useSearchParams() (#19175) 2025-02-07 15:19:15 +00:00
Anik Dhabal Babu 87f02f5588 fix: Lock time-zone not working properly (#19152)
Post release / release (push) Canceled after 0s
* fix: flaky test organization.spec

* fix
2025-02-07 13:55:58 +00:00
Anik Dhabal Babu 4671ffc4f4 fix (#19156) 2025-02-07 13:54:41 +00:00
Eunjae Lee 6cad7ac1d1 fix: selected filters for numeric and text filters (#19129)
* fix: selected filters for numeric and text filters

* little adjustment
2025-02-07 13:43:25 +00:00
GitHub Actions aff2c7cdb4 chore: release v4.9.8 2025-02-07 13:36:35 +00:00
luzpaz 787ff543df fix: source typo in bookingScenario.ts test (#19162) 2025-02-07 12:56:48 +00:00
luzpaz 01e442df17 fix: source typo in EditUserForm.tsx (#19157) 2025-02-07 12:48:24 +00:00
808de26401 feat: add option to disable sending transcription emails (#19065)
* feat: add option to disable sending transcription emails

* fix: types and tests

* fix: type error

* fix: type err

* fix: type err

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-02-07 12:33:38 +00:00
6ca76707da test: Scenario that replicates the crash in production (#19071)
* Add a case that crashes

* Add proper crash test case

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmgmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-02-07 11:20:29 +00:00
Benny Joo e79af63e6c chore: refactor apiRouteMiddleware and add it to some api routes in App Router (#19146)
* chore: remove try and catch from api routes in app router

* refactor

* refactor apiRouteMiddleware

* refactor
2025-02-07 11:04:34 +00:00
Hariom BalharaandHariom Balhara 24a080b988 Add documentation to pass metadata (#19124)
Co-authored-by: Hariom Balhara <hariombalhara@gmgmail.com>
2025-02-07 14:40:22 +05:30
luzpaz 8612f77c07 fix: typos in docs/ (#19144)
Found via `codespell -q 3 -S "*.svg,./apps/web/public/static/locales" -L afterall,datea,fo,ist,optionel`
2025-02-07 06:45:34 +00:00
Benny Joo 1babf5bf50 chore: Remove HeadSeo components where not needed (#19151) 2025-02-07 04:11:26 +00:00
Benny Joo e8890156a1 chore: migrate api/cancel to App Router (#19074)
* Add app/api/cancel/route.ts

* remove pages router api/cancel

* refactors needed

* refactor
2025-02-06 23:16:38 +00:00
Benny Joo c9bfd977b1 perf: make metadata generation light for event type single page (#19143)
* add findTitleById method to Repository

* refactor route
2025-02-07 00:26:29 +02:00
Benny Joo 19921ab7c9 chore: migrate api/availability/calendar to App Router (#19099)
* add app/api/availability/calendar/route.ts

* remove pages router api

* refactor
2025-02-06 15:58:05 -05:00
Benny Joo 239c8a6fa9 chore: migrate /api/team/create (#19105) 2025-02-06 15:57:03 -05:00
Udit Takkar cb9dfaa76b fix: logo for org team event types (#19131)
* fix: logo on booking page

* fix: logo for org team event tyoes

* chore
2025-02-06 20:50:15 +05:30
Lauris Skraucis 0b46415856 feat: track who updated routing form (#19079)
* chore: updatedBy App_RoutingForms_Form schema field

* feat: track who updated routing form
2025-02-06 11:52:53 -03:00
sean-brydon 3a522320e7 change error message (#19135) 2025-02-06 14:06:52 +00:00
Joe Au-Yeung 89033c0e9c chore: Salesforce add more logging (#19106)
* Add more logs

* Remove unused method

* Fallback when creating an event

* Type fix

* Type fix

* Type fix

* Add more logging

* More logging
2025-02-06 10:25:38 -03:00
Benny Joo 3972972d57 fix: do not export client components as default in RSC (#19130) 2025-02-06 12:15:37 +00:00
Lauris Skraucis 695d2c0f39 feat: v2 bookings return cancelledBy and rescheduledBy (#19110) 2025-02-06 12:45:13 +02:00
Eunjae Lee 4c32d0c499 fix: check searchParams existence (#19128) 2025-02-06 10:28:46 +00:00
Somay Chauhan 3252efebb1 feat: enable booker atom for non org team events (#19125)
* feat: enable booker atom for non org team events

* Update useTeamEventType.ts
2025-02-06 12:32:05 +05:30
Keith Williams a6fa9d7d60 chore: Trigger Docker release on publish (#19121) 2025-02-05 21:51:33 +00:00
GitHub Actions 38a80c90bb chore: release v4.9.7
Post release / release (push) Canceled after 0s
2025-02-05 21:27:34 +00:00
Morgan 5f897837f2 chore: create event-type input lengthInMinutesOptions (#19114) 2025-02-05 21:39:16 +02:00
Tushar Bhatt 510e73b579 fix: startdate filter and ui sync on bookings page (#19113)
* fix: date-filter issues

* fixed comment

* fixed comment

* removing extra node
2025-02-05 16:03:52 +00:00
Joe Au-YeungandKeith Williams 1787f6d25d Flatmap today's bookings (#19104)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-02-05 14:35:06 +00:00
Somay Chauhan afa83ddb7f fix: add orgId to EventTeamAssignmentTabPlatformWrapper (#19109) 2025-02-05 18:19:31 +05:30
Eunjae Lee 744359dcdf fix: null check (#19111) 2025-02-05 10:27:42 +00:00
Keith WilliamsandPeer Richelsen b7bf58d560 chore: Don't require checks for /help updates (#19089)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-02-05 06:43:43 -03:00
Lauris Skraucis da02cd06d6 feat: v2 organization bookings endpoints (#18875)
* chore: extra team bookings e2e tests

* feat: v2 organizations bookings endpoints

* feat: v2 organizations bookings endpoints

* test

* revert seed.ts change
2025-02-05 10:58:36 +02:00
Morgan 14ec3813ee chore: enable more router props (#19088)
* chore: enable more router props

* chore: expose booker props via router atom
2025-02-05 08:36:34 +00:00
Hariom BalharaandHariom Balhara 288248d94f Prevent xss in preview aand load resources from known URL (#18978)
Co-authored-by: Hariom Balhara <hariombalhara@gmgmail.com>
2025-02-05 12:17:56 +05:30
Peer Richelsen 1232eefece feat: attio app (#19102)
* feat: attio app

* added help page

* fixed redirect
2025-02-04 20:45:44 +00:00
Calcom BotandLingo.dev 24abcc3fb2 feat: update translations via @lingodotdev (#19098)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-02-04 19:06:53 +00:00
Eunjae Lee 935e4c52a4 fix: nullish check on insights routing (#19097) 2025-02-04 16:46:01 +00:00
Sanchit Tewariandamrit 9a8ec4d281 fix: Ensure correct fallback to current month when 'month' query param is missing (#18097)
Updated the query parameter handling logic to properly format the 'date' param
as 'YYYY-MM' in case month param is missing.  This resolves an issue where previous slots were not appearing.

Co-authored-by: amrit <iamamrit27@gmail.com>
2025-02-04 20:33:13 +05:30
Morgan 259624d44d fix: do not force cancellation reason for platform (#19087) 2025-02-04 14:55:05 +00:00
Carina WollendorferandCarinaWolli 7e0845ccf4 run cron job every minute (#19086)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-02-04 14:15:22 +00:00
Tushar Bhatt 0522af0c88 fix: empty state search messaging event page (#19064)
* test

* fix: adding empty search headline

* revert a change
2025-02-04 10:14:09 -03:00
Keith Williams f080a59c89 chore: Update CODEOWNERS for Platform and infra-related code (#18938)
* chore: Update CODEOWNERS for Platform and infra-related code

* Added more high-risk files/folders
2025-02-04 08:39:26 -03:00
557 changed files with 29067 additions and 15117 deletions
+12
View File
@@ -1,3 +1,6 @@
/apps/api/**/* @calcom/Platform
/deploy/**/* @calcom/Foundation
/infra/**/* @calcom/Foundation
/packages/app-store/applecalendar/**/* @calcom/Foundation
/packages/app-store/caldavcalendar/**/* @calcom/Foundation
/packages/app-store/exchange2013calendar/**/* @calcom/Foundation
@@ -9,3 +12,12 @@
/packages/app-store/larkcalendar/**/* @calcom/Foundation
/packages/app-store/office365calendar/**/* @calcom/Foundation
/packages/app-store/zohocalendar/**/* @calcom/Foundation
/packages/core/getAggregatedAvailability/index.ts @calcom/Foundation
/packages/core/getAggregateWorkingHours.ts @calcom/Foundation
/packages/core/getUserAvailability.ts @calcom/Foundation
/packages/features/bookings/lib/**/* @calcom/Foundation
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
/packages/lib/slots.ts @calcom/Foundation
/packages/platform/**/* @calcom/Platform
/packages/prisma/**/* @calcom/Foundation
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
+2 -2
View File
@@ -4,8 +4,8 @@ on:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs “every 5 minutes” (see https://crontab.guru)
- cron: "*/5 * * * *"
# Runs “every minute” (see https://crontab.guru)
- cron: "* * * * *"
jobs:
cron-webhookTriggers:
env:
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
with:
filters: |
has-files-requiring-all-checks:
- "!(**.md|.github/CODEOWNERS|docs/**|apps/web/public/static/locales/**/common.json)"
- "!(**.md|.github/CODEOWNERS|docs/**|help/**|apps/web/public/static/locales/**/common.json)"
- name: Get Latest Commit SHA
id: get_sha
run: |
+1
View File
@@ -4,6 +4,7 @@ on: # yamllint disable-line rule:truthy
release:
types:
- created
- published
# in case manual trigger is needed
workflow_dispatch:
inputs:
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
View File
+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"
+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"
+2 -1
View File
@@ -30,7 +30,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.93",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.99",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -61,6 +61,7 @@
"helmet": "^7.1.0",
"ioredis": "^5.3.2",
"jsforce": "^1.11.0",
"lodash": "^4.17.21",
"luxon": "^3.4.4",
"nest-winston": "^1.9.4",
"next-auth": "^4.22.1",
@@ -169,4 +169,19 @@ export class CreateBookingInput_2024_04_15 {
label?: string | undefined;
}
>;
@IsString()
@IsOptional()
@ApiPropertyOptional()
teamMemberEmail?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
crmAppSlug?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
crmOwnerRecordType?: string;
}
@@ -32,5 +32,6 @@ import { Module } from "@nestjs/common";
ApiKeyRepository,
],
controllers: [BookingsController_2024_08_13],
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
})
export class BookingsModule_2024_08_13 {}
@@ -455,7 +455,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
// eslint-disable-next-line
// @ts-ignore
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
console.log("asap data", JSON.stringify(data, null, 2));
expect(data.attendees[0].name).toEqual(`${splitName.firstName} ${splitName.lastName}`);
expect(data.attendees[0].bookingFieldsResponses.name).toEqual(splitName);
} else {
@@ -561,6 +561,43 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
it("should get bookings by teamId and eventTypeId", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings?teamId=${team2.id}&eventTypeId=${team2EventTypeId}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].eventTypeId).toEqual(team2EventTypeId);
});
});
it("should not get bookings by teamId and non existing eventTypeId", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings?teamId=${team2.id}&eventTypeId=90909`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(0);
});
});
it("should should get bookings by teamIds", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings?teamIds=${team1.id},${team2.id}`)
@@ -167,6 +167,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
timeZone: "Europe/Rome",
},
},
rating: 10,
});
app = moduleRef.createNestApplication();
@@ -396,6 +397,12 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
// Check updatedAt date is between the time of the request and after the request
expect(data.updatedAt).toBeDefined();
const updatedAtDate = data.updatedAt ? new Date(data.updatedAt) : null;
expect(updatedAtDate?.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
expect(updatedAtDate?.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
expect(data.metadata).toEqual(body.metadata);
createdBooking = data;
} else {
@@ -546,6 +553,30 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
it("should should get a booking with rating", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${bookingInThePast.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_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).toEqual(bookingInThePast.id);
expect(data.uid).toEqual(bookingInThePast.uid);
expect(data.rating).toEqual(bookingInThePast.rating);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should should get 1 recurrence of a recurring booking", async () => {
const recurrenceUid = createdRecurringBooking[0].uid;
return request(app.getHttpServer())
@@ -869,7 +900,63 @@ describe("Bookings Endpoints 2024-08-13", () => {
it("should should get bookings by before specified end time", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings?afterStart=${createdRecurringBooking[0].start}`)
.get(`/v2/bookings?beforeEnd=${createdRecurringBooking[0].end}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(3);
});
});
it("should should get bookings after specified update time", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings?afterUpdatedAt=${createdRecurringBooking[1].updatedAt}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
});
});
it("should should get bookings before specified update time", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings?beforeUpdatedAt=${createdRecurringBooking[0].updatedAt}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(3);
});
});
it("should should get bookings in update range", async () => {
return request(app.getHttpServer())
.get(
`/v2/bookings?afterUpdatedAt=${createdRecurringBooking[0].updatedAt}&beforeUpdatedAt=${createdRecurringBooking[2].updatedAt}`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
@@ -1004,6 +1091,46 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data[1].start).toEqual(createdBooking.start);
});
});
it("should should sort bookings by updated in descending order", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings?eventTypeId=${eventTypeId}&sortUpdatedAt=desc`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data[0].updatedAt).toEqual(createdBooking.updatedAt);
expect(data[1].updatedAt).toEqual(bookingInThePast.updatedAt?.toISOString());
});
});
it("should should sort bookings by updated in ascending order", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings?eventTypeId=${eventTypeId}&sortUpdatedAt=asc`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data[0].updatedAt).toEqual(bookingInThePast.updatedAt?.toISOString());
expect(data[1].updatedAt).toEqual(createdBooking.updatedAt);
});
});
});
describe("reschedule bookings", () => {
@@ -1308,6 +1435,108 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("who cancelled and rescheduled a booking", () => {
it("should return who cancelled a booking", async () => {
const cancelledByEmail = `user-bookings-canceller-${randomString()}@canceller.com`;
const cancelledBooking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${eventTypeId}`,
title: "booking title",
startTime: "2050-09-05T11:00:00.000Z",
endTime: "2050-09-05T12:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
status: "CANCELLED",
cancelledBy: cancelledByEmail,
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
return request(app.getHttpServer())
.get(`/v2/bookings/${cancelledBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
const responseBody: GetBookingOutput_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.uid).toEqual(cancelledBooking.uid);
expect(data.cancelledByEmail).toEqual(cancelledByEmail);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should return who rescheduled a booking", async () => {
const rescheduledByEmail = `user-bookings-rescheduler-${randomString()}@rescheduler.com`;
const rescheduledBooking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${eventTypeId}`,
title: "booking title",
startTime: "2050-09-05T11:00:00.000Z",
endTime: "2050-09-05T12:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
status: "CANCELLED",
rescheduledBy: rescheduledByEmail,
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
return request(app.getHttpServer())
.get(`/v2/bookings/${rescheduledBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
await bookingsRepositoryFixture.deleteById(rescheduledBooking.id);
const responseBody: GetBookingOutput_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.uid).toEqual(rescheduledBooking.uid);
expect(data.rescheduledByEmail).toEqual(rescheduledByEmail);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
});
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
return (
!Array.isArray(data) &&
@@ -463,11 +463,18 @@ export class InputBookingsService_2024_08_13 {
teamIds: queryParams.teamsIds || (queryParams.teamId ? [queryParams.teamId] : undefined),
eventTypeIds:
queryParams.eventTypeIds || (queryParams.eventTypeId ? [queryParams.eventTypeId] : undefined),
afterUpdatedDate: queryParams.afterUpdatedAt,
beforeUpdatedDate: queryParams.beforeUpdatedAt,
};
}
transformGetBookingsSort(queryParams: GetBookingsInput_2024_08_13) {
if (!queryParams.sortStart && !queryParams.sortEnd && !queryParams.sortCreated) {
if (
!queryParams.sortStart &&
!queryParams.sortEnd &&
!queryParams.sortCreated &&
!queryParams.sortUpdatedAt
) {
return undefined;
}
@@ -475,6 +482,7 @@ export class InputBookingsService_2024_08_13 {
sortStart: queryParams.sortStart,
sortEnd: queryParams.sortEnd,
sortCreated: queryParams.sortCreated,
sortUpdated: queryParams.sortUpdatedAt,
};
}
@@ -108,7 +108,9 @@ export class OutputBookingsService_2024_08_13 {
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
cancelledByEmail: databaseBooking.cancelledBy || undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
@@ -130,6 +132,8 @@ export class OutputBookingsService_2024_08_13 {
meetingUrl: location,
absentHost: !!databaseBooking.noShowHost,
createdAt: databaseBooking.createdAt,
updatedAt: databaseBooking.updatedAt,
rating: databaseBooking.rating,
};
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
@@ -198,7 +202,9 @@ export class OutputBookingsService_2024_08_13 {
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
cancelledByEmail: databaseBooking.cancelledBy || undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
@@ -221,6 +227,8 @@ export class OutputBookingsService_2024_08_13 {
absentHost: !!databaseBooking.noShowHost,
bookingFieldsResponses: databaseBooking.responses,
createdAt: databaseBooking.createdAt,
updatedAt: databaseBooking.updatedAt,
rating: databaseBooking.rating,
};
const bookingTransformed = plainToClass(RecurringBookingOutput_2024_08_13, booking, {
@@ -267,6 +275,8 @@ export class OutputBookingsService_2024_08_13 {
meetingUrl: location,
absentHost: !!databaseBooking.noShowHost,
createdAt: databaseBooking.createdAt,
updatedAt: databaseBooking.updatedAt,
rating: databaseBooking.rating,
};
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
@@ -276,7 +286,8 @@ export class OutputBookingsService_2024_08_13 {
const { responses } = safeParse(
seatedBookingDataSchema,
attendee.bookingSeat?.data,
defaultSeatedBookingData
defaultSeatedBookingData,
false
);
const attendeeData = {
@@ -293,7 +304,8 @@ export class OutputBookingsService_2024_08_13 {
attendeeParsed.metadata = safeParse(
seatedBookingMetadataSchema,
attendee.bookingSeat?.metadata,
defaultSeatedBookingMetadata
defaultSeatedBookingMetadata,
false
);
// note(Lauris): as of now email is not returned for privacy
delete attendeeParsed.bookingFieldsResponses.email;
@@ -372,6 +384,8 @@ export class OutputBookingsService_2024_08_13 {
recurringBookingUid: databaseBooking.recurringEventId,
absentHost: !!databaseBooking.noShowHost,
createdAt: databaseBooking.createdAt,
updatedAt: databaseBooking.updatedAt,
rating: databaseBooking.rating,
};
const parsed = plainToClass(GetRecurringSeatedBookingOutput_2024_08_13, booking, {
@@ -383,7 +397,8 @@ export class OutputBookingsService_2024_08_13 {
const { responses } = safeParse(
seatedBookingDataSchema,
attendee.bookingSeat?.data,
defaultSeatedBookingData
defaultSeatedBookingData,
false
);
const attendeeData = {
@@ -400,7 +415,8 @@ export class OutputBookingsService_2024_08_13 {
attendeeParsed.metadata = safeParse(
seatedBookingMetadataSchema,
attendee.bookingSeat?.metadata,
defaultSeatedBookingMetadata
defaultSeatedBookingMetadata,
false
);
// note(Lauris): as of now email is not returned for privacy
delete attendeeParsed.bookingFieldsResponses.email;
@@ -34,7 +34,7 @@ import {
Post,
Body,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger";
import { User } from "@prisma/client";
import { plainToClass } from "class-transformer";
import { Request } from "express";
@@ -123,10 +123,15 @@ export class CalendarsController {
};
}
@ApiParam({
enum: [OFFICE_365_CALENDAR, GOOGLE_CALENDAR],
type: String,
name: "calendar",
})
@UseGuards(ApiAuthGuard)
@Get("/:calendar/connect")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Get connect URL" })
@ApiOperation({ summary: "Get oAuth connect URL" })
async redirect(
@Req() req: Request,
@Headers("Authorization") authorization: string,
@@ -146,10 +151,15 @@ export class CalendarsController {
}
}
@ApiParam({
enum: [OFFICE_365_CALENDAR, GOOGLE_CALENDAR],
type: String,
name: "calendar",
})
@Get("/:calendar/save")
@HttpCode(HttpStatus.OK)
@Redirect(undefined, 301)
@ApiOperation({ summary: "Save a calendar" })
@ApiOperation({ summary: "Save an oAuth calendar credentials" })
async save(
@Query("state") state: string,
@Query("code") code: string,
@@ -177,6 +187,11 @@ export class CalendarsController {
}
}
@ApiParam({
enum: [APPLE_CALENDAR],
type: String,
name: "calendar",
})
@UseGuards(ApiAuthGuard)
@Post("/:calendar/credentials")
@ApiOperation({ summary: "Sync credentials" })
@@ -198,6 +213,11 @@ export class CalendarsController {
}
}
@ApiParam({
enum: [APPLE_CALENDAR, GOOGLE_CALENDAR, OFFICE_365_CALENDAR],
type: String,
name: "calendar",
})
@Get("/:calendar/check")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard, PermissionsGuard)
@@ -219,6 +239,11 @@ export class CalendarsController {
}
}
@ApiParam({
enum: [APPLE_CALENDAR, GOOGLE_CALENDAR, OFFICE_365_CALENDAR],
type: String,
name: "calendar",
})
@UseGuards(ApiAuthGuard)
@Post("/:calendar/disconnect")
@HttpCode(HttpStatus.OK)
@@ -7,11 +7,7 @@ import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
@Injectable()
export class EventTypesRepository_2024_06_14 {
constructor(
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private usersService: UsersService
) {}
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createUserEventType(
userId: number,
@@ -47,6 +43,13 @@ export class EventTypesRepository_2024_06_14 {
});
}
async getEventTypeWithHosts(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
include: { hosts: true },
});
}
async getUserEventType(userId: number, eventTypeId: number) {
return this.dbRead.prisma.eventType.findFirst({
where: {
@@ -1,6 +1,6 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsString, ValidateNested } from "class-validator";
import { IsEnum, IsString, ValidateNested, IsOptional } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
@@ -10,8 +10,9 @@ class Data {
callId!: string;
@IsString()
@IsOptional()
@ApiProperty()
agentId!: string;
agentId?: string;
}
export class CreatePhoneCallOutput {
+3 -2
View File
@@ -26,7 +26,8 @@ import {
Headers,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { ApiExcludeController } from "@nestjs/swagger";
import { ApiOperation } from "@nestjs/swagger";
import { Request } from "express";
import { APPS_READ, GOOGLE_CALENDAR_TYPE, SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -41,7 +42,7 @@ const CALENDAR_SCOPES = [
path: "/v2/gcal",
version: API_VERSIONS_VALUES,
})
@DocsTags("Platform / Google Calendar")
@ApiExcludeController(true)
export class GcalController {
private readonly logger = new Logger("Platform Gcal Provider");
@@ -8,10 +8,11 @@ import { MeModule } from "@/ee/me/me.module";
import { ProviderModule } from "@/ee/provider/provider.module";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { SlotsModule } from "@/modules/slots/slots.module";
import { SlotsModule_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slots.module";
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@@ -29,7 +30,8 @@ import { Module } from "@nestjs/common";
BookingsModule_2024_04_15,
BookingsModule_2024_08_13,
TeamsMembershipsModule,
SlotsModule,
SlotsModule_2024_04_15,
SlotsModule_2024_09_04,
TeamsModule,
],
})
+3
View File
@@ -6,6 +6,7 @@ import {
VERSION_2024_06_11,
VERSION_2024_06_14,
VERSION_2024_08_13,
VERSION_2024_09_04,
} from "@calcom/platform-constants";
export const API_VERSIONS_VALUES: VersionValue = API_VERSIONS as unknown as VersionValue;
@@ -13,8 +14,10 @@ export const VERSION_2024_06_14_VALUE: VersionValue = VERSION_2024_06_14 as unkn
export const VERSION_2024_06_11_VALUE: VersionValue = VERSION_2024_06_11 as unknown as VersionValue;
export const VERSION_2024_04_15_VALUE: VersionValue = VERSION_2024_04_15 as unknown as VersionValue;
export const VERSION_2024_08_13_VALUE: VersionValue = VERSION_2024_08_13 as unknown as VersionValue;
export const VERSION_2024_09_04_VALUE: VersionValue = VERSION_2024_09_04 as unknown as VersionValue;
export { VERSION_2024_04_15 };
export { VERSION_2024_06_11 };
export { VERSION_2024_06_14 };
export { VERSION_2024_08_13 };
export { VERSION_2024_09_04 };
+15 -8
View File
@@ -3,20 +3,27 @@ import { ZodSchema } from "zod";
const logger = new Logger("safeParse");
export function safeParse<T>(schema: ZodSchema<T>, value: unknown, defaultValue: T): T {
export function safeParse<T>(
schema: ZodSchema<T>,
value: unknown,
defaultValue: T,
logError = true
): T {
const result = schema.safeParse(value);
if (result.success) {
return result.data;
} else {
const errorStack = new Error().stack;
logger.error(
`Zod parsing failed.\n` +
`1. Schema: ${schema.description || "UnnamedSchema"}\n` +
`2. Input: ${JSON.stringify(value, null, 2)}\n` +
`3. Zod Error: ${result.error}\n` +
`4. Call Stack: ${errorStack}`
);
if (logError) {
logger.error(
`Zod parsing failed.\n` +
`1. Schema: ${schema.description || "UnnamedSchema"}\n` +
`2. Input: ${JSON.stringify(value, null, 2)}\n` +
`3. Zod Error: ${result.error}\n` +
`4. Call Stack: ${errorStack}`
);
}
return defaultValue;
}
@@ -0,0 +1,30 @@
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { ExecutionContext } from "@nestjs/common";
import { createParamDecorator } from "@nestjs/common";
export const GetOptionalUser = createParamDecorator<
keyof ApiAuthGuardUser | (keyof ApiAuthGuardUser)[],
ExecutionContext
>((data, ctx) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as ApiAuthGuardUser;
if (!user) {
return null;
}
if (Array.isArray(data)) {
return data.reduce((prev, curr) => {
return {
...prev,
[curr]: user[curr],
};
}, {});
}
if (data) {
return user[data];
}
return user;
});
@@ -0,0 +1,14 @@
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { NO_AUTH_PROVIDED_MESSAGE } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
export class OptionalApiAuthGuard extends ApiAuthGuard {
handleRequest(error: Error, user: any) {
// note(Lauris): optional means that auth is not required but if it is invalid then still throw error.
const noAuthProvided = error && error.message === NO_AUTH_PROVIDED_MESSAGE;
if (user || noAuthProvided || !error) {
return user || null;
} else {
throw error;
}
}
}
@@ -19,6 +19,8 @@ import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom
export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean };
export const NO_AUTH_PROVIDED_MESSAGE =
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
@Injectable()
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
constructor(
@@ -62,9 +64,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
return await this.authenticateNextAuth(nextAuthToken);
}
throw new UnauthorizedException(
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers"
);
throw new UnauthorizedException(NO_AUTH_PROVIDED_MESSAGE);
} catch (err) {
if (err instanceof Error) {
return this.error(err);
@@ -1,6 +1,6 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { ConferencingController } from "@/modules/conferencing/controllers/conferencing.controller";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
@@ -1,4 +1,4 @@
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
@@ -1,4 +1,4 @@
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
@@ -4,6 +4,7 @@ import { BillingModule } from "@/modules/billing/billing.module";
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.module";
import { RouterModule } from "@/modules/router/router.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
@@ -25,6 +26,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
AtomsModule,
StripeModule,
ConferencingModule,
OrganizationsTeamsBookingsModule,
RouterModule,
],
})
@@ -1,10 +1,11 @@
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [MembershipsRepository],
exports: [MembershipsRepository],
providers: [MembershipsRepository, MembershipsService],
exports: [MembershipsRepository, MembershipsService],
})
export class MembershipsModule {}
@@ -0,0 +1,24 @@
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { Injectable } from "@nestjs/common";
import { intersectionBy } from "lodash";
@Injectable()
export class MembershipsService {
constructor(private readonly membershipsRepository: MembershipsRepository) {}
async haveMembershipsInCommon(firstUserId: number, secondUserId: number) {
const memberships = await this.membershipsInCommon(firstUserId, secondUserId);
return memberships.length > 0;
}
async membershipsInCommon(firstUserId: number, secondUserId: number) {
const firstUserMemberships = await this.membershipsRepository.findUserMemberships(firstUserId);
const secondUserMemberships = await this.membershipsRepository.findUserMemberships(secondUserId);
return intersectionBy(
firstUserMemberships.filter((m) => m.accepted),
secondUserMemberships.filter((m) => m.accepted),
"teamId"
);
}
}
@@ -0,0 +1,179 @@
import { ApiProperty } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import {
ArrayMinSize,
ArrayNotEmpty,
IsArray,
IsEnum,
IsInt,
IsISO8601,
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from "class-validator";
enum Status {
upcoming = "upcoming",
recurring = "recurring",
past = "past",
cancelled = "cancelled",
unconfirmed = "unconfirmed",
}
type StatusType = keyof typeof Status;
enum SortOrder {
asc = "asc",
desc = "desc",
}
type SortOrderType = keyof typeof SortOrder;
export class GetOrganizationsTeamsBookingsInput_2024_08_13 {
// note(Lauris): filters
@IsOptional()
@Transform(({ value }) => {
if (typeof value === "string") {
return value.split(",").map((status: string) => status.trim());
}
return value;
})
@ArrayNotEmpty({ message: "status cannot be empty." })
@IsEnum(Status, {
each: true,
message: "Invalid status. Allowed are upcoming, recurring, past, cancelled, unconfirmed",
})
@ApiProperty({
required: false,
description:
"Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.",
example: "?status=upcoming,past",
enum: Status,
isArray: true,
})
status?: StatusType[];
@IsString()
@IsOptional()
@ApiProperty({
type: String,
required: false,
description: "Filter bookings by the attendee's email address.",
example: "example@domain.com",
})
attendeeEmail?: string;
@IsString()
@IsOptional()
@ApiProperty({
type: String,
required: false,
description: "Filter bookings by the attendee's name.",
example: "John Doe",
})
attendeeName?: string;
@IsOptional()
@Transform(({ value }) => {
if (typeof value === "string") {
return value.split(",").map((eventTypeId: string) => parseInt(eventTypeId));
}
return value;
})
@IsArray()
@IsNumber({}, { each: true })
@ArrayMinSize(1, { message: "eventTypeIds must contain at least 1 event type id" })
@ApiProperty({
type: String,
required: false,
description:
"Filter bookings by event type ids belonging to the team. Event type ids must be separated by a comma.",
example: "?eventTypeIds=100,200",
})
eventTypeIds?: number[];
@IsInt()
@IsOptional()
@Type(() => Number)
@ApiProperty({
type: String,
required: false,
description: "Filter bookings by event type id belonging to the team.",
example: "?eventTypeId=100",
})
eventTypeId?: number;
@IsOptional()
@IsISO8601({ strict: true }, { message: "fromDate must be a valid ISO 8601 date." })
@ApiProperty({
type: String,
required: false,
description: "Filter bookings with start after this date string.",
example: "?afterStart=2025-03-07T10:00:00.000Z",
})
afterStart?: string;
@IsOptional()
@IsISO8601({ strict: true }, { message: "toDate must be a valid ISO 8601 date." })
@ApiProperty({
type: String,
required: false,
description: "Filter bookings with end before this date string.",
example: "?beforeEnd=2025-03-07T11:00:00.000Z",
})
beforeEnd?: string;
// note(Lauris): sort
@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;
@IsOptional()
@IsEnum(SortOrder, {
message: 'SortCreated must be either "asc" or "desc".',
})
@ApiProperty({
required: false,
description:
"Sort results by their creation time (when booking was made) in ascending or descending order.",
example: "?sortCreated=asc OR ?sortCreated=desc",
enum: SortOrder,
})
sortCreated?: SortOrderType;
// note(Lauris): pagination
@ApiProperty({ required: false, description: "The number of items to return", example: 10 })
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@Min(1)
@Max(250)
@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;
}
@@ -0,0 +1,364 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.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 { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.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 {
CAL_API_VERSION_HEADER,
SUCCESS_STATUS,
VERSION_2024_08_13,
X_CAL_CLIENT_ID,
X_CAL_SECRET_KEY,
} from "@calcom/platform-constants";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Organizations TeamsBookings Endpoints 2024-08-13", () => {
describe("Organization Team bookings", () => {
let app: INestApplication;
let organization: Team;
let team1: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let hostsRepositoryFixture: HostsRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
const teamUserEmail = "orgUser1team1@api.com";
const teamUserEmail2 = "orgUser2team1@api.com";
let teamUser: User;
let teamUser2: User;
let team1EventTypeId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
teamUserEmail,
Test.createTestingModule({
imports: [AppModule, OrganizationsTeamsBookingsModule],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({ name: "organization team bookings" });
oAuthClient = await createOAuthClient(organization.id);
team1 = await teamRepositoryFixture.create({
name: "team 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClient.id,
},
},
});
teamUser = await userRepositoryFixture.create({
email: teamUserEmail,
locale: "it",
name: "orgUser1team1",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
teamUser2 = await userRepositoryFixture.create({
email: teamUserEmail2,
locale: "es",
name: "orgUser2team1",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(teamUser.id, userSchedule);
await schedulesService.createUserSchedule(teamUser2.id, userSchedule);
await profileRepositoryFixture.create({
uid: `usr-${teamUser.id}`,
username: teamUserEmail,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: teamUser.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${teamUser2.id}`,
username: teamUserEmail2,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: teamUser2.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: teamUser.id } },
team: { connect: { id: team1.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: teamUser.id } },
team: { connect: { id: organization.id } },
accepted: true,
});
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team1.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
team1EventTypeId = team1EventType.id;
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: teamUser.id,
},
},
eventType: {
connect: {
id: team1EventType.id,
},
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
describe("create team bookings", () => {
it("should create a team 1 booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: team1EventTypeId,
attendee: {
name: "alice",
email: "alice@gmail.com",
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, 14, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(team1EventTypeId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
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"
);
}
});
});
});
describe("get team bookings", () => {
it("should should get bookings by teamId", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/teams/${team1.id}/bookings`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].eventTypeId).toEqual(team1EventTypeId);
});
});
it("should get bookings by teamId and eventTypeId", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${organization.id}/teams/${team1.id}/bookings?eventTypeId=${team1EventTypeId}`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].eventTypeId).toEqual(team1EventTypeId);
});
});
it("should not get bookings by teamId and non existing eventTypeId", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/teams/${team1.id}/bookings?eventTypeId=90909`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(0);
});
});
it("should not get bookings by non existing teamId", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/teams/90909/bookings`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(404);
});
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(teamUser.email);
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email);
await app.close();
});
});
});
@@ -0,0 +1,47 @@
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.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 { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { GetOrganizationsTeamsBookingsInput_2024_08_13 } from "@/modules/organizations/controllers/teams/bookings/inputs/get-organizations-teams-bookings.input";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId/bookings",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Orgs / Teams / Bookings")
export class OrganizationsTeamsBookingsController {
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
@Get("/")
@ApiOperation({ summary: "Get organization team bookings" })
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@HttpCode(HttpStatus.OK)
async getAllOrgTeamBookings(
@Query() queryParams: GetOrganizationsTeamsBookingsInput_2024_08_13,
@Param("teamId", ParseIntPipe) teamId: number,
@GetUser() user: UserWithProfile
): Promise<GetBookingsOutput_2024_08_13> {
const bookings = await this.bookingsService.getBookings({ ...queryParams, teamId }, user);
return {
status: SUCCESS_STATUS,
data: bookings,
};
}
}
@@ -0,0 +1,16 @@
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsTeamsBookingsController } from "@/modules/organizations/controllers/teams/bookings/organizations-teams-bookings.controller";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { Module } from "@nestjs/common";
@Module({
imports: [BookingsModule_2024_08_13, PrismaModule, StripeModule, RedisModule, MembershipsModule],
providers: [OrganizationsRepository, OrganizationsTeamsRepository],
controllers: [OrganizationsTeamsBookingsController],
})
export class OrganizationsTeamsBookingsModule {}
@@ -131,4 +131,14 @@ export class OrganizationsRepository {
},
});
}
async findOrgBySlug(slug: string) {
return this.dbRead.prisma.team.findFirst({
where: {
slug,
parentId: null,
isOrganization: true,
},
});
}
}
@@ -37,22 +37,6 @@ export class OrganizationsUsersRepository {
});
}
async getOrganizationUserByUsername(orgId: number, username: string) {
return await this.dbRead.prisma.user.findFirst({
where: {
username,
...this.filterOnOrgMembership(orgId),
},
include: {
profiles: {
where: {
organizationId: orgId,
},
},
},
});
}
async getOrganizationUserByEmail(orgId: number, email: string) {
return await this.dbRead.prisma.user.findFirst({
where: {
@@ -109,4 +93,19 @@ export class OrganizationsUsersRepository {
},
});
}
async getOrganizationUserByUsername(orgId: number, username: string) {
const profile = await this.dbRead.prisma.profile.findUnique({
where: {
username_organizationId: {
organizationId: orgId,
username,
},
},
include: {
user: true,
},
});
return profile?.user;
}
}
@@ -1,9 +1,13 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { Controller, Req, NotFoundException, Param, Post, Body } from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { Request } from "express";
import { getRoutedUrl } from "@calcom/platform-libraries";
import {
getRoutedUrl,
getTeamMemberEmailForResponseOrContactUsingUrlQuery,
} from "@calcom/platform-libraries";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
@@ -13,6 +17,8 @@ import { ApiResponse } from "@calcom/platform-types";
@DocsTags("Router controller")
@DocsExcludeController(true)
export class RouterController {
constructor(private readonly teamsEventTypesRepository: TeamsEventTypesRepository) {}
@Post("/forms/:formId/submit")
async getRoutingFormResponse(
@Req() request: Request,
@@ -27,13 +33,58 @@ export class RouterController {
}
if (routedUrlData?.redirect?.destination) {
return { status: "success", data: routedUrlData?.redirect?.destination, redirect: true };
return this.handleRedirect(routedUrlData.redirect.destination);
}
if (routedUrlData?.props) {
return { status: "success", data: { message: routedUrlData?.props?.message ?? "" }, redirect: false };
return { status: "success", data: { message: routedUrlData.props.message ?? "" }, redirect: false };
}
return { status: "success", data: { message: "No Route nor custom message found." }, redirect: false };
}
private async handleRedirect(destination: string): Promise<ApiResponse<unknown> & { redirect: boolean }> {
const routingUrl = new URL(destination);
const routingSearchParams = routingUrl.searchParams;
if (
routingSearchParams.get("cal.action") === "eventTypeRedirectUrl" &&
routingSearchParams.has("email") &&
routingSearchParams.has("cal.teamId") &&
!routingSearchParams.has("cal.skipContactOwner")
) {
return this.handleRedirectWithContactOwner(routingUrl, routingSearchParams);
}
return { status: "success", data: destination, redirect: true };
}
private async handleRedirectWithContactOwner(
routingUrl: URL,
routingSearchParams: URLSearchParams
): Promise<ApiResponse<unknown> & { redirect: boolean }> {
const pathNameParams = routingUrl.pathname.split("/");
const eventTypeSlug = pathNameParams[pathNameParams.length - 1];
const teamId = Number(routingSearchParams.get("cal.teamId"));
const eventTypeData = await this.teamsEventTypesRepository.getTeamEventTypeBySlug(
teamId,
eventTypeSlug,
3
);
// get the salesforce record owner email for the email given as a form response.
const {
email: teamMemberEmail,
recordType: crmOwnerRecordType,
crmAppSlug,
} = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: Object.fromEntries(routingSearchParams),
eventData: eventTypeData,
});
Boolean(teamMemberEmail) && routingUrl.searchParams.set("cal.teamMemberEmail", teamMemberEmail);
Boolean(crmOwnerRecordType) && routingUrl.searchParams.set("cal.crmOwnerRecordType", crmOwnerRecordType);
Boolean(crmAppSlug) && routingUrl.searchParams.set("cal.crmAppSlug", crmAppSlug);
return { status: "success", data: routingUrl.toString(), redirect: true };
}
}
@@ -1,10 +1,11 @@
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RouterController } from "@/modules/router/controllers/router.controller";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [],
providers: [TeamsEventTypesRepository],
exports: [],
controllers: [RouterController],
})
@@ -4,7 +4,7 @@ import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/
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 { SlotsModule_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slots.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
@@ -12,6 +12,8 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { AttendeeRepositoryFixture } from "test/fixtures/repository/attendee.repository.fixture";
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
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";
@@ -241,7 +243,7 @@ const expectedSlotsRomeRange = {
},
};
describe("Slots Endpoints", () => {
describe("Slots 2024-04-15 Endpoints", () => {
describe("Individual user slots", () => {
let app: INestApplication;
@@ -250,6 +252,8 @@ describe("Slots Endpoints", () => {
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let selectedSlotsRepositoryFixture: SelectedSlotsRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let bookingSeatsRepositoryFixture: BookingSeatRepositoryFixture;
let attendeesRepositoryFixture: AttendeeRepositoryFixture;
const userEmail = `slots-${randomString()}-controller-e2e@api.com`;
const userName = "bob";
@@ -258,6 +262,9 @@ describe("Slots Endpoints", () => {
let eventTypeSlug: string;
let reservedSlotUid: string;
const seatedEventTypeSlug = "peer-coding-seated";
let seatedEventTypeId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
@@ -268,7 +275,7 @@ describe("Slots Endpoints", () => {
UsersModule,
TokensModule,
SchedulesModule_2024_06_11,
SlotsModule,
SlotsModule_2024_04_15,
],
})
)
@@ -283,6 +290,8 @@ describe("Slots Endpoints", () => {
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
selectedSlotsRepositoryFixture = new SelectedSlotsRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
bookingSeatsRepositoryFixture = new BookingSeatRepositoryFixture(moduleRef);
attendeesRepositoryFixture = new AttendeeRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
@@ -291,7 +300,7 @@ describe("Slots Endpoints", () => {
});
// nxte(Lauris): this creates default schedule monday to friday from 9AM to 5PM in Europe/Rome timezone
const userSchedule = await schedulesService.createUserSchedule(user.id, {
await schedulesService.createUserSchedule(user.id, {
name: `slots-schedule-${randomString()}-slots.controller.e2e-spec`,
timeZone: "Europe/Rome",
isDefault: true,
@@ -306,9 +315,24 @@ describe("Slots Endpoints", () => {
},
user.id
);
eventTypeId = eventType.id;
eventTypeSlug = eventType.slug;
const seatedEvent = await eventTypesRepositoryFixture.create(
{
title: `slots-event-type-seated-${randomString()}-slots.controller.e2e-spec`,
slug: `slots-event-type-seated-${randomString()}-slots.controller.e2e-spec`,
length: 60,
seatsPerTimeSlot: 5,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
},
user.id
);
seatedEventTypeId = seatedEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -445,7 +469,7 @@ describe("Slots Endpoints", () => {
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({
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${eventTypeId}`,
title: "booking title",
startTime,
@@ -486,6 +510,179 @@ describe("Slots Endpoints", () => {
expect(slots).toEqual({
slots: { ...expectedSlotsUTC.slots, "2050-09-05": expectedSlotsUTC2050_09_05 },
});
await bookingsRepositoryFixture.deleteById(booking.id);
});
it("should do a booking for seated event and slot should show attendees count and bookingUid", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${seatedEventTypeId}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
eventType: {
connect: {
id: seatedEventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
const attendee = await attendeesRepositoryFixture.create({
name: "tester",
email: "tester@example.com",
timeZone: "Europe/London",
booking: {
connect: {
id: booking.id,
},
},
});
bookingSeatsRepositoryFixture.create({
referenceUid: "100",
data: {},
booking: {
connect: {
id: booking.id,
},
},
attendee: {
connect: {
id: attendee.id,
},
},
});
const response = await request(app.getHttpServer())
.get(
`/api/v2/slots/available?eventTypeId=${seatedEventTypeId}&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 = [
{ 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", attendees: 1, bookingUid: booking.uid },
{ time: "2050-09-05T12:00:00.000Z" },
{ time: "2050-09-05T13:00:00.000Z" },
{ time: "2050-09-05T14:00:00.000Z" },
];
expect(slots).toEqual({
slots: { ...expectedSlotsUTC.slots, "2050-09-05": expectedSlotsUTC2050_09_05 },
});
await bookingsRepositoryFixture.deleteById(booking.id);
});
it("should do a booking for seated event and slot should show attendees count and bookingUid in range format", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${seatedEventTypeId}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
eventType: {
connect: {
id: seatedEventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
const attendee = await attendeesRepositoryFixture.create({
name: "tester",
email: "tester@example.com",
timeZone: "Europe/London",
booking: {
connect: {
id: booking.id,
},
},
});
bookingSeatsRepositoryFixture.create({
referenceUid: "100",
data: {},
booking: {
connect: {
id: booking.id,
},
},
attendee: {
connect: {
id: attendee.id,
},
},
});
const response = await request(app.getHttpServer())
.get(
`/api/v2/slots/available?eventTypeId=${seatedEventTypeId}&startTime=2050-09-05&endTime=2050-09-10&slotFormat=range`
)
.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 = [
{ 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",
attendees: 1,
bookingUid: booking.uid,
},
{ 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" },
];
expect(slots).toEqual({
slots: { ...expectedSlotsUTCRange.slots, "2050-09-05": expectedSlotsUTC2050_09_05 },
});
await bookingsRepositoryFixture.deleteById(booking.id);
});
afterAll(async () => {
@@ -1,25 +1,31 @@
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 { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
import { SlotsService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots.service";
import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common";
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
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 {
SUCCESS_STATUS,
VERSION_2024_06_14,
VERSION_2024_04_15,
VERSION_2024_06_11,
VERSION_2024_08_13,
} from "@calcom/platform-constants";
import { getAvailableSlots } from "@calcom/platform-libraries";
import type { AvailableSlotsType } from "@calcom/platform-libraries";
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
import { RemoveSelectedSlotInput_2024_04_15, ReserveSlotInput_2024_04_15 } from "@calcom/platform-types";
import { ApiResponse, GetAvailableSlotsInput_2024_04_15 } from "@calcom/platform-types";
@Controller({
path: "/v2/slots",
version: API_VERSIONS_VALUES,
version: [VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14, VERSION_2024_08_13],
})
@DocsTags("Slots")
export class SlotsController {
@DocsExcludeController(true)
export class SlotsController_2024_04_15 {
constructor(
private readonly slotsService: SlotsService,
private readonly slotsOutputService: SlotsOutputService
private readonly slotsService: SlotsService_2024_04_15,
private readonly slotsOutputService: SlotsOutputService_2024_04_15
) {}
@Post("/reserve")
@@ -40,7 +46,7 @@ export class SlotsController {
})
@ApiOperation({ summary: "Reserve a slot" })
async reserveSlot(
@Body() body: ReserveSlotInput,
@Body() body: ReserveSlotInput_2024_04_15,
@Res({ passthrough: true }) res: ExpressResponse,
@Req() req: ExpressRequest
): Promise<ApiResponse<string>> {
@@ -65,7 +71,7 @@ export class SlotsController {
})
@ApiOperation({ summary: "Delete a selected slot" })
async deleteSelectedSlot(
@Query() params: RemoveSelectedSlotInput,
@Query() params: RemoveSelectedSlotInput_2024_04_15,
@Req() req: ExpressRequest
): Promise<ApiResponse> {
const uid = req.cookies?.uid || params.uid;
@@ -148,7 +154,7 @@ export class SlotsController {
})
@ApiOperation({ summary: "Get available slots" })
async getAvailableSlots(
@Query() query: GetAvailableSlotsInput,
@Query() query: GetAvailableSlotsInput_2024_04_15,
@Req() req: ExpressRequest
): Promise<ApiResponse<AvailableSlotsType>> {
const isTeamEvent = await this.slotsService.checkIfIsTeamEvent(query.eventTypeId);
@@ -4,11 +4,15 @@ 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 }[]> };
export type TimeSlots = {
slots: Record<string, { time: string; attendees?: number; bookingUid?: string }[]>;
};
type RangeSlots = {
slots: Record<string, { startTime: string; endTime: string; attendees?: number; bookingUid?: string }[]>;
};
@Injectable()
export class SlotsOutputService {
export class SlotsOutputService_2024_04_15 {
constructor(private readonly eventTypesRepository: EventTypesRepository_2024_04_15) {}
async getOutputSlots(
@@ -30,6 +34,8 @@ export class SlotsOutputService {
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",
...(slot.attendees ? { attendees: slot.attendees } : {}),
...(slot.bookingUid ? { bookingUid: slot.bookingUid } : {}),
}));
return acc;
}, {} as Record<string, { time: string }[]>);
@@ -42,6 +48,8 @@ export class SlotsOutputService {
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",
...(slot.attendees ? { attendees: slot.attendees } : {}),
...(slot.bookingUid ? { bookingUid: slot.bookingUid } : {}),
}));
return acc;
}, {} as Record<string, { startTime: string; endTime: string }[]>);
@@ -62,14 +70,16 @@ export class SlotsOutputService {
const slotDuration = await this.getDuration(duration, eventTypeId);
const slots = Object.entries(availableSlots.slots).reduce<
Record<string, { startTime: string; endTime: string }[]>
Record<string, { startTime: string; endTime: string; attendees?: number; bookingUid?: string }[]>
>((acc, [date, slots]) => {
acc[date] = (slots as { time: string }[]).map((slot) => {
acc[date] = (slots as { time: string; attendees?: number; bookingUid?: string }[]).map((slot) => {
const startTime = new Date(slot.time);
const endTime = new Date(startTime.getTime() + slotDuration * 60000);
return {
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
...(slot.attendees ? { attendees: slot.attendees } : {}),
...(slot.bookingUid ? { bookingUid: slot.bookingUid } : {}),
};
});
return acc;
@@ -1,20 +1,20 @@
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 { SlotsRepository_2024_04_15 } from "@/modules/slots/slots-2024-04-15/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";
import { ReserveSlotInput } from "@calcom/platform-types";
import { ReserveSlotInput_2024_04_15 } from "@calcom/platform-types";
@Injectable()
export class SlotsService {
export class SlotsService_2024_04_15 {
constructor(
private readonly eventTypeRepo: EventTypesRepository_2024_04_15,
private readonly slotsRepo: SlotsRepository
private readonly slotsRepo: SlotsRepository_2024_04_15
) {}
async reserveSlot(input: ReserveSlotInput, headerUid?: string) {
async reserveSlot(input: ReserveSlotInput_2024_04_15, headerUid?: string) {
const uid = headerUid || uuid();
const eventType = await this.eventTypeRepo.getEventTypeWithSeats(input.eventTypeId);
if (!eventType) {
@@ -0,0 +1,15 @@
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_2024_04_15 } from "@/modules/slots/slots-2024-04-15/controllers/slots.controller";
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
import { SlotsService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots.service";
import { SlotsRepository_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slots.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, EventTypesModule_2024_04_15],
providers: [SlotsRepository_2024_04_15, SlotsService_2024_04_15, SlotsOutputService_2024_04_15],
controllers: [SlotsController_2024_04_15],
exports: [SlotsService_2024_04_15],
})
export class SlotsModule_2024_04_15 {}
@@ -4,10 +4,10 @@ import { Injectable } from "@nestjs/common";
import { DateTime } from "luxon";
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries";
import { ReserveSlotInput } from "@calcom/platform-types";
import { ReserveSlotInput_2024_04_15 } from "@calcom/platform-types";
@Injectable()
export class SlotsRepository {
export class SlotsRepository_2024_04_15 {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getBookingWithAttendees(bookingUid?: string) {
@@ -17,7 +17,7 @@ export class SlotsRepository {
});
}
async upsertSelectedSlot(userId: number, input: ReserveSlotInput, uid: string, isSeat: boolean) {
async upsertSelectedSlot(userId: number, input: ReserveSlotInput_2024_04_15, uid: string, isSeat: boolean) {
const { slotUtcEndDate, slotUtcStartDate, eventTypeId } = input;
const releaseAt = DateTime.utc()
@@ -0,0 +1,182 @@
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 {
expectedSlotsRome,
expectedSlotsUTC,
} from "@/modules/slots/slots-2024-09-04/controllers/e2e/expected-slots";
import { GetSlotsOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-slots.output";
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/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 { Profile, User } from "@prisma/client";
import * as request from "supertest";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_09_04 } from "@calcom/platform-constants";
import { CreateScheduleInput_2024_06_11 } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
describe("Slots 2024-09-04 Endpoints", () => {
describe("Dynamic event type slots", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let schedulesService: SchedulesService_2024_06_11;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
const userEmailOne = `slots-2024-09-04-user-1-dynamic-slots-${randomString()}@api.com`;
const userEmailTwo = `slots-2024-09-04-user-2-dynamic-slots-${randomString()}@api.com`;
let organization: Team;
let userOne: User;
let userTwo: User;
let orgProfileOne: Profile;
let orgProfileTwo: Profile;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmailOne,
Test.createTestingModule({
imports: [
AppModule,
PrismaModule,
UsersModule,
TokensModule,
SchedulesModule_2024_06_11,
SlotsModule_2024_09_04,
],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_06_11>(SchedulesService_2024_06_11);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
const orgSlug = `slots-2024-09-04-org-${randomString()}`;
organization = await organizationsRepositoryFixture.create({
name: orgSlug,
isOrganization: true,
slug: orgSlug,
});
userOne = await userRepositoryFixture.create({
email: userEmailOne,
name: userEmailOne,
username: userEmailOne,
});
userTwo = await userRepositoryFixture.create({
email: userEmailTwo,
name: userEmailTwo,
username: userEmailTwo,
});
orgProfileOne = await profileRepositoryFixture.create({
uid: `usr-${userOne.id}`,
username: "teammate-one",
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: userOne.id,
},
},
});
orgProfileTwo = await profileRepositoryFixture.create({
uid: `usr-${userTwo.id}`,
username: "teammate-two",
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: userTwo.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_06_11 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
// note(Lauris): this creates default schedule monday to friday from 9AM to 5PM in Europe/Rome timezone
await schedulesService.createUserSchedule(userOne.id, userSchedule);
await schedulesService.createUserSchedule(userTwo.id, userSchedule);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should get slots in UTC by usernames", async () => {
return request(app.getHttpServer())
.get(
`/v2/slots?usernames=${orgProfileOne.username},${orgProfileTwo.username}&organizationSlug=${organization.slug}&start=2050-09-05&end=2050-09-09&duration=60`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200)
.then(async (response) => {
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
expect(slots).toEqual(expectedSlotsUTC);
});
});
it("should get slots in specified timezone and in specified duration by usernames", async () => {
return request(app.getHttpServer())
.get(
`/v2/slots?usernames=${orgProfileOne.username},${orgProfileTwo.username}&organizationSlug=${organization.slug}&start=2050-09-05&end=2050-09-09&duration=60&timeZone=Europe/Rome`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200)
.then(async (response) => {
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
expect(slots).toEqual(expectedSlotsRome);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(userOne.email);
await userRepositoryFixture.deleteByEmail(userTwo.email);
await organizationsRepositoryFixture.delete(organization.id);
await app.close();
});
});
});
@@ -0,0 +1,211 @@
export const expectedSlotsUTC = {
"2050-09-05": [
{ start: "2050-09-05T07:00:00.000Z" },
{ start: "2050-09-05T08:00:00.000Z" },
{ start: "2050-09-05T09:00:00.000Z" },
{ start: "2050-09-05T10:00:00.000Z" },
{ start: "2050-09-05T11:00:00.000Z" },
{ start: "2050-09-05T12:00:00.000Z" },
{ start: "2050-09-05T13:00:00.000Z" },
{ start: "2050-09-05T14:00:00.000Z" },
],
"2050-09-06": [
{ start: "2050-09-06T07:00:00.000Z" },
{ start: "2050-09-06T08:00:00.000Z" },
{ start: "2050-09-06T09:00:00.000Z" },
{ start: "2050-09-06T10:00:00.000Z" },
{ start: "2050-09-06T11:00:00.000Z" },
{ start: "2050-09-06T12:00:00.000Z" },
{ start: "2050-09-06T13:00:00.000Z" },
{ start: "2050-09-06T14:00:00.000Z" },
],
"2050-09-07": [
{ start: "2050-09-07T07:00:00.000Z" },
{ start: "2050-09-07T08:00:00.000Z" },
{ start: "2050-09-07T09:00:00.000Z" },
{ start: "2050-09-07T10:00:00.000Z" },
{ start: "2050-09-07T11:00:00.000Z" },
{ start: "2050-09-07T12:00:00.000Z" },
{ start: "2050-09-07T13:00:00.000Z" },
{ start: "2050-09-07T14:00:00.000Z" },
],
"2050-09-08": [
{ start: "2050-09-08T07:00:00.000Z" },
{ start: "2050-09-08T08:00:00.000Z" },
{ start: "2050-09-08T09:00:00.000Z" },
{ start: "2050-09-08T10:00:00.000Z" },
{ start: "2050-09-08T11:00:00.000Z" },
{ start: "2050-09-08T12:00:00.000Z" },
{ start: "2050-09-08T13:00:00.000Z" },
{ start: "2050-09-08T14:00:00.000Z" },
],
"2050-09-09": [
{ start: "2050-09-09T07:00:00.000Z" },
{ start: "2050-09-09T08:00:00.000Z" },
{ start: "2050-09-09T09:00:00.000Z" },
{ start: "2050-09-09T10:00:00.000Z" },
{ start: "2050-09-09T11:00:00.000Z" },
{ start: "2050-09-09T12:00:00.000Z" },
{ start: "2050-09-09T13:00:00.000Z" },
{ start: "2050-09-09T14:00:00.000Z" },
],
};
export const expectedSlotsRome = {
"2050-09-05": [
{ start: "2050-09-05T09:00:00.000+02:00" },
{ start: "2050-09-05T10:00:00.000+02:00" },
{ start: "2050-09-05T11:00:00.000+02:00" },
{ start: "2050-09-05T12:00:00.000+02:00" },
{ start: "2050-09-05T13:00:00.000+02:00" },
{ start: "2050-09-05T14:00:00.000+02:00" },
{ start: "2050-09-05T15:00:00.000+02:00" },
{ start: "2050-09-05T16:00:00.000+02:00" },
],
"2050-09-06": [
{ start: "2050-09-06T09:00:00.000+02:00" },
{ start: "2050-09-06T10:00:00.000+02:00" },
{ start: "2050-09-06T11:00:00.000+02:00" },
{ start: "2050-09-06T12:00:00.000+02:00" },
{ start: "2050-09-06T13:00:00.000+02:00" },
{ start: "2050-09-06T14:00:00.000+02:00" },
{ start: "2050-09-06T15:00:00.000+02:00" },
{ start: "2050-09-06T16:00:00.000+02:00" },
],
"2050-09-07": [
{ start: "2050-09-07T09:00:00.000+02:00" },
{ start: "2050-09-07T10:00:00.000+02:00" },
{ start: "2050-09-07T11:00:00.000+02:00" },
{ start: "2050-09-07T12:00:00.000+02:00" },
{ start: "2050-09-07T13:00:00.000+02:00" },
{ start: "2050-09-07T14:00:00.000+02:00" },
{ start: "2050-09-07T15:00:00.000+02:00" },
{ start: "2050-09-07T16:00:00.000+02:00" },
],
"2050-09-08": [
{ start: "2050-09-08T09:00:00.000+02:00" },
{ start: "2050-09-08T10:00:00.000+02:00" },
{ start: "2050-09-08T11:00:00.000+02:00" },
{ start: "2050-09-08T12:00:00.000+02:00" },
{ start: "2050-09-08T13:00:00.000+02:00" },
{ start: "2050-09-08T14:00:00.000+02:00" },
{ start: "2050-09-08T15:00:00.000+02:00" },
{ start: "2050-09-08T16:00:00.000+02:00" },
],
"2050-09-09": [
{ start: "2050-09-09T09:00:00.000+02:00" },
{ start: "2050-09-09T10:00:00.000+02:00" },
{ start: "2050-09-09T11:00:00.000+02:00" },
{ start: "2050-09-09T12:00:00.000+02:00" },
{ start: "2050-09-09T13:00:00.000+02:00" },
{ start: "2050-09-09T14:00:00.000+02:00" },
{ start: "2050-09-09T15:00:00.000+02:00" },
{ start: "2050-09-09T16:00:00.000+02:00" },
],
};
export const expectedSlotsUTCRange = {
"2050-09-05": [
{ start: "2050-09-05T07:00:00.000Z", end: "2050-09-05T08:00:00.000Z" },
{ start: "2050-09-05T08:00:00.000Z", end: "2050-09-05T09:00:00.000Z" },
{ start: "2050-09-05T09:00:00.000Z", end: "2050-09-05T10:00:00.000Z" },
{ start: "2050-09-05T10:00:00.000Z", end: "2050-09-05T11:00:00.000Z" },
{ start: "2050-09-05T11:00:00.000Z", end: "2050-09-05T12:00:00.000Z" },
{ start: "2050-09-05T12:00:00.000Z", end: "2050-09-05T13:00:00.000Z" },
{ start: "2050-09-05T13:00:00.000Z", end: "2050-09-05T14:00:00.000Z" },
{ start: "2050-09-05T14:00:00.000Z", end: "2050-09-05T15:00:00.000Z" },
],
"2050-09-06": [
{ start: "2050-09-06T07:00:00.000Z", end: "2050-09-06T08:00:00.000Z" },
{ start: "2050-09-06T08:00:00.000Z", end: "2050-09-06T09:00:00.000Z" },
{ start: "2050-09-06T09:00:00.000Z", end: "2050-09-06T10:00:00.000Z" },
{ start: "2050-09-06T10:00:00.000Z", end: "2050-09-06T11:00:00.000Z" },
{ start: "2050-09-06T11:00:00.000Z", end: "2050-09-06T12:00:00.000Z" },
{ start: "2050-09-06T12:00:00.000Z", end: "2050-09-06T13:00:00.000Z" },
{ start: "2050-09-06T13:00:00.000Z", end: "2050-09-06T14:00:00.000Z" },
{ start: "2050-09-06T14:00:00.000Z", end: "2050-09-06T15:00:00.000Z" },
],
"2050-09-07": [
{ start: "2050-09-07T07:00:00.000Z", end: "2050-09-07T08:00:00.000Z" },
{ start: "2050-09-07T08:00:00.000Z", end: "2050-09-07T09:00:00.000Z" },
{ start: "2050-09-07T09:00:00.000Z", end: "2050-09-07T10:00:00.000Z" },
{ start: "2050-09-07T10:00:00.000Z", end: "2050-09-07T11:00:00.000Z" },
{ start: "2050-09-07T11:00:00.000Z", end: "2050-09-07T12:00:00.000Z" },
{ start: "2050-09-07T12:00:00.000Z", end: "2050-09-07T13:00:00.000Z" },
{ start: "2050-09-07T13:00:00.000Z", end: "2050-09-07T14:00:00.000Z" },
{ start: "2050-09-07T14:00:00.000Z", end: "2050-09-07T15:00:00.000Z" },
],
"2050-09-08": [
{ start: "2050-09-08T07:00:00.000Z", end: "2050-09-08T08:00:00.000Z" },
{ start: "2050-09-08T08:00:00.000Z", end: "2050-09-08T09:00:00.000Z" },
{ start: "2050-09-08T09:00:00.000Z", end: "2050-09-08T10:00:00.000Z" },
{ start: "2050-09-08T10:00:00.000Z", end: "2050-09-08T11:00:00.000Z" },
{ start: "2050-09-08T11:00:00.000Z", end: "2050-09-08T12:00:00.000Z" },
{ start: "2050-09-08T12:00:00.000Z", end: "2050-09-08T13:00:00.000Z" },
{ start: "2050-09-08T13:00:00.000Z", end: "2050-09-08T14:00:00.000Z" },
{ start: "2050-09-08T14:00:00.000Z", end: "2050-09-08T15:00:00.000Z" },
],
"2050-09-09": [
{ start: "2050-09-09T07:00:00.000Z", end: "2050-09-09T08:00:00.000Z" },
{ start: "2050-09-09T08:00:00.000Z", end: "2050-09-09T09:00:00.000Z" },
{ start: "2050-09-09T09:00:00.000Z", end: "2050-09-09T10:00:00.000Z" },
{ start: "2050-09-09T10:00:00.000Z", end: "2050-09-09T11:00:00.000Z" },
{ start: "2050-09-09T11:00:00.000Z", end: "2050-09-09T12:00:00.000Z" },
{ start: "2050-09-09T12:00:00.000Z", end: "2050-09-09T13:00:00.000Z" },
{ start: "2050-09-09T13:00:00.000Z", end: "2050-09-09T14:00:00.000Z" },
{ start: "2050-09-09T14:00:00.000Z", end: "2050-09-09T15:00:00.000Z" },
],
};
export const expectedSlotsRomeRange = {
"2050-09-05": [
{ start: "2050-09-05T09:00:00.000+02:00", end: "2050-09-05T10:00:00.000+02:00" },
{ start: "2050-09-05T10:00:00.000+02:00", end: "2050-09-05T11:00:00.000+02:00" },
{ start: "2050-09-05T11:00:00.000+02:00", end: "2050-09-05T12:00:00.000+02:00" },
{ start: "2050-09-05T12:00:00.000+02:00", end: "2050-09-05T13:00:00.000+02:00" },
{ start: "2050-09-05T13:00:00.000+02:00", end: "2050-09-05T14:00:00.000+02:00" },
{ start: "2050-09-05T14:00:00.000+02:00", end: "2050-09-05T15:00:00.000+02:00" },
{ start: "2050-09-05T15:00:00.000+02:00", end: "2050-09-05T16:00:00.000+02:00" },
{ start: "2050-09-05T16:00:00.000+02:00", end: "2050-09-05T17:00:00.000+02:00" },
],
"2050-09-06": [
{ start: "2050-09-06T09:00:00.000+02:00", end: "2050-09-06T10:00:00.000+02:00" },
{ start: "2050-09-06T10:00:00.000+02:00", end: "2050-09-06T11:00:00.000+02:00" },
{ start: "2050-09-06T11:00:00.000+02:00", end: "2050-09-06T12:00:00.000+02:00" },
{ start: "2050-09-06T12:00:00.000+02:00", end: "2050-09-06T13:00:00.000+02:00" },
{ start: "2050-09-06T13:00:00.000+02:00", end: "2050-09-06T14:00:00.000+02:00" },
{ start: "2050-09-06T14:00:00.000+02:00", end: "2050-09-06T15:00:00.000+02:00" },
{ start: "2050-09-06T15:00:00.000+02:00", end: "2050-09-06T16:00:00.000+02:00" },
{ start: "2050-09-06T16:00:00.000+02:00", end: "2050-09-06T17:00:00.000+02:00" },
],
"2050-09-07": [
{ start: "2050-09-07T09:00:00.000+02:00", end: "2050-09-07T10:00:00.000+02:00" },
{ start: "2050-09-07T10:00:00.000+02:00", end: "2050-09-07T11:00:00.000+02:00" },
{ start: "2050-09-07T11:00:00.000+02:00", end: "2050-09-07T12:00:00.000+02:00" },
{ start: "2050-09-07T12:00:00.000+02:00", end: "2050-09-07T13:00:00.000+02:00" },
{ start: "2050-09-07T13:00:00.000+02:00", end: "2050-09-07T14:00:00.000+02:00" },
{ start: "2050-09-07T14:00:00.000+02:00", end: "2050-09-07T15:00:00.000+02:00" },
{ start: "2050-09-07T15:00:00.000+02:00", end: "2050-09-07T16:00:00.000+02:00" },
{ start: "2050-09-07T16:00:00.000+02:00", end: "2050-09-07T17:00:00.000+02:00" },
],
"2050-09-08": [
{ start: "2050-09-08T09:00:00.000+02:00", end: "2050-09-08T10:00:00.000+02:00" },
{ start: "2050-09-08T10:00:00.000+02:00", end: "2050-09-08T11:00:00.000+02:00" },
{ start: "2050-09-08T11:00:00.000+02:00", end: "2050-09-08T12:00:00.000+02:00" },
{ start: "2050-09-08T12:00:00.000+02:00", end: "2050-09-08T13:00:00.000+02:00" },
{ start: "2050-09-08T13:00:00.000+02:00", end: "2050-09-08T14:00:00.000+02:00" },
{ start: "2050-09-08T14:00:00.000+02:00", end: "2050-09-08T15:00:00.000+02:00" },
{ start: "2050-09-08T15:00:00.000+02:00", end: "2050-09-08T16:00:00.000+02:00" },
{ start: "2050-09-08T16:00:00.000+02:00", end: "2050-09-08T17:00:00.000+02:00" },
],
"2050-09-09": [
{ start: "2050-09-09T09:00:00.000+02:00", end: "2050-09-09T10:00:00.000+02:00" },
{ start: "2050-09-09T10:00:00.000+02:00", end: "2050-09-09T11:00:00.000+02:00" },
{ start: "2050-09-09T11:00:00.000+02:00", end: "2050-09-09T12:00:00.000+02:00" },
{ start: "2050-09-09T12:00:00.000+02:00", end: "2050-09-09T13:00:00.000+02:00" },
{ start: "2050-09-09T13:00:00.000+02:00", end: "2050-09-09T14:00:00.000+02:00" },
{ start: "2050-09-09T14:00:00.000+02:00", end: "2050-09-09T15:00:00.000+02:00" },
{ start: "2050-09-09T15:00:00.000+02:00", end: "2050-09-09T16:00:00.000+02:00" },
{ start: "2050-09-09T16:00:00.000+02:00", end: "2050-09-09T17:00:00.000+02:00" },
],
};
@@ -0,0 +1,474 @@
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 { expectedSlotsUTC } from "@/modules/slots/slots-2024-09-04/controllers/e2e/expected-slots";
import { GetSlotsOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-slots.output";
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/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 { 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 { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_09_04 } from "@calcom/platform-constants";
import { CreateScheduleInput_2024_06_11 } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
describe("Slots 2024-09-04 Endpoints", () => {
describe("Organization team event type slots", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let schedulesService: SchedulesService_2024_06_11;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
const sharedUsername = `slots-2024-09-04-shared-username-${randomString()}`;
const sharedEventTypeSlug = `slots-2024-09-04-shared-event-type-slug-${randomString()}`;
const orgUserEmailOne = `slots-2024-09-04-org-user-one-${randomString()}@api.com`;
const orgUserEmailTwo = `slots-2024-09-04-org-user-two-${randomString()}@api.com`;
const nonOrgUserEmailOne = `slots-2024-09-04-non-org-user-one-${randomString()}@api.com`;
let organization: Team;
let team: Team;
let orgUserOne: User;
let orgUserTwo: User;
let collectiveEventTypeId: number;
let roundRobinEventTypeId: number;
let collectiveBookingId: number;
let roundRobinBookingId: number;
let fullyBookedRoundRobinBookingIdOne: number;
let fullyBookedRoundRobinBookingIdTwo: number;
let nonOrgUser: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
orgUserEmailOne,
Test.createTestingModule({
imports: [
AppModule,
PrismaModule,
UsersModule,
TokensModule,
SchedulesModule_2024_06_11,
SlotsModule_2024_09_04,
],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_06_11>(SchedulesService_2024_06_11);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
const orgSlug = `slots-2024-09-04-organization-${randomString()}`;
organization = await organizationsRepositoryFixture.create({
name: orgSlug,
isOrganization: true,
slug: orgSlug,
timeZone: "Europe/Rome",
});
orgUserOne = await userRepositoryFixture.create({
email: orgUserEmailOne,
name: orgUserEmailOne,
username: orgUserEmailOne,
});
await eventTypesRepositoryFixture.create(
{ title: "frisbee match orgUserOne", slug: sharedEventTypeSlug, length: 60 },
orgUserOne.id
);
orgUserTwo = await userRepositoryFixture.create({
email: orgUserEmailTwo,
name: orgUserEmailTwo,
username: orgUserEmailTwo,
});
nonOrgUser = await userRepositoryFixture.create({
email: nonOrgUserEmailOne,
name: nonOrgUserEmailOne,
username: sharedUsername,
});
await eventTypesRepositoryFixture.create(
{ title: "frisbee match nonOrgUser", slug: sharedEventTypeSlug, length: 60 },
nonOrgUser.id
);
await profileRepositoryFixture.create({
uid: `usr-${orgUserOne.id}`,
username: sharedUsername,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: orgUserOne.id,
},
},
});
team = await teamRepositoryFixture.create({
name: `slots-2024-09-04-team-${randomString()}`,
isOrganization: false,
parent: { connect: { id: organization.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: orgUserOne.id } },
team: { connect: { id: team.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: orgUserTwo.id } },
team: { connect: { id: team.id } },
accepted: true,
});
const collectiveEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team.id },
},
title: "Collective Event Type",
slug: `slots-2024-09-04-org-collective-event-type-${randomString()}`,
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
users: {
connect: [{ id: orgUserOne.id }, { id: orgUserTwo.id }],
},
});
collectiveEventTypeId = collectiveEventType.id;
const roundRobinEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
title: "RR Event Type",
slug: `slots-2024-09-04-org-round-robin-event-type-${randomString()}`,
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
users: {
connect: [{ id: orgUserOne.id }, { id: orgUserTwo.id }],
},
});
roundRobinEventTypeId = roundRobinEventType.id;
const orgUsersSchedule: CreateScheduleInput_2024_06_11 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
const nonOrgUserSchedule: CreateScheduleInput_2024_06_11 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
availability: [
{
days: ["Monday"],
startTime: "09:00",
endTime: "17:00",
},
],
};
// note(Lauris): this creates default schedule monday to friday from 9AM to 5PM in Europe/Rome timezone
await schedulesService.createUserSchedule(orgUserOne.id, orgUsersSchedule);
await schedulesService.createUserSchedule(orgUserTwo.id, orgUsersSchedule);
await schedulesService.createUserSchedule(nonOrgUser.id, nonOrgUserSchedule);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
describe("org and non org user have the same username and event type slug", () => {
it("should get org user event slots in UTC", async () => {
return request(app.getHttpServer())
.get(
`/v2/slots?organizationSlug=${organization.slug}&eventTypeSlug=${sharedEventTypeSlug}&username=${sharedUsername}&start=2050-09-05&end=2050-09-09`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200)
.then(async (response) => {
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
expect(slots).toEqual(expectedSlotsUTC);
});
});
it("should get non org user event slots in UTC", async () => {
return request(app.getHttpServer())
.get(
`/v2/slots?&eventTypeSlug=${sharedEventTypeSlug}&username=${sharedUsername}&start=2050-09-05&end=2050-09-09`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200)
.then(async (response) => {
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(1);
expect(slots).toEqual({ "2050-09-05": expectedSlotsUTC["2050-09-05"] });
});
});
});
it("should get collective team event slots in UTC", async () => {
return request(app.getHttpServer())
.get(`/v2/slots?eventTypeId=${collectiveEventTypeId}&start=2050-09-05&end=2050-09-09`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200)
.then(async (response) => {
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
expect(slots).toEqual(expectedSlotsUTC);
});
});
it("should get round robin team event slots in UTC", async () => {
return request(app.getHttpServer())
.get(`/v2/slots?eventTypeId=${roundRobinEventTypeId}&start=2050-09-05&end=2050-09-09`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200)
.then(async (response) => {
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
expect(slots).toEqual(expectedSlotsUTC);
});
});
it("should book collective event type and slot should not be available at that time", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${collectiveEventTypeId}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
eventType: {
connect: {
id: collectiveEventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: orgUserOne.id,
},
},
});
collectiveBookingId = booking.id;
const response = await request(app.getHttpServer())
.get(`/v2/slots?eventTypeId=${collectiveEventTypeId}&start=2050-09-05&end=2050-09-09`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200);
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
(slot) => slot.start !== startTime
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
bookingsRepositoryFixture.deleteById(booking.id);
});
it("should book round robin event type and slot should be available at that time", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${roundRobinEventTypeId}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
eventType: {
connect: {
id: roundRobinEventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: orgUserOne.id,
},
},
});
roundRobinBookingId = booking.id;
const response = await request(app.getHttpServer())
.get(`/v2/slots?eventTypeId=${roundRobinEventTypeId}&start=2050-09-05&end=2050-09-09`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200);
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
expect(slots).toEqual(expectedSlotsUTC);
bookingsRepositoryFixture.deleteById(booking.id);
});
it("should fully book round robin event type and slot should not be available at that time", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const bookingOne = await bookingsRepositoryFixture.create({
uid: `booking-uid-${roundRobinEventTypeId}-1`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
eventType: {
connect: {
id: roundRobinEventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: orgUserOne.id,
},
},
});
fullyBookedRoundRobinBookingIdOne = bookingOne.id;
const bookingTwo = await bookingsRepositoryFixture.create({
uid: `booking-uid-${roundRobinEventTypeId}-2`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
eventType: {
connect: {
id: roundRobinEventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: orgUserTwo.id,
},
},
});
fullyBookedRoundRobinBookingIdTwo = bookingTwo.id;
const response = await request(app.getHttpServer())
.get(`/v2/slots?eventTypeId=${roundRobinEventTypeId}&start=2050-09-05&end=2050-09-09`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200);
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
(slot) => slot.start !== startTime
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
bookingsRepositoryFixture.deleteById(bookingOne.id);
bookingsRepositoryFixture.deleteById(bookingTwo.id);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(orgUserOne.email);
await userRepositoryFixture.deleteByEmail(orgUserTwo.email);
await teamRepositoryFixture.delete(team.id);
await organizationsRepositoryFixture.delete(organization.id);
await bookingsRepositoryFixture.deleteById(collectiveBookingId);
await bookingsRepositoryFixture.deleteById(roundRobinBookingId);
await bookingsRepositoryFixture.deleteById(fullyBookedRoundRobinBookingIdOne);
await bookingsRepositoryFixture.deleteById(fullyBookedRoundRobinBookingIdTwo);
await app.close();
});
});
});
@@ -0,0 +1,534 @@
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 { expectedSlotsUTC } from "@/modules/slots/slots-2024-09-04/controllers/e2e/expected-slots";
import { GetSlotsOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-slots.output";
import { ReserveSlotOutputResponse_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/reserve-slot.output";
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/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 { advanceTo, clear } from "jest-date-mock";
import { DateTime } from "luxon";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { SelectedSlotsRepositoryFixture } from "test/fixtures/repository/selected-slots.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_09_04 } from "@calcom/platform-constants";
import {
CreateScheduleInput_2024_06_11,
ReserveSlotOutput_2024_09_04 as ReserveSlotOutputData_2024_09_04,
} from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
describe("Slots 2024-09-04 Endpoints", () => {
describe("Team event type slots", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let schedulesService: SchedulesService_2024_06_11;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let selectedSlotsRepositoryFixture: SelectedSlotsRepositoryFixture;
const teammateEmailOne = `slots-2024-09-04-user-1-team-slots-${randomString()}`;
let teammateApiKeyString: string;
const teammateEmailTwo = `slots-2024-09-04-user-2-team-slots-${randomString()}`;
const outsiderEmail = `slots-2024-09-04-unrelated-team-slots-${randomString()}`;
let outsider: User;
let outsiderApiKeyString: string;
let team: Team;
let teammateOne: User;
let teammateTwo: User;
let collectiveEventTypeId: number;
let collectiveEventTypeWithoutHostsId: number;
let roundRobinEventTypeId: number;
let collectiveBookingId: number;
let roundRobinBookingId: number;
let fullyBookedRoundRobinBookingIdOne: number;
let fullyBookedRoundRobinBookingIdTwo: number;
let reservedSlot: ReserveSlotOutputData_2024_09_04;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [
AppModule,
PrismaModule,
UsersModule,
TokensModule,
SchedulesModule_2024_06_11,
SlotsModule_2024_09_04,
],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_06_11>(SchedulesService_2024_06_11);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
selectedSlotsRepositoryFixture = new SelectedSlotsRepositoryFixture(moduleRef);
teammateOne = await userRepositoryFixture.create({
email: teammateEmailOne,
name: teammateEmailOne,
username: teammateEmailOne,
});
teammateTwo = await userRepositoryFixture.create({
email: teammateEmailTwo,
name: teammateEmailTwo,
username: teammateEmailTwo,
});
outsider = await userRepositoryFixture.create({
email: outsiderEmail,
name: outsiderEmail,
username: outsiderEmail,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(teammateOne.id, null);
teammateApiKeyString = keyString;
const { keyString: unrelatedUserKeyString } = await apiKeysRepositoryFixture.createApiKey(
outsider.id,
null
);
outsiderApiKeyString = unrelatedUserKeyString;
team = await teamRepositoryFixture.create({
name: `slots-2024-09-04-team-${randomString()}`,
isOrganization: false,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: teammateOne.id } },
team: { connect: { id: team.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: teammateTwo.id } },
team: { connect: { id: team.id } },
accepted: true,
});
const collectiveEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team.id },
},
title: "Collective Event Type",
slug: `slots-2024-09-04-collective-event-type-${randomString()}`,
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
users: {
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
},
hosts: {
create: [
{
userId: teammateOne.id,
isFixed: true,
},
{
userId: teammateTwo.id,
isFixed: true,
},
],
},
});
collectiveEventTypeId = collectiveEventType.id;
const collectiveEventTypeWithoutHosts = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team.id },
},
title: "Collective Event Type Without Hosts",
slug: `slots-2024-09-04-collective-event-type-without-hosts-${randomString()}`,
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
users: {
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
},
});
collectiveEventTypeWithoutHostsId = collectiveEventTypeWithoutHosts.id;
const roundRobinEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
title: "RR Event Type",
slug: `slots-2024-09-04-round-robin-event-type-${randomString()}`,
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
users: {
connect: [{ id: teammateOne.id }, { id: teammateTwo.id }],
},
hosts: {
create: [
{
userId: teammateOne.id,
isFixed: true,
},
{
userId: teammateTwo.id,
isFixed: true,
},
],
},
});
roundRobinEventTypeId = roundRobinEventType.id;
const userSchedule: CreateScheduleInput_2024_06_11 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
// note(Lauris): this creates default schedule monday to friday from 9AM to 5PM in Europe/Rome timezone
await schedulesService.createUserSchedule(teammateOne.id, userSchedule);
await schedulesService.createUserSchedule(teammateTwo.id, userSchedule);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should get collective team event slots in UTC", async () => {
return request(app.getHttpServer())
.get(`/v2/slots?eventTypeId=${collectiveEventTypeId}&start=2050-09-05&end=2050-09-09`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200)
.then(async (response) => {
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
expect(slots).toEqual(expectedSlotsUTC);
});
});
it("should get round robin team event slots in UTC", async () => {
return request(app.getHttpServer())
.get(`/v2/slots?eventTypeId=${roundRobinEventTypeId}&start=2050-09-05&end=2050-09-09`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200)
.then(async (response) => {
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
expect(slots).toEqual(expectedSlotsUTC);
});
});
it("should not be able reserve a team event type slot with custom duration if no auth is provided", async () => {
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId: collectiveEventTypeId,
slotStart: "2050-09-05T10:00:00.000Z",
reservationDuration: 10,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(401);
});
it("should not be able reserve a slot with custom duration if provided auth user is not part of the team that owns the team event type", async () => {
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId: collectiveEventTypeId,
slotStart: "2050-09-05T10:00:00.000Z",
reservationDuration: 10,
})
.set({ Authorization: `Bearer cal_test_${outsiderApiKeyString}` })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(403);
});
it("should not be able reserve a slot for team event type without hosts", async () => {
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId: collectiveEventTypeWithoutHostsId,
slotStart: "2050-09-05T10:00:00.000Z",
})
.set({ Authorization: `Bearer cal_test_${teammateApiKeyString}` })
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(400);
});
it("should reserve a slot as team member of the team that owns the team event type", async () => {
// note(Lauris): mock current date to test slots release time
const now = "2049-09-05T12:00:00.000Z";
const newDate = DateTime.fromISO(now, { zone: "UTC" }).toJSDate();
advanceTo(newDate);
const slotStartTime = "2050-09-05T10:00:00.000Z";
const reserveResponse = await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.set({ Authorization: `Bearer cal_test_${teammateApiKeyString}` })
.send({
eventTypeId: collectiveEventTypeId,
slotStart: slotStartTime,
reservationDuration: 10,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(201);
const reserveResponseBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponse.body;
expect(reserveResponseBody.status).toEqual(SUCCESS_STATUS);
const responseReservedSlot: ReserveSlotOutputData_2024_09_04 = reserveResponseBody.data;
expect(responseReservedSlot.reservationUid).toBeDefined();
if (!responseReservedSlot.reservationUid) {
throw new Error("Reserved slot uid is undefined");
}
reservedSlot = responseReservedSlot;
const response = await request(app.getHttpServer())
.get(`/v2/slots?eventTypeId=${collectiveEventTypeId}&start=2050-09-05&end=2050-09-09`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200);
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
(slot) => slot.start !== slotStartTime
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(reservedSlot.reservationUid);
expect(dbSlot).toBeDefined();
if (dbSlot) {
const dbReleaseAt = DateTime.fromJSDate(dbSlot.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 10 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
}
await selectedSlotsRepositoryFixture.deleteByUId(reservedSlot.reservationUid);
clear();
});
it("should book collective event type and slot should not be available at that time", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${collectiveEventTypeId}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
eventType: {
connect: {
id: collectiveEventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: teammateOne.id,
},
},
});
collectiveBookingId = booking.id;
const response = await request(app.getHttpServer())
.get(`/v2/slots?eventTypeId=${collectiveEventTypeId}&start=2050-09-05&end=2050-09-09`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200);
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
(slot) => slot.start !== startTime
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
bookingsRepositoryFixture.deleteById(booking.id);
});
it("should book round robin event type and slot should be available at that time", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const booking = await bookingsRepositoryFixture.create({
uid: `booking-uid-${roundRobinEventTypeId}`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
eventType: {
connect: {
id: roundRobinEventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: teammateOne.id,
},
},
});
roundRobinBookingId = booking.id;
const response = await request(app.getHttpServer())
.get(`/v2/slots?eventTypeId=${roundRobinEventTypeId}&start=2050-09-05&end=2050-09-09`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200);
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
(slot) => slot.start !== startTime
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
bookingsRepositoryFixture.deleteById(booking.id);
});
it("should fully book round robin event type and slot should not be available at that time", async () => {
const startTime = "2050-09-05T11:00:00.000Z";
const bookingOne = await bookingsRepositoryFixture.create({
uid: `booking-uid-${roundRobinEventTypeId}-1`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
eventType: {
connect: {
id: roundRobinEventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: teammateOne.id,
},
},
});
fullyBookedRoundRobinBookingIdOne = bookingOne.id;
const bookingTwo = await bookingsRepositoryFixture.create({
uid: `booking-uid-${roundRobinEventTypeId}-2`,
title: "booking title",
startTime,
endTime: "2050-09-05T12:00:00.000Z",
eventType: {
connect: {
id: roundRobinEventTypeId,
},
},
metadata: {},
responses: {
name: "tester",
email: "tester@example.com",
guests: [],
},
user: {
connect: {
id: teammateTwo.id,
},
},
});
fullyBookedRoundRobinBookingIdTwo = bookingTwo.id;
const response = await request(app.getHttpServer())
.get(`/v2/slots?eventTypeId=${roundRobinEventTypeId}&start=2050-09-05&end=2050-09-09`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(200);
const responseBody: GetSlotsOutput_2024_09_04 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const slots = responseBody.data;
expect(slots).toBeDefined();
const days = Object.keys(slots);
expect(days.length).toEqual(5);
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
(slot) => slot.start !== startTime
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
bookingsRepositoryFixture.deleteById(bookingOne.id);
bookingsRepositoryFixture.deleteById(bookingTwo.id);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(teammateOne.email);
await userRepositoryFixture.deleteByEmail(teammateTwo.email);
await userRepositoryFixture.deleteByEmail(outsiderEmail);
await teamRepositoryFixture.delete(team.id);
await bookingsRepositoryFixture.deleteById(collectiveBookingId);
await bookingsRepositoryFixture.deleteById(roundRobinBookingId);
await bookingsRepositoryFixture.deleteById(fullyBookedRoundRobinBookingIdOne);
await bookingsRepositoryFixture.deleteById(fullyBookedRoundRobinBookingIdTwo);
await app.close();
});
});
});
@@ -0,0 +1,290 @@
import { VERSION_2024_09_04 } from "@/lib/api-versions";
import { GetOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
import { GetReservedSlotOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-reserved-slot.output";
import { GetSlotsOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-slots.output";
import { ReserveSlotOutputResponse_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/reserve-slot.output";
import { SlotsService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots.service";
import {
Query,
Body,
Controller,
Get,
Delete,
Post,
Param,
Res,
HttpCode,
HttpStatus,
Patch,
UseGuards,
BadRequestException,
} from "@nestjs/common";
import {
ApiOperation,
ApiTags as DocsTags,
ApiHeader,
ApiResponse as DocsResponse,
ApiQuery,
} from "@nestjs/swagger";
import { User } from "@prisma/client";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
GetSlotsInput_2024_09_04,
GetSlotsInputPipe,
ReserveSlotInput_2024_09_04,
ReserveSlotOutput_2024_09_04 as ReserveSlotOutputType_2024_09_04,
GetReservedSlotOutput_2024_09_04 as GetReservedSlotOutputType_2024_09_04,
} from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "/v2/slots",
version: VERSION_2024_09_04,
})
@DocsTags("Slots")
@ApiHeader({
name: "cal-api-version",
description: `Must be set to \`2024-09-04\``,
required: true,
})
export class SlotsController_2024_09_04 {
constructor(private readonly slotsService: SlotsService_2024_09_04) {}
@Get("/")
@ApiOperation({
summary: "Find out when is an event type ready to be booked.",
description: `
There are 4 ways to get available slots:
1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'
2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'
3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'
4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.
All of them require "start" and "end" query parameters which define the time range for which available slots should be checked.
Optional parameters are:
- timeZone: Time zone in which the available slots should be returned. Defaults to UTC.
- duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.
- slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value.
`,
})
@ApiQuery({
name: "timeZone",
required: false,
description: "Time zone in which the available slots should be returned. Defaults to UTC.",
example: "Europe/Rome",
})
@ApiQuery({
name: "duration",
required: false,
description:
"If event type has multiple possible durations then you can specify the desired duration here. Also, if you are fetching slots for a dynamic event then you can specify the duration her which defaults to 30, meaning that returned slots will be each 30 minutes long.",
example: "60",
})
@ApiQuery({
name: "slotFormat",
required: false,
description:
"Format of slot times in response. Use 'range' to get start and end times. Use 'time' or omit this query parameter to get only start time.",
example: "range",
})
@ApiQuery({
name: "usernames",
required: false,
description: `The usernames for which available slots should be checked separated by a comma.
Checking slots by usernames is used mainly for dynamic events where there is no specific event but we just want to know when 2 or more people are available.
Must contain at least 2 usernames.`,
example: "alice,bob",
})
@ApiQuery({
name: "eventTypeId",
required: false,
description: "The ID of the event type for which available slots should be checked.",
example: "100",
})
@ApiQuery({
name: "eventTypeSlug",
required: false,
description:
"The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too.",
example: "event-type-slug",
})
@ApiQuery({
name: "username",
required: false,
description: "The username of the user to get event types for.",
example: "bob",
})
@ApiQuery({
name: "end",
required: true,
description: `
Time until which available slots should be checked.
Must be in UTC timezone as ISO 8601 datestring.
You can pass date without hours which defaults to end of day or specify hours:
2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z.`,
example: "2050-09-06",
})
@ApiQuery({
name: "start",
required: true,
description: `
Time starting from which available slots should be checked.
Must be in UTC timezone as ISO 8601 datestring.
You can pass date without hours which defaults to start of day or specify hours:
2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z.`,
example: "2050-09-05",
})
@DocsResponse({
status: 200,
description: `A map of available slots indexed by date, where each date is associated with an array of time slots. If format=range is specified, each slot will be an object with start and end properties denoting start and end of the slot.
For seated slots each object will have attendeesCount and bookingUid properties.`,
schema: {
oneOf: [
{
type: "object",
title: "Default format (or with format=time)",
additionalProperties: {
type: "array",
items: { type: "string" },
},
example: {
status: "success",
data: {
"2050-09-05": [
{ start: "2050-09-05T09:00:00.000+02:00" },
{ start: "2050-09-05T10:00:00.000+02:00" },
],
"2050-09-06": [
{ start: "2050-09-06T09:00:00.000+02:00" },
{ start: "2050-09-06T10:00:00.000+02:00" },
],
},
},
},
{
type: "object",
title: "Range format (when format=range)",
additionalProperties: {
type: "array",
items: {
type: "object",
properties: {
start: { type: "string" },
end: { type: "string" },
},
},
},
example: {
status: "success",
data: {
"2050-09-05": [
{ start: "2050-09-05T09:00:00.000+02:00", end: "2050-09-05T10:00:00.000+02:00" },
{ start: "2050-09-05T10:00:00.000+02:00", end: "2050-09-05T11:00:00.000+02:00" },
],
"2050-09-06": [
{ start: "2050-09-06T09:00:00.000+02:00", end: "2050-09-06T10:00:00.000+02:00" },
{ start: "2050-09-06T10:00:00.000+02:00", end: "2050-09-06T11:00:00.000+02:00" },
],
},
},
},
],
},
})
async getAvailableSlots(
@Query(new GetSlotsInputPipe()) query: GetSlotsInput_2024_09_04
): Promise<GetSlotsOutput_2024_09_04> {
const slots = await this.slotsService.getAvailableSlots(query);
return {
data: slots,
status: SUCCESS_STATUS,
};
}
@Post("/reservations")
@UseGuards(OptionalApiAuthGuard)
@ApiOperation({
summary: "Reserve a slot",
description: "Make a slot not available for others to book for a certain period of time.",
})
async reserveSlot(
@Body() body: ReserveSlotInput_2024_09_04,
@GetOptionalUser() user: User
): Promise<ReserveSlotOutputResponse_2024_09_04> {
const reservedSlot = await this.slotsService.reserveSlot(body, user?.id);
return {
status: SUCCESS_STATUS,
data: plainToClass(ReserveSlotOutputType_2024_09_04, reservedSlot, {
strategy: "excludeAll",
}),
};
}
@Get("/reservations/:uid")
@ApiOperation({
summary: "Get reserved slot",
})
async getReservedSlot(@Param("uid") uid: string): Promise<GetReservedSlotOutput_2024_09_04> {
const reservedSlot = await this.slotsService.getReservedSlot(uid);
return {
status: SUCCESS_STATUS,
data: plainToClass(GetReservedSlotOutputType_2024_09_04, reservedSlot, {
strategy: "excludeAll",
}),
};
}
@Patch("/reservations/:uid")
@ApiOperation({
summary: "Updated reserved a slot",
})
@HttpCode(HttpStatus.OK)
async updateReservedSlot(
@Body() body: ReserveSlotInput_2024_09_04,
@Param("uid") uid: string
): Promise<ReserveSlotOutputResponse_2024_09_04> {
const reservedSlot = await this.slotsService.updateReservedSlot(body, uid);
return {
status: SUCCESS_STATUS,
data: plainToClass(ReserveSlotOutputType_2024_09_04, reservedSlot, {
strategy: "excludeAll",
}),
};
}
@Delete("/reservations/:uid")
@HttpCode(HttpStatus.OK)
@DocsResponse({
status: 200,
schema: {
type: "object",
example: {
status: "success",
},
},
})
async deleteReservedSlot(@Param("uid") uid: string): Promise<ApiResponse> {
await this.slotsService.deleteReservedSlot(uid);
return {
status: SUCCESS_STATUS,
};
}
}
@@ -0,0 +1,18 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsNotEmptyObject, ValidateNested } from "class-validator";
import {
ApiResponseWithoutData,
GetReservedSlotOutput_2024_09_04 as GetReservedSlotOutputType_2024_09_04,
} from "@calcom/platform-types";
export class GetReservedSlotOutput_2024_09_04 extends ApiResponseWithoutData {
@ApiProperty({
type: GetReservedSlotOutputType_2024_09_04,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => GetReservedSlotOutputType_2024_09_04)
data!: GetReservedSlotOutputType_2024_09_04 | null;
}
@@ -0,0 +1,19 @@
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { ValidateNested } from "class-validator";
import { ApiResponseWithoutData, SlotsOutput_2024_09_04 } from "@calcom/platform-types";
import { RangeSlotsOutput_2024_09_04 } from "@calcom/platform-types";
@ApiExtraModels(SlotsOutput_2024_09_04, RangeSlotsOutput_2024_09_04)
export class GetSlotsOutput_2024_09_04 extends ApiResponseWithoutData {
@ValidateNested()
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(SlotsOutput_2024_09_04) },
{ $ref: getSchemaPath(RangeSlotsOutput_2024_09_04) },
],
})
@Type(() => Object)
data!: SlotsOutput_2024_09_04 | RangeSlotsOutput_2024_09_04;
}
@@ -0,0 +1,18 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsNotEmptyObject, ValidateNested } from "class-validator";
import {
ApiResponseWithoutData,
ReserveSlotOutput_2024_09_04 as ReserveSlotOutputType_2024_09_04,
} from "@calcom/platform-types";
export class ReserveSlotOutputResponse_2024_09_04 extends ApiResponseWithoutData {
@ApiProperty({
type: ReserveSlotOutputType_2024_09_04,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => ReserveSlotOutputType_2024_09_04)
data!: ReserveSlotOutputType_2024_09_04;
}
@@ -0,0 +1,93 @@
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { DateTime } from "luxon";
import { dynamicEvent } from "@calcom/platform-libraries";
import { GetSlotsInput_2024_09_04 } from "@calcom/platform-types";
@Injectable()
export class SlotsInputService_2024_09_04 {
constructor(
private readonly eventTypeRepository: EventTypesRepository_2024_06_14,
private readonly usersRepository: UsersRepository,
private readonly organizationsUsersRepository: OrganizationsUsersRepository,
private readonly organizationsRepository: OrganizationsRepository
) {}
async transformGetSlotsQuery(query: GetSlotsInput_2024_09_04) {
const eventType = await this.getEventType(query);
if (!eventType) {
throw new NotFoundException(`Event Type not found`);
}
const isTeamEvent = !!eventType?.teamId;
const startTime = query.start;
const endTime = this.adjustEndTime(query.end);
const duration = query.duration;
const eventTypeId = eventType.id;
const eventTypeSlug = eventType.slug;
const usernameList = "usernames" in query ? query.usernames : [];
const timeZone = query.timeZone;
const orgSlug = "organizationSlug" in query ? query.organizationSlug : null;
return {
isTeamEvent,
startTime,
endTime,
duration,
eventTypeId,
eventTypeSlug,
usernameList,
timeZone,
orgSlug,
};
}
private async getEventType(input: GetSlotsInput_2024_09_04) {
if ("eventTypeId" in input) {
return this.eventTypeRepository.getEventTypeById(input.eventTypeId);
}
if ("eventTypeSlug" in input) {
const user = await this.getEventTypeUser(input);
if (!user) {
throw new NotFoundException(`User with username ${input.username} not found`);
}
return this.eventTypeRepository.getUserEventTypeBySlug(user.id, input.eventTypeSlug);
}
return input.duration ? { ...dynamicEvent, length: input.duration } : dynamicEvent;
}
private async getEventTypeUser(input: GetSlotsInput_2024_09_04) {
if ("eventTypeSlug" in input) {
if ("organizationSlug" in input && input.organizationSlug) {
const organization = await this.organizationsRepository.findOrgBySlug(input.organizationSlug);
if (!organization) {
throw new NotFoundException(
`slots-input.service.ts: Organization with slug ${input.organizationSlug} not found`
);
}
return await this.organizationsUsersRepository.getOrganizationUserByUsername(
organization.id,
input.username
);
}
return await this.usersRepository.findByUsername(input.username);
}
}
private adjustEndTime(endTime: string) {
let dateTime = DateTime.fromISO(endTime, { zone: "utc" });
if (dateTime.hour === 0 && dateTime.minute === 0 && dateTime.second === 0) {
dateTime = dateTime.set({ hour: 23, minute: 59, second: 59 });
}
return dateTime.toISO();
}
}
@@ -0,0 +1,229 @@
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { Injectable, BadRequestException } from "@nestjs/common";
import { DateTime } from "luxon";
import { SlotFormat } from "@calcom/platform-enums";
import {
GetReservedSlotOutput_2024_09_04,
RangeSlot_2024_09_04,
RangeSlotsOutput_2024_09_04,
ReserveSlotOutput_2024_09_04,
SeatedRangeSlot_2024_09_04,
SeatedSlot_2024_09_04,
Slot_2024_09_04,
SlotsOutput_2024_09_04,
} from "@calcom/platform-types";
import { SelectedSlots } from "@calcom/prisma/client";
type GetAvailableSlots = {
slots: Record<string, { time: string; attendees?: number; bookingUid?: string }[]>;
};
@Injectable()
export class SlotsOutputService_2024_09_04 {
constructor(private readonly eventTypesRepository: EventTypesRepository_2024_06_14) {}
async getAvailableSlots(
availableSlots: GetAvailableSlots,
eventTypeId: number,
duration?: number,
format?: SlotFormat,
timeZone?: string
): Promise<SlotsOutput_2024_09_04 | RangeSlotsOutput_2024_09_04> {
if (!format || format === SlotFormat.Time) {
return this.getAvailableTimeSlots(availableSlots, eventTypeId, timeZone);
}
return this.getAvailableRangeSlots(availableSlots, eventTypeId, timeZone, duration);
}
private async getAvailableTimeSlots(
availableSlots: GetAvailableSlots,
eventTypeId: number,
timeZone: string | undefined
): Promise<SlotsOutput_2024_09_04> {
const eventType = await this.eventTypesRepository.getEventTypeById(eventTypeId);
const slots: { [key: string]: (Slot_2024_09_04 | SeatedSlot_2024_09_04)[] } = {};
for (const date in availableSlots.slots) {
slots[date] = availableSlots.slots[date].map((slot) => {
if (!timeZone) {
if (!eventType?.seatsPerTimeSlot) {
return this.getAvailableTimeSlot(slot.time);
}
return this.getAvailableTimeSlotSeated(
slot.time,
slot.attendees || 0,
eventType.seatsPerTimeSlot || 0,
slot.bookingUid
);
}
const slotTimezoneAdjusted = DateTime.fromISO(slot.time, { zone: "utc" }).setZone(timeZone).toISO();
if (!slotTimezoneAdjusted) {
throw new BadRequestException(
`Could not adjust timezone for slot ${slot.time} with timezone ${timeZone}`
);
}
if (!eventType?.seatsPerTimeSlot) {
return this.getAvailableTimeSlot(slotTimezoneAdjusted);
}
return this.getAvailableTimeSlotSeated(
slotTimezoneAdjusted,
slot.attendees || 0,
eventType.seatsPerTimeSlot || 0,
slot.bookingUid
);
});
}
return slots;
}
private getAvailableTimeSlot(start: string): Slot_2024_09_04 | SeatedSlot_2024_09_04 {
return {
start,
};
}
private getAvailableTimeSlotSeated(
start: string,
seatsBooked: number,
eventTypeSeatsPerTimeslot: number,
bookingUid: string | undefined
): Slot_2024_09_04 | SeatedSlot_2024_09_04 {
return {
start,
seatsBooked,
seatsRemaining: eventTypeSeatsPerTimeslot - seatsBooked,
seatsTotal: eventTypeSeatsPerTimeslot,
bookingUid,
};
}
private async getAvailableRangeSlots(
availableSlots: GetAvailableSlots,
eventTypeId: number,
timeZone?: string,
duration?: number
): Promise<RangeSlotsOutput_2024_09_04> {
const eventType = await this.eventTypesRepository.getEventTypeById(eventTypeId);
const slotDuration = duration ?? eventType?.length;
const slots = Object.entries(availableSlots.slots).reduce<
Record<string, (RangeSlot_2024_09_04 | SeatedRangeSlot_2024_09_04)[]>
>((acc, [date, slots]) => {
acc[date] = slots.map((slot) => {
if (timeZone) {
const start = DateTime.fromISO(slot.time, { zone: "utc" }).setZone(timeZone).toISO();
if (!start) {
throw new BadRequestException(
`Could not adjust timezone for slot ${slot.time} with timezone ${timeZone}`
);
}
const end = DateTime.fromISO(slot.time, { zone: "utc" })
.plus({ minutes: slotDuration })
.setZone(timeZone)
.toISO();
if (!end) {
throw new BadRequestException(
`Could not adjust timezone for slot end time ${slot.time} with timezone ${timeZone}`
);
}
if (!eventType?.seatsPerTimeSlot) {
return this.getAvailableRangeSlot(start, end);
}
return this.getAvailableRangeSlotSeated(
start,
end,
slot.attendees || 0,
eventType.seatsPerTimeSlot ?? undefined,
slot.bookingUid
);
} else {
const start = DateTime.fromISO(slot.time, { zone: "utc" }).toISO();
const end = DateTime.fromISO(slot.time, { zone: "utc" }).plus({ minutes: slotDuration }).toISO();
if (!start || !end) {
throw new BadRequestException(`Could not create UTC time for slot ${slot.time}`);
}
if (!eventType?.seatsPerTimeSlot) {
return this.getAvailableRangeSlot(start, end);
}
return this.getAvailableRangeSlotSeated(
start,
end,
slot.attendees || 0,
eventType.seatsPerTimeSlot ?? undefined,
slot.bookingUid
);
}
});
return acc;
}, {});
return slots;
}
private getAvailableRangeSlot(
start: string,
end: string
): RangeSlot_2024_09_04 | SeatedRangeSlot_2024_09_04 {
return {
start,
end,
};
}
private getAvailableRangeSlotSeated(
start: string,
end: string,
seatsBooked: number,
eventTypeSeatsPerTimeslot: number,
bookingUid: string | undefined
): RangeSlot_2024_09_04 | SeatedRangeSlot_2024_09_04 {
return {
start,
end,
seatsBooked,
seatsRemaining: eventTypeSeatsPerTimeslot - seatsBooked,
seatsTotal: eventTypeSeatsPerTimeslot,
bookingUid,
};
}
getReservationSlot(slot: SelectedSlots): GetReservedSlotOutput_2024_09_04 {
return {
eventTypeId: slot.eventTypeId,
slotStart: DateTime.fromJSDate(slot.slotUtcStartDate, { zone: "utc" }).toISO() || "unknown-slot-start",
slotEnd: DateTime.fromJSDate(slot.slotUtcEndDate, { zone: "utc" }).toISO() || "unknown-slot-end",
slotDuration: DateTime.fromJSDate(slot.slotUtcEndDate, { zone: "utc" }).diff(
DateTime.fromJSDate(slot.slotUtcStartDate, { zone: "utc" }),
"minutes"
).minutes,
reservationUid: slot.uid,
reservationUntil:
DateTime.fromJSDate(slot.releaseAt, { zone: "utc" }).toISO() || "unknown-reserved-until",
};
}
getReservationSlotCreated(slot: SelectedSlots, reservationDuration: number): ReserveSlotOutput_2024_09_04 {
return {
eventTypeId: slot.eventTypeId,
slotStart: DateTime.fromJSDate(slot.slotUtcStartDate, { zone: "utc" }).toISO() || "unknown-slot-start",
slotEnd: DateTime.fromJSDate(slot.slotUtcEndDate, { zone: "utc" }).toISO() || "unknown-slot-end",
slotDuration: DateTime.fromJSDate(slot.slotUtcEndDate, { zone: "utc" }).diff(
DateTime.fromJSDate(slot.slotUtcStartDate, { zone: "utc" }),
"minutes"
).minutes,
reservationDuration,
reservationUid: slot.uid,
reservationUntil:
DateTime.fromJSDate(slot.releaseAt, { zone: "utc" }).toISO() || "unknown-reserved-until",
};
}
}
@@ -0,0 +1,273 @@
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
import { TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
import { SlotsInputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-input.service";
import { SlotsOutputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-output.service";
import { SlotsRepository_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.repository";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
UnauthorizedException,
UnprocessableEntityException,
} from "@nestjs/common";
import { DateTime } from "luxon";
import { z } from "zod";
import { getAvailableSlots } from "@calcom/platform-libraries";
import { GetSlotsInput_2024_09_04, ReserveSlotInput_2024_09_04 } from "@calcom/platform-types";
import { EventType } from "@calcom/prisma/client";
const eventTypeMetadataSchema = z.object({
multipleDuration: z.number().array().optional(),
});
const DEFAULT_RESERVATION_DURATION = 5;
@Injectable()
export class SlotsService_2024_09_04 {
constructor(
private readonly eventTypeRepository: EventTypesRepository_2024_06_14,
private readonly slotsRepository: SlotsRepository_2024_09_04,
private readonly slotsOutputService: SlotsOutputService_2024_09_04,
private readonly slotsInputService: SlotsInputService_2024_09_04,
private readonly membershipsService: MembershipsService,
private readonly membershipsRepository: MembershipsRepository,
private readonly teamsRepository: TeamsRepository
) {}
async getAvailableSlots(query: GetSlotsInput_2024_09_04) {
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
const availableSlots: TimeSlots = await getAvailableSlots({
input: {
...queryTransformed,
},
ctx: {},
});
const formatted = await this.slotsOutputService.getAvailableSlots(
availableSlots,
queryTransformed.eventTypeId,
queryTransformed.duration,
query.format,
queryTransformed.timeZone
);
return formatted;
}
async reserveSlot(input: ReserveSlotInput_2024_09_04, authUserId?: number) {
if (input.reservationDuration && !authUserId) {
throw new UnauthorizedException(
"reservationDuration can only be used for authenticated requests - use access token, api key or OAuth credentials"
);
}
const eventType = await this.eventTypeRepository.getEventTypeWithHosts(input.eventTypeId);
if (!eventType) {
throw new NotFoundException(`Event Type with ID=${input.eventTypeId} not found`);
}
if (input.reservationDuration && authUserId) {
const canSpecifyCustomReservationDuration = await this.canSpecifyCustomReservationDuration(
authUserId,
eventType
);
if (!canSpecifyCustomReservationDuration) {
throw new ForbiddenException(
"authenticated user is not owner of event type, does not have memberships in common with owner of the event type, nor does belong to event type's team or org."
);
}
}
const startDate = DateTime.fromISO(input.slotStart, { zone: "utc" });
if (!startDate.isValid) {
throw new BadRequestException("Invalid start date");
}
const metadata = eventTypeMetadataSchema.parse(eventType);
if (
input.slotDuration &&
metadata.multipleDuration &&
!metadata.multipleDuration.includes(input.slotDuration)
) {
throw new BadRequestException(
`Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: ${metadata.multipleDuration.join(
", "
)}`
);
}
const endDate = startDate.plus({ minutes: input.slotDuration ?? eventType.length });
if (!endDate.isValid) {
throw new BadRequestException("Invalid end date");
}
const booking = await this.slotsRepository.getBookingWithAttendeesByEventTypeIdAndStart(
input.eventTypeId,
startDate.toJSDate()
);
if (eventType.seatsPerTimeSlot) {
const attendeesCount = booking?.attendees?.length;
if (attendeesCount) {
const seatsLeft = eventType.seatsPerTimeSlot - attendeesCount;
if (seatsLeft < 1) {
throw new UnprocessableEntityException(
`Booking with id=${input.eventTypeId} at ${input.slotStart} has no more seats left.`
);
}
}
}
const nonSeatedEventAlreadyBooked = !eventType.seatsPerTimeSlot && booking;
if (nonSeatedEventAlreadyBooked) {
throw new UnprocessableEntityException(`Can't reserve a slot if the event is already booked.`);
}
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
if (eventType.userId) {
const slot = await this.slotsRepository.createSlot(
eventType.userId,
eventType.id,
startDate.toISO(),
endDate.toISO(),
eventType.seatsPerTimeSlot !== null,
reservationDuration
);
return this.slotsOutputService.getReservationSlotCreated(slot, reservationDuration);
}
const host = eventType.hosts[0];
if (!host) {
throw new BadRequestException("Cannot reserve a slot for a team event without any hosts");
}
const slot = await this.slotsRepository.createSlot(
host.userId,
eventType.id,
startDate.toISO(),
endDate.toISO(),
eventType.seatsPerTimeSlot !== null,
reservationDuration
);
return this.slotsOutputService.getReservationSlotCreated(slot, reservationDuration);
}
async canSpecifyCustomReservationDuration(authUserId: number, eventType: EventType) {
if (eventType.userId) {
return await this.canSpecifyCustomReservationDurationIndividualEvent(authUserId, eventType.userId);
}
if (eventType.teamId) {
return await this.canSpecifyCustomReservationDurationTeamEvent(authUserId, eventType.teamId);
}
return false;
}
async canSpecifyCustomReservationDurationIndividualEvent(authUserId: number, eventTypeOwnerId: number) {
if (authUserId === eventTypeOwnerId) return true;
if (await this.membershipsService.haveMembershipsInCommon(authUserId, eventTypeOwnerId)) return true;
return false;
}
async canSpecifyCustomReservationDurationTeamEvent(authUserId: number, teamId: number) {
const teamMembership = await this.membershipsRepository.findMembershipByTeamId(teamId, authUserId);
const hasAcceptedTeamMembership = !!teamMembership?.accepted;
if (hasAcceptedTeamMembership) return true;
const team = await this.teamsRepository.getById(teamId);
if (!team?.parentId) {
return false;
}
const orgMembership = await this.membershipsRepository.findMembershipByTeamId(team.parentId, authUserId);
const hasAcceptedOrgMembership = !!orgMembership?.accepted;
return hasAcceptedOrgMembership;
}
async getReservedSlot(uid: string) {
const slot = await this.slotsRepository.getByUid(uid);
if (!slot) {
return null;
}
return this.slotsOutputService.getReservationSlot(slot);
}
async updateReservedSlot(input: ReserveSlotInput_2024_09_04, uid: string) {
const dbSlot = await this.slotsRepository.getByUid(uid);
if (!dbSlot) {
throw new NotFoundException(`Slot with uid=${uid} not found`);
}
const eventType = await this.eventTypeRepository.getEventTypeWithHosts(input.eventTypeId);
if (!eventType) {
throw new NotFoundException(`Event Type with ID=${input.eventTypeId} not found`);
}
const startDate = DateTime.fromISO(input.slotStart, { zone: "utc" });
if (!startDate.isValid) {
throw new BadRequestException("Invalid start date");
}
const metadata = eventTypeMetadataSchema.parse(eventType);
if (
input.slotDuration &&
metadata.multipleDuration &&
!metadata.multipleDuration.includes(input.slotDuration)
) {
throw new BadRequestException(
`Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: ${metadata.multipleDuration.join(
", "
)}`
);
}
const endDate = startDate.plus({ minutes: input.slotDuration ?? eventType.length });
if (!endDate.isValid) {
throw new BadRequestException("Invalid end date");
}
const booking = await this.slotsRepository.getBookingWithAttendeesByEventTypeIdAndStart(
input.eventTypeId,
startDate.toJSDate()
);
if (eventType.seatsPerTimeSlot) {
const attendeesCount = booking?.attendees?.length;
if (attendeesCount) {
const seatsLeft = eventType.seatsPerTimeSlot - attendeesCount;
if (seatsLeft < 1) {
throw new UnprocessableEntityException(
`Booking with id=${input.eventTypeId} at ${input.slotStart} has no more seats left.`
);
}
}
}
const nonSeatedEventAlreadyBooked = !eventType.seatsPerTimeSlot && booking;
if (nonSeatedEventAlreadyBooked) {
throw new UnprocessableEntityException(`Can't reserve a slot if the event is already booked.`);
}
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
const slot = await this.slotsRepository.updateSlot(
eventType.id,
startDate.toISO(),
endDate.toISO(),
dbSlot.id,
reservationDuration
);
return this.slotsOutputService.getReservationSlotCreated(slot, reservationDuration);
}
async deleteReservedSlot(uid: string) {
return this.slotsRepository.deleteSlot(uid);
}
}
@@ -0,0 +1,30 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SlotsController_2024_09_04 } from "@/modules/slots/slots-2024-09-04/controllers/slots.controller";
import { SlotsInputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-input.service";
import { SlotsOutputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-output.service";
import { SlotsService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots.service";
import { SlotsRepository_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.repository";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, EventTypesModule_2024_06_14, StripeModule, TeamsModule, MembershipsModule],
providers: [
SlotsRepository_2024_09_04,
SlotsService_2024_09_04,
UsersRepository,
SlotsInputService_2024_09_04,
SlotsOutputService_2024_09_04,
OrganizationsUsersRepository,
OrganizationsRepository,
],
controllers: [SlotsController_2024_09_04],
exports: [SlotsService_2024_09_04],
})
export class SlotsModule_2024_09_04 {}
@@ -0,0 +1,74 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { DateTime } from "luxon";
import { v4 as uuid } from "uuid";
@Injectable()
export class SlotsRepository_2024_09_04 {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getByUid(uid: string) {
return this.dbRead.prisma.selectedSlots.findFirst({ where: { uid } });
}
async getBookingWithAttendeesByEventTypeIdAndStart(eventTypeId: number, startTime: Date) {
return this.dbRead.prisma.booking.findFirst({
where: { eventTypeId, startTime },
select: { attendees: true },
});
}
async createSlot(
userId: number,
eventTypeId: number,
slotUtcStartDate: string,
slotUtcEndDate: string,
isSeat: boolean,
duration: number
) {
const uid = uuid();
const reservationUntil = DateTime.utc().plus({ minutes: duration }).toISO();
return this.dbWrite.prisma.selectedSlots.create({
data: {
uid,
userId,
eventTypeId,
slotUtcStartDate,
slotUtcEndDate,
releaseAt: reservationUntil,
isSeat,
},
});
}
async updateSlot(
eventTypeId: number,
slotUtcStartDate: string,
slotUtcEndDate: string,
id: number,
duration: number
) {
const reservationUntil = DateTime.utc().plus({ minutes: duration }).toISO();
return this.dbWrite.prisma.selectedSlots.update({
where: {
id,
},
data: {
slotUtcEndDate,
slotUtcStartDate,
releaseAt: reservationUntil,
eventTypeId,
},
});
}
async deleteSlot(uid: string) {
// note(Lauris): we have deleteMany because for some reason uid is not unique in the prisma schema
return this.dbWrite.prisma.selectedSlots.deleteMany({
where: { uid: { equals: uid } },
});
}
}
@@ -1,15 +0,0 @@
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, SlotsOutputService],
controllers: [SlotsController],
exports: [SlotsService],
})
export class SlotsModule {}
@@ -137,10 +137,14 @@ export class TeamsEventTypesController {
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: GetTeamEventTypesQuery_2024_06_14
): Promise<GetTeamEventTypesOutput> {
const { eventSlug } = queryParams;
const { eventSlug, hostsLimit } = queryParams;
if (eventSlug) {
const eventType = await this.teamsEventTypesService.getTeamEventTypeBySlug(teamId, eventSlug);
const eventType = await this.teamsEventTypesService.getTeamEventTypeBySlug(
teamId,
eventSlug,
hostsLimit
);
return {
status: SUCCESS_STATUS,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma } from "@calcom/prisma/client";
export class AttendeeRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async create(attendee: Prisma.AttendeeCreateInput) {
return this.prismaWriteClient.attendee.create({ data: attendee });
}
}
@@ -0,0 +1,19 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma } from "@calcom/prisma/client";
export class BookingSeatRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async create(bookingSeat: Prisma.BookingSeatCreateInput) {
return this.prismaWriteClient.bookingSeat.create({ data: bookingSeat });
}
}
@@ -35,7 +35,7 @@ export class BookingsRepositoryFixture {
}
async deleteById(bookingId: Booking["id"]) {
return this.prismaWriteClient.booking.delete({ where: { id: bookingId } });
return this.prismaWriteClient.booking.deleteMany({ where: { id: bookingId } });
}
async deleteAllBookings(userId: User["id"], userEmail: User["email"]) {
@@ -19,7 +19,10 @@ export class OrganizationRepositoryFixture {
async create(data: Prisma.TeamCreateInput) {
return await this.prismaWriteClient.$transaction(async (prisma) => {
const team = await prisma.team.create({
data,
data: {
...data,
isOrganization: true,
},
});
await prisma.organizationSettings.create({
@@ -12,6 +12,10 @@ export class SelectedSlotsRepositoryFixture {
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async getByUid(uid: string) {
return this.prismaReadClient.selectedSlots.findFirst({ where: { uid } });
}
async deleteByUId(uid: SelectedSlots["uid"]) {
return this.prismaWriteClient.selectedSlots.deleteMany({ where: { uid } });
}
+1 -1
View File
@@ -230,7 +230,7 @@
--cal-text-info: hsla(228, 56%, 33%, 1);
--cal-text-success: hsla(133, 34%, 24%, 1);
--cal-text-attention: hsla(16, 62%, 28%, 1);
--cal-text-error: hsla(2, 55%, 30%, 1);
--cal-text-error: hsla(0, 63%, 31%, 1);
/* Brand shinanigans
-> These will be computed for the users theme at runtime.
-41
View File
@@ -1,41 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
-36
View File
@@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

-485
View File
@@ -1,485 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/**
* Add css variables here as well for light mode in addition to tailwind.config to avoid FOUC
*/
:root {
/* background */
--cal-bg-emphasis: hsla(220,13%,91%,1);
--cal-bg: hsla(0,0%,100%,1);
--cal-bg-subtle: hsla(220, 14%, 96%,1);
--cal-bg-muted: hsla(210,20%,98%,1);
--cal-bg-inverted: hsla(0,0%,6%,1);
/* background -> components*/
--cal-bg-info: hsla(218,83%,98%,1);
--cal-bg-success: hsla(134,76%,94%,1);
--cal-bg-attention: hsla(37, 86%, 92%, 1);
--cal-bg-error: hsla(3,66%,93%,1);
--cal-bg-dark-error: hsla(2, 55%, 30%, 1);
/* Borders */
--cal-border-emphasis: hsla(218, 11%, 65%, 1);
--cal-border: hsla(216, 12%, 84%, 1);
--cal-border-subtle: hsla(220, 13%, 91%, 1);
--cal-border-booker: #e5e7eb;
--cal-border-muted: hsla(220, 14%, 96%, 1);
--cal-border-error: hsla(4, 63%, 41%, 1);
--cal-border-focus: hsla(0, 0%, 10%, 1);
/* Content/Text */
--cal-text-emphasis: hsla(217, 19%, 27%, 1);
--cal-text: hsla(217, 19%, 27%, 1);
--cal-text-subtle: hsla(220, 9%, 46%, 1);
--cal-text-muted: hsla(218, 11%, 65%, 1);
--cal-text-inverted: hsla(0, 0%, 100%, 1);
/* Content/Text -> components */
--cal-text-info: hsla(228, 56%, 33%, 1);
--cal-text-success: hsla(133, 34%, 24%, 1);
--cal-text-attention: hsla(16, 62%, 28%, 1);
--cal-text-error: hsla(2, 55%, 30%, 1);
/* Brand shinanigans
-> These will be computed for the users theme at runtime.
*/
--cal-brand: hsla(221, 39%, 11%, 1);
--cal-brand-emphasis: hsla(0, 0%, 6%, 1);
--cal-brand-text: hsla(0, 0%, 100%, 1);
}
.dark {
/* background */
--cal-bg-emphasis: hsla(0, 0%, 25%, 1);
--cal-bg: hsla(0, 0%, 10%, 1);
--cal-bg-subtle: hsla(0, 0%, 18%, 1);
--cal-bg-muted: hsla(0, 0%, 12%, 1);
--cal-bg-inverted: hsla(220, 14%, 96%, 1);
/* background -> components*/
--cal-bg-info: hsla(228, 56%, 33%, 1);
--cal-bg-success: hsla(133, 34%, 24%, 1);
--cal-bg-attention: hsla(16, 62%, 28%, 1);
--cal-bg-error: hsla(2, 55%, 30%, 1);
--cal-bg-dark-error: hsla(2, 55%, 30%, 1);
/* Borders */
--cal-border-emphasis: hsla(0, 0%, 46%, 1);
--cal-border: hsla(0, 0%, 34%, 1);
--cal-border-subtle: hsla(0, 0%, 22%, 1);
--cal-border-booker: hsla(0, 0%, 22%, 1);
--cal-border-muted: hsla(0, 0%, 18%, 1);
--cal-border-error: hsla(4, 63%, 41%, 1);
--cal-border-focus: hsla(0, 0%, 100%, 1);
/* Content/Text */
--cal-text-emphasis: hsla(240, 20%, 99%, 1);
--cal-text: hsla(0, 0%, 84%, 1);
--cal-text-subtle: hsla(0, 0%, 65%, 1);
--cal-text-muted: hsla(0, 0%, 34%, 1);
--cal-text-inverted: hsla(0, 0%, 10%, 1);
/* Content/Text -> components */
--cal-text-info: hsla(218, 83%, 93%, 1);
--cal-text-success: hsla(134, 76%, 94%, 1);
--cal-text-attention: hsla(37, 86%, 92%, 1);
--cal-text-error: hsla(3, 66%, 93%, 1);
/* Brand shenanigans
-> These will be computed for the users theme at runtime.
*/
--cal-brand: hsla(0, 0%, 100%, 1);
--cal-brand-emphasis: hsla(218, 11%, 65%, 1);
--cal-brand-text: hsla(0, 0%, 0%,1);
}
@layer base {
* {
@apply border-default
}
}
::-moz-selection {
color: var(--cal-brand-text);
background: var(--cal-brand);
}
::selection {
color: var(--cal-brand-text);
background: var(--cal-brand);
}
body  {
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
/*
Desktop App specific CSS
https://docs.todesktop.com/
*/
html.todesktop-platform-win32 .todesktop\:\!bg-transparent{
background: inherit !important;
}
/* disable user selection on buttons, links and images on desktop app */
html.todesktop button,
html.todesktop a,
html.todesktop img,
html.todesktop header {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
cursor: default !important;
}
html.todesktop,
html.todesktop div {
cursor: default !important;
}
/* make header draggable on desktop app */
html.todesktop header {
-webkit-app-region: drag;
}
html.todesktop header button,
html.todesktop header a {
-webkit-app-region: no-drag;
}
html.todesktop-platform-darwin body, html.todesktop-platform-darwin aside {
background: transparent !important;
}
html.todesktop-platform-darwin.dark main.bg-default {
background: rgba(0, 0, 0, 0.6) !important;
}
html.todesktop-platform-darwin.light main.bg-default {
background: rgba(255, 255, 255, 0.8) !important;
}
html.todesktop.light {
--cal-bg-emphasis: hsla(0, 0%, 11%, 0.1);
}
html.todesktop.dark {
--cal-bg-emphasis: hsla(220, 2%, 26%, 0.3);
}
/*
Adds Utility to hide scrollbar to tailwind
https://github.com/tailwindlabs/tailwindcss/discussions/2394
https://github.com/tailwindlabs/tailwindcss/pull/5732
*/
@layer utilities {
/* Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}
/*
* Override the default tailwindcss-forms styling (default is: 'colors.blue.600')
* @see: https://github.com/tailwindlabs/tailwindcss-forms/issues/14#issuecomment-1005376006
*/
[type="text"]:focus,
[type="email"]:focus,
[type="url"]:focus,
[type="password"]:focus,
[type="number"]:focus,
[type="date"]:focus,
[type="datetime-local"]:focus,
[type="month"]:focus,
[type="search"]:focus,
[type="tel"]:focus,
[type="checkbox"]:focus,
[type="radio"]:focus,
[type="time"]:focus,
[type="week"]:focus,
[multiple]:focus,
textarea:focus,
select:focus {
--tw-ring-color: var(--brand-color);
border-color: var(--brand-color);
}
@layer components {
.scroll-bar {
@apply scrollbar-thin scrollbar-thumb-rounded-md dark:scrollbar-thumb-darkgray-300 scrollbar-thumb-gray-300 scrollbar-track-transparent;
}
}
/* TODO: avoid global specific css */
/* button[role="switch"][data-state="checked"] span {
transform: translateX(16px);
} */
@layer components {
/* slider */
.slider {
@apply relative flex h-4 w-40 select-none items-center;
}
.slider > .slider-track {
@apply relative h-1 flex-grow rounded-md bg-gray-400;
}
.slider .slider-range {
@apply absolute h-full rounded-full bg-gray-700;
}
.slider .slider-thumb {
@apply block h-3 w-3 cursor-pointer rounded-full bg-gray-700 transition-all;
}
.slider .slider-thumb:hover {
@apply bg-gray-600;
}
.slider .slider-thumb:focus {
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.2);
}
}
/* hide chat bubble on mobile */
@media only screen and (max-width: 768px) {
/* Intercom FAB*/
#launcher {
display: none !important;
}
/* Zendesk FAB*/
div[role="presentation"] > iframe {
display: none !important;
}
/* Helpscout FAB*/
.BeaconFabButtonFrame {
margin-left: -30px;
left: 50%;
bottom: 28px !important;
z-index: 1058 !important;
}
}
/* TODO: implement styling for react-multi-email */
/* !important to override react-dates */
.DateRangePickerInput__withBorder {
border: 0 !important;
}
.DateInput_input {
border: 1px solid #d1d5db !important;
border-radius: 2px !important;
font-size: inherit !important;
font-weight: inherit !important;
color: #000;
padding: 11px ​11px 9px !important;
line-height: 16px !important;
}
.DateInput_input__focused {
border: 2px solid #000 !important;
border-radius: 2px !important;
box-shadow: none !important;
padding: 10px ​10px 9px !important;
}
.DateRangePickerInput_arrow {
padding: 0px 10px;
}
.loader {
display: block;
width: 30px;
height: 30px;
margin: 60px auto;
position: relative;
border-width: 4px;
border-style: solid;
animation: loader 2s infinite ease;
}
.loader-inner {
vertical-align: top;
display: inline-block;
width: 100%;
animation: loader-inner 2s infinite ease-in;
}
.no-ring-inset {
--tw-ring-inset: unset;
}
@keyframes loader {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(180deg);
}
50% {
transform: rotate(180deg);
}
75% {
transform: rotate(360deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes loader-inner {
0% {
height: 0%;
}
25% {
height: 0%;
}
50% {
height: 100%;
}
75% {
height: 100%;
}
100% {
height: 0%;
}
}
.text-inverted-important {
color: white !important;
}
@layer utilities {
.transition-max-width {
-webkit-transition-property: max-width;
transition-property: max-width;
}
}
#timeZone input:focus {
box-shadow: none;
}
/* react-date-picker forces a border upon us, cast it away */
.react-date-picker__wrapper {
border: none !important;
}
.react-date-picker__inputGroup__input {
padding-top: 0;
padding-bottom: 0;
}
/* animations */
.slideInBottom {
animation-duration: 0.3s;
animation-fill-mode: both;
animation-name: slideInBottom;
}
@keyframes slideInBottom {
from {
opacity: 0;
transform: translateY(30%);
pointer-events: none;
}
to {
opacity: 1;
pointer-events: auto;
}
}
/* animations */
.slideInTop {
animation-duration: 0.3s;
animation-fill-mode: both;
animation-name: slideInTop;
}
@keyframes slideInTop {
from {
opacity: 0;
transform: translateY(-20%);
pointer-events: none;
}
to {
opacity: 1;
pointer-events: auto;
transform: translateY(0%);
}
}
.fadeIn {
animation-duration: 0.3s;
animation-fill-mode: both;
animation-name: fadeIn;
animation-timing-function: ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/**
* Makes sure h-screen works on mobile Safari. By default h-screen
* does not take into account the height of the address bar, causing
* weird behaviour when scrolling — sometimes the height will be correct
* and sometimes it won't, depending on whether the address bar is
* in 'collapsed' state or not.
* @see: https://benborgers.com/posts/tailwind-h-screen
*/
@supports (-webkit-touch-callout: none) {
.h-screen {
height: -webkit-fill-available;
}
}
::-webkit-search-cancel-button {
-webkit-appearance: none;
}
.react-tel-input .country-list .country:hover,
.react-tel-input .country-list .country.highlight {
@apply !bg-emphasis;
}
.react-tel-input .flag-dropdown .selected-flag,
.react-tel-input .flag-dropdown.open .selected-flag {
@apply !bg-default;
}
.react-tel-input .flag-dropdown {
@apply !border-r-default left-0.5 !border-y-0 !border-l-0;
}
.intercom-lightweight-app {
@apply z-40 !important;
}
-31
View File
@@ -1,31 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>{children}</body>
</html>
);
}
-5
View File
@@ -1,5 +0,0 @@
// import AvatarDemo from "./_components/AvatarDemo";
export default function Home() {
return <div className="p-4 font-[family-name:var(--font-geist-sans)]">{/* <AvatarDemo /> */}</div>;
}
-16
View File
@@ -1,16 +0,0 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;
-7
View File
@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
-30
View File
@@ -1,30 +0,0 @@
{
"name": "ui-playground",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack --port=1337",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@calcom/config": "*",
"@calcom/lib": "*",
"@calcom/ui": "*",
"next": "15.1.6",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^9",
"eslint-config-next": "15.1.6",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
-8
View File
@@ -1,8 +0,0 @@
import base from "@calcom/config/tailwind-preset";
/** @type {import('tailwindcss').Config} */
module.exports = {
...base,
content: [...base.content],
plugins: [...base.plugins, require("tailwindcss-animate")],
};
-27
View File
@@ -1,27 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
@@ -12,8 +12,7 @@ export function ShellMainAppDir(props: LayoutProps) {
<div
className={classNames(
"flex items-center md:mb-6 md:mt-0",
props.smallHeading ? "lg:mb-7" : "lg:mb-8",
props.hideHeadingOnMobile ? "mb-0" : "mb-6"
props.smallHeading ? "lg:mb-7" : "lg:mb-8"
)}>
{!!props.backPath && <ShellMainAppDirBackButton backPath={props.backPath} />}
{props.heading && (
@@ -21,13 +20,15 @@ export function ShellMainAppDir(props: LayoutProps) {
className={classNames(props.large && "py-8", "flex w-full max-w-full items-center truncate")}>
{props.HeadingLeftIcon && <div className="ltr:mr-4">{props.HeadingLeftIcon}</div>}
<div
className={classNames("w-full truncate ltr:mr-4 rtl:ml-4 md:block", props.headerClassName)}>
className={classNames(
"hidden w-full truncate ltr:mr-4 rtl:ml-4 md:block",
props.headerClassName
)}>
{props.heading && (
<h3
className={classNames(
"font-cal text-emphasis max-w-28 sm:max-w-72 md:max-w-80 inline truncate text-lg font-semibold tracking-wide sm:text-xl md:block xl:max-w-full",
props.smallHeading ? "text-base" : "text-xl",
props.hideHeadingOnMobile && "hidden"
props.smallHeading ? "text-base" : "text-xl"
)}>
{props.heading}
</h3>
@@ -2,7 +2,7 @@ import Shell from "@calcom/features/shell/Shell";
const Layout = ({ children }: { children: React.ReactNode }) => {
return (
<Shell hideHeadingOnMobile withoutSeo={true} withoutMain={true}>
<Shell withoutSeo={true} withoutMain={true}>
{children}
</Shell>
);
@@ -1,13 +1,13 @@
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps as ServerPageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/teams/getServerSideProps";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import type { PageProps as ClientPageProps } from "~/teams/teams-view";
import TeamsView, { TeamsCTA } from "~/teams/teams-view";
export const generateMetadata = async () =>
@@ -16,11 +16,15 @@ export const generateMetadata = async () =>
(t) => t("create_manage_teams_collaborative")
);
const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ searchParams }: ServerPageProps) => {
const session = await getServerSession({ req: buildLegacyRequest(headers(), cookies()) });
const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token;
const callbackUrl = token ? `/teams?token=${encodeURIComponent(token)}` : null;
if (!session) {
redirect(callbackUrl ? `/auth/login?callbackUrl=${callbackUrl}` : "/auth/login");
}
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getData(context);
const t = await getTranslate();
return (
@@ -28,7 +32,7 @@ const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
CTA={<TeamsCTA />}
heading={t("teams")}
subtitle={t("create_manage_teams_collaborative")}>
<TeamsView {...props} />
<TeamsView />
</ShellMainAppDir>
);
};
@@ -0,0 +1,59 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { generateMeetingMetadata } from "app/_utils";
import { headers, cookies } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@server/lib/[user]/[type]/getServerSideProps";
import type { PageProps as LegacyPageProps } from "~/users/views/users-type-public-view";
import LegacyPage from "~/users/views/users-type-public-view";
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getData(legacyCtx);
const { booking, isSEOIndexable = true, eventData, isBrandingHidden } = props;
const rescheduleUid = booking?.uid;
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}`,
})),
],
};
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),
},
};
};
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getData(legacyCtx);
return <LegacyPage {...props} />;
};
export default ServerPage;
@@ -0,0 +1,52 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { generateMeetingMetadata } from "app/_utils";
import { headers, cookies } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@server/lib/[user]/getServerSideProps";
import type { PageProps as LegacyPageProps } from "~/users/views/users-public-view";
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 meeting = {
title: markdownStrippedBio,
profile: { name: `${profile.name}`, image: profile.image },
users: [{ username: `${profile.username}`, name: `${profile.name}` }],
};
const metadata = await generateMeetingMetadata(
meeting,
() => profile.name,
() => markdownStrippedBio,
false,
getOrgFullOrigin(entity.orgSlug ?? null)
);
return {
...metadata,
robots: {
follow: allowSEOIndexing,
index: allowSEOIndexing,
},
};
};
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
const props = await getData(buildLegacyCtx(headers(), cookies(), params, searchParams));
return <LegacyPage {...props} />;
};
export default ServerPage;
@@ -32,4 +32,4 @@ const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
return <SetupView {...props} />;
};
export default ServerPage;
export default ServerPage;
@@ -21,9 +21,9 @@ const querySchema = z.object({
const ServerPage = async ({ searchParams }: PageProps) => {
const t = await getTranslate();
const { error } = querySchema.parse({ error: searchParams?.error || undefined });
const errorMsg = error || t("error_during_login");
const errorMsg = t("error_during_login") + (error ? ` Error code: ${error}` : "");
return (
<AuthContainer title="" description="" isAppDir={true}>
<AuthContainer>
<div>
<div className="bg-error mx-auto flex h-12 w-12 items-center justify-center rounded-full">
<Icon name="x" className="h-6 w-6 text-red-600" />
@@ -9,4 +9,8 @@ export const generateMetadata = async () => {
);
};
export default Page;
const ServerPageWrapper = async () => {
return <Page />;
};
export default ServerPageWrapper;
@@ -9,4 +9,8 @@ export const generateMetadata = async () => {
);
};
export default VerifyEmailPage;
const ServerPageWrapper = async () => {
return <VerifyEmailPage />;
};
export default ServerPageWrapper;
@@ -0,0 +1,21 @@
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import type { PageProps as ServerPageProps } from "app/_types";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import OldPage from "~/bookings/views/bookings-single-view";
import {
getServerSideProps,
type PageProps as ClientPageProps,
} from "~/bookings/views/bookings-single-view.getServerSideProps";
const getEmbedData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
const context = buildLegacyCtx(headers(), cookies(), params, searchParams);
const props = await getEmbedData(context);
return <OldPage {...props} />;
};
export default ServerPage;
@@ -1,7 +1,6 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps as _PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
@@ -27,4 +26,11 @@ export const generateMetadata = async ({ params, searchParams }: _PageProps) =>
};
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default WithLayout({ getLayout: null, Page: Type, getData })<"P">;
const ServerPage = async ({ params, searchParams }: _PageProps) => {
const legacyCtx = buildLegacyCtx(headers(), cookies(), params, searchParams);
const pageProps = await getData(legacyCtx);
return <Type {...pageProps} />;
};
export default ServerPage;
@@ -2,6 +2,9 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps as _PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { z } from "zod";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import type { PageProps as EventTypePageProps } from "@lib/event-types/[type]/getServerSideProps";
@@ -9,12 +12,30 @@ import { getServerSideProps } 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);
const { eventType } = await getData(legacyCtx);
const querySchema = z.object({
type: z
.string()
.refine((val) => !isNaN(Number(val)), {
message: "event-type id must be a string that can be cast to a number",
})
.transform((val) => Number(val)),
});
export const generateMetadata = async ({ params }: _PageProps) => {
const parsed = querySchema.safeParse(params);
if (!parsed.success) {
return await _generateMetadata(
(t) => `${t("event_type")}`,
() => ""
);
}
const data = await EventTypeRepository.findTitleById({
id: parsed.data.type,
});
return await _generateMetadata(
(t) => `${eventType.title} | ${t("event_type")}`,
(t) => (data?.title ? `${data.title} | ${t("event_type")}` : `${t("event_type")}`),
() => ""
);
};
@@ -0,0 +1,53 @@
"use client";
import { useSession } from "next-auth/react";
import { UpgradeTip } from "@calcom/features/tips";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon, Button, ButtonGroup } from "@calcom/ui";
export default function UpgradeTipWrapper({ children }: { children: React.ReactNode }) {
const { t } = useLocale();
const session = useSession();
const features = [
{
icon: <Icon name="users" className="h-5 w-5" />,
title: t("view_bookings_across"),
description: t("view_bookings_across_description"),
},
{
icon: <Icon name="refresh-ccw" className="h-5 w-5" />,
title: t("identify_booking_trends"),
description: t("identify_booking_trends_description"),
},
{
icon: <Icon name="user-plus" className="h-5 w-5" />,
title: t("spot_popular_event_types"),
description: t("spot_popular_event_types_description"),
},
];
return (
<UpgradeTip
plan="team"
title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")}
features={features}
background="/tips/insights"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="minimal" href="https://go.cal.com/insights" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
{!session.data?.user ? null : children}
</UpgradeTip>
);
}
@@ -1,65 +1,15 @@
"use client";
import { useSession } from "next-auth/react";
import { getTranslate } from "app/_utils";
import Shell from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, ButtonGroup } from "@calcom/ui";
import { Icon } from "@calcom/ui";
export default function InsightsLayout({ children }: { children: React.ReactNode }) {
const { t } = useLocale();
const session = useSession();
const features = [
{
icon: <Icon name="users" className="h-5 w-5" />,
title: t("view_bookings_across"),
description: t("view_bookings_across_description"),
},
{
icon: <Icon name="refresh-ccw" className="h-5 w-5" />,
title: t("identify_booking_trends"),
description: t("identify_booking_trends_description"),
},
{
icon: <Icon name="user-plus" className="h-5 w-5" />,
title: t("spot_popular_event_types"),
description: t("spot_popular_event_types_description"),
},
];
import UpgradeTipWrapper from "./UpgradeTipWrapper";
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
const t = await getTranslate();
return (
<div>
<Shell
withoutMain={false}
withoutSeo={true}
heading={t("insights")}
subtitle={t("insights_subtitle")}
title={t("insights")}
description={t("insights_subtitle")}>
<UpgradeTip
plan="team"
title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")}
features={features}
background="/tips/insights"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="minimal" href="https://go.cal.com/insights" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
{!session.data?.user ? null : children}
</UpgradeTip>
<Shell withoutMain={false} withoutSeo={true} heading={t("insights")} subtitle={t("insights_subtitle")}>
<UpgradeTipWrapper>{children}</UpgradeTipWrapper>
</Shell>
</div>
);

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