Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d15490d5aa | ||
|
|
ba6e547f6c | ||
|
|
2493315f92 | ||
|
|
ec117d4034 | ||
|
|
646058b8a1 | ||
|
|
06c359ac9b | ||
|
|
0d96d9c44c | ||
|
|
e5f8d4a1ac | ||
|
|
19c72323b8 | ||
|
|
f19b2804b6 | ||
|
|
f387fdb166 | ||
|
|
ec8f9d5f7a | ||
|
|
6cb3bdc567 | ||
|
|
d574840c3f | ||
|
|
6a85d0e10b | ||
|
|
816df3704e | ||
|
|
badd9a5698 | ||
|
|
4e02fe4251 | ||
|
|
01b9780938 | ||
|
|
6be144f95f | ||
|
|
37dd6d888b | ||
|
|
f5ffee0a20 | ||
|
|
6cd51e0a8d | ||
|
|
80fad2aab9 | ||
|
|
1ccfc65766 | ||
|
|
86918b03cf | ||
|
|
a5b684af1f | ||
|
|
8889e8566c | ||
|
|
fab364f084 | ||
|
|
5bc9d71a88 | ||
|
|
2b5ea649d4 | ||
|
|
0802ebb17b | ||
|
|
29b8d68f45 | ||
|
|
6994cac5e0 | ||
|
|
df9b72583d | ||
|
|
553eab59e6 | ||
|
|
8d675fd8b6 | ||
|
|
a5639a8290 | ||
|
|
44d9c4bcc5 | ||
|
|
1b586560f8 | ||
|
|
e082cc7d3d | ||
|
|
d411e70ea3 | ||
|
|
8dffa4d371 | ||
|
|
b7bf6e80e6 | ||
|
|
2a93e661f9 | ||
|
|
bfb51b82b2 | ||
|
|
f50be6c08b | ||
|
|
df75df7008 | ||
|
|
deb55c0536 | ||
|
|
7fc11ff341 | ||
|
|
1a70f83d34 | ||
|
|
39077a08f1 | ||
|
|
9a671f9063 | ||
|
|
3bc9fa11d0 | ||
|
|
7e699ab810 | ||
|
|
1bd17ba365 | ||
|
|
4d5246cf4d | ||
|
|
5d988453fa | ||
|
|
85b9782264 | ||
|
|
c2b091aa67 | ||
|
|
7093e1e4c6 | ||
|
|
ead42c1a9c | ||
|
|
e669996442 | ||
|
|
0e6f365dc2 | ||
|
|
08b501625d | ||
|
|
b89ff43921 | ||
|
|
fadfba8830 | ||
|
|
43be9bd6a8 | ||
|
|
caf184151d | ||
|
|
22e3338709 | ||
|
|
048e56806c | ||
|
|
ade64c0d09 | ||
|
|
5beb2943b9 | ||
|
|
b3d3cb1dee | ||
|
|
79d695236f | ||
|
|
2dbbdc21f2 | ||
|
|
e4254ef7cc | ||
|
|
7f9709bdaa | ||
|
|
77054d92df | ||
|
|
1277f5e9f7 | ||
|
|
109c303b60 | ||
|
|
a52a0e90e7 | ||
|
+25 |
558140fdd6 | ||
|
|
fa09fd66ff | ||
|
|
b1cbeebe48 | ||
|
|
93bad282d7 | ||
|
|
47d941676e | ||
|
|
54c4b0b65e | ||
|
|
0f8356f417 | ||
|
|
cecfcc7c6f | ||
|
|
0b3f9d06a9 | ||
|
|
71e7300830 | ||
|
|
bccffb328b | ||
|
|
80fa172f79 | ||
|
|
50173658f8 | ||
|
|
dddbd7dad4 | ||
|
|
ae2d85d81b |
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"changelog": ["@changesets/changelog-github", { "repo": "calcom/cal.com" }],
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
"ignore": [],
|
||||
"privatePackages": {
|
||||
"version": false,
|
||||
"tag": false
|
||||
}
|
||||
}
|
||||
|
||||
+8
-6
@@ -159,13 +159,17 @@ 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_SAMPLE_RATE=
|
||||
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
|
||||
@@ -198,6 +202,7 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
||||
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||
NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID=
|
||||
STRIPE_TEAM_PRODUCT_ID=
|
||||
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
|
||||
STRIPE_ORG_MONTHLY_PRICE_ID=
|
||||
@@ -363,9 +368,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 +427,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
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/**/package.json @calcom/Foundation
|
||||
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
||||
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/components/**/* @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/modules/**/* @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/lib/**/* @calcom/Foundation
|
||||
/apps/web/middleware.ts @calcom/Foundation
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Create PR containing updated CHANGELOG.md and release packages to NPM once PR is merged
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
permissions: {} # remove permissions and set explicitly under "permissions:" below
|
||||
jobs:
|
||||
release:
|
||||
if: github.repository == 'calcom/cal.com' # prevent this action from running on forks
|
||||
permissions:
|
||||
id-token: write # for NPM provenance - developers can verify where and how it was built
|
||||
contents: write # to create release by changesets/action below
|
||||
pull-requests: write # to create pull request by changesets/action below
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup node, yarn and install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: yarn changesets-version
|
||||
publish: yarn changesets-release
|
||||
commit: "chore: version packages" # commit message
|
||||
title: "chore: version packages" # PR title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Cron - checkSmsPrices
|
||||
|
||||
on:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||
schedule:
|
||||
# Runs “every minute” (see https://crontab.guru)
|
||||
- cron: "* * * * *"
|
||||
jobs:
|
||||
cron-checkSmsPrices:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: cURL request
|
||||
if: ${{ env.APP_URL && env.CRON_API_KEY }}
|
||||
run: |
|
||||
curl ${{ secrets.APP_URL }}/api/cron/checkSmsPrices \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
|
||||
--fail
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
@@ -646,6 +646,14 @@ following
|
||||
15. Create a verify service
|
||||
16. Copy Verify Service SID to your `.env` file into the `TWILIO_VERIFY_SID` field
|
||||
|
||||
## Changesets
|
||||
|
||||
We use changesets to generate changelogs and publish public packages (packages with `private: true` are ignored).
|
||||
|
||||
An example of good readme is [atoms readme](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/README.md). Every public package must:
|
||||
1. Follow semantic versioning when using changesets.
|
||||
2. Mark breaking changes using `❗️Breaking change`
|
||||
|
||||
<!-- LICENSE -->
|
||||
|
||||
## License
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@calcom/trpc": "*",
|
||||
"@sentry/nextjs": "^8.8.0",
|
||||
"@sentry/nextjs": "^9.15.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"memory-cache": "^0.2.0",
|
||||
"next": "^13.5.9",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export {};
|
||||
@@ -3,7 +3,6 @@
|
||||
"version": "0.0.1",
|
||||
"description": "Platform API for Cal.com",
|
||||
"author": "Cal.com Inc.",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "yarn dev:build && nest build",
|
||||
@@ -38,7 +37,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.196",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.208",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -182,7 +182,7 @@ export class BookingsController_2024_08_13 {
|
||||
): Promise<GetBookingsOutput_2024_08_13> {
|
||||
const profile = this.usersService.getUserMainProfile(user);
|
||||
|
||||
const bookings = await this.bookingsService.getBookings(queryParams, {
|
||||
const { bookings, pagination } = await this.bookingsService.getBookings(queryParams, {
|
||||
email: user.email,
|
||||
id: user.id,
|
||||
orgId: profile?.organizationId,
|
||||
@@ -191,6 +191,7 @@ export class BookingsController_2024_08_13 {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: bookings,
|
||||
pagination,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let eventTypeId: number;
|
||||
let seatedEvent: EventType;
|
||||
let eventTypeWithBookingFields: EventType;
|
||||
let eventTypeWithBookingFielsCustom: EventType;
|
||||
const eventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
||||
const eventTypeWithBookingFieldsSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
||||
const seatedEventTypeSlug = `booking-fields-2024-08-13-event-type-${randomString()}`;
|
||||
@@ -331,6 +332,313 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
user.id
|
||||
);
|
||||
|
||||
eventTypeWithBookingFielsCustom = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: "test booking field types",
|
||||
slug: "test-booking-field-types",
|
||||
length: 30,
|
||||
bookingFields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "name",
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
variant: "fullName",
|
||||
editable: "system",
|
||||
required: true,
|
||||
defaultLabel: "your_name",
|
||||
variantsConfig: {
|
||||
variants: {
|
||||
fullName: {
|
||||
fields: [
|
||||
{
|
||||
name: "fullName",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
firstAndLastName: {
|
||||
fields: [
|
||||
{
|
||||
name: "firstName",
|
||||
type: "text",
|
||||
label: "",
|
||||
required: true,
|
||||
placeholder: "",
|
||||
},
|
||||
{
|
||||
name: "lastName",
|
||||
type: "text",
|
||||
label: "",
|
||||
required: false,
|
||||
placeholder: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
type: "email",
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system",
|
||||
required: true,
|
||||
defaultLabel: "email_address",
|
||||
},
|
||||
{
|
||||
name: "location",
|
||||
type: "radioInput",
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system",
|
||||
required: false,
|
||||
defaultLabel: "location",
|
||||
getOptionsAt: "locations",
|
||||
optionsInputs: {
|
||||
phone: { type: "phone", required: true, placeholder: "" },
|
||||
somewhereElse: { type: "text", required: true, placeholder: "" },
|
||||
attendeeInPerson: { type: "address", required: true, placeholder: "" },
|
||||
},
|
||||
hideWhenJustOneOption: true,
|
||||
},
|
||||
{
|
||||
name: "test-phone",
|
||||
type: "phone",
|
||||
label: "test-phone",
|
||||
hidden: false,
|
||||
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-address",
|
||||
type: "address",
|
||||
label: "test-address",
|
||||
hidden: false,
|
||||
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-text",
|
||||
type: "text",
|
||||
label: "test-text",
|
||||
hidden: false,
|
||||
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-number",
|
||||
type: "number",
|
||||
label: "test-number",
|
||||
hidden: false,
|
||||
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-textarea",
|
||||
type: "textarea",
|
||||
label: "test-textarea",
|
||||
hidden: false,
|
||||
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-boolean",
|
||||
type: "boolean",
|
||||
label: "test-boolean",
|
||||
hidden: false,
|
||||
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||
editable: "user",
|
||||
required: false,
|
||||
labelAsSafeHtml: "<p>test-boolean</p>\n",
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-url",
|
||||
type: "url",
|
||||
label: "test-url",
|
||||
hidden: false,
|
||||
sources: [{ id: "user", type: "user", label: "User", fieldRequired: true }],
|
||||
editable: "user",
|
||||
required: false,
|
||||
placeholder: "",
|
||||
labelAsSafeHtml: "<p>test-url</p>\n",
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-multiemail",
|
||||
type: "multiemail",
|
||||
label: "test-multiemail",
|
||||
hidden: false,
|
||||
sources: [
|
||||
{
|
||||
id: "user",
|
||||
type: "user",
|
||||
label: "User",
|
||||
fieldRequired: true,
|
||||
},
|
||||
],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-multiselect",
|
||||
type: "multiselect",
|
||||
label: "test-multiselect",
|
||||
hidden: false,
|
||||
options: [
|
||||
{
|
||||
label: "apple",
|
||||
value: "apple",
|
||||
},
|
||||
{
|
||||
label: "orange",
|
||||
value: "orange",
|
||||
},
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: "user",
|
||||
type: "user",
|
||||
label: "User",
|
||||
fieldRequired: true,
|
||||
},
|
||||
],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-checkbox",
|
||||
type: "checkbox",
|
||||
label: "test-checkbox",
|
||||
hidden: false,
|
||||
options: [
|
||||
{
|
||||
label: "blue",
|
||||
value: "blue",
|
||||
},
|
||||
{
|
||||
label: "red",
|
||||
value: "red",
|
||||
},
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: "user",
|
||||
type: "user",
|
||||
label: "User",
|
||||
fieldRequired: true,
|
||||
},
|
||||
],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-radio",
|
||||
type: "radio",
|
||||
label: "test-radio",
|
||||
hidden: false,
|
||||
options: [
|
||||
{
|
||||
label: "pineapple",
|
||||
value: "pineapple",
|
||||
},
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: "user",
|
||||
type: "user",
|
||||
label: "User",
|
||||
fieldRequired: true,
|
||||
},
|
||||
],
|
||||
editable: "user",
|
||||
required: false,
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "test-select",
|
||||
type: "select",
|
||||
label: "test-select",
|
||||
options: [
|
||||
{
|
||||
label: "water",
|
||||
value: "water",
|
||||
},
|
||||
{
|
||||
label: "juice",
|
||||
value: "juice",
|
||||
},
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: "user",
|
||||
type: "user",
|
||||
label: "User",
|
||||
fieldRequired: true,
|
||||
},
|
||||
],
|
||||
editable: "user",
|
||||
required: true,
|
||||
placeholder: "",
|
||||
disableOnPrefill: false,
|
||||
},
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
hidden: true,
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system-but-optional",
|
||||
required: true,
|
||||
defaultLabel: "what_is_this_meeting_about",
|
||||
defaultPlaceholder: "",
|
||||
},
|
||||
{
|
||||
name: "notes",
|
||||
type: "textarea",
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system-but-optional",
|
||||
required: false,
|
||||
defaultLabel: "additional_notes",
|
||||
defaultPlaceholder: "share_additional_notes",
|
||||
},
|
||||
{
|
||||
name: "guests",
|
||||
type: "multiemail",
|
||||
hidden: false,
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system-but-optional",
|
||||
required: false,
|
||||
defaultLabel: "additional_guests",
|
||||
defaultPlaceholder: "email",
|
||||
},
|
||||
{
|
||||
name: "rescheduleReason",
|
||||
type: "textarea",
|
||||
views: [{ id: "reschedule", label: "Reschedule View" }],
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system-but-optional",
|
||||
required: false,
|
||||
defaultLabel: "reason_for_reschedule",
|
||||
defaultPlaceholder: "reschedule_placeholder",
|
||||
},
|
||||
],
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
bookingWithSplitName = await bookingsRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
@@ -543,6 +851,318 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Booking Field Type Validation", () => {
|
||||
const basePayload = {
|
||||
start: "2025-06-19T11:00:00.000Z",
|
||||
attendee: {
|
||||
name: "Charlie TypeTest",
|
||||
email: "charlie.typetest@example.com",
|
||||
timeZone: "Europe/Madrid",
|
||||
language: "en",
|
||||
},
|
||||
};
|
||||
|
||||
it("should reject with 400 if 'test-phone' (phone) is not a string", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-phone": 12345,
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-phone'. Expected type string (compatible with field type 'phone'), but received number."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-address' (address) is not a string", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-address": true,
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-address'. Expected type string (compatible with field type 'address'), but received boolean."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-text' (text) is not a string", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-text": 123,
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-text'. Expected type string (compatible with field type 'text'), but received number."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-number' (number) is not a number", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-number": "123",
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-number'. Expected type number (compatible with field type 'number'), but received string."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-textarea' (textarea) is not a string", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-textarea": { text: "invalid" },
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-textarea'. Expected type string (compatible with field type 'textarea'), but received object."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-boolean' (boolean) is not a boolean", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-boolean": "true",
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-boolean'. Expected type boolean (compatible with field type 'boolean'), but received string."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-url' (url) is not a string", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-url": 12345,
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-url'. Expected type string (compatible with field type 'url'), but received number."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-multiemail' (multiemail) is not an array", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-multiemail": "not-an-array@example.com",
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-multiemail'. Expected type array (compatible with field type 'multiemail'), but received string."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-multiselect' (multiselect) is not an array", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-multiselect": "not-an-array",
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-multiselect'. Expected type array (compatible with field type 'multiselect'), but received string."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-checkbox' (checkbox) is not an array", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-checkbox": true,
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-checkbox'. Expected type array (compatible with field type 'checkbox'), but received boolean."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-radio' (radio) is not a string or number", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-radio": true,
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-radio'. Expected type string or number (compatible with field type 'radio'), but received boolean."
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject with 400 if 'test-select' (select) is not a string or number", async () => {
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
"test-select": true,
|
||||
},
|
||||
};
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toBe(
|
||||
"Invalid type for booking field 'test-select'. Expected type string or number (compatible with field type 'select'), but received boolean."
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for test-select field with invalid option", async () => {
|
||||
const fieldName = "test-select";
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
[fieldName]: "INVALID_DRINK",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toBe(
|
||||
`Invalid option 'INVALID_DRINK' for booking field '${fieldName}'. Allowed options are: water, juice.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for test-radio field with invalid option", async () => {
|
||||
const fieldName = "test-radio";
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
[fieldName]: "INVALID_FRUIT_FOR_RADIO",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toBe(
|
||||
`Invalid option 'INVALID_FRUIT_FOR_RADIO' for booking field '${fieldName}'. Allowed options are: pineapple.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for test-multiselect field with an invalid option", async () => {
|
||||
const fieldName = "test-multiselect";
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
[fieldName]: ["apple", "INVALID_FRUIT"],
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toBe(
|
||||
`One or more invalid options for booking field '${fieldName}'. Allowed options are: apple, orange.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException for test-checkbox field with invalid options", async () => {
|
||||
const fieldName = "test-checkbox";
|
||||
const payload = {
|
||||
...basePayload,
|
||||
eventTypeId: eventTypeWithBookingFielsCustom.id,
|
||||
bookingFieldsResponses: {
|
||||
[fieldName]: ["blue", "INVALID_COLOR"],
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings`)
|
||||
.send(payload)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toBe(
|
||||
`One or more invalid options for booking field '${fieldName}'. Allowed options are: blue, red.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { EventType, User } from "@prisma/client";
|
||||
import { EventType, User, Workflow } from "@prisma/client";
|
||||
import { advanceTo, clear } from "jest-date-mock";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
@@ -22,6 +22,8 @@ import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-type
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { WorkflowReminderRepositoryFixture } from "test/fixtures/repository/workflow-reminder.repository.fixture";
|
||||
import { WorkflowRepositoryFixture } from "test/fixtures/repository/workflow.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
VERSION_2024_08_13,
|
||||
X_CAL_CLIENT_ID,
|
||||
} from "@calcom/platform-constants";
|
||||
import { AttendeeScheduledEmail, OrganizerScheduledEmail } from "@calcom/platform-libraries/emails";
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
|
||||
@@ -60,6 +63,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let workflowReminderRepositoryFixture: WorkflowReminderRepositoryFixture;
|
||||
let workflowRepositoryFixture: WorkflowRepositoryFixture;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
|
||||
@@ -68,6 +73,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
let eventTypeId: number;
|
||||
let eventType: EventType;
|
||||
let eventTypeWithAttendeeSmsReminder: EventType;
|
||||
let workflow: Workflow;
|
||||
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||
let recurringEventTypeId: number;
|
||||
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||
@@ -97,6 +104,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
workflowReminderRepositoryFixture = new WorkflowReminderRepositoryFixture(moduleRef);
|
||||
workflowRepositoryFixture = new WorkflowRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({
|
||||
name: `user-bookings-organization-${randomString()}`,
|
||||
@@ -177,6 +186,85 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
rating: 10,
|
||||
});
|
||||
|
||||
workflow = await workflowRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
trigger: "BEFORE_EVENT",
|
||||
time: 24,
|
||||
timeUnit: "HOUR",
|
||||
position: 0,
|
||||
isActiveOnAll: false,
|
||||
name: "Attendee SMS Reminder",
|
||||
steps: {
|
||||
create: {
|
||||
stepNumber: 1,
|
||||
action: "SMS_ATTENDEE",
|
||||
sendTo: null,
|
||||
reminderBody:
|
||||
"Hi {ATTENDEE}, this is a reminder that your meeting ({EVENT_NAME}) with {ORGANIZER} is on {EVENT_DATE_YYYY MMM D} at {EVENT_TIME_h:mma} {TIMEZONE}.",
|
||||
emailSubject: "Reminder: {EVENT_NAME} - {EVENT_DATE_ddd, MMM D, YYYY h:mma}",
|
||||
template: "REMINDER",
|
||||
numberRequired: true,
|
||||
sender: "Cal",
|
||||
numberVerificationPending: false,
|
||||
includeCalendarEvent: false,
|
||||
verifiedAt: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
eventTypeWithAttendeeSmsReminder = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: "event with attendee sms reminder",
|
||||
slug: "event-with-attendee-sms-reminder",
|
||||
length: 15,
|
||||
bookingFields: [
|
||||
{
|
||||
name: "smsReminderNumber",
|
||||
type: "phone",
|
||||
sources: [
|
||||
{
|
||||
id: String(workflow.id),
|
||||
type: "workflow",
|
||||
label: "Workflow",
|
||||
editUrl: `/workflows/${workflow.id}`,
|
||||
fieldRequired: true,
|
||||
},
|
||||
],
|
||||
editable: "system",
|
||||
required: true,
|
||||
defaultLabel: "number_text_notifications",
|
||||
defaultPlaceholder: "enter_phone_number",
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
disableStandardEmails: {
|
||||
all: {
|
||||
attendee: true,
|
||||
host: true,
|
||||
},
|
||||
confirmation: {
|
||||
host: true,
|
||||
attendee: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
create: {
|
||||
workflow: {
|
||||
connect: {
|
||||
id: workflow.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -1668,7 +1756,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
describe("book by username and event type slug", () => {
|
||||
it("should not create a booking by if neither event type id nor username and event type slug provided", async () => {
|
||||
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
|
||||
const body = {
|
||||
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
|
||||
attendee: {
|
||||
@@ -1677,7 +1764,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: googleMeetUrl,
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
@@ -2227,6 +2314,91 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("create booking with attendee sms reminder", () => {
|
||||
it("should not be able create a booking if attendee sms reminder is missing", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||
eventTypeId: eventTypeWithAttendeeSmsReminder.id,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Missing attendee phone number - it is required by the event type. Pass it as \"attendee.phoneNumber\" string in the request.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should be able create a booking if attendee sms reminder is provided", async () => {
|
||||
const phoneNumber = "+37122222222";
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||
eventTypeId: eventTypeWithAttendeeSmsReminder.id,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
phoneNumber,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 15, 15, 0)).toISOString());
|
||||
expect(data.duration).toEqual(15);
|
||||
expect(data.eventTypeId).toEqual(eventTypeWithAttendeeSmsReminder.id);
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
phoneNumber: body.attendee.phoneNumber,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.bookingFieldsResponses).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
attendeePhoneNumber: body.attendee.phoneNumber,
|
||||
smsReminderNumber: body.attendee.phoneNumber,
|
||||
});
|
||||
const dbBooking = await bookingsRepositoryFixture.getById(data.id);
|
||||
expect(dbBooking?.smsReminderNumber).toEqual(phoneNumber);
|
||||
const dbWorkflowReminder = await workflowReminderRepositoryFixture.getByBookingUid(data.uid);
|
||||
expect(dbWorkflowReminder?.method).toEqual("SMS");
|
||||
} else {
|
||||
throw new Error("Invalid response data");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsRecurranceBooking(data: any): data is RecurringBookingOutput_2024_08_13 {
|
||||
return (
|
||||
!Array.isArray(data) &&
|
||||
|
||||
@@ -20,6 +20,7 @@ import { UsersRepository, UserWithProfile } from "@/modules/users/users.reposito
|
||||
import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
@@ -59,13 +60,17 @@ type CreatedBooking = {
|
||||
start: string;
|
||||
};
|
||||
|
||||
const eventTypeBookingFieldSchema = z.object({
|
||||
name: z.string(),
|
||||
required: z.boolean(),
|
||||
editable: z.string(),
|
||||
});
|
||||
const eventTypeBookingFieldSchema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
required: z.boolean(),
|
||||
editable: z.string(),
|
||||
type: z.string(),
|
||||
options: z.array(z.object({ value: z.string() })).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
|
||||
export const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
|
||||
|
||||
export type EventTypeWithOwnerAndTeam = EventType & { owner: User | null; team: Team | null };
|
||||
|
||||
@@ -171,36 +176,187 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
|
||||
const bookingFields = { ...body.bookingFieldsResponses, attendeePhoneNumber: body.attendee.phoneNumber };
|
||||
const bookingFieldsResponses: Record<string, unknown> = {
|
||||
...body.bookingFieldsResponses,
|
||||
attendeePhoneNumber: body.attendee.phoneNumber,
|
||||
smsReminderNumber: body.attendee.phoneNumber,
|
||||
};
|
||||
if (!eventType?.bookingFields) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee
|
||||
// note(Lauris): we filter out system fields, because some of them are set by default and name and email are passed in the body.attendee. Only exception
|
||||
// is smsReminderNumber, because if it is required and not passed sms workflow won't work.
|
||||
const eventTypeBookingFields = eventTypeBookingFieldsSchema
|
||||
.parse(eventType.bookingFields)
|
||||
.filter((field) => !field.editable.startsWith("system"));
|
||||
.filter((field) => !field.editable.startsWith("system") || field.name === "smsReminderNumber");
|
||||
|
||||
if (!eventTypeBookingFields.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const field of eventTypeBookingFields) {
|
||||
if (field.required && !(field.name in bookingFields)) {
|
||||
if (field.name === "attendeePhoneNumber") {
|
||||
for (const eventTypeBookingField of eventTypeBookingFields) {
|
||||
if (
|
||||
eventTypeBookingField.required &&
|
||||
(bookingFieldsResponses[eventTypeBookingField.name] === null ||
|
||||
bookingFieldsResponses[eventTypeBookingField.name] === undefined)
|
||||
) {
|
||||
if (
|
||||
eventTypeBookingField.name === "attendeePhoneNumber" ||
|
||||
eventTypeBookingField.name === "smsReminderNumber"
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" in the request.`
|
||||
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" string in the request.`
|
||||
);
|
||||
}
|
||||
throw new BadRequestException(
|
||||
`Missing required booking field response: ${field.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses. You can fetch the event type with ID ${eventType.id} to see the required fields.`
|
||||
`Missing required booking field response: ${eventTypeBookingField.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses. You can fetch the event type with ID ${eventType.id} to see the required fields.`
|
||||
);
|
||||
}
|
||||
|
||||
const bookingFieldResponseValue = bookingFieldsResponses[eventTypeBookingField.name];
|
||||
if (bookingFieldResponseValue !== undefined) {
|
||||
const bookingFieldResponseValueType = typeof bookingFieldResponseValue;
|
||||
let expectedBookingFieldResponseValueType = "";
|
||||
let isValidType = false;
|
||||
const eventTypeBookingFieldType = eventTypeBookingField.type;
|
||||
|
||||
switch (eventTypeBookingFieldType) {
|
||||
case "phone":
|
||||
expectedBookingFieldResponseValueType = "string";
|
||||
isValidType = bookingFieldResponseValueType === "string";
|
||||
break;
|
||||
case "address":
|
||||
expectedBookingFieldResponseValueType = "string";
|
||||
isValidType = bookingFieldResponseValueType === "string";
|
||||
break;
|
||||
case "text":
|
||||
expectedBookingFieldResponseValueType = "string";
|
||||
isValidType = bookingFieldResponseValueType === "string";
|
||||
break;
|
||||
case "number":
|
||||
expectedBookingFieldResponseValueType = "number";
|
||||
isValidType = bookingFieldResponseValueType === "number";
|
||||
break;
|
||||
case "textarea":
|
||||
expectedBookingFieldResponseValueType = "string";
|
||||
isValidType = bookingFieldResponseValueType === "string";
|
||||
break;
|
||||
case "multiemail":
|
||||
expectedBookingFieldResponseValueType = "array";
|
||||
isValidType = Array.isArray(bookingFieldResponseValue);
|
||||
break;
|
||||
case "boolean":
|
||||
expectedBookingFieldResponseValueType = "boolean";
|
||||
isValidType = bookingFieldResponseValueType === "boolean";
|
||||
break;
|
||||
case "select":
|
||||
expectedBookingFieldResponseValueType = "string or number";
|
||||
isValidType =
|
||||
bookingFieldResponseValueType === "string" || bookingFieldResponseValueType === "number";
|
||||
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
|
||||
const submittedValue = bookingFieldResponseValue as string | number;
|
||||
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||
if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) {
|
||||
throw new BadRequestException(
|
||||
`Invalid option '${submittedValue}' for booking field '${
|
||||
eventTypeBookingField.name
|
||||
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "multiselect":
|
||||
expectedBookingFieldResponseValueType = "array";
|
||||
isValidType = Array.isArray(bookingFieldResponseValue);
|
||||
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
|
||||
const submittedValues = bookingFieldResponseValue as (string | number)[];
|
||||
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||
if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) {
|
||||
throw new BadRequestException(
|
||||
`One or more invalid options for booking field '${
|
||||
eventTypeBookingField.name
|
||||
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "checkbox":
|
||||
expectedBookingFieldResponseValueType = "array";
|
||||
isValidType = Array.isArray(bookingFieldResponseValue);
|
||||
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
|
||||
const submittedValues = bookingFieldResponseValue as (string | number)[];
|
||||
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||
if (!this.areValidMultipleOptionValues(submittedValues, allowedOptionValues)) {
|
||||
throw new BadRequestException(
|
||||
`One or more invalid options for booking field '${
|
||||
eventTypeBookingField.name
|
||||
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "radio":
|
||||
expectedBookingFieldResponseValueType = "string or number";
|
||||
isValidType =
|
||||
bookingFieldResponseValueType === "string" || bookingFieldResponseValueType === "number";
|
||||
if (isValidType && Array.isArray(eventTypeBookingField.options)) {
|
||||
const submittedValue = bookingFieldResponseValue as string | number;
|
||||
const allowedOptionValues = eventTypeBookingField.options.map((opt) => opt.value);
|
||||
if (!this.isValidSingleOptionValue(submittedValue, allowedOptionValues)) {
|
||||
throw new BadRequestException(
|
||||
`Invalid option '${submittedValue}' for booking field '${
|
||||
eventTypeBookingField.name
|
||||
}'. Allowed options are: ${allowedOptionValues.join(", ")}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "url":
|
||||
expectedBookingFieldResponseValueType = "string";
|
||||
isValidType = bookingFieldResponseValueType === "string";
|
||||
break;
|
||||
default:
|
||||
// note(Lauris): by default pass the field if we have a missing "case" in the switch
|
||||
isValidType = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isValidType) {
|
||||
throw new BadRequestException(
|
||||
`Invalid type for booking field '${eventTypeBookingField.name}'. Expected type ${expectedBookingFieldResponseValueType} (compatible with field type '${eventTypeBookingFieldType}'), but received ${bookingFieldResponseValueType}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private isValidSingleOptionValue(
|
||||
bookingFieldResponseValue: string | number,
|
||||
eventTypeBookingFieldOptions: string[]
|
||||
): boolean {
|
||||
if (eventTypeBookingFieldOptions.length === 0) {
|
||||
// note(Lauris): If no options defined, cannot validate against them, so pass.
|
||||
return true;
|
||||
}
|
||||
return eventTypeBookingFieldOptions.some((val) => val === String(bookingFieldResponseValue));
|
||||
}
|
||||
|
||||
private areValidMultipleOptionValues(
|
||||
bookingFieldResponseValues: (string | number)[],
|
||||
eventTypeBookingFieldOptions: string[]
|
||||
): boolean {
|
||||
if (eventTypeBookingFieldOptions.length === 0) {
|
||||
// note(Lauris): If no options defined, cannot validate against them, so pass.
|
||||
return true;
|
||||
}
|
||||
return bookingFieldResponseValues.every((submittedVal) =>
|
||||
eventTypeBookingFieldOptions.some((allowedVal) => allowedVal === String(submittedVal))
|
||||
);
|
||||
}
|
||||
|
||||
async createInstantBooking(
|
||||
request: Request,
|
||||
body: CreateInstantBookingInput_2024_08_13,
|
||||
@@ -376,7 +532,7 @@ export class BookingsService_2024_08_13 {
|
||||
queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user);
|
||||
}
|
||||
|
||||
const fetchedBookings: { bookings: { id: number }[] } = await getAllUserBookings({
|
||||
const fetchedBookings: { bookings: { id: number }[]; totalCount: number } = await getAllUserBookings({
|
||||
bookingListingByStatus: queryParams.status || [],
|
||||
skip: queryParams.skip ?? 0,
|
||||
take: queryParams.take ?? 100,
|
||||
@@ -432,7 +588,29 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
}
|
||||
|
||||
return formattedBookings;
|
||||
const skip = Math.abs(queryParams?.skip ?? 0);
|
||||
const take = Math.abs(queryParams?.take ?? 100);
|
||||
const itemsPerPage = take;
|
||||
const totalPages = itemsPerPage !== 0 ? Math.ceil(fetchedBookings.totalCount / itemsPerPage) : 0;
|
||||
const currentPage = Math.floor(skip / itemsPerPage) + 1;
|
||||
const hasNextPage = skip + itemsPerPage < fetchedBookings.totalCount;
|
||||
const hasPreviousPage = skip > 0;
|
||||
return {
|
||||
bookings: formattedBookings,
|
||||
pagination: {
|
||||
totalItems: fetchedBookings.totalCount,
|
||||
// clamp remainingItems between 0 and totalCount
|
||||
remainingItems: Math.min(
|
||||
Math.max(fetchedBookings.totalCount - (skip + take), 0),
|
||||
fetchedBookings.totalCount
|
||||
),
|
||||
itemsPerPage: itemsPerPage,
|
||||
currentPage: currentPage,
|
||||
totalPages: totalPages,
|
||||
hasNextPage: hasNextPage,
|
||||
hasPreviousPage: hasPreviousPage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getAttendeeEmail(queryParamsAttendeeEmail: string, user: { id: number }) {
|
||||
@@ -572,6 +750,20 @@ export class BookingsService_2024_08_13 {
|
||||
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
|
||||
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
||||
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
|
||||
|
||||
if (!bookingBefore) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found.`);
|
||||
}
|
||||
|
||||
const nowUtc = DateTime.utc();
|
||||
const bookingStartTimeUtc = DateTime.fromJSDate(bookingBefore.startTime, { zone: "utc" });
|
||||
|
||||
if (nowUtc < bookingStartTimeUtc) {
|
||||
throw new BadRequestException(
|
||||
`Bookings can only be marked as absent after their scheduled start time. Current time in UTC+0: ${nowUtc.toISO()}, Booking start time in UTC+0: ${bookingStartTimeUtc.toISO()}`
|
||||
);
|
||||
}
|
||||
|
||||
const platformClientParams = bookingBefore?.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(bookingBefore.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { EventTypeWithOwnerAndTeam } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import {
|
||||
eventTypeBookingFieldsSchema,
|
||||
EventTypeWithOwnerAndTeam,
|
||||
} from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import {
|
||||
bookingResponsesSchema,
|
||||
seatedBookingDataSchema,
|
||||
@@ -159,6 +162,12 @@ export class InputBookingsService_2024_08_13 {
|
||||
this.isBookingLocationWithEventTypeLocations(inputLocation, eventType);
|
||||
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
|
||||
|
||||
const needsSmsReminderNumber = eventType.bookingFields
|
||||
? eventTypeBookingFieldsSchema
|
||||
.parse(eventType.bookingFields)
|
||||
.find((field) => field.name === "smsReminderNumber")
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
start: startTime.toISO(),
|
||||
end: endTime.toISO(),
|
||||
@@ -174,6 +183,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
name: inputBooking.attendee.name,
|
||||
email: attendeeEmail ?? "",
|
||||
attendeePhoneNumber: inputBooking.attendee.phoneNumber,
|
||||
smsReminderNumber: needsSmsReminderNumber ? inputBooking.attendee.phoneNumber : undefined,
|
||||
guests,
|
||||
location,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OrganizerIntegrationLocation } from "@calcom/lib";
|
||||
import { OrganizerIntegrationLocation } from "@calcom/lib/event-types/transformers/internal/locations";
|
||||
|
||||
type BaseEventType = {
|
||||
length: number;
|
||||
|
||||
+62
-2
@@ -1428,7 +1428,6 @@ describe("Event types Endpoints", () => {
|
||||
{
|
||||
name: "location",
|
||||
type: "radioInput",
|
||||
label: "",
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system",
|
||||
required: false,
|
||||
@@ -1558,7 +1557,6 @@ describe("Event types Endpoints", () => {
|
||||
{
|
||||
name: "location",
|
||||
type: "radioInput",
|
||||
label: "",
|
||||
sources: [{ id: "default", type: "default", label: "Default" }],
|
||||
editable: "system",
|
||||
required: false,
|
||||
@@ -1783,6 +1781,68 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("creating event type with input of another event type output", () => {
|
||||
let firstCreatedEventType: EventTypeOutput_2024_06_14;
|
||||
let secondCreatedEventType: EventTypeOutput_2024_06_14;
|
||||
|
||||
it("should create first event type", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "first created coding class",
|
||||
slug: "first-created-coding-class",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "address",
|
||||
address: "via volturno 10, Roma",
|
||||
public: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.locations).toEqual(body.locations);
|
||||
firstCreatedEventType = responseBody.data;
|
||||
});
|
||||
});
|
||||
|
||||
it("should create second event type using first as input", async () => {
|
||||
const body = {
|
||||
...firstCreatedEventType,
|
||||
title: "second created coding class",
|
||||
slug: "second-created-coding-class",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
secondCreatedEventType = responseBody.data;
|
||||
|
||||
const { id, title, slug, ...restFirst } = firstCreatedEventType;
|
||||
const { id: id2, title: title2, slug: slug2, ...restSecond } = secondCreatedEventType;
|
||||
expect(restFirst).toEqual(restSecond);
|
||||
expect(id2).not.toEqual(id);
|
||||
expect(title2).not.toEqual(title);
|
||||
expect(slug2).not.toEqual(slug);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
@@ -8,7 +8,6 @@ import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
@@ -19,7 +18,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule, ConferencingModule],
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule],
|
||||
providers: [
|
||||
EventTypesRepository_2024_06_14,
|
||||
EventTypesService_2024_06_14,
|
||||
|
||||
+33
-13
@@ -5,6 +5,9 @@ import { ConferencingService } from "@/modules/conferencing/services/conferencin
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { CONFERENCING_APPS } from "@calcom/platform-constants";
|
||||
import { getUsersCredentials } from "@calcom/platform-libraries";
|
||||
import { getApps } from "@calcom/platform-libraries/app-store";
|
||||
import {
|
||||
transformBookingFieldsApiToInternal,
|
||||
transformLocationsApiToInternal,
|
||||
@@ -50,8 +53,7 @@ interface ValidationContext {
|
||||
export class InputEventTypesService_2024_06_14 {
|
||||
constructor(
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly conferencingService: ConferencingService
|
||||
private readonly calendarsService: CalendarsService
|
||||
) {}
|
||||
|
||||
async transformAndValidateCreateEventTypeInput(
|
||||
@@ -104,13 +106,6 @@ export class InputEventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
transformInputCreateEventType(inputEventType: CreateEventTypeInput_2024_06_14) {
|
||||
const defaultLocations: CreateEventTypeInput_2024_06_14["locations"] = [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
];
|
||||
|
||||
const {
|
||||
lengthInMinutes,
|
||||
lengthInMinutesOptions,
|
||||
@@ -130,10 +125,11 @@ export class InputEventTypesService_2024_06_14 {
|
||||
} = inputEventType;
|
||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||
|
||||
const locationsTransformed = locations?.length ? this.transformInputLocations(locations) : undefined;
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
locations: this.transformInputLocations(locations || defaultLocations),
|
||||
locations: locationsTransformed,
|
||||
bookingFields: this.transformInputBookingFields(bookingFields),
|
||||
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
||||
durationLimits: bookingLimitsDuration
|
||||
@@ -228,6 +224,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
const systemCustomNameField = systemCustomFields?.find((field) => field.type === "name");
|
||||
const systemCustomEmailField = systemCustomFields?.find((field) => field.type === "email");
|
||||
const systemCustomTitleField = systemCustomFields?.find((field) => field.name === "title");
|
||||
const systemCustomLocationField = systemCustomFields?.find((field) => field.name === "location");
|
||||
const systemCustomNotesField = systemCustomFields?.find((field) => field.name === "notes");
|
||||
const systemCustomGuestsField = systemCustomFields?.find((field) => field.name === "guests");
|
||||
const systemCustomRescheduleReasonField = systemCustomFields?.find(
|
||||
@@ -237,7 +234,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
const defaultFieldsBefore: (SystemField | CustomField)[] = [
|
||||
systemCustomNameField || systemBeforeFieldName,
|
||||
systemCustomEmailField || systemBeforeFieldEmail,
|
||||
systemBeforeFieldLocation,
|
||||
systemCustomLocationField || systemBeforeFieldLocation,
|
||||
];
|
||||
|
||||
const defaultFieldsAfter = [
|
||||
@@ -278,7 +275,8 @@ export class InputEventTypesService_2024_06_14 {
|
||||
field.name !== "title" &&
|
||||
field.name !== "notes" &&
|
||||
field.name !== "guests" &&
|
||||
field.name !== "rescheduleReason"
|
||||
field.name !== "rescheduleReason" &&
|
||||
field.name !== "location"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -463,10 +461,32 @@ export class InputEventTypesService_2024_06_14 {
|
||||
if (location.type === "integration") {
|
||||
// cal-video is global, so we can skip this check
|
||||
if (location.integration !== "cal-video") {
|
||||
await this.conferencingService.checkAppIsValidAndConnected(user, location.integration);
|
||||
await this.checkAppIsValidAndConnected(user, location.integration);
|
||||
}
|
||||
}
|
||||
}) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
async checkAppIsValidAndConnected(user: UserWithProfile, appSlug: string) {
|
||||
const conferencingApps = ["google-meet", "office365-video", "zoom"];
|
||||
if (!conferencingApps.includes(appSlug)) {
|
||||
throw new BadRequestException("Invalid app, available apps are: ", conferencingApps.join(", "));
|
||||
}
|
||||
|
||||
if (appSlug === "office365-video") {
|
||||
appSlug = "msteams";
|
||||
}
|
||||
|
||||
const credentials = await getUsersCredentials(user);
|
||||
|
||||
const foundApp = getApps(credentials, true).filter((app) => app.slug === appSlug)[0];
|
||||
|
||||
const appLocation = foundApp?.appData?.location;
|
||||
|
||||
if (!foundApp || !appLocation) {
|
||||
throw new BadRequestException(`${appSlug} not connected.`);
|
||||
}
|
||||
return foundApp.credential;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ if (process.env.SENTRY_DSN) {
|
||||
// Ensure to call this before requiring any other modules!
|
||||
Sentry.init({
|
||||
dsn: getEnv("SENTRY_DSN"),
|
||||
integrations: [nodeProfilingIntegration()],
|
||||
integrations: [nodeProfilingIntegration(), Sentry.prismaIntegration()],
|
||||
// Performance Monitoring
|
||||
tracesSampleRate: getEnv("SENTRY_TRACES_SAMPLE_RATE") ?? 1.0, // Capture 100% of the transactions
|
||||
// Set sampling rate for profiling - this is relative to tracesSampleRate
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BillingProcessor } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
||||
@@ -5,6 +6,7 @@ import { BillingConfigService } from "@/modules/billing/services/billing.config.
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
@@ -33,6 +35,8 @@ import { Module } from "@nestjs/common";
|
||||
BillingRepository,
|
||||
BillingProcessor,
|
||||
ManagedOrganizationsBillingService,
|
||||
OAuthClientRepository,
|
||||
BookingsRepository_2024_08_13,
|
||||
],
|
||||
exports: [BillingService, BillingRepository, ManagedOrganizationsBillingService],
|
||||
controllers: [BillingController],
|
||||
|
||||
@@ -15,12 +15,21 @@ export class BillingRepository {
|
||||
},
|
||||
});
|
||||
|
||||
async getBillingForTeamBySubscriptionId(subscriptionId: string) {
|
||||
return this.dbRead.prisma.platformBilling.findFirst({
|
||||
where: {
|
||||
subscriptionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateTeamBilling(
|
||||
teamId: number,
|
||||
billingStart: number,
|
||||
billingEnd: number,
|
||||
plan: PlatformPlan,
|
||||
subscriptionId?: string
|
||||
subscriptionId?: string,
|
||||
priceId?: string
|
||||
) {
|
||||
return this.dbWrite.prisma.platformBilling.update({
|
||||
where: {
|
||||
@@ -32,6 +41,7 @@ export class BillingRepository {
|
||||
subscriptionId,
|
||||
plan: plan.toString(),
|
||||
overdue: false,
|
||||
priceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -127,6 +127,9 @@ export class BillingController {
|
||||
case "customer.subscription.deleted":
|
||||
await this.billingService.handleStripeSubscriptionDeleted(event);
|
||||
break;
|
||||
case "invoice.created":
|
||||
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
|
||||
break;
|
||||
case "invoice.payment_failed":
|
||||
await this.billingService.handleStripePaymentFailed(event);
|
||||
break;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { InjectQueue } from "@nestjs/bull";
|
||||
import {
|
||||
BadRequestException,
|
||||
@@ -30,6 +33,9 @@ export class BillingService implements OnModuleDestroy {
|
||||
private readonly billingRepository: BillingRepository,
|
||||
private readonly configService: ConfigService<AppConfig>,
|
||||
private readonly billingConfigService: BillingConfigService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
@InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue
|
||||
) {
|
||||
this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
|
||||
@@ -119,7 +125,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
return url;
|
||||
}
|
||||
|
||||
async setSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
|
||||
async setPerBookingSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
|
||||
const billingCycleStart = DateTime.now().get("day");
|
||||
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
|
||||
|
||||
@@ -132,6 +138,25 @@ export class BillingService implements OnModuleDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
async setPerActiveUserSubscriptionForTeam(
|
||||
teamId: number,
|
||||
subscriptionId: string,
|
||||
plan: PlatformPlan,
|
||||
priceId: string
|
||||
) {
|
||||
const billingCycleStart = DateTime.now().get("day");
|
||||
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
|
||||
|
||||
return this.billingRepository.updateTeamBilling(
|
||||
teamId,
|
||||
billingCycleStart,
|
||||
billingCycleEnd,
|
||||
plan,
|
||||
subscriptionId,
|
||||
priceId
|
||||
);
|
||||
}
|
||||
|
||||
async handleStripeSubscriptionDeleted(event: Stripe.Event) {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const teamId = subscription?.metadata?.teamId;
|
||||
@@ -204,9 +229,19 @@ export class BillingService implements OnModuleDestroy {
|
||||
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
|
||||
return;
|
||||
}
|
||||
const isPriceIdPresent = Boolean(checkoutSession.metadata?.priceId);
|
||||
|
||||
if (checkoutSession.mode === "subscription") {
|
||||
await this.setSubscriptionForTeam(
|
||||
if (checkoutSession.mode === "subscription" && isPriceIdPresent) {
|
||||
await this.setPerActiveUserSubscriptionForTeam(
|
||||
teamId,
|
||||
checkoutSession.subscription as string,
|
||||
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan],
|
||||
checkoutSession.metadata?.priceId
|
||||
);
|
||||
}
|
||||
|
||||
if (checkoutSession.mode === "subscription" && !isPriceIdPresent) {
|
||||
await this.setPerBookingSubscriptionForTeam(
|
||||
teamId,
|
||||
checkoutSession.subscription as string,
|
||||
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
|
||||
@@ -220,6 +255,67 @@ export class BillingService implements OnModuleDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
async handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event) {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
||||
|
||||
if (!subscriptionId) {
|
||||
throw new NotFoundException("No subscription found for team");
|
||||
}
|
||||
|
||||
const teamWithBilling = await this.billingRepository.getBillingForTeamBySubscriptionId(subscriptionId);
|
||||
|
||||
if (teamWithBilling?.plan === "PER_ACTIVE_USER") {
|
||||
const activeManagedUsersCount = await this.getActiveManagedUsersCount(
|
||||
subscriptionId,
|
||||
new Date(invoice.period_start * 1000),
|
||||
new Date(invoice.period_end * 1000)
|
||||
);
|
||||
|
||||
await this.stripeService.getStripe().subscriptions.update(subscriptionId, {
|
||||
items: [
|
||||
{
|
||||
quantity: activeManagedUsersCount > 0 ? activeManagedUsersCount : 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveManagedUsersCount(subscriptionId: string, invoiceStart: Date, invoiceEnd: Date) {
|
||||
const managedUsersEmails = await this.usersRepository.getOrgsManagedUserEmailsBySubscriptionId(
|
||||
subscriptionId
|
||||
);
|
||||
|
||||
if (!managedUsersEmails) return 0;
|
||||
|
||||
if (!invoiceStart || !invoiceEnd) {
|
||||
this.logger.log("Invoice period start or end date is null");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const activeManagedUserEmailsAsHost = await this.usersRepository.getActiveManagedUsersAsHost(
|
||||
subscriptionId,
|
||||
invoiceStart,
|
||||
invoiceEnd
|
||||
);
|
||||
|
||||
const activeHostEmails = activeManagedUserEmailsAsHost.map((email) => email.email);
|
||||
const notActiveHostEmails = managedUsersEmails
|
||||
.filter((email) => !activeHostEmails.includes(email.email))
|
||||
.map((email) => email.email);
|
||||
|
||||
if (notActiveHostEmails.length === 0) return activeManagedUserEmailsAsHost.length;
|
||||
|
||||
const activeManagedUserEmailsAsAttendee = await this.usersRepository.getActiveManagedUsersAsAttendee(
|
||||
notActiveHostEmails,
|
||||
invoiceStart,
|
||||
invoiceEnd
|
||||
);
|
||||
|
||||
return activeManagedUserEmailsAsAttendee.length + activeManagedUserEmailsAsHost.length;
|
||||
}
|
||||
|
||||
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
|
||||
@@ -263,7 +359,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
proration_behavior: "create_prorations",
|
||||
});
|
||||
|
||||
await this.setSubscriptionForTeam(
|
||||
await this.setPerBookingSubscriptionForTeam(
|
||||
teamId,
|
||||
teamWithBilling?.platformBilling?.subscriptionId,
|
||||
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
|
||||
|
||||
@@ -4,6 +4,7 @@ export enum PlatformPlan {
|
||||
ESSENTIALS = "ESSENTIALS",
|
||||
SCALE = "SCALE",
|
||||
ENTERPRISE = "ENTERPRISE",
|
||||
PER_ACTIVE_USER = "PER_ACTIVE_USER",
|
||||
}
|
||||
|
||||
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
|
||||
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE" | "PER_ACTIVE_USER";
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ export class OAuthClientUsersController {
|
||||
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
||||
await this.tokensRepository.createOAuthTokens(oAuthClientId, id, true);
|
||||
await this.tokensRepository.forceRefreshOAuthTokens(oAuthClientId, id);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
|
||||
@@ -45,7 +45,7 @@ export class OrganizationsBookingsController {
|
||||
): Promise<GetBookingsOutput_2024_08_13> {
|
||||
const { userIds, ...restParams } = queryParams;
|
||||
|
||||
const bookings = await this.bookingsService.getBookings(
|
||||
const { bookings, pagination } = await this.bookingsService.getBookings(
|
||||
{ ...restParams },
|
||||
{ email: user.email, id: user.id, orgId },
|
||||
userIds
|
||||
@@ -54,6 +54,7 @@ export class OrganizationsBookingsController {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: bookings,
|
||||
pagination,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
@@ -27,7 +28,8 @@ export class InputOrganizationsEventTypesService {
|
||||
private readonly teamsRepository: TeamsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||
private readonly conferencingService: OrganizationsConferencingService
|
||||
private readonly conferencingService: OrganizationsConferencingService,
|
||||
private readonly conferencingRepository: ConferencingRepository
|
||||
) {}
|
||||
async transformAndValidateCreateTeamEventTypeInput(
|
||||
userId: number,
|
||||
@@ -306,6 +308,22 @@ export class InputOrganizationsEventTypesService {
|
||||
}) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
async checkAppIsValidAndConnected(teamId: number, app: string) {
|
||||
const conferencingApps = ["google-meet", "office365-video", "zoom"];
|
||||
if (!conferencingApps.includes(app)) {
|
||||
throw new BadRequestException("Invalid app, available apps are: ", conferencingApps.join(", "));
|
||||
}
|
||||
if (app === "office365-video") {
|
||||
app = "msteams";
|
||||
}
|
||||
const credential = await this.conferencingRepository.findTeamConferencingApp(teamId, app);
|
||||
|
||||
if (!credential) {
|
||||
throw new BadRequestException(`${app} not connected.`);
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityValue(priority: keyof typeof HostPriority): number {
|
||||
|
||||
+196
-10
@@ -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 } },
|
||||
|
||||
+5
-9
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+59
-6
@@ -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,
|
||||
});
|
||||
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -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;
|
||||
}
|
||||
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
|
||||
+114
@@ -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[];
|
||||
}
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
|
||||
+89
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
+25
-8
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
],
|
||||
|
||||
+2
-1
@@ -54,7 +54,7 @@ export class OrganizationsTeamsBookingsController {
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetBookingsOutput_2024_08_13> {
|
||||
const bookings = await this.bookingsService.getBookings(
|
||||
const { bookings, pagination } = await this.bookingsService.getBookings(
|
||||
{ ...queryParams, teamId },
|
||||
{ email: user.email, id: user.id, orgId }
|
||||
);
|
||||
@@ -62,6 +62,7 @@ export class OrganizationsTeamsBookingsController {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: bookings,
|
||||
pagination,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -40,7 +40,7 @@ export class OrganizationsUsersBookingsController {
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Query() query: GetBookingsInput_2024_08_13
|
||||
): Promise<GetBookingsOutput_2024_08_13> {
|
||||
const bookings = await this.organizationUsersBookingsService.getOrganizationUserBookings(
|
||||
const { bookings, pagination } = await this.organizationUsersBookingsService.getOrganizationUserBookings(
|
||||
orgId,
|
||||
userId,
|
||||
query
|
||||
@@ -49,6 +49,7 @@ export class OrganizationsUsersBookingsController {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: bookings,
|
||||
pagination,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -29,6 +29,7 @@ import {
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
PaginationMetaDto,
|
||||
} from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
@@ -373,17 +374,26 @@ describe("Organizations UsersBookings Endpoints 2024-08-13", () => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.pagination).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
const pagination: PaginationMetaDto = responseBody.pagination;
|
||||
expect(data.length).toEqual(3);
|
||||
expect(data.find((booking) => booking.id === createdPersonalBooking.id)).toBeDefined();
|
||||
expect(data.find((booking) => booking.id === createdTeamBooking.id)).toBeDefined();
|
||||
expect(
|
||||
data.find((booking) => booking.id === createdPersonalBookingUsingUsername.id)
|
||||
).toBeDefined();
|
||||
expect(pagination.totalItems).toEqual(3);
|
||||
expect(pagination.remainingItems).toEqual(0);
|
||||
expect(pagination.hasNextPage).toEqual(false);
|
||||
expect(pagination.hasPreviousPage).toEqual(false);
|
||||
expect(pagination.itemsPerPage).toEqual(100);
|
||||
expect(pagination.totalPages).toEqual(1);
|
||||
expect(pagination.currentPage).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
+86
@@ -685,6 +685,92 @@ describe("Slots 2024-04-15 Endpoints", () => {
|
||||
await bookingsRepositoryFixture.deleteById(booking.id);
|
||||
});
|
||||
|
||||
describe("routingFormResponseId and _isDryRun validation", () => {
|
||||
const baseUrl = "/api/v2/slots/available";
|
||||
const baseParams = {
|
||||
startTime: "2050-09-05",
|
||||
endTime: "2050-09-10",
|
||||
};
|
||||
|
||||
it("should allow routingFormResponseId=0 in dry-run mode", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(baseUrl)
|
||||
.query({
|
||||
...baseParams,
|
||||
eventTypeId,
|
||||
routingFormResponseId: 0,
|
||||
_isDryRun: true,
|
||||
})
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject routingFormResponseId=1 in dry-run mode", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(baseUrl)
|
||||
.query({
|
||||
...baseParams,
|
||||
eventTypeId,
|
||||
routingFormResponseId: 1,
|
||||
_isDryRun: true,
|
||||
})
|
||||
.expect(400)
|
||||
.expect((res) => {
|
||||
expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain(
|
||||
"routingFormResponseId must be 0 for dry run"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow routingFormResponseId=1 in non-dry-run mode", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(baseUrl)
|
||||
.query({
|
||||
...baseParams,
|
||||
eventTypeId,
|
||||
routingFormResponseId: 1,
|
||||
})
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.status).toEqual(SUCCESS_STATUS);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject routingFormResponseId=0 in non-dry-run mode", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(baseUrl)
|
||||
.query({
|
||||
...baseParams,
|
||||
eventTypeId,
|
||||
routingFormResponseId: 0,
|
||||
})
|
||||
.expect(400)
|
||||
.expect((res) => {
|
||||
expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain(
|
||||
"routingFormResponseId must be a positive number"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject routingFormResponseId=-1 in non-dry-run mode", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(baseUrl)
|
||||
.query({
|
||||
...baseParams,
|
||||
eventTypeId,
|
||||
routingFormResponseId: -1,
|
||||
})
|
||||
.expect(400)
|
||||
.expect((res) => {
|
||||
expect(res.body.error.details.errors[0].constraints.routingFormResponseIdValidator).toContain(
|
||||
"routingFormResponseId must be a positive number"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await selectedSlotsRepositoryFixture.deleteByUId(reservedSlotUid);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import type { RangeSlots, TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import { SlotsService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots.service";
|
||||
import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common";
|
||||
import { Query, Body, Controller, Get, Delete, Post, Req, Res, BadRequestException } from "@nestjs/common";
|
||||
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger";
|
||||
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
|
||||
@@ -157,7 +157,11 @@ export class SlotsController_2024_04_15 {
|
||||
@Query() query: GetAvailableSlotsInput_2024_04_15,
|
||||
@Req() req: ExpressRequest
|
||||
): Promise<ApiResponse<{ slots: TimeSlots["slots"] | RangeSlots["slots"] }>> {
|
||||
const isTeamEvent = await this.slotsService.checkIfIsTeamEvent(query.eventTypeId);
|
||||
try {
|
||||
const isTeamEvent =
|
||||
query.isTeamEvent === undefined
|
||||
? await this.slotsService.checkIfIsTeamEvent(query.eventTypeId)
|
||||
: query.isTeamEvent;
|
||||
const availableSlots = await getAvailableSlots({
|
||||
input: {
|
||||
...query,
|
||||
@@ -168,19 +172,29 @@ export class SlotsController_2024_04_15 {
|
||||
},
|
||||
});
|
||||
|
||||
const { slots } = await this.slotsOutputService.getOutputSlots(
|
||||
availableSlots,
|
||||
query.duration,
|
||||
query.eventTypeId,
|
||||
query.slotFormat,
|
||||
query.timeZone
|
||||
);
|
||||
const { slots } = await this.slotsOutputService.getOutputSlots(
|
||||
availableSlots,
|
||||
query.duration,
|
||||
query.eventTypeId,
|
||||
query.slotFormat,
|
||||
query.timeZone
|
||||
);
|
||||
|
||||
return {
|
||||
data: {
|
||||
slots,
|
||||
},
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
return {
|
||||
data: {
|
||||
slots,
|
||||
},
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("Invalid time range given")) {
|
||||
throw new BadRequestException(
|
||||
"Invalid time range given - check the 'startTime' and 'endTime' query parameters."
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ export class SlotsService_2024_04_15 {
|
||||
|
||||
let shouldReserveSlot = true;
|
||||
if (eventType.seatsPerTimeSlot) {
|
||||
const bookingWithAttendees = await this.slotsRepo.getBookingWithAttendees(input.bookingUid);
|
||||
const bookingWithAttendees = input.bookingUid
|
||||
? await this.slotsRepo.getBookingWithAttendees(input.bookingUid)
|
||||
: undefined;
|
||||
const bookingAttendeesLength = bookingWithAttendees?.attendees?.length;
|
||||
if (bookingAttendeesLength) {
|
||||
const seatsLeft = eventType.seatsPerTimeSlot - bookingAttendeesLength;
|
||||
|
||||
@@ -82,7 +82,7 @@ export class SlotsController_2024_09_04 {
|
||||
Optional parameters are:
|
||||
- timeZone: Time zone in which the available slots should be returned. Defaults to UTC.
|
||||
- duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.
|
||||
- slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value.
|
||||
- format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use "range" as value.
|
||||
`,
|
||||
})
|
||||
@ApiQuery({
|
||||
@@ -99,7 +99,7 @@ export class SlotsController_2024_09_04 {
|
||||
example: "60",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "slotFormat",
|
||||
name: "format",
|
||||
required: false,
|
||||
description:
|
||||
"Format of slot times in response. Use 'range' to get start and end times. Use 'time' or omit this query parameter to get only start time.",
|
||||
|
||||
@@ -42,24 +42,33 @@ export class SlotsService_2024_09_04 {
|
||||
) {}
|
||||
|
||||
async getAvailableSlots(query: GetSlotsInput_2024_09_04) {
|
||||
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
|
||||
try {
|
||||
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
|
||||
const availableSlots: TimeSlots = await getAvailableSlots({
|
||||
input: {
|
||||
...queryTransformed,
|
||||
},
|
||||
ctx: {},
|
||||
});
|
||||
const formatted = await this.slotsOutputService.getAvailableSlots(
|
||||
availableSlots,
|
||||
queryTransformed.eventTypeId,
|
||||
queryTransformed.duration,
|
||||
query.format,
|
||||
queryTransformed.timeZone
|
||||
);
|
||||
|
||||
const availableSlots: TimeSlots = await getAvailableSlots({
|
||||
input: {
|
||||
...queryTransformed,
|
||||
},
|
||||
ctx: {},
|
||||
});
|
||||
|
||||
const formatted = await this.slotsOutputService.getAvailableSlots(
|
||||
availableSlots,
|
||||
queryTransformed.eventTypeId,
|
||||
queryTransformed.duration,
|
||||
query.format,
|
||||
queryTransformed.timeZone
|
||||
);
|
||||
|
||||
return formatted;
|
||||
return formatted;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("Invalid time range given")) {
|
||||
throw new BadRequestException(
|
||||
"Invalid time range given - check the 'start' and 'end' query parameters."
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async reserveSlot(input: ReserveSlotInput_2024_09_04, authUserId?: number) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsConferencingModule } from "@/modules/organizations/conferencing/organizations-conferencing.module";
|
||||
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
|
||||
@@ -31,6 +32,7 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsTeamsRepository,
|
||||
OutputTeamEventTypesResponsePipe,
|
||||
OutputOrganizationsEventTypesService,
|
||||
ConferencingRepository,
|
||||
],
|
||||
exports: [TeamsEventTypesRepository, TeamsEventTypesService],
|
||||
controllers: [TeamsEventTypesController],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Injectable } from "@nestjs/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { PlatformAuthorizationToken } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
@Injectable()
|
||||
export class TokensRepository {
|
||||
@@ -50,31 +51,18 @@ export class TokensRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async createOAuthTokens(clientId: string, ownerId: number, deleteOld?: boolean) {
|
||||
if (deleteOld) {
|
||||
try {
|
||||
await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.deleteMany({
|
||||
where: { client: { id: clientId }, userId: ownerId },
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.deleteMany({
|
||||
where: {
|
||||
client: { id: clientId },
|
||||
userId: ownerId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
} catch (err) {
|
||||
this.logger.error("createOAuthTokens - Failed to delete old tokens", err);
|
||||
}
|
||||
}
|
||||
|
||||
async createOAuthTokens(clientId: string, ownerId: number) {
|
||||
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
|
||||
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
|
||||
const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signAccessToken({ clientId, ownerId, expiresAt: accessExpiry.valueOf() }),
|
||||
secret: this.jwtService.signAccessToken({
|
||||
clientId,
|
||||
ownerId,
|
||||
expiresAt: accessExpiry.valueOf(),
|
||||
jti: uuidv4(),
|
||||
}),
|
||||
expiresAt: accessExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
@@ -82,7 +70,12 @@ export class TokensRepository {
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signRefreshToken({ clientId, ownerId, expiresAt: refreshExpiry.valueOf() }),
|
||||
secret: this.jwtService.signRefreshToken({
|
||||
clientId,
|
||||
ownerId,
|
||||
expiresAt: refreshExpiry.valueOf(),
|
||||
jti: uuidv4(),
|
||||
}),
|
||||
expiresAt: refreshExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
@@ -98,6 +91,57 @@ export class TokensRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async forceRefreshOAuthTokens(clientId: string, ownerId: number) {
|
||||
const accessExpiry = DateTime.now().plus({ minute: 60 }).startOf("minute").toJSDate();
|
||||
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();
|
||||
|
||||
const [_deletedAccessToken, _deletedRefreshToken, accessToken, refreshToken] =
|
||||
await this.dbWrite.prisma.$transaction([
|
||||
this.dbWrite.prisma.accessToken.deleteMany({
|
||||
where: { client: { id: clientId }, userId: ownerId },
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.deleteMany({
|
||||
where: {
|
||||
client: { id: clientId },
|
||||
userId: ownerId,
|
||||
},
|
||||
}),
|
||||
this.dbWrite.prisma.accessToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signAccessToken({
|
||||
clientId,
|
||||
ownerId,
|
||||
expiresAt: accessExpiry.valueOf(),
|
||||
jti: uuidv4(),
|
||||
}),
|
||||
expiresAt: accessExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
},
|
||||
}),
|
||||
this.dbWrite.prisma.refreshToken.create({
|
||||
data: {
|
||||
secret: this.jwtService.signRefreshToken({
|
||||
clientId,
|
||||
ownerId,
|
||||
expiresAt: refreshExpiry.valueOf(),
|
||||
jti: uuidv4(),
|
||||
}),
|
||||
expiresAt: refreshExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
owner: { connect: { id: ownerId } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accessToken: accessToken.secret,
|
||||
accessTokenExpiresAt: accessToken.expiresAt,
|
||||
refreshToken: refreshToken.secret,
|
||||
refreshTokenExpiresAt: refreshToken.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async getAccessTokenExpiryDate(accessTokenSecret: string) {
|
||||
const accessToken = await this.dbRead.prisma.accessToken.findFirst({
|
||||
where: {
|
||||
@@ -140,6 +184,7 @@ export class TokensRepository {
|
||||
ownerId: tokenUserId,
|
||||
expiresAt: accessExpiry.valueOf(),
|
||||
userId: tokenUserId,
|
||||
jti: uuidv4(),
|
||||
}),
|
||||
expiresAt: accessExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
@@ -155,6 +200,7 @@ export class TokensRepository {
|
||||
ownerId: tokenUserId,
|
||||
expiresAt: refreshExpiry.valueOf(),
|
||||
userId: tokenUserId,
|
||||
jti: uuidv4(),
|
||||
}),
|
||||
expiresAt: refreshExpiry,
|
||||
client: { connect: { id: clientId } },
|
||||
|
||||
@@ -317,4 +317,73 @@ export class UsersRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getOrgsManagedUserEmailsBySubscriptionId(subscriptionId: string) {
|
||||
return await this.dbRead.prisma.user.findMany({
|
||||
distinct: ["email"],
|
||||
where: {
|
||||
isPlatformManaged: true,
|
||||
profiles: {
|
||||
some: {
|
||||
organization: {
|
||||
platformBilling: {
|
||||
subscriptionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getActiveManagedUsersAsHost(subscriptionId: string, startTime: Date, endTime: Date) {
|
||||
return await this.dbRead.prisma.user.findMany({
|
||||
distinct: ["email"],
|
||||
where: {
|
||||
isPlatformManaged: true,
|
||||
profiles: {
|
||||
some: {
|
||||
organization: {
|
||||
platformBilling: {
|
||||
subscriptionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
bookings: {
|
||||
some: {
|
||||
userId: { not: null },
|
||||
startTime: {
|
||||
gte: startTime,
|
||||
lte: endTime,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getActiveManagedUsersAsAttendee(managedUsersEmails: string[], startTime: Date, endTime: Date) {
|
||||
return await this.dbRead.prisma.attendee.findMany({
|
||||
distinct: ["email"],
|
||||
where: {
|
||||
email: { in: managedUsersEmails },
|
||||
booking: {
|
||||
startTime: {
|
||||
gte: startTime,
|
||||
lte: endTime,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1689,6 +1689,7 @@
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"schema": {
|
||||
"default": 100,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -1699,6 +1700,7 @@
|
||||
"description": "The number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -5603,6 +5605,7 @@
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"schema": {
|
||||
"default": 100,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -5613,6 +5616,7 @@
|
||||
"description": "The number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
@@ -6978,6 +6982,7 @@
|
||||
"description": "The number of items to return",
|
||||
"example": 10,
|
||||
"schema": {
|
||||
"default": 100,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -6988,6 +6993,7 @@
|
||||
"description": "The number of items to skip",
|
||||
"example": 0,
|
||||
"schema": {
|
||||
"default": 0,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -10500,7 +10506,7 @@
|
||||
"get": {
|
||||
"operationId": "SlotsController_2024_09_04_getAvailableSlots",
|
||||
"summary": "Find out when is an event type ready to be booked.",
|
||||
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n\n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - slotFormat: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n ",
|
||||
"description": "\n There are 4 ways to get available slots for event type of an individual user:\n\n 1. By event type id. Event type id can be of user and team event types. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By event type slug + username. Example '/v2/slots?eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 3. By event type slug + username + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&username=bob&start=2050-09-05&end=2050-09-06'\n\n 4. By usernames only (used for dynamic event type - there is no specific event but you want to know when 2 or more people are available). Example '/v2/slots?usernames=alice,bob&username=bob&organizationSlug=org-slug&start=2050-09-05&end=2050-09-06'. As you see you also need to provide the slug of the organization to which each user in the 'usernames' array belongs.\n\n And 3 ways to get available slots for team event type:\n\n 1. By team event type id. Example '/v2/slots?eventTypeId=10&start=2050-09-05&end=2050-09-06&timeZone=Europe/Rome'\n\n 2. By team event type slug + team slug. Example '/v2/slots?eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n 3. By team event type slug + team slug + organization slug when searching within an organization. Example '/v2/slots?organizationSlug=org-slug&eventTypeSlug=intro&teamSlug=team-slug&start=2050-09-05&end=2050-09-06'\n\n All of them require \"start\" and \"end\" query parameters which define the time range for which available slots should be checked.\n Optional parameters are:\n - timeZone: Time zone in which the available slots should be returned. Defaults to UTC.\n - duration: Only use for event types that allow multiple durations or for dynamic event types. If not passed for multiple duration event types defaults to default duration. For dynamic event types defaults to 30 aka each returned slot is 30 minutes long. So duration=60 means that returned slots will be each 60 minutes long.\n - format: Format of the slots. By default return is an object where each key is date and value is array of slots as string. If you want to get start and end of each slot use \"range\" as value.\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -10577,7 +10583,7 @@
|
||||
"schema": {}
|
||||
},
|
||||
{
|
||||
"name": "slotFormat",
|
||||
"name": "format",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Format of slot times in response. Use 'range' to get start and end times. Use 'time' or omit this query parameter to get only start time.",
|
||||
@@ -14540,6 +14546,22 @@
|
||||
"slug"
|
||||
]
|
||||
},
|
||||
"LocationDefaultFieldInput_2024_06_14": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"example": "location",
|
||||
"description": "only allowed value for type is `location`. This booking field is displayed only when event type has 2 or more locations in order to allow person doing the booking pick the location."
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"slug"
|
||||
]
|
||||
},
|
||||
"NotesDefaultFieldInput_2024_06_14": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -14753,6 +14775,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
@@ -15344,6 +15369,9 @@
|
||||
"hidden": {
|
||||
"type": "boolean",
|
||||
"description": "If true show under event type settings but don't show this booking field in the Booker. If false show in both."
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -15360,11 +15388,11 @@
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"name",
|
||||
"email",
|
||||
"title",
|
||||
"location",
|
||||
"notes",
|
||||
"guests"
|
||||
"guests",
|
||||
"rescheduleReason"
|
||||
],
|
||||
"example": "rescheduleReason",
|
||||
"description": "only allowed value for type is `rescheduleReason`",
|
||||
@@ -15410,11 +15438,11 @@
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"name",
|
||||
"email",
|
||||
"title",
|
||||
"location",
|
||||
"notes",
|
||||
"guests"
|
||||
"guests",
|
||||
"rescheduleReason"
|
||||
],
|
||||
"example": "title",
|
||||
"description": "only allowed value for type is `title`",
|
||||
@@ -15460,11 +15488,11 @@
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"name",
|
||||
"email",
|
||||
"title",
|
||||
"location",
|
||||
"notes",
|
||||
"guests"
|
||||
"guests",
|
||||
"rescheduleReason"
|
||||
],
|
||||
"example": "notes",
|
||||
"description": "only allowed value for type is `notes`",
|
||||
@@ -15510,11 +15538,11 @@
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"name",
|
||||
"email",
|
||||
"title",
|
||||
"location",
|
||||
"notes",
|
||||
"guests"
|
||||
"guests",
|
||||
"rescheduleReason"
|
||||
],
|
||||
"example": "guests",
|
||||
"description": "only allowed value for type is `guests`",
|
||||
@@ -16743,6 +16771,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
@@ -17098,154 +17129,6 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"ConferencingAppsOutputDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "Id of the conferencing app credentials"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "google_video",
|
||||
"description": "Type of conferencing app"
|
||||
},
|
||||
"userId": {
|
||||
"type": "number",
|
||||
"description": "Id of the user associated to the conferencing app"
|
||||
},
|
||||
"invalid": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"example": true,
|
||||
"description": "Whether if the connection is working or not."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"userId"
|
||||
]
|
||||
},
|
||||
"ConferencingAppOutputResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/ConferencingAppsOutputDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"GetConferencingAppsOauthUrlResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"ConferencingAppsOutputResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ConferencingAppsOutputDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"SetDefaultConferencingAppOutputResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"DefaultConferencingAppsOutputDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"appSlug": {
|
||||
"type": "string"
|
||||
},
|
||||
"appLink": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"GetDefaultConferencingAppOutputResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/DefaultConferencingAppsOutputDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"DisconnectConferencingAppOutputResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"OrgTeamOutputDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18260,6 +18143,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"
|
||||
},
|
||||
"option": {
|
||||
"type": "string"
|
||||
},
|
||||
"optionId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"option",
|
||||
"optionId"
|
||||
]
|
||||
},
|
||||
"NumberAttribute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"option": {
|
||||
"type": "number"
|
||||
},
|
||||
"optionId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"option",
|
||||
"optionId"
|
||||
]
|
||||
},
|
||||
"SingleSelectAttribute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"option": {
|
||||
"type": "string"
|
||||
},
|
||||
"optionId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"option",
|
||||
"optionId"
|
||||
]
|
||||
},
|
||||
"MultiSelectAttributeOption": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"optionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"option": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"optionId",
|
||||
"option"
|
||||
]
|
||||
},
|
||||
"MultiSelectAttribute": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MultiSelectAttributeOption"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"options"
|
||||
]
|
||||
},
|
||||
"MembershipUserOutputDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18289,7 +18309,7 @@
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"TeamMembershipOutput": {
|
||||
"OrganizationMembershipOutput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -18317,6 +18337,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": [
|
||||
@@ -18325,7 +18364,8 @@
|
||||
"teamId",
|
||||
"accepted",
|
||||
"role",
|
||||
"user"
|
||||
"user",
|
||||
"attributes"
|
||||
]
|
||||
},
|
||||
"GetAllOrgMemberships": {
|
||||
@@ -18340,7 +18380,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
"$ref": "#/components/schemas/OrganizationMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -18390,7 +18430,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
"$ref": "#/components/schemas/OrganizationMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -18410,7 +18450,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
"$ref": "#/components/schemas/OrganizationMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -18430,7 +18470,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
"$ref": "#/components/schemas/OrganizationMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -18469,7 +18509,7 @@
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/TeamMembershipOutput"
|
||||
"$ref": "#/components/schemas/OrganizationMembershipOutput"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -18548,6 +18588,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
@@ -19406,6 +19449,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/TitleDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/LocationDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NotesDefaultFieldInput_2024_06_14"
|
||||
},
|
||||
@@ -19692,6 +19738,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": {
|
||||
@@ -20941,6 +21026,154 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"ConferencingAppsOutputDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "number",
|
||||
"description": "Id of the conferencing app credentials"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "google_video",
|
||||
"description": "Type of conferencing app"
|
||||
},
|
||||
"userId": {
|
||||
"type": "number",
|
||||
"description": "Id of the user associated to the conferencing app"
|
||||
},
|
||||
"invalid": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"example": true,
|
||||
"description": "Whether if the connection is working or not."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"type",
|
||||
"userId"
|
||||
]
|
||||
},
|
||||
"ConferencingAppOutputResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/ConferencingAppsOutputDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"GetConferencingAppsOauthUrlResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"ConferencingAppsOutputResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ConferencingAppsOutputDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"SetDefaultConferencingAppOutputResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"DefaultConferencingAppsOutputDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"appSlug": {
|
||||
"type": "string"
|
||||
},
|
||||
"appLink": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"GetDefaultConferencingAppOutputResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/DefaultConferencingAppsOutputDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"DisconnectConferencingAppOutputResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"GoogleServiceAccountKeyInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -23941,6 +24174,60 @@
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"PaginationMetaDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"totalItems": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "The total number of items available across all pages, matching the query criteria.",
|
||||
"example": 123
|
||||
},
|
||||
"remainingItems": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "The number of items remaining to be fetched *after* the current page. Calculated as: `totalItems - (skip + itemsPerPage)`.",
|
||||
"example": 103
|
||||
},
|
||||
"itemsPerPage": {
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"description": "The maximum number of items requested per page.",
|
||||
"example": 10
|
||||
},
|
||||
"currentPage": {
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"description": "The current page number being returned.",
|
||||
"example": 2
|
||||
},
|
||||
"totalPages": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "The total number of pages available.",
|
||||
"example": 13
|
||||
},
|
||||
"hasNextPage": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates if there is a subsequent page available after the current one.",
|
||||
"example": true
|
||||
},
|
||||
"hasPreviousPage": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates if there is a preceding page available before the current one.",
|
||||
"example": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"totalItems",
|
||||
"remainingItems",
|
||||
"itemsPerPage",
|
||||
"currentPage",
|
||||
"totalPages",
|
||||
"hasNextPage",
|
||||
"hasPreviousPage"
|
||||
]
|
||||
},
|
||||
"GetBookingsOutput_2024_08_13": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -23972,13 +24259,17 @@
|
||||
},
|
||||
"description": "Array of booking data, which can contain either BookingOutput objects or RecurringBookingOutput objects"
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "#/components/schemas/PaginationMetaDto"
|
||||
},
|
||||
"error": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
"data",
|
||||
"pagination"
|
||||
]
|
||||
},
|
||||
"RescheduleBookingInput_2024_08_13": {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
|
||||
export class WorkflowReminderRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
private prismaWriteClient: PrismaWriteService["prisma"];
|
||||
|
||||
constructor(private readonly module: TestingModule) {
|
||||
this.prismaReadClient = module.get(PrismaReadService).prisma;
|
||||
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
|
||||
}
|
||||
|
||||
async getByBookingUid(uid: string) {
|
||||
return this.prismaReadClient.workflowReminder.findFirst({ where: { bookingUid: uid } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export class WorkflowRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
private prismaWriteClient: PrismaWriteService["prisma"];
|
||||
|
||||
constructor(private readonly module: TestingModule) {
|
||||
this.prismaReadClient = module.get(PrismaReadService).prisma;
|
||||
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
|
||||
}
|
||||
|
||||
async create(data: Prisma.WorkflowCreateInput) {
|
||||
return this.prismaWriteClient.workflow.create({ data });
|
||||
}
|
||||
|
||||
async delete(id: number) {
|
||||
return this.prismaWriteClient.workflow.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ Buttons can be grouped together using the ButtonGroup component. The ButtonGroup
|
||||
|
||||
### Button Group Sizes
|
||||
|
||||
#### Seperated
|
||||
#### Separated
|
||||
|
||||
<ButtonGroupSizes/>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export const SizesExample: React.FC = () => (
|
||||
<div className="space-y-4 md:w-80">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-emphasis text-sm">Default Height</h3>
|
||||
<TextAreaField name="deafult" placeholder="Default height textarea..." />
|
||||
<TextAreaField name="default" placeholder="Default height textarea..." />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
+3
-1
@@ -69,4 +69,6 @@ public/embed
|
||||
# Copied app-store images
|
||||
public/app-store
|
||||
|
||||
certificates
|
||||
certificates
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { getServerSideProps } from "@server/lib/[user]/[type]/getServerSideProps";
|
||||
@@ -13,6 +16,18 @@ const getData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getData(context);
|
||||
|
||||
const locale = props.eventData?.interfaceLanguage;
|
||||
if (locale) {
|
||||
const ns = "common";
|
||||
const translations = await loadTranslations(locale, ns);
|
||||
return (
|
||||
<CustomI18nProvider translations={translations} locale={locale} ns={ns}>
|
||||
<TypePage {...props} />
|
||||
</CustomI18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <TypePage {...props} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { generateMeetingMetadata } from "app/_utils";
|
||||
import { headers, cookies } from "next/headers";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
|
||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -55,6 +57,17 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getData(legacyCtx);
|
||||
|
||||
const locale = props.eventData?.interfaceLanguage;
|
||||
if (locale) {
|
||||
const ns = "common";
|
||||
const translations = await loadTranslations(locale, ns);
|
||||
return (
|
||||
<CustomI18nProvider translations={translations} locale={locale} ns={ns}>
|
||||
<LegacyPage {...props} />
|
||||
</CustomI18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <LegacyPage {...props} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
|
||||
|
||||
@@ -17,8 +20,32 @@ export type ClientPageProps = UserTypePageProps | TeamTypePageProps;
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getData(context);
|
||||
if ((props as TeamTypePageProps)?.teamId) return <TeamTypePage {...(props as TeamTypePageProps)} />;
|
||||
return <UserTypePage {...(props as UserTypePageProps)} />;
|
||||
|
||||
const eventLocale = props.eventData?.interfaceLanguage;
|
||||
const ns = "common";
|
||||
let translations;
|
||||
if (eventLocale) {
|
||||
const ns = "common";
|
||||
translations = await loadTranslations(eventLocale, ns);
|
||||
}
|
||||
|
||||
if ((props as TeamTypePageProps)?.teamId) {
|
||||
return eventLocale ? (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
<TeamTypePage {...(props as TeamTypePageProps)} />
|
||||
</CustomI18nProvider>
|
||||
) : (
|
||||
<TeamTypePage {...(props as TeamTypePageProps)} />
|
||||
);
|
||||
}
|
||||
|
||||
return eventLocale ? (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
<UserTypePage {...(props as UserTypePageProps)} />
|
||||
</CustomI18nProvider>
|
||||
) : (
|
||||
<UserTypePage {...(props as UserTypePageProps)} />
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { generateMeetingMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
|
||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
|
||||
@@ -63,10 +65,32 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
const props = await getData(
|
||||
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
|
||||
);
|
||||
if ((props as TeamTypePageProps)?.teamId) {
|
||||
return <TeamTypePage {...(props as TeamTypePageProps)} />;
|
||||
|
||||
const eventLocale = props.eventData?.interfaceLanguage;
|
||||
const ns = "common";
|
||||
let translations;
|
||||
if (eventLocale) {
|
||||
const ns = "common";
|
||||
translations = await loadTranslations(eventLocale, ns);
|
||||
}
|
||||
return <UserTypePage {...(props as UserTypePageProps)} />;
|
||||
|
||||
if ((props as TeamTypePageProps)?.teamId) {
|
||||
return eventLocale ? (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
<TeamTypePage {...(props as TeamTypePageProps)} />
|
||||
</CustomI18nProvider>
|
||||
) : (
|
||||
<TeamTypePage {...(props as TeamTypePageProps)} />
|
||||
);
|
||||
}
|
||||
|
||||
return eventLocale ? (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
<UserTypePage {...(props as UserTypePageProps)} />
|
||||
</CustomI18nProvider>
|
||||
) : (
|
||||
<UserTypePage {...(props as UserTypePageProps)} />
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
|
||||
|
||||
@@ -12,6 +15,18 @@ const getData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getData(context);
|
||||
|
||||
const eventLocale = props.eventData?.interfaceLanguage;
|
||||
if (eventLocale) {
|
||||
const ns = "common";
|
||||
const translations = await loadTranslations(eventLocale, ns);
|
||||
return (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
<TypePage {...props} />
|
||||
</CustomI18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <TypePage {...props} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { generateMeetingMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
|
||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
|
||||
@@ -54,6 +56,18 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
const props = await getData(
|
||||
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
|
||||
);
|
||||
|
||||
const eventLocale = props.eventData?.interfaceLanguage;
|
||||
if (eventLocale) {
|
||||
const ns = "common";
|
||||
const translations = await loadTranslations(eventLocale, ns);
|
||||
return (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
<LegacyPage {...props} />
|
||||
</CustomI18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <LegacyPage {...props} />;
|
||||
};
|
||||
export default ServerPage;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
import OldPage from "~/bookings/views/bookings-single-view";
|
||||
@@ -15,6 +18,18 @@ const getEmbedData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getEmbedData(context);
|
||||
|
||||
const eventLocale = props.eventType?.interfaceLanguage;
|
||||
if (eventLocale) {
|
||||
const ns = "common";
|
||||
const translations = await loadTranslations(eventLocale, ns);
|
||||
return (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
<OldPage {...props} />
|
||||
</CustomI18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <OldPage {...props} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps as _PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
@@ -36,6 +38,18 @@ const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
|
||||
const ServerPage = async ({ params, searchParams }: _PageProps) => {
|
||||
const context = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getData(context);
|
||||
|
||||
const eventLocale = props.eventType?.interfaceLanguage;
|
||||
if (eventLocale) {
|
||||
const ns = "common";
|
||||
const translations = await loadTranslations(eventLocale, ns);
|
||||
return (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
<OldPage {...props} />
|
||||
</CustomI18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <OldPage {...props} />;
|
||||
};
|
||||
export default ServerPage;
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { getTranslate } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
|
||||
import UpgradeTipWrapper from "./UpgradeTipWrapper";
|
||||
|
||||
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||
|
||||
if (!insightsEnabled) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
|
||||
import InsightsPage from "~/insights/insights-view";
|
||||
|
||||
@@ -15,12 +12,5 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
export default async function Page() {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||
|
||||
if (!insightsEnabled) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <InsightsPage />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
|
||||
import InsightsVirtualQueuesPage from "~/insights/insights-virtual-queues-view";
|
||||
|
||||
@@ -15,12 +12,5 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
export default async function Page() {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||
|
||||
if (!insightsEnabled) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <InsightsVirtualQueuesPage />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
|
||||
import InsightsRoutingPage from "~/insights/insights-routing-view";
|
||||
|
||||
@@ -15,12 +12,5 @@ export const generateMetadata = async () =>
|
||||
);
|
||||
|
||||
export default async function Page() {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||
|
||||
if (!insightsEnabled) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <InsightsRoutingPage />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type CustomI18nContextType = {
|
||||
translations: Record<string, string>;
|
||||
ns: string;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export const CustomI18nContext = createContext<CustomI18nContextType | null>(null);
|
||||
|
||||
export function CustomI18nProvider({
|
||||
children,
|
||||
translations,
|
||||
locale,
|
||||
ns,
|
||||
}: CustomI18nContextType & {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// Memoize the value to prevent re-renders unless the data changes
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
translations,
|
||||
locale,
|
||||
ns,
|
||||
}),
|
||||
[locale, ns]
|
||||
);
|
||||
|
||||
return <CustomI18nContext.Provider value={value}>{children}</CustomI18nContext.Provider>;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { NextApiResponse } from "next";
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import { parseRequestData } from "app/api/parseRequestData";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
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 { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
@@ -11,12 +12,12 @@ import logger from "@calcom/lib/logger";
|
||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
async function ensureSignupIsEnabled(req: RequestWithUsernameStatus) {
|
||||
async function ensureSignupIsEnabled(body: Record<string, string>) {
|
||||
const { token } = signupSchema
|
||||
.pick({
|
||||
token: true,
|
||||
})
|
||||
.parse(req.body);
|
||||
.parse(body);
|
||||
|
||||
// Still allow signups if there is a team invite
|
||||
if (token) return;
|
||||
@@ -32,26 +33,17 @@ async function ensureSignupIsEnabled(req: RequestWithUsernameStatus) {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureReqIsPost(req: RequestWithUsernameStatus) {
|
||||
if (req.method !== "POST") {
|
||||
throw new HttpError({
|
||||
statusCode: 405,
|
||||
message: "Method not allowed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
|
||||
async function handler(req: NextRequest) {
|
||||
const remoteIp = getIP(req);
|
||||
// Use a try catch instead of returning res every time
|
||||
try {
|
||||
const body = await parseRequestData(req);
|
||||
await checkCfTurnstileToken({
|
||||
token: req.headers["cf-access-token"] as string,
|
||||
token: req.headers.get("cf-access-token") as string,
|
||||
remoteIp,
|
||||
});
|
||||
|
||||
ensureReqIsPost(req);
|
||||
await ensureSignupIsEnabled(req);
|
||||
await ensureSignupIsEnabled(body);
|
||||
|
||||
/**
|
||||
* Im not sure its worth merging these two handlers. They are different enough to be separate.
|
||||
@@ -61,15 +53,17 @@ export default async function handler(req: RequestWithUsernameStatus, res: NextA
|
||||
* @zomars: We need to be able to test this with E2E. They way it's done RN it will never run on CI.
|
||||
*/
|
||||
if (IS_PREMIUM_USERNAME_ENABLED) {
|
||||
return await calcomSignupHandler(req, res);
|
||||
return await calcomSignupHandler(body);
|
||||
}
|
||||
|
||||
return await selfHostedSignupHandler(req, res);
|
||||
return await selfHostedSignupHandler(body);
|
||||
} catch (e) {
|
||||
if (e instanceof HttpError) {
|
||||
return res.status(e.statusCode).json({ message: e.message });
|
||||
return NextResponse.json({ message: e.message }, { status: e.statusCode });
|
||||
}
|
||||
logger.error(e);
|
||||
return res.status(500).json({ message: "Internal server error" });
|
||||
return NextResponse.json({ message: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = defaultResponderForAppDir(handler);
|
||||
@@ -5,7 +5,8 @@ import { NextResponse } from "next/server";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendOrganizerRequestReminderEmail } from "@calcom/emails";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import { BookingStatus, ReminderType } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/twilioProvider";
|
||||
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { CreditType } from "@calcom/prisma/enums";
|
||||
|
||||
async function postHandler(req: NextRequest) {
|
||||
const apiKey = req.headers.get("authorization") || req.nextUrl.searchParams.get("apiKey");
|
||||
|
||||
if (process.env.CRON_API_KEY !== apiKey) {
|
||||
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!IS_SMS_CREDITS_ENABLED) {
|
||||
return NextResponse.json({ ok: true, message: "SMS credits not enabled" });
|
||||
}
|
||||
|
||||
const smsLogsWithoutPrice = await prisma.creditExpenseLog.findMany({
|
||||
where: {
|
||||
credits: null,
|
||||
smsSid: {
|
||||
not: null,
|
||||
},
|
||||
date: {
|
||||
gte: dayjs().subtract(1, "hour").toDate(),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
smsSid: true,
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
let pricesUpdated = 0;
|
||||
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
|
||||
|
||||
const creditService = new CreditService();
|
||||
|
||||
await Promise.allSettled(
|
||||
smsLogsWithoutPrice.map(async (log) => {
|
||||
if (!log.smsSid) return;
|
||||
|
||||
try {
|
||||
const price = await twilio.getPriceForSMS(log.smsSid);
|
||||
const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
|
||||
if (!credits) return;
|
||||
|
||||
const updatedLog = await prisma.creditExpenseLog.update({
|
||||
where: { id: log.id },
|
||||
data: { credits },
|
||||
select: {
|
||||
creditBalance: {
|
||||
select: {
|
||||
id: true,
|
||||
additionalCredits: true,
|
||||
teamId: true,
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
creditType: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (updatedLog.creditType === CreditType.ADDITIONAL) {
|
||||
const decrementValue =
|
||||
credits <= updatedLog.creditBalance.additionalCredits
|
||||
? credits
|
||||
: updatedLog.creditBalance.additionalCredits;
|
||||
|
||||
await prisma.creditBalance.update({
|
||||
where: { id: updatedLog.creditBalance.id },
|
||||
data: {
|
||||
additionalCredits: {
|
||||
decrement: decrementValue,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!updatedLog.creditBalance.teamId && !updatedLog.creditBalance.userId) {
|
||||
logger.error(`teamId or userId missing for expense log ${log.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const availableCredits = await creditService.getAllCredits({
|
||||
teamId: updatedLog.creditBalance.teamId,
|
||||
userId: updatedLog.creditBalance.userId,
|
||||
});
|
||||
|
||||
const remainingMonthlyCredits = Math.max(0, availableCredits.totalRemainingMonthlyCredits);
|
||||
|
||||
await creditService.handleLowCreditBalance({
|
||||
userId: updatedLog.creditBalance.userId,
|
||||
teamId: updatedLog.creditBalance.teamId,
|
||||
remainingCredits: remainingMonthlyCredits + availableCredits.additionalCredits,
|
||||
});
|
||||
|
||||
pricesUpdated++;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to process SMS log ${log.smsSid}`, err);
|
||||
await prisma.creditExpenseLog.update({
|
||||
where: { id: log.id },
|
||||
data: { credits: 0 },
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, pricesUpdated });
|
||||
}
|
||||
|
||||
export const POST = defaultResponderForAppDir(postHandler);
|
||||
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* @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";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
|
||||
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
|
||||
|
||||
@@ -37,7 +43,7 @@ async function handleCreateCredentials() {
|
||||
log.info(
|
||||
`Skipping credential creation for workspace platform ${workspacePlatform.slug} - only google is supported`
|
||||
);
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
// We can't know by looking at a Credential record in DB, if it has access to the `user.email` calendar in Google Calendar.
|
||||
@@ -50,15 +56,15 @@ async function handleCreateCredentials() {
|
||||
});
|
||||
|
||||
const existingCredentialUserIds = new Set(existingCredentials.map((cred) => cred.userId));
|
||||
|
||||
const membersNeedingCredentials = organization.delegatedMembers.filter(
|
||||
const delegatedMembers = organization.delegatedMembers;
|
||||
const membersNeedingCredentials = delegatedMembers.filter(
|
||||
(member) => !existingCredentialUserIds.has(member.userId)
|
||||
);
|
||||
|
||||
const toProcessMembers = membersNeedingCredentials.slice(0, batchSizeToCreateCredentials);
|
||||
|
||||
log.info(
|
||||
`Creating credentials for ${toProcessMembers.length} members in organization ${organization.id}`
|
||||
`Creating credentials for ${toProcessMembers.length} members out of ${delegatedMembers.length} delegated members in organization ${organization.id}`
|
||||
);
|
||||
|
||||
const credentialCreationPromises = toProcessMembers.map(async (member) => {
|
||||
@@ -75,18 +81,24 @@ async function handleCreateCredentials() {
|
||||
});
|
||||
log.info(`Created credential for member ${member.userId}`);
|
||||
} catch (error) {
|
||||
log.error(`Error creating credential for member ${member.userId}:`, error);
|
||||
log.error(`Error creating credential for member ${member.userId}:`, safeStringify(error));
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
const results = await Promise.allSettled(credentialCreationPromises);
|
||||
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
||||
const failureCount = results.filter((r) => r.status === "rejected").length;
|
||||
|
||||
log.info(
|
||||
`Created ${successCount} credentials for delegationCredentialId: ${delegationCredential.id} and ${failureCount} failures`
|
||||
);
|
||||
totalSuccess += successCount;
|
||||
totalFailures += failureCount;
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Completed creating credentials for all delegation credentials. Total Success: ${totalSuccess}, Total Failures: ${totalFailures}`
|
||||
);
|
||||
|
||||
return {
|
||||
executedAt: new Date().toISOString(),
|
||||
success: totalSuccess,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -31,8 +36,10 @@ async function handleCreateSelectedCalendars() {
|
||||
});
|
||||
|
||||
if (!allDelegationUserCredentials.length) {
|
||||
const message = "No delegation credentials found";
|
||||
log.info(message);
|
||||
return {
|
||||
message: "No delegation credentials found",
|
||||
message,
|
||||
success: 0,
|
||||
failures: 0,
|
||||
};
|
||||
@@ -153,17 +160,19 @@ async function handleCreateSelectedCalendars() {
|
||||
);
|
||||
const successCount = results.filter((r) => r.status === "fulfilled").length;
|
||||
const failureCount = results.filter((r) => r.status === "rejected").length;
|
||||
|
||||
log.info(
|
||||
`Processed ${successCount} selected calendars for delegationCredentialId: ${delegationCredentialId} and ${failureCount} failures`
|
||||
);
|
||||
totalSuccess += successCount;
|
||||
totalFailures += failureCount;
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Completed processing all selected calendars. Total Success: ${totalSuccess}, Total Failures: ${totalFailures}`
|
||||
`Completed creating selected calendars for all delegation credentials. Total Success: ${totalSuccess}, Total Failures: ${totalFailures}`
|
||||
);
|
||||
|
||||
return {
|
||||
message: "All selected calendars processed",
|
||||
message: "All selected calendars created",
|
||||
executedAt: new Date().toISOString(),
|
||||
success: totalSuccess,
|
||||
failures: totalFailures,
|
||||
|
||||
@@ -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,11 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { type NextPage } from "next";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import CustomError from "./error";
|
||||
import type { ErrorProps } from "./error";
|
||||
|
||||
export const GlobalError: NextPage<ErrorProps> = (props) => {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(props.error);
|
||||
}, [props.error]);
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
|
||||
@@ -7,7 +7,6 @@ import React from "react";
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import { IconSprites } from "@calcom/ui/components/icon";
|
||||
import { NotificationSoundHandler } from "@calcom/web/components/notification-sound-handler";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -152,13 +151,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
]}
|
||||
/>
|
||||
|
||||
<Providers>
|
||||
<Providers isEmbed={isEmbed}>
|
||||
<AppRouterI18nProvider translations={translations} locale={locale} ns={ns}>
|
||||
{children}
|
||||
</AppRouterI18nProvider>
|
||||
</Providers>
|
||||
{!isEmbed && <NotificationSoundHandler />}
|
||||
<NotificationSoundHandler />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -5,20 +5,23 @@ import { SessionProvider } from "next-auth/react";
|
||||
import CacheProvider from "react-inlinesvg/provider";
|
||||
|
||||
import { WebPushProvider } from "@calcom/features/notifications/WebPushContext";
|
||||
import { NotificationSoundHandler } from "@calcom/web/components/notification-sound-handler";
|
||||
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import PlainChat from "@lib/plain/dynamicProvider";
|
||||
|
||||
type ProvidersProps = {
|
||||
isEmbed: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
export function Providers({ children }: ProvidersProps) {
|
||||
export function Providers({ isEmbed, children }: ProvidersProps) {
|
||||
const isBookingPage = useIsBookingPage();
|
||||
|
||||
return (
|
||||
<SessionProvider>
|
||||
<TrpcProvider>
|
||||
{!isBookingPage ? <PlainChat /> : null}
|
||||
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
|
||||
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
|
||||
<CacheProvider>
|
||||
<WebPushProvider>{children}</WebPushProvider>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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,
|
||||
|
||||
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
|
||||
// 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: !!process.env.SENTRY_DEBUG,
|
||||
beforeSend(event) {
|
||||
if (
|
||||
event.exception?.values?.some(
|
||||
(e) =>
|
||||
// Ignore fake error "UnhandledRejection: Non-Error promise rejection captured with value: Object Not Found Matching Id:3, MethodName:update, ParamCount:4"
|
||||
// Raised GH issue: https://github.com/getsentry/sentry-javascript/issues/3440
|
||||
e.value?.includes("Non-Error promise rejection captured with") ||
|
||||
e.value?.includes("Object Not Found Matching Id")
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
event.tags = {
|
||||
...event.tags,
|
||||
errorSource: "client",
|
||||
};
|
||||
return event;
|
||||
},
|
||||
});
|
||||
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||
@@ -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;
|
||||
|
||||
@@ -26,6 +26,7 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
interfaceLanguage: true,
|
||||
length: true,
|
||||
eventName: true,
|
||||
recurringEvent: true,
|
||||
|
||||
@@ -13,7 +13,7 @@ const enum ThemeSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a unique indentifier that remains same as long as we land on the public page for same entity like event-type/routing-form/dynamic-group-booking etc
|
||||
* Returns a unique identifier that remains the same as long as we land on the public page for the same entity like event-type/routing-form/dynamic-group-booking etc
|
||||
* The theme stays same for same identifier.
|
||||
*/
|
||||
export function getUniqueIdentifierForBookingPage({ pathname }: { pathname: string }) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -113,8 +114,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const organizationSettings = getOrganizationSEOSettings(team);
|
||||
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
|
||||
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const useApiV2 = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
|
||||
|
||||
return {
|
||||
props: {
|
||||
useApiV2,
|
||||
eventData: {
|
||||
eventTypeId,
|
||||
entity: {
|
||||
@@ -136,6 +141,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
title: eventData.title,
|
||||
users: eventHostsUserData,
|
||||
hidden: eventData.hidden,
|
||||
interfaceLanguage: eventData.interfaceLanguage,
|
||||
},
|
||||
booking,
|
||||
user: teamSlug,
|
||||
@@ -201,6 +207,7 @@ const getTeamWithEventsData = async (
|
||||
hidden: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
interfaceLanguage: true,
|
||||
hosts: {
|
||||
take: 3,
|
||||
select: {
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function ForgotPassword(props: PageProps) {
|
||||
{
|
||||
"--cal-brand": "#111827",
|
||||
"--cal-brand-emphasis": "#101010",
|
||||
"--cal-brand-text": "white",
|
||||
"--cal-brand-text": "Black",
|
||||
"--cal-brand-subtle": "#9CA3AF",
|
||||
} as CSSProperties
|
||||
}>
|
||||
@@ -127,7 +127,7 @@ export default function ForgotPassword(props: PageProps) {
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
className="w-full justify-center dark:bg-white dark:text-black"
|
||||
className="w-full justify-center bg-white hover:bg-black hover:text-white"
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={loading}
|
||||
|
||||
@@ -15,15 +15,14 @@ import { HOSTED_CAL_FEATURES, WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/consta
|
||||
import { emailRegex } from "@calcom/lib/emailSchema";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useLastUsed, LastUsed } from "@calcom/lib/hooks/useLastUsed";
|
||||
import { LastUsed, useLastUsed } from "@calcom/lib/hooks/useLastUsed";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
|
||||
import { collectPageParameters, telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { EmailField } from "@calcom/ui/components/form";
|
||||
import { PasswordField } from "@calcom/ui/components/form";
|
||||
import { EmailField, PasswordField } from "@calcom/ui/components/form";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import type { WithNonceProps } from "@lib/withNonce";
|
||||
@@ -254,12 +253,13 @@ PageProps & WithNonceProps<{}>) {
|
||||
defaultValue={totpEmail || (searchParams?.get("email") as string)}
|
||||
placeholder="john.doe@example.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
{...register("email")}
|
||||
/>
|
||||
<div className="relative">
|
||||
<PasswordField
|
||||
id="password"
|
||||
autoComplete="off"
|
||||
autoComplete="current-password"
|
||||
required={!totpEmail}
|
||||
className="mb-0"
|
||||
{...register("password")}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -5,8 +5,8 @@ import { z } from "zod";
|
||||
import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
|
||||
@@ -236,6 +236,7 @@ export default function Success(props: PageProps) {
|
||||
if (eventType.isDynamic && bookingInfo.responses?.title) {
|
||||
evtName = bookingInfo.responses.title as string;
|
||||
}
|
||||
|
||||
const eventNameObject = {
|
||||
attendeeName: bookingInfo.responses.name as z.infer<typeof nameObjectSchema> | string,
|
||||
eventType: eventType.title,
|
||||
@@ -243,7 +244,7 @@ export default function Success(props: PageProps) {
|
||||
host: props.profile.name || "Nameless",
|
||||
location: location,
|
||||
bookingFields: bookingInfo.responses,
|
||||
eventDuration: eventType.length,
|
||||
eventDuration: dayjs(bookingInfo.endTime).diff(bookingInfo.startTime, "minutes"),
|
||||
t,
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
|
||||
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
|
||||
import { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader";
|
||||
import { parseEventTypeColor } from "@calcom/lib";
|
||||
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
@@ -24,6 +23,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { parseEventTypeColor } from "@calcom/lib/isEventTypeColor";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
@@ -596,7 +596,7 @@ export const InfiniteEventTypeList = ({
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
StartIcon="ellipsis"
|
||||
// Unsual practice to use radix state open but for some reason this dropdown and only thi dropdown clears the border radius of this button.
|
||||
// Unusual practice to use radix state open but for some reason this dropdown and only this dropdown clears the border radius of this button.
|
||||
className="ltr:radix-state-open:rounded-r-[--btn-group-radius] rtl:radix-state-open:rounded-l-[--btn-group-radius]"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user