diff --git a/.github/workflows/e2e-app-store.yml b/.github/workflows/e2e-app-store.yml index e0b36f40cc5eb0..a74e61b595cc7f 100644 --- a/.github/workflows/e2e-app-store.yml +++ b/.github/workflows/e2e-app-store.yml @@ -98,5 +98,5 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@v4 with: - name: app-store-results - path: test-results + name: blob-report-app-store + path: blob-report diff --git a/.github/workflows/e2e-embed-react.yml b/.github/workflows/e2e-embed-react.yml index 4f114f5ef8d7c9..f0a9c5ab298020 100644 --- a/.github/workflows/e2e-embed-react.yml +++ b/.github/workflows/e2e-embed-react.yml @@ -85,5 +85,5 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@v4 with: - name: embed-react-results - path: test-results + name: blob-report-embed-react + path: blob-report diff --git a/.github/workflows/e2e-embed.yml b/.github/workflows/e2e-embed.yml index 05d7015b8808c5..57b922e6b8716b 100644 --- a/.github/workflows/e2e-embed.yml +++ b/.github/workflows/e2e-embed.yml @@ -91,5 +91,5 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@v4 with: - name: embed-core-results - path: test-results + name: blob-report-embed-core + path: blob-report diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index d18a871ef8d417..dd32541fddf1bb 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -13,5 +13,5 @@ jobs: if: github.event.review.state == 'approved' uses: actions-ecosystem/action-add-labels@v1 with: - github_token: ${{ secrets.GITHUB_TOKEN }} + github_token: ${{ secrets.READY_FOR_E2E_PAT }} labels: 'ready-for-e2e' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d5bd3d4dd2e0b0..eb2998c2fa86e1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -189,19 +189,19 @@ jobs: merge-reports: name: Merge reports - if: ${{ !cancelled() }} - needs: [e2e] + if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} + needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store] uses: ./.github/workflows/merge-reports.yml secrets: inherit publish-report: name: Publish HTML report - if: ${{ !cancelled() }} + if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }} permissions: contents: write issues: write pull-requests: write - needs: [merge-reports] + needs: [changes, check-label, merge-reports] uses: ./.github/workflows/publish-report.yml secrets: inherit diff --git a/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts b/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts index 82bc9cf48680fa..d69ae853907ed0 100644 --- a/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts @@ -9,8 +9,10 @@ import { UsersModule } from "@/modules/users/users.module"; import { INestApplication } from "@nestjs/common"; import { NestExpressApplication } from "@nestjs/platform-express"; import { Test } from "@nestjs/testing"; -import { User } from "@prisma/client"; +import { User, Team } from "@prisma/client"; import * as request from "supertest"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { withApiAuth } from "test/utils/withApiAuth"; @@ -25,9 +27,11 @@ describe("Me Endpoints", () => { let userRepositoryFixture: UserRepositoryFixture; let schedulesRepositoryFixture: SchedulesRepositoryFixture; - + let profilesRepositoryFixture: ProfileRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; const userEmail = "me-controller-e2e@api.com"; let user: User; + let org: Team; beforeAll(async () => { const moduleRef = await withApiAuth( @@ -43,6 +47,9 @@ describe("Me Endpoints", () => { .compile(); userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef); user = await userRepositoryFixture.create({ @@ -50,6 +57,20 @@ describe("Me Endpoints", () => { username: userEmail, }); + org = await organizationsRepositoryFixture.create({ + name: "Test org team", + isOrganization: true, + isPlatform: true, + }); + + await profilesRepositoryFixture.create({ + uid: "asd-asd", + username: userEmail, + user: { connect: { id: user.id } }, + organization: { connect: { id: org.id } }, + movedFromUser: { connect: { id: user.id } }, + }); + app = moduleRef.createNestApplication(); bootstrap(app as NestExpressApplication); @@ -75,6 +96,8 @@ describe("Me Endpoints", () => { expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId); expect(responseBody.data.weekStart).toEqual(user.weekStart); expect(responseBody.data.timeZone).toEqual(user.timeZone); + expect(responseBody.data.organization?.isPlatform).toEqual(true); + expect(responseBody.data.organization?.id).toEqual(org.id); }); }); @@ -138,6 +161,7 @@ describe("Me Endpoints", () => { afterAll(async () => { await userRepositoryFixture.deleteByEmail(user.email); + await organizationsRepositoryFixture.delete(org.id); await app.close(); }); }); diff --git a/apps/api/v2/src/ee/me/me.controller.ts b/apps/api/v2/src/ee/me/me.controller.ts index c22a9a606ece14..b03583d6e7f0e9 100644 --- a/apps/api/v2/src/ee/me/me.controller.ts +++ b/apps/api/v2/src/ee/me/me.controller.ts @@ -29,8 +29,19 @@ export class MeController { @Get("/") @Permissions([PROFILE_READ]) async getMe(@GetUser() user: UserWithProfile): Promise { - const me = userSchemaResponse.parse(user); - + const organization = user?.movedToProfile?.organization; + const me = userSchemaResponse.parse( + organization + ? { + ...user, + organizationId: organization.id, + organization: { + id: organization.id, + isPlatform: organization.isPlatform, + }, + } + : user + ); return { status: SUCCESS_STATUS, data: me, diff --git a/apps/api/v2/src/ee/me/outputs/me.output.ts b/apps/api/v2/src/ee/me/outputs/me.output.ts index 597b2738e9117f..64c3fb64837d3e 100644 --- a/apps/api/v2/src/ee/me/outputs/me.output.ts +++ b/apps/api/v2/src/ee/me/outputs/me.output.ts @@ -1,5 +1,11 @@ -import { IsInt, IsEmail, IsOptional, IsString } from "class-validator"; +import { Type } from "class-transformer"; +import { IsInt, IsEmail, IsOptional, IsString, ValidateNested } from "class-validator"; +export class MeOrgOutput { + isPlatform!: boolean; + + id!: number; +} export class MeOutput { @IsInt() id!: number; @@ -25,4 +31,9 @@ export class MeOutput { @IsInt() organizationId!: number | null; + + @IsOptional() + @ValidateNested() + @Type(() => MeOrgOutput) + organization?: MeOrgOutput; } diff --git a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts index ee213059fca83c..2a427d08df92fd 100644 --- a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts @@ -4,6 +4,7 @@ import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Reflector } from "@nestjs/core"; +import { getToken } from "next-auth/jwt"; import { hasPermissions } from "@calcom/platform-utils"; @@ -24,6 +25,12 @@ export class PermissionsGuard implements CanActivate { const request = context.switchToHttp().getRequest(); const authString = request.get("Authorization")?.replace("Bearer ", ""); + const nextAuthSecret = this.config.get("next.authSecret", { infer: true }); + const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret }); + + if (nextAuthToken) { + return true; + } if (!authString) { return false; diff --git a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts index 17df03b9adbf14..921e8cef83ecca 100644 --- a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts +++ b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts @@ -11,6 +11,7 @@ import { Injectable, InternalServerErrorException, UnauthorizedException } from import { ConfigService } from "@nestjs/config"; import { PassportStrategy } from "@nestjs/passport"; import type { Request } from "express"; +import { getToken } from "next-auth/jwt"; import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; @@ -45,6 +46,13 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") return await this.authenticateBearerToken(bearerToken, requestOrigin); } + const nextAuthSecret = this.config.get("next.authSecret", { infer: true }); + const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret }); + + if (nextAuthToken) { + return await this.authenticateNextAuth(nextAuthToken); + } + throw new UnauthorizedException( "No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers" ); @@ -58,6 +66,11 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") } } + async authenticateNextAuth(token: { email?: string | null }) { + const user = await this.nextAuthStrategy(token); + return this.success(user); + } + async authenticateOAuthClient(oAuthClientId: string, oAuthClientSecret: string) { const user = await this.oAuthClientStrategy(oAuthClientId, oAuthClientSecret); return this.success(user); @@ -163,4 +176,17 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(ownerId); return user; } + + async nextAuthStrategy(token: { email?: string | null }) { + if (!token.email) { + throw new UnauthorizedException("Email not found in the authentication token."); + } + + const user = await this.userRepository.findByEmailWithProfile(token.email); + if (!user) { + throw new UnauthorizedException("User associated with the authentication token email not found."); + } + + return user; + } } diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts index a4fa88daa459bb..0303a5117ffa78 100644 --- a/apps/api/v2/src/modules/users/users.repository.ts +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -3,10 +3,10 @@ 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 } from "@nestjs/common"; -import type { Profile, User } from "@prisma/client"; +import type { Profile, User, Team } from "@prisma/client"; export type UserWithProfile = User & { - movedToProfile?: Profile | null; + movedToProfile?: (Profile & { organization: Pick }) | null; }; @Injectable() @@ -67,12 +67,15 @@ export class UsersRepository { } async findByIdWithProfile(userId: number): Promise { + console.log("findByIdWithProfile"); return this.dbRead.prisma.user.findUnique({ where: { id: userId, }, include: { - movedToProfile: true, + movedToProfile: { + include: { organization: { select: { isPlatform: true, name: true, slug: true, id: true } } }, + }, }, }); } @@ -126,7 +129,9 @@ export class UsersRepository { email, }, include: { - movedToProfile: true, + movedToProfile: { + include: { organization: { select: { isPlatform: true, name: true, slug: true, id: true } } }, + }, }, }); } diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index a293fb3d6c94f4..3ee2180616d1e5 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -1219,16 +1219,6 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateScheduleInput_2024_06_11" - } - } - } - }, "responses": { "201": { "description": "", @@ -1331,16 +1321,6 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateScheduleInput_2024_06_11" - } - } - } - }, "responses": { "200": { "description": "", @@ -2380,16 +2360,6 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateScheduleInput_2024_04_15" - } - } - } - }, "responses": { "200": { "description": "", @@ -6071,57 +6041,6 @@ "data" ] }, - "CreateScheduleInput_2024_06_11": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "One-on-one coaching" - }, - "timeZone": { - "type": "string", - "example": "Europe/Rome" - }, - "availability": { - "example": [ - { - "days": [ - "Monday", - "Tuesday" - ], - "startTime": "09:00", - "endTime": "10:00" - } - ], - "type": "array", - "items": { - "$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11" - } - }, - "isDefault": { - "type": "boolean", - "example": true - }, - "overrides": { - "example": [ - { - "date": "2024-05-20", - "startTime": "12:00", - "endTime": "14:00" - } - ], - "type": "array", - "items": { - "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11" - } - } - }, - "required": [ - "name", - "timeZone", - "isDefault" - ] - }, "CreateScheduleOutput_2024_06_11": { "type": "object", "properties": { @@ -6170,52 +6089,6 @@ "data" ] }, - "UpdateScheduleInput_2024_06_11": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "One-on-one coaching" - }, - "timeZone": { - "type": "string", - "example": "Europe/Rome" - }, - "availability": { - "example": [ - { - "days": [ - "Monday", - "Tuesday" - ], - "startTime": "09:00", - "endTime": "10:00" - } - ], - "type": "array", - "items": { - "$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11" - } - }, - "isDefault": { - "type": "boolean", - "example": true - }, - "overrides": { - "example": [ - { - "date": "2024-05-20", - "startTime": "12:00", - "endTime": "14:00" - } - ], - "type": "array", - "items": { - "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11" - } - } - } - }, "UpdateScheduleOutput_2024_06_11": { "type": "object", "properties": { @@ -7310,26 +7183,6 @@ "userId" ] }, - "GetDefaultScheduleOutput_2024_06_11": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "success", - "enum": [ - "success", - "error" - ] - }, - "data": { - "$ref": "#/components/schemas/ScheduleOutput_2024_06_11" - } - }, - "required": [ - "status", - "data" - ] - }, "CreateAvailabilityInput_2024_04_15": { "type": "object", "properties": { @@ -7627,66 +7480,6 @@ "data" ] }, - "UpdateScheduleInput_2024_04_15": { - "type": "object", - "properties": { - "timeZone": { - "type": "string" - }, - "name": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - }, - "schedule": { - "example": [ - [], - [ - { - "start": "2022-01-01T00:00:00.000Z", - "end": "2022-01-02T00:00:00.000Z" - } - ], - [], - [], - [], - [], - [] - ], - "items": { - "type": "array" - }, - "type": "array" - }, - "dateOverrides": { - "example": [ - [], - [ - { - "start": "2022-01-01T00:00:00.000Z", - "end": "2022-01-02T00:00:00.000Z" - } - ], - [], - [], - [], - [], - [] - ], - "items": { - "type": "array" - }, - "type": "array" - } - }, - "required": [ - "timeZone", - "name", - "isDefault", - "schedule" - ] - }, "EventTypeModel_2024_04_15": { "type": "object", "properties": { @@ -7932,6 +7725,21 @@ "status" ] }, + "MeOrgOutput": { + "type": "object", + "properties": { + "isPlatform": { + "type": "boolean" + }, + "id": { + "type": "number" + } + }, + "required": [ + "isPlatform", + "id" + ] + }, "MeOutput": { "type": "object", "properties": { @@ -7960,6 +7768,9 @@ "organizationId": { "type": "number", "nullable": true + }, + "organization": { + "$ref": "#/components/schemas/MeOrgOutput" } }, "required": [ diff --git a/apps/web/components/GTM.tsx b/apps/web/components/GTM.tsx new file mode 100644 index 00000000000000..f91d5a1d6bd6ab --- /dev/null +++ b/apps/web/components/GTM.tsx @@ -0,0 +1,54 @@ +import { GoogleTagManager } from "@next/third-parties/google"; +import { useQuery } from "@tanstack/react-query"; + +const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID; + +const CACHE_KEY = "user_geolocation"; +const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours in milliseconds + +export async function fetchGeolocation() { + const cachedData = localStorage.getItem(CACHE_KEY); + + if (cachedData) { + const { country, timestamp } = JSON.parse(cachedData); + + if (Date.now() - timestamp < CACHE_DURATION) { + return { country }; + } + } + + const res = await fetch("/api/geolocation"); + const data = await res.json(); + + const newCacheData = { + country: data.country, + timestamp: Date.now(), + }; + + localStorage.setItem(CACHE_KEY, JSON.stringify(newCacheData)); + return data; +} + +export function useGeolocation() { + const { data, isLoading, error } = useQuery({ + queryKey: ["geolocation"], + queryFn: fetchGeolocation, + staleTime: 24 * 60 * 60 * 1000, // 24 hours + }); + + return { + isUS: data?.country === "US", + loading: isLoading, + error, + }; +} + +export function GoogleTagManagerComponent() { + const { isUS, loading } = useGeolocation(); + + if (!isUS || !GTM_ID || loading) { + return null; + } + + return ; +} diff --git a/apps/web/components/PageWrapper.tsx b/apps/web/components/PageWrapper.tsx index 2250d84b151c16..c3f0a0788321bc 100644 --- a/apps/web/components/PageWrapper.tsx +++ b/apps/web/components/PageWrapper.tsx @@ -13,6 +13,8 @@ import type { AppProps } from "@lib/app-providers"; import AppProviders from "@lib/app-providers"; import { seoConfig } from "@lib/config/next-seo.config"; +import { GoogleTagManagerComponent } from "@components/GTM"; + export interface CalPageWrapper { (props?: AppProps): JSX.Element; PageWrapper?: AppProps["Component"]["PageWrapper"]; @@ -93,6 +95,7 @@ function PageWrapper(props: AppProps) { ) )} + ); } diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 86933ef541bd97..cfcc5d9abbc70d 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -18,6 +18,7 @@ import getPaymentAppData from "@calcom/lib/getPaymentAppData"; import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { useGetTheme } from "@calcom/lib/hooks/useTheme"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -100,6 +101,12 @@ function BookingListItem(booking: BookingItemProps) { const location = booking.location as ReturnType; const locationVideoCallUrl = bookingMetadataSchema.parse(booking?.metadata || {})?.videoCallUrl; + const { resolvedTheme, forcedTheme } = useGetTheme(); + const hasDarkTheme = !forcedTheme && resolvedTheme === "dark"; + const eventTypeColor = + booking.eventType.eventTypeColor && + booking.eventType.eventTypeColor[hasDarkTheme ? "darkEventTypeColor" : "lightEventTypeColor"]; + const locationToDisplay = getSuccessPageLocationMessage( locationVideoCallUrl ? locationVideoCallUrl : location, t, @@ -355,79 +362,82 @@ function BookingListItem(booking: BookingItemProps) { /> )} - - -
-
{startTime}
-
- {formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "} - {formatTime(booking.endTime, userTimeFormat, userTimeZone)} - -
- {!isPending && ( - - )} - {isPending && ( - - {t("unconfirmed")} - - )} - {booking.eventType?.team && ( - - {booking.eventType.team.name} - - )} - {booking.paid && !booking.payment[0] ? ( - - {t("error_collecting_card")} - - ) : booking.paid ? ( - - {booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")} - - ) : null} - {recurringDates !== undefined && ( -
- +
+ {eventTypeColor &&
} + +
+
{startTime}
+
+ {formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "} + {formatTime(booking.endTime, userTimeFormat, userTimeZone)} +
- )} -
- + {!isPending && ( + + )} + {isPending && ( + + {t("unconfirmed")} + + )} + {booking.eventType?.team && ( + + {booking.eventType.team.name} + + )} + {booking.paid && !booking.payment[0] ? ( + + {t("error_collecting_card")} + + ) : booking.paid ? ( + + {booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")} + + ) : null} + {recurringDates !== undefined && ( +
+ +
+ )} +
+ +
diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/apps/web/components/eventtype/EventAdvancedTab.tsx index 39245a06f4b37c..d6eaa871b3b374 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/apps/web/components/eventtype/EventAdvancedTab.tsx @@ -19,8 +19,10 @@ import type { EditableSchema } from "@calcom/features/form-builder/schema"; import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector"; import { classNames } from "@calcom/lib"; import cx from "@calcom/lib/classNames"; +import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants"; import { APP_NAME, IS_VISUAL_REGRESSION_TESTING, WEBSITE_URL } from "@calcom/lib/constants"; import { generateHashedLink } from "@calcom/lib/generateHashedLink"; +import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { Prisma } from "@calcom/prisma/client"; import { SchedulingType } from "@calcom/prisma/enums"; @@ -38,9 +40,11 @@ import { TextField, Tooltip, showToast, + ColorPicker, } from "@calcom/ui"; import RequiresConfirmationController from "./RequiresConfirmationController"; +import { DisableAllEmailsSetting } from "./settings/DisableAllEmailsSetting"; const CustomEventTypeModal = dynamic(() => import("@components/eventtype/CustomEventTypeModal")); @@ -50,6 +54,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick(); const { t } = useLocale(); const [showEventNameTip, setShowEventNameTip] = useState(false); + const [darkModeError, setDarkModeError] = useState(false); + const [lightModeError, setLightModeError] = useState(false); const [hashedLinkVisible, setHashedLinkVisible] = useState(!!formMethods.getValues("hashedLink")); const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!formMethods.getValues("successRedirectUrl")); const [useEventTypeDestinationCalendarEmail, setUseEventTypeDestinationCalendarEmail] = useState( @@ -57,7 +63,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick workflowOnEventType.workflow); const selectedThemeIsDark = user?.theme === "dark" || @@ -131,9 +136,20 @@ export const EventAdvancedTab = ({ eventType, team }: Pick setShowEventNameTip(false); + + const [isEventTypeColorChecked, setIsEventTypeColorChecked] = useState(!!eventType.eventTypeColor); + + const [eventTypeColorState, setEventTypeColorState] = useState( + eventType.eventTypeColor || { + lightEventTypeColor: DEFAULT_LIGHT_BRAND_COLOR, + darkEventTypeColor: DEFAULT_DARK_BRAND_COLOR, + } + ); + const displayDestinationCalendarSelector = !!connectedCalendarsQuery.data?.connectedCalendars.length && (!team || isChildrenManagedEventType); @@ -537,6 +553,82 @@ export const EventAdvancedTab = ({ eventType, team }: Pick )} /> + ( + { + const value = e ? eventTypeColorState : null; + formMethods.setValue("eventTypeColor", value, { + shouldDirty: true, + }); + setIsEventTypeColorChecked(e); + }} + childrenClassName="lg:ml-0"> +
+
+

{t("light_event_type_color")}

+ { + if (checkWCAGContrastColor("#ffffff", value)) { + const newVal = { + ...eventTypeColorState, + lightEventTypeColor: value, + }; + setLightModeError(false); + formMethods.setValue("eventTypeColor", newVal, { shouldDirty: true }); + setEventTypeColorState(newVal); + } else { + setLightModeError(true); + } + }} + /> + {lightModeError ? ( +
+ +
+ ) : null} +
+ +
+

{t("dark_event_type_color")}

+ { + if (checkWCAGContrastColor("#101010", value)) { + const newVal = { + ...eventTypeColorState, + darkEventTypeColor: value, + }; + setDarkModeError(false); + formMethods.setValue("eventTypeColor", newVal, { shouldDirty: true }); + setEventTypeColorState(newVal); + } else { + setDarkModeError(true); + } + }} + /> + {darkModeError ? ( +
+ +
+ ) : null} +
+
+
+ )} + /> {isRoundRobinEventType && ( )} + {team?.parentId && ( + <> + { + return ( + <> + + + ); + }} + /> + ( + <> + + + )} + /> + + )} {showEventNameTip && ( { const { t } = useLocale(); - const { setValue } = useFormContext(); + const { setValue, getValues, control } = useFormContext(); + + const isRRWeightsEnabled = useWatch({ + control, + name: "isRRWeightsEnabled", + }); return (
@@ -210,7 +220,26 @@ const RoundRobinHosts = ({

{t("round_robin_helper")}

-
+
+ {!assignAllTeamMembers && ( + + name="isRRWeightsEnabled" + render={({ field: { value, onChange } }) => ( + { + onChange(active); + + const rrHosts = getValues("hosts").filter((host) => !host.isFixed); + const sortedRRHosts = rrHosts.sort((a, b) => sortHosts(a, b, active)); + setValue("hosts", sortedRRHosts); + }} + /> + )} + /> + )} + containerClassName={assignAllTeamMembers ? "-mt-4" : ""} + onActive={() => { setValue( "hosts", - teamMembers - .map((teamMember) => ({ - isFixed: false, - userId: parseInt(teamMember.value, 10), - priority: 2, - })) - .sort((a, b) => b.priority - a.priority), + teamMembers.map((teamMember) => ({ + isFixed: false, + userId: parseInt(teamMember.value, 10), + priority: 2, + weight: 100, + weightAdjustment: 0, + })), { shouldDirty: true } - ) - } + ); + setValue("isRRWeightsEnabled", false); + }} />
@@ -340,11 +372,8 @@ const Hosts = ({ teamMembers={teamMembers} value={value} onChange={(changeValue) => { - onChange( - [...value.filter((host: Host) => host.isFixed), ...changeValue].sort( - (a, b) => b.priority - a.priority - ) - ); + const hosts = [...value.filter((host: Host) => host.isFixed), ...changeValue]; + onChange(hosts); }} assignAllTeamMembers={assignAllTeamMembers} setAssignAllTeamMembers={setAssignAllTeamMembers} diff --git a/apps/web/components/eventtype/settings/DisableAllEmailsSetting.tsx b/apps/web/components/eventtype/settings/DisableAllEmailsSetting.tsx new file mode 100644 index 00000000000000..453967a0878a68 --- /dev/null +++ b/apps/web/components/eventtype/settings/DisableAllEmailsSetting.tsx @@ -0,0 +1,82 @@ +import type { TFunction } from "next-i18next"; +import { Trans } from "next-i18next"; +import { useState } from "react"; + +import { + SettingsToggle, + Dialog, + DialogContent, + DialogFooter, + InputField, + DialogClose, + Button, +} from "@calcom/ui"; + +interface DisableEmailsSettingProps { + checked: boolean; + onCheckedChange: (e: boolean) => void; + recipient: "attendees" | "hosts"; + t: TFunction; +} + +export const DisableAllEmailsSetting = ({ + checked, + onCheckedChange, + recipient, + t, +}: DisableEmailsSettingProps) => { + const [dialogOpen, setDialogOpen] = useState(false); + const [confirmText, setConfirmText] = useState(""); + + const title = + recipient === "attendees" ? t("disable_all_emails_to_attendees") : t("disable_all_emails_to_hosts"); + + return ( +
+ setDialogOpen(e)}> + +

+ + This will disable all emails to {{ recipient }}. This includes booking confirmations, requests, + reschedules and reschedule requests, cancellation emails, and any other emails related to + booking updates. +
+
+ It is your responsibility to ensure that your {{ recipient }} are aware of any bookings and + changes to their bookings. +
+

+

{t("type_confirm_to_continue")}

+ { + setConfirmText(e.target.value); + }} + /> + + + + +
+
+ { + checked ? onCheckedChange(!checked) : setDialogOpen(true); + }} + /> +
+ ); +}; diff --git a/apps/web/components/settings/platform/hooks/useGetUserAttributes.ts b/apps/web/components/settings/platform/hooks/useGetUserAttributes.ts index 8e5d0e5c677c75..e837f115fda363 100644 --- a/apps/web/components/settings/platform/hooks/useGetUserAttributes.ts +++ b/apps/web/components/settings/platform/hooks/useGetUserAttributes.ts @@ -1,15 +1,23 @@ -import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import { useCheckTeamBilling } from "@calcom/web/lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient"; +import { usePlatformMe } from "./usePlatformMe"; + export const useGetUserAttributes = () => { - const { data: user, isLoading: isUserLoading } = useMeQuery(); + const { data: platformUser, isLoading: isPlatformUserLoading } = usePlatformMe(); const { data: userBillingData, isFetching: isUserBillingDataLoading } = useCheckTeamBilling( - user?.organizationId, - user?.organization.isPlatform + platformUser?.organizationId, + platformUser?.organization?.isPlatform ?? false ); - const isPlatformUser = user?.organization.isPlatform; + const isPlatformUser = platformUser?.organization?.isPlatform ?? false; const isPaidUser = userBillingData?.valid; - const userOrgId = user?.organizationId; + const userOrgId = platformUser?.organizationId; - return { isUserLoading, isUserBillingDataLoading, isPlatformUser, isPaidUser, userBillingData, userOrgId }; + return { + isUserLoading: isPlatformUserLoading, + isUserBillingDataLoading, + isPlatformUser, + isPaidUser, + userBillingData, + userOrgId, + }; }; diff --git a/apps/web/components/settings/platform/hooks/usePlatformMe.ts b/apps/web/components/settings/platform/hooks/usePlatformMe.ts new file mode 100644 index 00000000000000..b3e3c61f993c9e --- /dev/null +++ b/apps/web/components/settings/platform/hooks/usePlatformMe.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; + +import type { UserResponse } from "@calcom/platform-types"; + +export const usePlatformMe = () => { + const QUERY_KEY = "get-platform-me"; + const platformMeQuery = useQuery({ + queryKey: [QUERY_KEY], + queryFn: async (): Promise => { + const response = await fetch(`/api/v2/me`, { + method: "get", + headers: { "Content-type": "application/json" }, + }); + const data = await response.json(); + + return data.data as UserResponse; + }, + }); + + return platformMeQuery; +}; diff --git a/apps/web/modules/event-types/views/event-types-listing-view.tsx b/apps/web/modules/event-types/views/event-types-listing-view.tsx index e4f455d30f1dc0..afc8eebb572c5d 100644 --- a/apps/web/modules/event-types/views/event-types-listing-view.tsx +++ b/apps/web/modules/event-types/views/event-types-listing-view.tsx @@ -17,12 +17,14 @@ import { DuplicateDialog } from "@calcom/features/eventtypes/components/Duplicat import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter"; import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery"; import Shell from "@calcom/features/shell/Shell"; +import { parseEventTypeColor } from "@calcom/lib"; import { APP_NAME } from "@calcom/lib/constants"; import { WEBSITE_URL } from "@calcom/lib/constants"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; +import { useGetTheme } from "@calcom/lib/hooks/useTheme"; import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery"; import { HttpError } from "@calcom/lib/http-error"; import type { User } from "@calcom/prisma/client"; @@ -213,6 +215,11 @@ const Item = ({ readOnly: boolean; }) => { const { t } = useLocale(); + const { resolvedTheme, forcedTheme } = useGetTheme(); + const hasDarkTheme = !forcedTheme && resolvedTheme === "dark"; + const parsedeventTypeColor = parseEventTypeColor(type.eventTypeColor); + const eventTypeColor = + parsedeventTypeColor && parsedeventTypeColor[hasDarkTheme ? "darkEventTypeColor" : "lightEventTypeColor"]; const content = () => (
@@ -238,40 +245,46 @@ const Item = ({
); - return readOnly ? ( -
- {content()} - -
- ) : ( - -
- - {type.title} - - {group.profile.slug ? ( - - {`/${group.profile.slug}/${type.slug}`} - - ) : null} - {readOnly && ( - - {t("readonly")} - + return ( +
+ {eventTypeColor && ( +
+ )} +
+ {readOnly ? ( +
+ {content()} + +
+ ) : ( + +
+ + {type.title} + + {group.profile.slug ? ( + + {`/${group.profile.slug}/${type.slug}`} + + ) : null} + {readOnly && ( + + {t("readonly")} + + )} +
+ + )}
- - +
); }; @@ -470,7 +483,7 @@ export const EventTypeList = ({ return (
  • -
    +
    {!(firstItem && firstItem.id === type.id) && ( moveEventType(index, -1)} arrowDirection="up" /> )} diff --git a/apps/web/modules/event-types/views/event-types-single-view.tsx b/apps/web/modules/event-types/views/event-types-single-view.tsx index e53fd01e230bcd..d3a1a3e0d26307 100644 --- a/apps/web/modules/event-types/views/event-types-single-view.tsx +++ b/apps/web/modules/event-types/views/event-types-single-view.tsx @@ -21,6 +21,7 @@ import { } from "@calcom/features/ee/cal-ai-phone/promptTemplates"; import type { Workflow } from "@calcom/features/ee/workflows/lib/types"; import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect"; +import { sortHosts } from "@calcom/features/eventtypes/components/HostEditDialogs"; import type { FormValues } from "@calcom/features/eventtypes/lib/types"; import { validateIntervalLimitOrder } from "@calcom/lib"; import { WEBSITE_URL } from "@calcom/lib/constants"; @@ -89,7 +90,13 @@ const ManagedEventTypeDialog = dynamic(() => import("@components/eventtype/Manag const AssignmentWarningDialog = dynamic(() => import("@components/eventtype/AssignmentWarningDialog")); -export type Host = { isFixed: boolean; userId: number; priority: number }; +export type Host = { + isFixed: boolean; + userId: number; + priority: number; + weight: number; + weightAdjustment: number; +}; export type CustomInputParsed = typeof customInputSchema._output; @@ -282,6 +289,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf length: eventType.length, hidden: eventType.hidden, hashedLink: eventType.hashedLink?.link || undefined, + eventTypeColor: eventType.eventTypeColor || null, periodDates: { startDate: periodDates.startDate, endDate: periodDates.endDate, @@ -296,7 +304,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf slotInterval: eventType.slotInterval, minimumBookingNotice: eventType.minimumBookingNotice, metadata: eventType.metadata, - hosts: eventType.hosts, + hosts: eventType.hosts.sort((a, b) => sortHosts(a, b, eventType.isRRWeightsEnabled)), successRedirectUrl: eventType.successRedirectUrl || "", forwardParamsSuccessRedirect: eventType.forwardParamsSuccessRedirect, users: eventType.users, @@ -329,6 +337,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf templateType: eventType.aiPhoneCallConfig?.templateType ?? "CUSTOM_TEMPLATE", schedulerName: eventType.aiPhoneCallConfig?.schedulerName, }, + isRRWeightsEnabled: eventType.isRRWeightsEnabled, }; }, [eventType, periodDates]); const formMethods = useForm({ @@ -398,6 +407,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf return () => { router.events.off("routeChangeStart", handleRouteChange); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [router, eventType.hosts, eventType.children, eventType.assignAllTeamMembers]); const appsMetadata = formMethods.getValues("metadata")?.apps; @@ -518,6 +528,8 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf const updatedFields: Partial = {}; Object.keys(dirtyFields).forEach((key) => { const typedKey = key as keyof typeof dirtyFields; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore updatedFields[typedKey] = undefined; const isDirty = isFieldDirty(typedKey); if (isDirty) { @@ -545,6 +557,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf onlyShowFirstAvailableSlot, durationLimits, recurringEvent, + eventTypeColor, locations, metadata, customInputs, @@ -615,6 +628,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf bookingLimits, onlyShowFirstAvailableSlot, durationLimits, + eventTypeColor, seatsPerTimeSlot, seatsShowAttendees, seatsShowAvailabilityCount, @@ -703,6 +717,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf onlyShowFirstAvailableSlot, durationLimits, recurringEvent, + eventTypeColor, locations, metadata, customInputs, @@ -765,6 +780,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf bookingLimits, onlyShowFirstAvailableSlot, durationLimits, + eventTypeColor, seatsPerTimeSlot, seatsShowAttendees, seatsShowAvailabilityCount, diff --git a/apps/web/modules/videos/ai/ai-transcribe.tsx b/apps/web/modules/videos/ai/ai-transcribe.tsx index 5e7bf9152ca2d8..380f7d3c545592 100644 --- a/apps/web/modules/videos/ai/ai-transcribe.tsx +++ b/apps/web/modules/videos/ai/ai-transcribe.tsx @@ -152,9 +152,10 @@ export const CalAiTranscribe = () => { id="cal-ai-transcription" style={{ textShadow: "0 0 20px black, 0 0 20px black, 0 0 20px black", + backgroundColor: "rgba(0,0,0,0.6)", }} ref={transcriptRef} - className="max-h-full overflow-x-hidden overflow-y-scroll p-2 text-center text-white"> + className="flex max-h-full justify-center overflow-x-hidden overflow-y-scroll p-2 text-center text-white"> {transcript ? transcript.split("\n").map((line, i) => ( diff --git a/apps/web/modules/videos/views/videos-single-view.tsx b/apps/web/modules/videos/views/videos-single-view.tsx index 95dac5e05bb8ab..e40e8ce105e2eb 100644 --- a/apps/web/modules/videos/views/videos-single-view.tsx +++ b/apps/web/modules/videos/views/videos-single-view.tsx @@ -94,7 +94,9 @@ export default function JoinCall(props: PageProps) { -
    +
    diff --git a/apps/web/pages/api/cron/bookingReminder.ts b/apps/web/pages/api/cron/bookingReminder.ts index 9547bb1eced517..149f372c88c040 100644 --- a/apps/web/pages/api/cron/bookingReminder.ts +++ b/apps/web/pages/api/cron/bookingReminder.ts @@ -7,6 +7,7 @@ import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import { BookingStatus, ReminderType } from "@calcom/prisma/enums"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -61,6 +62,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) select: { recurringEvent: true, bookingFields: true, + metadata: true, }, }, responses: true, @@ -130,7 +132,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [], }; - await sendOrganizerRequestReminderEmail(evt); + await sendOrganizerRequestReminderEmail(evt, booking?.eventType?.metadata as EventTypeMetadata); await prisma.reminderMail.create({ data: { diff --git a/apps/web/pages/api/geolocation.ts b/apps/web/pages/api/geolocation.ts new file mode 100644 index 00000000000000..b5d61d6481b7fe --- /dev/null +++ b/apps/web/pages/api/geolocation.ts @@ -0,0 +1,7 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const country = req.headers["x-vercel-ip-country"] || "Unknown"; + res.setHeader("Cache-Control", "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400"); + res.status(200).json({ country }); +} diff --git a/apps/web/pages/settings/my-account/appearance.tsx b/apps/web/pages/settings/my-account/appearance.tsx index a2f0bb36b9f22f..9fe645435bd68b 100644 --- a/apps/web/pages/settings/my-account/appearance.tsx +++ b/apps/web/pages/settings/my-account/appearance.tsx @@ -348,12 +348,11 @@ const AppearanceView = ({ defaultValue={DEFAULT_BRAND_COLOURS.light} resetDefaultValue={DEFAULT_LIGHT_BRAND_COLOR} onChange={(value) => { - try { - checkWCAGContrastColor("#ffffff", value); + if (checkWCAGContrastColor("#ffffff", value)) { setLightModeError(false); brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true }); - } catch (err) { - setLightModeError(false); + } else { + setLightModeError(true); } }} /> @@ -377,11 +376,10 @@ const AppearanceView = ({ defaultValue={DEFAULT_BRAND_COLOURS.dark} resetDefaultValue={DEFAULT_DARK_BRAND_COLOR} onChange={(value) => { - try { - checkWCAGContrastColor("#101010", value); + if (checkWCAGContrastColor("#101010", value)) { setDarkModeError(false); brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true }); - } catch (err) { + } else { setDarkModeError(true); } }} diff --git a/apps/web/public/icons/sprite.svg b/apps/web/public/icons/sprite.svg index 0ae3cf4bf03ac6..e50f98860f1532 100644 --- a/apps/web/public/icons/sprite.svg +++ b/apps/web/public/icons/sprite.svg @@ -51,6 +51,10 @@ + + + + diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 7128212cd9d235..566d93457890d6 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -22,7 +22,7 @@ "verify_email_banner_body": "Verify your email address to guarantee the best email and calendar deliverability", "verify_email_email_header": "Verify your email address", "verify_email_email_button": "Verify email", - "cal_ai_assistant":"Cal AI Assistant", + "cal_ai_assistant": "Cal AI Assistant", "verify_email_change_description": "You have recently requested to change the email address you use to log into your {{appName}} account. Please click the button below to confirm your new email address.", "verify_email_change_success_toast": "Updated your email to {{email}}", "verify_email_change_failure_toast": "Failed to update email.", @@ -70,6 +70,8 @@ "meeting_awaiting_payment": "Your meeting is awaiting payment", "dark_theme_contrast_error": "Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible.", "light_theme_contrast_error": "Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible.", + "event_type_color_light_theme_contrast_error": "Light Theme color doesn't pass contrast check. We recommend you change this color so your event types color will be more visible.", + "event_type_color_dark_theme_contrast_error": "Dark Theme color doesn't pass contrast check. We recommend you change this color so your event types color will be more visible.", "payment_not_created_error": "Payment could not be created", "couldnt_charge_card_error": "Could not charge card for Payment", "no_available_users_found_error": "No available users found. Could you try another time slot?", @@ -87,7 +89,7 @@ "missing_card_fields": "Missing card fields", "pay_now": "Pay now", "general_prompt": "General Prompt", - "begin_message":"Begin Message", + "begin_message": "Begin Message", "codebase_has_to_stay_opensource": "The codebase has to stay open source, whether it was modified or not", "cannot_repackage_codebase": "You can not repackage or sell the codebase", "acquire_license": "Acquire a commercial license to remove these terms by emailing", @@ -118,9 +120,9 @@ "event_still_awaiting_approval": "An event is still waiting for your approval", "booking_submitted_subject": "Booking Submitted: {{title}} at {{date}}", "download_recording_subject": "Download Recording: {{title}} at {{date}}", - "download_transcript_email_subject":"Download Transcript: {{title}} at {{date}}", + "download_transcript_email_subject": "Download Transcript: {{title}} at {{date}}", "download_your_recording": "Download your recording", - "download_your_transcripts":"Download your Transcripts", + "download_your_transcripts": "Download your Transcripts", "your_meeting_has_been_booked": "Your meeting has been booked", "event_type_has_been_rescheduled_on_time_date": "Your {{title}} has been rescheduled to {{date}}.", "event_has_been_rescheduled": "Updated - Your event has been rescheduled", @@ -171,7 +173,7 @@ "seat_options_doesnt_support_confirmation": "Seats option doesn't support confirmation requirement", "multilocation_doesnt_support_seats": "Multiple Locations doesn't support seats option", "no_show_fee_doesnt_support_seats": "No show fee doesn't support seats option", - "seats_option_doesnt_support_multi_location" : "Seats option doesn't support Multiple Locations", + "seats_option_doesnt_support_multi_location": "Seats option doesn't support Multiple Locations", "team_upgrade_seats_details": "Of the {{memberCount}} members in your team, {{unpaidCount}} seat(s) are unpaid. At ${{seatPrice}}/month per seat the estimated total cost of your membership is ${{totalCost}}/month.", "team_upgrade_banner_description": "You haven't finished your team setup. Your team \"{{teamName}}\" needs to be upgraded.", "upgrade_banner_action": "Upgrade here", @@ -222,7 +224,7 @@ "2fa_confirm_current_password": "Confirm your current password to get started.", "2fa_scan_image_or_use_code": "Scan the image below with the authenticator app on your phone or manually enter the text code instead.", "text": "Text", - "your_phone_number":"Your Phone Number", + "your_phone_number": "Your Phone Number", "multiline_text": "Multiline Text", "number": "Number", "checkbox": "Checkbox", @@ -776,6 +778,8 @@ "brand_color": "Brand Color", "light_brand_color": "Brand Color (Light Theme)", "dark_brand_color": "Brand Color (Dark Theme)", + "light_event_type_color": "Event Type Color (Light Theme)", + "dark_event_type_color": "Event Type Color (Dark Theme)", "file_not_named": "File is not named [idOrSlug]/[user]", "create_team": "Create Team", "name": "Name", @@ -1309,7 +1313,7 @@ "upgrade": "Upgrade", "upgrade_to_access_recordings_title": "Upgrade to access recordings", "upgrade_to_access_recordings_description": "Recordings are only available as part of our teams plan. Upgrade to start recording your calls", - "upgrade_to_cal_ai_phone_number_description":"Upgrade to Enterprise to generate an AI Agent phone number that can call guests to schedule calls", + "upgrade_to_cal_ai_phone_number_description": "Upgrade to Enterprise to generate an AI Agent phone number that can call guests to schedule calls", "recordings_are_part_of_the_teams_plan": "Recordings are part of the teams plan", "team_feature_teams": "This is a Team feature. Upgrade to Team to see your team's availability.", "team_feature_workflows": "This is a Team feature. Upgrade to Team to automate your event notifications and reminders with Workflows.", @@ -1465,7 +1469,7 @@ "download_transcript": "Download Transcript", "recording_from_your_recent_call": "A recording from your recent call on {{appName}} is ready for download", "transcript_from_previous_call": "Transcript from your recent call on {{appName}} is ready to download. Links are valid only for 1 Hour", - "link_valid_for_12_hrs":"Note: The download link is valid only for 12 hours. You can generate new download link by following instructions <1>here.", + "link_valid_for_12_hrs": "Note: The download link is valid only for 12 hours. You can generate new download link by following instructions <1>here.", "create_your_first_form": "Create your first form", "create_your_first_form_description": "With Routing Forms you can ask qualifying questions and route to the correct person or event type.", "create_your_first_webhook": "Create your first Webhook", @@ -2310,7 +2314,7 @@ "dont_want_to_wait": "Don't want to wait?", "meeting_started": "Meeting Started", "pay_and_book": "Pay to book", - "cal_ai_event_tab_description":"Let AI Agents book you", + "cal_ai_event_tab_description": "Let AI Agents book you", "booking_not_found_error": "Could not find booking", "booking_seats_full_error": "Booking seats are full", "missing_payment_credential_error": "Missing payment credentials", @@ -2359,7 +2363,11 @@ "Highest": "highest", "send_booker_to": "Send Booker to", "set_priority": "Set Priority", + "set_weight": "Set Weight", + "enable_weights": "Enable Weights", "priority_for_user": "Priority for {{userName}}", + "weights_description": "Weights determine how meetings are distributed among hosts. <1>Learn more", + "weight_for_user": "Weight for {{userName}}", "change_priority": "change priority", "field_identifiers_as_variables": "Use field identifiers as variables for your custom event redirect", "field_identifiers_as_variables_with_example": "Use field identifiers as variables for your custom event redirect (e.g. {{variable}})", @@ -2370,7 +2378,7 @@ "account_unlinked_success": "Account unlinked successfully", "account_unlinked_error": "There was an error unlinking the account", "travel_schedule": "Travel Schedule", - "travel_schedule_description": "Plan your trip ahead to keep your existing schedule in a different timezone and prevent being booked at midnight.", + "travel_schedule_description": "Plan your trip ahead to keep your existing schedule in a different timezone and prevent being booked at midnight.", "schedule_timezone_change": "Schedule timezone change", "date": "Date", "overlaps_with_existing_schedule": "This overlaps with an existing schedule. Please select a different date.", @@ -2399,11 +2407,11 @@ "email_team_invite|content|added_to_subteam": "{{invitedBy}} has added you to the team {{teamName}} in their organization {{parentTeamName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", "email_team_invite|content|invited_to_subteam": "{{invitedBy}} has invited you to join the team {{teamName}} in their organization {{parentTeamName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", "email_team_invite|content|invited_to_regular_team": "{{invitedBy}} has invited you to join their team {{teamName}} on {{appName}}. {{appName}} is the event-juggling scheduler that enables you and your team to schedule meetings without the email tennis.", - "email|existing_user_added_link_will_change":"On accepting the invite, your link will change to your organization domain but don't worry, all previous links will still work and redirect appropriately.

    Please note: All of your personal event types will be moved into the {teamName} organization, which may also include potential personal link.

    For personal events we recommend creating a new account with a personal email address.", - "email|existing_user_added_link_changed":"Your link has been changed from {prevLinkWithoutProtocol} to {newLinkWithoutProtocol} but don't worry, all previous links still work and redirect appropriately.

    Please note: All of your personal event types have been moved into the {teamName} organisation, which may also include potential personal link.

    Please log in and make sure you have no private events on your new organisational account.

    For personal events we recommend creating a new account with a personal email address.

    Enjoy your new clean link: {newLinkWithoutProtocol}", + "email|existing_user_added_link_will_change": "On accepting the invite, your link will change to your organization domain but don't worry, all previous links will still work and redirect appropriately.

    Please note: All of your personal event types will be moved into the {teamName} organization, which may also include potential personal link.

    For personal events we recommend creating a new account with a personal email address.", + "email|existing_user_added_link_changed": "Your link has been changed from {prevLinkWithoutProtocol} to {newLinkWithoutProtocol} but don't worry, all previous links still work and redirect appropriately.

    Please note: All of your personal event types have been moved into the {teamName} organisation, which may also include potential personal link.

    Please log in and make sure you have no private events on your new organisational account.

    For personal events we recommend creating a new account with a personal email address.

    Enjoy your new clean link: {newLinkWithoutProtocol}", "email_organization_created|subject": "Your organization has been created", - "your_current_plan":"Your current plan", - "organization_price_per_user_month":"$37 per user per month (30 seats minimum)", + "your_current_plan": "Your current plan", + "organization_price_per_user_month": "$37 per user per month (30 seats minimum)", "privacy_organization_description": "Manage privacy settings for your organization", "privacy": "Privacy", "team_will_be_under_org": "New teams will be under your organization", @@ -2472,10 +2480,10 @@ "review": "Review", "reviewed": "Reviewed", "unreviewed": "Unreviewed", - "rating_url_info":"The URL for Rating Feedback Form", - "no_show_url_info":"The URL for No Show Feedback", - "no_support_needed":"No Support Needed?", - "hide_support":"Hide Support", + "rating_url_info": "The URL for Rating Feedback Form", + "no_show_url_info": "The URL for No Show Feedback", + "no_support_needed": "No Support Needed?", + "hide_support": "Hide Support", "event_ratings": "Average Ratings", "event_no_show": "Host No Show", "recent_ratings": "Recent ratings", @@ -2496,6 +2504,8 @@ "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", "creating_oauth_client_error": "An error occurred while creating the OAuth client, please try again later", + "event_type_color": "Event type color", + "event_type_color_description": "This is only used for event type & booking differentiation within the app. It is not displayed to bookers.", "mark_as_no_show_title": "Mark as no show", "x_marked_as_no_show": "{{x}} marked as no-show", "x_unmarked_as_no_show": "{{x}} unmarked as no-show", @@ -2517,6 +2527,11 @@ "event_expired": "This event is expired", "skip_contact_creation": "Skip creating contacts if they do not exist in {{appName}} ", "skip_writing_to_calendar_note": "If your ICS link is read-only (e.g., Proton Calendar), check the box above to avoid errors. You'll also need to manually update your calendar for changes.", + "disable_all_emails_to_attendees": "Disable standard emails to attendees related to this event type", + "disable_all_emails_description": "Disables standard email communication related to this event type, including booking confirmations, reminders, and cancellations.", + "disable_all_emails_to_hosts": "Disable standard emails to hosts related to this event type", + "type_confirm_to_continue": "Type confirm to continue", + "disable_email": "Disable email", "grant_admin_api": "Grant Admin API Access", "revoke_admin_api": "Revoke Admin API Access", "apple_connect_atom_label": "Connect Apple Calendar", diff --git a/apps/web/test/lib/CheckForEmptyAssignment.test.ts b/apps/web/test/lib/CheckForEmptyAssignment.test.ts index e8508e70897ea4..eba9c22853b342 100644 --- a/apps/web/test/lib/CheckForEmptyAssignment.test.ts +++ b/apps/web/test/lib/CheckForEmptyAssignment.test.ts @@ -8,7 +8,7 @@ describe("Tests to Check if Event Types have empty Assignment", () => { checkForEmptyAssignment({ assignedUsers: [], assignAllTeamMembers: false, - hosts: [{ userId: 101, isFixed: false, priority: 2 }], + hosts: [{ userId: 101, isFixed: false, priority: 2, weight: 100, weightAdjustment: 0 }], isManagedEventType: true, }) ).toBe(true); @@ -61,7 +61,7 @@ describe("Tests to Check if Event Types have empty Assignment", () => { checkForEmptyAssignment({ assignedUsers: [], assignAllTeamMembers: false, - hosts: [{ userId: 101, isFixed: false, priority: 2 }], + hosts: [{ userId: 101, isFixed: false, priority: 2, weight: 100, weightAdjustment: 0 }], isManagedEventType: false, }) ).toBe(false); diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index 6f84b0928ab638..afc0abe74fbc21 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -146,6 +146,7 @@ describe("handleChildrenEventTypes", () => { bookingLimits: undefined, durationLimits: undefined, recurringEvent: undefined, + eventTypeColor: undefined, userId: 4, }, }); @@ -301,6 +302,7 @@ describe("handleChildrenEventTypes", () => { bookingLimits: undefined, durationLimits: undefined, recurringEvent: undefined, + eventTypeColor: undefined, hashedLink: undefined, lockTimeZoneToggleOnBookingPage: false, requiresBookerEmailVerification: false, @@ -417,6 +419,7 @@ describe("handleChildrenEventTypes", () => { bookingLimits: undefined, durationLimits: undefined, recurringEvent: undefined, + eventTypeColor: undefined, hashedLink: undefined, locations: [], lockTimeZoneToggleOnBookingPage: false, diff --git a/apps/web/test/lib/team-event-types.test.ts b/apps/web/test/lib/team-event-types.test.ts index e7cb1584ea3223..72df7a81522901 100644 --- a/apps/web/test/lib/team-event-types.test.ts +++ b/apps/web/test/lib/team-event-types.test.ts @@ -1,16 +1,17 @@ import prismaMock from "../../../../tests/libs/__mocks__/prismaMock"; -import { expect, it } from "vitest"; +import { expect, it, describe } from "vitest"; import { getLuckyUser } from "@calcom/lib/server"; -import { buildUser } from "@calcom/lib/test/builder"; +import { buildUser, buildBooking } from "@calcom/lib/test/builder"; +import { addWeightAdjustmentToNewHosts } from "@calcom/trpc/server/routers/viewer/eventTypes/util"; it("can find lucky user with maximize availability", async () => { const user1 = buildUser({ id: 1, username: "test1", name: "Test User 1", - email: "test@example.com", + email: "test1@example.com", bookings: [ { createdAt: new Date("2022-01-25T05:30:00.000Z"), @@ -24,7 +25,7 @@ it("can find lucky user with maximize availability", async () => { id: 2, username: "test2", name: "Test User 2", - email: "tes2t@example.com", + email: "test2@example.com", bookings: [ { createdAt: new Date("2022-01-25T04:30:00.000Z"), @@ -39,7 +40,11 @@ it("can find lucky user with maximize availability", async () => { await expect( getLuckyUser("MAXIMIZE_AVAILABILITY", { availableUsers: users, - eventTypeId: 1, + eventType: { + id: 1, + isRRWeightsEnabled: false, + }, + allRRHosts: [], }) ).resolves.toStrictEqual(users[1]); }); @@ -49,7 +54,7 @@ it("can find lucky user with maximize availability and priority ranking", async id: 1, username: "test1", name: "Test User 1", - email: "test@example.com", + email: "test1@example.com", priority: 2, bookings: [ { @@ -64,7 +69,7 @@ it("can find lucky user with maximize availability and priority ranking", async id: 2, username: "test2", name: "Test User 2", - email: "tes2t@example.com", + email: "test2@example.com", bookings: [ { createdAt: new Date("2022-01-25T04:30:00.000Z"), @@ -76,16 +81,16 @@ it("can find lucky user with maximize availability and priority ranking", async // TODO: we may be able to use native prisma generics somehow? prismaMock.user.findMany.mockResolvedValue(users); prismaMock.booking.findMany.mockResolvedValue([]); - const test = await getLuckyUser("MAXIMIZE_AVAILABILITY", { - availableUsers: users, - eventTypeId: 1, - }); // both users have medium priority (one user has no priority set, default to medium) so pick least recently booked await expect( getLuckyUser("MAXIMIZE_AVAILABILITY", { availableUsers: users, - eventTypeId: 1, + eventType: { + id: 1, + isRRWeightsEnabled: false, + }, + allRRHosts: [], }) ).resolves.toStrictEqual(users[1]); @@ -93,7 +98,7 @@ it("can find lucky user with maximize availability and priority ranking", async id: 1, username: "test1", name: "Test User 1", - email: "test@example.com", + email: "test1@example.com", priority: 0, bookings: [ { @@ -105,7 +110,7 @@ it("can find lucky user with maximize availability and priority ranking", async id: 2, username: "test2", name: "Test User 2", - email: "tes2t@example.com", + email: "test2@example.com", priority: 2, bookings: [ { @@ -118,7 +123,7 @@ it("can find lucky user with maximize availability and priority ranking", async id: 2, username: "test2", name: "Test User 2", - email: "tes2t@example.com", + email: "test2@example.com", priority: 4, bookings: [ { @@ -136,7 +141,11 @@ it("can find lucky user with maximize availability and priority ranking", async await expect( getLuckyUser("MAXIMIZE_AVAILABILITY", { availableUsers: usersWithPriorities, - eventTypeId: 1, + eventType: { + id: 1, + isRRWeightsEnabled: false, + }, + allRRHosts: [], }) ).resolves.toStrictEqual(usersWithPriorities[2]); @@ -144,7 +153,7 @@ it("can find lucky user with maximize availability and priority ranking", async id: 1, username: "test1", name: "Test User 1", - email: "test@example.com", + email: "test1@example.com", priority: 0, bookings: [ { @@ -169,7 +178,7 @@ it("can find lucky user with maximize availability and priority ranking", async id: 3, username: "test3", name: "Test User 3", - email: "test3t@example.com", + email: "test3@example.com", priority: 3, bookings: [ { @@ -187,7 +196,475 @@ it("can find lucky user with maximize availability and priority ranking", async await expect( getLuckyUser("MAXIMIZE_AVAILABILITY", { availableUsers: usersWithSamePriorities, - eventTypeId: 1, + eventType: { + id: 1, + isRRWeightsEnabled: false, + }, + allRRHosts: [], }) ).resolves.toStrictEqual(usersWithSamePriorities[1]); }); + +describe("maximize availability and weights", () => { + it("can find lucky user if hosts have same weights", async () => { + const user1 = buildUser({ + id: 1, + username: "test1", + name: "Test User 1", + email: "test1@example.com", + priority: 3, + weight: 100, + bookings: [ + { + createdAt: new Date("2022-01-25T06:30:00.000Z"), + }, + { + createdAt: new Date("2022-01-25T03:30:00.000Z"), + }, + ], + }); + const user2 = buildUser({ + id: 2, + username: "test2", + name: "Test User 2", + email: "test2@example.com", + priority: 3, + weight: 100, + bookings: [ + { + createdAt: new Date("2022-01-25T05:30:00.000Z"), + }, + { + createdAt: new Date("2022-01-25T04:30:00.000Z"), + }, + ], + }); + + prismaMock.user.findMany.mockResolvedValue([user1, user2]); + + prismaMock.booking.findMany.mockResolvedValue([ + buildBooking({ + id: 1, + userId: 1, + createdAt: new Date("2022-01-25T06:30:00.000Z"), + }), + buildBooking({ + id: 2, + userId: 1, + createdAt: new Date("2022-01-25T03:30:00.000Z"), + }), + buildBooking({ + id: 3, + userId: 2, + createdAt: new Date("2022-01-25T05:30:00.000Z"), + }), + buildBooking({ + id: 4, + userId: 2, + createdAt: new Date("2022-01-25T04:30:00.000Z"), + }), + ]); + + const allRRHosts = [ + { + user: { id: user1.id, email: user1.email }, + weight: user1.weight, + weightAdjustment: user1.weightAdjustment, + }, + { + user: { id: user2.id, email: user2.email }, + weight: user2.weight, + weightAdjustment: user2.weightAdjustment, + }, + ]; + + await expect( + getLuckyUser("MAXIMIZE_AVAILABILITY", { + availableUsers: [user1, user2], + eventType: { + id: 1, + isRRWeightsEnabled: true, + }, + allRRHosts, + }) + ).resolves.toStrictEqual(user2); + }); + + it("can find lucky user if hosts have different weights", async () => { + const user1 = buildUser({ + id: 1, + username: "test1", + name: "Test User 1", + email: "test1@example.com", + priority: 3, + weight: 200, + bookings: [ + { + createdAt: new Date("2022-01-25T08:30:00.000Z"), + }, + { + createdAt: new Date("2022-01-25T07:30:00.000Z"), + }, + { + createdAt: new Date("2022-01-25T05:30:00.000Z"), + }, + ], + }); + const user2 = buildUser({ + id: 2, + username: "test2", + name: "Test User 2", + email: "test2@example.com", + priority: 3, + weight: 100, + bookings: [ + { + createdAt: new Date("2022-01-25T06:30:00.000Z"), + }, + { + createdAt: new Date("2022-01-25T03:30:00.000Z"), + }, + ], + }); + + prismaMock.user.findMany.mockResolvedValue([user1, user2]); + + prismaMock.booking.findMany.mockResolvedValue([ + buildBooking({ + id: 1, + userId: 1, + createdAt: new Date("2022-01-25T08:30:00.000Z"), + }), + buildBooking({ + id: 2, + userId: 1, + createdAt: new Date("2022-01-25T07:30:00.000Z"), + }), + buildBooking({ + id: 3, + userId: 1, + createdAt: new Date("2022-01-25T05:30:00.000Z"), + }), + buildBooking({ + id: 4, + userId: 2, + createdAt: new Date("2022-01-25T06:30:00.000Z"), + }), + buildBooking({ + id: 4, + userId: 2, + createdAt: new Date("2022-01-25T03:30:00.000Z"), + }), + ]); + + const allRRHosts = [ + { + user: { id: user1.id, email: user1.email }, + weight: user1.weight, + weightAdjustment: user1.weightAdjustment, + }, + { + user: { id: user2.id, email: user2.email }, + weight: user2.weight, + weightAdjustment: user2.weightAdjustment, + }, + ]; + + await expect( + getLuckyUser("MAXIMIZE_AVAILABILITY", { + availableUsers: [user1, user2], + eventType: { + id: 1, + isRRWeightsEnabled: true, + }, + allRRHosts, + }) + ).resolves.toStrictEqual(user1); + }); + + it("can find lucky user with weights and adjusted weights", async () => { + const user1 = buildUser({ + id: 1, + username: "test1", + name: "Test User 1", + email: "test1@example.com", + priority: 3, + weight: 150, + weightAdjustment: 4, // + 3 = 7 bookings + bookings: [ + { + createdAt: new Date("2022-01-25T07:30:00.000Z"), + }, + { + createdAt: new Date("2022-01-25T05:30:00.000Z"), + }, + { + createdAt: new Date("2022-01-25T03:30:00.000Z"), + }, + ], + }); + const user2 = buildUser({ + id: 2, + username: "test2", + name: "Test User 2", + email: "test2@example.com", + priority: 3, + weight: 100, + weightAdjustment: 3, // + 2 = 5 bookings + bookings: [ + { + createdAt: new Date("2022-01-25T06:30:00.000Z"), + }, + { + createdAt: new Date("2022-01-25T03:30:00.000Z"), + }, + ], + }); + + prismaMock.user.findMany.mockResolvedValue([user1, user2]); + + prismaMock.booking.findMany.mockResolvedValue([ + buildBooking({ + id: 1, + userId: 1, + createdAt: new Date("2022-01-25T05:30:00.000Z"), + }), + buildBooking({ + id: 2, + userId: 1, + createdAt: new Date("2022-01-25T03:30:00.000Z"), + }), + buildBooking({ + id: 3, + userId: 1, + createdAt: new Date("2022-01-25T07:30:00.000Z"), + }), + buildBooking({ + id: 4, + userId: 2, + createdAt: new Date("2022-01-25T06:30:00.000Z"), + }), + buildBooking({ + id: 4, + userId: 2, + createdAt: new Date("2022-01-25T03:30:00.000Z"), + }), + ]); + + const allRRHosts = [ + { + user: { id: user1.id, email: user1.email }, + weight: user1.weight, + weightAdjustment: user1.weightAdjustment, + }, + { + user: { id: user2.id, email: user2.email }, + weight: user2.weight, + weightAdjustment: user2.weightAdjustment, + }, + ]; + + await expect( + getLuckyUser("MAXIMIZE_AVAILABILITY", { + availableUsers: [user1, user2], + eventType: { + id: 1, + isRRWeightsEnabled: true, + }, + allRRHosts, + }) + ).resolves.toStrictEqual(user1); + }); +}); + +function convertHostsToUsers( + hosts: { + userId: number; + isFixed: boolean; + priority: number; + weight: number; + weightAdjustment?: number; + newHost?: boolean; + }[] +) { + return hosts.map((host) => { + return { + id: host.userId, + email: `test${host.userId}@example.com`, + hosts: host.newHost + ? [] + : [ + { + isFixed: host.isFixed, + priority: host.priority, + weightAdjustment: host.weightAdjustment, + weight: host.weight, + }, + ], + }; + }); +} + +describe("addWeightAdjustmentToNewHosts", () => { + it("weight adjustment is correctly added to host with two hosts that have the same weight", async () => { + const hosts = [ + { + userId: 1, + isFixed: false, + priority: 2, + weight: 100, + }, + { + userId: 2, + isFixed: false, + priority: 2, + weight: 100, + newHost: true, + }, + ]; + + const users = convertHostsToUsers(hosts); + + prismaMock.user.findMany.mockResolvedValue(users); + + // mock for allBookings (for ongoing RR hosts) + prismaMock.booking.findMany + .mockResolvedValueOnce([ + buildBooking({ + id: 1, + userId: 1, + }), + buildBooking({ + id: 2, + userId: 1, + }), + buildBooking({ + id: 3, + userId: 1, + }), + buildBooking({ + id: 4, + userId: 1, + }), + ]) + // mock for bookings of new RR host + .mockResolvedValueOnce([ + buildBooking({ + id: 5, + userId: 2, + }), + ]); + + const hostsWithAdjustedWeight = await addWeightAdjustmentToNewHosts({ + hosts, + isWeightsEnabled: true, + eventTypeId: 1, + prisma: prismaMock, + }); + /* + both users have weight 100, user1 has 4 bookings user 2 has 1 bookings already + */ + expect(hostsWithAdjustedWeight.find((host) => host.userId === 2)?.weightAdjustment).toBe(3); + }); + + it("weight adjustment is correctly added to host with several hosts that have different weights", async () => { + // make different weights + const hosts = [ + { + userId: 1, + isFixed: false, + priority: 2, + weight: 100, + }, + { + userId: 2, + isFixed: false, + priority: 2, + weight: 200, + newHost: true, + }, + { + userId: 3, + isFixed: false, + priority: 2, + weight: 200, + }, + { + userId: 4, + isFixed: false, + priority: 2, + weight: 100, + }, + { + userId: 5, + isFixed: false, + priority: 2, + weight: 50, + newHost: true, + }, + ]; + + const users = convertHostsToUsers(hosts); + + prismaMock.user.findMany.mockResolvedValue(users); + + // mock for allBookings (for ongoing RR hosts) + prismaMock.booking.findMany + .mockResolvedValueOnce([ + // 8 bookings for ongoing hosts (hosts that already existed before) + buildBooking({ + id: 1, + userId: 1, + }), + buildBooking({ + id: 2, + userId: 1, + }), + buildBooking({ + id: 3, + userId: 3, + }), + buildBooking({ + id: 4, + userId: 3, + }), + buildBooking({ + id: 5, + userId: 4, + }), + buildBooking({ + id: 6, + userId: 4, + }), + buildBooking({ + id: 7, + userId: 4, + }), + buildBooking({ + id: 8, + userId: 4, + }), + ]) + // mock for bookings of new RR host + .mockResolvedValueOnce([ + buildBooking({ + id: 5, + userId: 2, + }), + ]) + .mockResolvedValue([]); + + const hostsWithAdjustedWeight = await addWeightAdjustmentToNewHosts({ + hosts, + isWeightsEnabled: true, + eventTypeId: 1, + prisma: prismaMock, + }); + + // 8 bookings from ongoing hosts, 400 total weight --> average 0.02 bookings per weight unit --> 0.02 * 200 = 4 - 1 prev bookings = 3 + expect(hostsWithAdjustedWeight.find((host) => host.userId === 2)?.weightAdjustment).toBe(3); + + // 8 bookings from ongoing hosts, 400 total weight --> average 0.02 bookings per weight unit --> 0.02 * 50 = 1 (no prev bookings) + expect(hostsWithAdjustedWeight.find((host) => host.userId === 5)?.weightAdjustment).toBe(1); + }); +}); diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 035ed74826373b..48868bdafa5ec1 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -185,6 +185,7 @@ type WhiteListedBookingProps = { credentialId?: number | null; })[]; bookingSeat?: Prisma.BookingSeatCreateInput[]; + createdAt?: string; }; type InputBooking = Partial> & WhiteListedBookingProps; diff --git a/package.json b/package.json index 42be9cf6945937..2ab82dcd27ffbf 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ }, "dependencies": { "@daily-co/daily-js": "^0.59.0", + "@next/third-parties": "^14.2.5", "@vercel/functions": "^1.4.0", "city-timezones": "^1.2.1", "eslint": "^8.34.0", diff --git a/packages/app-store/routing-forms/components/SingleForm.tsx b/packages/app-store/routing-forms/components/SingleForm.tsx index aa10c4acd539a9..357cadb5f92596 100644 --- a/packages/app-store/routing-forms/components/SingleForm.tsx +++ b/packages/app-store/routing-forms/components/SingleForm.tsx @@ -368,7 +368,9 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF value={sendUpdatesTo.map((userId) => ({ isFixed: true, userId: userId, - priority: 1, + priority: 2, + weight: 100, + weightAdjustment: 0, }))} onChange={(value) => { hookForm.setValue( diff --git a/packages/app-store/stripepayment/lib/PaymentService.ts b/packages/app-store/stripepayment/lib/PaymentService.ts index 5218ac81800b9c..89c78ddb85b456 100644 --- a/packages/app-store/stripepayment/lib/PaymentService.ts +++ b/packages/app-store/stripepayment/lib/PaymentService.ts @@ -9,6 +9,7 @@ import { getErrorFromUnknown } from "@calcom/lib/errors"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; @@ -336,22 +337,26 @@ export class PaymentService implements IAbstractPaymentService { startTime: { toISOString: () => string }; uid: string; }, - paymentData: Payment + paymentData: Payment, + eventTypeMetadata?: EventTypeMetadata ): Promise { - await sendAwaitingPaymentEmail({ - ...event, - paymentInfo: { - link: createPaymentLink({ - paymentUid: paymentData.uid, - name: booking.user?.name, - email: booking.user?.email, - date: booking.startTime.toISOString(), - }), - paymentOption: paymentData.paymentOption || "ON_BOOKING", - amount: paymentData.amount, - currency: paymentData.currency, + await sendAwaitingPaymentEmail( + { + ...event, + paymentInfo: { + link: createPaymentLink({ + paymentUid: paymentData.uid, + name: booking.user?.name, + email: booking.user?.email, + date: booking.startTime.toISOString(), + }), + paymentOption: paymentData.paymentOption || "ON_BOOKING", + amount: paymentData.amount, + currency: paymentData.currency, + }, }, - }); + eventTypeMetadata + ); } async deletePayment(paymentId: Payment["id"]): Promise { diff --git a/packages/app-store/vital/lib/reschedule.ts b/packages/app-store/vital/lib/reschedule.ts index c800597dcb99e3..4d34043d057a6a 100644 --- a/packages/app-store/vital/lib/reschedule.ts +++ b/packages/app-store/vital/lib/reschedule.ts @@ -10,6 +10,7 @@ import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { Person } from "@calcom/types/Calendar"; import { getCalendar } from "../../_utils/getCalendar"; @@ -41,6 +42,11 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { destinationCalendar: true, }, }, + eventType: { + select: { + metadata: true, + }, + }, }, where: { uid: bookingUid, @@ -141,9 +147,13 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { // Send emails try { - await sendRequestRescheduleEmail(builder.calendarEvent, { - rescheduleLink: builder.rescheduleLink, - }); + await sendRequestRescheduleEmail( + builder.calendarEvent, + { + rescheduleLink: builder.rescheduleLink, + }, + bookingToReschedule?.eventType?.metadata as EventTypeMetadata + ); } catch (error) { if (error instanceof Error) { logger.error(error.message); diff --git a/packages/app-store/wipemycalother/lib/reschedule.ts b/packages/app-store/wipemycalother/lib/reschedule.ts index ff8d2afe28a327..91f3da71408c60 100644 --- a/packages/app-store/wipemycalother/lib/reschedule.ts +++ b/packages/app-store/wipemycalother/lib/reschedule.ts @@ -10,6 +10,7 @@ import logger from "@calcom/lib/logger"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { Person } from "@calcom/types/Calendar"; import { getCalendar } from "../../_utils/getCalendar"; @@ -41,6 +42,11 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { destinationCalendar: true, }, }, + eventType: { + select: { + metadata: true, + }, + }, }, where: { uid: bookingUid, @@ -141,9 +147,13 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => { // Send emails try { - await sendRequestRescheduleEmail(builder.calendarEvent, { - rescheduleLink: builder.rescheduleLink, - }); + await sendRequestRescheduleEmail( + builder.calendarEvent, + { + rescheduleLink: builder.rescheduleLink, + }, + bookingToReschedule?.eventType?.metadata as EventTypeMetadata + ); } catch (error) { if (error instanceof Error) { logger.error(error.message); diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index e5fc5a5aa5a4d1..1803c58b092e8a 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -170,6 +170,7 @@ export default class EventManager { if (evt.location === MeetLocationType && mainHostDestinationCalendar?.integration !== "google_calendar") { log.warn("Falling back to Cal Video integration as Google Calendar not installed"); evt["location"] = "integrations:daily"; + evt["conferenceCredentialId"] = undefined; } const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null; diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 484464efb46fc4..525954819c9a5b 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line no-restricted-imports import { cloneDeep } from "lodash"; import type { TFunction } from "next-i18next"; +import type { z } from "zod"; import type { EventNameObjectType } from "@calcom/core/event"; import { getEventName } from "@calcom/core/event"; @@ -8,6 +9,7 @@ import type BaseEmail from "@calcom/emails/templates/_base-email"; import { formatCalEvent } from "@calcom/lib/formatCalendarEvent"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; +import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import type { MonthlyDigestEmailData } from "./src/templates/MonthlyDigestEmail"; @@ -61,6 +63,8 @@ import SlugReplacementEmail from "./templates/slug-replacement-email"; import type { TeamInvite } from "./templates/team-invite-email"; import TeamInviteEmail from "./templates/team-invite-email"; +type EventTypeMetadata = z.infer; + const sendEmail = (prepare: () => BaseEmail) => { return new Promise((resolve, reject) => { try { @@ -72,16 +76,25 @@ const sendEmail = (prepare: () => BaseEmail) => { }); }; +const eventTypeDisableAttendeeEmail = (metadata?: EventTypeMetadata) => { + return !!metadata?.disableStandardEmails?.all?.attendee; +}; + +const eventTypeDisableHostEmail = (metadata?: EventTypeMetadata) => { + return !!metadata?.disableStandardEmails?.all?.host; +}; + export const sendScheduledEmails = async ( calEvent: CalendarEvent, eventNameObject?: EventNameObjectType, hostEmailDisabled?: boolean, - attendeeEmailDisabled?: boolean + attendeeEmailDisabled?: boolean, + eventTypeMetadata?: EventTypeMetadata ) => { const formattedCalEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; - if (!hostEmailDisabled) { + if (!hostEmailDisabled && !eventTypeDisableHostEmail(eventTypeMetadata)) { emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent: formattedCalEvent }))); if (formattedCalEvent.team) { @@ -93,7 +106,7 @@ export const sendScheduledEmails = async ( } } - if (!attendeeEmailDisabled) { + if (!attendeeEmailDisabled && !eventTypeDisableAttendeeEmail(eventTypeMetadata)) { emailsToSend.push( ...formattedCalEvent.attendees.map((attendee) => { return sendEmail( @@ -117,7 +130,12 @@ export const sendScheduledEmails = async ( }; // for rescheduled round robin booking that assigned new members -export const sendRoundRobinScheduledEmails = async (calEvent: CalendarEvent, members: Person[]) => { +export const sendRoundRobinScheduledEmails = async ( + calEvent: CalendarEvent, + members: Person[], + eventTypeMetadata?: EventTypeMetadata +) => { + if (eventTypeDisableHostEmail(eventTypeMetadata)) return; const formattedCalEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; @@ -130,7 +148,12 @@ export const sendRoundRobinScheduledEmails = async (calEvent: CalendarEvent, mem await Promise.all(emailsToSend); }; -export const sendRoundRobinRescheduledEmails = async (calEvent: CalendarEvent, members: Person[]) => { +export const sendRoundRobinRescheduledEmails = async ( + calEvent: CalendarEvent, + members: Person[], + eventTypeMetadata?: EventTypeMetadata +) => { + if (eventTypeDisableHostEmail(eventTypeMetadata)) return; const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; @@ -143,7 +166,12 @@ export const sendRoundRobinRescheduledEmails = async (calEvent: CalendarEvent, m await Promise.all(emailsToSend); }; -export const sendRoundRobinCancelledEmails = async (calEvent: CalendarEvent, members: Person[]) => { +export const sendRoundRobinCancelledEmails = async ( + calEvent: CalendarEvent, + members: Person[], + eventTypeMetadata?: EventTypeMetadata +) => { + if (eventTypeDisableHostEmail(eventTypeMetadata)) return; const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; @@ -154,37 +182,50 @@ export const sendRoundRobinCancelledEmails = async (calEvent: CalendarEvent, mem await Promise.all(emailsToSend); }; -export const sendRescheduledEmails = async (calEvent: CalendarEvent) => { +export const sendRescheduledEmails = async ( + calEvent: CalendarEvent, + eventTypeMetadata?: EventTypeMetadata +) => { const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; - emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent }))); + if (!eventTypeDisableHostEmail(eventTypeMetadata)) { + emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent }))); - if (calendarEvent.team) { - for (const teamMember of calendarEvent.team.members) { - emailsToSend.push( - sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent, teamMember })) - ); + if (calendarEvent.team) { + for (const teamMember of calendarEvent.team.members) { + emailsToSend.push( + sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent, teamMember })) + ); + } } } - emailsToSend.push( - ...calendarEvent.attendees.map((attendee) => { - return sendEmail(() => new AttendeeRescheduledEmail(calendarEvent, attendee)); - }) - ); + if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) { + emailsToSend.push( + ...calendarEvent.attendees.map((attendee) => { + return sendEmail(() => new AttendeeRescheduledEmail(calendarEvent, attendee)); + }) + ); + } await Promise.all(emailsToSend); }; -export const sendRescheduledSeatEmail = async (calEvent: CalendarEvent, attendee: Person) => { +export const sendRescheduledSeatEmail = async ( + calEvent: CalendarEvent, + attendee: Person, + eventTypeMetadata?: EventTypeMetadata +) => { const calendarEvent = formatCalEvent(calEvent); const clonedCalEvent = cloneDeep(calendarEvent); - const emailsToSend: Promise[] = [ - sendEmail(() => new AttendeeRescheduledEmail(clonedCalEvent, attendee)), - sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent })), - ]; + const emailsToSend: Promise[] = []; + + if (!eventTypeDisableHostEmail(eventTypeMetadata)) + emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent }))); + if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) + emailsToSend.push(sendEmail(() => new AttendeeRescheduledEmail(clonedCalEvent, attendee))); await Promise.all(emailsToSend); }; @@ -195,13 +236,14 @@ export const sendScheduledSeatsEmails = async ( newSeat: boolean, showAttendees: boolean, hostEmailDisabled?: boolean, - attendeeEmailDisabled?: boolean + attendeeEmailDisabled?: boolean, + eventTypeMetadata?: EventTypeMetadata ) => { const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; - if (!hostEmailDisabled) { + if (!hostEmailDisabled && !eventTypeDisableHostEmail(eventTypeMetadata)) { emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent: calendarEvent, newSeat }))); if (calendarEvent.team) { @@ -213,7 +255,7 @@ export const sendScheduledSeatsEmails = async ( } } - if (!attendeeEmailDisabled) { + if (!attendeeEmailDisabled && !eventTypeDisableAttendeeEmail(eventTypeMetadata)) { emailsToSend.push( sendEmail( () => @@ -231,16 +273,30 @@ export const sendScheduledSeatsEmails = async ( await Promise.all(emailsToSend); }; -export const sendCancelledSeatEmails = async (calEvent: CalendarEvent, cancelledAttendee: Person) => { +export const sendCancelledSeatEmails = async ( + calEvent: CalendarEvent, + cancelledAttendee: Person, + eventTypeMetadata?: EventTypeMetadata +) => { const formattedCalEvent = formatCalEvent(calEvent); const clonedCalEvent = cloneDeep(formattedCalEvent); - await Promise.all([ - sendEmail(() => new AttendeeCancelledSeatEmail(clonedCalEvent, cancelledAttendee)), - sendEmail(() => new OrganizerAttendeeCancelledSeatEmail({ calEvent: formattedCalEvent })), - ]); + const emailsToSend: Promise[] = []; + + if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) + emailsToSend.push(sendEmail(() => new AttendeeCancelledSeatEmail(clonedCalEvent, cancelledAttendee))); + if (!eventTypeDisableHostEmail(eventTypeMetadata)) + emailsToSend.push( + sendEmail(() => new OrganizerAttendeeCancelledSeatEmail({ calEvent: formattedCalEvent })) + ); + + await Promise.all(emailsToSend); }; -export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => { +export const sendOrganizerRequestEmail = async ( + calEvent: CalendarEvent, + eventTypeMetadata?: EventTypeMetadata +) => { + if (eventTypeDisableHostEmail(eventTypeMetadata)) return; const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; @@ -256,12 +312,18 @@ export const sendOrganizerRequestEmail = async (calEvent: CalendarEvent) => { await Promise.all(emailsToSend); }; -export const sendAttendeeRequestEmail = async (calEvent: CalendarEvent, attendee: Person) => { +export const sendAttendeeRequestEmail = async ( + calEvent: CalendarEvent, + attendee: Person, + eventTypeMetadata?: EventTypeMetadata +) => { + if (eventTypeDisableAttendeeEmail(eventTypeMetadata)) return; const calendarEvent = formatCalEvent(calEvent); await sendEmail(() => new AttendeeRequestEmail(calendarEvent, attendee)); }; -export const sendDeclinedEmails = async (calEvent: CalendarEvent) => { +export const sendDeclinedEmails = async (calEvent: CalendarEvent, eventTypeMetadata?: EventTypeMetadata) => { + if (eventTypeDisableAttendeeEmail(eventTypeMetadata)) return; const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; @@ -276,57 +338,67 @@ export const sendDeclinedEmails = async (calEvent: CalendarEvent) => { export const sendCancelledEmails = async ( calEvent: CalendarEvent, - eventNameObject: Pick + eventNameObject: Pick, + eventTypeMetadata?: EventTypeMetadata ) => { const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; - - emailsToSend.push(sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent }))); const calEventLength = calendarEvent.length; + const eventDuration = calEventLength as number; + if (typeof calEventLength !== "number") { logger.error( "`calEventLength` is not a number", safeStringify({ calEventLength, calEventTitle: calEvent.title, bookingId: calEvent.bookingId }) ); } - const eventDuration = calEventLength as number; - if (calendarEvent.team?.members) { - for (const teamMember of calendarEvent.team.members) { - emailsToSend.push( - sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent, teamMember })) - ); + if (!eventTypeDisableHostEmail(eventTypeMetadata)) { + emailsToSend.push(sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent }))); + + if (calendarEvent.team?.members) { + for (const teamMember of calendarEvent.team.members) { + emailsToSend.push( + sendEmail(() => new OrganizerCancelledEmail({ calEvent: calendarEvent, teamMember })) + ); + } } } - emailsToSend.push( - ...calendarEvent.attendees.map((attendee) => { - return sendEmail( - () => - new AttendeeCancelledEmail( - { - ...calendarEvent, - title: getEventName({ - ...eventNameObject, - t: attendee.language.translate, - attendeeName: attendee.name, - host: calendarEvent.organizer.name, - eventType: calendarEvent.type, - eventDuration, - ...(calendarEvent.responses && { bookingFields: calendarEvent.responses }), - ...(calendarEvent.location && { location: calendarEvent.location }), - }), - }, - attendee - ) - ); - }) - ); + if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) { + emailsToSend.push( + ...calendarEvent.attendees.map((attendee) => { + return sendEmail( + () => + new AttendeeCancelledEmail( + { + ...calendarEvent, + title: getEventName({ + ...eventNameObject, + t: attendee.language.translate, + attendeeName: attendee.name, + host: calendarEvent.organizer.name, + eventType: calendarEvent.type, + eventDuration, + ...(calendarEvent.responses && { bookingFields: calendarEvent.responses }), + ...(calendarEvent.location && { location: calendarEvent.location }), + }), + }, + attendee + ) + ); + }) + ); + } await Promise.all(emailsToSend); }; -export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent) => { +export const sendOrganizerRequestReminderEmail = async ( + calEvent: CalendarEvent, + eventTypeMetadata?: EventTypeMetadata +) => { + if (eventTypeDisableHostEmail(eventTypeMetadata)) return; const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; @@ -342,7 +414,11 @@ export const sendOrganizerRequestReminderEmail = async (calEvent: CalendarEvent) } }; -export const sendAwaitingPaymentEmail = async (calEvent: CalendarEvent) => { +export const sendAwaitingPaymentEmail = async ( + calEvent: CalendarEvent, + eventTypeMetadata?: EventTypeMetadata +) => { + if (eventTypeDisableAttendeeEmail(eventTypeMetadata)) return; const emailsToSend: Promise[] = []; emailsToSend.push( @@ -398,38 +474,49 @@ export const sendChangeOfEmailVerificationLink = async (verificationInput: Chang export const sendRequestRescheduleEmail = async ( calEvent: CalendarEvent, - metadata: { rescheduleLink: string } + metadata: { rescheduleLink: string }, + eventTypeMetadata?: EventTypeMetadata ) => { const emailsToSend: Promise[] = []; const calendarEvent = formatCalEvent(calEvent); - emailsToSend.push(sendEmail(() => new OrganizerRequestedToRescheduleEmail(calendarEvent, metadata))); - - emailsToSend.push(sendEmail(() => new AttendeeWasRequestedToRescheduleEmail(calendarEvent, metadata))); + if (!eventTypeDisableHostEmail(eventTypeMetadata)) { + emailsToSend.push(sendEmail(() => new OrganizerRequestedToRescheduleEmail(calendarEvent, metadata))); + } + if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) { + emailsToSend.push(sendEmail(() => new AttendeeWasRequestedToRescheduleEmail(calendarEvent, metadata))); + } await Promise.all(emailsToSend); }; -export const sendLocationChangeEmails = async (calEvent: CalendarEvent) => { +export const sendLocationChangeEmails = async ( + calEvent: CalendarEvent, + eventTypeMetadata?: EventTypeMetadata +) => { const calendarEvent = formatCalEvent(calEvent); const emailsToSend: Promise[] = []; - emailsToSend.push(sendEmail(() => new OrganizerLocationChangeEmail({ calEvent: calendarEvent }))); + if (!eventTypeDisableHostEmail(eventTypeMetadata)) { + emailsToSend.push(sendEmail(() => new OrganizerLocationChangeEmail({ calEvent: calendarEvent }))); - if (calendarEvent.team?.members) { - for (const teamMember of calendarEvent.team.members) { - emailsToSend.push( - sendEmail(() => new OrganizerLocationChangeEmail({ calEvent: calendarEvent, teamMember })) - ); + if (calendarEvent.team?.members) { + for (const teamMember of calendarEvent.team.members) { + emailsToSend.push( + sendEmail(() => new OrganizerLocationChangeEmail({ calEvent: calendarEvent, teamMember })) + ); + } } } - emailsToSend.push( - ...calendarEvent.attendees.map((attendee) => { - return sendEmail(() => new AttendeeLocationChangeEmail(calendarEvent, attendee)); - }) - ); + if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) { + emailsToSend.push( + ...calendarEvent.attendees.map((attendee) => { + return sendEmail(() => new AttendeeLocationChangeEmail(calendarEvent, attendee)); + }) + ); + } await Promise.all(emailsToSend); }; @@ -476,7 +563,12 @@ export const sendSlugReplacementEmail = async ({ await sendEmail(() => new SlugReplacementEmail(email, name, teamName, slug, t)); }; -export const sendNoShowFeeChargedEmail = async (attendee: Person, evt: CalendarEvent) => { +export const sendNoShowFeeChargedEmail = async ( + attendee: Person, + evt: CalendarEvent, + eventTypeMetadata?: EventTypeMetadata +) => { + if (eventTypeDisableAttendeeEmail(eventTypeMetadata)) return; await sendEmail(() => new NoShowFeeChargedEmail(evt, attendee)); }; diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index dff904dc2774b5..2e645671ca6f58 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -531,17 +531,19 @@ export const AUTH_OPTIONS: AuthOptions = { belongsToActiveTeam, // All organizations in the token would be too big to store. It breaks the sessions request. // So, we just set the currently switched organization only here. - org: profileOrg - ? { - id: profileOrg.id, - name: profileOrg.name, - slug: profileOrg.slug ?? profileOrg.requestedSlug ?? "", - logoUrl: profileOrg.logoUrl, - fullDomain: getOrgFullOrigin(profileOrg.slug ?? profileOrg.requestedSlug ?? ""), - domainSuffix: subdomainSuffix(), - role: orgRole as MembershipRole, // It can't be undefined if we have a profileOrg - } - : null, + // platform org user don't need profiles nor domains + org: + profileOrg && !profileOrg.isPlatform + ? { + id: profileOrg.id, + name: profileOrg.name, + slug: profileOrg.slug ?? profileOrg.requestedSlug ?? "", + logoUrl: profileOrg.logoUrl, + fullDomain: getOrgFullOrigin(profileOrg.slug ?? profileOrg.requestedSlug ?? ""), + domainSuffix: subdomainSuffix(), + role: orgRole as MembershipRole, // It can't be undefined if we have a profileOrg + } + : null, } as JWT; }; if (!user) { diff --git a/packages/features/bookings/lib/handleBookingRequested.ts b/packages/features/bookings/lib/handleBookingRequested.ts index c6869c64fdbde6..9252315690665a 100644 --- a/packages/features/bookings/lib/handleBookingRequested.ts +++ b/packages/features/bookings/lib/handleBookingRequested.ts @@ -1,3 +1,5 @@ +import type { Prisma } from "@prisma/client"; + import { sendAttendeeRequestEmail, sendOrganizerRequestEmail } from "@calcom/emails"; import { getWebhookPayloadForBooking } from "@calcom/features/bookings/lib/getWebhookPayloadForBooking"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; @@ -6,6 +8,7 @@ import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; const log = logger.getSubLogger({ prefix: ["[handleBookingRequested] book:user"] }); @@ -28,6 +31,7 @@ export async function handleBookingRequested(args: { requiresConfirmation: boolean; title: string; teamId?: number | null; + metadata: Prisma.JsonValue; } | null; eventTypeId: number | null; userId: number | null; @@ -37,8 +41,12 @@ export async function handleBookingRequested(args: { const { evt, booking } = args; log.debug("Emails: Sending booking requested emails"); - await sendOrganizerRequestEmail({ ...evt }); - await sendAttendeeRequestEmail({ ...evt }, evt.attendees[0]); + await sendOrganizerRequestEmail({ ...evt }, booking?.eventType?.metadata as EventTypeMetadata); + await sendAttendeeRequestEmail( + { ...evt }, + evt.attendees[0], + booking?.eventType?.metadata as EventTypeMetadata + ); const orgId = await getOrgIdFromMemberOrTeamId({ memberId: booking.userId, diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 48d84cf2f77b5b..6a20fb5201ba85 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -26,6 +26,7 @@ import type { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { EventTypeMetaDataSchema, schemaBookingCancelParams } from "@calcom/prisma/zod-utils"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import { deleteAllWorkflowReminders, getAllWorkflowsFromEventType, @@ -320,7 +321,11 @@ async function handler(req: CustomRequest) { const dataForWebhooks = { evt, webhooks, eventTypeInfo }; // If it's just an attendee of a booking then just remove them from that booking - const result = await cancelAttendeeSeat(req, dataForWebhooks); + const result = await cancelAttendeeSeat( + req, + dataForWebhooks, + bookingToDelete?.eventType?.metadata as EventTypeMetadata + ); if (result) return { success: true, @@ -503,7 +508,11 @@ async function handler(req: CustomRequest) { try { // TODO: if emails fail try to requeue them if (!platformClientId || (platformClientId && arePlatformEmailsEnabled)) - await sendCancelledEmails(evt, { eventName: bookingToDelete?.eventType?.eventName }); + await sendCancelledEmails( + evt, + { eventName: bookingToDelete?.eventType?.eventName }, + bookingToDelete?.eventType?.metadata as EventTypeMetadata + ); } catch (error) { console.error("Error deleting event", error); } diff --git a/packages/features/bookings/lib/handleConfirmation.ts b/packages/features/bookings/lib/handleConfirmation.ts index 0f964dd50fd91c..3bc204e3865d1b 100644 --- a/packages/features/bookings/lib/handleConfirmation.ts +++ b/packages/features/bookings/lib/handleConfirmation.ts @@ -111,7 +111,8 @@ export async function handleConfirmation(args: { { ...evt, additionalInformation: metadata }, undefined, isHostConfirmationEmailsDisabled, - isAttendeeConfirmationEmailDisabled + isAttendeeConfirmationEmailDisabled, + eventTypeMetadata ); } catch (error) { log.error(error); @@ -255,13 +256,15 @@ export async function handleConfirmation(args: { evtOfBooking.uid = updatedBookings[index].uid; const isFirstBooking = index === 0; - await scheduleMandatoryReminder( - evtOfBooking, - workflows, - false, - !!updatedBookings[index].eventType?.owner?.hideBranding, - evt.attendeeSeatId - ); + if (!eventTypeMetadata?.disableStandardEmails?.all?.attendee) { + await scheduleMandatoryReminder( + evtOfBooking, + workflows, + false, + !!updatedBookings[index].eventType?.owner?.hideBranding, + evt.attendeeSeatId + ); + } await scheduleWorkflowReminders({ workflows, diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index adb8fa91d5bf44..5df4daacfe3a86 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -613,7 +613,8 @@ async function handler( : await getLuckyUser("MAXIMIZE_AVAILABILITY", { // find a lucky user that is not already in the luckyUsers array availableUsers: freeUsers, - eventTypeId: eventType.id, + allRRHosts: eventTypeWithUsers.hosts.filter((host) => !host.isFixed), + eventType, }); if (!newLuckyUser) { break; // prevent infinite loop @@ -1357,17 +1358,20 @@ async function handler( originalBookingMemberEmails.find((orignalMember) => orignalMember.email === member.email) ); - sendRoundRobinRescheduledEmails(copyEventAdditionalInfo, rescheduledMembers); - sendRoundRobinScheduledEmails(copyEventAdditionalInfo, newBookedMembers); - sendRoundRobinCancelledEmails(copyEventAdditionalInfo, cancelledMembers); + sendRoundRobinRescheduledEmails(copyEventAdditionalInfo, rescheduledMembers, eventType.metadata); + sendRoundRobinScheduledEmails(copyEventAdditionalInfo, newBookedMembers, eventType.metadata); + sendRoundRobinCancelledEmails(copyEventAdditionalInfo, cancelledMembers, eventType.metadata); } else { // send normal rescheduled emails (non round robin event, where organizers stay the same) - await sendRescheduledEmails({ - ...copyEvent, - additionalInformation: metadata, - additionalNotes, // Resets back to the additionalNote input and not the override value - cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email - }); + await sendRescheduledEmails( + { + ...copyEvent, + additionalInformation: metadata, + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email + }, + eventType?.metadata + ); } } // If it's not a reschedule, doesn't require confirmation and there's no price, @@ -1504,7 +1508,8 @@ async function handler( }, eventNameObject, isHostConfirmationEmailsDisabled, - isAttendeeConfirmationEmailDisabled + isAttendeeConfirmationEmailDisabled, + eventType.metadata ); } } @@ -1533,8 +1538,8 @@ async function handler( calEvent: getPiiFreeCalendarEvent(evt), }) ); - await sendOrganizerRequestEmail({ ...evt, additionalNotes }); - await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0]); + await sendOrganizerRequestEmail({ ...evt, additionalNotes }, eventType.metadata); + await sendAttendeeRequestEmail({ ...evt, additionalNotes }, attendeesList[0], eventType.metadata); } if (booking.location?.startsWith("http")) { @@ -1745,13 +1750,15 @@ async function handler( const evtWithMetadata = { ...evt, metadata, eventType: { slug: eventType.slug } }; - await scheduleMandatoryReminder( - evtWithMetadata, - workflows, - !isConfirmedByDefault, - !!eventType.owner?.hideBranding, - evt.attendeeSeatId - ); + if (!eventType.metadata?.disableStandardEmails?.all?.attendee) { + await scheduleMandatoryReminder( + evtWithMetadata, + workflows, + !isConfirmedByDefault, + !!eventType.owner?.hideBranding, + evt.attendeeSeatId + ); + } try { await scheduleWorkflowReminders({ diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index 12b9a723aef7a2..ccd0841b7872a2 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -61,6 +61,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { durationLimits: true, rescheduleWithSameRoundRobinHost: true, assignAllTeamMembers: true, + isRRWeightsEnabled: true, parentId: true, parent: { select: { @@ -93,6 +94,8 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { select: { isFixed: true, priority: true, + weight: true, + weightAdjustment: true, user: { select: { credentials: { diff --git a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts index a7117bec7e8482..1e2a5a0e46e228 100644 --- a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts +++ b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts @@ -31,10 +31,12 @@ export const loadUsers = async (eventType: EventType, dynamicUserList: string[], const loadUsersByEventType = async (eventType: EventType): Promise => { const hosts = eventType.hosts || []; - const users = hosts.map(({ user, isFixed, priority }) => ({ + const users = hosts.map(({ user, isFixed, priority, weight, weightAdjustment }) => ({ ...user, isFixed, priority, + weight, + weightAdjustment, })); return users.length ? users : eventType.users; }; diff --git a/packages/features/bookings/lib/handleNewBooking/types.ts b/packages/features/bookings/lib/handleNewBooking/types.ts index cb4e7fb2d6d573..2b42e2de13fad4 100644 --- a/packages/features/bookings/lib/handleNewBooking/types.ts +++ b/packages/features/bookings/lib/handleNewBooking/types.ts @@ -57,6 +57,8 @@ export type IsFixedAwareUser = User & { credentials: CredentialPayload[]; organization?: { slug: string }; priority?: number; + weight?: number; + weightAdjustment?: number; }; export type NewBookingEventType = AwaitedGetDefaultEvent | getEventTypeResponse; diff --git a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts index af732a9027f751..06624de91d5c00 100644 --- a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts +++ b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts @@ -11,6 +11,7 @@ import prisma from "@calcom/prisma"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import { deleteAllWorkflowReminders } from "@calcom/trpc/server/routers/viewer/workflows/util"; import type { CalendarEvent } from "@calcom/types/Calendar"; @@ -28,7 +29,8 @@ async function cancelAttendeeSeat( }[]; evt: CalendarEvent; eventTypeInfo: EventTypeInfo; - } + }, + eventTypeMetadata: EventTypeMetadata ) { const { seatReferenceUid } = schemaBookingCancelParams.parse(req.body); const { webhooks, evt, eventTypeInfo } = dataForWebhooks; @@ -105,10 +107,14 @@ async function cancelAttendeeSeat( const tAttendees = await getTranslation(attendee.locale ?? "en", "common"); - await sendCancelledSeatEmails(evt, { - ...attendee, - language: { translate: tAttendees, locale: attendee.locale ?? "en" }, - }); + await sendCancelledSeatEmails( + evt, + { + ...attendee, + language: { translate: tAttendees, locale: attendee.locale ?? "en" }, + }, + eventTypeMetadata + ); } evt.attendees = attendee diff --git a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts index 9a6fd8b17e01a8..6bedcc2f2ee277 100644 --- a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts +++ b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts @@ -138,7 +138,8 @@ const createNewSeat = async ( newSeat, !!eventType.seatsShowAttendees, isHostConfirmationEmailsDisabled, - isAttendeeConfirmationEmailDisabled + isAttendeeConfirmationEmailDisabled, + eventType.metadata ); } const credentials = await refreshCredentials(allCredentials); diff --git a/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts b/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts index e340319666ff76..c3a8369f79e97d 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/attendee/attendeeRescheduleSeatedBooking.ts @@ -18,7 +18,7 @@ const attendeeRescheduleSeatedBooking = async ( originalBookingEvt: CalendarEvent, eventManager: EventManager ) => { - const { tAttendees, bookingSeat, bookerEmail, evt } = rescheduleSeatedBookingObject; + const { tAttendees, bookingSeat, bookerEmail, evt, eventType } = rescheduleSeatedBookingObject; let { originalRescheduledBooking } = rescheduleSeatedBookingObject; seatAttendee["language"] = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" }; @@ -91,7 +91,7 @@ const attendeeRescheduleSeatedBooking = async ( await eventManager.updateCalendarAttendees(copyEvent, newTimeSlotBooking); - await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person); + await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person, eventType.metadata); const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => { return attendee.email !== bookerEmail; }); diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts index 7efe855e038b2b..8b009e7ee0c223 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/combineTwoSeatedBookings.ts @@ -136,11 +136,14 @@ const combineTwoSeatedBookings = async ( if (noEmail !== true && isConfirmedByDefault) { // TODO send reschedule emails to attendees of the old booking loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats"); - await sendRescheduledEmails({ - ...copyEvent, - additionalNotes, // Resets back to the additionalNote input and not the override value - cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email - }); + await sendRescheduledEmails( + { + ...copyEvent, + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email + }, + eventType.metadata + ); } // Update the old booking with the cancelled status diff --git a/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts b/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts index 93dc6f4342660d..42222aa9296602 100644 --- a/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts +++ b/packages/features/bookings/lib/handleSeats/reschedule/owner/moveSeatedBookingToNewTimeSlot.ts @@ -90,11 +90,14 @@ const moveSeatedBookingToNewTimeSlot = async ( if (noEmail !== true && isConfirmedByDefault) { const copyEvent = cloneDeep(evt); loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats"); - await sendRescheduledEmails({ - ...copyEvent, - additionalNotes, // Resets back to the additionalNote input and not the override value - cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email - }); + await sendRescheduledEmails( + { + ...copyEvent, + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email + }, + eventType.metadata + ); } const foundBooking = await findBookingQuery(newBooking.id); diff --git a/packages/features/credentials/handleDeleteCredential.ts b/packages/features/credentials/handleDeleteCredential.ts index bf69811dfe1085..c5343fdf7cfad8 100644 --- a/packages/features/credentials/handleDeleteCredential.ts +++ b/packages/features/credentials/handleDeleteCredential.ts @@ -13,7 +13,7 @@ import { getTranslation } from "@calcom/lib/server/i18n"; import { bookingMinimalSelect, prisma } from "@calcom/prisma"; import { AppCategories, BookingStatus } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; -import type { EventTypeAppMetadataSchema } from "@calcom/prisma/zod-utils"; +import type { EventTypeAppMetadataSchema, EventTypeMetadata } from "@calcom/prisma/zod-utils"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -244,6 +244,7 @@ const handleDeleteCredential = async ({ seatsPerTimeSlot: true, seatsShowAttendees: true, eventName: true, + metadata: true, }, }, uid: true, @@ -335,7 +336,8 @@ const handleDeleteCredential = async ({ }, { eventName: booking?.eventType?.eventName, - } + }, + booking?.eventType?.metadata as EventTypeMetadata ); } }); diff --git a/packages/features/ee/components/BrandColorsForm.tsx b/packages/features/ee/components/BrandColorsForm.tsx index df924b19200f80..4651e6faef4949 100644 --- a/packages/features/ee/components/BrandColorsForm.tsx +++ b/packages/features/ee/components/BrandColorsForm.tsx @@ -66,12 +66,11 @@ const BrandColorsForm = ({ defaultValue={brandColor || DEFAULT_LIGHT_BRAND_COLOR} resetDefaultValue={DEFAULT_LIGHT_BRAND_COLOR} onChange={(value) => { - try { - checkWCAGContrastColor("#ffffff", value); + if (checkWCAGContrastColor("#ffffff", value)) { setLightModeError(false); brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true }); - } catch (err) { - setLightModeError(false); + } else { + setLightModeError(true); } }} /> @@ -95,11 +94,10 @@ const BrandColorsForm = ({ defaultValue={darkBrandColor || DEFAULT_DARK_BRAND_COLOR} resetDefaultValue={DEFAULT_DARK_BRAND_COLOR} onChange={(value) => { - try { - checkWCAGContrastColor("#101010", value); + if (checkWCAGContrastColor("#101010", value)) { setDarkModeError(false); brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true }); - } catch (err) { + } else { setDarkModeError(true); } }} diff --git a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts index ed5d19b1631d3d..29ca66d784caca 100644 --- a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts +++ b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts @@ -185,6 +185,7 @@ export default async function handleChildrenEventTypes({ metadata: (managedEventTypeValues.metadata as Prisma.InputJsonValue) ?? undefined, bookingFields: (managedEventTypeValues.bookingFields as Prisma.InputJsonValue) ?? undefined, durationLimits: (managedEventTypeValues.durationLimits as Prisma.InputJsonValue) ?? undefined, + eventTypeColor: (managedEventTypeValues.eventTypeColor as Prisma.InputJsonValue) ?? undefined, onlyShowFirstAvailableSlot: managedEventTypeValues.onlyShowFirstAvailableSlot ?? false, userId, users: { diff --git a/packages/features/ee/payments/api/webhook.ts b/packages/features/ee/payments/api/webhook.ts index fa07ee1dc8f591..3992966b8d7963 100644 --- a/packages/features/ee/payments/api/webhook.ts +++ b/packages/features/ee/payments/api/webhook.ts @@ -107,8 +107,8 @@ const handleSetupSuccess = async (event: Stripe.Event) => { paid: true, }); } else { - await sendOrganizerRequestEmail({ ...evt }); - await sendAttendeeRequestEmail({ ...evt }, evt.attendees[0]); + await sendOrganizerRequestEmail({ ...evt }, eventType.metadata); + await sendAttendeeRequestEmail({ ...evt }, evt.attendees[0], eventType.metadata); } }; diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index c566d3e641bc55..cc58d445e8b66a 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -26,6 +26,7 @@ import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import { prisma } from "@calcom/prisma"; import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; const bookingSelect = { @@ -87,7 +88,13 @@ export const roundRobinReassignment = async ({ bookingId }: { bookingId: number eventType.hosts = eventType.hosts.length ? eventType.hosts - : eventType.users.map((user) => ({ user, isFixed: false, priority: 2 })); + : eventType.users.map((user) => ({ + user, + isFixed: false, + priority: 2, + weight: 100, + weightAdjustment: 0, + })); const roundRobinHosts = eventType.hosts.filter((host) => !host.isFixed); @@ -129,8 +136,13 @@ export const roundRobinReassignment = async ({ bookingId }: { bookingId: number const reassignedRRHost = await getLuckyUser("MAXIMIZE_AVAILABILITY", { availableUsers, - eventTypeId: eventTypeId, + eventType: { + id: eventTypeId, + isRRWeightsEnabled: eventType.isRRWeightsEnabled, + }, + allRRHosts: eventType.hosts.filter((host) => !host.isFixed), }); + const hasOrganizerChanged = !previousRRHost || booking.userId === previousRRHost?.id; const organizer = hasOrganizerChanged ? reassignedRRHost : booking.user; const organizerT = await getTranslation(organizer?.locale || "en", "common"); @@ -398,15 +410,19 @@ export const roundRobinReassignment = async ({ bookingId }: { bookingId: number }); } - await sendRoundRobinCancelledEmails(cancelledRRHostEvt, [ - { - ...previousRRHost, - name: previousRRHost.name || "", - username: previousRRHost.username || "", - timeFormat: getTimeFormatStringFromUserTimeFormat(previousRRHost.timeFormat), - language: { translate: previousRRHostT, locale: previousRRHost.locale || "en" }, - }, - ]); + await sendRoundRobinCancelledEmails( + cancelledRRHostEvt, + [ + { + ...previousRRHost, + name: previousRRHost.name || "", + username: previousRRHost.username || "", + timeFormat: getTimeFormatStringFromUserTimeFormat(previousRRHost.timeFormat), + language: { translate: previousRRHostT, locale: previousRRHost.locale || "en" }, + }, + ], + eventType?.metadata as EventTypeMetadata + ); } // Handle changing workflows with organizer diff --git a/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts b/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts index 29357ec21c79b2..e6a3cac99c63c2 100644 --- a/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts +++ b/packages/features/ee/workflows/lib/reminders/providers/sendgridProvider.ts @@ -121,15 +121,12 @@ function addHTMLStyles(html?: string) { return ""; } const dom = new JSDOM(html); - const document = dom.window.document; - // Select all tags inside
    elements --> only used for emojis in rating template - const links = document.querySelectorAll("h6 a"); + const links = Array.from(dom.window.document.querySelectorAll("h6 a")).map((link) => link as HTMLElement); links.forEach((link) => { - const htmlLink = link as HTMLElement; - htmlLink.style.fontSize = "20px"; - htmlLink.style.textDecoration = "none"; + link.style.fontSize = "20px"; + link.style.textDecoration = "none"; }); return dom.serialize(); diff --git a/packages/features/eventtypes/components/AddMembersWithSwitch.tsx b/packages/features/eventtypes/components/AddMembersWithSwitch.tsx index 2ee0fbef6532b0..f273770eeb9e87 100644 --- a/packages/features/eventtypes/components/AddMembersWithSwitch.tsx +++ b/packages/features/eventtypes/components/AddMembersWithSwitch.tsx @@ -46,6 +46,7 @@ const CheckedHostField = ({ value, onChange, helperText, + isRRWeightsEnabled, ...rest }: { labelText?: string; @@ -55,6 +56,7 @@ const CheckedHostField = ({ onChange?: (options: Host[]) => void; options?: Options; helperText?: React.ReactNode | string; + isRRWeightsEnabled?: boolean; } & Omit>, "onChange" | "value">) => { return (
    @@ -69,6 +71,8 @@ const CheckedHostField = ({ isFixed, userId: parseInt(option.value, 10), priority: option.priority ?? 2, + weight: option.weight ?? 100, + weightAdjustment: option.weightAdjustment ?? 0, })) ); }} @@ -78,12 +82,14 @@ const CheckedHostField = ({ const option = options.find((member) => member.value === host.userId.toString()); if (!option) return acc; - acc.push({ ...option, priority: host.priority ?? 2, isFixed }); + acc.push({ ...option, priority: host.priority ?? 2, isFixed, weight: host.weight ?? 100 }); + return acc; }, [] as CheckedSelectOption[])} controlShouldRenderValue={false} options={options} placeholder={placeholder} + isRRWeightsEnabled={isRRWeightsEnabled} {...rest} />
    @@ -102,6 +108,7 @@ const AddMembersWithSwitch = ({ isFixed, placeholder = "", containerClassName = "", + isRRWeightsEnabled, }: { value: Host[]; onChange: (hosts: Host[]) => void; @@ -113,13 +120,14 @@ const AddMembersWithSwitch = ({ isFixed: boolean; placeholder?: string; containerClassName?: string; + isRRWeightsEnabled?: boolean; }) => { const { t } = useLocale(); const { setValue } = useFormContext(); return (
    -
    +
    {automaticAddAllEnabled ? (
    ) : ( <> diff --git a/packages/features/eventtypes/components/CheckedTeamSelect.tsx b/packages/features/eventtypes/components/CheckedTeamSelect.tsx index 58ac75a9f2c2f6..094b086dac0cfa 100644 --- a/packages/features/eventtypes/components/CheckedTeamSelect.tsx +++ b/packages/features/eventtypes/components/CheckedTeamSelect.tsx @@ -1,30 +1,20 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; -import type { Dispatch, SetStateAction } from "react"; import { useState } from "react"; -import { useFormContext } from "react-hook-form"; import type { Props } from "react-select"; -import type { FormValues, Host } from "@calcom/features/eventtypes/lib/types"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { - Avatar, - Button, - Dialog, - DialogClose, - DialogContent, - DialogFooter, - Icon, - Label, - Select, - Tooltip, -} from "@calcom/ui"; +import { Avatar, Button, Icon, Select, Tooltip } from "@calcom/ui"; + +import { PriorityDialog, WeightDialog } from "./HostEditDialogs"; export type CheckedSelectOption = { avatar: string; label: string; value: string; priority?: number; + weight?: number; + weightAdjustment?: number; isFixed?: boolean; disabled?: boolean; }; @@ -32,12 +22,16 @@ export type CheckedSelectOption = { export const CheckedTeamSelect = ({ options = [], value = [], + isRRWeightsEnabled, ...props }: Omit, "value" | "onChange"> & { value?: readonly CheckedSelectOption[]; onChange: (value: readonly CheckedSelectOption[]) => void; + isRRWeightsEnabled?: boolean; }) => { const [priorityDialogOpen, setPriorityDialogOpen] = useState(false); + const [weightDialogOpen, setWeightDialogOpen] = useState(false); + const [currentOption, setCurrentOption] = useState(value[0] ?? null); const { t } = useLocale(); @@ -68,20 +62,35 @@ export const CheckedTeamSelect = ({

    {option.label}

    {option && !option.isFixed ? ( - - - + <> + + + + {isRRWeightsEnabled ? ( + + ) : ( + <> + )} + ) : ( <> )} @@ -89,7 +98,7 @@ export const CheckedTeamSelect = ({ props.onChange(value.filter((item) => item.value !== option.value))} - className="my-auto h-4 w-4" + className="my-auto ml-2 h-4 w-4" />
  • @@ -97,12 +106,20 @@ export const CheckedTeamSelect = ({ ))} {currentOption && !currentOption.isFixed ? ( - + <> + + + ) : ( <> )} @@ -110,67 +127,6 @@ export const CheckedTeamSelect = ({ ); }; -interface IPriiorityDialog { - isOpenDialog: boolean; - setIsOpenDialog: Dispatch>; - option: CheckedSelectOption; - onChange: (value: readonly CheckedSelectOption[]) => void; -} - -const PriorityDialog = (props: IPriiorityDialog) => { - const { t } = useLocale(); - const { isOpenDialog, setIsOpenDialog, option, onChange } = props; - const { getValues } = useFormContext(); - - const priorityOptions = [ - { label: t("lowest"), value: 0 }, - { label: t("low"), value: 1 }, - { label: t("medium"), value: 2 }, - { label: t("high"), value: 3 }, - { label: t("highest"), value: 4 }, - ]; - - const [newPriority, setNewPriority] = useState<{ label: string; value: number }>(); - const setPriority = () => { - if (!!newPriority) { - const hosts: Host[] = getValues("hosts"); - const updatedHosts = hosts - .filter((host) => !host.isFixed) - .map((host) => { - return { - ...option, - value: host.userId.toString(), - priority: host.userId === parseInt(option.value, 10) ? newPriority.value : host.priority, - isFixed: false, - }; - }) - .sort((a, b) => b.priority ?? 2 - a.priority ?? 2); - onChange(updatedHosts); - } - setIsOpenDialog(false); - }; - return ( - - -
    - - setNewPriority(value ?? priorityOptions[2])} + options={priorityOptions} + /> +
    + + + + + +
    +
    + ); +}; + +export const weightDescription = ( + + Weights determine how meetings are distributed among hosts. + + Learn more + + +); + +export function sortHosts( + hostA: { priority: number | null; weight: number | null }, + hostB: { priority: number | null; weight: number | null }, + isRRWeightsEnabled: boolean +) { + const weightA = hostA.weight ?? 100; + const priorityA = hostA.priority ?? 2; + const weightB = hostB.weight ?? 100; + const priorityB = hostB.priority ?? 2; + + if (isRRWeightsEnabled) { + if (weightA === weightB) { + return priorityB - priorityA; + } else { + return weightB - weightA; + } + } else { + return priorityB - priorityA; + } +} + +export const WeightDialog = (props: IDialog) => { + const { t } = useLocale(); + const { isOpenDialog, setIsOpenDialog, option, onChange } = props; + const { getValues } = useFormContext(); + const [newWeight, setNewWeight] = useState(100); + + const setWeight = () => { + const hosts: Host[] = getValues("hosts"); + const updatedHosts = hosts + .filter((host) => !host.isFixed) + .map((host) => { + return { + ...option, + value: host.userId.toString(), + priority: host.priority, + weight: host.userId === parseInt(option.value, 10) ? newWeight : host.weight, + isFixed: false, + weightAdjustment: host.weightAdjustment, + }; + }); + + const sortedHosts = updatedHosts.sort((a, b) => sortHosts(a, b, true)); + + onChange(sortedHosts); + setIsOpenDialog(false); + }; + + return ( + + +
    + +
    + setNewWeight(parseInt(e.target.value))} + addOnSuffix={<>%} + /> +
    +
    + + + + +
    +
    + ); +}; diff --git a/packages/features/eventtypes/lib/getPublicEvent.ts b/packages/features/eventtypes/lib/getPublicEvent.ts index 570bd5e14f7a4e..68ae1973dc5fa5 100644 --- a/packages/features/eventtypes/lib/getPublicEvent.ts +++ b/packages/features/eventtypes/lib/getPublicEvent.ts @@ -248,7 +248,7 @@ export const getPublicEvent = async ( }; // In case it's not a group event, it's either a single user or a team, and we query that data. - const event = await prisma.eventType.findFirst({ + let event = await prisma.eventType.findFirst({ where: { slug: eventSlug, ...usersOrTeamQuery, @@ -256,6 +256,27 @@ export const getPublicEvent = async ( select: publicEventSelect, }); + // If no event was found, check for platform org user event + if (!event && !orgQuery) { + event = await prisma.eventType.findFirst({ + where: { + slug: eventSlug, + users: { + some: { + username, + isPlatformManaged: false, + movedToProfile: { + organization: { + isPlatform: true, + }, + }, + }, + }, + }, + select: publicEventSelect, + }); + } + if (!event) return null; const eventMetaData = EventTypeMetaDataSchema.parse(event.metadata || {}); diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 6188b509c08f6a..3a9040cd682354 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -6,6 +6,7 @@ import type { PeriodType, SchedulingType } from "@calcom/prisma/enums"; import type { BookerLayoutSettings, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { customInputSchema } from "@calcom/prisma/zod-utils"; import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils"; +import type { eventTypeColor } from "@calcom/prisma/zod-utils"; import type { RouterOutputs } from "@calcom/trpc/react"; import type { IntervalLimit, RecurringEvent } from "@calcom/types/Calendar"; @@ -19,7 +20,13 @@ export type AvailabilityOption = { }; export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"]; export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]; -export type Host = { isFixed: boolean; userId: number; priority: number }; +export type Host = { + isFixed: boolean; + userId: number; + priority: number; + weight: number; + weightAdjustment: number; +}; export type TeamMember = { value: string; label: string; @@ -47,6 +54,7 @@ export type FormValues = { hidden: boolean; hideCalendarNotes: boolean; hashedLink: string | undefined; + eventTypeColor: z.infer; locations: { type: EventLocationType["type"]; address?: string; @@ -120,6 +128,7 @@ export type FormValues = { useEventTypeDestinationCalendarEmail: boolean; forwardParamsSuccessRedirect: boolean | null; secondaryEmailId?: number; + isRRWeightsEnabled: boolean; }; export type LocationFormValues = Pick; diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index 3a9dadb70b5ad1..dc575f53849b9d 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -384,7 +384,8 @@ function UserDropdown({ small }: UserDropdownProps) { const { data: user } = useMeQuery(); const utils = trpc.useUtils(); const bookerUrl = useBookerUrl(); - + const pathname = usePathname(); + const isPlatformPages = pathname?.startsWith("/settings/platform"); useEffect(() => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore @@ -468,7 +469,7 @@ function UserDropdown({ small }: UserDropdownProps) { onHelpItemSelect()} /> ) : ( <> - {!isPlatformUser && ( + {!isPlatformPages && ( <> - {!isPlatformUser && ( + {!isPlatformPages && ( )} + {!isPlatformPages && ( + + + Platform + + + )} @@ -912,9 +924,11 @@ function SideBarContainer({ bannersHeight, isPlatformUser = false }: SideBarCont return ; } -function SideBar({ bannersHeight, user, isPlatformUser = false }: SideBarProps) { +function SideBar({ bannersHeight, user }: SideBarProps) { const { t, isLocaleReady } = useLocale(); const orgBranding = useOrgBranding(); + const pathname = usePathname(); + const isPlatformPages = pathname?.startsWith("/settings/platform"); const publicPageUrl = useMemo(() => { if (!user?.org?.id) return `${process.env.NEXT_PUBLIC_WEBSITE_URL}/${user?.username}`; @@ -953,10 +967,10 @@ function SideBar({ bannersHeight, user, isPlatformUser = false }: SideBarProps) return (