Compare commits

..
54 Commits
Author SHA1 Message Date
GitHub Actions fa09fd66ff chore: release v5.2.14
Post release / release (push) Canceled after 0s
2025-05-09 17:03:57 +00:00
Joe Au-Yeung b1cbeebe48 feat: Add option to include no shows in RR calculations (#21063)
* Show distribution option for RR scheduling type

* Add `includeNoShowInRRCalculation` to event type schema

* Add `includeNoShowInRRCalculation` initial event type data

* Add FE option for `includeNoShowInRRCalculation`

* Consider `includeNoShowInRRCalculation` when building `where` clause when getting bookings for RR

* Add `includeNoShowInRRCalculation` param to `getAllBookingsForRoundRobin`

* Add `includeNoShow...` param to `GetLuckyUserParams`

* Pass `includeNoShow...` param to `getBookingsOfInterval` calls

* Add FE option for `includeNoShow...` (actual commit)

* Type fixes

* Fix brackets

* Add booking repository test

* Remove unused method

* Add translation

* Type fix

* Type fix

* Test passing

* Fix tests

* Fix tests

* Fix test

* Type fixes
2025-05-09 16:29:57 +00:00
devin-ai-integration[bot] 93bad282d7 feat: add SMS_ATTENDEE action to activateEventType.handler.ts (#21201) 2025-05-09 18:08:21 +02:00
Anik Dhabal Babu 47d941676e fix: add org email into ics when hideorganizer email is true (#21212)
* fix: add org email into ics

* update test
2025-05-09 15:57:24 +00:00
Keith Williams 54c4b0b65e chore: Remove Sentry replay integration (#21211) 2025-05-09 11:44:30 -04:00
Udit Takkar 0f8356f417 chore: add some console logs for debugging (#21130)
* chore: add some console logs for debugging

* chore: log only essential
2025-05-09 08:26:12 +00:00
Hariom BalharaandTushar Bhatt cecfcc7c6f Setup delegation credential related crons (#21176)
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-09 07:59:31 +00:00
amritandTushar Bhatt 0b3f9d06a9 test: add tests for the search dropdown in /bookings/upcoming filter (#21141)
* fix: trigger lingo.dev by removing duplicate value

* feat: add filter dropdown item search tests for bookings list

* refactor: update page navigation to wait for DOM content in bookings list tests and add new filter item search test

* remove comments

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-09 11:12:30 +05:30
Yadong (Adam) ZhangandOmar López 71e7300830 build(apps/web): enabled client code Sentry source maps uploading. (#21108)
Co-authored-by: Omar López <zomars@me.com>
2025-05-08 20:14:47 -07:00
Omar López bccffb328b refactor: removes copy-webpack-plugin (#21200) 2025-05-09 01:01:53 +00:00
Carina WollendorferandCarinaWolli 80fa172f79 feat: add feature flag for sending workflow emails with smtp (#21187)
* add feature flag

* add unit tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-08 22:39:14 +00:00
Joe Au-Yeung 50173658f8 refactor: Use Iffy for workflow body scanning (#21170)
* Add Iffy API key variable

* WIP using Iffy to scan comments

* Use Iffy for workflow body scanning

* Update entity

* Clean up

* Update test

* Fix client id

* Fix test
2025-05-08 23:38:49 +01:00
Lauris Skraucis dddbd7dad4 feat: v2 membership attributes (#21131)
* refactor: getByUserId.handler.ts

* feat: return org membership attributes

* docs

* export new library function

* test: attributes

* chore: bump libraries

* refactor: value -> option
2025-05-08 23:37:24 +01:00
Benny Joo ae2d85d81b chore: Remove @calcom/features/schedules barrel files (#21199)
* remove barrel files

* update imports
2025-05-08 23:15:51 +01:00
GitHub Actions 80c407a4b7 chore: release v5.2.13
Post release / release (push) Canceled after 0s
2025-05-08 21:44:17 +00:00
6e9ec113fe feat: Prerender booking link and reuse with headless router (#20720)
* mvp done

* wip

* fix ts errors and other code improvements

* fix ts errors

* ensure mobile layout support

* Make skeleton responsive on screen resize

* refactor

* Add test for EmbedElement

* make skeleton closer to pixel perfect

* Address PR feedback

* Router-preloading
## What does this PR do?

<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

- Fixes #XXXX (GitHub issue number)
- Fixes CAL-XXXX (Linear issue number - should be visible at the bottom of the GitHub issue description)

## Visual Demo (For contributors especially)

A visual demonstration is strongly recommended, for both the original and new change **(video / image - any one)**.

#### Video Demo (if applicable):

- Show screen recordings of the issue or feature.
- Demonstrate how to reproduce the issue, the behavior before and after the change.

#### Image Demo (if applicable):

- Add side-by-side screenshots of the original and updated change.
- Highlight any significant change(s).

## Mandatory Tasks (DO NOT REMOVE)

- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [ ] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->

- Are there environment variables that should be set?
- What are the minimal test data to have?
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR

## Checklist

<!-- Remove bullet points below that don't apply to you -->

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings

* wip\

* wip

* fix mrge.io feedback

* wip

* Add README and lifecycle

* Add README and lifecycle

* Update routing form-seed and some other fixes

* remove linkFailed fix from the branch

* self-review

* self-review-2

* self-review-3

* Handle soft connect\

* Update README and fix a bug with query parmas

* Add one more case in routing-html playground

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: amrit <iamamrit27@gmail.com>
2025-05-08 19:59:26 +01:00
Hariom Balhara 3ee6a7ecde update delegation creential readme (#21178) 2025-05-08 17:40:38 +00:00
Somay Chauhan 8ad62d183c feat: booking references api type filter (#21179)
* feat: add `type` filter for booking references API endpointa

* Update openapi.json

* feat: add enum validation for booking reference types in filter input

* refactor: rename externalUid to eventUid and add destinationCalendarId in booking references

* refactor: replace enum with const array for BookingReferenceType

* fix: type safety for booking references where clause using Prisma types

* refactor: rename BookingReferenceType to BookingReferences
2025-05-08 17:23:09 +00:00
Benny Joo f047799299 chore: remove @calcom/lib/server barrel file (#21190) 2025-05-08 22:36:30 +05:30
Lauris SkraucisandMorgan 3a9e353443 fix: don't allow double reserving already reserved slot (#21139)
* feat: prevent double reserving slots

* tests

* remove log

* logs

* refactor: releaseAt utc check

* fix: check overlap when updating slots

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-05-08 14:39:33 +02:00
Udit Takkar b73a3aeb0c feat: enforce log in cal video (#21082)
* feat: enforce log in cal video

* chore: remove un necessary text

* fix: type err

* fix: pass user name
2025-05-08 12:37:50 +00:00
Anik Dhabal Babu b6ff64ed74 fix: attendees not visible for hosts (#21156) 2025-05-08 12:23:27 +00:00
Anik Dhabal Babu 1b281fe269 fix: unable to change the workflow action (#21185) 2025-05-08 10:06:02 +00:00
Somay Chauhan 024913592b feat: Add handleSlotSelect prop for custom timeslot selection/reservation handling (#21133)
* feat: Add `handleSlotSelect` prop for custom timeslot selection/reservation handling

* Update custom-slot-select-flow.mdx

* Update custom-slot-select-flow.mdx

* refactor: rename handleSlotSelect to handleSlotReservation for clearer intent

* docs: add API reference link for slot reservation api
2025-05-08 15:13:58 +05:30
Udit TakkarandAnik Dhabal Babu 78a34038d2 test: add e2e test for round robin event types (#21159)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-05-08 11:41:21 +05:30
Romit GabaniandTushar Bhatt 5f770a9789 fix: hiding And connect with specific team members option for non org teams (#21083)
* fix: hiding And connect with specific team members option for non org teams

* removed console logs

* added comment

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-08 05:09:11 +00:00
devin-ai-integration[bot]andmorgan@cal.com 24a43877ce perf: optimize event type query by using team IDs instead of complex join (#21162)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
2025-05-07 13:00:49 -07:00
GitHub Actions 4a96be4175 chore: release v5.2.12
Post release / release (push) Canceled after 0s
2025-05-07 19:34:41 +00:00
594bb606b8 perf: Refactor bookings queries for API (#16407)
* perf: Refactor bookings queries for API

* fix syntax

* fixing vars

* Inverted the filter by attendees if

* fixed prisma filter issue

* Defaulting list of emails to empty array

* Only running 2 queries if we have data to filter by

* Reverted changes that have been separated out to other PR

* fix: ts build error

* adds tests + extracts logic for units

* fix integration tests

* fix tests expectes

* Fix test

* switching back

* attempt : fix tests

* test: add test for duplicate bookings (#21165)

* feat: Add tests for booking and duration limits (#21158)

* Add tests for booking and duration limits in getSchedule.test.ts

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use SchedulingType enum instead of string for schedulingType

Co-Authored-By: keith@cal.com <keith@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>

* test: add test to ensure no duplicate bookings when merging query results

Co-Authored-By: keith@cal.com <keith@cal.com>

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* fix: deduplicate bookings when merging results from multiple queries

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: ensure pagination works correctly when merging results from multiple queries (#21167)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: Tushar <tusharbhatt0135@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
2025-05-07 11:44:56 -07:00
Omar López 1c088f65c5 chore: Removes around app (#20650) 2025-05-07 14:41:46 -04:00
devin-ai-integration[bot]andkeith@cal.com 7d7a429780 feat: Add tests for booking and duration limits (#21158)
* Add tests for booking and duration limits in getSchedule.test.ts

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use SchedulingType enum instead of string for schedulingType

Co-Authored-By: keith@cal.com <keith@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
2025-05-07 13:06:10 -04:00
GiridharandTushar Bhatt d3228441aa fix:decline-booking-reason-missing (#21144)
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-07 21:01:33 +05:30
Morgan 21d6bf1fca fix: api v2 routing form responses flaky tests (#21150) 2025-05-07 16:22:02 +03:00
a4134aa5a3 feat: add 'View Responses' menu item to Routing Form dropdown (#21114)
* feat: add 'View Responses' menu item to Routing Form dropdown

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: use nuqs createSerializer for URL generation

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* refactor: move serializer to data-table lib and use proper type constants

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: type errors in serializers and FormActions

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: use parseAsArrayOf with parseAsJson for proper nuqs serialization

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: update to use dataTableQueryParamsSerializer with proper nuqs pattern

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* refactor: extract parsers from DataTableProvider to be reused in serializers

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: remove deprecated activeFiltersSerializer and use empty array instead of null

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: only show View Responses menu when form id and teamId exist

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* remove teamId condition

* refactor: improve viewResponses URL generation with cleaner conditional

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* Update packages/features/data-table/lib/parsers.ts

* refactor: make constants in parsers.ts non-exported

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: add explicit type annotations to constants in parsers.ts

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: add explicit type annotations and export DEFAULT_PAGE_SIZE in parsers.ts

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: add explicit type assertions to DEFAULT_ACTIVE_FILTERS and DEFAULT_SORTING

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-05-07 13:10:21 +00:00
Anik Dhabal Babu 61397e5184 fix: e2e flakes (#20630)
* fix

* Update managed-event-types.e2e.ts

* fix
2025-05-07 18:04:06 +05:30
Somay Chauhan 6143cd60e5 feat: Add booking references API endpoints (#21127)
* feat: Add booking references API endpoints

* refactor: Implement booking-reference service and repositories

* Add description and reorder properties in BookingReference schema

* Refactor booking references service to separate output service
2025-05-07 12:30:53 +00:00
Eunjae Lee ac821f6b13 chore: convert booking_denormalized e2e test to integration test (#21148) 2025-05-07 14:23:25 +02:00
sean-brydon 2bce38fd2a feat(v3): table designs (#21147) 2025-05-07 09:54:36 +00:00
Hariom BalharaandOmar López 390ee79134 Reduce payload size (#21093)
Co-authored-by: Omar López <zomars@me.com>
2025-05-07 03:16:07 +00:00
cfc9f0bfc3 refactor: Consolidate date parameter extraction and booking period check logic (#21142)
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2025-05-06 16:33:22 -07:00
Morgan 58d5f2a17c fix: advanced text filters getBookings with kysely (#21143) 2025-05-06 21:08:14 +00:00
Anwar Sadath 37f271bd74 fix: delete organization user endpoint not working in api v2 (#21136) 2025-05-06 19:10:55 +00:00
devin-ai-integration[bot]andkeith@cal.com d770cfc65e perf: Optimize team bookings query by fetching data for multiple users at once (#21137)
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-05-06 11:47:06 -07:00
Eunjae Leeandsean-brydon ab8556790e feat: add denormalized table for BookingTimeStatus view (#20947)
* feat: add denormalized table for BookingTimeStatus

* Update packages/prisma/migrations/20240320000001_add_booking_time_status_denormalized/migration.sql

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* add more indexes

* improvement

* update schema

* improve indexes

* apply feedback from mrge

* split update trigger for better perf

* remove unnecessary checks

* split migration into two

* less logging

* address feedback

* add updatedAt and userUsername

* remove timeStatus

* add deletion trigger

* clean up denorm table on row deletion at EventType or users

* add timeStatus back as a part of a new view

* keep user and eventType nullable

* rename triggers

* add tests

* clean up migrations

* update tests

* update tests

* update test clean up

* one more try

* similar updates

* use e2e test instead

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-05-06 20:34:45 +02:00
Hariom Balhara 3d89b37cc6 Adding already released embed version (#21033) 2025-05-06 15:19:47 +00:00
GitHub Actions 78b213321c chore: release v5.2.11
Post release / release (push) Canceled after 0s
2025-05-06 14:43:34 +00:00
Carina WollendorferandCarinaWolli 276ba1179f fix: improvements for scheduling Email reminders (#21129)
* improvements cron job

* improvements emailReminderManager

* order bookings for reminders

* fix cleaning up past cancelled reminders

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-05-06 15:23:48 +01:00
Hariom BalharaandAnik Dhabal Babu 0109d166a1 Update delegation-credential.md (#21124)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-05-06 13:29:13 +00:00
Morgan 779c014256 fix: get bookings advanced email and attendee filter (#21125)
* fix: get bookings advanced email and attendee filter

* bump libraries

* bump libraries
2025-05-06 09:28:57 +01:00
sean-brydonandUdit Takkar 073faf9e10 feat: Salesforce Event Type App v3 designs (#21100)
* section component

* more section UI

* wip

* core components styled

* all components using sections

* match icons to figma

* small inputs

* fix select small to be the same as small text

* center flex rows

* enforce inputs to be full spanning grid

* tidy up section container for single inputs

* fix select and i18n

* wip

* install available apps

* raw heading

* app install card

* fix app logos width x height

* fix gap

* match design for other button

* fix prop name

* push i18n string

* fix doc snippets

* fix docs

* chore: remove yarn.lock changes

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2025-05-06 08:21:20 +00:00
GitHub Actions d0e39cf2dd chore: release v5.2.10
Post release / release (push) Canceled after 0s
2025-05-06 02:50:01 +00:00
Benny Joo f9488a1866 fix: theme search param crashing booking page (#21120)
* fix

* fix
2025-05-06 00:31:40 +00:00
MorganandKeith Williams d4a3894a9e chore: get bookings with kysely (#21084)
* chore: get bookings with kysely

* fixup! chore: get bookings with kysely

* replace left joins with inner joins

* add missing filter and count with kysely

* main query done

* kysely use connection string

* fix attendee emails operator

* fix immutability and add attendee email filter

* chore: everything working but date filters

* limit offset

* chore: bump platform libraries

* fix bookingseat / attendee user email filter

* refactor, clean, and subquery updatedAt/createdAt

* fix import kysely types path

* fix missing references and payments fields

* create kysely module in apiv2

* fix before created at filter

* fix types

* update yarn.lock

* update yarn.lock

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-05-05 17:21:15 -07:00
Omar López 91288b41cd refactor: centralize feature flag operations in FeaturesRepository (#21032) 2025-05-05 19:06:41 -04:00
275 changed files with 11930 additions and 2145 deletions
+6 -6
View File
@@ -159,13 +159,16 @@ SENDGRID_EMAIL=
NEXT_PUBLIC_SENDGRID_SENDER_NAME=
# Sentry
SENTRY_ORG=
SENTRY_PROJECT=
SENTRY_AUTH_TOKEN=
# Used for capturing exceptions and logging messages
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_DEBUG=
SENTRY_DISABLE_CLIENT_SOURCE_MAPS=
SENTRY_DISABLE_SERVER_SOURCE_MAPS=
SENTRY_MAX_SPANS=
SENTRY_TRACES_SAMPLE_RATE=
SENTRY_REPLAYS_SESSION_SAMPLE_RATE=
SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=
# Formbricks Experience Management Integration
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
@@ -363,9 +366,6 @@ E2E_TEST_OIDC_USER_PASSWORD=
# ***********************************************************************************************************
# disable setry server source maps
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
# api v2
NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2"
@@ -425,7 +425,7 @@ DIRECTORY_IDS_TO_LOG=
# Read more about it in the README.md
NEXT_PUBLIC_SINGLE_ORG_SLUG=
AKISMET_API_KEY=
IFFY_API_KEY=
## Env variables related to avoiding booking failures
# Request for checking reservation would be attempted to send every these seconds if the request is stale at that time
+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
View File
@@ -0,0 +1,2 @@
undecided:
- calcom-monorepo
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@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"
@@ -0,0 +1,37 @@
/**
* Constructs the WHERE clause for Prisma booking findMany operation.
*
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
*
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
*/
export function buildWhereClause(userId: number | null, attendeeEmails: string[], userIds: number[] = []) {
const filterByAttendeeEmails = attendeeEmails.length > 0;
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
let whereClause = {};
if (filterByAttendeeEmails) {
whereClause = {
AND: [
userFilter,
{
attendees: {
some: {
email: { in: attendeeEmails },
},
},
},
],
};
} else {
whereClause = {
...userFilter,
};
}
return whereClause;
}
+79 -57
View File
@@ -1,4 +1,4 @@
import type { Prisma } from "@prisma/client";
import type { Prisma, Booking } from "@prisma/client";
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
@@ -6,6 +6,7 @@ import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { withMiddleware } from "~/lib/helpers/withMiddleware";
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
import {
getAccessibleUsers,
retrieveOrgScopedAccessibleUsers,
@@ -151,58 +152,6 @@ type GetAdminArgsType = {
userId: number;
};
/**
* Constructs the WHERE clause for Prisma booking findMany operation.
*
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
*
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
*/
function buildWhereClause(
userId: number | null,
attendeeEmails: string[],
userIds: number[] = [],
userEmails: string[] = []
) {
const filterByAttendeeEmails = attendeeEmails.length > 0;
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
let whereClause = {};
if (filterByAttendeeEmails) {
whereClause = {
AND: [
userFilter,
{
attendees: {
some: {
email: { in: attendeeEmails },
},
},
},
],
};
} else {
whereClause = {
OR: [
userFilter,
{
attendees: {
some: {
email: { in: userEmails },
},
},
},
],
};
}
return whereClause;
}
export async function handler(req: NextApiRequest) {
const {
userId,
@@ -238,6 +187,7 @@ export async function handler(req: NextApiRequest) {
? [queryFilterForAttendeeEmails.attendeeEmail]
: [];
const filterByAttendeeEmails = attendeeEmails.length > 0;
let userEmailsToFilterBy: string[] = [];
/** Only admins can query other users */
if (isSystemWideAdmin) {
@@ -251,7 +201,8 @@ export async function handler(req: NextApiRequest) {
userId,
};
const { userId: argUserId, userIds, userEmails } = await handleSystemWideAdminArgs(systemWideAdminArgs);
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
userEmailsToFilterBy = userEmails;
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
}
} else if (isOrganizationOwnerOrAdmin) {
let requestedUserIds = [userId];
@@ -265,7 +216,8 @@ export async function handler(req: NextApiRequest) {
userId,
};
const { userId: argUserId, userIds, userEmails } = await handleOrgWideAdminArgs(orgWideAdminArgs);
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
userEmailsToFilterBy = userEmails;
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
} else {
const user = await prisma.user.findUnique({
where: { id: userId },
@@ -276,7 +228,7 @@ export async function handler(req: NextApiRequest) {
if (!user) {
throw new HttpError({ message: "User not found", statusCode: 404 });
}
args.where = buildWhereClause(userId, attendeeEmails, [], []);
args.where = buildWhereClause(userId, attendeeEmails, []);
}
if (dateFrom) {
@@ -317,7 +269,77 @@ export async function handler(req: NextApiRequest) {
}
}
const data = await prisma.booking.findMany(args);
let data: Booking[] = [];
if (!filterByAttendeeEmails && userEmailsToFilterBy.length > 0) {
const queryOne = prisma.booking.findMany(args);
const whereClauseForQueryTwo: Prisma.BookingWhereInput = {
attendees: {
some: {
email: { in: userEmailsToFilterBy },
},
},
};
if (dateFrom) {
whereClauseForQueryTwo.startTime = { gte: dateFrom };
}
if (dateTo) {
whereClauseForQueryTwo.endTime = { lte: dateTo };
}
if (status === "upcoming") {
whereClauseForQueryTwo.startTime = { gte: new Date().toISOString() };
}
const argsForQueryTwo: Prisma.BookingFindManyArgs = {
...args,
where: whereClauseForQueryTwo,
};
const queryTwo = prisma.booking.findMany(argsForQueryTwo);
const [resultOne, resultTwo] = await Promise.all([queryOne, queryTwo]);
const bookingMap = new Map();
[...resultOne, ...resultTwo].forEach((booking) => {
bookingMap.set(booking.id, booking);
});
const dedupedResults = Array.from(bookingMap.values());
if (args.orderBy) {
let sortField: keyof Booking;
let sortDirection: "asc" | "desc" = "asc";
if (typeof args.orderBy === "object" && !Array.isArray(args.orderBy)) {
const orderByKey = Object.keys(args.orderBy)[0] as keyof typeof args.orderBy;
sortField = orderByKey as keyof Booking;
sortDirection = args.orderBy[orderByKey] as "asc" | "desc";
} else {
sortField = "id";
}
const sortOrder = sortDirection === "desc" ? -1 : 1;
dedupedResults.sort((a, b) => {
const aValue = a[sortField];
const bValue = b[sortField];
if (aValue < bValue) return -1 * sortOrder;
if (aValue > bValue) return 1 * sortOrder;
return 0;
});
}
if (args.take !== undefined && args.skip !== undefined) {
data = dedupedResults.slice(args.skip, args.skip + args.take);
} else {
data = dedupedResults;
}
} else {
data = await prisma.booking.findMany(args);
}
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
}
@@ -2,6 +2,7 @@ import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, it } from "vitest";
import { ZodError } from "zod";
import prisma from "@calcom/prisma";
@@ -206,8 +207,155 @@ describe("GET /api/bookings", async () => {
console.log("bookings=>", responseData.bookings);
responseData.bookings.forEach((booking) => {
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
if (booking.id === 19) expect(booking.eventType?.team).toBe(null);
if (booking.id === 19) {
// The team field can be either null or undefined due to nullish() in the schema
expect(booking.eventType?.team === null || booking.eventType?.team === undefined).toBe(true);
}
});
});
});
describe("Date filtering", () => {
it("filters bookings by dateFrom", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
dateFrom: "2023-01-01T00:00:00Z",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
responseData.bookings.forEach((booking) => {
expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(
new Date("2023-01-01T00:00:00Z").getTime()
);
});
});
it("filters bookings by dateTo", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
dateTo: "2024-12-31T23:59:59Z",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
responseData.bookings.forEach((booking) => {
expect(new Date(booking.endTime).getTime()).toBeLessThanOrEqual(
new Date("2024-12-31T23:59:59Z").getTime()
);
});
});
});
describe("Sorting and ordering", () => {
it("sorts bookings by createdAt in descending order", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
sortBy: "createdAt",
order: "desc",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
const timestamps = responseData.bookings.map((b) => new Date(b.createdAt).getTime());
expect(timestamps).toEqual([...timestamps].sort((a, b) => b - a));
});
});
describe("Multiple attendee email filtering", () => {
it("filters bookings by multiple attendee emails", async () => {
const attendeeEmails = ["test1@example.com", "test2@example.com"];
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
attendeeEmail: attendeeEmails,
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
responseData.bookings.forEach((booking) => {
const bookingAttendeeEmails = booking.attendees?.map((a) => a.email);
expect(bookingAttendeeEmails?.some((email) => attendeeEmails.includes(email))).toBe(true);
});
});
});
describe("Error cases", () => {
it("throws error for invalid status parameter", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
status: "invalid_status",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
await expect(handler(req)).rejects.toThrow(ZodError);
});
});
describe("Result merging", () => {
it("does not return duplicate bookings when merging results from multiple queries", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
const testUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const testUserBooking = await prisma.booking.findFirstOrThrow({
where: { userId: testUser.id },
include: { attendees: true },
});
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
userId: testUser.id, // This will make userEmailsToFilterBy contain the test user's email
},
pagination: {
take: 100,
skip: 0,
},
});
req.isSystemWideAdmin = true;
req.userId = adminUser.id;
const responseData = await handler(req);
const bookingIds = responseData.bookings.map((booking) => booking.id);
const uniqueBookingIds = new Set(bookingIds);
expect(uniqueBookingIds.size).toBe(bookingIds.length);
if (uniqueBookingIds.size !== bookingIds.length) {
const counts = bookingIds.reduce((acc, id) => {
acc[id] = (acc[id] || 0) + 1;
return acc;
}, {} as Record<number, number>);
const duplicates = Object.entries(counts)
.filter(([_, count]) => count > 1)
.map(([id]) => id);
console.log(`Found duplicate booking IDs: ${duplicates.join(", ")}`);
}
});
});
});
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
describe("buildWhereClause", () => {
it("should return empty object when no filters are provided", () => {
const result = buildWhereClause(null, [], []);
expect(result).toEqual({});
});
it("should filter by userId when only userId is provided", () => {
const userId = 123;
const result = buildWhereClause(userId, [], []);
expect(result).toEqual({ userId: 123 });
});
it("should filter by userIds when userIds array is provided", () => {
const userIds = [1, 2, 3];
const result = buildWhereClause(null, [], userIds);
expect(result).toEqual({ userId: { in: [1, 2, 3] } });
});
it("should filter by attendee emails when provided", () => {
const attendeeEmails = ["test@example.com", "user@example.com"];
const result = buildWhereClause(null, attendeeEmails, []);
expect(result).toEqual({
AND: [
{},
{
attendees: {
some: {
email: { in: ["test@example.com", "user@example.com"] },
},
},
},
],
});
});
it("should combine userId and attendee email filters", () => {
const userId = 123;
const attendeeEmails = ["test@example.com"];
const result = buildWhereClause(userId, attendeeEmails, []);
expect(result).toEqual({
AND: [
{ userId: 123 },
{
attendees: {
some: {
email: { in: ["test@example.com"] },
},
},
},
],
});
});
it("should combine userIds and attendee email filters", () => {
const userIds = [1, 2, 3];
const attendeeEmails = ["test@example.com"];
const result = buildWhereClause(null, attendeeEmails, userIds);
expect(result).toEqual({
AND: [
{ userId: { in: [1, 2, 3] } },
{
attendees: {
some: {
email: { in: ["test@example.com"] },
},
},
},
],
});
});
});
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.185",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.197",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -9,6 +9,7 @@ import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { BillingModule } from "@/modules/billing/billing.module";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { KyselyModule } from "@/modules/kysely/kysely.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
@Module({
imports: [
PrismaModule,
KyselyModule,
RedisModule,
TokensModule,
BillingModule,
@@ -13,6 +13,7 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { BillingService } from "@/modules/billing/services/billing.service";
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
@@ -99,6 +100,7 @@ export class BookingsController_2024_04_15 {
constructor(
private readonly oAuthFlowService: OAuthFlowService,
private readonly prismaReadService: PrismaReadService,
private readonly kyselyReadService: KyselyReadService,
private readonly oAuthClientRepository: OAuthClientRepository,
private readonly billingService: BillingService,
private readonly config: ConfigService,
@@ -129,6 +131,7 @@ export class BookingsController_2024_04_15 {
ctx: {
user: { email: user.email, id: user.id, orgId: profile?.organizationId },
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
kysely: this.kyselyReadService.kysely,
},
});
@@ -0,0 +1,27 @@
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
@Injectable()
export class BookingReferencesRepository_2024_08_13 {
constructor(private readonly dbRead: PrismaReadService) {}
async getBookingReferences(bookingId: number, filter?: BookingReferencesFilterInput_2024_08_13) {
const whereClause: Prisma.BookingReferenceWhereInput = { bookingId };
if (filter?.type) {
whereClause.type = filter.type;
}
return this.dbRead.prisma.bookingReference.findMany({
where: whereClause,
select: {
type: true,
uid: true,
id: true,
externalCalendarId: true,
},
});
}
}
@@ -1,8 +1,11 @@
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
@@ -17,6 +20,7 @@ import { BillingModule } from "@/modules/billing/billing.module";
import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { KyselyModule } from "@/modules/kysely/kysely.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
@@ -36,6 +40,7 @@ import { Module } from "@nestjs/common";
@Module({
imports: [
PrismaModule,
KyselyModule,
RedisModule,
TokensModule,
BillingModule,
@@ -56,6 +61,7 @@ import { Module } from "@nestjs/common";
BookingsService_2024_08_13,
InputBookingsService_2024_08_13,
OutputBookingsService_2024_08_13,
OutputBookingReferencesService_2024_08_13,
BookingsRepository_2024_08_13,
EventTypesRepository_2024_06_14,
BookingSeatRepository,
@@ -69,6 +75,8 @@ import { Module } from "@nestjs/common";
OrganizationsTeamsRepository,
OrganizationsRepository,
ErrorsBookingsService_2024_08_13,
BookingReferencesService_2024_08_13,
BookingReferencesRepository_2024_08_13,
],
controllers: [BookingsController_2024_08_13],
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
@@ -1,15 +1,20 @@
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
import { CalendarLinksOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UsersService } from "@/modules/users/services/users.service";
@@ -82,7 +87,8 @@ export class BookingsController_2024_08_13 {
constructor(
private readonly bookingsService: BookingsService_2024_08_13,
private readonly usersService: UsersService
private readonly usersService: UsersService,
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
) {}
@Post("/")
@@ -386,4 +392,30 @@ export class BookingsController_2024_08_13 {
data: calendarLinks,
};
}
@Get("/:bookingUid/references")
@PlatformPlan("SCALE")
@UseGuards(ApiAuthGuard, BookingUidGuard)
@Permissions([BOOKING_READ])
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get 'Booking References' for a booking",
})
@HttpCode(HttpStatus.OK)
async getBookingReferences(
@Param("bookingUid") bookingUid: string,
@GetUser("id") userId: number,
@Query() filter: BookingReferencesFilterInput_2024_08_13
): Promise<BookingReferencesOutput_2024_08_13> {
const bookingReferences = await this.bookingReferencesService.getBookingReferences(
bookingUid,
userId,
filter
);
return {
status: SUCCESS_STATUS,
data: bookingReferences,
};
}
}
@@ -0,0 +1,23 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsIn, IsOptional } from "class-validator";
export const BookingReferences = [
"google_calendar",
"office365_calendar",
"daily_video",
"google_video",
"office365_video",
"zoom_video",
] as const;
export class BookingReferencesFilterInput_2024_08_13 {
@ApiProperty({
description: "Filter booking references by type",
required: false,
enum: BookingReferences,
example: "google_calendar",
})
@IsOptional()
@IsIn(BookingReferences)
type?: (typeof BookingReferences)[number];
}
@@ -0,0 +1,46 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsNumber, IsString } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class BookingReference {
@IsString()
@ApiProperty({
description: "The type of the booking reference",
})
type!: string;
@IsString()
@ApiProperty({
description: "The event uid of the booking",
})
eventUid!: string;
@IsString()
@ApiProperty({
description: "The id of the calendar the event is created in",
nullable: true,
})
destinationCalendarId!: string | null;
@IsNumber()
@ApiProperty({
description: "The id of the booking reference",
})
id!: number;
}
export class BookingReferencesOutput_2024_08_13 {
@ApiProperty({
description: "The status of the request, always 'success' for successful responses",
example: SUCCESS_STATUS,
})
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
description: "Booking References",
type: [BookingReference],
})
data!: BookingReference[];
}
@@ -0,0 +1,46 @@
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
@Injectable()
export class BookingReferencesService_2024_08_13 {
constructor(
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly bookingReferencesRepository: BookingReferencesRepository_2024_08_13,
private readonly outputBookingReferencesService: OutputBookingReferencesService_2024_08_13
) {}
async getBookingReferences(
bookingUid: string,
userId: number,
filter?: BookingReferencesFilterInput_2024_08_13
) {
const booking = await this.bookingsRepository.getByUidWithUser(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
}
if (booking.user?.id !== userId) {
throw new BadRequestException(`Booking with uid ${bookingUid} does not belong to user`);
}
const bookingReferences = await this.bookingReferencesRepository.getBookingReferences(booking.id, filter);
return this.outputBookingReferencesService.getOutputBookingReferences(bookingReferences);
}
async getOrgBookingReferences(bookingUid: string, filter?: BookingReferencesFilterInput_2024_08_13) {
const booking = await this.bookingsRepository.getByUidWithUser(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
}
const bookingReferences = await this.bookingReferencesRepository.getBookingReferences(booking.id, filter);
return this.outputBookingReferencesService.getOutputBookingReferences(bookingReferences);
}
}
@@ -7,6 +7,7 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { BillingService } from "@/modules/billing/services/billing.service";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
@@ -78,6 +79,7 @@ export class BookingsService_2024_08_13 {
private readonly bookingSeatRepository: BookingSeatRepository,
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly prismaReadService: PrismaReadService,
private readonly kyselyReadService: KyselyReadService,
private readonly billingService: BillingService,
private readonly usersService: UsersService,
private readonly usersRepository: UsersRepository,
@@ -385,6 +387,7 @@ export class BookingsService_2024_08_13 {
ctx: {
user,
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
kysely: this.kyselyReadService.kysely,
},
sort: this.inputService.transformGetBookingsSort(queryParams),
});
@@ -0,0 +1,20 @@
import { Injectable } from "@nestjs/common";
interface IBookingReference {
type: string;
uid: string;
id: number;
externalCalendarId: string | null;
}
@Injectable()
export class OutputBookingReferencesService_2024_08_13 {
getOutputBookingReferences(bookingReferences: IBookingReference[]) {
return bookingReferences.map((bookingReference) => ({
type: bookingReference.type,
eventUid: bookingReference.uid,
id: bookingReference.id,
destinationCalendarId: bookingReference?.externalCalendarId,
}));
}
}
@@ -22,7 +22,7 @@ import {
RecurringBookingOutput_2024_08_13,
SeatedAttendee,
} from "@calcom/platform-types";
import { Booking, BookingSeat } from "@calcom/prisma/client";
import { Booking, BookingReference, BookingSeat } from "@calcom/prisma/client";
export const bookingResponsesSchema = z
.object({
@@ -0,0 +1,36 @@
import type { OnModuleDestroy } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { DeduplicateJoinsPlugin, Kysely, ParseJSONResultsPlugin, PostgresDialect } from "kysely";
import { Pool } from "pg";
import type { DB } from "@calcom/kysely/types";
@Injectable()
export class KyselyReadService implements OnModuleDestroy {
private logger = new Logger("KyselyReadService");
public kysely: Kysely<DB>;
constructor(readonly configService: ConfigService) {
const dbUrl = configService.get("db.readUrl", { infer: true });
const pool = new Pool({ connectionString: dbUrl });
// 3. Create the Dialect, passing the configured pool instance
const dialect = new PostgresDialect({
pool: pool, // Use the pool instance created above
});
this.kysely = new Kysely<DB>({
dialect,
plugins: [new ParseJSONResultsPlugin(), new DeduplicateJoinsPlugin()],
});
}
async onModuleDestroy() {
try {
await this.kysely.destroy();
} catch (error) {
this.logger.error(error);
}
}
}
@@ -0,0 +1,36 @@
import type { OnModuleDestroy } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Kysely, ParseJSONResultsPlugin, PostgresDialect, DeduplicateJoinsPlugin } from "kysely";
import { Pool } from "pg";
import type { DB } from "@calcom/kysely/types";
@Injectable()
export class KyselyWriteService implements OnModuleDestroy {
private logger = new Logger("KyselyWriteService");
public kysely: Kysely<DB>;
constructor(readonly configService: ConfigService) {
const dbUrl = configService.get("db.writeUrl", { infer: true });
const pool = new Pool({ connectionString: dbUrl });
// 3. Create the Dialect, passing the configured pool instance
const dialect = new PostgresDialect({
pool: pool, // Use the pool instance created above
});
this.kysely = new Kysely<DB>({
dialect,
plugins: [new ParseJSONResultsPlugin(), new DeduplicateJoinsPlugin()],
});
}
async onModuleDestroy() {
try {
await this.kysely.destroy();
} catch (error) {
this.logger.error(error);
}
}
}
@@ -0,0 +1,9 @@
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
import { KyselyWriteService } from "@/modules/kysely/kysely-write.service";
import { Module } from "@nestjs/common";
@Module({
providers: [KyselyReadService, KyselyWriteService],
exports: [KyselyReadService, KyselyWriteService],
})
export class KyselyModule {}
@@ -6,6 +6,7 @@ import { CreateOrgMembershipOutput } from "@/modules/organizations/memberships/o
import { DeleteOrgMembership } from "@/modules/organizations/memberships/outputs/delete-membership.output";
import { GetAllOrgMemberships } from "@/modules/organizations/memberships/outputs/get-all-memberships.output";
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
import { OrgUserAttribute } from "@/modules/organizations/memberships/outputs/organization-membership.output";
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
@@ -14,17 +15,16 @@ 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 { Attribute, AttributeOption, User } from "@prisma/client";
import * as request from "supertest";
import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { 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 { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiSuccessResponse } from "@calcom/platform-types";
import { Membership, Team } from "@calcom/prisma/client";
describe("Organizations Memberships Endpoints", () => {
@@ -33,7 +33,8 @@ describe("Organizations Memberships Endpoints", () => {
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let attributesRepositoryFixture: AttributeRepositoryFixture;
let org: Team;
let membership: Membership;
@@ -49,6 +50,17 @@ describe("Organizations Memberships Endpoints", () => {
let userToInviteViaApi: User;
let textAttribute: Attribute;
let multiSelectAttribute: Attribute;
let numberAttribute: Attribute;
let singleSelectAttribute: Attribute;
let textAttributeOption: AttributeOption;
let multiSelectAttributeOption: AttributeOption;
let multiSelectAttributeOption2: AttributeOption;
let numberAttributeOption: AttributeOption;
let singleSelectAttributeOption: AttributeOption;
const metadata = {
some: "key",
};
@@ -64,7 +76,8 @@ describe("Organizations Memberships Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
attributesRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
@@ -91,16 +104,42 @@ describe("Organizations Memberships Endpoints", () => {
isOrganization: true,
});
membership = await membershipsRepositoryFixture.create({
await setupAttributes();
membership = await membershipRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
membership2 = await membershipsRepositoryFixture.create({
membership2 = await membershipRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user2.id } },
team: { connect: { id: org.id } },
AttributeToUser: {
create: [
{
attributeOption: { connect: { id: textAttributeOption.id } },
weight: 100,
},
{
attributeOption: { connect: { id: multiSelectAttributeOption.id } },
weight: 100,
},
{
attributeOption: { connect: { id: multiSelectAttributeOption2.id } },
weight: null,
},
{
attributeOption: { connect: { id: numberAttributeOption.id } },
weight: 100,
},
{
attributeOption: { connect: { id: singleSelectAttributeOption.id } },
weight: 100,
},
],
},
});
app = moduleRef.createNestApplication();
@@ -109,6 +148,87 @@ describe("Organizations Memberships Endpoints", () => {
await app.init();
});
async function setupAttributes() {
textAttribute = await attributesRepositoryFixture.create({
team: { connect: { id: org.id } },
type: "TEXT",
name: "team",
slug: `team-${randomString()}`,
enabled: true,
usersCanEditRelation: false,
isWeightsEnabled: false,
isLocked: false,
});
multiSelectAttribute = await attributesRepositoryFixture.create({
team: { connect: { id: org.id } },
type: "MULTI_SELECT",
name: "skills",
slug: `skills-${randomString()}`,
enabled: true,
usersCanEditRelation: false,
isWeightsEnabled: false,
isLocked: false,
});
numberAttribute = await attributesRepositoryFixture.create({
team: { connect: { id: org.id } },
type: "NUMBER",
name: "age",
slug: `age-${randomString()}`,
enabled: true,
usersCanEditRelation: false,
isWeightsEnabled: false,
isLocked: false,
});
singleSelectAttribute = await attributesRepositoryFixture.create({
team: { connect: { id: org.id } },
type: "SINGLE_SELECT",
name: "frontend",
slug: `frontend-${randomString()}`,
enabled: true,
usersCanEditRelation: false,
isWeightsEnabled: false,
isLocked: false,
});
textAttributeOption = await attributesRepositoryFixture.createOption({
attribute: { connect: { id: textAttribute.id } },
value: "coders",
slug: "coders",
isGroup: false,
});
multiSelectAttributeOption = await attributesRepositoryFixture.createOption({
attribute: { connect: { id: multiSelectAttribute.id } },
value: "javascript",
slug: "javascript",
isGroup: false,
});
multiSelectAttributeOption2 = await attributesRepositoryFixture.createOption({
attribute: { connect: { id: multiSelectAttribute.id } },
value: "typescript",
slug: "typescript",
isGroup: false,
});
numberAttributeOption = await attributesRepositoryFixture.createOption({
attribute: { connect: { id: numberAttribute.id } },
value: "18",
slug: "18",
isGroup: false,
});
singleSelectAttributeOption = await attributesRepositoryFixture.createOption({
attribute: { connect: { id: singleSelectAttribute.id } },
value: "yes",
slug: "yes",
isGroup: false,
});
}
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
@@ -139,9 +259,54 @@ describe("Organizations Memberships Endpoints", () => {
expect(responseBody.data[1].user.email).toEqual(user2.email);
expect(responseBody.data[1].user.username).toEqual(user2.username);
expect(responseBody.data[1].teamId).toEqual(org.id);
userHasCorrectAttributes(responseBody.data[1].attributes);
});
});
function userHasCorrectAttributes(attributes: OrgUserAttribute[]) {
expect(attributes.length).toEqual(4);
const responseNumberAttribute = attributes.find((attr) => attr.type === "number");
const responseSingleSelectAttribute = attributes.find((attr) => attr.type === "singleSelect");
const responseMultiSelectAttribute = attributes.find((attr) => attr.type === "multiSelect");
const responseTextAttribute = attributes.find((attr) => attr.type === "text");
expect(responseNumberAttribute).toEqual({
id: numberAttribute.id,
name: numberAttribute.name,
optionId: numberAttributeOption.id,
option: +numberAttributeOption.value,
type: "number",
});
expect(responseSingleSelectAttribute).toEqual({
id: singleSelectAttribute.id,
name: singleSelectAttribute.name,
optionId: singleSelectAttributeOption.id,
option: singleSelectAttributeOption.value,
type: "singleSelect",
});
expect(responseMultiSelectAttribute).toEqual({
id: multiSelectAttribute.id,
name: multiSelectAttribute.name,
options: [
{
optionId: multiSelectAttributeOption2.id,
option: multiSelectAttributeOption2.value,
},
{
optionId: multiSelectAttributeOption.id,
option: multiSelectAttributeOption.value,
},
],
type: "multiSelect",
});
expect(responseTextAttribute).toEqual({
id: textAttribute.id,
name: textAttribute.name,
optionId: textAttributeOption.id,
option: textAttributeOption.value,
type: "text",
});
}
it("should get all the memberships of the org paginated", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`)
@@ -182,6 +347,25 @@ describe("Organizations Memberships Endpoints", () => {
});
});
it("should get the membership of the org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/memberships/${membership2.id}`)
.expect(200)
.then((response) => {
const responseBody: GetOrgMembership = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membership2.id);
expect(responseBody.data.userId).toEqual(user2.id);
expect(responseBody.data.role).toEqual("MEMBER");
expect(responseBody.data.user.bio).toEqual(bio);
expect(responseBody.data.user.metadata).toEqual(metadata);
expect(responseBody.data.user.email).toEqual(user2.email);
expect(responseBody.data.user.username).toEqual(user2.username);
expect(responseBody.data.teamId).toEqual(org.id);
userHasCorrectAttributes(responseBody.data.attributes);
});
});
it("should create the membership of the org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/memberships`)
@@ -259,7 +443,8 @@ describe("Organizations Memberships Endpoints", () => {
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let attributesRepositoryFixture: AttributeRepositoryFixture;
let org: Team;
let membership: Membership;
@@ -277,7 +462,8 @@ describe("Organizations Memberships Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
attributesRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
@@ -289,7 +475,7 @@ describe("Organizations Memberships Endpoints", () => {
isOrganization: true,
});
membership = await membershipsRepositoryFixture.create({
membership = await membershipRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
@@ -20,7 +20,6 @@ import { GetAllOrgMemberships } from "@/modules/organizations/memberships/output
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import {
Controller,
UseGuards,
@@ -36,7 +35,6 @@ import {
HttpStatus,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
@@ -70,9 +68,7 @@ export class OrganizationsMembershipsController {
);
return {
status: SUCCESS_STATUS,
data: memberships.map((membership) =>
plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" })
),
data: memberships,
};
}
@@ -88,7 +84,7 @@ export class OrganizationsMembershipsController {
const membership = await this.organizationsMembershipService.createOrgMembership(orgId, body);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
data: membership,
};
}
@@ -105,7 +101,7 @@ export class OrganizationsMembershipsController {
const membership = await this.organizationsMembershipService.getOrgMembership(orgId, membershipId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
data: membership,
};
}
@@ -122,7 +118,7 @@ export class OrganizationsMembershipsController {
const membership = await this.organizationsMembershipService.deleteOrgMembership(orgId, membershipId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
data: membership,
};
}
@@ -144,7 +140,7 @@ export class OrganizationsMembershipsController {
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
data: membership,
};
}
}
@@ -4,8 +4,31 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { MembershipUserSelect } from "@/modules/teams/memberships/teams-memberships.repository";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@calcom/prisma/client";
import { UpdateOrgMembershipDto } from "./inputs/update-organization-membership.input";
export type DbOrgMembership = Awaited<ReturnType<OrganizationsMembershipRepository["findOrgMembership"]>>;
const attributeToUserSelect = {
createdByDSyncId: true,
weight: true,
attributeOption: {
select: {
id: true,
value: true,
slug: true,
attribute: {
select: {
id: true,
name: true,
type: true,
},
},
},
},
} as const satisfies Prisma.AttributeToUserSelect;
@Injectable()
export class OrganizationsMembershipRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
@@ -16,7 +39,12 @@ export class OrganizationsMembershipRepository {
id: membershipId,
teamId: organizationId,
},
include: { user: { select: MembershipUserSelect } },
include: {
user: { select: MembershipUserSelect },
AttributeToUser: {
select: attributeToUserSelect,
},
},
});
}
@@ -26,7 +54,12 @@ export class OrganizationsMembershipRepository {
teamId: organizationId,
userId,
},
include: { user: { select: MembershipUserSelect } },
include: {
user: { select: MembershipUserSelect },
AttributeToUser: {
select: attributeToUserSelect,
},
},
});
}
@@ -36,7 +69,12 @@ export class OrganizationsMembershipRepository {
id: membershipId,
teamId: organizationId,
},
include: { user: { select: MembershipUserSelect } },
include: {
user: { select: MembershipUserSelect },
AttributeToUser: {
select: attributeToUserSelect,
},
},
});
}
@@ -45,14 +83,24 @@ export class OrganizationsMembershipRepository {
create: { ...data, teamId: organizationId },
update: { role: data.role, accepted: data.accepted, disableImpersonation: data.disableImpersonation },
where: { userId_teamId: { userId: data.userId, teamId: organizationId } },
include: { user: { select: MembershipUserSelect } },
include: {
user: { select: MembershipUserSelect },
AttributeToUser: {
select: attributeToUserSelect,
},
},
});
}
async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) {
return this.dbWrite.prisma.membership.update({
data: { ...data },
where: { id: membershipId, teamId: organizationId },
include: { user: { select: MembershipUserSelect } },
include: {
user: { select: MembershipUserSelect },
AttributeToUser: {
select: attributeToUserSelect,
},
},
});
}
@@ -61,7 +109,12 @@ export class OrganizationsMembershipRepository {
where: {
teamId: organizationId,
},
include: { user: { select: MembershipUserSelect } },
include: {
user: { select: MembershipUserSelect },
AttributeToUser: {
select: attributeToUserSelect,
},
},
skip,
take,
});
@@ -1,4 +1,4 @@
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
@@ -11,10 +11,10 @@ export class CreateOrgMembershipOutput {
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamMembershipOutput,
type: OrganizationMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput;
@Type(() => OrganizationMembershipOutput)
data!: OrganizationMembershipOutput;
}
@@ -1,4 +1,4 @@
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
@@ -9,5 +9,5 @@ export class DeleteOrgMembership {
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
data!: TeamMembershipOutput;
data!: OrganizationMembershipOutput;
}
@@ -1,4 +1,4 @@
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested, IsArray } from "class-validator";
@@ -11,11 +11,11 @@ export class GetAllOrgMemberships {
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamMembershipOutput,
type: OrganizationMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested({ each: true })
@Type(() => TeamMembershipOutput)
@Type(() => OrganizationMembershipOutput)
@IsArray()
data!: TeamMembershipOutput[];
data!: OrganizationMembershipOutput[];
}
@@ -1,4 +1,4 @@
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
@@ -11,10 +11,10 @@ export class GetOrgMembership {
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamMembershipOutput,
type: OrganizationMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput;
@Type(() => OrganizationMembershipOutput)
data!: OrganizationMembershipOutput;
}
@@ -0,0 +1,114 @@
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsArray, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
class BaseAttribute {
@IsString()
@Expose()
@ApiProperty()
id!: string;
@IsString()
@Expose()
@ApiProperty()
name!: string;
}
export class TextAttribute extends BaseAttribute {
@IsString()
@Expose()
@ApiProperty()
type!: "text";
@IsString()
@Expose()
@ApiProperty()
option!: string;
@IsString()
@Expose()
@ApiProperty()
optionId!: string;
}
export class NumberAttribute extends BaseAttribute {
@IsString()
@Expose()
@ApiProperty()
type!: "number";
@IsNumber()
@Expose()
@ApiProperty()
option!: number;
@IsString()
@Expose()
@ApiProperty()
optionId!: string;
}
export class SingleSelectAttribute extends BaseAttribute {
@IsString()
@IsString()
@Expose()
@ApiProperty()
type!: "singleSelect";
@IsString()
@Expose()
@ApiProperty()
option!: string;
@IsString()
@Expose()
@ApiProperty()
optionId!: string;
}
class MultiSelectAttributeOption {
@IsString()
@Expose()
@ApiProperty()
optionId!: string;
@IsString()
@Expose()
@ApiProperty()
option!: string;
}
export class MultiSelectAttribute extends BaseAttribute {
@IsString()
@Expose()
@ApiProperty()
type!: "multiSelect";
@IsArray()
@ValidateNested({ each: true })
@Expose()
@ApiProperty()
options!: MultiSelectAttributeOption[];
}
export type OrgUserAttribute = TextAttribute | NumberAttribute | SingleSelectAttribute | MultiSelectAttribute;
@ApiExtraModels(BaseAttribute, TextAttribute, NumberAttribute, SingleSelectAttribute, MultiSelectAttribute)
export class OrganizationMembershipOutput extends TeamMembershipOutput {
@IsArray()
@ValidateNested({ each: true })
@Expose()
@ApiProperty({
required: true,
oneOf: [
{ $ref: getSchemaPath(TextAttribute) },
{ $ref: getSchemaPath(NumberAttribute) },
{ $ref: getSchemaPath(SingleSelectAttribute) },
{ $ref: getSchemaPath(MultiSelectAttribute) },
],
type: "array",
})
@Type(() => Object)
attributes!: OrgUserAttribute[];
}
@@ -1,4 +1,4 @@
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
@@ -11,10 +11,10 @@ export class UpdateOrgMembership {
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamMembershipOutput,
type: OrganizationMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput;
@Type(() => OrganizationMembershipOutput)
data!: OrganizationMembershipOutput;
}
@@ -0,0 +1,89 @@
import { DbOrgMembership } from "@/modules/organizations/memberships/organizations-membership.repository";
import {
MultiSelectAttribute,
NumberAttribute,
OrganizationMembershipOutput,
OrgUserAttribute,
SingleSelectAttribute,
TextAttribute,
} from "@/modules/organizations/memberships/outputs/organization-membership.output";
import { Injectable } from "@nestjs/common";
import { plainToClass } from "class-transformer";
import { groupMembershipAttributes } from "@calcom/platform-libraries";
import type { GroupedAttribute } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationsMembershipOutputService {
getOrgMembershipsOutput(memberships: NonNullable<DbOrgMembership>[]) {
return memberships.map((membership) => this.getOrgMembershipOutput(membership));
}
getOrgMembershipOutput(organizationMembership: NonNullable<DbOrgMembership>) {
const { AttributeToUser, ...orgMembership } = organizationMembership;
const groupedMembershipAttributes: GroupedAttribute[] = groupMembershipAttributes(AttributeToUser);
const attributesOutput = this.getAttributesOutput(groupedMembershipAttributes);
return plainToClass(OrganizationMembershipOutput, { ...orgMembership, attributes: attributesOutput });
}
private getAttributesOutput(attributes: GroupedAttribute[]): OrgUserAttribute[] {
return attributes.map((attribute) => {
switch (attribute.type) {
case "TEXT":
return this.getTextAttributeOutput(attribute);
case "NUMBER":
return this.getNumberAttributeOutput(attribute);
case "SINGLE_SELECT":
return this.getSingleSelectAttributeOutput(attribute);
case "MULTI_SELECT":
return this.getMultiSelectAttributeOutput(attribute);
default:
return this.getTextAttributeOutput(attribute);
}
});
}
private getTextAttributeOutput(attribute: GroupedAttribute): TextAttribute {
return {
id: attribute.id,
name: attribute.name,
optionId: attribute.options[0].id,
option: attribute.options[0].value,
type: "text",
};
}
private getNumberAttributeOutput(attribute: GroupedAttribute): NumberAttribute {
return {
id: attribute.id,
name: attribute.name,
optionId: attribute.options[0].id,
option: +attribute.options[0].value,
type: "number",
};
}
private getSingleSelectAttributeOutput(attribute: GroupedAttribute): SingleSelectAttribute {
return {
id: attribute.id,
name: attribute.name,
optionId: attribute.options[0].id,
option: attribute.options[0].value,
type: "singleSelect",
};
}
private getMultiSelectAttributeOutput(attribute: GroupedAttribute): MultiSelectAttribute {
return {
id: attribute.id,
name: attribute.name,
options: attribute.options.map((option: GroupedAttribute["options"][number]) => ({
optionId: option.id,
option: option.value,
})),
type: "multiSelect",
};
}
}
@@ -1,19 +1,30 @@
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import { Injectable } from "@nestjs/common";
import { Injectable, NotFoundException } from "@nestjs/common";
import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service";
@Injectable()
export class OrganizationsMembershipService {
constructor(private readonly organizationsMembershipRepository: OrganizationsMembershipRepository) {}
constructor(
private readonly organizationsMembershipRepository: OrganizationsMembershipRepository,
private readonly organizationsMembershipOutputService: OrganizationsMembershipOutputService
) {}
async getOrgMembership(organizationId: number, membershipId: number) {
const membership = await this.organizationsMembershipRepository.findOrgMembership(
organizationId,
membershipId
);
return membership;
if (!membership) {
throw new NotFoundException(
`Membership with id ${membershipId} within organization id ${organizationId} not found`
);
}
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
async getOrgMembershipByUserId(organizationId: number, userId: number) {
@@ -21,7 +32,13 @@ export class OrganizationsMembershipService {
organizationId,
userId
);
return membership;
if (!membership) {
throw new NotFoundException(
`Membership for user with id ${userId} within organization id ${organizationId} not found`
);
}
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
async getPaginatedOrgMemberships(organizationId: number, skip = 0, take = 250) {
@@ -30,7 +47,7 @@ export class OrganizationsMembershipService {
skip,
take
);
return memberships;
return this.organizationsMembershipOutputService.getOrgMembershipsOutput(memberships);
}
async deleteOrgMembership(organizationId: number, membershipId: number) {
@@ -38,7 +55,7 @@ export class OrganizationsMembershipService {
organizationId,
membershipId
);
return membership;
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) {
@@ -47,11 +64,11 @@ export class OrganizationsMembershipService {
membershipId,
data
);
return membership;
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
async createOrgMembership(organizationId: number, data: CreateOrgMembershipDto) {
const membership = await this.organizationsMembershipRepository.createOrgMembership(organizationId, data);
return membership;
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
}
@@ -32,6 +32,7 @@ import { OrganizationsRepository } from "@/modules/organizations/index/organizat
import { OrganizationsService } from "@/modules/organizations/index/organizations.service";
import { OrganizationsMembershipsController } from "@/modules/organizations/memberships/organizations-membership.controller";
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
@@ -98,6 +99,7 @@ import { Module } from "@nestjs/common";
EmailService,
OrganizationsMembershipRepository,
OrganizationsMembershipService,
OrganizationsMembershipOutputService,
OrganizationsEventTypesService,
InputOrganizationsEventTypesService,
OutputOrganizationsEventTypesService,
@@ -4,6 +4,7 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
import { OrganizationsOrganizationsController } from "@/modules/organizations/organizations/organizations-organizations.controller";
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
ManagedOrganizationsBillingService,
OrganizationsRepository,
OrganizationsMembershipService,
OrganizationsMembershipOutputService,
OrganizationsMembershipRepository,
ManagedOrganizationsOutputService,
],
@@ -126,36 +126,6 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
await app.init();
});
afterAll(async () => {
await prismaWriteService.prisma.app_RoutingForms_FormResponse.deleteMany({
where: {
formId: routingForm.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.apiKey.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: team.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: org.id,
},
});
await app.close();
});
describe(`GET /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
it("should not get routing form responses for non existing org", async () => {
return request(app.getHttpServer())
@@ -261,4 +231,44 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
});
});
});
afterAll(async () => {
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
where: {
id: routingFormResponse.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
where: {
id: routingFormResponse2.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.apiKey.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: team.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: org.id,
},
});
await prismaWriteService.prisma.user.delete({
where: {
id: user.id,
},
});
await app.close();
});
});
@@ -1,13 +1,18 @@
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import {
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
OPTIONAL_API_KEY_HEADER,
API_KEY_OR_ACCESS_TOKEN_HEADER,
} from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.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";
@@ -20,7 +25,7 @@ import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SUCCESS_STATUS, BOOKING_READ } from "@calcom/platform-constants";
import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
@Controller({
@@ -33,7 +38,10 @@ import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsTeamsBookingsController {
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
constructor(
private readonly bookingsService: BookingsService_2024_08_13,
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
) {}
@Get("/")
@ApiOperation({ summary: "Get organization team bookings" })
@@ -56,4 +64,33 @@ export class OrganizationsTeamsBookingsController {
data: bookings,
};
}
@Get("/:bookingUid/references")
@PlatformPlan("SCALE")
@Roles("TEAM_ADMIN")
@Permissions([BOOKING_READ])
@UseGuards(
ApiAuthGuard,
BookingUidGuard,
IsOrgGuard,
RolesGuard,
IsTeamInOrg,
PlatformPlanGuard,
IsAdminAPIEnabledGuard
)
@ApiOperation({
summary: "Get 'Booking References' for a booking",
})
@HttpCode(HttpStatus.OK)
async getBookingReferences(
@Param("bookingUid") bookingUid: string,
@Query() filter: BookingReferencesFilterInput_2024_08_13
): Promise<BookingReferencesOutput_2024_08_13> {
const bookingReferences = await this.bookingReferencesService.getOrgBookingReferences(bookingUid, filter);
return {
status: SUCCESS_STATUS,
data: bookingReferences,
};
}
}
@@ -1,4 +1,8 @@
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsTeamsBookingsController } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.controller";
@@ -10,7 +14,14 @@ import { Module } from "@nestjs/common";
@Module({
imports: [BookingsModule_2024_08_13, PrismaModule, StripeModule, RedisModule, MembershipsModule],
providers: [OrganizationsRepository, OrganizationsTeamsRepository],
providers: [
OrganizationsRepository,
OrganizationsTeamsRepository,
BookingReferencesService_2024_08_13,
BookingReferencesRepository_2024_08_13,
BookingsRepository_2024_08_13,
OutputBookingReferencesService_2024_08_13,
],
controllers: [OrganizationsTeamsBookingsController],
})
export class OrganizationsTeamsBookingsModule {}
@@ -35,7 +35,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
let org: Team;
let orgTeam: Team;
let routingFormResponseId: string;
let routingFormResponseId: number;
const authEmail = `organizations-teams-routing-forms-responses-user-${randomString()}@api.com`;
let user: User;
let apiKeyString: string;
@@ -43,8 +43,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
let routingFormId: string;
const routingFormResponses = [
{
id: 1,
formFillerId: "cm78tvkvd0001kh8jq0tu5iq9",
formFillerId: `${randomString()}`,
response: {
"participant-field": {
label: "participant",
@@ -184,7 +183,6 @@ describe("Organizations Teams Routing Forms Responses", () => {
const responseData = responseBody.data;
expect(responseData).toBeDefined();
expect(responseData.length).toEqual(1);
expect(responseData[0].id).toEqual(routingFormResponses[0].id);
expect(responseData[0].response).toEqual(routingFormResponses[0].response);
expect(responseData[0].formFillerId).toEqual(routingFormResponses[0].formFillerId);
expect(responseData[0].createdAt).toEqual(routingFormResponses[0].createdAt.toISOString());
@@ -248,6 +246,8 @@ describe("Organizations Teams Routing Forms Responses", () => {
});
afterAll(async () => {
await routingFormsRepositoryFixture.deleteResponse(routingFormResponseId);
await routingFormsRepositoryFixture.delete(routingFormId);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
@@ -141,7 +141,16 @@ export class OrganizationsUsersRepository {
return await this.dbWrite.prisma.user.delete({
where: {
id: userId,
organizationId: orgId,
OR: [
{ organizationId: orgId },
{
profiles: {
some: {
organizationId: orgId,
},
},
},
],
},
include: {
profiles: {
@@ -4,6 +4,7 @@ import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { OrganizationsDelegationCredentialRepository } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.repository";
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsController } from "@/modules/selected-calendars/controllers/selected-calendars.controller";
@@ -22,6 +23,7 @@ import { Module } from "@nestjs/common";
CredentialsRepository,
AppsRepository,
OrganizationsMembershipService,
OrganizationsMembershipOutputService,
OrganizationsDelegationCredentialRepository,
OrganizationsMembershipRepository,
SelectedCalendarsService,
@@ -181,7 +181,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
title: "frisbee match",
slug: `slots-2024-09-04-variable-length-event-type-${randomString()}`,
length: 15,
metadata: { multipleDuration: [15, 30, 45, 60] },
metadata: { multipleDuration: [15, 30, 45, 60, 180] },
},
user.id
);
@@ -506,6 +506,65 @@ describe("Slots 2024-09-04 Endpoints", () => {
expect(reserveResponseBody.data).toEqual(rest);
});
describe("overlapping slot reservations", () => {
it("start of request slot overlaps already existing reserved slot", async () => {
// Try to reserve 10:15-11:15 when 10:00-11:00 is taken (60 min event)
const newSlotStart = DateTime.fromISO(reservedSlot.slotStart).plus({ minutes: 15 }).toISO();
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: newSlotStart,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422)
.then((response) => {
expect(response.body.error.message).toEqual(
"This time slot is already reserved by another user. Please choose a different time."
);
});
});
it("end of request slot overlaps already existing reserved slot", async () => {
// Try to reserve 9:45-10:45 when 10:00-11:00 is taken (60 min event)
const newSlotStart = DateTime.fromISO(reservedSlot.slotStart).minus({ minutes: 15 }).toISO();
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: newSlotStart,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422)
.then((response) => {
expect(response.body.error.message).toEqual(
"This time slot is already reserved by another user. Please choose a different time."
);
});
});
it("request slot is inside already existing reserved slot", async () => {
// Try to reserve 10:10-11:10 when 10:00-11:00 is taken (60 min event)
const newSlotStart = DateTime.fromISO(reservedSlot.slotStart).plus({ minutes: 10 }).toISO();
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: newSlotStart,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422)
.then((response) => {
expect(response.body.error.message).toEqual(
"This time slot is already reserved by another user. Please choose a different time."
);
});
});
});
it("should update a reserved slot and it should not appear in available slots", async () => {
// note(Lauris): mock current date to test slots release time
const now = "2049-09-05T14:00:00.000Z";
@@ -1266,6 +1325,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
});
describe("variable length", () => {
let responseReservedVariableSlot: ReserveSlotOutputData_2024_09_04;
it("should not be able to reserve a slot for variable length event type with invalid duration", async () => {
const slotStartTime = "2050-09-05T10:00:00.000Z";
const reserveResponse = await request(app.getHttpServer())
@@ -1279,7 +1339,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
.expect(400);
expect(reserveResponse.body.error.message).toEqual(
"Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: 15, 30, 45, 60"
"Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: 15, 30, 45, 60, 180"
);
});
@@ -1303,17 +1363,17 @@ describe("Slots 2024-09-04 Endpoints", () => {
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();
expect(responseReservedSlot.eventTypeId).toEqual(variableLengthEventType.id);
expect(responseReservedSlot.slotStart).toEqual(slotStartTime);
expect(responseReservedSlot.slotDuration).toEqual(slotDuration);
expect(responseReservedSlot.slotEnd).toEqual(
responseReservedVariableSlot = reserveResponseBody.data;
expect(responseReservedVariableSlot.reservationUid).toBeDefined();
expect(responseReservedVariableSlot.eventTypeId).toEqual(variableLengthEventType.id);
expect(responseReservedVariableSlot.slotStart).toEqual(slotStartTime);
expect(responseReservedVariableSlot.slotDuration).toEqual(slotDuration);
expect(responseReservedVariableSlot.slotEnd).toEqual(
DateTime.fromISO(slotStartTime, { zone: "UTC" }).plus({ minutes: slotDuration }).toISO()
);
expect(responseReservedSlot.reservationDuration).toEqual(5);
expect(responseReservedVariableSlot.reservationDuration).toEqual(5);
if (!responseReservedSlot.reservationUid) {
if (!responseReservedVariableSlot.reservationUid) {
throw new Error("Reserved slot uid is undefined");
}
@@ -1337,16 +1397,41 @@ describe("Slots 2024-09-04 Endpoints", () => {
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(reservedSlot.reservationUid);
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(
responseReservedVariableSlot.reservationUid
);
expect(dbSlot).toBeDefined();
if (dbSlot) {
const dbReleaseAt = DateTime.fromJSDate(dbSlot.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 5 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
expect(responseReservedSlot.reservationUntil).toEqual(expectedReleaseAt);
expect(responseReservedVariableSlot.reservationUntil).toEqual(expectedReleaseAt);
}
clear();
});
it("request slot contains already existing reserved slot", async () => {
// Try to reserve 9:45-12:45 when 10:00-11:00 is taken
const newSlotStart = DateTime.fromISO(responseReservedVariableSlot.slotStart)
.minus({ minutes: 15 })
.toISO();
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId: variableLengthEventType.id,
slotStart: newSlotStart,
slotDuration: 180,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.set("Authorization", `Bearer cal_test_${apiKeyString}`)
.expect(422)
.then((response) => {
expect(response.body.error.message).toEqual(
"This time slot is already reserved by another user. Please choose a different time."
);
});
});
});
describe("out of office", () => {
@@ -124,6 +124,8 @@ export class SlotsService_2024_09_04 {
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
if (eventType.userId) {
const slot = await this.slotsRepository.createSlot(
eventType.userId,
@@ -153,6 +155,20 @@ export class SlotsService_2024_09_04 {
return this.slotsOutputService.getReservationSlotCreated(slot, reservationDuration);
}
private async checkSlotOverlap(eventTypeId: number, startDate: string, endDate: string) {
const overlappingReservation = await this.slotsRepository.getOverlappingSlotReservation(
eventTypeId,
startDate,
endDate
);
if (overlappingReservation) {
throw new UnprocessableEntityException(
`This time slot is already reserved by another user. Please choose a different time.`
);
}
}
validateSlotDuration(eventType: EventType, inputSlotDuration: number) {
const eventTypeMetadata = eventTypeMetadataSchema.parse(eventType.metadata);
if (!eventTypeMetadata?.multipleDuration) {
@@ -257,6 +273,8 @@ export class SlotsService_2024_09_04 {
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
const slot = await this.slotsRepository.updateSlot(
eventType.id,
startDate.toISO(),
@@ -19,6 +19,30 @@ export class SlotsRepository_2024_09_04 {
});
}
async getOverlappingSlotReservation(eventTypeId: number, startDate: string, endDate: string) {
return this.dbRead.prisma.selectedSlots.findFirst({
where: {
eventTypeId,
AND: [
{
OR: [
// Case 1: New slot starts during an existing slot
{ slotUtcStartDate: { lte: startDate }, slotUtcEndDate: { gt: startDate } },
// Case 2: New slot ends during an existing slot
{ slotUtcStartDate: { lt: endDate }, slotUtcEndDate: { gte: endDate } },
// Case 3: New slot is completely inside an existing slot
{ slotUtcStartDate: { lte: startDate }, slotUtcEndDate: { gte: endDate } },
// Case 4: New slot completely overlaps an existing slot
{ slotUtcStartDate: { gte: startDate }, slotUtcEndDate: { lte: endDate } },
],
},
// Only consider non-expired reservations
{ releaseAt: { gt: DateTime.utc().toJSDate() } },
],
},
});
}
async createSlot(
userId: number,
eventTypeId: number,
@@ -112,7 +112,7 @@ export class TeamsMembershipsController {
@Roles("TEAM_ADMIN")
@Patch("/:membershipId")
@ApiOperation({ summary: "Create a membership" })
@ApiOperation({ summary: "Update membership" })
async updateTeamMembership(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("membershipId", ParseIntPipe) membershipId: number,
+406 -19
View File
@@ -3282,6 +3282,82 @@
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/bookings/{bookingUid}/references": {
"get": {
"operationId": "OrganizationsTeamsBookingsController_getBookingReferences",
"summary": "Get 'Booking References' for a booking",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "type",
"required": false,
"in": "query",
"description": "Filter booking references by type",
"example": "google_calendar",
"schema": {
"enum": [
"google_calendar",
"office365_calendar",
"daily_video",
"google_video",
"office365_video",
"zoom_video"
],
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookingReferencesOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Orgs / Teams / Bookings"
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/conferencing/{app}/connect": {
"post": {
"operationId": "OrganizationsConferencingController_connectTeamApp",
@@ -7448,6 +7524,74 @@
]
}
},
"/v2/bookings/{bookingUid}/references": {
"get": {
"operationId": "BookingsController_2024_08_13_getBookingReferences",
"summary": "Get 'Booking References' for a booking",
"parameters": [
{
"name": "cal-api-version",
"in": "header",
"description": "Must be set to 2024-08-13",
"required": true,
"schema": {
"type": "string",
"default": "2024-08-13"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "type",
"required": false,
"in": "query",
"description": "Filter booking references by type",
"example": "google_calendar",
"schema": {
"enum": [
"google_calendar",
"office365_calendar",
"daily_video",
"google_video",
"office365_video",
"zoom_video"
],
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookingReferencesOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Bookings"
]
}
},
"/v2/calendars/ics-feed/save": {
"post": {
"operationId": "CalendarsController_createIcsFeed",
@@ -9563,7 +9707,7 @@
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phone-numbers": {
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phones": {
"get": {
"operationId": "OrgTeamsVerifiedResourcesController_getVerifiedPhoneNumbers",
"summary": "Get list of verified phone numbers of an org team.",
@@ -9671,7 +9815,7 @@
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phone-numbers/{id}": {
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phones/{id}": {
"get": {
"operationId": "OrgTeamsVerifiedResourcesController_getVerifiedPhoneById",
"summary": "Get verified phone number of an org team by id.",
@@ -11506,7 +11650,7 @@
},
"patch": {
"operationId": "TeamsMembershipsController_updateTeamMembership",
"summary": "Create a membership",
"summary": "Update membership",
"parameters": [
{
"name": "Authorization",
@@ -11855,7 +11999,7 @@
]
}
},
"/v2/teams/{teamId}/verified-resources/phone-numbers": {
"/v2/teams/{teamId}/verified-resources/phones": {
"get": {
"operationId": "TeamsVerifiedResourcesController_getVerifiedPhoneNumbers",
"summary": "Get list of verified phone numbers of a team.",
@@ -11963,7 +12107,7 @@
]
}
},
"/v2/teams/{teamId}/verified-resources/phone-numbers/{id}": {
"/v2/teams/{teamId}/verified-resources/phones/{id}": {
"get": {
"operationId": "TeamsVerifiedResourcesController_getVerifiedPhoneById",
"summary": "Get verified phone number of a team by id.",
@@ -12235,7 +12379,7 @@
]
}
},
"/v2/verified-resources/phone-numbers": {
"/v2/verified-resources/phones": {
"get": {
"operationId": "UserVerifiedResourcesController_getVerifiedPhoneNumbers",
"summary": "Get list of verified phone numbers.",
@@ -12327,7 +12471,7 @@
]
}
},
"/v2/verified-resources/phone-numbers/{id}": {
"/v2/verified-resources/phones/{id}": {
"get": {
"operationId": "UserVerifiedResourcesController_getVerifiedPhoneById",
"summary": "Get verified phone number by id.",
@@ -14955,8 +15099,7 @@
"eightxeight-video",
"discord-video",
"demodesk-video",
"campfire-video",
"around-video"
"campfire-video"
],
"example": "cal-video"
},
@@ -18117,6 +18260,143 @@
"type": "object",
"properties": {}
},
"BaseAttribute": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"name"
]
},
"TextAttribute": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"type": {
"type": "string"
},
"value": {
"type": "string"
},
"valueId": {
"type": "string"
}
},
"required": [
"id",
"name",
"type",
"value",
"valueId"
]
},
"NumberAttribute": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"type": {
"type": "string"
},
"value": {
"type": "number"
},
"valueId": {
"type": "string"
}
},
"required": [
"id",
"name",
"type",
"value",
"valueId"
]
},
"SingleSelectAttribute": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"type": {
"type": "string"
},
"value": {
"type": "string"
},
"valueId": {
"type": "string"
}
},
"required": [
"id",
"name",
"type",
"value",
"valueId"
]
},
"MultiSelectAttributeValue": {
"type": "object",
"properties": {
"valueId": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": [
"valueId",
"value"
]
},
"MultiSelectAttribute": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"type": {
"type": "string"
},
"values": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MultiSelectAttributeValue"
}
}
},
"required": [
"id",
"name",
"type",
"values"
]
},
"MembershipUserOutputDto": {
"type": "object",
"properties": {
@@ -18146,7 +18426,7 @@
"email"
]
},
"TeamMembershipOutput": {
"OrganizationMembershipOutput": {
"type": "object",
"properties": {
"id": {
@@ -18174,6 +18454,25 @@
},
"user": {
"$ref": "#/components/schemas/MembershipUserOutputDto"
},
"attributes": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/TextAttribute"
},
{
"$ref": "#/components/schemas/NumberAttribute"
},
{
"$ref": "#/components/schemas/SingleSelectAttribute"
},
{
"$ref": "#/components/schemas/MultiSelectAttribute"
}
]
}
}
},
"required": [
@@ -18182,7 +18481,8 @@
"teamId",
"accepted",
"role",
"user"
"user",
"attributes"
]
},
"GetAllOrgMemberships": {
@@ -18197,7 +18497,7 @@
]
},
"data": {
"$ref": "#/components/schemas/TeamMembershipOutput"
"$ref": "#/components/schemas/OrganizationMembershipOutput"
}
},
"required": [
@@ -18247,7 +18547,7 @@
]
},
"data": {
"$ref": "#/components/schemas/TeamMembershipOutput"
"$ref": "#/components/schemas/OrganizationMembershipOutput"
}
},
"required": [
@@ -18267,7 +18567,7 @@
]
},
"data": {
"$ref": "#/components/schemas/TeamMembershipOutput"
"$ref": "#/components/schemas/OrganizationMembershipOutput"
}
},
"required": [
@@ -18287,7 +18587,7 @@
]
},
"data": {
"$ref": "#/components/schemas/TeamMembershipOutput"
"$ref": "#/components/schemas/OrganizationMembershipOutput"
}
},
"required": [
@@ -18326,7 +18626,7 @@
]
},
"data": {
"$ref": "#/components/schemas/TeamMembershipOutput"
"$ref": "#/components/schemas/OrganizationMembershipOutput"
}
},
"required": [
@@ -19549,6 +19849,45 @@
"data"
]
},
"TeamMembershipOutput": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"userId": {
"type": "number"
},
"teamId": {
"type": "number"
},
"accepted": {
"type": "boolean"
},
"role": {
"type": "string",
"enum": [
"MEMBER",
"OWNER",
"ADMIN"
]
},
"disableImpersonation": {
"type": "boolean"
},
"user": {
"$ref": "#/components/schemas/MembershipUserOutputDto"
}
},
"required": [
"id",
"userId",
"teamId",
"accepted",
"role",
"user"
]
},
"OrgTeamMembershipsOutputResponseDto": {
"type": "object",
"properties": {
@@ -21636,7 +21975,7 @@
"type": "object",
"properties": {
"id": {
"type": "string"
"type": "number"
},
"formId": {
"type": "string"
@@ -22189,8 +22528,7 @@
"eightxeight-video",
"discord-video",
"demodesk-video",
"campfire-video",
"around-video"
"campfire-video"
]
}
},
@@ -24144,6 +24482,55 @@
"data"
]
},
"BookingReference": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "The type of the booking reference"
},
"eventUid": {
"type": "string",
"description": "The event uid of the booking"
},
"destinationCalendarId": {
"type": "string",
"nullable": true,
"description": "The id of the calendar the event is created in"
},
"id": {
"type": "number",
"description": "The id of the booking reference"
}
},
"required": [
"type",
"eventUid",
"destinationCalendarId",
"id"
]
},
"BookingReferencesOutput_2024_08_13": {
"type": "object",
"properties": {
"status": {
"type": "object",
"description": "The status of the request, always 'success' for successful responses",
"example": "success"
},
"data": {
"description": "Booking References",
"type": "array",
"items": {
"$ref": "#/components/schemas/BookingReference"
}
}
},
"required": [
"status",
"data"
]
},
"CreateTeamMembershipInput": {
"type": "object",
"properties": {
@@ -1,7 +1,7 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma, App_RoutingForms_Form } from "@prisma/client";
import { Prisma, App_RoutingForms_Form, App_RoutingForms_FormResponse } from "@prisma/client";
export class RoutingFormsRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
@@ -23,4 +23,10 @@ export class RoutingFormsRepositoryFixture {
async delete(routingFormId: App_RoutingForms_Form["id"]) {
return this.prismaWriteClient.app_RoutingForms_Form.delete({ where: { id: routingFormId } });
}
async deleteResponse(routingFormResponseId: App_RoutingForms_FormResponse["id"]) {
return this.prismaWriteClient.app_RoutingForms_FormResponse.delete({
where: { id: routingFormResponseId },
});
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ export const RenderComponentWithSnippet: React.FC<PropsWithChildren<Props>> = (p
// },
// });
return (
<div className="border-gray-6 bg-gray-1 rounded-lg border">
<div className="border-gray-6 bg-default rounded-lg border">
<div className={className("not-prose p-8 xl:p-12", props.className)}>{props.children}</div>
<div className="bg-gray-3 border-gray-6 flex items-center justify-start border-b border-t p-2">
@@ -0,0 +1,58 @@
"use client";
import { RenderComponentWithSnippet } from "@/app/components/render";
import { useState } from "react";
import { Switch } from "@calcom/ui/components/form";
import { Section } from "@calcom/ui/components/section";
export const BasicExample = () => {
const [isOpen, setIsOpen] = useState(true);
return (
<RenderComponentWithSnippet>
<Section>
<Section.Header
title="Basic Section"
description="This is a basic section with a title and description">
<Switch size="sm" checked={isOpen} onCheckedChange={setIsOpen} />
</Section.Header>
{isOpen && (
<Section.Content>
<p>This is the main content of the section.</p>
</Section.Content>
)}
</Section>
</RenderComponentWithSnippet>
);
};
export const BasicExampleSnippet = `
"use client";
import { useState } from "react";
import { Section } from "@calcom/ui/components/section";
import { Switch } from "@calcom/ui/components/form";
const BasicSection = () => {
const [isOpen, setIsOpen] = useState(true);
return (
<Section>
<Section.Header
title="Basic Section"
description="This is a basic section with a title and description"
>
<Section.HeaderRight>
<Switch size="sm" checked={isOpen} onCheckedChange={setIsOpen} />
</Section.HeaderRight>
</Section.Header>
{isOpen && (
<Section.Content>
<p>This is the main content of the section.</p>
</Section.Content>
)}
</Section>
);
};
`;
@@ -0,0 +1,40 @@
---
title: Section
---
import { Section } from "@calcom/ui/components/section"
import { RenderComponentWithSnippet } from "@/app/components/render"
import { Row } from "@/app/components/row"
import { BasicExample } from "./section.basic"
import { WithIconExample } from "./section.withIcon"
import { SubSectionExample } from "./section.subsection"
## Basic Usage
The Section component provides a structured way to organize content with a consistent visual hierarchy. It includes several subcomponents for different parts of the section.
<BasicExample/>
## With Icon
Sections can include an icon in the header to provide visual context.
<WithIconExample/>
## SubSections
Sections can contain subsections for organizing related content in a nested structure.
<SubSectionExample/>
## Components
The Section component is composed of several subcomponents:
- `Section` - The root container
- `Section.Header` - Container for the section header
- `Section.Title` - The section title
- `Section.Description` - Optional description text
- `Section.Content` - The main content area
- `Section.SubSection` - A nested section
- `Section.SubSectionHeader` - Header for a subsection
- `Section.SubSectionContent` - Content area for a subsection
@@ -0,0 +1,151 @@
"use client";
import { RenderComponentWithSnippet } from "@/app/components/render";
import { useState } from "react";
import { Input, Switch } from "@calcom/ui/components/form";
import { Section } from "@calcom/ui/components/section";
export const SubSectionExample = () => {
const [isMainOpen, setIsMainOpen] = useState(true);
const [isFirstSubsectionOpen, setIsFirstSubsectionOpen] = useState(true);
const [isSecondSubsectionOpen, setIsSecondSubsectionOpen] = useState(true);
return (
<RenderComponentWithSnippet>
<Section>
<Section.Header
icon="calendar"
title="Section with Subsections"
description="This section contains nested subsections">
<Switch size="sm" checked={isMainOpen} onCheckedChange={setIsMainOpen} />
</Section.Header>
{isMainOpen && (
<Section.Content>
<Section.SubSection>
<Section.SubSectionHeader icon="zap" title="Automatic Sync (Justify Start)" justify="start">
<Switch size="sm" />
</Section.SubSectionHeader>
</Section.SubSection>
<Section.SubSection>
<Section.SubSectionHeader icon="user" title="Create new records for guests added">
<Switch
size="sm"
checked={isFirstSubsectionOpen}
onCheckedChange={setIsFirstSubsectionOpen}
/>
</Section.SubSectionHeader>
{isFirstSubsectionOpen && (
<Section.SubSectionContent>
<p>This is the content of the first subsection.</p>
</Section.SubSectionContent>
)}
</Section.SubSection>
<Section.SubSection>
<Section.SubSectionHeader icon="settings" title="Open Settings">
<Switch
size="sm"
checked={isSecondSubsectionOpen}
onCheckedChange={setIsSecondSubsectionOpen}
/>
</Section.SubSectionHeader>
{isSecondSubsectionOpen && (
<Section.SubSectionContent>
<div className="flex gap-3 px-3 py-1.5">
<div className="text-subtle w-full text-sm font-medium">Field Name</div>
<div className="text-subtle w-full text-sm font-medium">Field Type</div>
<div className="text-subtle w-full text-sm font-medium">Value</div>
<div className="text-subtle w-full text-sm font-medium">When to Write</div>
</div>
<Section.SubSectionNested>
<div className="flex gap-3">
<Input value="Customer Name" className="w-full" />
<Input value="Text" className="w-full" />
<Input value="John Doe" className="w-full" />
<Input value="On Create" className="w-full" />
</div>
</Section.SubSectionNested>
</Section.SubSectionContent>
)}
</Section.SubSection>
</Section.Content>
)}
</Section>
</RenderComponentWithSnippet>
);
};
export const SubSectionExampleSnippet = `
"use client";
import { useState } from "react";
import { Section } from "@calcom/ui/components/section";
import { Switch } from "@calcom/ui/components/form";
const SectionWithSubsections = () => {
const [isMainOpen, setIsMainOpen] = useState(true);
const [isFirstSubsectionOpen, setIsFirstSubsectionOpen] = useState(true);
const [isSecondSubsectionOpen, setIsSecondSubsectionOpen] = useState(true);
return (
<Section>
<Section.Header
icon="calendar"
title="Section with Subsections"
description="This section contains nested subsections">
<Switch size="sm" checked={isMainOpen} onCheckedChange={setIsMainOpen} />
</Section.Header>
{isMainOpen && (
<Section.Content>
<Section.SubSection>
<Section.SubSectionHeader icon="zap" title="Automatic Sync (Justify Start)" justify="start">
<Switch size="sm" />
</Section.SubSectionHeader>
</Section.SubSection>
<Section.SubSection>
<Section.SubSectionHeader icon="user" title="Create new records for guests added">
<Switch
size="sm"
checked={isFirstSubsectionOpen}
onCheckedChange={setIsFirstSubsectionOpen}
/>
</Section.SubSectionHeader>
{isFirstSubsectionOpen && (
<Section.SubSectionContent>
<p>This is the content of the first subsection.</p>
</Section.SubSectionContent>
)}
</Section.SubSection>
<Section.SubSection>
<Section.SubSectionHeader icon="settings" title="Open Settings">
<Switch
size="sm"
checked={isSecondSubsectionOpen}
onCheckedChange={setIsSecondSubsectionOpen}
/>
</Section.SubSectionHeader>
{isSecondSubsectionOpen && (
<Section.SubSectionContent>
<div className="flex gap-3 px-3 py-1.5">
<div className="text-subtle w-full text-sm font-medium">Field Name</div>
<div className="text-subtle w-full text-sm font-medium">Field Type</div>
<div className="text-subtle w-full text-sm font-medium">Value</div>
<div className="text-subtle w-full text-sm font-medium">When to Write</div>
</div>
<Section.SubSectionNested>
<div className="flex gap-3">
<Input value="Customer Name" className="w-full" />
<Input value="Text" className="w-full" />
<Input value="John Doe" className="w-full" />
<Input value="On Create" className="w-full" />
</div>
</Section.SubSectionNested>
</Section.SubSectionContent>
)}
</Section.SubSection>
</Section.Content>
)}
</Section>
);
};
`;
@@ -0,0 +1,63 @@
"use client";
import { RenderComponentWithSnippet } from "@/app/components/render";
import { useState } from "react";
import { Switch } from "@calcom/ui/components/form";
import { Section } from "@calcom/ui/components/section";
export const WithIconExample = () => {
const [isOpen, setIsOpen] = useState(true);
return (
<RenderComponentWithSnippet>
<Section>
<Section.Header
icon="calendar"
title="Section with Icon"
description="This section includes an icon in the header. Toggle to see auto-animation.">
<Switch size="sm" checked={isOpen} onCheckedChange={setIsOpen} />
</Section.Header>
{isOpen && (
<Section.Content>
<p>
This section demonstrates how to include an icon in the header and uses FormKit auto-animate for
smooth transitions when toggling content visibility.
</p>
</Section.Content>
)}
</Section>
</RenderComponentWithSnippet>
);
};
export const WithIconExampleSnippet = `
"use client";
import { useState } from "react";
import { Section } from "@calcom/ui/components/section";
import { Switch } from "@calcom/ui/components/form";
const SectionWithIcon = () => {
const [isOpen, setIsOpen] = useState(true);
return (
<Section>
<Section.Header
icon="calendar"
title="Section with Icon"
description="This section includes an icon in the header. Toggle to see auto-animation.">
<Switch size="sm" checked={isOpen} onCheckedChange={setIsOpen} />
</Section.Header>
{isOpen && (
<Section.Content>
<p>
This section demonstrates how to include an icon in the header and uses FormKit auto-animate for
smooth transitions when toggling content visibility.
</p>
</Section.Content>
)}
</Section>
);
};
`;
+9
View File
@@ -1,5 +1,14 @@
# @calcom/web
## 5.2.8
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.5.3
- @calcom/embed-react@1.5.3
- @calcom/embed-snippet@1.3.3
## 4.8.7
### Patch Changes
@@ -1,7 +1,7 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import InsightsPage from "~/insights/insights-view";
@@ -15,8 +15,8 @@ export const generateMetadata = async () =>
);
export default async function Page() {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const insightsEnabled = await getFeatureFlag(prisma, "insights");
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
@@ -1,7 +1,7 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import InsightsVirtualQueuesPage from "~/insights/insights-virtual-queues-view";
@@ -15,8 +15,8 @@ export const generateMetadata = async () =>
);
export default async function Page() {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const insightsEnabled = await getFeatureFlag(prisma, "insights");
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
@@ -1,7 +1,7 @@
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import InsightsRoutingPage from "~/insights/insights-routing-view";
@@ -15,8 +15,8 @@ export const generateMetadata = async () =>
);
export default async function Page() {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const insightsEnabled = await getFeatureFlag(prisma, "insights");
const featuresRepository = new FeaturesRepository();
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
@@ -2,22 +2,29 @@
## Setting up Delegation Credential for Google Calendar API
Step 1: Create a Google Cloud Project
Step 0: Create a Workspace Platform(to be done once for the Cal.com instance, by the Cal.com instance admin)
- Create a Workspace Platform through admin interface at <https://app.cal.com/settings/admin/workspace-platforms>
- Use slug="google". Slug has to be exactly this. This is how we know we need to use Google Calendar and Google Meet.
- Use "Google" as the name of the workspace platform. Change it as per your liking.
- Optionally provide description for the workspace platform.
Step 1: Create a Google Cloud Project or use existing one(to be done once for the Cal.com instance)
Before you can create a service account, you'll need to set up a Google Cloud project.
1. Create a Google Cloud Project:
- Go to the Google Cloud Console
- Select Create Project
- Give your project a name and select your billing account (if applicable)
- Click Create
1. Go to the Google Cloud Console
2. Select Create Project
3. Give your project a name and select your billing account (if applicable)
4. Click Create
2. Enable the Google Calendar API:
1. Go to the Google Cloud Console
2. Select API & Services → Library
3. Search for "Google Calendar API"
4. Click Enable
Step 2: Create a Service Account
Step 2: Create a Service Account in Google Cloud Console(to be done for every organization)
A service account is needed to act on behalf of users
@@ -26,21 +33,38 @@ A service account is needed to act on behalf of users
2. Create a New Service Account:
- Click on Create Service Account
- Give your service account a name and description
- Click Create and Continue
- Click Create and Continue(Optional steps can be skipped)
3. Download the Service Account Key JSON file
Step 3: To Be taken by Cal.com instance admin:
Step 3: Create Delegation Credential(To Be taken by Cal.com instance admin):
- Create a Workspace Platform with slug="google". Slug has to be exactly this. This is how we know we need to use Google Calendar and Google Meet.
- Impersonate the organization owner and go to https://app.cal.com/settings/organizations/delegation-credential to create a Delegation Credential
Last Step (To Be Taken By Cal.com organization Owner/Admin): Assign Specific API Permissions via OAuth Scopes:
- Create Delegation Credential with workspace platform "google"
- User must be a member of the Google Workspace to be able to enable Delegation Credential as there is a validation if the user's calendar can be accessed through the service account
- Get the Client ID from there
- Go to your Google Admin Console (admin-google-com)
- Navigate to Security → Access and Data Controls -> API controls -> Manage Domain-Wide Delegation
- Here, you'll authorize the Client ID(Unique ID) to access the Google Calendar API
- Add the necessary API scopes for Google Calendar(Full access to Google Calendar)
<https://www.googleapis.com/auth/calendar>
- Use domain as "acme.com" if @acme.com is the email address for your Google Workspace
- Choose Workspace Platform as "Google"
- Add Service Account Key JSON file(obtained in Step 2)
- Click on "Create" button
Step 4: Copy the Client ID and OAuth Scope (To Be Taken By Cal.com organization Owner/Admin in Cal.com):
- Go to https://app.cal.com/settings/organizations/delegation-credential
- Copy the Client ID for your Google Workspace domain(e.g. acme.com)
- Client ID is a number like 123456789012345678901 that
Step 5: Add Client ID under Domain-Wide Delegation (To Be taken By Google Workspace Admin):
- Go to your Google Admin Console(admin.google.com)
- Navigate to Security → Access and Data Controls -> API controls -> Manage Domain-Wide Delegation
- Here, you'll authorize the Client ID to access the Google Calendar API
- Add the following API scope for Google Calendar(Full access to Google Calendar. We use it to read freebusy time and create/update events in the members' calendars)
- `https://www.googleapis.com/auth/calendar`
Step 6: Enable Delegation Credential(To Be taken By Cal.com organization Owner/Admin in Cal.com):
- Prerequisite: The owner/admin must be part of the Google Workspace to enable Delegation Credential
- Go to https://app.cal.com/settings/organizations/delegation-credential
- Enable Delegation Credential
- If you have added the Client ID for correct Google Workspace, the Delegation Credential would be enabled, otherwise you would see an error message, that should help and contact support if you still face issues.
## Onboarding Improvements
@@ -1,3 +1,8 @@
/**
* @description This route is used to create and delete Credential records for all members of an organization that has delegation credentials enabled.
*
* It also deletes Credential records for all members of an organization that has delegation credentials disabled.
*/
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
@@ -1,3 +1,8 @@
/**
* @description This route is used to create SelectedCalendar records based on Credential records corresponding to Delegation Credentials
*
* It works in conjunction with `/api/cron/credentials` route(which creates the Credential records for all the members of an organization that has delegation credentials enabled)
*/
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
+2 -2
View File
@@ -1,9 +1,9 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { NextResponse } from "next/server";
import * as pjson from "package.json";
import packageJson from "package.json";
async function getHandler() {
return NextResponse.json({ version: pjson.version });
return NextResponse.json({ version: packageJson.version });
}
export const GET = defaultResponderForAppDir(getHandler);
@@ -1,6 +1,6 @@
import { useForm } from "react-hook-form";
import { Schedule } from "@calcom/features/schedules";
import Schedule from "@calcom/features/schedules/components/Schedule";
import { DEFAULT_SCHEDULE } from "@calcom/lib/availability";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
+31
View File
@@ -0,0 +1,31 @@
// This file configures the initialization of Sentry on the client.
// The added config here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
// Define how likely Replay events are sampled.
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
// replaysSessionSampleRate: parseFloat(process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0.0") || 0.0,
// Define how likely Replay events are sampled when an error occurs.
// replaysOnErrorSampleRate: parseFloat(process.env.SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE ?? "0.0") || 0.0,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
beforeSend(event) {
event.tags = {
...event.tags,
errorSource: "client",
};
return event;
},
});
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
+4
View File
@@ -1,3 +1,5 @@
import * as Sentry from "@sentry/nextjs";
export async function register() {
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config");
@@ -7,3 +9,5 @@ export async function register() {
await import("./sentry.edge.config");
}
}
export const onRequestError = Sentry.captureRequestError;
@@ -52,8 +52,8 @@ describe("getThemeProviderProps", () => {
expect(result).toEqual({
...appThemeExpectedProps,
storageKey: undefined,
key: undefined,
storageKey: "forcedThemeKey",
key: "forcedThemeKey",
forcedTheme: "light",
enableSystem: false,
});
+17 -5
View File
@@ -1,4 +1,5 @@
import type { ReadonlyURLSearchParams } from "next/navigation";
import { z } from "zod";
import { EmbedTheme } from "@calcom/features/embed/lib/constants";
@@ -92,13 +93,28 @@ export function getThemeProviderProps({
searchParams: ReadonlyURLSearchParams | null;
}) {
const isBookingPage = props.isBookingPage;
const themeSupport = isBookingPage
? ThemeSupport.Booking
: props.isThemeSupported === false
? ThemeSupport.None
: ThemeSupport.App;
const themeQueryParam = searchParams?.get("theme") ?? "";
const themeParsed = z.enum(["light", "dark", "system", "auto"]).safeParse(themeQueryParam);
const isWrongThemeValue = themeQueryParam.length > 0 && !themeParsed.success;
const forcedTheme = themeSupport === ThemeSupport.None || isWrongThemeValue ? "light" : undefined;
if (forcedTheme) {
return {
key: "forcedThemeKey",
storageKey: "forcedThemeKey",
forcedTheme,
attribute: "class",
nonce: props.nonce,
enableColorScheme: false,
enableSystem: themeSupport !== ThemeSupport.None,
};
}
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
const themeBasis = pathname ? getUniqueIdentifierForBookingPage({ pathname }) : null;
const isThemeBasisRequired = isBookingPageThemeSupportRequired || isEmbedMode;
@@ -109,8 +125,6 @@ export function getThemeProviderProps({
}
const appearanceIdSuffix = themeBasis ? `:${themeBasis}` : "";
const forcedTheme = themeSupport === ThemeSupport.None ? "light" : undefined;
const themeQueryParam = searchParams?.get("theme");
const embedExplicitlySetThemeSuffix =
isEmbedMode && themeQueryParam && themeQueryParam !== EmbedTheme.auto ? `:${themeQueryParam}` : "";
@@ -126,8 +140,6 @@ export function getThemeProviderProps({
const themeProviderProps = {
storageKey,
// Can force a theme through this, no matter what system preference is or storage value is
forcedTheme,
nonce: props.nonce,
enableColorScheme: false,
// Enables theme switching based on system preference if true
@@ -1,10 +1,10 @@
import type { GetServerSidePropsResult } from "next";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import prisma from "@calcom/prisma";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
export const getServerSideProps = async (): Promise<GetServerSidePropsResult<{ isOrg: boolean }>> => {
const organizations = await getFeatureFlag(prisma, "organizations");
const featuresRepository = new FeaturesRepository();
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
// Check if organizations are enabled
if (!organizations) {
return {
+6 -4
View File
@@ -4,7 +4,7 @@ import { z } from "zod";
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
import { emailSchema } from "@calcom/lib/emailSchema";
import slugify from "@calcom/lib/slugify";
@@ -24,9 +24,11 @@ const querySchema = z.object({
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const emailVerificationEnabled = await getFeatureFlag(prisma, "email-verification");
const signupDisabled = await getFeatureFlag(prisma, "disable-signup");
const featuresRepository = new FeaturesRepository();
const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"email-verification"
);
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
const token = z.string().optional().parse(ctx.query.token);
const redirectUrlData = z
@@ -5,7 +5,7 @@ import {
getOrganizationSettings,
getVerifiedDomain,
} from "@calcom/features/ee/organizations/lib/orgSettings";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { IS_CALCOM } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
@@ -71,7 +71,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
// Provided by Rewrite from next.config.js
const isOrgProfile = context.query?.isOrgProfile === "1";
const organizationsEnabled = await getFeatureFlag(prisma, "organizations");
const featuresRepository = new FeaturesRepository();
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
log.debug("getServerSideProps", {
isOrgProfile,
@@ -7,6 +7,7 @@ import {
updateMeetingTokenIfExpired,
} from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
import { CAL_VIDEO_MEETING_LINK_FOR_TESTING } from "@calcom/lib/constants";
import { isENVDev } from "@calcom/lib/env";
@@ -135,6 +136,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const videoReference = getCalVideoReference(bookingObj.references);
const featureRepo = new FeaturesRepository();
const displayLogInOverlay = profile?.organizationId
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
: false;
return {
props: {
meetingUrl: videoReference.meetingUrl ?? "",
@@ -153,6 +159,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
hasTeamPlan: !!hasTeamPlan,
calVideoLogo,
displayLogInOverlay,
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
},
};
}
@@ -8,7 +8,8 @@ import { useCallback, useState } from "react";
import { BulkEditDefaultForEventsModal } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import type { BulkUpdatParams } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
import { NewScheduleButton } from "@calcom/features/schedules/components/NewScheduleButton";
import { ScheduleListItem } from "@calcom/features/schedules/components/ScheduleListItem";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
@@ -152,28 +152,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
slug: eventType.team?.slug || eventType.users[0]?.username || null,
};
if (bookingInfo !== null && eventType.seatsPerTimeSlot) {
await handleSeatsEventTypeOnBooking(
eventType,
bookingInfo,
seatReferenceUid,
session?.user.id === eventType.userId
);
}
const payment = await prisma.payment.findFirst({
where: {
bookingId: bookingInfo.id,
},
select: {
success: true,
refunded: true,
currency: true,
amount: true,
paymentOption: true,
},
});
const userId = session?.user?.id;
const checkIfUserIsHost = (userId?: number | null) => {
@@ -194,6 +172,23 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const isLoggedInUserHost = checkIfUserIsHost(userId);
if (bookingInfo !== null && eventType.seatsPerTimeSlot) {
await handleSeatsEventTypeOnBooking(eventType, bookingInfo, seatReferenceUid, isLoggedInUserHost);
}
const payment = await prisma.payment.findFirst({
where: {
bookingId: bookingInfo.id,
},
select: {
success: true,
refunded: true,
currency: true,
amount: true,
paymentOption: true,
},
});
if (!isLoggedInUserHost) {
// Removing hidden fields from responses
for (const key in bookingInfo.responses) {
@@ -9,7 +9,7 @@ export default function MaintenancePage() {
return (
<div className="bg-subtle flex h-screen">
<div className="bg-default m-auto rounded-md p-10 text-right ltr:text-left">
<h1 className="text-emphasis text-2xl font-medium">{t("down_for_maintenance")}</h1>
<h1 className="text-emphasis text-2xl font-medium">{t("under_maintenance")}</h1>
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">{t("maintenance_message")}</p>
<Button href={`${WEBSITE_URL}/support`}>{t("contact_support")}</Button>
</div>
@@ -7,12 +7,15 @@ import { useState, useEffect, useRef } from "react";
import dayjs from "@calcom/dayjs";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { TRANSCRIPTION_STOPPED_ICON, RECORDING_DEFAULT_ICON } from "@calcom/lib/constants";
import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
import { Icon } from "@calcom/ui/components/icon";
import type { getServerSideProps } from "@lib/video/[uid]/getServerSideProps";
@@ -22,7 +25,15 @@ import { CalAiTranscribe } from "~/videos/ai/ai-transcribe";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function JoinCall(props: PageProps) {
const { meetingUrl, meetingPassword, booking, hasTeamPlan, calVideoLogo } = props;
const {
meetingUrl,
meetingPassword,
booking,
hasTeamPlan,
calVideoLogo,
displayLogInOverlay,
loggedInUserName,
} = props;
const [daily, setDaily] = useState<DailyCall | null>(null);
useEffect(() => {
@@ -50,6 +61,7 @@ export default function JoinCall(props: PageProps) {
height: "100%",
},
url: meetingUrl,
userName: loggedInUserName ? loggedInUserName ?? undefined : undefined,
...(typeof meetingPassword === "string" && { token: meetingPassword }),
...(hasTeamPlan && {
customTrayButtons: {
@@ -112,6 +124,8 @@ export default function JoinCall(props: PageProps) {
/>
)}
</div>
{displayLogInOverlay && <LogInOverlay isLoggedIn={!!loggedInUserName} bookingUid={booking.uid} />}
<VideoMeetingInfo booking={booking} />
</DailyProvider>
);
@@ -187,6 +201,58 @@ function ProgressBar(props: ProgressBarProps) {
);
}
interface LogInOverlayProps {
isLoggedIn: boolean;
bookingUid: string;
}
export function LogInOverlay(props: LogInOverlayProps) {
const { t } = useLocale();
const { isLoggedIn, bookingUid } = props;
const [open, setOpen] = useState(!isLoggedIn);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
title={t("join_video_call")}
description={t("choose_how_you_d_like_to_join_call")}
className="bg-black text-white sm:max-w-[480px]">
<div className="pb-8">
<div className="space-y-8">
<Button color="primary" className="mt-4 w-full justify-center " onClick={() => setOpen(false)}>
{t("continue_as_guest")}
</Button>
{/* Divider */}
<div className="relative py-2">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-600" />
</div>
<div className="relative flex justify-center">
<span className="bg-black px-4 text-sm text-gray-400">{t("or")}</span>
</div>
</div>
<div className="space-y-3">
<h4 className="text-lg font-semibold text-white">{t("sign_in_to_cal_com")}</h4>
<p className="text-sm text-gray-300">{t("track_your_meetings")}</p>
<Button
color="primary"
className="mt-4 w-full justify-center"
onClick={() =>
(window.location.href = `${WEBAPP_URL}/auth/login?callbackUrl=${WEBAPP_URL}/video/${bookingUid}`)
}>
<Icon name="external-link" className="mr-2 h-4 w-4" />
{t("log_in_to_cal_com")}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
interface VideoMeetingInfo {
booking: PageProps["booking"];
}
+1 -45
View File
@@ -1,9 +1,6 @@
require("dotenv").config({ path: "../../.env" });
const CopyWebpackPlugin = require("copy-webpack-plugin");
const os = require("os");
const englishTranslation = require("./public/static/locales/en/common.json");
const { withAxiom } = require("next-axiom");
const { withSentryConfig } = require("@sentry/nextjs");
const { version } = require("./package.json");
const {
i18n: { locales },
@@ -190,9 +187,8 @@ const nextConfig = {
experimental: {
// externalize server-side node_modules with size > 1mb, to improve dev mode performance/RAM usage
optimizePackageImports: ["@calcom/ui"],
turbo: {},
},
productionBrowserSourceMaps: process.env.SENTRY_DISABLE_CLIENT_SOURCE_MAPS === "0",
productionBrowserSourceMaps: true,
/* We already do type check on GH actions */
typescript: {
ignoreBuildErrors: !!process.env.CI,
@@ -228,10 +224,6 @@ const nextConfig = {
},
webpack: (config, { webpack, buildId, isServer }) => {
if (isServer) {
if (process.env.SENTRY_DISABLE_SERVER_SOURCE_MAPS === "1") {
config.devtool = false;
}
// Module not found fix @see https://github.com/boxyhq/jackson/issues/1535#issuecomment-1704381612
config.plugins.push(
new webpack.IgnorePlugin({
@@ -250,27 +242,6 @@ const nextConfig = {
})
);
config.plugins.push(
new CopyWebpackPlugin({
patterns: [
{
from: "../../packages/app-store/**/static/**",
to({ context, absoluteFilename }) {
// Adds compatibility for windows path
if (os.platform() === "win32") {
const absoluteFilenameWin = absoluteFilename.replaceAll("\\", "/");
const contextWin = context.replaceAll("\\", "/");
const appName = /app-store\/(.*)\/static/.exec(absoluteFilenameWin);
return Promise.resolve(`${contextWin}/public/app-store/${appName[1]}/[name][ext]`);
}
const appName = /app-store\/(.*)\/static/.exec(absoluteFilename);
return Promise.resolve(`${context}/public/app-store/${appName[1]}/[name][ext]`);
},
},
],
})
);
config.plugins.push(new webpack.DefinePlugin({ "process.env.BUILD_ID": JSON.stringify(buildId) }));
config.resolve.fallback = {
@@ -714,19 +685,4 @@ const nextConfig = {
},
};
if (!!process.env.NEXT_PUBLIC_SENTRY_DSN) {
plugins.push((nextConfig) =>
withSentryConfig(nextConfig, {
autoInstrumentServerFunctions: false,
hideSourceMaps: true,
// disable source map generation for the server code
disableServerWebpackPlugin: !!process.env.SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN,
silent: false,
sourcemaps: {
disable: process.env.SENTRY_DISABLE_SERVER_SOURCE_MAPS === "1",
},
})
);
}
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
+7 -5
View File
@@ -1,20 +1,22 @@
{
"name": "@calcom/web",
"version": "5.2.9",
"version": "5.2.14",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
"analyze:server": "BUNDLE_ANALYZE=server next build",
"analyze:browser": "BUNDLE_ANALYZE=browser next build",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
"dev": "next dev --turbopack",
"dev": "yarn copy-static && next dev --turbopack",
"dev:cron": "ts-node cron-tester.ts",
"dev-https": "NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --experimental-https",
"dx": "yarn dev",
"test-codegen": "yarn playwright codegen http://localhost:3000",
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit",
"build": "next build",
"sentry:release": "NODE_OPTIONS='--max-old-space-size=6144' node scripts/create-sentry-release.js",
"copy-static": "node scripts/copy-app-store-static.js",
"build": "yarn copy-static && next build && yarn sentry:release",
"start": "next start",
"lint": "eslint . --ignore-path .gitignore",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
@@ -69,7 +71,7 @@
"@radix-ui/react-switch": "^1.0.0",
"@radix-ui/react-toggle-group": "^1.0.0",
"@radix-ui/react-tooltip": "^1.0.0",
"@sentry/nextjs": "^9.8.0",
"@sentry/nextjs": "^9.15.0",
"@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.35.0",
"@tanstack/react-query": "^5.17.15",
@@ -179,10 +181,10 @@
"@types/stripe": "^8.0.417",
"@types/uuid": "8.3.1",
"autoprefixer": "^10.4.12",
"copy-webpack-plugin": "^11.0.0",
"cron": "^3.1.7",
"detect-port": "^1.3.0",
"env-cmd": "^10.1.0",
"glob": "^10.3.10",
"google-auth-library": "^9.15.0",
"module-alias": "^2.2.2",
"msw": "^0.42.3",
+3 -3
View File
@@ -3,7 +3,7 @@ import type { NextApiResponse } from "next";
import calcomSignupHandler from "@calcom/feature-auth/signup/handlers/calcomHandler";
import selfHostedSignupHandler from "@calcom/feature-auth/signup/handlers/selfHostedHandler";
import { type RequestWithUsernameStatus } from "@calcom/features/auth/signup/username";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import getIP from "@calcom/lib/getIP";
import { HttpError } from "@calcom/lib/http-error";
@@ -21,8 +21,8 @@ async function ensureSignupIsEnabled(req: RequestWithUsernameStatus) {
// Still allow signups if there is a team invite
if (token) return;
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const signupDisabled = await getFeatureFlag(prisma, "disable-signup");
const featuresRepository = new FeaturesRepository();
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || signupDisabled) {
throw new HttpError({
+43
View File
@@ -0,0 +1,43 @@
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { getRoutedUrl } from "@calcom/lib/server/getRoutedUrl";
export default defaultHandler({
OPTIONS: Promise.resolve({
default: defaultResponder(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, cache-control, pragma");
res.status(204).end();
}),
}),
POST: Promise.resolve({
default: defaultResponder(async (req, res) => {
// getRoutedUrl has more detailed schema validation, we do a basic one here.
const params = req.body;
res.setHeader("Access-Control-Allow-Origin", "*");
const routedUrlData = await getRoutedUrl({ req, query: { ...params } });
if (routedUrlData?.notFound) {
return res.status(404).json({ status: "error", data: { message: "Form not found" } });
}
if (routedUrlData?.redirect?.destination) {
return res
.status(200)
.json({ status: "success", data: { redirect: routedUrlData.redirect.destination } });
}
if (routedUrlData?.props?.errorMessage) {
return res.status(400).json({ status: "error", data: { message: routedUrlData.props.errorMessage } });
}
if (routedUrlData?.props?.message) {
return res.status(200).json({ status: "success", data: { message: routedUrlData.props.message } });
}
return res
.status(500)
.json({ status: "error", data: { message: "Neither Route nor custom message found." } });
}),
}),
});
+2 -2
View File
@@ -8,7 +8,7 @@ import PageWrapper from "@components/PageWrapper";
import { getServerSideProps } from "../../server/lib/router/getServerSideProps";
export default function Router({ form, message }: inferSSRProps<typeof getServerSideProps>) {
export default function Router({ form, message, errorMessage }: inferSSRProps<typeof getServerSideProps>) {
return (
<>
<Head>
@@ -17,7 +17,7 @@ export default function Router({ form, message }: inferSSRProps<typeof getServer
<div className="mx-auto my-0 max-w-3xl md:my-24">
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
<div className="text-default bg-default -mx-4 rounded-sm border border-neutral-200 p-4 py-6 sm:mx-0 sm:px-8">
<div>{message}</div>
<div>{message || errorMessage}</div>
</div>
</div>
</div>
@@ -1,6 +1,7 @@
import { test } from "../../lib/fixtures";
import type { TApp } from "./types";
// Around is not available in the app store anymore
const APP: TApp = {
slug: "around",
type: "integrations:around_video",
@@ -11,7 +12,8 @@ const APP: TApp = {
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("check non-oAuth link-based conferencing apps", () => {
// TODO: remove skip once more apps are added
test.skip("check non-oAuth link-based conferencing apps", () => {
test(`check conferencing app: ${APP.slug} by skipping the configure step`, async ({
appsPage,
page,
@@ -24,7 +26,7 @@ test.describe("check non-oAuth link-based conferencing apps", () => {
});
});
test.describe("check non-oAuth link-based conferencing apps using the new flow", () => {
test.skip("check non-oAuth link-based conferencing apps using the new flow", () => {
test(`can add ${APP.slug} app and book with it`, async ({ appsPage, page, users }) => {
const user = await users.create();
await user.apiLogin();
+93 -3
View File
@@ -1,4 +1,5 @@
import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/client";
@@ -72,7 +73,7 @@ test.describe("Bookings", () => {
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/upcoming`);
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
@@ -350,7 +351,7 @@ test.describe("Bookings", () => {
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/upcoming`);
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
@@ -457,7 +458,7 @@ test.describe("Bookings", () => {
const bookingsGetResponse1 = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto("/bookings/upcoming");
await page.goto("/bookings/upcoming", { waitUntil: "domcontentloaded" });
await bookingsGetResponse1;
await page.locator('[data-testid="add-filter-button"]').click();
@@ -470,6 +471,95 @@ test.describe("Bookings", () => {
await expect(page.locator('[data-testid="booking-item"]')).toHaveCount(0);
});
test.describe("Filter Dropdown Item Search", () => {
const filterItemsConfig = [
{ key: "eventTypeId", name: "Event Type", testId: "add-filter-item-eventTypeId" },
{ key: "teamId", name: "Team", testId: "add-filter-item-teamId" },
{ key: "userId", name: "Member", testId: "add-filter-item-userId" },
{ key: "attendeeName", name: "Attendees Name", testId: "add-filter-item-attendeeName" },
{ key: "attendeeEmail", name: "Attendee Email", testId: "add-filter-item-attendeeEmail" },
{ key: "dateRange", name: "Date Range", testId: "add-filter-item-dateRange" },
];
const searchInputSelector = "[cmdk-input]";
const getFilterItemLocator = (page: Page, testId: string) => page.locator(`[data-testid="${testId}"]`);
test.beforeEach(async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await page.locator('[data-testid="add-filter-button"]').click();
await expect(page.locator(searchInputSelector)).toBeVisible();
});
test("should show all filter items initially and after clearing search", async ({ page }) => {
const searchInput = page.locator(searchInputSelector);
// Initial check: all defined filter items should be visible
for (const item of filterItemsConfig) {
await expect(
getFilterItemLocator(page, item.testId),
`Item ${item.name} should be visible initially`
).toBeVisible();
}
// Type something and then clear the search
await searchInput.fill("Some text");
await searchInput.clear();
// After clearing: all defined filter items should be visible again
for (const item of filterItemsConfig) {
await expect(
getFilterItemLocator(page, item.testId),
`Item ${item.name} should be visible after clearing search`
).toBeVisible();
}
});
test("search should be case-insensitive", async ({ page }) => {
const searchInput = page.locator(searchInputSelector);
// Search for "member" (lowercase)
await searchInput.fill("member");
await expect(getFilterItemLocator(page, "add-filter-item-userId")).toBeVisible();
await expect(getFilterItemLocator(page, "add-filter-item-eventTypeId")).toBeHidden();
await expect(getFilterItemLocator(page, "add-filter-item-teamId")).toBeHidden();
});
test("should individually find each filter item by its full name", async ({ page }) => {
const searchInput = page.locator(searchInputSelector);
for (const targetItem of filterItemsConfig) {
await searchInput.fill(targetItem.name);
// Check that the target item is visible
await expect(
getFilterItemLocator(page, targetItem.testId),
`Searching for "${targetItem.name}", item "${targetItem.name}" should be visible`
).toBeVisible();
await searchInput.clear();
}
});
test("should show no items for a non-matching search term", async ({ page }) => {
const searchInput = page.locator(searchInputSelector);
const nonExistentTerm = "NonExistentFilterXYZ123";
await searchInput.fill(nonExistentTerm);
for (const item of filterItemsConfig) {
await expect(
getFilterItemLocator(page, item.testId),
`Item ${item.name} should be hidden for non-matching term '${nonExistentTerm}'`
).toBeHidden();
}
});
});
});
async function createBooking({
+5
View File
@@ -23,6 +23,11 @@ test.describe("hash my url", () => {
// We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]');
await page.locator("ul[data-testid=event-types] > li a").first().click();
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
"aria-current",
"page"
); // fix the race condition
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toContainText("Event Setup"); //fix the race condition
// We wait for the page to load
await page.locator(".primary-navigation >> text=Advanced").click();
// ignore if it is already checked, and click if unchecked
+2 -5
View File
@@ -50,16 +50,13 @@ test.describe("2FA Tests", async () => {
secret: secret!,
});
// FIXME: this passes even when switch is not checked, compare to test
// below which checks for data-state="checked" and works as expected
await page.waitForSelector(`[data-testid=two-factor-switch]`);
await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy();
await expect(page.getByTestId("backup-codes-download")).toBeVisible();
return user;
});
await test.step("Logout", async () => {
await page.goto("/auth/logout");
await users.logout();
});
await test.step("Login with 2FA enabled", async () => {
@@ -295,6 +295,167 @@ test.describe("Bookings", () => {
await page.goto(`/org/${org.slug}/${teamSlugUpperCase}/${teamEventSlugUpperCase}`);
await page.waitForSelector("[data-testid=day]");
});
test("Round robin event type properly rotates hosts", async ({ page, users, orgs }) => {
const org = await orgs.create({
name: "TestOrg",
});
const teamMatesObj = [{ name: "teammate-1" }, { name: "teammate-2" }, { name: "teammate-3" }];
const owner = await users.create(
{
username: "pro-user",
name: "pro-user",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
},
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const { team } = await owner.getFirstTeamMembership();
const teamEvent = await owner.getFirstTeamEvent(team.id);
const eventHostsObj = [...teamMatesObj, { name: "pro-user" }];
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
await bookTeamEvent({ page, team, event: teamEvent, teamMatesObj: eventHostsObj });
const firstHost = await page.getByTestId("booking-host-name").textContent();
expect(firstHost).not.toBeNull();
// Second booking - should get a different host
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.getByTestId("success-page")).toBeVisible();
const secondHost = await page.getByTestId("booking-host-name").textContent();
expect(secondHost).not.toBeNull();
expect(secondHost).not.toBe(firstHost);
// Third booking - should get a different host
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.getByTestId("success-page")).toBeVisible();
const thirdHost = await page.getByTestId("booking-host-name").textContent();
expect(thirdHost).not.toBeNull();
expect(thirdHost).not.toBe(firstHost);
expect(thirdHost).not.toBe(secondHost);
// Fourth booking - should get a different host
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.getByTestId("success-page")).toBeVisible();
const fourthHost = await page.getByTestId("booking-host-name").textContent();
expect(fourthHost).not.toBeNull();
expect(fourthHost).not.toBe(firstHost);
expect(fourthHost).not.toBe(secondHost);
expect(fourthHost).not.toBe(thirdHost);
// Verify all hosts were used
const allHosts = [firstHost, secondHost, thirdHost, fourthHost];
const uniqueHosts = new Set(allHosts);
expect(uniqueHosts.size).toBe(4);
}
);
});
test("Round robin event type with confirmation required handles cancellation and rebooking correctly with the same details and slot", async ({
page,
users,
orgs,
}) => {
const org = await orgs.create({
name: "TestOrg",
});
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{
username: "pro-user",
name: "pro-user",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
},
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const { team } = await owner.getFirstTeamMembership();
const teamEvent = await owner.getFirstTeamEvent(team.id);
const eventHostsObj = [...teamMatesObj, { name: "pro-user" }];
// Enable confirmation required for the event
await owner.apiLogin();
await page.goto(`/event-types/${teamEvent.id}?tabName=advanced`);
await page.getByTestId("requires-confirmation").click();
await page.getByTestId("update-eventtype").click();
await page.waitForResponse((response) => response.url().includes("/api/trpc/eventTypes/update"));
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
await bookTeamEvent({ page, team, event: teamEvent, teamMatesObj: eventHostsObj });
const firstHost = await page.getByTestId("booking-host-name").textContent();
expect(firstHost).not.toBeNull();
const bookingUid = page.url().split("/booking/")[1];
expect(bookingUid).not.toBeNull();
// Login as the host to cancel the booking
await page.goto("/auth/logout");
const allUsers = await users.get();
const hostUser = allUsers.find((mate) => mate.name === firstHost);
if (!hostUser) throw new Error("Host not found");
await hostUser.apiLogin();
// Cancel the booking
await page.goto(`/booking/${bookingUid}`);
await page.getByTestId("cancel").click();
await page.getByTestId("confirm_cancel").click();
await page.waitForResponse((response) => response.url().includes("/api/cancel"));
// Logout and go back to booking page
await page.goto("/auth/logout");
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
// Rebook with the same details
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.getByTestId("success-page")).toBeVisible();
// Verify a new host is assigned
const newHost = await page.getByTestId("booking-host-name").textContent();
expect(newHost).not.toBeNull();
expect(newHost).toBe(firstHost);
// Verify the booking was successful by checking the new booking UID
const newBookingUid = page.url().split("/booking/")[1];
expect(newBookingUid).not.toBeNull();
expect(newBookingUid).not.toBe(bookingUid);
}
);
});
});
test.describe("User Event", () => {
+11 -1
View File
@@ -620,6 +620,7 @@
"your_full_name": "Your full name",
"no_name": "No name",
"enter_number_between_range": "Please enter a number between 1 and {{maxOccurences}}",
"continue_as_guest_description": "Quick and easy - no account required. Perfect for one-time meetings.",
"email_address": "Email address",
"enter_valid_email": "Please enter a valid email",
"please_schedule_future_call": " Please schedule a future call if we're not available in {{seconds}} seconds",
@@ -629,6 +630,7 @@
"in_person_attendee_address": "In Person (Attendee Address)",
"somewhere_else": "Somewhere Else",
"custom_attendee_location": "Custom attendee location",
"continue_as_guest": "Continue as Guest",
"any_location": "Any location",
"yes": "Yes",
"no": "No",
@@ -1297,6 +1299,11 @@
"event_cancelled_trigger": "when event is canceled",
"after_hosts_cal_video_no_show": "After hosts didn't join cal video",
"after_guests_cal_video_no_show": "After guests didn't join cal video",
"join_video_call": "Join the video call",
"sign_in_to_cal_com": "Sign in to your Cal.com account",
"log_in_to_cal_com": "Log in to Cal.com",
"track_your_meetings": "Track your meetings, get notifications, and manage your schedule more effectively.",
"choose_how_you_d_like_to_join_call": "Choose how you'd like to join this call",
"new_event_trigger": "when new event is booked",
"email_host_action": "send email to host",
"email_attendee_action": "send email to attendees",
@@ -1566,6 +1573,7 @@
"workflow_turned_on_successfully": "{{workflowName}} workflow turned {{offOn}} successfully",
"download_responses": "Download responses",
"download_responses_description": "Download all responses to your form in CSV format.",
"view_responses": "View Responses",
"download": "Download",
"downloading": "Downloading",
"download_recording": "Download Recording",
@@ -2812,6 +2820,7 @@
"salesforce_skip_entry_creation": "Skip creating {{entry}} record if they do not exist in Salesforce",
"salesforce_if_account_does_not_exist": "If the contact does not exist under an account, create new lead from attendee",
"salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts",
"salesforce_change_record_owner_on_booking":"Change record owner on booking",
"mass_assign_attributes": "Mass assign attributes",
"reroute_preview_custom_message": "It results in showing custom message. Try changing the response to route to an event",
"reroute_preview_external_redirect": "It results in redirecting to {{externalUrl}}. Try changing the response to route to an event",
@@ -2908,6 +2917,7 @@
"routing_form_insights_assignment_reason": "Assignment Reason",
"access_denied": "Access Denied",
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
"salesforce_add_attendees_as":"Add new attendees as",
"salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)",
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
"salesforce_option": "Salesforce Option",
@@ -3131,7 +3141,6 @@
"user_team": "User/Team",
"client_id_copied": "Client ID copied!",
"client_secret_copied": "Client secret copied!",
"down_for_maintenance": "Down for maintenance",
"maintenance_message": "The Cal.com team are performing scheduled maintenance. If you have any questions, please contact support.",
"your_payment_failed": "Your payment failed",
"payment_successful": "Payment successful!",
@@ -3180,6 +3189,7 @@
"routing_form_next_in_queue": "{{count}} next in queue",
"routing_form_select_members_to_email": "Send email responses to",
"routing_incomplete_booking_tab": "Incomplete Bookings",
"include_no_show_in_rr_calculation": "Include no show bookings in round robin calculations",
"matching": "Matching",
"event_redirect": "Event Redirect",
"reset_form": "Reset Form",
+31
View File
@@ -0,0 +1,31 @@
const fs = require("fs");
const path = require("path");
const glob = require("glob");
const copyAppStoreStatic = () => {
// Get all static files from app-store packages
const staticFiles = glob.sync("../../packages/app-store/**/static/**/*", { nodir: true });
staticFiles.forEach((file) => {
// Extract app name from path
const appNameMatch = file.match(/app-store\/(.*?)\/static/);
if (!appNameMatch) return;
const appName = appNameMatch[1];
const fileName = path.basename(file);
// Create destination directory if it doesn't exist
const destDir = path.join(process.cwd(), "public", "app-store", appName);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
// Copy file to destination
const destPath = path.join(destDir, fileName);
fs.copyFileSync(file, destPath);
console.log(`Copied ${file} to ${destPath}`);
});
};
// Run the copy function
copyAppStoreStatic();
+43
View File
@@ -0,0 +1,43 @@
const { execSync } = require("child_process");
const CLIENT_FILES_PATH = ".next/static/chunks";
try {
// Continue if required any env vars are not set
const requiredEnvVars = ["SENTRY_AUTH_TOKEN", "SENTRY_ORG", "SENTRY_PROJECT"];
const missingEnvVars = requiredEnvVars.filter((envVar) => !process.env[envVar]);
if (missingEnvVars.length > 0) {
console.log(
`Skipping release creation as required environment variables are not set: ${missingEnvVars.join(", ")}`
);
process.exit(0);
}
const release = execSync("git rev-parse HEAD").toString().trim();
// Add release
execSync(`sentry-cli releases new ${release}`, { stdio: "inherit" });
// Add git commits
execSync(`sentry-cli releases set-commits ${release} --auto`, {
stdio: "inherit",
});
// Inject Debug IDs
execSync(`sentry-cli sourcemaps inject ${CLIENT_FILES_PATH}`, { stdio: "inherit" });
// Upload with release flag
execSync(
`sentry-cli sourcemaps upload ${CLIENT_FILES_PATH} --validate --ext=js --ext=map --release=${release}`,
{
stdio: "inherit",
env: process.env,
}
);
// Finalize the release
execSync(`sentry-cli releases finalize ${release}`, { stdio: "inherit" });
} catch (err) {
console.error("Sentry cli execution failed:", err);
process.exit(1);
}
-1
View File
@@ -1 +0,0 @@
export {};
+7
View File
@@ -2,4 +2,11 @@ import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
beforeSend(event) {
event.tags = {
...event.tags,
errorSource: "edge",
};
return event;
},
});
+7 -2
View File
@@ -4,7 +4,12 @@ Sentry.init({
debug: !!process.env.SENTRY_DEBUG,
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
_experiments: {
maxSpans: parseInt(process.env.SENTRY_MAX_SPANS ?? "1000") || 1000,
integrations: [Sentry.prismaIntegration()],
beforeSend(event) {
event.tags = {
...event.tags,
errorSource: "server",
};
return event;
},
});
+664 -2
View File
@@ -1542,7 +1542,7 @@ describe("getSchedule", () => {
{
id: 1,
slotInterval: 45,
schedulingType: "COLLECTIVE",
schedulingType: SchedulingType.COLLECTIVE,
hosts: [
{
userId: 101,
@@ -1746,6 +1746,668 @@ describe("getSchedule", () => {
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(23); // 2 booking per day as limit, only one booking on that
});
test("test that duration limit is working correctly if user is all day available", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
durationLimits: {
PER_DAY: 120, // 2 hours per day
},
users: [
{
id: 101,
},
],
},
{
id: 2,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
durationLimits: {
PER_DAY: 240, // 4 hours per day
},
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "All Day available",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T00:00:00.000Z"),
endTime: new Date("1970-01-01T23:59:59.999Z"),
date: null,
},
],
timeZone: Timezones["+6:00"],
},
],
},
],
bookings: [
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T08:00:00.000Z`,
endTime: `${plus2DateString}T09:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T10:00:00.000Z`,
endTime: `${plus2DateString}T11:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
{
userId: 101,
eventTypeId: 2,
startTime: `${plus2DateString}T12:00:00.000Z`,
endTime: `${plus2DateString}T13:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
],
};
await createBookingScenario(scenarioData);
const thisUserAvailabilityDurationLimitReached = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
const thisUserAvailabilityDurationLimitNotReached = await getSchedule({
input: {
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
let availableSlotsInTz: dayjs.Dayjs[] = [];
for (const date in thisUserAvailabilityDurationLimitReached.slots) {
thisUserAvailabilityDurationLimitReached.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
availableSlotsInTz = [];
for (const date in thisUserAvailabilityDurationLimitNotReached.slots) {
thisUserAvailabilityDurationLimitNotReached.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(
availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length
).toBeGreaterThan(0);
});
test("test that duration limit is working correctly with different timezone", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
durationLimits: {
PER_DAY: 120, // 2 hours per day
},
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "All Day available",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T00:00:00.000Z"),
endTime: new Date("1970-01-01T23:59:59.999Z"),
date: null,
},
],
timeZone: Timezones["+6:00"],
},
],
},
],
bookings: [
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T08:00:00.000Z`,
endTime: `${plus2DateString}T09:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T10:00:00.000Z`,
endTime: `${plus2DateString}T11:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
],
};
await createBookingScenario(scenarioData);
const thisUserAvailabilityDurationLimit = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["-11:00"], // attendee timezone
isTeamEvent: false,
orgSlug: null,
},
});
const availableSlotsInTz: dayjs.Dayjs[] = [];
for (const date in thisUserAvailabilityDurationLimit.slots) {
thisUserAvailabilityDurationLimit.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
});
test.skip("test that PER_WEEK duration limits work correctly", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
const { dateString: plus4DateString } = getDate({ dateIncrement: 4 });
const { dateString: plus5DateString } = getDate({ dateIncrement: 5 });
const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
durationLimits: {
PER_WEEK: 180, // 3 hours per week
},
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "All Day available",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T00:00:00.000Z"),
endTime: new Date("1970-01-01T23:59:59.999Z"),
date: null,
},
],
timeZone: Timezones["+6:00"],
},
],
},
],
bookings: [
{
userId: 101,
eventTypeId: 1,
startTime: `${plus1DateString}T08:00:00.000Z`,
endTime: `${plus1DateString}T09:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T10:00:00.000Z`,
endTime: `${plus2DateString}T11:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
{
userId: 101,
eventTypeId: 1,
startTime: `${plus3DateString}T12:00:00.000Z`,
endTime: `${plus3DateString}T13:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
],
};
await createBookingScenario(scenarioData);
const weeklyAvailability = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus5DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
const availableSlotsInTz: dayjs.Dayjs[] = [];
for (const date in weeklyAvailability.slots) {
weeklyAvailability.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(
availableSlotsInTz.filter(
(slot) =>
slot.format().startsWith(plus1DateString) ||
slot.format().startsWith(plus2DateString) ||
slot.format().startsWith(plus3DateString) ||
slot.format().startsWith(plus4DateString)
).length
).toBe(0);
});
test.skip("global team duration limit blocks slots if one fixed host reached limit", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
team: {
id: 1,
durationLimits: { PER_DAY: 120 }, // 2 hours per day
},
schedulingType: SchedulingType.COLLECTIVE,
users: [
{
id: 101,
},
{
id: 102,
},
],
},
{
id: 2,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
team: {
id: 1,
durationLimits: { PER_DAY: 120 }, // 2 hours per day
},
schedulingType: SchedulingType.COLLECTIVE,
users: [
{
id: 101,
},
{
id: 102,
},
],
},
{
id: 3,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "All Day available",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T00:00:00.000Z"),
endTime: new Date("1970-01-01T23:59:59.999Z"),
date: null,
},
],
timeZone: Timezones["+6:00"],
},
],
},
],
bookings: [
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T08:00:00.000Z`,
endTime: `${plus2DateString}T09:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T10:00:00.000Z`,
endTime: `${plus2DateString}T11:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
],
};
await createBookingScenario(scenarioData);
const availabilityEventTypeOne = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
const availableSlotsInTz: dayjs.Dayjs[] = [];
for (const date in availabilityEventTypeOne.slots) {
availabilityEventTypeOne.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
const availabilityEventTypeTwo = await getSchedule({
input: {
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
const availableSlotsInTz2: dayjs.Dayjs[] = [];
for (const date in availabilityEventTypeTwo.slots) {
availabilityEventTypeTwo.slots[date].forEach((timeObj) => {
availableSlotsInTz2.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(availableSlotsInTz2.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
const availabilityUserEventType = await getSchedule({
input: {
eventTypeId: 3,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
const availableSlotsInTz3: dayjs.Dayjs[] = [];
for (const date in availabilityUserEventType.slots) {
availabilityUserEventType.slots[date].forEach((timeObj) => {
availableSlotsInTz3.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(
availableSlotsInTz3.filter((slot) => slot.format().startsWith(plus2DateString)).length
).toBeGreaterThan(0);
});
test.skip("test that combined booking and duration limits work correctly", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
bookingLimits: {
PER_DAY: 3, // 3 bookings per day
},
durationLimits: {
PER_DAY: 120, // 2 hours per day
},
users: [
{
id: 101,
},
],
},
{
id: 2,
length: 30, // 30 minute event
beforeEventBuffer: 0,
afterEventBuffer: 0,
bookingLimits: {
PER_DAY: 2, // 2 bookings per day
},
durationLimits: {
PER_DAY: 120, // 2 hours per day
},
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "All Day available",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T00:00:00.000Z"),
endTime: new Date("1970-01-01T23:59:59.999Z"),
date: null,
},
],
timeZone: Timezones["+6:00"],
},
],
},
],
bookings: [
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T08:00:00.000Z`,
endTime: `${plus2DateString}T09:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T10:00:00.000Z`,
endTime: `${plus2DateString}T11:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
{
userId: 101,
eventTypeId: 2,
startTime: `${plus2DateString}T12:00:00.000Z`,
endTime: `${plus2DateString}T12:30:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
],
};
await createBookingScenario(scenarioData);
const eventType1Availability = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
const eventType2Availability = await getSchedule({
input: {
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
let availableSlotsInTz: dayjs.Dayjs[] = [];
for (const date in eventType1Availability.slots) {
eventType1Availability.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
availableSlotsInTz = [];
for (const date in eventType2Availability.slots) {
eventType2Availability.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(
availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length
).toBeGreaterThan(0);
// Create a new booking scenario with an additional booking
await createBookingScenario({
eventTypes: scenarioData.eventTypes,
users: scenarioData.users,
bookings: [
...scenarioData.bookings,
{
userId: 101,
eventTypeId: 2,
startTime: `${plus2DateString}T14:00:00.000Z`,
endTime: `${plus2DateString}T14:30:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
],
});
const eventType2AvailabilityUpdated = await getSchedule({
input: {
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
availableSlotsInTz = [];
for (const date in eventType2AvailabilityUpdated.slots) {
eventType2AvailabilityUpdated.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
});
test("test that booking limit is working correctly if user is all day available and attendee is in different timezone than host", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
@@ -2246,7 +2908,7 @@ describe("getSchedule", () => {
{
id: 1,
slotInterval: 45,
schedulingType: "COLLECTIVE",
schedulingType: SchedulingType.COLLECTIVE,
length: 45,
users: [
{
@@ -114,6 +114,8 @@ describe("handleChildrenEventTypes", () => {
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
...evType
} = mockFindFirstEventType({
id: 123,
@@ -137,7 +139,6 @@ describe("handleChildrenEventTypes", () => {
users: { connect: [{ id: 4 }] },
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
instantMeetingScheduleId: undefined,
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
@@ -170,6 +171,9 @@ describe("handleChildrenEventTypes", () => {
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
assignRRMembersUsingSegment,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -191,7 +195,6 @@ describe("handleChildrenEventTypes", () => {
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...rest,
assignRRMembersUsingSegment: undefined,
useEventLevelSelectedCalendars: undefined,
customReplyToEmail: null,
rrSegmentQueryValue: undefined,
@@ -275,6 +278,9 @@ describe("handleChildrenEventTypes", () => {
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
...evType
} = mockFindFirstEventType({
id: 123,
@@ -307,7 +313,6 @@ describe("handleChildrenEventTypes", () => {
requiresBookerEmailVerification: false,
userId: 4,
workflows: undefined,
hashedLink: undefined,
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
},
@@ -332,6 +337,11 @@ describe("handleChildrenEventTypes", () => {
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
rrSegmentQueryValue,
useEventLevelSelectedCalendars,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -354,9 +364,6 @@ describe("handleChildrenEventTypes", () => {
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...rest,
assignRRMembersUsingSegment: undefined,
useEventLevelSelectedCalendars: undefined,
rrSegmentQueryValue: undefined,
customReplyToEmail: null,
locations: [],
hashedLink: {
@@ -364,7 +371,6 @@ describe("handleChildrenEventTypes", () => {
},
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
instantMeetingScheduleId: undefined,
},
where: {
userId_parentId: {
@@ -396,6 +402,9 @@ describe("handleChildrenEventTypes", () => {
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
includeNoShowInRRCalculation,
instantMeetingScheduleId,
assignRRMembersUsingSegment,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
@@ -418,10 +427,14 @@ describe("handleChildrenEventTypes", () => {
schedulingType: SchedulingType.MANAGED,
requiresBookerEmailVerification: false,
lockTimeZoneToggleOnBookingPage: false,
lockedTimeZone: "Europe/London",
useEventTypeDestinationCalendarEmail: false,
workflows: [],
parentId: 1,
locations: [],
instantMeetingScheduleId: null,
assignRRMembersUsingSegment: false,
includeNoShowInRRCalculation: false,
...evType,
};
@@ -449,7 +462,6 @@ describe("handleChildrenEventTypes", () => {
recurringEvent: undefined,
eventTypeColor: undefined,
customReplyToEmail: null,
instantMeetingScheduleId: undefined,
locations: [],
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
@@ -461,28 +473,24 @@ describe("handleChildrenEventTypes", () => {
workflows: {
create: [{ workflowId: 11 }],
},
hashedLink: undefined,
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
useEventLevelSelectedCalendars: false,
},
});
const { profileId, ...rest } = evType;
const { profileId, rrSegmentQueryValue, ...rest } = evType;
if ("workflows" in rest) delete rest.workflows;
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...rest,
locations: [],
assignRRMembersUsingSegment: undefined,
useEventLevelSelectedCalendars: undefined,
customReplyToEmail: null,
rrSegmentQueryValue: undefined,
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
hashedLink: {
deleteMany: {},
},
instantMeetingScheduleId: undefined,
},
where: {
userId_parentId: {
+8
View File
@@ -15,6 +15,14 @@
{
"path": "/api/tasks/cleanup",
"schedule": "0 0 * * *"
},
{
"path": "/api/cron/credentials",
"schedule": "*/5 * * * *"
},
{
"path": "/api/cron/selected-calendars",
"schedule": "*/5 * * * *"
}
],
"functions": {
+353 -19
View File
@@ -3145,6 +3145,80 @@
"tags": ["Orgs / Teams / Bookings"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/bookings/{bookingUid}/references": {
"get": {
"operationId": "OrganizationsTeamsBookingsController_getBookingReferences",
"summary": "Get 'Booking References' for a booking",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "type",
"required": false,
"in": "query",
"description": "Filter booking references by type",
"example": "google_calendar",
"schema": {
"enum": [
"google_calendar",
"office365_calendar",
"daily_video",
"google_video",
"office365_video",
"zoom_video"
],
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookingReferencesOutput_2024_08_13"
}
}
}
}
},
"tags": ["Orgs / Teams / Bookings"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/conferencing/{app}/connect": {
"post": {
"operationId": "OrganizationsConferencingController_connectTeamApp",
@@ -7118,6 +7192,72 @@
"tags": ["Bookings"]
}
},
"/v2/bookings/{bookingUid}/references": {
"get": {
"operationId": "BookingsController_2024_08_13_getBookingReferences",
"summary": "Get 'Booking References' for a booking",
"parameters": [
{
"name": "cal-api-version",
"in": "header",
"description": "Must be set to 2024-08-13",
"required": true,
"schema": {
"type": "string",
"default": "2024-08-13"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "type",
"required": false,
"in": "query",
"description": "Filter booking references by type",
"example": "google_calendar",
"schema": {
"enum": [
"google_calendar",
"office365_calendar",
"daily_video",
"google_video",
"office365_video",
"zoom_video"
],
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookingReferencesOutput_2024_08_13"
}
}
}
}
},
"tags": ["Bookings"]
}
},
"/v2/calendars/ics-feed/save": {
"post": {
"operationId": "CalendarsController_createIcsFeed",
@@ -9110,7 +9250,7 @@
"tags": ["Organization Team Verified Resources"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phone-numbers": {
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phones": {
"get": {
"operationId": "OrgTeamsVerifiedResourcesController_getVerifiedPhoneNumbers",
"summary": "Get list of verified phone numbers of an org team.",
@@ -9214,7 +9354,7 @@
"tags": ["Organization Team Verified Resources"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phone-numbers/{id}": {
"/v2/organizations/{orgId}/teams/{teamId}/verified-resources/phones/{id}": {
"get": {
"operationId": "OrgTeamsVerifiedResourcesController_getVerifiedPhoneById",
"summary": "Get verified phone number of an org team by id.",
@@ -10976,7 +11116,7 @@
},
"patch": {
"operationId": "TeamsMembershipsController_updateTeamMembership",
"summary": "Create a membership",
"summary": "Update membership",
"parameters": [
{
"name": "Authorization",
@@ -11311,7 +11451,7 @@
"tags": ["Teams Verified Resources"]
}
},
"/v2/teams/{teamId}/verified-resources/phone-numbers": {
"/v2/teams/{teamId}/verified-resources/phones": {
"get": {
"operationId": "TeamsVerifiedResourcesController_getVerifiedPhoneNumbers",
"summary": "Get list of verified phone numbers of a team.",
@@ -11415,7 +11555,7 @@
"tags": ["Teams Verified Resources"]
}
},
"/v2/teams/{teamId}/verified-resources/phone-numbers/{id}": {
"/v2/teams/{teamId}/verified-resources/phones/{id}": {
"get": {
"operationId": "TeamsVerifiedResourcesController_getVerifiedPhoneById",
"summary": "Get verified phone number of a team by id.",
@@ -11675,7 +11815,7 @@
"tags": ["Verified Resources"]
}
},
"/v2/verified-resources/phone-numbers": {
"/v2/verified-resources/phones": {
"get": {
"operationId": "UserVerifiedResourcesController_getVerifiedPhoneNumbers",
"summary": "Get list of verified phone numbers.",
@@ -11763,7 +11903,7 @@
"tags": ["Verified Resources"]
}
},
"/v2/verified-resources/phone-numbers/{id}": {
"/v2/verified-resources/phones/{id}": {
"get": {
"operationId": "UserVerifiedResourcesController_getVerifiedPhoneById",
"summary": "Get verified phone number by id.",
@@ -14059,8 +14199,7 @@
"eightxeight-video",
"discord-video",
"demodesk-video",
"campfire-video",
"around-video"
"campfire-video"
],
"example": "cal-video"
},
@@ -16823,6 +16962,114 @@
"type": "object",
"properties": {}
},
"BaseAttribute": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": ["id", "name"]
},
"TextAttribute": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"type": {
"type": "string"
},
"value": {
"type": "string"
},
"valueId": {
"type": "string"
}
},
"required": ["id", "name", "type", "value", "valueId"]
},
"NumberAttribute": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"type": {
"type": "string"
},
"value": {
"type": "number"
},
"valueId": {
"type": "string"
}
},
"required": ["id", "name", "type", "value", "valueId"]
},
"SingleSelectAttribute": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"type": {
"type": "string"
},
"value": {
"type": "string"
},
"valueId": {
"type": "string"
}
},
"required": ["id", "name", "type", "value", "valueId"]
},
"MultiSelectAttributeValue": {
"type": "object",
"properties": {
"valueId": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": ["valueId", "value"]
},
"MultiSelectAttribute": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"type": {
"type": "string"
},
"values": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MultiSelectAttributeValue"
}
}
},
"required": ["id", "name", "type", "values"]
},
"MembershipUserOutputDto": {
"type": "object",
"properties": {
@@ -16850,7 +17097,7 @@
},
"required": ["email"]
},
"TeamMembershipOutput": {
"OrganizationMembershipOutput": {
"type": "object",
"properties": {
"id": {
@@ -16874,9 +17121,28 @@
},
"user": {
"$ref": "#/components/schemas/MembershipUserOutputDto"
},
"attributes": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/TextAttribute"
},
{
"$ref": "#/components/schemas/NumberAttribute"
},
{
"$ref": "#/components/schemas/SingleSelectAttribute"
},
{
"$ref": "#/components/schemas/MultiSelectAttribute"
}
]
}
}
},
"required": ["id", "userId", "teamId", "accepted", "role", "user"]
"required": ["id", "userId", "teamId", "accepted", "role", "user", "attributes"]
},
"GetAllOrgMemberships": {
"type": "object",
@@ -16887,7 +17153,7 @@
"enum": ["success", "error"]
},
"data": {
"$ref": "#/components/schemas/TeamMembershipOutput"
"$ref": "#/components/schemas/OrganizationMembershipOutput"
}
},
"required": ["status", "data"]
@@ -16924,7 +17190,7 @@
"enum": ["success", "error"]
},
"data": {
"$ref": "#/components/schemas/TeamMembershipOutput"
"$ref": "#/components/schemas/OrganizationMembershipOutput"
}
},
"required": ["status", "data"]
@@ -16938,7 +17204,7 @@
"enum": ["success", "error"]
},
"data": {
"$ref": "#/components/schemas/TeamMembershipOutput"
"$ref": "#/components/schemas/OrganizationMembershipOutput"
}
},
"required": ["status", "data"]
@@ -16952,7 +17218,7 @@
"enum": ["success", "error"]
},
"data": {
"$ref": "#/components/schemas/TeamMembershipOutput"
"$ref": "#/components/schemas/OrganizationMembershipOutput"
}
},
"required": ["status", "data"]
@@ -16981,7 +17247,7 @@
"enum": ["success", "error"]
},
"data": {
"$ref": "#/components/schemas/TeamMembershipOutput"
"$ref": "#/components/schemas/OrganizationMembershipOutput"
}
},
"required": ["status", "data"]
@@ -18110,6 +18376,34 @@
},
"required": ["status", "data"]
},
"TeamMembershipOutput": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"userId": {
"type": "number"
},
"teamId": {
"type": "number"
},
"accepted": {
"type": "boolean"
},
"role": {
"type": "string",
"enum": ["MEMBER", "OWNER", "ADMIN"]
},
"disableImpersonation": {
"type": "boolean"
},
"user": {
"$ref": "#/components/schemas/MembershipUserOutputDto"
}
},
"required": ["id", "userId", "teamId", "accepted", "role", "user"]
},
"OrgTeamMembershipsOutputResponseDto": {
"type": "object",
"properties": {
@@ -19837,7 +20131,7 @@
"type": "object",
"properties": {
"id": {
"type": "string"
"type": "number"
},
"formId": {
"type": "string"
@@ -20315,8 +20609,7 @@
"eightxeight-video",
"discord-video",
"demodesk-video",
"campfire-video",
"around-video"
"campfire-video"
]
}
},
@@ -22128,6 +22421,47 @@
},
"required": ["status", "data"]
},
"BookingReference": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "The type of the booking reference"
},
"eventUid": {
"type": "string",
"description": "The event uid of the booking"
},
"destinationCalendarId": {
"type": "string",
"nullable": true,
"description": "The id of the calendar the event is created in"
},
"id": {
"type": "number",
"description": "The id of the booking reference"
}
},
"required": ["type", "eventUid", "destinationCalendarId", "id"]
},
"BookingReferencesOutput_2024_08_13": {
"type": "object",
"properties": {
"status": {
"type": "object",
"description": "The status of the request, always 'success' for successful responses",
"example": "success"
},
"data": {
"description": "Booking References",
"type": "array",
"items": {
"$ref": "#/components/schemas/BookingReference"
}
}
},
"required": ["status", "data"]
},
"CreateTeamMembershipInput": {
"type": "object",
"properties": {
@@ -0,0 +1,20 @@
## Using `handleSlotReservation` for Custom Slot Selection Flows
If you want to trigger a custom action immediately when a user selects a timeslot (before the booking form appears), you can use the `handleSlotReservation` prop. This prop allows you to intercept the slot selection event, enabling you to run custom logic—such as calling the reserve slot api's https://cal.com/docs/api-reference/v2/slots/reserve-a-slot.
**Example usage:**
```tsx
<Booker
handleSlotReservation={(timeslot) => {
// Your custom logic here
console.log("User selected timeslot:", timeslot);
// For example, call the reserve slot api's etc.
}}
// ...other props
/>
```
- When a user selects a slot, the `handleSlotReservation` function will be called with the selected timeslot as its argument.
- If you provide this prop, the default slot reservation flow will be interrupted until your custom logic is complete.
- This is useful for introducing custom slot reservation flows.
+54 -64
View File
@@ -5,12 +5,11 @@ import { useTranslation } from "react-i18next";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Switch } from "@calcom/ui/components/form";
import { Badge } from "@calcom/ui/components/badge";
import { Icon } from "@calcom/ui/components/icon";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Switch } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Section } from "@calcom/ui/components/section";
import type { CredentialOwner } from "../types";
import OmniInstallAppButton from "./OmniInstallAppButton";
@@ -49,45 +48,36 @@ export default function AppCard({
const isPlatform = useIsPlatform();
return (
<div
className={classNames(
"border-subtle",
app?.isInstalled && "mb-4 rounded-md border",
!app.enabled && "grayscale"
)}>
<div className={classNames(app.isInstalled ? "p-4 text-sm sm:p-4" : "px-5 py-4 text-sm sm:px-5")}>
<div className="flex w-full flex-col gap-2 sm:flex-row sm:gap-0">
{/* Don't know why but w-[42px] isn't working, started happening when I started using next/dynamic */}
<Link
href={`/apps/${app.slug}`}
className={classNames(app?.isInstalled ? "mr-[11px]" : "mr-3", "h-auto w-10 rounded-sm")}>
<img
className={classNames(
app?.logo.includes("-dark") && "dark:invert",
app?.isInstalled ? "min-w-[42px]" : "min-w-[40px]",
"w-full"
)}
src={app?.logo}
alt={app?.name}
/>
</Link>
<div className="flex flex-col pe-3">
<div className="text-emphasis">
<span className={classNames(app?.isInstalled && "text-base", "font-semibold leading-4")}>
{app?.name}
</span>
<Section className={classNames(!app?.isInstalled && "rounded-xl")}>
<Section.Header
rawHeading={
<div>
<div className="flex w-full items-center gap-1">
<Section.Title>{app?.name}</Section.Title>
{!app?.isInstalled && (
<span className="bg-emphasis ml-1 rounded px-1 py-0.5 text-xs font-medium leading-3 tracking-[0.01em]">
{app?.categories[0].charAt(0).toUpperCase() + app?.categories[0].slice(1)}
</span>
)}
</div>
<p title={app?.description} className="text-default line-clamp-1 pt-1 text-sm font-normal">
{description || app?.description}
</p>
<Section.Description>{app?.description}</Section.Description>
</div>
<div className="ml-auto flex items-center space-x-2">
{app.credentialOwner && !isPlatform && (
}
iconSlot={
<Link href={`/apps/${app.slug}`} className="flex h-8 w-8 items-center justify-center">
<img
className={classNames(
app?.logo.includes("-dark") && "dark:invert",
"max-h-full max-w-full object-contain"
)}
src={app?.logo}
alt={app?.name}
/>
</Link>
}>
<div>
<div>
{/* {app.credentialOwner && !isPlatform && (
<div className="ml-auto">
<Badge variant="gray">
<div className="flex items-center">
@@ -101,10 +91,11 @@ export default function AppCard({
</div>
</Badge>
</div>
)}
)} */}
{app?.isInstalled || app.credentialOwner ? (
<div className="ml-auto flex items-center">
<Switch
size="sm"
disabled={!app.enabled || managedDisabled || disableSwitch}
onCheckedChange={(enabled) => {
if (switchOnClick) {
@@ -128,32 +119,31 @@ export default function AppCard({
)}
</div>
</div>
</div>
{hideAppCardOptions ? null : (
<div ref={animationRef}>
{app?.isInstalled && switchChecked && <hr className="border-subtle" />}
{app?.isInstalled && switchChecked ? (
app.isSetupAlready === undefined || app.isSetupAlready ? (
<div className="relative p-4 pt-5 text-sm [&_input]:mb-0 [&_input]:leading-4">
{!hideSettingsIcon && !isPlatform && (
<Link href={`/apps/${app.slug}/setup`} className="absolute right-4 top-4">
<Icon name="settings" className="text-default h-4 w-4" aria-hidden="true" />
</Section.Header>
{hideAppCardOptions
? null
: app?.isInstalled &&
switchChecked && (
<div ref={animationRef}>
{app.isSetupAlready === undefined || app.isSetupAlready ? (
<div className="relative text-sm [&_input]:mb-0 [&_input]:leading-4">
{!hideSettingsIcon && !isPlatform && (
<Link href={`/apps/${app.slug}/setup`} className="absolute right-4 top-4">
<Icon name="settings" className="text-default h-4 w-4" aria-hidden="true" />
</Link>
)}
{children}
</div>
) : (
<div className="flex h-64 w-full flex-col items-center justify-center gap-4 ">
<p>{t("this_app_is_not_setup_already")}</p>
<Link href={`/apps/${app.slug}/setup`}>
<Button StartIcon="settings">{t("setup")}</Button>
</Link>
)}
{children}
</div>
) : (
<div className="flex h-64 w-full flex-col items-center justify-center gap-4 ">
<p>{t("this_app_is_not_setup_already")}</p>
<Link href={`/apps/${app.slug}/setup`}>
<Button StartIcon="settings">{t("setup")}</Button>
</Link>
</div>
)
) : null}
</div>
)}
</div>
</div>
)}
</div>
)}
</Section>
);
}
@@ -5,7 +5,6 @@
import alby_config_json from "./alby/config.json";
import amie_config_json from "./amie/config.json";
import { metadata as applecalendar__metadata_ts } from "./applecalendar/_metadata";
import around_config_json from "./around/config.json";
import attio_config_json from "./attio/config.json";
import autocheckin_config_json from "./autocheckin/config.json";
import baa_for_hipaa_config_json from "./baa-for-hipaa/config.json";
@@ -112,7 +111,6 @@ export const appStoreMetadata = {
alby: alby_config_json,
amie: amie_config_json,
applecalendar: applecalendar__metadata_ts,
around: around_config_json,
attio: attio_config_json,
autocheckin: autocheckin_config_json,
"baa-for-hipaa": baa_for_hipaa_config_json,

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