Compare commits

..
20 Commits
Author SHA1 Message Date
GitHub Actions a0706d9b51 chore: release v4.8.2
Post release / release (push) Canceled after 0s
2024-12-17 17:05:26 +00:00
Omar LópezandKeith Williams 2eb385af48 feat: allows forcing/skipping calendar cache serving (#18224)
* feat: allows forcing/skipping calendar cache serving

Signed-off-by: Omar López <zomars@me.com>

* Update features.repository.ts

* Added to HNB

* type fixes

* Update packages/prisma/migrations/20241216000000_add_calendar_cache_serve/migration.sql

Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* Update packages/prisma/zod-utils.ts

Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* Update selectedCalendar.ts

---------

Signed-off-by: Omar López <zomars@me.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-12-17 16:54:55 +00:00
Lauris Skraucis a0f986f949 feat: v2 teams CRUD (#18019)
* feat: teams endpoints

* refactor: organization teams reuse team types

* refactor: move team specific functions out of organizations-teams.repository

* supporting files

* regenerate docs

* feat: stripe service to generate team creation checkout

* refactor: request payment when creating a team

* fix: tests

* fix env

* driveby: event type name and email booking fields

* finish merge main
2024-12-17 14:19:57 +00:00
Keith Williams b0094bad2d perf: More tracing for handleNewBooking (#18233) 2024-12-17 12:29:41 +00:00
Rajiv Sahal c732c06835 fix: bookerEmbed atom (#18168)
* update translations

* add custom hook for platform embed

* update event type ouput DTO response

* fixup

* display terms and conditions for embed

* update v2 docs

* PR feedback

* update docs

* PR feedback

* update docs

* update output DTO

* update docs

* update output typing

* docs update

* resolve merge conflicts

* merge conflicts resolving

* resolve merge conflicts

* fixup

* update docs
2024-12-17 12:07:47 +00:00
Anik Dhabal Babu c1334475c5 fix: private team does not hide team members from manged event type (#18166) 2024-12-17 17:05:15 +05:30
Lauris Skraucis b35dc43011 feat: v2 wildcard redirectURIs (#18165)
* feat: isOriginAllowed function

* feat: use isOriginAllowed in v2

* refactor: shorten isOriginAllowed
2024-12-17 11:21:59 +00:00
Morgan c7dc3ecc49 chore: add TLD in username of managed users (#18229)
* chore: add TLD in username of managed users

* fixup! chore: add TLD in username of managed users

* bump platform libraries
2024-12-17 12:45:48 +02:00
Somay Chauhan a613c975d3 refactor: added ApiPropertyOptional decorator for optional fields in api for better documentation (#18208)
* update decorators 1

* update decorators 2

* update decorators 3

* update decorators 4

* update decorators 4

* update decorators 6

* update decorators 7

* added documentation,json and openapi.json

* implemented feedback

* remove default values from update dto's

* update

* removed default from redirectUris
2024-12-17 10:29:07 +00:00
Morgan 5fad7c9478 fix: redirect on calendar oauth denied auth (#18228) 2024-12-17 09:05:10 +00:00
Udit Takkar 318470d866 fix: webhook improvement (#18220) 2024-12-17 14:32:38 +05:30
Morgan a0ce42e375 chore: improve prisma exception filter messages (#18227)
* chore: improve prisma exception filter messages

* fixup! chore: improve prisma exception filter messages
2024-12-17 08:40:19 +00:00
Anik Dhabal Babu 5293a5cc54 fix: column f.userid does not exist (#18215) 2024-12-17 08:37:48 +00:00
Benny Joo 42aefc9032 perf: limit # of host members for OG image (#18219)
* fetch only 5 hosts and only first user

* refactor

* limit to 3
2024-12-17 08:15:11 +00:00
Keith Williams 14acf4e9bc chore: New booking perf tracing (#18212)
* chore: Add perf tracing for handleNewBooking

* Added a few more to the event manager

* Added the sentry wrapper that enables perf tracing

* Revert "Added a few more to the event manager"

This reverts commit ccf3ee5200466b8b1d51f5663363fbb9dfd9a06f.

* Fixed where we wrap sentry

* Reverse sentry wrapper
2024-12-17 08:51:00 +01:00
Lauris Skraucis 80815bd8a5 feat: Booker atom slots callbacks (#18203) 2024-12-17 12:58:18 +05:30
Benny Joo 9ed8face03 Revert "feat: remove setup-availability step in onboarding flow (#17799)" (#18205)
This reverts commit a466e8eef7.
2024-12-16 22:03:41 +00:00
Udit Takkar 18a107d081 fix: trigger guest no show bug (#18164)
* fix: trigger guest no show

* test: add tests for trigger guest no show
2024-12-16 11:43:03 -05:00
Alex van Andel 04d7537e2f perf: improve insights where query and type fix (#18211)
* perf: insights perf improvement

* removed additional const
2024-12-16 15:50:20 +00:00
Udit Takkar 423b17a819 refactor: getContacts (#18137) 2024-12-16 10:17:59 -05:00
172 changed files with 5482 additions and 1371 deletions
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+1 -1
View File
@@ -29,7 +29,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.72",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.73",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
+2
View File
@@ -30,6 +30,8 @@ const loadConfig = (): AppConfig => {
stripe: {
apiKey: getEnv("STRIPE_API_KEY"),
webhookSecret: getEnv("STRIPE_WEBHOOK_SECRET"),
teamMonthlyPriceId: getEnv("STRIPE_TEAM_MONTHLY_PRICE_ID", "set-team-monthly-price-in-your-env"),
isTeamBillingEnabled: getEnv("IS_TEAM_BILLING_ENABLED", true),
},
app: {
baseUrl: getEnv("WEB_APP_URL", "https://app.cal.com"),
+2
View File
@@ -21,6 +21,8 @@ export type AppConfig = {
stripe: {
apiKey: string;
webhookSecret: string;
teamMonthlyPriceId: string;
isTeamBillingEnabled: boolean;
};
app: {
baseUrl: string;
@@ -1,3 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import {
IsBoolean,
@@ -13,53 +14,66 @@ import {
class Location {
@IsString()
@ApiProperty()
optionValue!: string;
@IsString()
@ApiProperty()
value!: string;
}
class Response {
@IsString()
@ApiProperty()
name!: string;
@IsEmail()
@ApiProperty()
email!: string;
@IsArray()
@IsString({ each: true })
@ApiProperty({ type: [String] })
guests!: string[];
@IsOptional()
@ValidateNested()
@Type(() => Location)
@ApiPropertyOptional({ type: Location })
location?: Location;
@IsOptional()
@IsString()
@ApiPropertyOptional()
notes?: string;
}
export class CreateBookingInput_2024_04_15 {
@IsString()
@IsOptional()
@ApiPropertyOptional()
end?: string;
@IsString()
@ApiProperty()
start!: string;
@IsNumber()
@ApiProperty()
eventTypeId!: number;
@IsString()
@IsOptional()
@ApiPropertyOptional()
eventTypeSlug?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
rescheduleUid?: string;
@IsTimeZone()
@ApiProperty()
timeZone!: string;
@Transform(({ value }: { value: string | string[] }) => {
@@ -68,38 +82,48 @@ export class CreateBookingInput_2024_04_15 {
@IsOptional()
@IsArray()
@IsString({ each: true })
@ApiPropertyOptional({ type: [String] })
user?: string[];
@IsString()
@ApiProperty()
language!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
bookingUid?: string;
@IsObject()
@ApiProperty({ type: Object })
metadata!: Record<string, string>;
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
hasHashedBookingLink?: boolean;
@IsString()
@IsOptional()
@ApiPropertyOptional()
hashedLink!: string | null;
@IsString()
@IsOptional()
@ApiPropertyOptional()
seatReferenceUid?: string;
@Type(() => Response)
@ApiProperty({ type: Response })
responses!: Response;
@IsString()
@IsOptional()
@ApiPropertyOptional()
orgSlug?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
locationUrl?: string;
}
@@ -1,4 +1,5 @@
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsString, IsNumber, IsOptional } from "class-validator";
import type { AppsStatus } from "@calcom/platform-libraries";
@@ -6,23 +7,29 @@ import type { AppsStatus } from "@calcom/platform-libraries";
export class CreateRecurringBookingInput_2024_04_15 extends CreateBookingInput_2024_04_15 {
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
noEmail?: boolean;
@IsOptional()
@IsNumber()
@ApiPropertyOptional()
recurringCount?: number;
@IsOptional()
@ApiPropertyOptional({ type: [Object] })
appsStatus?: AppsStatus[] | undefined;
@IsOptional()
@ApiPropertyOptional({ type: [Object] })
allRecurringDates?: Record<string, string>[];
@IsOptional()
@IsNumber()
@ApiPropertyOptional()
currentRecurringIndex?: number;
@IsString()
@IsOptional()
@ApiPropertyOptional()
recurringEventId?: string;
}
@@ -1,22 +1,27 @@
import { ApiPropertyOptional, ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsOptional, IsArray, IsEmail, IsBoolean, ValidateNested } from "class-validator";
class Attendee {
@IsEmail()
@ApiProperty()
email!: string;
@IsBoolean()
@ApiProperty()
noShow!: boolean;
}
export class MarkNoShowInput_2024_04_15 {
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
noShowHost?: boolean;
@ValidateNested()
@Type(() => Attendee)
@IsArray()
@IsOptional()
@ApiPropertyOptional({ type: [Attendee] })
attendees?: Attendee[];
}
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsString,
@@ -17,145 +17,185 @@ import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Metadata {
@IsUrl()
@ApiProperty()
videoCallUrl!: string;
}
class Location {
@IsString()
@ApiProperty()
optionValue!: string;
@IsString()
@ApiProperty()
value!: string;
}
class Response {
@IsString()
@ApiProperty()
name!: string;
@IsEmail()
@ApiProperty()
email!: string;
@IsString()
@ApiProperty()
notes!: string;
@IsArray()
@IsString({ each: true })
@ApiProperty({ type: [String] })
guests!: string[];
@ValidateNested()
@Type(() => Location)
@ApiProperty({ type: Location })
location!: Location;
}
class User {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty({ type: String, nullable: true })
name!: string | null;
@IsEmail()
@ApiProperty()
email!: string;
@IsString()
@ApiProperty({ type: String, nullable: true })
username!: string | null;
@IsString()
@ApiProperty()
timeZone!: string;
}
class Attendee {
@IsString()
@ApiProperty()
name!: string;
@IsEmail()
@ApiProperty()
email!: string;
@IsString()
@ApiProperty()
timeZone!: string;
}
class EventType {
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
eventName!: string | null;
@IsString()
@ApiProperty()
slug!: string;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
timeZone!: string | null;
}
class GetBookingData_2024_04_15 {
@IsString()
@ApiProperty()
title!: string;
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty()
uid!: string;
@IsString()
@ApiProperty({ type: String, nullable: true })
description!: string | null;
@IsObject()
@ApiProperty({ type: Object })
customInputs!: any;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
smsReminderNumber!: string | null;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
recurringEventId!: string | null;
@IsDateString()
@ApiProperty()
startTime!: Date;
@IsDateString()
@ApiProperty()
endTime!: Date;
@IsUrl()
@ApiProperty({ type: String, nullable: true })
location!: string | null;
@IsString()
@ApiProperty()
status!: string;
@Type(() => Metadata)
@ApiProperty({ type: Metadata })
metadata!: Metadata | any;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
cancellationReason!: string | null;
@ValidateNested()
@Type(() => Response)
@ApiProperty({ type: Response })
responses!: Response | any;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
rejectionReason!: string | null;
@IsString()
@IsEmail()
@ApiProperty({ type: String, nullable: true })
userPrimaryEmail!: string | null;
@ValidateNested()
@Type(() => User)
@ApiProperty({ type: User, nullable: true })
user!: User | null;
@ValidateNested()
@Type(() => Attendee)
@IsArray()
@ApiProperty({ type: [Attendee] })
attendees!: Attendee[];
@IsInt()
@ApiProperty({ type: Number, nullable: true })
eventTypeId!: number | null;
@ValidateNested()
@Type(() => EventType)
@ApiProperty({ type: EventType, nullable: true })
eventType!: EventType | null;
}
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsString,
@@ -26,179 +26,230 @@ enum Status {
class Attendee {
@IsInt()
@ApiProperty()
id!: number;
@IsEmail()
@ApiProperty()
email!: string;
@IsString()
@ApiProperty()
name!: string;
@IsString()
@ApiProperty()
timeZone!: string;
@IsString()
@ApiProperty({ type: String, nullable: true })
locale!: string | null;
@IsInt()
@ApiProperty({ type: Number, nullable: true })
bookingId!: number | null;
}
class EventType {
@IsString()
@IsOptional()
@IsString()
@ApiPropertyOptional()
slug?: string;
@IsOptional()
@IsInt()
@ApiPropertyOptional()
id?: number;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
eventName?: string | null;
@IsInt()
@ApiProperty()
price!: number;
@IsOptional()
@ApiPropertyOptional()
recurringEvent?: any;
@IsString()
@ApiProperty()
currency!: string;
@IsObject()
@ApiProperty()
metadata!: any;
@IsBoolean()
@IsOptional()
seatsShowAttendees?: boolean | undefined | null;
@ApiPropertyOptional({ type: Boolean, nullable: true })
seatsShowAttendees?: boolean | null;
@IsBoolean()
@IsOptional()
seatsShowAvailabilityCount?: boolean | undefined | null;
@ApiPropertyOptional({ type: Boolean, nullable: true })
seatsShowAvailabilityCount?: boolean | null;
@IsOptional()
@ApiPropertyOptional({ nullable: true })
team?: any | null;
}
class Reference {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty()
type!: string;
@IsString()
@ApiProperty()
uid!: string;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
meetingId?: string | null;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
thirdPartyRecurringEventId?: string | null;
@IsString()
@ApiProperty({ type: String, nullable: true })
meetingPassword!: string | null;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
meetingUrl?: string | null;
@IsInt()
@ApiProperty({ type: Number, nullable: true })
bookingId!: number | null;
@IsEmail()
@ApiProperty({ type: String, nullable: true })
externalCalendarId!: string | null;
@IsOptional()
@ApiPropertyOptional()
deleted?: any;
@IsInt()
@ApiProperty({ type: Number, nullable: true })
credentialId!: number | null;
}
class User {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty({ type: String, nullable: true })
name!: string | null;
@IsEmail()
@ApiProperty()
email!: string;
}
class GetBookingsDataEntry {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty()
title!: string;
@IsOptional()
@IsEmail()
@ApiProperty({ type: String, nullable: true })
userPrimaryEmail?: string | null;
@IsString()
@ApiProperty({ type: String, nullable: true })
description!: string | null;
@IsObject()
@ApiProperty({ type: Object })
customInputs!: object | any;
@IsDateString()
@ApiProperty()
startTime!: string;
@IsDateString()
@ApiProperty()
endTime!: string;
@ValidateNested({ each: true })
@Type(() => Attendee)
@IsArray()
@ApiProperty({ type: [Attendee] })
attendees!: Attendee[];
@ApiProperty()
metadata!: any;
@IsString()
@ApiProperty()
uid!: string;
@IsOptional()
@IsString()
@ApiProperty({ type: String, nullable: true })
recurringEventId!: string | null;
@IsUrl()
@ApiProperty({ type: String, nullable: true })
location!: string | null;
@ValidateNested()
@Type(() => EventType)
@ApiProperty({ type: EventType })
eventType!: EventType;
@IsEnum(Status)
status!: "CANCELLED" | "REJECTED" | "ACCEPTED" | "PENDING" | "AWAITING_HOST";
@ApiProperty({ enum: Status, type: String })
status!: Status;
@IsBoolean()
@ApiProperty({ type: Boolean })
paid!: boolean;
@IsArray()
@ApiProperty()
payment!: any[];
@ValidateNested()
@Type(() => Reference)
@IsArray()
@ApiProperty({ type: [Reference] })
references!: Reference[];
@IsBoolean()
@ApiProperty({ type: Boolean })
isRecorded!: boolean;
@IsArray()
@ApiProperty()
seatsReferences!: any[];
@ValidateNested()
@Type(() => User)
@ApiProperty({ type: User })
user!: User | null;
@IsOptional()
@ApiPropertyOptional()
rescheduled?: any;
}
@@ -206,17 +257,20 @@ class GetBookingsData_2024_04_15 {
@ValidateNested()
@Type(() => GetBookingsDataEntry)
@IsArray()
@ApiProperty({ type: [GetBookingsDataEntry] })
bookings!: GetBookingsDataEntry[];
@IsArray()
@ApiProperty()
recurringInfo!: any[];
@IsInt()
@ApiProperty({ type: Number, nullable: true })
nextCursor!: number | null;
}
export class GetBookingsOutput_2024_04_15 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS], type: String })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsString, IsEnum, IsOptional, ValidateNested, IsArray, IsEmail, IsBoolean } from "class-validator";
@@ -6,24 +6,29 @@ import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Attendee {
@IsEmail()
@ApiProperty()
email!: string;
@IsBoolean()
@ApiProperty()
noShow!: boolean;
}
class HandleMarkNoShowData_2024_04_15 {
@IsString()
@ApiProperty()
message!: string;
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
noShowHost?: boolean;
@ValidateNested()
@Type(() => Attendee)
@IsArray()
@IsOptional()
@ApiPropertyOptional({ type: [Attendee] })
attendees?: Attendee[];
}
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
ArrayNotEmpty,
IsBoolean,
@@ -50,11 +50,10 @@ export class CreateIcsFeedInputDto {
urls!: string[];
@IsBoolean()
@ApiProperty({
@ApiPropertyOptional({
example: false,
description: "Whether to allowing writing to the calendar or not",
type: "boolean",
required: false,
default: true,
})
@IsOptional()
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsDate, IsEnum, IsOptional, IsString, ValidateNested } from "class-validator";
@@ -6,13 +6,16 @@ import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class BusyTimesOutput {
@IsDate()
@ApiProperty({ type: Date })
start!: Date;
@IsDate()
@ApiProperty({ type: Date })
end!: Date;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, nullable: true })
source?: string | null;
}
@@ -24,5 +27,6 @@ export class GetBusyTimesOutput {
@ValidateNested()
@Type(() => BusyTimesOutput)
@IsArray()
@ApiProperty({ type: [BusyTimesOutput] })
data!: BusyTimesOutput[];
}
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
@@ -18,195 +18,246 @@ import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Integration {
@IsOptional()
@IsObject()
@ApiPropertyOptional({ type: Object, nullable: true })
appData?: object | null;
@IsOptional()
@IsString()
@ApiPropertyOptional()
dirName?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
__template?: string;
@IsString()
@ApiProperty()
name!: string;
@IsString()
@ApiProperty()
description!: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
installed?: boolean;
@IsString()
@ApiProperty()
type!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
title?: string;
@IsString()
@ApiProperty()
variant!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
category?: string;
@IsArray()
@IsString({ each: true })
@ApiProperty({ type: [String] })
categories!: string[];
@IsString()
@ApiProperty()
logo!: string;
@IsString()
@ApiProperty()
publisher!: string;
@IsString()
@ApiProperty()
slug!: string;
@IsUrl()
@ApiProperty()
url!: string;
@IsEmail()
@ApiProperty()
email!: string;
@IsOptional()
@IsObject()
@ApiProperty({ type: Object, nullable: true })
locationOption!: object | null;
}
class Primary {
@IsEmail()
@ApiProperty()
externalId!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
integration?: string;
@IsOptional()
@IsEmail()
@ApiPropertyOptional()
name?: string;
@IsBoolean()
@ApiProperty({ type: Boolean, nullable: true })
primary!: boolean | null;
@IsBoolean()
@ApiProperty()
readOnly!: boolean;
@IsEmail()
@IsOptional()
@ApiPropertyOptional()
email?: string;
@IsBoolean()
@ApiProperty()
isSelected!: boolean;
@IsInt()
@ApiProperty()
credentialId!: number;
}
export class Calendar {
@IsEmail()
@ApiProperty()
externalId!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
integration?: string;
@IsEmail()
@IsOptional()
@ApiPropertyOptional()
name?: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ type: Boolean, nullable: true })
primary?: boolean | null;
@IsBoolean()
@ApiProperty()
readOnly!: boolean;
@IsEmail()
@IsOptional()
@ApiPropertyOptional()
email?: string;
@IsBoolean()
@ApiProperty()
isSelected!: boolean;
@IsInt()
@ApiProperty()
credentialId!: number;
}
export class ConnectedCalendar {
@ValidateNested()
@IsObject()
@ApiProperty({ type: Integration })
integration!: Integration;
@IsInt()
@ApiProperty()
credentialId!: number;
@ValidateNested()
@IsObject()
@IsOptional()
@ApiPropertyOptional({ type: Primary })
primary?: Primary;
@ValidateNested({ each: true })
@IsArray()
@IsOptional()
@ApiPropertyOptional({ type: [Calendar] })
calendars?: Calendar[];
}
class DestinationCalendar {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty()
integration!: string;
@IsEmail()
@ApiProperty()
externalId!: string;
@IsEmail()
@ApiProperty({ type: String, nullable: true })
primaryEmail!: string | null;
@IsInt()
@ApiProperty({ nullable: true })
userId!: number | null;
@IsOptional()
@IsInt()
@ApiProperty({ type: Number, nullable: true })
eventTypeId!: number | null;
@IsInt()
@ApiProperty({ type: Number, nullable: true })
credentialId!: number | null;
@IsString()
@IsOptional()
@ApiPropertyOptional({ type: String, nullable: true })
name?: string | null;
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
primary?: boolean;
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
readOnly?: boolean;
@IsEmail()
@IsOptional()
@ApiPropertyOptional()
email?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
integrationTitle?: string;
}
export class ConnectedCalendarsData {
@ValidateNested({ each: true })
@IsArray()
@ApiProperty({ type: [ConnectedCalendar] })
connectedCalendars!: ConnectedCalendar[];
@ValidateNested()
@IsObject()
@ApiProperty({ type: DestinationCalendar })
destinationCalendar!: DestinationCalendar;
}
export class ConnectedCalendarsOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
@@ -214,5 +265,6 @@ export class ConnectedCalendarsOutput {
@ValidateNested()
@Type(() => ConnectedCalendarsData)
@ApiProperty({ type: ConnectedCalendarsData })
data!: ConnectedCalendarsData;
}
@@ -116,7 +116,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
) {
// User chose not to authorize your app or didn't authorize your app
// redirect directly without oauth code
if (!code) {
if (!code || code === "undefined") {
return { url: redir || origin };
}
@@ -151,7 +151,7 @@ export class OutlookService implements OAuthCalendarApp {
redir?: string
) {
// if code is not defined, user denied to authorize office 365 app, just redirect straight away
if (!code) {
if (!code || code === "undefined") {
return { url: redir || origin };
}
@@ -1,5 +1,5 @@
import { EventTypeLocation_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input";
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { ApiProperty as DocsProperty, ApiPropertyOptional, ApiHideProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsString,
@@ -36,7 +36,10 @@ export class CreateEventTypeInput_2024_04_15 {
@IsOptional()
@IsString()
@DocsProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE })
@ApiPropertyOptional({
type: String,
example: CREATE_EVENT_DESCRIPTION_EXAMPLE,
})
description?: string;
@IsOptional()
@@ -48,30 +51,38 @@ export class CreateEventTypeInput_2024_04_15 {
@ValidateNested({ each: true })
@Type(() => EventTypeLocation_2024_04_15)
@IsArray()
@ApiPropertyOptional({
type: [EventTypeLocation_2024_04_15],
})
locations?: EventTypeLocation_2024_04_15[];
@IsBoolean()
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
disableGuests?: boolean;
@IsOptional()
@IsInt()
@Min(0)
@IsOptional()
@ApiPropertyOptional()
slotInterval?: number;
@IsOptional()
@IsInt()
@Min(0)
@IsOptional()
@ApiPropertyOptional()
minimumBookingNotice?: number;
@IsOptional()
@IsInt()
@Min(0)
@IsOptional()
@ApiPropertyOptional()
beforeEventBuffer?: number;
@IsOptional()
@IsInt()
@Min(0)
@IsOptional()
@ApiPropertyOptional()
afterEventBuffer?: number;
// @ApiHideProperty()
@@ -1,4 +1,4 @@
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { ApiProperty, ApiHideProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsString, IsNumber, IsBoolean, IsOptional, IsUrl } from "class-validator";
// note(Lauris): We will gradually expose more properties if any customer needs them.
@@ -6,7 +6,7 @@ import { IsString, IsNumber, IsBoolean, IsOptional, IsUrl } from "class-validato
export class EventTypeLocation_2024_04_15 {
@IsString()
@DocsProperty({ example: "link" })
@ApiProperty({ example: "link" })
type!: string;
@IsOptional()
@@ -16,7 +16,7 @@ export class EventTypeLocation_2024_04_15 {
@IsOptional()
@IsUrl()
@DocsProperty({ example: "https://masterchief.com/argentina/flan/video/9129412" })
@ApiPropertyOptional({ example: "https://masterchief.com/argentina/flan/video/9129412" })
link?: string;
@IsOptional()
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsBoolean, IsOptional, IsString } from "class-validator";
@@ -6,11 +6,11 @@ export class GetPublicEventTypeQueryParams_2024_04_15 {
@Transform(({ value }: { value: string }) => value === "true")
@IsBoolean()
@IsOptional()
@ApiProperty({ required: false })
@ApiPropertyOptional()
isTeamEvent?: boolean;
@ApiProperty({ required: false })
@IsString()
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, nullable: true })
org?: string | null;
}
@@ -2,6 +2,7 @@ import { Editable } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/e
import { BaseField } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/field-type";
import { Frequency } from "@/ee/event-types/event-types_2024_04_15/inputs/enums/frequency";
import { EventTypeLocation_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/inputs/event-type-location.input";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsString,
@@ -21,206 +22,259 @@ import {
class Option {
@IsString()
@ApiProperty()
value!: string;
@IsString()
@ApiProperty()
label!: string;
}
class Source {
@IsString()
@ApiProperty()
id!: string;
@IsString()
@ApiProperty()
type!: string;
@IsString()
@ApiProperty()
label!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
editUrl?: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
fieldRequired?: boolean;
}
class View {
@IsString()
@ApiProperty()
id!: string;
@IsString()
@ApiProperty()
label!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
description?: string;
}
class OptionsInput {
@IsString()
@ApiProperty({
description: 'Type of the field, can be one of "address", "text", or "phone".',
enum: ["address", "text", "phone"],
example: "text",
})
type!: "address" | "text" | "phone";
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
required?: boolean;
@IsOptional()
@IsString()
@ApiPropertyOptional()
placeholder?: string;
}
class VariantField {
@IsString()
@ApiProperty()
type!: BaseField;
@IsString()
@ApiProperty()
name!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
label?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
labelAsSafeHtml?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
placeholder?: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
required?: boolean;
}
class Variant {
@ValidateNested({ each: true })
@Type(() => VariantField)
@ApiProperty({ type: [VariantField] })
fields!: VariantField[];
}
class VariantsConfig {
@ApiProperty({ type: Object })
variants!: Record<string, Variant>;
}
export class BookingField_2024_04_15 {
@IsEnum(BaseField)
@ApiProperty({ enum: BaseField })
type!: BaseField;
@IsString()
@ApiProperty()
name!: string;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => Option)
@ApiPropertyOptional({ type: [Option] })
options?: Option[];
@IsOptional()
@IsString()
@ApiPropertyOptional()
label?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
labelAsSafeHtml?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
defaultLabel?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
placeholder?: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
required?: boolean;
@IsOptional()
@IsString()
@ApiPropertyOptional()
getOptionsAt?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => OptionsInput)
@ApiPropertyOptional()
optionsInputs?: Record<string, OptionsInput>;
@IsOptional()
@IsString()
@ApiPropertyOptional()
variant?: string;
@IsOptional()
@ValidateNested()
@Type(() => VariantsConfig)
@ApiPropertyOptional({ type: VariantsConfig })
variantsConfig?: VariantsConfig;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => View)
@ApiPropertyOptional({ type: [View] })
views?: View[];
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
hideWhenJustOneOption?: boolean;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
hidden?: boolean;
@IsOptional()
@IsEnum(Editable)
@ApiPropertyOptional()
editable?: Editable;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => Source)
@ApiPropertyOptional({ type: [Source] })
sources?: Source[];
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
disableOnPrefill?: boolean;
}
export class RecurringEvent_2024_04_15 {
@IsDate()
@IsOptional()
@ApiPropertyOptional({ type: Date })
dtstart?: Date;
@IsInt()
@ApiProperty()
interval!: number;
@IsInt()
@ApiProperty()
count!: number;
@IsEnum(Frequency)
@ApiProperty({ enum: Frequency })
freq!: Frequency;
@IsDate()
@IsOptional()
@ApiPropertyOptional({ type: Date })
until?: Date;
@IsString()
@IsOptional()
@ApiPropertyOptional()
tzid?: string;
}
export class IntervalLimits_2024_04_15 {
@IsNumber()
@IsOptional()
@ApiPropertyOptional()
PER_DAY?: number;
@IsNumber()
@IsOptional()
@ApiPropertyOptional()
PER_WEEK?: number;
@IsNumber()
@IsOptional()
@ApiPropertyOptional()
PER_MONTH?: number;
@IsNumber()
@IsOptional()
@ApiPropertyOptional()
PER_YEAR?: number;
}
@@ -228,27 +282,33 @@ export class UpdateEventTypeInput_2024_04_15 {
@IsInt()
@Min(1)
@IsOptional()
@ApiPropertyOptional()
length?: number;
@IsString()
@IsOptional()
@ApiPropertyOptional()
slug?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
title?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
description?: string;
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
hidden?: boolean;
@ValidateNested({ each: true })
@Type(() => EventTypeLocation_2024_04_15)
@IsOptional()
@ApiPropertyOptional({ type: [EventTypeLocation_2024_04_15] })
locations?: EventTypeLocation_2024_04_15[];
// @IsInt()
@@ -283,6 +343,7 @@ export class UpdateEventTypeInput_2024_04_15 {
@IsArray()
@ValidateNested({ each: true })
@Type(() => BookingField_2024_04_15)
@ApiPropertyOptional({ type: [BookingField_2024_04_15] })
bookingFields?: BookingField_2024_04_15[];
// @IsString()
@@ -328,6 +389,7 @@ export class UpdateEventTypeInput_2024_04_15 {
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
disableGuests?: boolean;
// @IsBoolean()
@@ -337,16 +399,19 @@ export class UpdateEventTypeInput_2024_04_15 {
@IsInt()
@Min(0)
@IsOptional()
@ApiPropertyOptional()
minimumBookingNotice?: number;
@IsInt()
@Min(0)
@IsOptional()
@ApiPropertyOptional()
beforeEventBuffer?: number;
@IsInt()
@Min(0)
@IsOptional()
@ApiPropertyOptional()
afterEventBuffer?: number;
// @IsInt()
@@ -384,6 +449,7 @@ export class UpdateEventTypeInput_2024_04_15 {
@IsInt()
@Min(0)
@IsOptional()
@ApiPropertyOptional()
slotInterval?: number;
// @IsString()
@@ -12,7 +12,7 @@ import {
IntervalLimits_2024_04_15,
RecurringEvent_2024_04_15,
} from "@/ee/event-types/event-types_2024_04_15/inputs/update-event-type.input";
import { ApiProperty as DocsProperty, ApiHideProperty } from "@nestjs/swagger";
import { ApiHideProperty, ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
@@ -29,23 +29,23 @@ import {
export class EventTypeOutput {
@IsInt()
@DocsProperty({ example: 1 })
@ApiProperty({ example: 1 })
id!: number;
@IsInt()
@DocsProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
@ApiProperty({ example: CREATE_EVENT_LENGTH_EXAMPLE })
length!: number;
@IsString()
@DocsProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
@ApiProperty({ example: CREATE_EVENT_SLUG_EXAMPLE })
slug!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
@ApiProperty({ example: CREATE_EVENT_TITLE_EXAMPLE })
title!: string;
@IsString()
@DocsProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE })
@ApiProperty({ example: CREATE_EVENT_DESCRIPTION_EXAMPLE, nullable: true })
description!: string | null;
@IsBoolean()
@@ -55,6 +55,7 @@ export class EventTypeOutput {
@ValidateNested({ each: true })
@Type(() => EventTypeLocation_2024_04_15)
@IsArray()
@ApiProperty({ type: [EventTypeLocation_2024_04_15], nullable: true })
locations!: EventTypeLocation_2024_04_15[] | null;
@IsInt()
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsBoolean,
@@ -17,330 +17,421 @@ import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Location {
@IsString()
@ApiProperty()
type!: string;
}
class Source {
@IsString()
@ApiProperty()
id!: string;
@IsString()
@ApiProperty()
type!: string;
@IsString()
@ApiProperty()
label!: string;
}
class OptionInput {
@IsString()
@ApiProperty()
type!: string;
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
required?: boolean;
@IsString()
@IsOptional()
@ApiPropertyOptional()
placeholder?: string;
}
class BookingField {
@IsString()
@ApiProperty()
name!: string;
@IsString()
@ApiProperty()
type!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
defaultLabel?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
label?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
placeholder?: string;
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
required?: boolean;
@IsOptional()
@ApiPropertyOptional()
getOptionsAt?: string;
@IsObject()
@IsOptional()
@ApiPropertyOptional()
optionsInputs?: { [key: string]: OptionInput };
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
hideWhenJustOneOption?: boolean;
@IsString()
@IsOptional()
@ApiPropertyOptional()
editable?: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => Source)
@IsOptional()
@ApiPropertyOptional({ type: [Source] })
sources?: Source[];
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
disableOnPrefill?: boolean;
}
class Organization {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@IsOptional()
@ApiPropertyOptional({ type: String, nullable: true })
slug?: string | null;
@IsString()
@ApiProperty()
name!: string;
@IsOptional()
metadata!: Record<string, any>;
@ApiPropertyOptional({ type: Object })
metadata?: Record<string, any>;
}
class Profile {
@IsString()
@ApiProperty({ type: String, nullable: true })
username!: string | null;
@IsInt()
@ApiProperty({ type: Number, nullable: true })
id!: number | null;
@IsInt()
@IsOptional()
@ApiPropertyOptional()
userId?: number;
@IsString()
@IsOptional()
@ApiPropertyOptional()
uid?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
name?: string;
@IsInt()
@ApiProperty({ type: Number, nullable: true })
organizationId!: number | null;
@ValidateNested()
@Type(() => Organization)
@ApiPropertyOptional({ type: Organization, nullable: true })
organization?: Organization | null;
@IsString()
@ApiProperty()
upId!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
image?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
brandColor?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
darkBrandColor?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
theme?: string;
@IsOptional()
@ApiPropertyOptional()
bookerLayouts?: any;
}
class Owner {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@IsOptional()
@ApiPropertyOptional({ type: String, nullable: true })
avatarUrl?: string | null;
@IsString()
@ApiProperty({ type: String, nullable: true })
username!: string | null;
@IsString()
@ApiProperty({ type: String, nullable: true })
name!: string | null;
@IsString()
@ApiProperty()
weekStart!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({ type: String, nullable: true })
brandColor?: string | null;
@IsString()
@IsOptional()
@ApiPropertyOptional({ type: String, nullable: true })
darkBrandColor?: string | null;
@IsString()
@IsOptional()
@ApiPropertyOptional({ type: String, nullable: true })
theme?: string | null;
@IsOptional()
metadata!: any;
@ApiPropertyOptional()
metadata?: any;
@IsInt()
@IsOptional()
@ApiPropertyOptional({ type: Number, nullable: true })
defaultScheduleId?: number | null;
@IsString()
@ApiProperty({ type: String, nullable: true })
nonProfileUsername!: string | null;
@ValidateNested()
@Type(() => Profile)
@ApiProperty({ type: Profile })
profile!: Profile;
}
class User {
@IsString()
@ApiProperty({ type: String, nullable: true })
username!: string | null;
@IsString()
@ApiProperty({ type: String, nullable: true })
name!: string | null;
@IsString()
@ApiProperty()
weekStart!: string;
@IsInt()
@IsOptional()
@ApiPropertyOptional()
organizationId?: number;
@IsString()
@IsOptional()
@ApiPropertyOptional({ type: String, nullable: true })
avatarUrl?: string | null;
@ValidateNested()
@ApiProperty({ type: Profile })
profile!: Profile;
@IsString()
@ApiProperty()
bookerUrl!: string;
}
class Schedule {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty({ type: String, nullable: true })
timeZone!: string | null;
}
class PublicEventTypeOutput {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty()
title!: string;
@IsString()
@ApiProperty()
description!: string;
@IsString()
@IsOptional()
@ApiPropertyOptional({ type: String, nullable: true })
eventName?: string | null;
@IsString()
@ApiProperty()
slug!: string;
@IsBoolean()
@ApiProperty()
isInstantEvent!: boolean;
@IsOptional()
@ApiPropertyOptional()
aiPhoneCallConfig?: any;
@IsOptional()
@ApiPropertyOptional()
schedulingType?: any;
@IsInt()
@ApiProperty()
length!: number;
@IsArray()
@ValidateNested({ each: true })
@Type(() => Location)
@ApiProperty({ type: [Location] })
locations!: Location[];
@IsArray()
@ApiProperty({ type: [Object] })
customInputs!: any[];
@IsBoolean()
@ApiProperty()
disableGuests!: boolean;
@IsObject()
@ApiProperty({ type: Object, nullable: true })
metadata!: object | null;
@IsBoolean()
@ApiProperty()
lockTimeZoneToggleOnBookingPage!: boolean;
@IsBoolean()
@ApiProperty()
requiresConfirmation!: boolean;
@IsBoolean()
@ApiProperty()
requiresBookerEmailVerification!: boolean;
@IsOptional()
@ApiPropertyOptional()
recurringEvent?: any;
@IsNumber()
@ApiProperty()
price!: number;
@IsString()
@ApiProperty()
currency!: string;
@IsOptional()
@ApiPropertyOptional({ type: Number, nullable: true })
seatsPerTimeSlot?: number | null;
@IsBoolean()
@ApiProperty({ type: Boolean, nullable: true })
seatsShowAvailabilityCount!: boolean | null;
@IsArray()
@ValidateNested({ each: true })
@Type(() => BookingField)
@ApiProperty({ type: [BookingField] })
bookingFields!: BookingField[];
@IsOptional()
@ApiPropertyOptional()
team?: any;
@IsOptional()
@IsUrl()
@ApiPropertyOptional({ type: String, nullable: true })
successRedirectUrl?: string | null;
@IsArray()
@ApiProperty()
workflows!: any[];
@IsArray()
@ApiProperty()
hosts!: any[];
@ValidateNested()
@Type(() => Owner)
@ApiProperty({ type: Owner, nullable: true })
owner!: Owner | null;
@ValidateNested()
@Type(() => Schedule)
@ApiProperty({ type: Schedule, nullable: true })
schedule!: Schedule | null;
@IsBoolean()
@ApiProperty()
hidden!: boolean;
@IsBoolean()
@ApiProperty()
assignAllTeamMembers!: boolean;
@IsOptional()
@ApiPropertyOptional()
bookerLayouts?: any;
@IsArray()
@ValidateNested({ each: true })
@Type(() => User)
@ApiProperty({ type: [User] })
users!: User[];
@IsObject()
@ApiProperty({ type: Object })
entity!: object;
@IsBoolean()
@@ -1,4 +1,4 @@
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
import { ApiProperty as DocsProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsString, IsBoolean, IsOptional, IsEnum, Matches } from "class-validator";
@@ -38,34 +38,34 @@ export class CreatePhoneCallInput {
@IsOptional()
@IsString()
@DocsProperty({ description: "Scheduler name" })
@ApiPropertyOptional({ description: "Scheduler name" })
schedulerName?: string;
@IsOptional()
@IsString()
@Transform(({ value }) => (value ? value : undefined))
@DocsProperty({ description: "Guest name" })
@ApiPropertyOptional({ description: "Guest name" })
guestName?: string;
@IsOptional()
@IsString()
@Transform(({ value }) => (value ? value : undefined))
@DocsProperty({ description: "Guest email" })
@ApiPropertyOptional({ description: "Guest email" })
guestEmail?: string;
@IsOptional()
@IsString()
@Transform(({ value }) => (value ? value : undefined))
@DocsProperty({ description: "Guest company" })
@ApiPropertyOptional({ description: "Guest company" })
guestCompany?: string;
@IsOptional()
@IsString()
@DocsProperty({ description: "Begin message" })
@ApiPropertyOptional({ description: "Begin message" })
beginMessage?: string;
@IsOptional()
@IsString()
@DocsProperty({ description: "General prompt" })
@ApiPropertyOptional({ description: "General prompt" })
generalPrompt?: string;
}
+14 -2
View File
@@ -1,39 +1,51 @@
import { ApiProperty as DocsProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsInt, IsEmail, IsOptional, IsString, ValidateNested } from "class-validator";
import { IsString, IsOptional, IsInt, IsEmail, ValidateNested } from "class-validator";
export class MeOrgOutput {
@DocsProperty()
isPlatform!: boolean;
@DocsProperty()
id!: number;
}
export class MeOutput {
@IsInt()
@DocsProperty()
id!: number;
@IsString()
@DocsProperty()
username!: string;
@IsEmail()
@DocsProperty()
email!: string;
@IsInt()
@DocsProperty()
timeFormat!: number;
@IsInt()
@IsOptional()
@DocsProperty({ type: Number, nullable: true })
defaultScheduleId!: number | null;
@IsString()
@DocsProperty()
weekStart!: string;
@IsString()
@DocsProperty()
timeZone!: string;
@IsInt()
@DocsProperty({ type: Number, nullable: true })
organizationId!: number | null;
@IsOptional()
@ValidateNested()
@Type(() => MeOrgOutput)
@ApiPropertyOptional({ type: MeOrgOutput })
organization?: MeOrgOutput;
}
@@ -9,6 +9,7 @@ import { ProviderModule } from "@/ee/provider/provider.module";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { SlotsModule } from "@/modules/slots/slots.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@@ -27,6 +28,7 @@ import { Module } from "@nestjs/common";
BookingsModule_2024_08_13,
TeamsMembershipsModule,
SlotsModule,
TeamsModule,
],
})
export class PlatformEndpointsModule implements NestModule {
@@ -1,20 +1,25 @@
import { CreateAvailabilityInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-availability.input";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsArray, IsBoolean, IsTimeZone, IsOptional, IsString, ValidateNested } from "class-validator";
export class CreateScheduleInput_2024_04_15 {
@IsString()
@ApiProperty()
name!: string;
@IsTimeZone()
@ApiProperty()
timeZone!: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateAvailabilityInput_2024_04_15)
@IsOptional()
@ApiPropertyOptional({ type: [CreateAvailabilityInput_2024_04_15] })
availabilities?: CreateAvailabilityInput_2024_04_15[];
@IsBoolean()
@ApiProperty()
isDefault!: boolean;
}
@@ -1,95 +1,117 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString, ValidateNested, IsArray } from "class-validator";
class EventTypeModel_2024_04_15 {
@IsInt()
@ApiProperty()
id!: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, nullable: true })
eventName?: string | null;
}
class AvailabilityModel_2024_04_15 {
@IsInt()
@ApiProperty()
id!: number;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
userId?: number | null;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
scheduleId?: number | null;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
eventTypeId?: number | null;
@IsArray()
@IsInt({ each: true })
@ApiProperty({ type: [Number] })
days!: number[];
@IsOptional()
@Type(() => Date)
@IsString() // Assuming date is serialized/deserialized appropriately
@IsString()
@ApiPropertyOptional()
startTime?: Date;
@IsOptional()
@Type(() => Date)
@IsString() // Assuming date is serialized/deserialized appropriately
@IsString()
@ApiPropertyOptional()
endTime?: Date;
@IsOptional()
@Type(() => Date)
@IsString() // Assuming date is serialized/deserialized appropriately
@IsString()
@ApiPropertyOptional({ type: String, nullable: true })
date?: Date | null;
}
class ScheduleModel_2024_04_15 {
@IsInt()
@ApiProperty()
id!: number;
@IsInt()
@ApiProperty()
userId!: number;
@IsString()
@ApiProperty()
name!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, nullable: true })
timeZone?: string | null;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => EventTypeModel_2024_04_15)
@IsArray()
@ApiPropertyOptional({ type: [EventTypeModel_2024_04_15] })
eventType?: EventTypeModel_2024_04_15[];
@IsOptional()
@ValidateNested({ each: true })
@Type(() => AvailabilityModel_2024_04_15)
@IsArray()
@ApiPropertyOptional({ type: [AvailabilityModel_2024_04_15] })
availability?: AvailabilityModel_2024_04_15[];
}
export class UpdatedScheduleOutput_2024_04_15 {
@ValidateNested()
@Type(() => ScheduleModel_2024_04_15)
@ApiProperty({ type: ScheduleModel_2024_04_15 })
schedule!: ScheduleModel_2024_04_15;
@IsBoolean()
@ApiProperty()
isDefault!: boolean;
@IsOptional()
@IsString()
@ApiPropertyOptional()
timeZone?: string;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
prevDefaultId?: number | null;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
currentDefaultId?: number | null;
}
@@ -1,105 +1,132 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsDate, IsOptional, IsArray, IsBoolean, IsInt, IsString, ValidateNested } from "class-validator";
class AvailabilityModel {
@IsInt()
@ApiProperty()
id!: number;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
userId?: number | null;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
eventTypeId?: number | null;
@IsArray()
@IsInt({ each: true })
@ApiProperty({ type: [Number] })
days!: number[];
@IsDate()
@Type(() => Date)
@ApiProperty({ type: Date })
startTime!: Date;
@IsDate()
@Type(() => Date)
@ApiProperty({ type: Date })
endTime!: Date;
@IsOptional()
@IsDate()
@Type(() => Date)
@ApiPropertyOptional({ type: Date, nullable: true })
date?: Date | null;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
scheduleId?: number | null;
}
class WorkingHours {
@IsArray()
@IsInt({ each: true })
@ApiProperty({ type: [Number] })
days!: number[];
@IsInt()
@ApiProperty()
startTime!: number;
@IsInt()
@ApiProperty()
endTime!: number;
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
userId?: number | null;
}
class TimeRange {
@IsOptional()
@IsInt()
@ApiPropertyOptional({ type: Number, nullable: true })
userId?: number | null;
@IsDate()
@ApiProperty({ type: Date })
start!: Date;
@IsDate()
@ApiProperty({ type: Date })
end!: Date;
}
export class ScheduleOutput {
@IsInt()
@ApiProperty()
id!: number;
@IsString()
@ApiProperty()
name!: string;
@IsBoolean()
@ApiProperty()
isManaged!: boolean;
@ValidateNested({ each: true })
@Type(() => WorkingHours)
@ApiProperty({ type: [WorkingHours] })
workingHours!: WorkingHours[];
@ValidateNested({ each: true })
@Type(() => AvailabilityModel)
@IsArray()
@ApiProperty({ type: [AvailabilityModel] })
schedule!: AvailabilityModel[];
@ApiProperty({ type: [[TimeRange]] })
availability!: TimeRange[][];
@IsString()
@ApiProperty()
timeZone!: string;
@ValidateNested({ each: true })
@IsArray()
@ApiPropertyOptional({ type: [Object] })
// note(Lauris) it should be
// dateOverrides!: { ranges: TimeRange[] }[];
// but docs aren't generating correctly it results in array of strings
dateOverrides!: unknown[];
@IsBoolean()
@ApiProperty()
isDefault!: boolean;
@IsBoolean()
@ApiProperty()
isLastSchedule!: boolean;
@IsBoolean()
@ApiProperty()
readOnly!: boolean;
}
+3 -1
View File
@@ -30,12 +30,14 @@ export type Environment = {
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: number;
AXIOM_DATASET: string;
AXIOM_TOKEN: string;
STRIPE_TEAM_MONTHLY_PRICE_ID: string;
IS_TEAM_BILLING_ENABLED: boolean;
};
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
const value = process.env[key] as Environment[K] | undefined;
if (!value) {
if (value === undefined) {
if (fallback) {
return fallback;
}
@@ -9,7 +9,13 @@ import {
} from "@prisma/client/runtime/library";
import { Request } from "express";
import { ERROR_STATUS, INTERNAL_SERVER_ERROR } from "@calcom/platform-constants";
import {
BAD_REQUEST,
CONFLICT,
ERROR_STATUS,
INTERNAL_SERVER_ERROR,
NOT_FOUND,
} from "@calcom/platform-constants";
import { Response } from "@calcom/platform-types";
type PrismaError =
@@ -43,11 +49,37 @@ export class PrismaExceptionFilter implements ExceptionFilter {
method: request.method,
requestId,
});
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
let message = "There was an error, please try again later.";
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
let errorCode = INTERNAL_SERVER_ERROR;
if (error instanceof PrismaClientKnownRequestError) {
switch (error.code) {
case "P2002": // Unique constraint failed
errorCode = CONFLICT;
statusCode = HttpStatus.CONFLICT;
message = "Invalid Input: Trying to create a record that already exists.";
break;
case "P2025": // Record not found
errorCode = NOT_FOUND;
statusCode = HttpStatus.NOT_FOUND;
message = "Invalid Query: The requested record was not found.";
break;
case "P2003": // Foreign key constraint failed
errorCode = BAD_REQUEST;
statusCode = HttpStatus.BAD_REQUEST;
message = "Invalid input: The referenced data does not exist.";
break;
default:
break;
}
}
response.status(statusCode).json({
status: ERROR_STATUS,
timestamp: new Date().toISOString(),
path: request.url,
error: { code: INTERNAL_SERVER_ERROR, message: "There was an error, please try again later." },
error: { code: errorCode, message: message },
});
}
}
@@ -0,0 +1,65 @@
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
describe("isOriginAllowed", () => {
describe("is allowed", () => {
it("should return true for exact match without wildcard", () => {
const allowedOrigins = ["https://app.cal.com/callback"];
const origin = "https://app.cal.com/callback";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(true);
});
it("should return true for wildcard domain match", () => {
const allowedOrigins = ["*.multiscreen.d1test.biz"];
const origin = "https://sub.multiscreen.d1test.biz";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(true);
});
it("should return true for wildcard pattern matching any domain", () => {
const allowedOrigins = ["*/callback"];
const origin = "https://another.com/callback";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(true);
});
it("should handle a wildcard only pattern", () => {
const allowedOrigins = ["*"];
const origin = "https://any.domain.com/anypath";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(true);
});
it("should handle multiple allowed URIs where only one matches", () => {
const allowedOrigins = [
"https://app.cal.com/other",
"*.notthisone.com",
"https://app.cal.com/callback",
];
const origin = "https://app.cal.com/callback";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(true);
});
it("should handle patterns with multiple wildcards correctly", () => {
const allowedOrigins = ["https://*.mydomain.com/*"];
const origin = "https://sub.mydomain.com/path/to/resource";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(true);
});
});
describe("is not allowed", () => {
it("should return false if no allowed patterns match", () => {
const allowedOrigins = ["https://app.cal.com/callback", "*.multiscreen.d1test.biz", "*/callback"];
const origin = "https://unknown.com";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(false);
});
it("should return false if wildcard pattern doesn't match the given origin", () => {
const allowedOrigins = ["*.example.com"];
const origin = "https://notexample.org";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(false);
});
it("should handle empty allowedUris array correctly", () => {
const allowedOrigins: string[] = [];
const origin = "https://app.cal.com/callback";
expect(isOriginAllowed(origin, allowedOrigins)).toBe(false);
});
});
});
@@ -0,0 +1,18 @@
export function isOriginAllowed(origin: string, allowedOrigins: string[]): boolean {
return allowedOrigins.some((allowedOrigin) => {
if (allowedOrigin.includes("*")) {
return wildcardToRegex(allowedOrigin).test(origin);
}
return origin === allowedOrigin;
});
}
function wildcardToRegex(pattern: string): RegExp {
const escaped = escapeRegex(pattern);
const regexPattern = "^" + escaped.replace(/\\\*/g, ".*") + "$";
return new RegExp(regexPattern);
}
function escapeRegex(str: string): string {
return str.replace(/[.+*?^${}()|[\]\\]/g, "\\$&");
}
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
import { paymentDataSelect } from "@calcom/platform-libraries";
@Injectable()
@@ -14,4 +15,53 @@ export class AtomsRepository {
select: paymentDataSelect,
});
}
async getUserTeams(userId: number) {
const userTeams = await this.dbRead.prisma.team.findMany({
where: {
members: {
some: {
userId,
accepted: true,
},
},
},
select: {
id: true,
credentials: {
select: credentialForCalendarServiceSelect,
},
name: true,
logoUrl: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
parent: {
select: {
id: true,
credentials: {
select: credentialForCalendarServiceSelect,
},
name: true,
logoUrl: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
},
},
});
return userTeams;
}
}
@@ -1,10 +1,12 @@
import { Transform, Type } from "class-transformer";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { ArrayNotEmpty, IsArray, IsInt, IsNumber, IsOptional } from "class-validator";
export class EventTypesAppInput {
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@IsOptional()
@ApiPropertyOptional()
teamId?: number;
}
@@ -12,5 +14,6 @@ export class BulkUpdateEventTypeToDefaultLocationDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
@ApiProperty({ type: [Number] })
eventTypeIds!: number[];
}
@@ -2,10 +2,10 @@ import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common";
@@ -46,7 +46,6 @@ export class EventTypesAtomService {
private readonly membershipsRepository: MembershipsRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly atomsRepository: AtomsRepository,
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
private readonly usersService: UsersService,
private readonly dbWrite: PrismaWriteService,
private readonly dbRead: PrismaReadService,
@@ -183,7 +182,7 @@ export class EventTypesAtomService {
let credentials = await this.credentialsRepository.getAllUserCredentialsById(userId);
let userTeams: TeamQuery[] = [];
if (teamId) {
const teamsQuery = await this.organizationsTeamsRepository.getUserTeamsById(userId);
const teamsQuery = await this.atomsRepository.getUserTeams(userId);
// If a team is a part of an org then include those apps
// Don't want to iterate over these parent teams
const filteredTeams: TeamQuery[] = [];
@@ -1,5 +1,6 @@
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { AuthMethods } from "@/lib/enums/auth-methods";
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
import { BaseStrategy } from "@/lib/passport/strategies/types";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { DeploymentsService } from "@/modules/deployments/deployments.service";
@@ -167,7 +168,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
throw new UnauthorizedException("OAuth client not found given the access token");
}
if (origin && !client.redirectUris.some((uri) => uri.startsWith(origin))) {
if (origin && !isOriginAllowed(origin, client.redirectUris)) {
throw new UnauthorizedException(
`Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${origin}' to the 'Redirect uris' of your OAuth client.`
);
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsEnum, IsNumber, IsOptional, IsBoolean } from "class-validator";
@@ -20,7 +20,11 @@ export class ConferencingAppsOutputDto {
@IsNumber()
userId!: number;
@ApiProperty({ example: true, description: "Whether if the connection is working or not." })
@ApiPropertyOptional({
example: true,
description: "Whether if the connection is working or not.",
nullable: true,
})
@Expose()
@IsBoolean()
@IsOptional()
@@ -28,24 +32,26 @@ export class ConferencingAppsOutputDto {
}
export class ConferencingAppsOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@ApiProperty({ type: String, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@ValidateNested({ each: true })
@Type(() => ConferencingAppsOutputDto)
@ApiProperty({ type: [ConferencingAppsOutputDto] })
data!: ConferencingAppsOutputDto[];
}
export class ConferencingAppOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@ApiProperty({ type: String, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => ConferencingAppsOutputDto)
@ApiProperty({ type: ConferencingAppsOutputDto })
data!: ConferencingAppsOutputDto;
}
@@ -33,6 +33,16 @@ export class MembershipsRepository {
return membership;
}
async findUserMemberships(userId: number) {
const memberships = await this.dbRead.prisma.membership.findMany({
where: {
userId,
},
});
return memberships;
}
async findMembershipByOrgId(orgId: number, userId: number) {
return this.findMembershipByTeamId(orgId, userId);
}
@@ -10,10 +10,10 @@ export class ManagedUserOutput {
@ApiProperty({ example: "alice+cluo37fwd0001khkzqqynkpj3@example.com" })
email!: string;
@ApiProperty({ example: "alice" })
@ApiProperty({ example: "alice", nullable: true })
username!: string | null;
@ApiProperty({ example: "alice" })
@ApiProperty({ example: "alice", nullable: true })
name!: string | null;
@ApiProperty({ example: "America/New_York" })
@@ -22,19 +22,19 @@ export class ManagedUserOutput {
@ApiProperty({ example: "Sunday" })
weekStart!: string;
@ApiProperty({ example: "2024-04-01T00:00:00.000Z", type: "string" })
@ApiProperty({ type: String, example: "2024-04-01T00:00:00.000Z" })
@Transform(({ value }) => value.toISOString())
createdDate!: Date;
@ApiProperty({ example: 12, nullable: true })
@ApiProperty({ type: Number, example: 12, nullable: true })
timeFormat!: number | null;
@ApiProperty({ example: null })
@ApiProperty({ type: Number, example: null, nullable: true })
defaultScheduleId!: number | null;
@IsEnum(Locales)
@IsOptional()
@ApiProperty({ example: Locales.EN, enum: Locales })
@ApiPropertyOptional({ type: String, example: Locales.EN, enum: Locales })
locale?: Locales;
@IsUrl()
@@ -43,7 +43,7 @@ export class ManagedUserOutput {
type: String,
example: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
description: `URL of the user's avatar image`,
required: false,
nullable: true,
})
avatarUrl?: string | null;
}
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
@@ -30,7 +30,7 @@ export class PlatformOAuthClientDto {
@IsNumber()
permissions!: number;
@ApiProperty({ example: "https://example.com/logo.png", required: false })
@ApiPropertyOptional({ example: "https://example.com/logo.png" })
@IsOptional()
@IsString()
logo!: string | null;
@@ -54,9 +54,7 @@ export class GetOAuthClientResponseDto {
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: PlatformOAuthClientDto,
})
@ApiProperty({ type: PlatformOAuthClientDto })
@IsNotEmptyObject()
@ValidateNested()
@Type(() => PlatformOAuthClientDto)
@@ -1,5 +1,6 @@
import { getEnv } from "@/env";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
@@ -62,7 +63,7 @@ export class OAuthFlowController {
throw new BadRequestException(`OAuth client with ID '${clientId}' not found`);
}
if (!oauthClient?.redirectUris.includes(body.redirectUri)) {
if (!isOriginAllowed(body.redirectUri, oauthClient.redirectUris)) {
throw new BadRequestException("Invalid 'redirect_uri' value.");
}
@@ -1,32 +1,40 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsArray, IsBoolean, IsOptional, IsString } from "class-validator";
export class UpdateOAuthClientInput {
@IsOptional()
@IsString()
@ApiPropertyOptional()
logo?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
name?: string;
@IsArray()
@IsOptional()
@IsString({ each: true })
redirectUris?: string[] = [];
@ApiPropertyOptional({ type: [String] })
redirectUris?: string[];
@IsOptional()
@IsString()
@ApiPropertyOptional()
bookingRedirectUri?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
bookingCancelRedirectUri?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
bookingRescheduleRedirectUri?: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
areEmailsEnabled?: boolean;
}
@@ -1,17 +1,21 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
export class AssignOrganizationAttributeOptionToUserInput {
@IsOptional()
@IsString()
@IsNotEmpty()
@ApiPropertyOptional()
readonly value?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
@ApiPropertyOptional()
readonly attributeOptionId?: string;
@IsNotEmpty()
@IsString()
@ApiProperty()
readonly attributeId!: string;
}
@@ -1,4 +1,5 @@
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { AttributeType } from "@prisma/client";
import { Type } from "class-transformer";
import {
@@ -14,22 +15,27 @@ import {
export class CreateOrganizationAttributeInput {
@IsString()
@IsNotEmpty()
@ApiProperty()
readonly name!: string;
@IsString()
@IsNotEmpty()
@ApiProperty()
readonly slug!: string;
@IsEnum(AttributeType)
@IsNotEmpty()
@ApiProperty({ enum: AttributeType })
readonly type!: AttributeType;
@IsArray()
@ValidateNested()
@ValidateNested({ each: true })
@Type(() => CreateOrganizationAttributeOptionInput)
@ApiProperty({ type: [CreateOrganizationAttributeOptionInput] })
readonly options!: CreateOrganizationAttributeOptionInput[];
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
readonly enabled?: boolean;
}
@@ -1,11 +1,14 @@
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsString, IsOptional } from "class-validator";
export class UpdateOrganizationAttributeOptionInput {
@IsString()
@IsOptional()
@ApiPropertyOptional()
readonly value?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
readonly slug?: string;
}
@@ -1,20 +1,25 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { AttributeType } from "@prisma/client";
import { IsBoolean, IsEnum, IsOptional, IsString } from "class-validator";
export class UpdateOrganizationAttributeInput {
@IsString()
@IsOptional()
@ApiPropertyOptional()
readonly name?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
readonly slug?: string;
@IsEnum(AttributeType)
@IsOptional()
@ApiPropertyOptional({ enum: AttributeType })
readonly type?: AttributeType;
@IsBoolean()
@IsOptional()
@ApiPropertyOptional()
readonly enabled?: boolean;
}
@@ -1,13 +1,15 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator";
export class CreateOrgMembershipDto {
@IsInt()
@ApiProperty()
readonly userId!: number;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ type: Boolean, default: false })
readonly accepted?: boolean = false;
@IsEnum(MembershipRole)
@@ -16,5 +18,6 @@ export class CreateOrgMembershipDto {
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ type: Boolean, default: false })
readonly disableImpersonation?: boolean = false;
}
@@ -1,12 +1,14 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator";
export class CreateOrgTeamMembershipDto {
@IsInt()
@ApiProperty()
readonly userId!: number;
@IsOptional()
@ApiPropertyOptional({ type: Boolean, default: false })
@IsBoolean()
readonly accepted?: boolean = false;
@@ -15,6 +17,7 @@ export class CreateOrgTeamMembershipDto {
readonly role: MembershipRole = MembershipRole.MEMBER;
@IsOptional()
@ApiPropertyOptional({ type: Boolean, default: false })
@IsBoolean()
readonly disableImpersonation?: boolean = false;
}
@@ -1,107 +1,3 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
import { CreateTeamInput } from "@/modules/teams/teams/inputs/create-team.input";
export class CreateOrgTeamDto {
@IsString()
@Length(1)
@ApiProperty({ description: "Name of the team", example: "CalTeam", required: true })
readonly name!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel", required: false })
readonly slug?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png",
description: `URL of the teams logo image`,
required: false,
})
readonly logoUrl?: string;
@IsOptional()
@IsUrl()
readonly calVideoLogo?: string;
@IsOptional()
@IsUrl()
readonly appLogo?: string;
@IsOptional()
@IsUrl()
readonly appIconLogo?: string;
@IsOptional()
@IsString()
readonly bio?: string;
@IsOptional()
@IsBoolean()
readonly hideBranding?: boolean = false;
@IsOptional()
@IsBoolean()
readonly isPrivate?: boolean;
@IsOptional()
@IsBoolean()
readonly hideBookATeamMember?: boolean;
@IsOptional()
@IsString()
readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object.
@IsOptional()
@IsString()
readonly theme?: string;
@IsOptional()
@IsString()
readonly brandColor?: string;
@IsOptional()
@IsString()
readonly darkBrandColor?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
description: `URL of the teams banner image which is shown on booker`,
required: false,
})
readonly bannerUrl?: string;
@IsOptional()
@IsString()
readonly timeFormat?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "America/New_York",
description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`,
required: false,
default: "Europe/London",
})
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "Monday",
required: false,
default: "Sunday",
})
readonly weekStart?: string = "Sunday";
@IsOptional()
@IsBoolean()
readonly autoAcceptCreator?: boolean = true;
}
export class CreateOrgTeamDto extends CreateTeamInput {}
@@ -1,17 +1,21 @@
import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { IsString, IsOptional, IsBoolean, IsEnum } from "class-validator";
export class CreateOrganizationUserInput extends CreateUserInput {
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, default: "en" })
locale = "en";
@IsOptional()
@IsEnum(MembershipRole)
@ApiPropertyOptional({ enum: MembershipRole, default: MembershipRole.MEMBER })
organizationRole: MembershipRole = MembershipRole.MEMBER;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ type: Boolean, default: true })
autoAccept = true;
}
@@ -1,17 +1,20 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { IsBoolean, IsOptional, IsEnum } from "class-validator";
export class UpdateOrgMembershipDto {
@IsOptional()
@IsBoolean()
readonly accepted?: boolean = false;
@ApiPropertyOptional()
readonly accepted?: boolean;
@IsOptional()
@IsEnum(MembershipRole)
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
readonly role?: MembershipRole = MembershipRole.MEMBER;
@ApiPropertyOptional({ enum: ["MEMBER", "OWNER", "ADMIN"] })
readonly role?: MembershipRole;
@IsOptional()
@IsBoolean()
readonly disableImpersonation?: boolean = false;
@ApiPropertyOptional()
readonly disableImpersonation?: boolean;
}
@@ -1,17 +1,20 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { IsBoolean, IsOptional, IsEnum } from "class-validator";
export class UpdateOrgTeamMembershipDto {
@IsOptional()
@IsBoolean()
readonly accepted?: boolean = false;
@ApiPropertyOptional()
readonly accepted?: boolean;
@IsOptional()
@IsEnum(MembershipRole)
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
readonly role?: MembershipRole = MembershipRole.MEMBER;
@ApiPropertyOptional({ enum: ["MEMBER", "OWNER", "ADMIN"] })
readonly role?: MembershipRole;
@IsOptional()
@IsBoolean()
readonly disableImpersonation?: boolean = false;
@ApiPropertyOptional()
readonly disableImpersonation?: boolean;
}
@@ -1,111 +1,3 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
import { UpdateTeamDto } from "@/modules/teams/teams/inputs/update-team.input";
export class UpdateOrgTeamDto {
@IsString()
@Length(1)
@ApiPropertyOptional({ description: "Name of the team", example: "CalTeam", required: false })
readonly name?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel", required: false })
readonly slug?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png",
description: `URL of the teams logo image`,
required: false,
})
readonly logoUrl?: string;
@IsOptional()
@IsUrl()
readonly calVideoLogo?: string;
@IsOptional()
@IsUrl()
readonly appLogo?: string;
@IsOptional()
@IsUrl()
readonly appIconLogo?: string;
@IsOptional()
@IsString()
readonly bio?: string;
@IsOptional()
@IsBoolean()
readonly hideBranding?: boolean = false;
@IsOptional()
@IsBoolean()
readonly isPrivate?: boolean;
@IsOptional()
@IsBoolean()
readonly hideBookATeamMember?: boolean;
@IsOptional()
@IsString()
readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object.
@IsOptional()
@IsString()
readonly theme?: string;
@IsOptional()
@IsString()
readonly brandColor?: string;
@IsOptional()
@IsString()
readonly darkBrandColor?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
description: `URL of the teams banner image which is shown on booker`,
required: false,
})
readonly bannerUrl?: string;
@IsOptional()
@IsString()
readonly timeFormat?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "America/New_York",
description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`,
required: false,
default: "Europe/London",
})
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "Monday",
required: false,
default: "Sunday",
})
readonly weekStart?: string = "Sunday";
@IsOptional()
@IsString()
readonly bookingLimits?: string;
@IsOptional()
@IsBoolean()
readonly includeManagedEventsInLimits?: boolean;
}
export class UpdateOrgTeamDto extends UpdateTeamDto {}
@@ -39,6 +39,7 @@ import { OrganizationsService } from "@/modules/organizations/services/organizat
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { UsersModule } from "@/modules/users/users.module";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
@@ -53,6 +54,7 @@ import { Module } from "@nestjs/common";
RedisModule,
EmailModule,
EventTypesModule_2024_06_14,
TeamsModule,
],
providers: [
OrganizationsRepository,
@@ -2,21 +2,21 @@ import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsBoolean, IsInt, IsString, IsEnum, ValidateNested, IsOptional } from "class-validator";
import { AttributeType } from "@calcom/prisma/client";
import { IsOptional, ValidateNested } from "class-validator";
export class GetSingleAttributeOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@IsOptional()
@Type(() => Attribute)
@ApiProperty({ type: Attribute, nullable: true })
data!: Attribute | null;
}
export class GetOrganizationAttributesOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@ValidateNested({ each: true })
@Type(() => Attribute)
@ApiProperty({ type: [Attribute] })
data!: Attribute[];
}
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { Expose } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator";
@@ -6,18 +6,22 @@ import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator";
export class OrgMembershipOutputDto {
@IsInt()
@Expose()
@ApiProperty()
readonly id!: number;
@IsInt()
@Expose()
@ApiProperty()
readonly userId!: number;
@IsInt()
@Expose()
@ApiProperty()
readonly teamId!: number;
@IsBoolean()
@Expose()
@ApiProperty()
readonly accepted!: boolean;
@IsString()
@@ -28,5 +32,6 @@ export class OrgMembershipOutputDto {
@IsOptional()
@IsBoolean()
@Expose()
@ApiPropertyOptional()
readonly disableImpersonation?: boolean;
}
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { Expose, Type } from "class-transformer";
import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, ValidateNested } from "class-validator";
@@ -9,38 +9,46 @@ export class MembershipUserOutputDto {
@IsOptional()
@IsString()
@Expose()
@ApiPropertyOptional()
readonly avatarUrl?: string;
@IsOptional()
@IsString()
@Expose()
@ApiPropertyOptional()
readonly username?: string;
@IsOptional()
@IsString()
@Expose()
@ApiPropertyOptional()
readonly name?: string;
@IsBoolean()
@Expose()
@ApiProperty()
readonly email!: string;
}
export class OrgTeamMembershipOutputDto {
@IsInt()
@Expose()
@ApiProperty()
readonly id!: number;
@IsInt()
@Expose()
@ApiProperty()
readonly userId!: number;
@IsInt()
@Expose()
@ApiProperty()
readonly teamId!: number;
@IsBoolean()
@Expose()
@ApiProperty()
readonly accepted!: boolean;
@IsString()
@@ -51,11 +59,13 @@ export class OrgTeamMembershipOutputDto {
@IsOptional()
@IsBoolean()
@Expose()
@ApiPropertyOptional()
readonly disableImpersonation?: boolean;
@ValidateNested()
@Type(() => MembershipUserOutputDto)
@Expose()
@ApiProperty({ type: MembershipUserOutputDto })
user!: MembershipUserOutputDto;
}
@@ -68,6 +78,7 @@ export class OrgTeamMembershipsOutputResponseDto {
@ValidateNested()
@Type(() => OrgTeamMembershipOutputDto)
@IsArray()
@ApiProperty({ type: [OrgTeamMembershipOutputDto] })
data!: OrgTeamMembershipOutputDto[];
}
@@ -79,5 +90,6 @@ export class OrgTeamMembershipOutputResponseDto {
@Expose()
@ValidateNested()
@Type(() => OrgTeamMembershipOutputDto)
@ApiProperty({ type: OrgTeamMembershipOutputDto })
data!: OrgTeamMembershipOutputDto;
}
@@ -29,7 +29,18 @@ export class OrganizationsEventTypesRepository {
schedule: true,
hosts: true,
destinationCalendar: true,
team: { select: { bannerUrl: true } },
team: {
select: {
bannerUrl: true,
name: true,
logoUrl: true,
slug: true,
weekStart: true,
brandColor: true,
darkBrandColor: true,
theme: true,
},
},
},
});
}
@@ -44,7 +55,18 @@ export class OrganizationsEventTypesRepository {
schedule: true,
hosts: true,
destinationCalendar: true,
team: { select: { bannerUrl: true } },
team: {
select: {
bannerUrl: true,
name: true,
logoUrl: true,
slug: true,
weekStart: true,
brandColor: true,
darkBrandColor: true,
theme: true,
},
},
},
});
}
@@ -4,8 +4,6 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationsTeamsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
@@ -20,14 +18,6 @@ export class OrganizationsTeamsRepository {
});
}
async findTeamById(teamId: number) {
return this.dbRead.prisma.team.findUnique({
where: {
id: teamId,
},
});
}
async findOrgTeams(organizationId: number) {
return this.dbRead.prisma.team.findMany({
where: {
@@ -105,69 +95,4 @@ export class OrganizationsTeamsRepository {
take,
});
}
async getTeamMembersIds(teamId: number) {
const team = await this.dbRead.prisma.team.findUnique({
where: {
id: teamId,
},
include: {
members: true,
},
});
if (!team) {
return [];
}
return team.members.map((member) => member.userId);
}
async getUserTeamsById(userId: number) {
const userTeams = await this.dbRead.prisma.team.findMany({
where: {
members: {
some: {
userId,
accepted: true,
},
},
},
select: {
id: true,
credentials: {
select: credentialForCalendarServiceSelect,
},
name: true,
logoUrl: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
parent: {
select: {
id: true,
credentials: {
select: credentialForCalendarServiceSelect,
},
name: true,
logoUrl: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
},
},
});
return userTeams;
}
}
@@ -1,6 +1,6 @@
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
@@ -15,7 +15,7 @@ import { SchedulingType } from "@calcom/prisma/client";
export class InputOrganizationsEventTypesService {
constructor(
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
private readonly teamsRepository: TeamsRepository,
private readonly usersRepository: UsersRepository,
private readonly orgEventTypesRepository: OrganizationsEventTypesRepository
) {}
@@ -167,7 +167,7 @@ export class InputOrganizationsEventTypesService {
eventType: { children: { userId: number | null }[] } | null
) {
if (inputEventType.assignAllTeamMembers) {
return await this.organizationsTeamsRepository.getTeamMembersIds(teamId);
return await this.teamsRepository.getTeamMembersIds(teamId);
}
// note(Lauris): when API user updates managed event type users
@@ -195,7 +195,7 @@ export class InputOrganizationsEventTypesService {
}
async getAllTeamMembers(teamId: number, schedulingType: SchedulingType | null) {
const membersIds = await this.organizationsTeamsRepository.getTeamMembersIds(teamId);
const membersIds = await this.teamsRepository.getTeamMembersIds(teamId);
const isFixed = schedulingType === "COLLECTIVE" ? true : false;
return membersIds.map((id) => ({
@@ -227,7 +227,7 @@ export class InputOrganizationsEventTypesService {
async validateHosts(teamId: number, hosts: CreateTeamEventTypeInput_2024_06_14["hosts"] | undefined) {
if (hosts && hosts.length) {
const membersIds = await this.organizationsTeamsRepository.getTeamMembersIds(teamId);
const membersIds = await this.teamsRepository.getTeamMembersIds(teamId);
const invalidHosts = hosts.filter((host) => !membersIds.includes(host.userId));
if (invalidHosts.length) {
throw new NotFoundException(`Invalid hosts: ${invalidHosts.join(", ")}`);
@@ -12,7 +12,10 @@ type EventTypeRelations = {
schedule: Schedule | null;
hosts: Host[];
destinationCalendar?: DestinationCalendar | null;
team?: Pick<Team, "bannerUrl"> | null;
team?: Pick<
Team,
"bannerUrl" | "name" | "logoUrl" | "slug" | "weekStart" | "brandColor" | "darkBrandColor" | "theme"
> | null;
};
export type DatabaseTeamEventType = EventType & EventTypeRelations;
@@ -98,8 +101,18 @@ export class OutputOrganizationsEventTypesService {
ownerId: userId,
parentEventTypeId: parentId,
schedulingType: databaseEventType.schedulingType,
bannerUrl: databaseEventType?.team?.bannerUrl,
assignAllTeamMembers: teamId ? assignAllTeamMembers : undefined,
team: {
id: teamId,
name: databaseEventType?.team?.name,
slug: databaseEventType?.team?.slug,
bannerUrl: databaseEventType?.team?.bannerUrl,
logoUrl: databaseEventType?.team?.logoUrl,
weekStart: databaseEventType?.team?.weekStart,
brandColor: databaseEventType?.team?.brandColor,
darkBrandColor: databaseEventType?.team?.darkBrandColor,
theme: databaseEventType?.team?.theme,
},
};
}
@@ -5,11 +5,12 @@ import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeController } from "@/modules/stripe/controllers/stripe.controller";
import { StripeService } from "@/modules/stripe/stripe.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [ConfigModule, PrismaModule],
imports: [ConfigModule, PrismaModule, UsersModule],
exports: [StripeService],
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository, MembershipsRepository],
controllers: [StripeController],
@@ -6,9 +6,16 @@ import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturn
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from "@nestjs/common";
import { UsersRepository } from "@/modules/users/users.repository";
import {
Injectable,
NotFoundException,
BadRequestException,
UnauthorizedException,
InternalServerErrorException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Prisma, Credential } from "@prisma/client";
import type { Prisma, Credential, User } from "@prisma/client";
import Stripe from "stripe";
import { z } from "zod";
@@ -30,6 +37,9 @@ type IntegrationOAuthCallbackState = {
export class StripeService {
private stripe: Stripe;
private redirectUri = `${this.config.get("api.url")}/stripe/save`;
private webAppUrl = this.config.get("app.baseUrl");
private environment = this.config.get("env.type");
private teamMonthlyPriceId = this.config.get("stripe.teamMonthlyPriceId");
constructor(
configService: ConfigService<AppConfig>,
@@ -37,7 +47,8 @@ export class StripeService {
private readonly appsRepository: AppsRepository,
private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository,
private readonly membershipRepository: MembershipsRepository
private readonly membershipRepository: MembershipsRepository,
private readonly usersRepository: UsersRepository
) {
this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", {
apiVersion: "2020-08-27",
@@ -176,4 +187,94 @@ export class StripeService {
status: SUCCESS_STATUS,
};
}
async generateTeamCheckoutSession(pendingPaymentTeamId: number, ownerId: number) {
const stripe = this.getStripe();
const customer = await this.getStripeCustomerIdFromUserId(ownerId);
if (!customer) {
throw new BadRequestException("Failed to create a customer on Stripe.");
}
const session = await stripe.checkout.sessions.create({
customer,
mode: "subscription",
allow_promotion_codes: true,
success_url: `${this.webAppUrl}/api/teams/api/create?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${this.webAppUrl}/settings/my-account/profile`,
line_items: [
{
/** We only need to set the base price and we can upsell it directly on Stripe's checkout */
price: this.teamMonthlyPriceId,
/**Initially it will be just the team owner */
quantity: 1,
},
],
customer_update: {
address: "auto",
},
// Disabled when testing locally as usually developer doesn't setup Tax in Stripe Test mode
automatic_tax: {
enabled: this.environment === "production",
},
metadata: {
pendingPaymentTeamId,
ownerId,
dubCustomerId: ownerId, // pass the userId during checkout creation for sales conversion tracking: https://d.to/conversions/stripe
},
});
if (!session.url) {
throw new InternalServerErrorException({
message: "Failed generating a Stripe checkout session URL.",
});
}
return session;
}
async getStripeCustomerIdFromUserId(userId: number) {
const user = await this.usersRepository.findById(userId);
if (!user?.email) return null;
const customerId = await this.getStripeCustomerId(user);
if (!customerId) {
return this.createStripeCustomerId(user);
}
return customerId;
}
async getStripeCustomerId(user: Pick<User, "email" | "name" | "metadata">) {
if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) {
return (user?.metadata as Prisma.JsonObject).stripeCustomerId as string;
}
return null;
}
async createStripeCustomerId(user: Pick<User, "email" | "name" | "metadata">) {
let customerId: string;
const stripe = this.getStripe();
try {
const customersResponse = await stripe.customers.list({
email: user.email,
limit: 1,
});
customerId = customersResponse.data[0].id;
} catch (error) {
const customer = await stripe.customers.create({ email: user.email });
customerId = customer.id;
}
await this.usersRepository.updateByEmail(user.email, {
metadata: {
...(user.metadata as Prisma.JsonObject),
stripeCustomerId: customerId,
},
});
return customerId;
}
}
@@ -1,20 +1,24 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator";
export class CreateTeamMembershipInput {
@IsInt()
@ApiProperty({ type: Number })
readonly userId!: number;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ type: Boolean, default: false })
readonly accepted?: boolean = false;
@IsOptional()
@IsEnum(MembershipRole)
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
@ApiPropertyOptional({ enum: ["MEMBER", "OWNER", "ADMIN"], default: "MEMBER" })
readonly role: MembershipRole = MembershipRole.MEMBER;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ type: Boolean, default: false })
readonly disableImpersonation?: boolean = false;
}
@@ -1,17 +1,20 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { IsBoolean, IsOptional, IsEnum } from "class-validator";
export class UpdateTeamMembershipInput {
@IsOptional()
@IsBoolean()
readonly accepted?: boolean = false;
@ApiPropertyOptional({ type: Boolean })
readonly accepted?: boolean;
@IsOptional()
@IsEnum(MembershipRole)
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
readonly role?: MembershipRole = MembershipRole.MEMBER;
@ApiPropertyOptional({ enum: ["MEMBER", "OWNER", "ADMIN"] })
readonly role?: MembershipRole;
@IsOptional()
@IsBoolean()
readonly disableImpersonation?: boolean = false;
@ApiPropertyOptional({ type: Boolean })
readonly disableImpersonation?: boolean;
}
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { Expose } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator";
@@ -6,18 +6,22 @@ import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator";
export class TeamMembershipOutput {
@IsInt()
@Expose()
@ApiProperty()
readonly id!: number;
@IsInt()
@Expose()
@ApiProperty()
readonly userId!: number;
@IsInt()
@Expose()
@ApiProperty()
readonly teamId!: number;
@IsBoolean()
@Expose()
@ApiProperty()
readonly accepted!: boolean;
@IsString()
@@ -28,5 +32,6 @@ export class TeamMembershipOutput {
@IsOptional()
@IsBoolean()
@Expose()
@ApiPropertyOptional()
readonly disableImpersonation?: boolean;
}
@@ -35,6 +35,18 @@ export class TeamsMembershipsRepository {
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
});
}
async findTeamMembershipsByNameAndUser(teamName: string, userId: number) {
return this.dbRead.prisma.membership.findFirst({
where: {
team: {
name: teamName,
},
userId,
},
});
}
async deleteTeamMembershipById(teamId: number, membershipId: number) {
return this.dbWrite.prisma.membership.delete({
where: {
@@ -0,0 +1,230 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { StripeService } from "@/modules/stripe/stripe.service";
import { CreateTeamInput } from "@/modules/teams/teams/inputs/create-team.input";
import { UpdateTeamDto } from "@/modules/teams/teams/inputs/update-team.input";
import { CreateTeamOutput } from "@/modules/teams/teams/outputs/teams/create-team.output";
import { GetTeamOutput } from "@/modules/teams/teams/outputs/teams/get-team.output";
import { GetTeamsOutput } from "@/modules/teams/teams/outputs/teams/get-teams.output";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "next-auth";
import Stripe from "stripe";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomNumber } from "test/utils/randomNumber";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { TeamOutputDto } from "@calcom/platform-types";
describe("Teams endpoint", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
const aliceEmail = `alice-${randomNumber()}@api.com`;
let alice: User;
let aliceApiKey: string;
const bobEmail = `bob-${randomNumber()}@api.com`;
let bob: User;
let bobApiKey: string;
let team1: TeamOutputDto;
let team2: TeamOutputDto;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, TeamsModule],
}).compile();
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(() => ({} as unknown as Stripe));
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
alice = await userRepositoryFixture.create({
email: aliceEmail,
});
bob = await userRepositoryFixture.create({
email: bobEmail,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(alice.id, null);
aliceApiKey = keyString;
const { keyString: bobKeyString } = await apiKeysRepositoryFixture.createApiKey(bob.id, null);
bobApiKey = bobKeyString;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
describe("User has membership in created team", () => {
it("should create a team", async () => {
const body: CreateTeamInput = {
name: "Team dog",
};
return request(app.getHttpServer())
.post("/v2/teams")
.send(body)
.set({ Authorization: `Bearer cal_test_${aliceApiKey}` })
.expect(201)
.then(async (response) => {
const responseBody: CreateTeamOutput = response.body;
const responseData = responseBody.data as TeamOutputDto;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseData).toBeDefined();
expect(responseData.id).toBeDefined();
expect(responseData.name).toEqual(body.name);
team1 = responseData;
});
});
it("should create a team", async () => {
const body: CreateTeamInput = {
name: "Team cats",
};
return request(app.getHttpServer())
.post("/v2/teams")
.send(body)
.set({ Authorization: `Bearer cal_test_${aliceApiKey}` })
.expect(201)
.then(async (response) => {
const responseBody: CreateTeamOutput = response.body;
const responseData = responseBody.data as TeamOutputDto;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseData).toBeDefined();
expect(responseData.id).toBeDefined();
expect(responseData.name).toEqual(body.name);
team2 = responseData;
});
});
it("should get a team", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team1.id}`)
.set({ Authorization: `Bearer cal_test_${aliceApiKey}` })
.expect(200)
.then(async (response) => {
const responseBody: GetTeamOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toBeDefined();
expect(responseBody.data.name).toEqual(team1.name);
});
});
it("should get teams", async () => {
return request(app.getHttpServer())
.get(`/v2/teams`)
.set({ Authorization: `Bearer cal_test_${aliceApiKey}` })
.expect(200)
.then(async (response) => {
const responseBody: GetTeamsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(2);
expect(responseBody.data[0].id).toBeDefined();
expect(responseBody.data[0].name).toEqual(team1.name);
expect(responseBody.data[1].id).toBeDefined();
expect(responseBody.data[1].name).toEqual(team2.name);
});
});
it("should update a team", async () => {
const body: UpdateTeamDto = {
name: "Team dogs shepherds",
};
return request(app.getHttpServer())
.patch(`/v2/teams/${team1.id}`)
.send(body)
.set({ Authorization: `Bearer cal_test_${aliceApiKey}` })
.expect(200)
.then(async (response) => {
const responseBody: GetTeamOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toBeDefined();
expect(responseBody.data.name).toEqual(body.name);
team1 = responseBody.data;
});
});
});
describe("User does not have membership in created team", () => {
it("should not be able to get a team", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team2.id}`)
.set({ Authorization: `Bearer cal_test_${bobApiKey}` })
.expect(403);
});
});
describe("User does not have sufficient membership in created team", () => {
it("should not be able to delete a team", async () => {
await membershipRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: bob.id } },
team: { connect: { id: team2.id } },
accepted: true,
});
return request(app.getHttpServer())
.delete(`/v2/teams/${team2.id}`)
.set({ Authorization: `Bearer cal_test_${bobApiKey}` })
.expect(403);
});
});
describe("Delete teams", () => {
it("should delete team", async () => {
return request(app.getHttpServer())
.delete(`/v2/teams/${team1.id}`)
.set({ Authorization: `Bearer cal_test_${aliceApiKey}` })
.expect(200)
.then(async (response) => {
const responseBody: GetTeamOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toBeDefined();
expect(responseBody.data.name).toEqual(team1.name);
});
});
it("should delete team", async () => {
return request(app.getHttpServer())
.delete(`/v2/teams/${team2.id}`)
.set({ Authorization: `Bearer cal_test_${aliceApiKey}` })
.expect(200)
.then(async (response) => {
const responseBody: GetTeamOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toBeDefined();
expect(responseBody.data.name).toEqual(team2.name);
});
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(aliceEmail);
await teamRepositoryFixture.delete(team1.id);
await teamRepositoryFixture.delete(team2.id);
await app.close();
});
});
@@ -0,0 +1,105 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { UpdateOrgTeamDto } from "@/modules/organizations/inputs/update-organization-team.input";
import { OrgTeamOutputResponseDto } from "@/modules/organizations/outputs/organization-team.output";
import { CreateTeamInput } from "@/modules/teams/teams/inputs/create-team.input";
import { CreateTeamOutput } from "@/modules/teams/teams/outputs/teams/create-team.output";
import { GetTeamOutput } from "@/modules/teams/teams/outputs/teams/get-team.output";
import { GetTeamsOutput } from "@/modules/teams/teams/outputs/teams/get-teams.output";
import { UpdateTeamOutput } from "@/modules/teams/teams/outputs/teams/update-team.output";
import { TeamsService } from "@/modules/teams/teams/services/teams.service";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Param, ParseIntPipe, Delete, Patch, Post, Body } from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { TeamOutputDto } from "@calcom/platform-types";
@Controller({
path: "/v2/teams",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard)
@DocsTags("Teams")
export class TeamsController {
constructor(private teamsService: TeamsService, private teamsRepository: TeamsRepository) {}
@Post()
@ApiOperation({ summary: "Create a team" })
async createTeam(
@Body() body: CreateTeamInput,
@GetUser() user: UserWithProfile
): Promise<CreateTeamOutput> {
const team = await this.teamsService.createTeam(body, user.id);
if ("paymentLink" in team) {
return {
status: SUCCESS_STATUS,
data: {
pendingTeam: plainToClass(TeamOutputDto, team.pendingTeam, { strategy: "excludeAll" }),
paymentLink: team.paymentLink,
message: team.message,
},
};
}
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamOutputDto, team, { strategy: "excludeAll" }),
};
}
@Get("/:teamId")
@ApiOperation({ summary: "Get a team" })
@UseGuards(RolesGuard)
@Roles("TEAM_MEMBER")
async getTeam(@Param("teamId", ParseIntPipe) teamId: number): Promise<GetTeamOutput> {
const team = await this.teamsRepository.getById(teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamOutputDto, team, { strategy: "excludeAll" }),
};
}
@Get("/")
@ApiOperation({ summary: "Get teams" })
async getTeams(@GetUser("id") userId: number): Promise<GetTeamsOutput> {
const teams = await this.teamsService.getUserTeams(userId);
return {
status: SUCCESS_STATUS,
data: teams.map((team) => plainToClass(TeamOutputDto, team, { strategy: "excludeAll" })),
};
}
@Patch("/:teamId")
@ApiOperation({ summary: "Update a team" })
@UseGuards(RolesGuard)
@Roles("TEAM_OWNER")
async updateTeam(
@Param("teamId", ParseIntPipe) teamId: number,
@Body() body: UpdateOrgTeamDto
): Promise<UpdateTeamOutput> {
const team = await this.teamsService.updateTeam(teamId, body);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamOutputDto, team, { strategy: "excludeAll" }),
};
}
@UseGuards(RolesGuard)
@Delete("/:teamId")
@ApiOperation({ summary: "Delete a team" })
@Roles("TEAM_OWNER")
async deleteTeam(@Param("teamId", ParseIntPipe) teamId: number): Promise<OrgTeamOutputResponseDto> {
const team = await this.teamsRepository.delete(teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamOutputDto, team, { strategy: "excludeAll" }),
};
}
}
@@ -0,0 +1,118 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
export class CreateTeamInput {
@IsString()
@Length(1)
@ApiProperty({ description: "Name of the team", example: "CalTeam", required: true })
readonly name!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel" })
readonly slug?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png",
description: `URL of the teams logo image`,
})
readonly logoUrl?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional()
readonly calVideoLogo?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional()
readonly appLogo?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional()
readonly appIconLogo?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
readonly bio?: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ type: Boolean, default: false })
readonly hideBranding?: boolean = false;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
readonly isPrivate?: boolean;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
readonly hideBookATeamMember?: boolean;
@IsOptional()
@IsString()
@ApiPropertyOptional()
readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object.
@IsOptional()
@IsString()
@ApiPropertyOptional()
readonly theme?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
readonly brandColor?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
readonly darkBrandColor?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
description: `URL of the teams banner image which is shown on booker`,
required: false,
})
readonly bannerUrl?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
readonly timeFormat?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "America/New_York",
description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`,
required: false,
default: "Europe/London",
})
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "Monday",
default: "Sunday",
})
readonly weekStart?: string = "Sunday";
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ type: Boolean, default: true })
readonly autoAcceptCreator?: boolean = true;
}
@@ -0,0 +1,119 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
export class UpdateTeamDto {
@IsString()
@Length(1)
@ApiPropertyOptional({ description: "Name of the team", example: "CalTeam" })
readonly name?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel" })
readonly slug?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png",
description: `URL of the teams logo image`,
})
readonly logoUrl?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional()
readonly calVideoLogo?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional()
readonly appLogo?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional()
readonly appIconLogo?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
readonly bio?: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
readonly hideBranding?: boolean;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
readonly isPrivate?: boolean;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
readonly hideBookATeamMember?: boolean;
@IsOptional()
@IsString()
@ApiPropertyOptional()
readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object.
@IsOptional()
@IsString()
@ApiPropertyOptional()
readonly theme?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
readonly brandColor?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
readonly darkBrandColor?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
description: `URL of the teams banner image which is shown on booker`,
})
readonly bannerUrl?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
readonly timeFormat?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "America/New_York",
description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`,
})
readonly timeZone?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "Monday",
})
readonly weekStart?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional()
readonly bookingLimits?: string;
@IsOptional()
@IsBoolean()
@ApiPropertyOptional()
readonly includeManagedEventsInLimits?: boolean;
}
@@ -0,0 +1,34 @@
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsEnum, ValidateNested, IsString, IsUrl } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { TeamOutputDto } from "@calcom/platform-types";
class Output {
@Expose()
@IsString()
message!: string;
@Expose()
@IsUrl()
paymentLink!: string;
@Expose()
@ValidateNested()
pendingTeam!: TeamOutputDto;
}
export class CreateTeamOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
oneOf: [{ $ref: getSchemaPath(Output) }, { $ref: getSchemaPath(TeamOutputDto) }],
description: "Either an Output object or a TeamOutputDto.",
})
@Expose()
@ValidateNested()
data!: Output | TeamOutputDto;
}
@@ -0,0 +1,18 @@
import { ApiProperty } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { Expose, Type } from "class-transformer";
import { IsEnum, IsString, ValidateNested } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { TeamOutputDto } from "@calcom/platform-types";
export class GetTeamOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => TeamOutputDto)
data!: TeamOutputDto;
}
@@ -0,0 +1,18 @@
import { ApiProperty } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { Expose, Type } from "class-transformer";
import { IsEnum, IsString, ValidateNested } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { TeamOutputDto } from "@calcom/platform-types";
export class GetTeamsOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => TeamOutputDto)
data!: TeamOutputDto[];
}
@@ -0,0 +1,18 @@
import { ApiProperty } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { Expose, Type } from "class-transformer";
import { IsEnum, IsString, ValidateNested } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { TeamOutputDto } from "@calcom/platform-types";
export class UpdateTeamOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => TeamOutputDto)
data!: TeamOutputDto;
}
@@ -0,0 +1,71 @@
import { StripeService } from "@/modules/stripe/stripe.service";
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { CreateTeamInput } from "@/modules/teams/teams/inputs/create-team.input";
import { UpdateTeamDto } from "@/modules/teams/teams/inputs/update-team.input";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { BadRequestException, Injectable, InternalServerErrorException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@Injectable()
export class TeamsService {
private isTeamBillingEnabled = this.configService.get("stripe.isTeamBillingEnabled");
constructor(
private readonly teamsRepository: TeamsRepository,
private readonly teamsMembershipsRepository: TeamsMembershipsRepository,
private readonly stripeService: StripeService,
private readonly configService: ConfigService
) {}
async createTeam(input: CreateTeamInput, ownerId: number) {
const { autoAcceptCreator, ...teamData } = input;
const existingTeam = await this.teamsMembershipsRepository.findTeamMembershipsByNameAndUser(
input.name,
ownerId
);
if (existingTeam) {
throw new BadRequestException({
message: `You already have created a team with name=${input.name}`,
});
}
if (!this.isTeamBillingEnabled) {
const team = await this.teamsRepository.create(teamData);
await this.teamsMembershipsRepository.createTeamMembership(team.id, {
userId: ownerId,
role: "OWNER",
accepted: !!autoAcceptCreator,
});
return team;
}
const pendingTeam = await this.teamsRepository.create({ ...teamData, pendingPayment: true });
const checkoutSession = await this.stripeService.generateTeamCheckoutSession(pendingTeam.id, ownerId);
if (!checkoutSession.url) {
await this.teamsRepository.delete(pendingTeam.id);
throw new InternalServerErrorException({
message: `Failed generating team Stripe checkout session URL which is why team creation was cancelled. Please contact support.`,
});
}
return {
message:
"Your team will be created once we receive your payment. Please complete the payment using the payment link.",
paymentLink: checkoutSession.url,
pendingTeam,
};
}
async getUserTeams(userId: number) {
const teams = await this.teamsRepository.getTeamsUserIsMemberOf(userId);
return teams;
}
async updateTeam(teamId: number, data: UpdateTeamDto) {
const team = await this.teamsRepository.update(teamId, data);
return team;
}
}
@@ -0,0 +1,18 @@
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { TeamsController } from "@/modules/teams/teams/controllers/teams.controller";
import { TeamsService } from "@/modules/teams/teams/services/teams.service";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, RedisModule, StripeModule],
providers: [TeamsRepository, TeamsService, TeamsMembershipsRepository, TeamsMembershipsService],
controllers: [TeamsController],
exports: [TeamsRepository],
})
export class TeamsModule {}
@@ -0,0 +1,73 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@calcom/prisma/client";
@Injectable()
export class TeamsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async create(team: Prisma.TeamCreateInput) {
return this.dbWrite.prisma.team.create({
data: team,
});
}
async getById(teamId: number) {
return this.dbRead.prisma.team.findUnique({
where: { id: teamId },
});
}
async getByIds(teamIds: number[]) {
return this.dbRead.prisma.team.findMany({
where: {
id: {
in: teamIds,
},
},
});
}
async getTeamMembersIds(teamId: number) {
const team = await this.dbRead.prisma.team.findUnique({
where: {
id: teamId,
},
include: {
members: true,
},
});
if (!team) {
return [];
}
return team.members.map((member) => member.userId);
}
async getTeamsUserIsMemberOf(userId: number) {
return this.dbRead.prisma.team.findMany({
where: {
members: {
some: {
userId,
},
},
},
});
}
async update(teamId: number, team: Prisma.TeamUpdateInput) {
return this.dbWrite.prisma.team.update({
where: { id: teamId },
data: team,
});
}
async delete(teamId: number) {
return this.dbWrite.prisma.team.delete({
where: { id: teamId },
});
}
}
@@ -16,12 +16,12 @@ export class CreateManagedUserInput {
@IsOptional()
@IsIn([12, 24], { message: "timeFormat must be a number either 12 or 24" })
@ApiProperty({ example: 12, enum: [12, 24], description: "Must be a number 12 or 24" })
@ApiPropertyOptional({ example: 12, enum: [12, 24], description: "Must be a number 12 or 24" })
timeFormat?: TimeFormat;
@IsOptional()
@IsString()
@ApiProperty({
@ApiPropertyOptional({
example: "Monday",
enum: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
})
@@ -30,7 +30,7 @@ export class CreateManagedUserInput {
@IsTimeZone()
@IsOptional()
@CapitalizeTimeZone()
@ApiProperty({
@ApiPropertyOptional({
example: "America/New_York",
description: `Timezone is used to create user's default schedule from Monday to Friday from 9AM to 5PM. If it is not passed then user does not have
a default schedule and it must be created manually via the /schedules endpoint. Until the schedule is created, the user can't access availability atom to set his / her availability nor booked.
@@ -49,7 +49,6 @@ export class CreateManagedUserInput {
type: String,
example: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
description: `URL of the user's avatar image`,
required: false,
})
avatarUrl?: string;
}
@@ -1,4 +1,4 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsOptional, Validate } from "class-validator";
@@ -12,7 +12,8 @@ export class GetUsersInput extends SkipTakePagination {
@Transform(({ value }: { value: string | string[] }) => {
return typeof value === "string" ? [value] : value;
})
@ApiProperty({
@ApiPropertyOptional({
type: [String],
description: "The email address or an array of email addresses to filter by",
})
emails?: string[];
@@ -1,31 +1,34 @@
import { Locales } from "@/lib/enums/locales";
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsIn, IsNumber, IsOptional, IsString, IsTimeZone, IsUrl } from "class-validator";
export class UpdateManagedUserInput {
@IsString()
@IsOptional()
@ApiPropertyOptional()
email?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
name?: string;
@IsOptional()
@IsIn([12, 24])
@ApiProperty({ example: 12, enum: [12, 24], description: "Must be 12 or 24" })
@ApiPropertyOptional({ example: 12, enum: [12, 24], description: "Must be 12 or 24" })
timeFormat?: TimeFormat;
@IsNumber()
@IsOptional()
@ApiPropertyOptional()
defaultScheduleId?: number;
@IsOptional()
@IsString()
@IsIn(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"])
@ApiProperty({
@ApiPropertyOptional({
example: "Monday",
enum: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
})
@@ -34,11 +37,12 @@ export class UpdateManagedUserInput {
@IsTimeZone()
@IsOptional()
@CapitalizeTimeZone()
@ApiPropertyOptional()
timeZone?: string;
@IsEnum(Locales)
@IsOptional()
@ApiProperty({ example: Locales.EN, enum: Locales })
@ApiPropertyOptional({ example: Locales.EN, enum: Locales })
locale?: Locales;
@IsUrl()
@@ -47,7 +51,6 @@ export class UpdateManagedUserInput {
type: String,
example: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
description: `URL of the user's avatar image`,
required: false,
})
avatarUrl?: string;
}
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
import { Injectable, NotFoundException } from "@nestjs/common";
import type { Profile, User, Team } from "@prisma/client";
import type { Profile, User, Team, Prisma } from "@prisma/client";
export type UserWithProfile = User & {
movedToProfile?: (Profile & { organization: Pick<Team, "isPlatform" | "id" | "slug" | "name"> }) | null;
@@ -184,6 +184,13 @@ export class UsersRepository {
});
}
async updateByEmail(email: string, updateData: Prisma.UserUpdateInput) {
return this.dbWrite.prisma.user.update({
where: { email },
data: updateData,
});
}
async updateUsername(userId: number, newUsername: string) {
return this.dbWrite.prisma.user.update({
where: { id: userId },
@@ -1,4 +1,4 @@
import { ApiProperty, PartialType } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
import { WebhookTriggerEvents } from "@prisma/client";
import { IsArray, IsBoolean, IsEnum, IsOptional, IsString } from "class-validator";
@@ -19,9 +19,11 @@ export class CreateWebhookInputDto {
payloadTemplate?: string;
@IsBoolean()
@ApiProperty()
active!: boolean;
@IsString()
@ApiProperty()
subscriberUrl!: string;
@IsArray()
@@ -43,6 +45,7 @@ export class CreateWebhookInputDto {
@IsString()
@IsOptional()
@ApiPropertyOptional()
secret?: string;
}
File diff suppressed because it is too large Load Diff
@@ -21,7 +21,7 @@ export class TeamRepositoryFixture {
}
async delete(teamId: Team["id"]) {
return this.prismaWriteClient.team.delete({ where: { id: teamId } });
return this.prismaWriteClient.team.deleteMany({ where: { id: teamId } });
}
async getPlatformOrgTeams(organizationId: number, oAuthClientId: string) {
+1
View File
@@ -20,6 +20,7 @@ const env: Partial<Omit<Environment, "NODE_ENV">> = {
// note(Lauris): setting high limit so that e2e tests themselves are not rate limited
RATE_LIMIT_DEFAULT_LIMIT: 10000,
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: 60000,
IS_TEAM_BILLING_ENABLED: false,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@@ -1,8 +1,10 @@
import { getTranslate, _generateMetadata } from "app/_utils";
import { Suspense } from "react";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
import { APP_NAME } from "@calcom/lib/constants";
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -10,6 +12,17 @@ export const generateMetadata = async () =>
(t) => t("add_webhook_description", { appName: APP_NAME })
);
const SkeletonLoader = () => {
return (
<SkeletonContainer>
<div className="divide-subtle border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
</SkeletonContainer>
);
};
const Page = async () => {
const t = await getTranslate();
@@ -19,7 +32,9 @@ const Page = async () => {
description={t("add_webhook_description", { appName: APP_NAME })}
borderInShellHeader={true}
backButton>
<NewWebhookView />
<Suspense fallback={<SkeletonLoader />}>
<NewWebhookView />
</Suspense>
</SettingsHeader>
);
};
@@ -0,0 +1,88 @@
import { useForm } from "react-hook-form";
import { Schedule } from "@calcom/features/schedules";
import { DEFAULT_SCHEDULE } from "@calcom/lib/availability";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { TRPCClientErrorLike } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import { Button, Form, Icon } from "@calcom/ui";
interface ISetupAvailabilityProps {
nextStep: () => void;
defaultScheduleId?: number | null;
}
const SetupAvailability = (props: ISetupAvailabilityProps) => {
const { defaultScheduleId } = props;
const { t } = useLocale();
const { nextStep } = props;
const scheduleId = defaultScheduleId === null ? undefined : defaultScheduleId;
const queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId: defaultScheduleId ?? undefined },
{
enabled: !!scheduleId,
}
);
const availabilityForm = useForm({
defaultValues: {
schedule: queryAvailability?.data?.availability || DEFAULT_SCHEDULE,
},
});
const mutationOptions = {
onError: (error: TRPCClientErrorLike<AppRouter>) => {
throw new Error(error.message);
},
onSuccess: () => {
nextStep();
},
};
const createSchedule = trpc.viewer.availability.schedule.create.useMutation(mutationOptions);
const updateSchedule = trpc.viewer.availability.schedule.update.useMutation(mutationOptions);
return (
<Form
form={availabilityForm}
handleSubmit={async (values) => {
try {
if (defaultScheduleId) {
await updateSchedule.mutate({
scheduleId: defaultScheduleId,
name: t("default_schedule_name"),
...values,
});
} else {
await createSchedule.mutate({
name: t("default_schedule_name"),
...values,
});
}
} catch (error) {
if (error instanceof Error) {
// setError(error);
// @TODO: log error
}
}
}}>
<div className="bg-default dark:text-inverted text-emphasis border-subtle w-full rounded-md border dark:bg-opacity-5">
<Schedule control={availabilityForm.control} name="schedule" weekStart={1} />
</div>
<div>
<Button
data-testid="save-availability"
type="submit"
className="mt-2 w-full justify-center p-2 text-sm sm:mt-8"
loading={availabilityForm.formState.isSubmitting}
disabled={availabilityForm.formState.isSubmitting}>
{t("next_step_text")} <Icon name="arrow-right" className="ml-2 h-4 w-4 self-center" />
</Button>
</div>
</Form>
);
};
export { SetupAvailability };
@@ -86,6 +86,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
length: true,
hidden: true,
hosts: {
take: 3,
select: {
user: {
select: {
@@ -135,6 +136,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
where: { id: eventTypeId },
select: {
users: {
take: 1,
select: {
username: true,
name: true,
@@ -143,15 +145,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
});
users =
data.length > 0
? [
{
username: data[0].username ?? "",
name: data[0].name ?? "",
},
]
: [];
if (data.length > 0) {
users = [
{
username: data[0].username ?? "",
name: data[0].name ?? "",
},
];
}
}
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
@@ -4,32 +4,30 @@ import { signOut } from "next-auth/react";
import type { TFunction } from "next-i18next";
import Head from "next/head";
import { usePathname, useRouter } from "next/navigation";
import { Suspense, useEffect } from "react";
import { Suspense } from "react";
import { Toaster } from "react-hot-toast";
import { z } from "zod";
import { classNames } from "@calcom/lib";
import { DEFAULT_SCHEDULE } from "@calcom/lib/availability";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import { IdentityProvider } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import type { TRPCClientErrorLike } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import { Button, showToast, StepCard, Steps } from "@calcom/ui";
import { Button, StepCard, Steps } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import type { getServerSideProps } from "@lib/getting-started/[[...step]]/getServerSideProps";
import { ConnectedCalendars } from "@components/getting-started/steps-views/ConnectCalendars";
import { ConnectedVideoStep } from "@components/getting-started/steps-views/ConnectedVideoStep";
import { SetupAvailability } from "@components/getting-started/steps-views/SetupAvailability";
import UserProfile from "@components/getting-started/steps-views/UserProfile";
import { UserSettings } from "@components/getting-started/steps-views/UserSettings";
const INITIAL_STEP = "user-settings";
const BASE_STEPS = ["user-settings", "user-profile"] as const;
const BASE_STEPS = ["user-settings", "setup-availability", "user-profile"] as const;
const EXTRA_STEPS = ["connected-calendar", "connected-video"] as const;
type StepType = (typeof BASE_STEPS)[number] | (typeof EXTRA_STEPS)[number];
@@ -43,6 +41,13 @@ const getStepsAndHeadersForUser = (identityProvider: IdentityProvider, t: TFunct
title: t("welcome_to_cal_header", { appName: APP_NAME }),
subtitle: [t("we_just_need_basic_info"), t("edit_form_later_subtitle")],
},
{
title: t("set_availability"),
subtitle: [
t("set_availability_getting_started_subtitle_1"),
t("set_availability_getting_started_subtitle_2"),
],
},
{
title: t("nearly_there"),
subtitle: [t("nearly_there_instructions")],
@@ -81,7 +86,9 @@ const getStepsAndHeadersForUser = (identityProvider: IdentityProvider, t: TFunct
const stepRouteSchema = z.object({
step: z
.array(z.enum(["user-settings", "user-profile", "connected-calendar", "connected-video"]))
.array(
z.enum(["user-settings", "setup-availability", "user-profile", "connected-calendar", "connected-video"])
)
.default([INITIAL_STEP]),
from: z.string().optional(),
});
@@ -90,15 +97,10 @@ export type PageProps = inferSSRProps<typeof getServerSideProps>;
const OnboardingPage = (props: PageProps) => {
const pathname = usePathname();
const params = useParamsWithFallback();
const router = useRouter();
const { t } = useLocale();
const [user] = trpc.viewer.me.useSuspenseQuery();
const scheduleMutationOptions = {
onError: (error: TRPCClientErrorLike<AppRouter>) => {
showToast(error.message, "error");
},
};
const createSchedule = trpc.viewer.availability.schedule.create.useMutation(scheduleMutationOptions);
const { t } = useLocale();
const result = stepRouteSchema.safeParse({
...params,
@@ -135,20 +137,6 @@ const OnboardingPage = (props: PageProps) => {
router.push(`/getting-started/${stepTransform(newStep)}`);
};
useEffect(() => {
if (!user || user.completedOnboarding) {
return;
}
const createDefaultSchedule = async () => {
await createSchedule.mutateAsync({
name: t("default_schedule_name"),
...DEFAULT_SCHEDULE,
});
};
createDefaultSchedule();
}, []);
return (
<div
className={classNames(
@@ -190,6 +178,9 @@ const OnboardingPage = (props: PageProps) => {
{currentStep === "connected-video" && <ConnectedVideoStep nextStep={goToNextStep} />}
{currentStep === "setup-availability" && (
<SetupAvailability nextStep={goToNextStep} defaultScheduleId={user.defaultScheduleId} />
)}
{currentStep === "user-profile" && <UserProfile />}
</Suspense>
</StepCard>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.8.1",
"version": "4.8.2",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+2 -1
View File
@@ -1,3 +1,4 @@
import { wrapApiHandlerWithSentry } from "@sentry/nextjs";
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
@@ -21,4 +22,4 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe
return booking;
}
export default defaultResponder(handler);
export default defaultResponder(wrapApiHandlerWithSentry(handler, "/api/book/event"));
@@ -158,13 +158,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
take: 10,
});
const eventTypeIds = bookingsFromSelected
.filter((booking) => typeof booking.eventTypeId === "number")
.map((booking) => booking.eventTypeId);
const eventTypeIds = bookingsFromSelected.reduce((acc: number[], booking) => {
if (typeof booking.eventTypeId !== "number") return acc;
return [...acc, booking.eventTypeId];
}, []);
const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {
id: {
in: eventTypeIds as number[],
in: eventTypeIds,
},
};
+12 -3
View File
@@ -41,7 +41,7 @@ test.describe("Onboarding", () => {
await page.locator("button[type=submit]").click();
if (identityProvider === IdentityProvider.GOOGLE) {
await expect(page).toHaveURL(/.*user-profile/);
await expect(page).toHaveURL(/.*setup-availability/);
} else {
await expect(page).toHaveURL(/.*connected-calendar/);
}
@@ -66,11 +66,20 @@ test.describe("Onboarding", () => {
// tests skip button, we don't want to test entire flow.
await page.locator("button[data-testid=skip-step]").click();
await expect(page).toHaveURL(/.*user-profile/);
await expect(page).toHaveURL(/.*setup-availability/);
});
}
await test.step("step 4 - User Profile", async () => {
await test.step("step 4 - Setup Availability", async () => {
const isDisabled = await page.locator("button[data-testid=save-availability]").isDisabled();
await expect(isDisabled).toBe(false);
// same here, skip this step.
await page.locator("button[data-testid=save-availability]").click();
await expect(page).toHaveURL(/.*user-profile/);
});
await test.step("step 5- User Profile", async () => {
await page.locator("button[type=submit]").click();
// should redirect to /event-types after onboarding
@@ -2651,6 +2651,8 @@
"go_back_and_assign": "Go back and Assign",
"leave_without_assigning": "Leave without Assigning",
"signing_up_terms": "By proceeding, you agree to our <0>Terms</0> and <1>Privacy Policy</1>.",
"proceeding_agreement": "By proceeding, you agree to our",
"terms": "Terms",
"always_show_x_days": "Always {{x}} days available",
"unable_to_subscribe_to_the_platform": "An error occurred while trying to subscribe to the platform plan, please try again later",
"updating_oauth_client_error": "An error occurred while updating the OAuth client, please try again later",
@@ -47,6 +47,7 @@ logger.settings.minLevel = 1;
const log = logger.getSubLogger({ prefix: ["[bookingScenario]"] });
type InputWebhook = {
id?: string;
appId: string | null;
userId?: number | null;
teamId?: number | null;
@@ -54,6 +55,8 @@ type InputWebhook = {
active: boolean;
eventTriggers: WebhookTriggerEvents[];
subscriberUrl: string;
time?: number | null;
timeUnit?: TimeUnit | null;
};
type InputWorkflow = {
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,7 @@ import { MeetLocationType } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache";
import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cache.repository.interface";
import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import type CalendarService from "@calcom/lib/CalendarService";
import {
@@ -510,16 +511,22 @@ export default class GoogleCalendarService implements Calendar {
return apiResponse.json;
}
async getCacheOrFetchAvailability(args: FreeBusyArgs): Promise<EventBusyDate[] | null> {
const { timeMin, timeMax, items } = args;
let freeBusyResult: calendar_v3.Schema$FreeBusyResponse = {};
async getFreeBusyResult(
args: FreeBusyArgs,
shouldServeCache?: boolean
): Promise<calendar_v3.Schema$FreeBusyResponse> {
if (shouldServeCache === false) return await this.fetchAvailability(args);
const calendarCache = await CalendarCache.init(null);
const cached = await calendarCache.getCachedAvailability(this.credential.id, args);
if (cached) {
freeBusyResult = cached.value as unknown as calendar_v3.Schema$FreeBusyResponse;
} else {
freeBusyResult = await this.fetchAvailability({ timeMin, timeMax, items });
}
if (cached) return cached.value as unknown as calendar_v3.Schema$FreeBusyResponse;
return await this.fetchAvailability(args);
}
async getCacheOrFetchAvailability(
args: FreeBusyArgs,
shouldServeCache?: boolean
): Promise<EventBusyDate[] | null> {
const freeBusyResult = await this.getFreeBusyResult(args, shouldServeCache);
if (!freeBusyResult.calendars) return null;
const result = Object.values(freeBusyResult.calendars).reduce((c, i) => {
@@ -537,7 +544,8 @@ export default class GoogleCalendarService implements Calendar {
async getAvailability(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
selectedCalendars: IntegrationCalendar[],
shouldServeCache?: boolean
): Promise<EventBusyDate[]> {
this.log.debug("Getting availability", safeStringify({ dateFrom, dateTo, selectedCalendars }));
const calendar = await this.authedCalendar();
@@ -563,11 +571,14 @@ export default class GoogleCalendarService implements Calendar {
// /freebusy from google api only allows a date range of 90 days
if (diff <= 90) {
const freeBusyData = await this.getCacheOrFetchAvailability({
timeMin: dateFrom,
timeMax: dateTo,
items: calsIds.map((id) => ({ id })),
});
const freeBusyData = await this.getCacheOrFetchAvailability(
{
timeMin: dateFrom,
timeMax: dateTo,
items: calsIds.map((id) => ({ id })),
},
shouldServeCache
);
if (!freeBusyData) throw new Error("No response from google calendar");
return freeBusyData;
@@ -583,11 +594,14 @@ export default class GoogleCalendarService implements Calendar {
if (endDate.isAfter(originalEndDate)) endDate = originalEndDate;
busyData.push(
...((await this.getCacheOrFetchAvailability({
timeMin: startDate.format(),
timeMax: endDate.format(),
items: calsIds.map((id) => ({ id })),
})) || [])
...((await this.getCacheOrFetchAvailability(
{
timeMin: startDate.format(),
timeMax: endDate.format(),
items: calsIds.map((id) => ({ id })),
},
shouldServeCache
)) || [])
);
startDate = endDate.add(1, "minutes");
@@ -672,12 +686,7 @@ export default class GoogleCalendarService implements Calendar {
}
async unwatchCalendar({ calendarId }: { calendarId: string }) {
const credentialId = this.credential.id;
const sc = await prisma.selectedCalendar.findFirst({
where: {
credentialId,
externalId: calendarId,
},
});
const sc = await SelectedCalendarRepository.findByExternalId(credentialId, calendarId);
// Delete the calendar cache to force a fresh cache
await prisma.calendarCache.deleteMany({ where: { credentialId } });
const calendar = await this.authedCalendar();
@@ -699,6 +708,12 @@ export default class GoogleCalendarService implements Calendar {
googleChannelResourceUri: null,
googleChannelExpiration: null,
});
// Populate the cache back for the remaining calendars, if any
const remainingCalendars =
sc?.credential?.selectedCalendars.filter((sc) => sc.externalId !== calendarId) || [];
if (remainingCalendars.length > 0) {
await this.fetchAvailabilityAndSetCache(remainingCalendars);
}
}
async setAvailabilityInCache(args: FreeBusyArgs, data: calendar_v3.Schema$FreeBusyResponse): Promise<void> {
@@ -707,12 +722,11 @@ export default class GoogleCalendarService implements Calendar {
}
async fetchAvailabilityAndSetCache(selectedCalendars: IntegrationCalendar[]) {
const date = new Date();
const parsedArgs = {
/** Expand the start date to the start of the month */
timeMin: new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0).toISOString(),
timeMin: getTimeMax(),
/** Expand the end date to the end of the month */
timeMax: new Date(date.getFullYear(), date.getMonth() + 1, 0, 0, 0, 0, 0).toISOString(),
timeMax: getTimeMin(),
items: selectedCalendars.map((sc) => ({ id: sc.externalId })),
};
const data = await this.fetchAvailability(parsedArgs);
@@ -0,0 +1,265 @@
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe, expect, it, beforeEach, vi } from "vitest";
import type { CredentialPayload } from "@calcom/types/Credential";
import SalesforceCRMService from "./CrmService";
import { SalesforceRecordEnum } from "./enums";
describe("SalesforceCRMService", () => {
let service: SalesforceCRMService;
let mockConnection: { query: any };
setupAndTeardown();
beforeEach(() => {
mockConnection = {
query: vi.fn(),
};
const mockCredential: CredentialPayload = {
id: 1,
type: "salesforce_crm",
key: {
access_token: "mock_token",
refresh_token: "mock_refresh",
instance_url: "https://mock.salesforce.com",
},
userId: 1,
appId: "salesforce",
teamId: null,
invalid: false,
user: {
email: "test-user@example.com",
},
};
service = new SalesforceCRMService(mockCredential, {}, true);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
service.conn = Promise.resolve(mockConnection);
});
describe("getContacts", () => {
it("should return empty array when no records found", async () => {
// Setup mock response
mockConnection.query.mockResolvedValueOnce({ records: [] });
const result = await service.getContacts({ emails: "test@example.com" });
expect(result).toEqual([]);
expect(mockConnection.query).toHaveBeenCalledWith(
"SELECT Id, Email, OwnerId FROM Contact WHERE Email IN ('test@example.com')"
);
});
it("should handle single email string input", async () => {
// Setup mock response with descriptive name
const contactQueryResponse = {
records: [
{
Id: "001",
Email: "test@example.com",
OwnerId: "owner001",
attributes: { type: "Contact" },
},
],
};
// Setup query spy
const querySpy = vi.spyOn(mockConnection, "query");
querySpy.mockResolvedValueOnce(contactQueryResponse);
const result = await service.getContacts({ emails: "test@example.com" });
expect(result).toEqual([
{
id: "001",
email: "test@example.com",
recordType: "Contact",
},
]);
// Verify the exact query made
expect(querySpy).toHaveBeenCalledWith(
"SELECT Id, Email, OwnerId FROM Contact WHERE Email IN ('test@example.com')"
);
});
it("should handle array of emails input", async () => {
const contactsQueryResponse = {
records: [
{
Id: "001",
Email: "test1@example.com",
OwnerId: "owner001",
attributes: { type: "Contact" },
},
{
Id: "002",
Email: "test2@example.com",
OwnerId: "owner002",
attributes: { type: "Contact" },
},
],
};
const querySpy = vi.spyOn(mockConnection, "query");
querySpy.mockResolvedValueOnce(contactsQueryResponse);
const result = await service.getContacts({
emails: ["test1@example.com", "test2@example.com"],
});
expect(result).toEqual([
{
id: "001",
email: "test1@example.com",
recordType: "Contact",
},
{
id: "002",
email: "test2@example.com",
recordType: "Contact",
},
]);
expect(querySpy).toHaveBeenCalledWith(
"SELECT Id, Email, OwnerId FROM Contact WHERE Email IN ('test1@example.com','test2@example.com')"
);
});
it("should include owner information when includeOwner is true", async () => {
const contactQueryResponse = {
records: [
{
Id: "001",
Email: "test@example.com",
OwnerId: "owner001",
attributes: { type: "Contact" },
},
],
};
const ownerQueryResponse = {
records: [
{
Id: "owner001",
Email: "owner@example.com",
Name: "Test Owner",
},
],
};
// Setup query spy with specific responses
const querySpy = vi.spyOn(mockConnection, "query");
querySpy
.mockResolvedValueOnce(contactQueryResponse) // Contact query
.mockResolvedValueOnce(ownerQueryResponse); // Owner query
const result = await service.getContacts({
emails: "test@example.com",
includeOwner: true,
});
expect(result).toEqual([
{
id: "001",
email: "test@example.com",
ownerId: "owner001",
ownerEmail: "owner@example.com",
recordType: "Contact",
},
]);
expect(querySpy).toHaveBeenNthCalledWith(
1,
"SELECT Id, Email, OwnerId FROM Contact WHERE Email IN ('test@example.com')"
);
expect(querySpy).toHaveBeenNthCalledWith(
2,
expect.stringContaining("SELECT Id, Email, Name FROM User WHERE Id = 'owner001'")
);
});
it("should handle account record type with round robin skip", async () => {
service = new SalesforceCRMService(
{} as CredentialPayload,
{
createEventOn: SalesforceRecordEnum.ACCOUNT,
roundRobinSkipCheckRecordOn: SalesforceRecordEnum.ACCOUNT,
},
true
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
service.conn = Promise.resolve(mockConnection);
const initialContactQueryResponse = {
records: [
{
Id: "001",
Email: "test@example.com",
AccountId: "acc001",
OwnerId: "owner001",
attributes: { type: "Contact" },
},
],
};
const accountQueryResponse = {
records: [
{
Id: "acc001",
OwnerId: "owner001",
Email: "test@example.com",
attributes: { type: "Account" },
},
],
};
const ownerQueryResponse = {
records: [
{
Id: "owner001",
Email: "owner@example.com",
Name: "Test Owner",
},
],
};
const querySpy = vi.spyOn(mockConnection, "query");
querySpy
.mockResolvedValueOnce(initialContactQueryResponse)
.mockResolvedValueOnce(accountQueryResponse)
.mockResolvedValueOnce(ownerQueryResponse);
const result = await service.getContacts({
emails: "test@example.com",
forRoundRobinSkip: true,
includeOwner: true,
});
expect(result).toEqual([
{
id: "acc001",
email: "test@example.com",
ownerId: "owner001",
ownerEmail: "owner@example.com",
recordType: "Account",
},
]);
expect(querySpy).toHaveBeenNthCalledWith(
1,
"SELECT Id, Email, OwnerId, AccountId FROM Contact WHERE Email = 'test@example.com' AND AccountId != null"
);
expect(querySpy).toHaveBeenNthCalledWith(2, "SELECT Id, OwnerId FROM Account WHERE Id = 'acc001'");
expect(querySpy).toHaveBeenNthCalledWith(
3,
expect.stringContaining("SELECT Id, Email, Name FROM User WHERE Id = 'owner001'")
);
});
});
});
+188 -97
View File
@@ -7,6 +7,7 @@ import type { FormResponse } from "@calcom/app-store/routing-forms/types/types";
import { getLocation } from "@calcom/lib/CalEventParser";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { prisma } from "@calcom/prisma";
import type { CalendarEvent, CalEventResponses } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
@@ -62,16 +63,18 @@ const salesforceTokenSchema = z.object({
export default class SalesforceCRMService implements CRM {
private integrationName = "";
private conn: Promise<jsforce.Connection>;
private conn!: Promise<jsforce.Connection>;
private log: typeof logger;
private calWarnings: string[] = [];
private appOptions: any;
private doNotCreateEvent = false;
private fallbackToContact = false;
constructor(credential: CredentialPayload, appOptions: any) {
constructor(credential: CredentialPayload, appOptions: any, testMode = false) {
this.integrationName = "salesforce_other_calendar";
this.conn = this.getClient(credential).then((c) => c);
if (!testMode) {
this.conn = this.getClient(credential).then((c) => c);
}
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.appOptions = appOptions;
}
@@ -323,108 +326,196 @@ export default class SalesforceCRMService implements CRM {
includeOwner?: boolean;
forRoundRobinSkip?: boolean;
}) {
const conn = await this.conn;
const emailArray = Array.isArray(emails) ? emails : [emails];
const appOptions = this.getAppOptions();
const recordToSearch =
(forRoundRobinSkip ? appOptions?.roundRobinSkipCheckRecordOn : appOptions?.createEventOn) ??
SalesforceRecordEnum.CONTACT;
let soql: string;
let accountOwnerId = "";
if (recordToSearch === SalesforceRecordEnum.ACCOUNT) {
// For an account let's assume that the first email is the one we should be querying against
const attendeeEmail = emailArray[0];
soql = `SELECT Id, Email, OwnerId, AccountId FROM Contact WHERE Email = '${attendeeEmail}' AND AccountId != null`;
const log = logger.getSubLogger({ prefix: [`[getContacts]:${emails}`] });
try {
const conn = await this.conn;
const emailArray = Array.isArray(emails) ? emails : [emails];
// If this is for a round robin skip then we need to return the account record
if (forRoundRobinSkip) {
const results = await conn.query(soql);
if (results.records.length) {
const contact = results.records[0] as { AccountId: string };
if (contact) {
soql = `SELECT Id, OwnerId FROM Account WHERE Id = '${contact.AccountId}'`;
}
} else {
// If we can't find the exact contact, then we need to search for an account where the contacts share the same email domain
const accountId = await this.getAccountIdBasedOnEmailDomainOfContacts(attendeeEmail);
if (accountId) {
soql = `SELECT Id, OwnerId FROM Account WHERE Id = '${accountId}'`;
}
}
}
// If creating events on contacts or leads
} else {
soql = `SELECT Id, Email, OwnerId FROM ${recordToSearch} WHERE Email IN ('${emailArray.join("','")}')`;
}
const results = await conn.query(soql);
const appOptions = this.getAppOptions();
const recordToSearch =
(forRoundRobinSkip ? appOptions?.roundRobinSkipCheckRecordOn : appOptions?.createEventOn) ??
SalesforceRecordEnum.CONTACT;
// If we're checking against the contact, the ownerId should take precedence
if (recordToSearch === SalesforceRecordEnum.ACCOUNT && results.records.length) {
const account = results.records[0] as ContactRecord;
accountOwnerId = account.OwnerId;
}
let soql: string;
let accountOwnerId = "";
let records: ContactRecord[] = [];
// If falling back to contacts, check for the contact before returning the leads or empty array
if (
appOptions.createEventOn === SalesforceRecordEnum.LEAD &&
appOptions.createEventOnLeadCheckForContact
) {
// Get any matching contacts
const contactSearch = await conn.query(
`SELECT Id, Email, OwnerId FROM ${SalesforceRecordEnum.CONTACT} WHERE Email IN ('${emailArray.join(
"','"
)}')`
log.info(
"Getting contacts for emails",
safeStringify({
emailArray,
includeOwner,
forRoundRobinSkip,
recordToSearch,
appOptions,
})
);
if (contactSearch && contactSearch.records.length > 0) {
records = contactSearch.records as ContactRecord[];
this.setFallbackToContact(true);
// Handle Account record type
if (recordToSearch === SalesforceRecordEnum.ACCOUNT) {
// For an account let's assume that the first email is the one we should be querying against
const attendeeEmail = emailArray[0];
log.info("Searching account for email", safeStringify({ attendeeEmail }));
soql = `SELECT Id, Email, OwnerId, AccountId FROM Contact WHERE Email = '${attendeeEmail}' AND AccountId != null`;
// If this is for a round robin skip then we need to return the account record
if (forRoundRobinSkip) {
const results = await conn.query(soql);
log.info("Account contact search results", safeStringify({ resultCount: results.records.length }));
if (results.records?.length) {
const contact = results.records[0] as { AccountId?: string };
if (contact?.AccountId) {
soql = `SELECT Id, OwnerId FROM Account WHERE Id = '${contact.AccountId}'`;
}
} else {
// If we can't find the exact contact, then we need to search for an account where the contacts share the same email domain
const accountId = await this.getAccountIdBasedOnEmailDomainOfContacts(attendeeEmail);
if (accountId) {
soql = `SELECT Id, OwnerId FROM Account WHERE Id = '${accountId}'`;
}
}
}
// If creating events on contacts or leads
} else {
// Handle Contact/Lead record types
soql = `SELECT Id, Email, OwnerId FROM ${recordToSearch} WHERE Email IN ('${emailArray.join(
"','"
)}')`;
}
} else if (!results || !results.records.length) {
const results = await conn.query(soql);
log.info("Query results", safeStringify({ recordCount: results.records?.length }));
// If we're checking against the contact, the ownerId should take precedence
if (recordToSearch === SalesforceRecordEnum.ACCOUNT && results.records?.length) {
const account = results.records[0] as ContactRecord;
if (account?.OwnerId) {
accountOwnerId = account.OwnerId;
}
}
let records: ContactRecord[] = [];
// If falling back to contacts, check for the contact before returning the leads or empty array
if (
appOptions.createEventOn === SalesforceRecordEnum.LEAD &&
appOptions.createEventOnLeadCheckForContact
) {
// Get any matching contacts
const contactSearch = await conn.query(
`SELECT Id, Email, OwnerId FROM ${SalesforceRecordEnum.CONTACT} WHERE Email IN ('${emailArray.join(
"','"
)}')`
);
if (contactSearch?.records?.length > 0) {
records = contactSearch.records as ContactRecord[];
this.setFallbackToContact(true);
log.info(
"Found matching contacts, falling back to contact",
safeStringify({
contactCount: records.length,
})
);
}
}
if (!records.length && results?.records?.length) {
records = results.records as ContactRecord[];
}
if (!records.length) {
log.info("No records found");
return [];
}
// Handle owner information
if (includeOwner || forRoundRobinSkip) {
const ownerIds: Set<string> = new Set();
if (accountOwnerId) {
ownerIds.add(accountOwnerId);
} else {
records.forEach((record) => {
if (record?.OwnerId) {
ownerIds.add(record.OwnerId);
}
});
}
if (ownerIds.size === 0) {
log.warn("No owner IDs found for records");
return [];
}
const ownersQuery = await Promise.all(
Array.from(ownerIds).map(async (ownerId) => {
const result = await this.getSalesforceUserFromUserId(ownerId);
return result;
})
);
// Filter out any undefined results and ensure records exist
const validOwnersQuery = ownersQuery.filter((query): query is jsforce.QueryResult<ContactRecord> => {
const firstRecord = query?.records?.[0];
if (!firstRecord) return false;
return (
typeof firstRecord === "object" && "Email" in firstRecord && typeof firstRecord.Email === "string"
);
});
if (validOwnersQuery.length === 0) {
log.warn("No valid owner records found");
return [];
}
return records
.map((record) => {
if (!record?.Id || !record?.OwnerId) {
log.warn("Invalid record data", { record });
return null;
}
const ownerEmail = accountOwnerId
? validOwnersQuery[0]?.records[0]?.Email
: validOwnersQuery.find((user) => user.records[0]?.Id === record.OwnerId)?.records[0]?.Email;
if (!ownerEmail) {
log.warn("Could not find owner email", { recordId: record.Id, ownerId: record.OwnerId });
return null;
}
return {
id: record.Id,
email: record.Email,
ownerId: record.OwnerId,
ownerEmail,
recordType: accountOwnerId ? SalesforceRecordEnum.ACCOUNT : record.attributes?.type,
};
})
.filter((record): record is NonNullable<typeof record> => record !== null);
}
return records
.map((record) => {
if (!record?.Id || !record?.Email || !record?.attributes?.type) {
log.warn("Invalid record data for basic mapping", { record });
return null;
}
return {
id: record.Id,
email: record.Email,
recordType: record.attributes.type,
};
})
.filter((record): record is NonNullable<typeof record> => record !== null);
} catch (error) {
log.error("Error in getContacts", safeStringify({ error }));
return [];
}
if (!records.length) records = results.records as ContactRecord[];
if (includeOwner || forRoundRobinSkip) {
const ownerIds: Set<string> = new Set();
if (accountOwnerId) {
ownerIds.add(accountOwnerId);
} else {
records.forEach((record) => {
ownerIds.add(record.OwnerId);
});
}
const ownersQuery = (await Promise.all(
Array.from(ownerIds).map(async (ownerId) => {
return await this.getSalesforceUserFromUserId(ownerId);
})
)) as { records: ContactRecord[] }[];
const contactsWithOwners = records.map((record) => {
const ownerEmail = accountOwnerId
? ownersQuery[0].records[0].Email
: ownersQuery.find((user) => user.records[0]?.Id === record.OwnerId)?.records[0].Email;
return {
id: record.Id,
email: record.Email,
ownerId: record.OwnerId,
ownerEmail,
recordType: accountOwnerId ? SalesforceRecordEnum.ACCOUNT : record.attributes.type,
};
});
return contactsWithOwners;
}
return records
? records.map((record) => ({
id: record.Id,
email: record.Email,
recordType: record.attributes.type,
}))
: [];
}
async createContacts(

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