feat: Support event level conflict checking (#18052)

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
authored and GitHub committed 2024-12-19 19:34:24 +00:00
1 parent d1c2872f6e
commit 85070574da
85 files changed
+3769 -6714

No files matched your search

@@ -61,6 +61,8 @@ describe("rateLimitApiKey middleware", () => {
reset: Date.now(),
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(checkRateLimitAndThrowError as any).mockImplementationOnce(({ onRateLimiterResponse }) => {
onRateLimiterResponse(rateLimiterResponse);
});
@@ -9,11 +9,25 @@ export const schemaSelectedCalendarBaseBodyParams = SelectedCalendar;
export const schemaSelectedCalendarPublic = SelectedCalendar.omit({});
export const schemaSelectedCalendarBodyParams = schemaSelectedCalendarBaseBodyParams.partial({
userId: true,
});
export const schemaSelectedCalendarBodyParams = schemaSelectedCalendarBaseBodyParams
.partial({
userId: true,
})
.omit({
// id will be set by the database
id: true,
// No eventTypeId support in API v1
eventTypeId: true,
});
export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams.partial();
export const schemaSelectedCalendarUpdateBodyParams = schemaSelectedCalendarBaseBodyParams
.omit({
// id is decided by DB
id: true,
// No eventTypeId support in API v1
eventTypeId: true,
})
.partial();
export const selectedCalendarIdSchema = schemaQueryIdAsString.transform((v, ctx) => {
/** We can assume the first part is the userId since it's an integer */
+2 -1
View File
@@ -14,7 +14,8 @@
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"start": "PORT=3003 next start",
"docker-start-api": "PORT=80 next start",
"type-check": "tsc --pretty --noEmit"
"type-check": "tsc --pretty --noEmit",
"type-check:ci": "tsc-absolute --pretty --noEmit"
},
"devDependencies": {
"@calcom/tsconfig": "*",
@@ -1,9 +1,10 @@
import type { NextApiRequest } from "next";
import type { UserWithCalendars } from "@calcom/lib/getConnectedDestinationCalendars";
import { getConnectedDestinationCalendars } from "@calcom/lib/getConnectedDestinationCalendars";
import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/lib/getConnectedDestinationCalendars";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { extractUserIdsFromQuery } from "~/lib/utils/extractUserIdsFromQuery";
@@ -104,12 +105,8 @@ async function getHandler(req: NextApiRequest) {
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
const usersWithCalendars = await prisma.user.findMany({
where: { id: { in: userIds } },
include: {
selectedCalendars: true,
destinationCalendar: true,
},
const usersWithCalendars = await UserRepository.findManyByIdsIncludeDestinationAndSelectedCalendars({
ids: userIds,
});
return await getConnectedCalendars(usersWithCalendars);
@@ -117,11 +114,12 @@ async function getHandler(req: NextApiRequest) {
async function getConnectedCalendars(users: UserWithCalendars[]) {
const connectedDestinationCalendarsPromises = users.map((user) =>
getConnectedDestinationCalendars(user, false, prisma).then((connectedCalendarsResult) =>
connectedCalendarsResult.connectedCalendars.map((calendar) => ({
userId: user.id,
...calendar,
}))
getConnectedDestinationCalendarsAndEnsureDefaultsInDb({ user, onboarding: false, prisma }).then(
(connectedCalendarsResult) =>
connectedCalendarsResult.connectedCalendars.map((calendar) => ({
userId: user.id,
...calendar,
}))
)
);
const connectedDestinationCalendars = await Promise.all(connectedDestinationCalendarsPromises);
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import { selectedCalendarIdSchema } from "~/lib/validations/selected-calendar";
@@ -49,7 +49,9 @@ import { selectedCalendarIdSchema } from "~/lib/validations/selected-calendar";
export async function deleteHandler(req: NextApiRequest) {
const { query } = req;
const userId_integration_externalId = selectedCalendarIdSchema.parse(query);
await prisma.selectedCalendar.delete({ where: { userId_integration_externalId } });
await SelectedCalendarRepository.deleteUserLevel({
where: userId_integration_externalId,
});
return { message: `Selected Calendar with id: ${query.id} deleted successfully` };
}
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import { schemaSelectedCalendarPublic, selectedCalendarIdSchema } from "~/lib/validations/selected-calendar";
@@ -49,8 +49,8 @@ import { schemaSelectedCalendarPublic, selectedCalendarIdSchema } from "~/lib/va
export async function getHandler(req: NextApiRequest) {
const { query } = req;
const userId_integration_externalId = selectedCalendarIdSchema.parse(query);
const data = await prisma.selectedCalendar.findUniqueOrThrow({
where: { userId_integration_externalId },
const data = await SelectedCalendarRepository.findUserLevelUniqueOrThrow({
where: userId_integration_externalId,
});
return { selected_calendar: schemaSelectedCalendarPublic.parse(data) };
}
@@ -3,6 +3,8 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import type { UpdateArguments } from "@calcom/lib/server/repository/selectedCalendar";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import prisma from "@calcom/prisma";
import {
@@ -56,7 +58,7 @@ export async function patchHandler(req: NextApiRequest) {
const { query, isSystemWideAdmin } = req;
const userId_integration_externalId = selectedCalendarIdSchema.parse(query);
const { userId: bodyUserId, ...data } = schemaSelectedCalendarUpdateBodyParams.parse(req.body);
const args: Prisma.SelectedCalendarUpdateArgs = { where: { userId_integration_externalId }, data };
const args: UpdateArguments = { where: { ...userId_integration_externalId }, data };
if (!isSystemWideAdmin && bodyUserId)
throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` });
@@ -67,7 +69,7 @@ export async function patchHandler(req: NextApiRequest) {
args.data.userId = bodyUserId;
}
const result = await prisma.selectedCalendar.update(args);
const result = await SelectedCalendarRepository.updateUserLevel(args);
return { selected_calendar: schemaSelectedCalendarPublic.parse(result) };
}
@@ -1,9 +1,9 @@
import type { Prisma } from "@prisma/client";
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import type { FindManyArgs } from "@calcom/lib/server/repository/selectedCalendar";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import { schemaSelectedCalendarPublic } from "~/lib/validations/selected-calendar";
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
@@ -34,7 +34,7 @@ import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/que
async function getHandler(req: NextApiRequest) {
const { userId, isSystemWideAdmin } = req;
/* Admin gets all selected calendar by default, otherwise only the user's ones */
const args: Prisma.SelectedCalendarFindManyArgs = isSystemWideAdmin ? {} : { where: { userId } };
const args: FindManyArgs = isSystemWideAdmin ? {} : { where: { userId } };
/** Only admins can query other users */
if (!isSystemWideAdmin && req.query.userId)
@@ -46,7 +46,7 @@ async function getHandler(req: NextApiRequest) {
if (Array.isArray(query.userId)) args.orderBy = { userId: "asc" };
}
const data = await prisma.selectedCalendar.findMany(args);
const data = await SelectedCalendarRepository.findManyUserLevel(args);
return { selected_calendars: data.map((v) => schemaSelectedCalendarPublic.parse(v)) };
}
@@ -3,6 +3,7 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import prisma from "@calcom/prisma";
import {
@@ -52,7 +53,11 @@ import {
async function postHandler(req: NextApiRequest) {
const { userId, isSystemWideAdmin } = req;
const { userId: bodyUserId, ...body } = schemaSelectedCalendarBodyParams.parse(req.body);
const args: Prisma.SelectedCalendarCreateArgs = { data: { ...body, userId } };
const args: {
data: Prisma.SelectedCalendarUncheckedCreateInput;
} = {
data: { ...body, userId },
};
if (!isSystemWideAdmin && bodyUserId)
throw new HttpError({ statusCode: 403, message: `ADMIN required for userId` });
@@ -63,7 +68,7 @@ async function postHandler(req: NextApiRequest) {
args.data.userId = bodyUserId;
}
const data = await prisma.selectedCalendar.create(args);
const data = await SelectedCalendarRepository.create(args.data);
return {
selected_calendar: schemaSelectedCalendarPublic.parse(data),
+4 -1
View File
@@ -52,6 +52,8 @@ describe("POST /api/attendees", () => {
bookingId: 1,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
prismaMock.attendee.create.mockResolvedValue(attendeeData);
req.isSystemWideAdmin = true;
req.userId = 123;
@@ -84,7 +86,8 @@ describe("POST /api/attendees", () => {
timeZone: "UTC",
bookingId: 1,
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
prismaMock.attendee.create.mockResolvedValue(attendeeData);
req.userId = 123;
+1 -1
View File
@@ -11,7 +11,7 @@ import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { buildBooking, buildEventType, buildWebhook } from "@calcom/lib/test/builder";
import prisma from "@calcom/prisma";
import type { Booking } from "@calcom/prisma";
import type { Booking } from "@calcom/prisma/client";
import handler from "../../../pages/api/bookings/_post";
@@ -21,6 +21,8 @@ describe("DELETE /api/event-types/[id]", () => {
beforeEach(() => {
vi.resetAllMocks();
// Mocking membership.findFirst
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
prismaMock.membership.findFirst.mockImplementation(({ where }) => {
const { userId, teamId, accepted, role } = where;
const mockData = [
@@ -72,6 +72,7 @@ describe("POST /api/event-types", () => {
},
});
req.userId = memberUserId;
// @ts-expect-error - Return type is wrong
vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => false);
await handler(req, res);
@@ -114,6 +115,7 @@ describe("POST /api/event-types", () => {
req.isSystemWideAdmin = true;
req.userId = adminUserId;
// @ts-expect-error - Return type is wrong
vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => false);
await handler(req, res);
@@ -138,6 +140,7 @@ describe("POST /api/event-types", () => {
req.isSystemWideAdmin = true;
req.userId = adminUserId;
// @ts-expect-error - Return type is wrong
vi.mocked(canUserAccessTeamWithRole).mockImplementationOnce(async () => true);
vi.mocked(checkParentEventOwnership).mockImplementationOnce(async () => undefined);
vi.mocked(checkUserMembership).mockImplementationOnce(async () => undefined);
@@ -5,6 +5,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ILicenseKeyService } from "@calcom/ee/common/server/LicenseKeyService";
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
import prisma from "@calcom/prisma";
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
@@ -21,7 +22,7 @@ afterEach(() => {
});
describe("Verify API key", () => {
let service: LicenseKeyService;
let service: ILicenseKeyService;
beforeEach(async () => {
service = await LicenseKeyService.create();
@@ -79,6 +79,15 @@ describe("POST /api/selected-calendars", () => {
integration: "google",
externalId: "ext123",
userId: 444444,
id: "xxx-xxx",
eventTypeId: null,
domainWideDelegationCredentialId: null,
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
error: null,
});
await handler(req, res);
@@ -104,10 +113,19 @@ describe("POST /api/selected-calendars", () => {
req.userId = 333333;
prismaMock.selectedCalendar.create.mockResolvedValue({
id: "xxx-xxx",
credentialId: 1,
integration: "google",
externalId: "ext123",
userId: 333333,
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
domainWideDelegationCredentialId: null,
eventTypeId: null,
error: null,
});
await handler(req, res);
+1 -1
View File
@@ -29,7 +29,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.77",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.78",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -19,7 +19,10 @@ import { DateTime } from "luxon";
import { z } from "zod";
import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries";
import {
getConnectedDestinationCalendarsAndEnsureDefaultsInDb,
getBusyCalendarTimes,
} from "@calcom/platform-libraries";
import { Calendar } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -41,12 +44,18 @@ export class CalendarsService {
if (!userWithCalendars) {
throw new NotFoundException("User not found");
}
return getConnectedDestinationCalendars(
userWithCalendars,
false,
this.dbWrite.prisma as unknown as PrismaClient
);
return getConnectedDestinationCalendarsAndEnsureDefaultsInDb({
user: {
...userWithCalendars,
allSelectedCalendars: userWithCalendars.selectedCalendars,
userLevelSelectedCalendars: userWithCalendars.selectedCalendars.filter(
(calendar) => !calendar.eventTypeId
),
},
onboarding: false,
eventTypeId: null,
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
async getBusyTimes(
@@ -2,50 +2,62 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
// It ensures that we work on userLevel calendars only
const ensureUserLevelWhere = {
eventTypeId: null,
};
@Injectable()
export class SelectedCalendarsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
upsertSelectedCalendar(externalId: string, credentialId: number, userId: number, integration: string) {
return this.dbWrite.prisma.selectedCalendar.upsert({
create: {
userId,
externalId,
credentialId,
integration,
},
update: {
userId,
externalId,
credentialId,
integration,
},
where: {
userId_integration_externalId: {
userId,
integration,
externalId,
async upsertSelectedCalendar(
externalId: string,
credentialId: number,
userId: number,
integration: string
) {
// Why we can't use .upsert here, see server/repository/selectedCalendar.ts#upsert
const existingUserSelectedCalendar = await this.getUserSelectedCalendar(userId, integration, externalId);
const data = {
userId,
externalId,
credentialId,
integration,
...ensureUserLevelWhere,
};
if (existingUserSelectedCalendar) {
return this.dbWrite.prisma.selectedCalendar.update({
where: {
id: existingUserSelectedCalendar.id,
},
},
data,
});
}
return this.dbWrite.prisma.selectedCalendar.create({
data,
});
}
getUserSelectedCalendars(userId: number) {
// It would be unique result but we can't use .findUnique here because of eventTypeId being nullable
return this.dbRead.prisma.selectedCalendar.findMany({
where: {
userId,
...ensureUserLevelWhere,
},
});
}
getUserSelectedCalendar(userId: number, integration: string, externalId: string) {
return this.dbRead.prisma.selectedCalendar.findUnique({
return this.dbRead.prisma.selectedCalendar.findFirst({
where: {
userId_integration_externalId: {
userId,
externalId,
integration,
},
userId,
externalId,
integration,
...ensureUserLevelWhere,
},
});
}
@@ -56,33 +68,46 @@ export class SelectedCalendarsRepository {
externalId: string,
credentialId: number
) {
return await this.dbWrite.prisma.selectedCalendar.upsert({
where: {
userId_integration_externalId: {
userId,
integration,
externalId,
},
},
create: {
const existingUserSelectedCalendar = await this.getUserSelectedCalendar(userId, integration, externalId);
if (existingUserSelectedCalendar) {
return;
}
return await this.dbWrite.prisma.selectedCalendar.create({
data: {
userId,
integration,
externalId,
credentialId,
...ensureUserLevelWhere,
},
// already exists
update: {},
});
}
async removeUserSelectedCalendar(userId: number, integration: string, externalId: string) {
// Using deleteMany because userId_externalId_integration_eventTypeId is a unique constraint but with eventTypeId being nullable, causing it to be not used as a unique constraint
const records = await this.dbWrite.prisma.selectedCalendar.findMany({
where: {
userId,
externalId,
integration,
...ensureUserLevelWhere,
},
});
// Make the behaviour same as .delete which throws error if no record is found
if (records.length === 0) {
throw new Error("No SelectedCalendar found.");
}
if (records.length > 1) {
throw new Error("Multiple SelecteCalendars found. Skipping deletion");
}
return await this.dbWrite.prisma.selectedCalendar.delete({
where: {
userId_integration_externalId: {
userId,
externalId,
integration,
},
id: records[0].id,
},
});
}
@@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useEffect, Suspense } from "react";
import { InstallAppButton } from "@calcom/app-store/components";
import {
@@ -109,12 +109,14 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
{heading && (
<>
<DestinationCalendarSettingsWebWrapper />
<SelectedCalendarsSettingsWebWrapper
onChanged={onChanged}
fromOnboarding={fromOnboarding}
destinationCalendarId={data.destinationCalendar?.externalId}
isPending={mutation.isPending}
/>
<Suspense fallback={<SkeletonLoader />}>
<SelectedCalendarsSettingsWebWrapper
onChanged={onChanged}
fromOnboarding={fromOnboarding}
destinationCalendarId={data.destinationCalendar?.externalId}
isPending={mutation.isPending}
/>
</Suspense>
</>
)}
</>
@@ -1,4 +1,4 @@
import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
import { UserCalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
interface IConnectedCalendarItem {
name: string;
@@ -52,7 +52,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => {
<div>
<ul className="p-4">
{calendars?.map((calendar, i) => (
<CalendarSwitch
<UserCalendarSwitch
credentialId={calendar.credentialId}
key={calendar.externalId}
externalId={calendar.externalId}
@@ -14,7 +14,10 @@ interface IConnectCalendarsProps {
const ConnectedCalendars = (props: IConnectCalendarsProps) => {
const { nextStep } = props;
const queryConnectedCalendars = trpc.viewer.connectedCalendars.useQuery({ onboarding: true });
const queryConnectedCalendars = trpc.viewer.connectedCalendars.useQuery({
onboarding: true,
eventTypeId: null,
});
const { t } = useLocale();
const queryIntegrations = trpc.viewer.integrations.useQuery({
variant: "calendar",
+21 -7
View File
@@ -14,6 +14,7 @@ const selectedCalendarSelectSchema = z.object({
integration: z.string(),
externalId: z.string(),
credentialId: z.coerce.number(),
eventTypeId: z.coerce.number().nullish(),
});
/** Shared authentication middleware for GET, DELETE and POST requests */
@@ -40,12 +41,13 @@ type CustomNextApiRequest = NextApiRequest & {
async function postHandler(req: CustomNextApiRequest) {
if (!req.userWithCredentials) throw new HttpError({ statusCode: 401, message: "Not authenticated" });
const user = req.userWithCredentials;
const { integration, externalId, credentialId } = selectedCalendarSelectSchema.parse(req.body);
const { integration, externalId, credentialId, eventTypeId } = selectedCalendarSelectSchema.parse(req.body);
await SelectedCalendarRepository.upsert({
userId: user.id,
integration,
externalId,
credentialId,
eventTypeId: eventTypeId ?? null,
});
return { message: "Calendar Selection Saved" };
@@ -54,18 +56,27 @@ async function postHandler(req: CustomNextApiRequest) {
async function deleteHandler(req: CustomNextApiRequest) {
if (!req.userWithCredentials) throw new HttpError({ statusCode: 401, message: "Not authenticated" });
const user = req.userWithCredentials;
const { integration, externalId, credentialId } = selectedCalendarSelectSchema.parse(req.query);
const { integration, externalId, credentialId, eventTypeId } = selectedCalendarSelectSchema.parse(
req.query
);
const calendarCacheRepository = await CalendarCache.initFromCredentialId(credentialId);
await calendarCacheRepository.unwatchCalendar({ calendarId: externalId });
await calendarCacheRepository.unwatchCalendar({
calendarId: externalId,
eventTypeIds: [eventTypeId ?? null],
});
await SelectedCalendarRepository.delete({
userId: user.id,
externalId,
integration,
where: {
userId: user.id,
externalId,
integration,
eventTypeId: eventTypeId ?? null,
},
});
return { message: "Calendar Selection Saved" };
}
// TODO: It doesn't seem to be used from within the app. It is possible that someone outside Cal.com is using this GET endpoint
async function getHandler(req: CustomNextApiRequest) {
if (!req.userWithCredentials) throw new HttpError({ statusCode: 401, message: "Not authenticated" });
const user = req.userWithCredentials;
@@ -76,7 +87,10 @@ async function getHandler(req: CustomNextApiRequest) {
// get user's credentials + their connected integrations
const calendarCredentials = getCalendarCredentials(user.credentials);
// get all the connected integrations' calendars (from third party)
const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
const { connectedCalendars } = await getConnectedCalendars(
calendarCredentials,
user.userLevelSelectedCalendars
);
const calendars = connectedCalendars.flatMap((c) => c.calendars).filter(notEmpty);
const selectableCalendars = calendars.map((cal) => {
return { selected: selectedCalendarIds.findIndex((s) => s.externalId === cal.externalId) > -1, ...cal };
@@ -2878,6 +2878,8 @@
"disable_org_url_description": "Redirects {{orgSlug}}.cal.com to {{destination}}",
"single_select": "Single Select",
"multi_select": "Multi Select",
"calendar_conflict_settings": "Calendar Conflict Settings",
"select_calendars_conflict_check": "Select which calendars to check for conflicts",
"group_meeting": "Group Meeting",
"salesforce_ignore_guests": "Do not create new records for guests added to the booking",
"google_meet": "Google Meet",
-57
View File
@@ -31,63 +31,6 @@ vi.mock("@calcom/lib/constants", () => ({
describe("getSchedule", () => {
setupAndTeardown();
describe("Calendar event", () => {
test("correctly identifies unavailable slots from calendar", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([
{
start: `${plus2DateString}T04:45:00.000Z`,
end: `${plus2DateString}T23:00:00.000Z`,
},
]);
const scenarioData = {
eventTypes: [
{
id: 1,
slotInterval: 45,
length: 45,
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
},
],
apps: [TestData.apps["google-calendar"]],
};
// An event with one accepted booking
await createBookingScenario(scenarioData);
const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
orgSlug: null,
},
});
// As per Google Calendar Availability, only 4PM(4-4:45PM) GMT slot would be available
expect(scheduleForDayWithAGoogleCalendarBooking).toHaveTimeSlots([`04:00:00.000Z`], {
dateString: plus2DateString,
});
});
});
// TODO: Move these inside describe('Team Event')
describe("Round robin lead skip(i.e. use contact owner specified by teamMemberEmail) - CRM", async () => {
@@ -0,0 +1,300 @@
import {
getDate,
getGoogleCalendarCredential,
createBookingScenario,
Timezones,
TestData,
mockCalendar,
} from "../../utils/bookingScenario/bookingScenario";
import { describe, test } from "vitest";
import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util";
import { expect, expectedSlotsForSchedule } from "./expects";
import { setupAndTeardown } from "./setupAndTeardown";
describe("getSchedule", () => {
setupAndTeardown();
describe("Calendar event", () => {
test("correctly identifies unavailable slots from selected calendars at user level", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const useEventLevelSelectedCalendars = false;
mockCalendar("googlecalendar", {
create: {
uid: "MOCK_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
busySlots: [
{
start: `${plus2DateString}T04:45:00.000Z`,
end: `${plus2DateString}T23:00:00.000Z`,
},
],
});
const scenarioData = {
eventTypes: [
{
id: 1,
useEventLevelSelectedCalendars,
slotInterval: 45,
length: 45,
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
},
],
apps: [TestData.apps["google-calendar"]],
};
// An event with one accepted booking
await createBookingScenario(scenarioData);
const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
orgSlug: null,
},
});
// As per Google Calendar Availability, only 4PM(4-4:45PM) GMT slot would be available
expect(scheduleForDayWithAGoogleCalendarBooking).toHaveTimeSlots([`04:00:00.000Z`], {
dateString: plus2DateString,
});
});
describe("useEventLevelSelectedCalendars is true", () => {
test("correctly identifies unavailable slots from selected calendars at event level", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const useEventLevelSelectedCalendars = true;
const eventTypeId = 1;
mockCalendar("googlecalendar", {
create: {
uid: "MOCK_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
busySlots: [
{
start: `${plus2DateString}T04:45:00.000Z`,
end: `${plus2DateString}T23:00:00.000Z`,
},
],
});
const scenarioData = {
eventTypes: [
{
id: 1,
useEventLevelSelectedCalendars,
slotInterval: 45,
length: 45,
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [
{
...TestData.selectedCalendars.google,
eventTypeId,
},
],
},
],
apps: [TestData.apps["google-calendar"]],
};
// An event with one accepted booking
await createBookingScenario(scenarioData);
const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({
input: {
eventTypeId,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
orgSlug: null,
},
});
// As per Google Calendar Availability, only 4PM(4-4:45PM) GMT slot would be available
expect(scheduleForDayWithAGoogleCalendarBooking).toHaveTimeSlots([`04:00:00.000Z`], {
dateString: plus2DateString,
});
});
test("doesn't consider user level selected calendars", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const useEventLevelSelectedCalendars = true;
const eventTypeId = 1;
mockCalendar("googlecalendar", {
create: {
uid: "MOCK_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
busySlots: [
{
start: `${plus2DateString}T04:45:00.000Z`,
end: `${plus2DateString}T23:00:00.000Z`,
},
],
});
const scenarioData = {
eventTypes: [
{
id: 1,
useEventLevelSelectedCalendars,
slotInterval: 60,
length: 60,
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [
{
...TestData.selectedCalendars.google,
eventTypeId: null,
},
],
},
],
apps: [TestData.apps["google-calendar"]],
};
// An event with one accepted booking
await createBookingScenario(scenarioData);
const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({
input: {
eventTypeId,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
orgSlug: null,
},
});
// As per Google Calendar Availability, only 4PM(4-4:45PM) GMT slot would be available
expect(scheduleForDayWithAGoogleCalendarBooking).toHaveTimeSlots(
expectedSlotsForSchedule["IstWorkHours"].interval["1hr"].allPossibleSlotsStartingAt430,
{
dateString: plus2DateString,
}
);
});
test("doesnt consider another event type's selected calendars", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const useEventLevelSelectedCalendars = true;
const eventTypeId = 1;
const anotherEventTypeId = 2;
mockCalendar("googlecalendar", {
create: {
uid: "MOCK_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
busySlots: [
{
start: `${plus2DateString}T04:45:00.000Z`,
end: `${plus2DateString}T23:00:00.000Z`,
},
],
});
const scenarioData = {
eventTypes: [
{
id: eventTypeId,
useEventLevelSelectedCalendars,
slotInterval: 60,
length: 60,
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [
{
...TestData.selectedCalendars.google,
eventTypeId: anotherEventTypeId,
},
],
},
],
apps: [TestData.apps["google-calendar"]],
};
// An event with one accepted booking
await createBookingScenario(scenarioData);
const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({
input: {
eventTypeId,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
orgSlug: null,
},
});
// As per Google Calendar Availability, only 4PM(4-4:45PM) GMT slot would be available
expect(scheduleForDayWithAGoogleCalendarBooking).toHaveTimeSlots(
expectedSlotsForSchedule["IstWorkHours"].interval["1hr"].allPossibleSlotsStartingAt430,
{
dateString: plus2DateString,
}
);
});
});
});
});
@@ -190,6 +190,7 @@ describe("handleChildrenEventTypes", () => {
data: {
...rest,
assignRRMembersUsingSegment: undefined,
useEventLevelSelectedCalendars: undefined,
rrSegmentQueryValue: undefined,
locations: [],
scheduleId: null,
@@ -350,6 +351,7 @@ describe("handleChildrenEventTypes", () => {
data: {
...rest,
assignRRMembersUsingSegment: undefined,
useEventLevelSelectedCalendars: undefined,
rrSegmentQueryValue: undefined,
locations: [],
hashedLink: {
@@ -440,6 +442,7 @@ describe("handleChildrenEventTypes", () => {
hashedLink: undefined,
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
useEventLevelSelectedCalendars: false,
},
});
const { profileId, ...rest } = evType;
@@ -449,6 +452,7 @@ describe("handleChildrenEventTypes", () => {
...rest,
locations: [],
assignRRMembersUsingSegment: undefined,
useEventLevelSelectedCalendars: undefined,
rrSegmentQueryValue: undefined,
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
@@ -114,7 +114,9 @@ type InputCredential = typeof TestData.credentials.google & {
id?: number;
};
type InputSelectedCalendar = typeof TestData.selectedCalendars.google;
type InputSelectedCalendar = (typeof TestData.selectedCalendars)[keyof typeof TestData.selectedCalendars] & {
eventTypeId?: number | null;
};
type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & {
id: number;
@@ -158,6 +160,7 @@ export type InputEventType = {
slotInterval?: number;
userId?: number;
minimumBookingNotice?: number;
useEventLevelSelectedCalendars?: boolean;
/**
* These user ids are `ScenarioData["users"]["id"]`
*/
@@ -1248,6 +1251,7 @@ export function getOrganizer({
metadata,
smsLockState,
completedOnboarding,
username,
}: {
name: string;
email: string;
@@ -1263,10 +1267,13 @@ export function getOrganizer({
metadata?: userMetadataType;
smsLockState?: SMSLockState;
completedOnboarding?: boolean;
username?: string;
}) {
username = username ?? TestData.users.example.username;
return {
...TestData.users.example,
name,
username,
email,
id,
schedules,
@@ -1293,13 +1300,15 @@ export function getScenarioData(
eventTypes,
usersApartFromOrganizer = [],
apps = [],
users: _users,
webhooks,
workflows,
bookings,
}: {
organizer: ReturnType<typeof getOrganizer>;
organizer?: ReturnType<typeof getOrganizer>;
eventTypes: ScenarioData["eventTypes"];
apps?: ScenarioData["apps"];
users?: ScenarioData["users"];
usersApartFromOrganizer?: ScenarioData["users"];
webhooks?: ScenarioData["webhooks"];
workflows?: ScenarioData["workflows"];
@@ -1307,7 +1316,13 @@ export function getScenarioData(
},
org?: { id: number | null } | undefined | null
) {
const users = [organizer, ...usersApartFromOrganizer];
if (_users && (usersApartFromOrganizer.length || organizer)) {
throw new Error("When users are provided, usersApartFromOrganizer and organizer should not be provided");
}
const users = _users ? _users : organizer ? [organizer, ...usersApartFromOrganizer] : [];
if (!users.length) {
throw new Error("No users are specified in any way");
}
if (org) {
const orgId = org.id;
if (!orgId) {
@@ -16,27 +16,46 @@ export function getBasicMockRequestDataForBooking() {
hashedLink: null,
};
}
type CommonPropsMockRequestData = {
rescheduleUid?: string;
bookingUid?: string;
recurringEventId?: string;
recurringCount?: number;
rescheduledBy?: string;
cancelledBy?: string;
schedulingType?: SchedulingType;
responses: {
email: string;
name: string;
location?: { optionValue: ""; value: string };
attendeePhoneNumber?: string;
smsReminderNumber?: string;
};
};
export function getMockRequestDataForBooking({
data,
}: {
data: Partial<ReturnType<typeof getBasicMockRequestDataForBooking>> & {
eventTypeId: number;
user?: string;
rescheduleUid?: string;
bookingUid?: string;
recurringEventId?: string;
recurringCount?: number;
rescheduledBy?: string;
cancelledBy?: string;
schedulingType?: SchedulingType;
responses: {
email: string;
name: string;
location?: { optionValue: ""; value: string };
attendeePhoneNumber?: string;
smsReminderNumber?: string;
};
} & CommonPropsMockRequestData;
}) {
return {
...getBasicMockRequestDataForBooking(),
...data,
};
}
export function getMockRequestDataForDynamicGroupBooking({
data,
}: {
data: Partial<ReturnType<typeof getBasicMockRequestDataForBooking>> & {
eventTypeId: 0;
eventTypeSlug: string;
user: string;
} & CommonPropsMockRequestData;
}) {
return {
...getBasicMockRequestDataForBooking(),
@@ -113,6 +113,8 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
// also this improves performance for most of the happy-paths.
try {
await gCalService.upsertSelectedCalendar({
// First install should add a user-level selectedCalendar only.
eventTypeId: null,
externalId: selectedCalendarWhereUnique.externalId,
});
} catch (error) {
@@ -14,7 +14,11 @@ async function postHandler(req: NextApiRequest) {
throw new HttpError({ statusCode: 403, message: "Missing Channel ID" });
}
const selectedCalendar = await SelectedCalendarRepository.findByGoogleChannelId(
// There could be multiple selected calendars for the same googleChannelId for different eventTypes and same user
// Every such record has their googleChannel related fields set which are same
// So, it is enough to get the first selected calendar for this googleChannelId
// Further code gets all the selected calendars for this calendar's credential
const selectedCalendar = await SelectedCalendarRepository.findFirstByGoogleChannelId(
req.headers["x-goog-channel-id"]
);
@@ -32,6 +36,9 @@ async function postHandler(req: NextApiRequest) {
});
const { selectedCalendars } = credential;
const calendar = await getCalendar(credential);
// Make sure to pass unique SelectedCalendars to avoid unnecessary third party api calls
// Necessary to do here so that it is ensure for all calendar apps
await calendar?.fetchAvailabilityAndSetCache?.(selectedCalendars);
return { message: "ok" };
}
@@ -2,10 +2,11 @@ import prismock from "../../../../tests/libs/__mocks__/prisma";
import oAuthManagerMock, { defaultMockOAuthManager } from "../../tests/__mocks__/OAuthManager";
import { adminMock, calendarMock, setCredentialsMock } from "./__mocks__/googleapis";
import { expect, test, beforeEach, vi } from "vitest";
import { expect, describe, test, beforeEach, vi } from "vitest";
import "vitest-fetch-mock";
import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import CalendarService from "./CalendarService";
@@ -34,6 +35,7 @@ vi.mock("@googleapis/calendar", () => calendarMock);
beforeEach(() => {
vi.clearAllMocks();
setCredentialsMock.mockClear();
oAuthManagerMock.OAuthManager = defaultMockOAuthManager;
calendarMock.calendar_v3.Calendar.mockClear();
adminMock.admin_directory_v1.Admin.mockClear();
});
@@ -60,6 +62,81 @@ const testSelectedCalendar = {
externalId: "example@cal.com",
};
function expectGoogleSubscriptionToHaveOccurredAndClearMock({ calendarId }: { calendarId: string }) {
expect(calendarMock.calendar_v3.Calendar().events.watch).toHaveBeenCalledTimes(1);
expect(calendarMock.calendar_v3.Calendar().events.watch).toHaveBeenCalledWith(
expect.objectContaining({
calendarId,
requestBody: expect.objectContaining({
type: "web_hook",
token: process.env.GOOGLE_WEBHOOK_TOKEN,
}),
})
);
calendarMock.calendar_v3.Calendar().events.watch.mockClear();
}
function expectGoogleSubscriptionToNotHaveOccurredAndClearMock() {
expect(calendarMock.calendar_v3.Calendar().events.watch).not.toHaveBeenCalled();
calendarMock.calendar_v3.Calendar().events.watch.mockClear();
}
function expectGoogleUnsubscriptionToHaveOccurredAndClearMock(
channels: {
resourceId: string;
channelId: string;
}[]
) {
expect(calendarMock.calendar_v3.Calendar().channels.stop).toHaveBeenCalledTimes(1);
channels.forEach((channel) => {
expect(calendarMock.calendar_v3.Calendar().channels.stop).toHaveBeenCalledWith({
requestBody: {
resourceId: channel.resourceId,
id: channel.channelId,
},
});
});
calendarMock.calendar_v3.Calendar().channels.stop.mockClear();
}
function expectGoogleUnsubscriptionToNotHaveOccurredAndClearMock() {
expect(calendarMock.calendar_v3.Calendar().channels.stop).not.toHaveBeenCalled();
calendarMock.calendar_v3.Calendar().channels.stop.mockClear();
}
async function expectSelectedCalendarToHaveGoogleChannelProps(
id: string,
googleChannelProps: {
googleChannelId: string;
googleChannelKind: string;
googleChannelResourceId: string;
googleChannelResourceUri: string;
googleChannelExpiration: string;
}
) {
const selectedCalendar = await SelectedCalendarRepository.findById(id);
expect(selectedCalendar).toEqual(expect.objectContaining(googleChannelProps));
}
async function expectSelectedCalendarToNotHaveGoogleChannelProps(selectedCalendarId: string) {
const selectedCalendar = await SelectedCalendarRepository.findFirst({
where: {
id: selectedCalendarId,
},
});
expect(selectedCalendar).toEqual(
expect.objectContaining({
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
})
);
}
test("Calendar Cache is being read on cache HIT", async () => {
const credentialInDb1 = await createCredentialInDb();
const dateFrom1 = new Date().toISOString();
@@ -124,61 +201,318 @@ test("Calendar Cache is being ignored on cache MISS", async () => {
expect(cachedAvailability).toBeNull();
});
test("Calendar can be watched and unwatched", async () => {
const credentialInDb1 = await createCredentialInDb();
oAuthManagerMock.OAuthManager = defaultMockOAuthManager;
const calendarCache = await CalendarCache.initFromCredentialId(credentialInDb1.id);
await calendarCache.watchCalendar({ calendarId: testSelectedCalendar.externalId });
const watchedCalendar = await prismock.selectedCalendar.findFirst({
async function expectCacheToBeNotSet({ credentialId }: { credentialId: number }) {
const caches = await prismock.calendarCache.findMany({
where: {
userId: credentialInDb1.userId!,
externalId: testSelectedCalendar.externalId,
integration: "google_calendar",
credentialId,
},
});
expect(watchedCalendar).toEqual(
expect(caches).toHaveLength(0);
}
async function expectCacheToBeSet({
credentialId,
itemsInKey,
}: {
credentialId: number;
itemsInKey: { id: string }[];
}) {
const caches = await prismock.calendarCache.findMany({
where: {
credentialId,
},
});
expect(caches).toHaveLength(1);
expect(JSON.parse(caches[0].key)).toEqual(
expect.objectContaining({
userId: 1,
items: itemsInKey,
})
);
}
describe("Watching and unwatching calendar", () => {
test("Calendar can be watched and unwatched", async () => {
const credentialInDb1 = await createCredentialInDb();
const calendarCache = await CalendarCache.initFromCredentialId(credentialInDb1.id);
await calendarCache.watchCalendar({
calendarId: testSelectedCalendar.externalId,
eventTypeIds: [null],
});
const watchedCalendar = await prismock.selectedCalendar.findFirst({
where: {
userId: credentialInDb1.userId!,
externalId: testSelectedCalendar.externalId,
integration: "google_calendar",
},
});
expect(watchedCalendar).toEqual(
expect.objectContaining({
userId: 1,
eventTypeId: null,
integration: "google_calendar",
externalId: "example@cal.com",
credentialId: 1,
domainWideDelegationCredentialId: null,
googleChannelId: "mock-channel-id",
googleChannelKind: "api#channel",
googleChannelResourceId: "mock-resource-id",
googleChannelResourceUri: "mock-resource-uri",
googleChannelExpiration: "1111111111",
})
);
expect(watchedCalendar?.id).toBeDefined();
await calendarCache.unwatchCalendar({
calendarId: testSelectedCalendar.externalId,
eventTypeIds: [null],
});
const calendarAfterUnwatch = await prismock.selectedCalendar.findFirst({
where: {
userId: credentialInDb1.userId!,
externalId: testSelectedCalendar.externalId,
integration: "google_calendar",
},
});
expect(calendarAfterUnwatch).toEqual(
expect.objectContaining({
userId: 1,
eventTypeId: null,
integration: "google_calendar",
externalId: "example@cal.com",
credentialId: 1,
domainWideDelegationCredentialId: null,
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
})
);
expect(calendarAfterUnwatch?.id).toBeDefined();
});
test("watchCalendar should not do google subscription if already subscribed for the same calendarId", async () => {
const credentialInDb1 = await createCredentialInDb();
const calendarCache = await CalendarCache.initFromCredentialId(credentialInDb1.id);
const userLevelCalendar = await SelectedCalendarRepository.create({
userId: credentialInDb1.userId!,
externalId: "externalId@cal.com",
integration: "google_calendar",
externalId: "example@cal.com",
credentialId: 1,
domainWideDelegationCredentialId: null,
eventTypeId: null,
credentialId: credentialInDb1.id,
});
const eventTypeLevelCalendar = await SelectedCalendarRepository.create({
userId: credentialInDb1.userId!,
externalId: "externalId@cal.com",
integration: "google_calendar",
eventTypeId: 1,
credentialId: credentialInDb1.id,
});
await calendarCache.watchCalendar({
calendarId: userLevelCalendar.externalId,
eventTypeIds: [userLevelCalendar.eventTypeId],
});
expectGoogleSubscriptionToHaveOccurredAndClearMock({
calendarId: userLevelCalendar.externalId,
});
await expectSelectedCalendarToHaveGoogleChannelProps(userLevelCalendar.id, {
googleChannelId: "mock-channel-id",
googleChannelKind: "api#channel",
googleChannelResourceId: "mock-resource-id",
googleChannelResourceUri: "mock-resource-uri",
googleChannelExpiration: "1111111111",
})
);
});
expect(watchedCalendar?.id).toBeDefined();
// Watch different selectedcalendar with same externalId and credentialId
await calendarCache.watchCalendar({
calendarId: eventTypeLevelCalendar.externalId,
eventTypeIds: [eventTypeLevelCalendar.eventTypeId],
});
await calendarCache.unwatchCalendar({ calendarId: testSelectedCalendar.externalId });
// There's a bug in prismock where upsert creates duplicate records so we need to acces the second element
const [, unWatchedCalendar] = await prismock.selectedCalendar.findMany({
where: {
userId: credentialInDb1.userId!,
externalId: testSelectedCalendar.externalId,
integration: "google_calendar",
},
expectGoogleSubscriptionToNotHaveOccurredAndClearMock();
// Google Subscription didn't occur but still the eventTypeLevelCalendar has the same googleChannelProps
await expectSelectedCalendarToHaveGoogleChannelProps(eventTypeLevelCalendar.id, {
googleChannelId: "mock-channel-id",
googleChannelKind: "api#channel",
googleChannelResourceId: "mock-resource-id",
googleChannelResourceUri: "mock-resource-uri",
googleChannelExpiration: "1111111111",
});
});
expect(unWatchedCalendar).toEqual(
expect.objectContaining({
userId: 1,
integration: "google_calendar",
externalId: "example@cal.com",
credentialId: 1,
domainWideDelegationCredentialId: null,
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
})
);
test("unwatchCalendar should not unsubscribe from google if there is another selectedCalendar with same externalId and credentialId", async () => {
const credentialInDb1 = await createCredentialInDb();
const calendarCache = await CalendarCache.initFromCredentialId(credentialInDb1.id);
expect(unWatchedCalendar?.id).toBeDefined();
const concernedCache = await prismock.calendarCache.create({
data: {
key: "test-key",
value: "test-value",
expiresAt: new Date(Date.now() + 100000000),
credentialId: credentialInDb1.id,
},
});
const someOtherCache = await prismock.calendarCache.create({
data: {
key: JSON.stringify({
items: [{ id: "someOtherExternalId@cal.com" }],
}),
value: "test-value-2",
expiresAt: new Date(Date.now() + 100000000),
credentialId: 999,
},
});
const googleChannelProps = {
googleChannelId: "test-channel-id",
googleChannelKind: "api#channel",
googleChannelResourceId: "test-resource-id",
googleChannelResourceUri: "test-resource-uri",
googleChannelExpiration: "1111111111",
};
const commonProps = {
userId: credentialInDb1.userId!,
externalId: "externalId@cal.com",
integration: "google_calendar",
credentialId: credentialInDb1.id,
...googleChannelProps,
};
const userLevelCalendar = await SelectedCalendarRepository.create({
...commonProps,
eventTypeId: null,
});
const eventTypeLevelCalendar = await SelectedCalendarRepository.create({
...commonProps,
eventTypeId: 1,
});
const eventTypeLevelCalendarForSomeOtherExternalIdButSameCredentialId =
await SelectedCalendarRepository.create({
...commonProps,
externalId: "externalId2@cal.com",
eventTypeId: 2,
});
await calendarCache.unwatchCalendar({
calendarId: userLevelCalendar.externalId,
eventTypeIds: [userLevelCalendar.eventTypeId],
});
// There is another selectedCalendar with same externalId and credentialId, so actual unsubscription does not happen
expectGoogleUnsubscriptionToNotHaveOccurredAndClearMock();
await expectSelectedCalendarToNotHaveGoogleChannelProps(userLevelCalendar.id);
await calendarCache.unwatchCalendar({
calendarId: eventTypeLevelCalendar.externalId,
eventTypeIds: [eventTypeLevelCalendar.eventTypeId],
});
expectGoogleUnsubscriptionToHaveOccurredAndClearMock([
{
resourceId: "test-resource-id",
channelId: "test-channel-id",
},
]);
// Concerned cache will just have remaining externalIds
expectCacheToBeSet({
credentialId: concernedCache.credentialId,
itemsInKey: [{ id: eventTypeLevelCalendarForSomeOtherExternalIdButSameCredentialId.externalId }],
});
expectCacheToBeSet({
credentialId: someOtherCache.credentialId,
itemsInKey: JSON.parse(someOtherCache.key).items,
});
await expectSelectedCalendarToNotHaveGoogleChannelProps(eventTypeLevelCalendar.id);
// Some other selectedCalendar stays unaffected
await expectSelectedCalendarToHaveGoogleChannelProps(
eventTypeLevelCalendarForSomeOtherExternalIdButSameCredentialId.id,
googleChannelProps
);
});
});
test("fetchAvailabilityAndSetCache should fetch and cache availability for selected calendars grouped by eventTypeId", async () => {
const credentialInDb = await createCredentialInDb();
const calendarService = new CalendarService(credentialInDb);
const selectedCalendars = [
{
externalId: "calendar1@test.com",
eventTypeId: 1,
},
{
externalId: "calendar2@test.com",
eventTypeId: 1,
},
{
externalId: "calendar1@test.com",
eventTypeId: 2,
},
{
externalId: "calendar1@test.com",
eventTypeId: null,
},
{
externalId: "calendar2@test.com",
eventTypeId: null,
},
];
const mockAvailabilityData = { busy: [] };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(calendarService, "fetchAvailability").mockResolvedValue(mockAvailabilityData as any);
const setAvailabilityInCacheSpy = vi.spyOn(calendarService, "setAvailabilityInCache");
await calendarService.fetchAvailabilityAndSetCache(selectedCalendars);
// Should make 2 calls - one for each unique eventTypeId
expect(calendarService.fetchAvailability).toHaveBeenCalledTimes(3);
// First call for eventTypeId 1 calendars
expect(calendarService.fetchAvailability).toHaveBeenNthCalledWith(1, {
timeMin: expect.any(String),
timeMax: expect.any(String),
items: [{ id: "calendar1@test.com" }, { id: "calendar2@test.com" }],
});
// Second call for eventTypeId 2 calendar
expect(calendarService.fetchAvailability).toHaveBeenNthCalledWith(2, {
timeMin: expect.any(String),
timeMax: expect.any(String),
items: [{ id: "calendar1@test.com" }],
});
// Second call for eventTypeId 2 calendar
expect(calendarService.fetchAvailability).toHaveBeenNthCalledWith(3, {
timeMin: expect.any(String),
timeMax: expect.any(String),
items: [{ id: "calendar1@test.com" }, { id: "calendar2@test.com" }],
});
// Should cache results for both calls
expect(setAvailabilityInCacheSpy).toHaveBeenCalledTimes(3);
expect(setAvailabilityInCacheSpy).toHaveBeenCalledWith(
expect.objectContaining({
items: expect.any(Array),
}),
mockAvailabilityData
);
});
test("`updateTokenObject` should update credential in DB as well as myGoogleAuth", async () => {
@@ -12,6 +12,7 @@ import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache";
import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cache.repository.interface";
import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import { uniqueBy } from "@calcom/lib/array";
import {
APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT,
@@ -30,6 +31,7 @@ import type {
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
import type { SelectedCalendarEventTypeIds } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import { invalidateCredential } from "../../_utils/invalidateCredential";
@@ -56,6 +58,14 @@ const GOOGLE_WEBHOOK_URL = `${GOOGLE_WEBHOOK_URL_BASE}/api/integrations/googleca
const isGaxiosResponse = (error: unknown): error is GaxiosResponse<calendar_v3.Schema$Event> =>
typeof error === "object" && !!error && error.hasOwnProperty("config");
type GoogleChannelProps = {
kind?: string | null;
id?: string | null;
resourceId?: string | null;
resourceUri?: string | null;
expiration?: string | null;
};
export default class GoogleCalendarService implements Calendar {
private integrationName = "";
private auth: ReturnType<typeof this.initGoogleAuth>;
@@ -215,6 +225,50 @@ export default class GoogleCalendarService implements Calendar {
return attendees;
};
private async stopWatchingCalendarsInGoogle(
channels: { googleChannelResourceId: string | null; googleChannelId: string | null }[]
) {
const calendar = await this.authedCalendar();
logger.debug(`Unsubscribing from calendars ${channels.map((c) => c.googleChannelId).join(", ")}`);
const uniqueChannels = uniqueBy(channels, ["googleChannelId", "googleChannelResourceId"]);
await Promise.allSettled(
uniqueChannels.map(({ googleChannelResourceId, googleChannelId }) =>
calendar.channels
.stop({
requestBody: {
resourceId: googleChannelResourceId,
id: googleChannelId,
},
})
.catch((err) => {
console.warn(JSON.stringify(err));
})
)
);
}
private async startWatchingCalendarsInGoogle({ calendarId }: { calendarId: string }) {
const calendar = await this.authedCalendar();
logger.debug(`Subscribing to calendar ${calendarId}`);
const res = await calendar.events.watch({
// Calendar identifier. To retrieve calendar IDs call the calendarList.list method. If you want to access the primary calendar of the currently logged in user, use the "primary" keyword.
calendarId,
requestBody: {
// A UUID or similar unique string that identifies this channel.
id: uuid(),
type: "web_hook",
address: GOOGLE_WEBHOOK_URL,
token: process.env.GOOGLE_WEBHOOK_TOKEN,
params: {
// The time-to-live in seconds for the notification channel. Default is 604800 seconds.
ttl: `${Math.round(ONE_MONTH_IN_MS / 1000)}`,
},
},
});
return res.data;
}
async createEvent(calEventRaw: CalendarEvent, credentialId: number): Promise<NewCalendarEventType> {
this.log.debug("Creating event");
const formattedCalEvent = formatCalEvent(calEventRaw);
@@ -718,74 +772,159 @@ export default class GoogleCalendarService implements Calendar {
}
}
async watchCalendar({ calendarId }: { calendarId: string }) {
/**
* It doesn't check if the subscription has expired or not.
* It just creates a new subscription.
*/
async watchCalendar({
calendarId,
eventTypeIds,
}: {
calendarId: string;
eventTypeIds: SelectedCalendarEventTypeIds;
}) {
if (!process.env.GOOGLE_WEBHOOK_TOKEN) {
log.warn("GOOGLE_WEBHOOK_TOKEN is not set, skipping watching calendar");
return;
}
try {
const calendar = await this.authedCalendar();
const res = await calendar.events.watch({
// Calendar identifier. To retrieve calendar IDs call the calendarList.list method. If you want to access the primary calendar of the currently logged in user, use the "primary" keyword.
calendarId,
requestBody: {
// A UUID or similar unique string that identifies this channel.
id: uuid(),
type: "web_hook",
address: GOOGLE_WEBHOOK_URL,
token: process.env.GOOGLE_WEBHOOK_TOKEN,
params: {
// The time-to-live in seconds for the notification channel. Default is 604800 seconds.
ttl: `${Math.round(ONE_MONTH_IN_MS / 1000)}`,
},
},
});
const response = res.data;
await this.upsertSelectedCalendar({
externalId: calendarId,
googleChannelId: response?.id,
googleChannelKind: response?.kind,
googleChannelResourceId: response?.resourceId,
googleChannelResourceUri: response?.resourceUri,
googleChannelExpiration: response?.expiration,
});
return res.data;
} catch (error) {
this.log.error(`Failed to watch calendar ${calendarId}`, error);
await this.upsertSelectedCalendar({
const allCalendarsWithSubscription = await SelectedCalendarRepository.findMany({
where: {
credentialId: this.credential.id,
externalId: calendarId,
error: error instanceof Error ? error.message : "Unknown error",
});
integration: this.integrationName,
googleChannelId: {
not: null,
},
},
});
const otherCalendarsWithSameSubscription = allCalendarsWithSubscription.filter(
(sc) => !eventTypeIds?.includes(sc.eventTypeId)
);
let googleChannelProps: GoogleChannelProps = otherCalendarsWithSameSubscription.length
? {
kind: otherCalendarsWithSameSubscription[0].googleChannelKind,
id: otherCalendarsWithSameSubscription[0].googleChannelId,
resourceId: otherCalendarsWithSameSubscription[0].googleChannelResourceId,
resourceUri: otherCalendarsWithSameSubscription[0].googleChannelResourceUri,
expiration: otherCalendarsWithSameSubscription[0].googleChannelExpiration,
}
: {};
let error: string | undefined;
if (!otherCalendarsWithSameSubscription.length) {
try {
googleChannelProps = await this.startWatchingCalendarsInGoogle({ calendarId });
} catch (error) {
this.log.error(`Failed to watch calendar ${calendarId}`, error);
// We set error to prevent attempting to watch on next cron run
error = error instanceof Error ? error.message : "Unknown error";
}
} else {
logger.info(
`Calendar ${calendarId} is already being watched for event types ${otherCalendarsWithSameSubscription.map(
(sc) => sc.eventTypeId
)}. So, not watching again and instead reusing the existing channel`
);
}
// FIXME: We shouldn't create SelectedCalendar, we should only update if exists
await this.upsertSelectedCalendarsForEventTypeIds(
{
externalId: calendarId,
googleChannelId: googleChannelProps.id,
googleChannelKind: googleChannelProps.kind,
googleChannelResourceId: googleChannelProps.resourceId,
googleChannelResourceUri: googleChannelProps.resourceUri,
googleChannelExpiration: googleChannelProps.expiration,
error,
},
eventTypeIds
);
return googleChannelProps;
}
async unwatchCalendar({ calendarId }: { calendarId: string }) {
/**
* GoogleChannel subscription is only stopped when all selectedCalendars are un-watched.
*/
async unwatchCalendar({
calendarId,
eventTypeIds,
}: {
calendarId: string;
eventTypeIds: SelectedCalendarEventTypeIds;
}) {
const credentialId = this.credential.id;
const sc = await SelectedCalendarRepository.findByExternalId(credentialId, calendarId);
const eventTypeIdsToBeUnwatched = eventTypeIds;
const calendarsWithSameCredentialId = await SelectedCalendarRepository.findMany({
where: {
credentialId,
},
});
const calendarWithSameExternalId = calendarsWithSameCredentialId.filter(
(sc) => sc.externalId === calendarId
);
const calendarsWithSameExternalIdThatAreBeingWatched = calendarWithSameExternalId.filter(
(sc) => !!sc.googleChannelId
);
// Except those requested to be un-watched, other calendars are still being watched
const calendarsWithSameExternalIdToBeStillWatched = calendarsWithSameExternalIdThatAreBeingWatched.filter(
(sc) => !eventTypeIdsToBeUnwatched.includes(sc.eventTypeId)
);
if (calendarsWithSameExternalIdToBeStillWatched.length) {
logger.info(
`There are other ${calendarsWithSameExternalIdToBeStillWatched.length} calendars with the same externalId_credentialId. Not unwatching. Just removing the channelId from this selected calendar`
);
// CalendarCache still need to exist
// We still need to keep the subscription
// Just remove the google channel related fields from this selected calendar
await this.upsertSelectedCalendarsForEventTypeIds(
{
externalId: calendarId,
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
},
eventTypeIdsToBeUnwatched
);
return;
}
const allChannelsForThisCalendarBeingUnwatched = calendarsWithSameExternalIdThatAreBeingWatched.map(
(sc) => ({
googleChannelResourceId: sc.googleChannelResourceId,
googleChannelId: sc.googleChannelId,
})
);
// Delete the calendar cache to force a fresh cache
await prisma.calendarCache.deleteMany({ where: { credentialId } });
const calendar = await this.authedCalendar();
await calendar.channels
.stop({
requestBody: {
resourceId: sc?.googleChannelResourceId,
id: sc?.googleChannelId,
},
})
.catch((err) => {
console.warn(JSON.stringify(err));
});
await this.upsertSelectedCalendar({
externalId: calendarId,
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
});
await this.stopWatchingCalendarsInGoogle(allChannelsForThisCalendarBeingUnwatched);
await this.upsertSelectedCalendarsForEventTypeIds(
{
externalId: calendarId,
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
},
eventTypeIdsToBeUnwatched
);
// Populate the cache back for the remaining calendars, if any
const remainingCalendars =
sc?.credential?.selectedCalendars.filter((sc) => sc.externalId !== calendarId) || [];
calendarsWithSameCredentialId.filter((sc) => sc.externalId !== calendarId) || [];
if (remainingCalendars.length > 0) {
await this.fetchAvailabilityAndSetCache(remainingCalendars);
}
@@ -797,15 +936,36 @@ export default class GoogleCalendarService implements Calendar {
}
async fetchAvailabilityAndSetCache(selectedCalendars: IntegrationCalendar[]) {
const parsedArgs = {
/** Expand the start date to the start of the month */
timeMin: getTimeMin(),
/** Expand the end date to the end of the month */
timeMax: getTimeMax(),
items: selectedCalendars.map((sc) => ({ id: sc.externalId })),
};
const data = await this.fetchAvailability(parsedArgs);
await this.setAvailabilityInCache(parsedArgs, data);
const selectedCalendarsPerEventType = new Map<
SelectedCalendarEventTypeIds[number],
IntegrationCalendar[]
>();
// TODO: Should be done outside of CalendarService as it is applicable to all Apps' CalendarServices
selectedCalendars.reduce((acc, selectedCalendar) => {
const eventTypeId = selectedCalendar.eventTypeId ?? null;
const mapValue = selectedCalendarsPerEventType.get(eventTypeId);
if (mapValue) {
mapValue.push(selectedCalendar);
} else {
acc.set(eventTypeId, [selectedCalendar]);
}
return acc;
}, selectedCalendarsPerEventType);
for (const [_eventTypeId, selectedCalendars] of Array.from(selectedCalendarsPerEventType.entries())) {
const parsedArgs = {
/** Expand the start date to the start of the month */
timeMin: getTimeMin(),
/** Expand the end date to the end of the month */
timeMax: getTimeMax(),
// Dont use eventTypeId in key because it can be used by any eventType
// The only reason we are building it per eventType is because there can be different groups of calendars to lookup the availablity for
items: selectedCalendars.map((sc) => ({ id: sc.externalId })),
};
const data = await this.fetchAvailability(parsedArgs);
await this.setAvailabilityInCache(parsedArgs, data);
}
}
async createSelectedCalendar(
@@ -827,12 +987,33 @@ export default class GoogleCalendarService implements Calendar {
}
return await SelectedCalendarRepository.upsert({
...data,
eventTypeId: data.eventTypeId ?? null,
integration: this.integrationName,
credentialId: this.credential.id,
userId: this.credential.userId,
});
}
async upsertSelectedCalendarsForEventTypeIds(
data: Omit<Prisma.SelectedCalendarUncheckedCreateInput, "integration" | "credentialId" | "userId">,
eventTypeIds: SelectedCalendarEventTypeIds
) {
if (!this.credential.userId) {
logger.error("upsertSelectedCalendarsForEventTypeIds failed. userId is missing.");
return;
}
await SelectedCalendarRepository.upsertManyForEventTypeIds({
data: {
...data,
integration: this.integrationName,
credentialId: this.credential.id,
userId: this.credential.userId,
},
eventTypeIds,
});
}
async getAllCalendars(
calendar: calendar_v3.Calendar,
fields: string[] = ["id", "summary", "primary", "accessRole"]
+9 -1
View File
@@ -16,13 +16,21 @@ export const routingFormOptions = z
})
.optional();
const optionalBooleanOnlyRunTimeValidation = z
.any()
.refine((val) => typeof val === "boolean" || val === undefined)
.optional();
export const appDataSchema = eventTypeAppCardZod.extend({
roundRobinLeadSkip: z.boolean().optional(),
roundRobinSkipCheckRecordOn: z
.nativeEnum(SalesforceRecordEnum)
.default(SalesforceRecordEnum.CONTACT)
.optional(),
ifFreeEmailDomainSkipOwnerCheck: z.boolean().optional(),
// Another way to define z.boolean().optional() through z.any()
// Required to prevent tsc crash. I have no clue why it started crashing suddenly.
// But we need to improve the schemas of all apps that are combined into apps.schemas.generated.ts
ifFreeEmailDomainSkipOwnerCheck: optionalBooleanOnlyRunTimeValidation,
skipContactCreation: z.boolean().optional(),
createEventOn: z.nativeEnum(SalesforceRecordEnum).default(SalesforceRecordEnum.CONTACT).optional(),
createNewContactUnderAccount: z.boolean().optional(),
@@ -28,7 +28,7 @@ const userSelect = Prisma.validator<Prisma.UserArgs>()({
},
});
type User = Prisma.UserGetPayload<typeof userSelect>;
type User = Omit<Prisma.UserGetPayload<typeof userSelect>, "selectedCalendars">;
type PersonAttendeeCommonFields = Pick<User, "id" | "email" | "name" | "locale" | "timeZone" | "username">;
interface ICalendarEventBuilder {
calendarEvent: CalendarEventClass;
+2
View File
@@ -67,10 +67,12 @@ const getCalendarsEvents = async (
/** We just pass the calendars that matched the credential type,
* TODO: Migrate credential type or appId
*/
// Important to have them unique so that
const passedSelectedCalendars = selectedCalendars
.filter((sc) => sc.integration === type)
// Needed to ensure cache keys are consistent
.sort((a, b) => (a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0));
if (!passedSelectedCalendars.length) return [];
/** We extract external Ids so we don't cache too much */
+10 -12
View File
@@ -19,10 +19,11 @@ import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma, { availabilityUserSelect } from "@calcom/prisma";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { EventTypeMetaDataSchema, stringToDayjsZod } from "@calcom/prisma/zod-utils";
import type { EventBusyDetails, IntervalLimitUnit } from "@calcom/types/Calendar";
import type { TimeRange } from "@calcom/types/schedule";
@@ -62,6 +63,7 @@ const _getEventType = async (id: number) => {
id: true,
seatsPerTimeSlot: true,
bookingLimits: true,
useEventLevelSelectedCalendars: true,
parent: {
select: {
team: {
@@ -150,15 +152,7 @@ const getUser = async (...args: Parameters<typeof _getUser>): Promise<ReturnType
};
const _getUser = async (where: Prisma.UserWhereInput) => {
return await prisma.user.findFirst({
where,
select: {
...availabilityUserSelect,
credentials: {
select: credentialForCalendarServiceSelect,
},
},
});
return UserRepository.findForAvailabilityCheck({ where });
};
type GetUser = Awaited<ReturnType<typeof getUser>>;
@@ -380,6 +374,10 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
const getBusyTimesStart = dateFrom.toISOString();
const getBusyTimesEnd = dateTo.toISOString();
const selectedCalendars = eventType?.useEventLevelSelectedCalendars
? EventTypeRepository.getSelectedCalendarsFromUser({ user, eventTypeId: eventType.id })
: user.userLevelSelectedCalendars;
const busyTimes = await monitorCallbackAsync(getBusyTimes, {
credentials: user.credentials,
startTime: getBusyTimesStart,
@@ -390,7 +388,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
username: `${user.username}`,
beforeEventBuffer,
afterEventBuffer,
selectedCalendars: user.selectedCalendars,
selectedCalendars,
seatedEvent: !!eventType?.seatsPerTimeSlot,
rescheduleUid: initialData?.rescheduleUid || null,
duration,
@@ -2,6 +2,7 @@ import type { LocationObject } from "@calcom/app-store/locations";
import { workflowSelect } from "@calcom/ee/workflows/lib/getAllWorkflows";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { parseRecurringEvent } from "@calcom/lib";
import { withSelectedCalendars } from "@calcom/lib/server/repository/user";
import prisma, { userSelect } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import {
@@ -160,14 +161,24 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
},
assignRRMembersUsingSegment: true,
rrSegmentQueryValue: true,
useEventLevelSelectedCalendars: true,
},
});
const { profile, ...restEventType } = eventType;
const { profile, hosts, users, ...restEventType } = eventType;
const isOrgTeamEvent = !!eventType?.team && !!profile?.organizationId;
const hostsWithSelectedCalendars = hosts.map((host) => ({
...host,
user: withSelectedCalendars(host.user),
}));
const usersWithSelectedCalendars = users.map((user) => withSelectedCalendars(user));
return {
...restEventType,
hosts: hostsWithSelectedCalendars,
users: usersWithSelectedCalendars,
metadata: EventTypeMetaDataSchema.parse(eventType?.metadata || {}),
recurringEvent: parseRecurringEvent(eventType?.recurringEvent),
customInputs: customInputSchema.array().parse(eventType?.customInputs || []),
@@ -6,6 +6,7 @@ import { filterHostsByLeadThreshold } from "@calcom/lib/bookings/filterHostsByLe
import { HttpError } from "@calcom/lib/http-error";
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { withSelectedCalendars } from "@calcom/lib/server/repository/user";
import { userSelect } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
@@ -91,7 +92,7 @@ export async function loadAndValidateUsers({
logger.warn({ message: "NewBooking: eventTypeUser.notFound" });
throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
}
users.push(eventTypeUser);
users.push(withSelectedCalendars(eventTypeUser));
}
if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
@@ -10,7 +10,7 @@ import {
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { withSelectedCalendars, UserRepository } from "@calcom/lib/server/repository/user";
import prisma, { userSelect } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
@@ -113,7 +113,8 @@ export const findUsersByUsername = async ({
metadata: true,
},
})
).map((user) => {
).map((_user) => {
const user = withSelectedCalendars(_user);
const profile = profiles?.find((profile) => profile.user.id === user.id) ?? null;
return {
...user,
@@ -1,10 +1,315 @@
/**
* These tests are integration tests that test the flow from receiving a api/book/event request and then verifying
* - database entries created in In-MEMORY DB using prismock
* - emails sent by checking the testEmails global variable
* - webhooks fired by mocking fetch
* - APIs of various apps called by mocking those apps' modules
*
* They don't intend to test what the apps logic should do, but rather test if the apps are called with the correct data. For testing that, once should write tests within each app.
*/
import {
createBookingScenario,
getDate,
getGoogleCalendarCredential,
TestData,
getOrganizer,
getBooker,
getScenarioData,
mockCalendar,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest";
import { expectBookingToBeInDatabase } from "@calcom/web/test/utils/bookingScenario/expects";
import { getMockRequestDataForDynamicGroupBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe } from "vitest";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { describe, expect } from "vitest";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
export type CustomNextApiRequest = NextApiRequest & Request;
export type CustomNextApiResponse = NextApiResponse & Response;
// Local test runs sometime gets too slow
const timeout = process.env.CI ? 5000 : 20000;
describe("handleNewBooking", () => {
setupAndTeardown();
test.todo("Dynamic Group Booking");
describe("Dynamic Group Booking", () => {
test(
`should allow a booking if there is no conflicting booking in any of the users' selectedCalendars`,
async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const groupUserId1 = 101;
const groupUserId2 = 102;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const groupUser1 = getOrganizer({
name: "group-user-1",
username: "group-user-1",
email: "group-user-1@example.com",
id: groupUserId1,
schedules: [TestData.schedules.IstWorkHours],
credentials: [],
selectedCalendars: [],
});
const groupUser2 = getOrganizer({
name: "group-user-2",
username: "group-user-2",
email: "group-user-2@example.com",
id: groupUserId2,
schedules: [TestData.schedules.IstWorkHours],
credentials: [],
selectedCalendars: [],
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [],
webhooks: [
{
userId: groupUserId1,
eventTriggers: ["BOOKING_CREATED"],
subscriberUrl: "http://my-webhook.example.com",
active: true,
eventTypeId: 1,
appId: null,
},
],
users: [groupUser1, groupUser2],
apps: [TestData.apps["google-calendar"]],
})
);
const _calendarMock = mockCalendar("googlecalendar", {
create: {
uid: "MOCK_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
busySlots: [
{
start: `${plus1DateString}T05:00:00.000Z`,
end: `${plus1DateString}T05:15:00.000Z`,
},
],
});
const mockBookingData = getMockRequestDataForDynamicGroupBooking({
data: {
start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`,
end: `${getDate({ dateIncrement: 1 }).dateString}T05:30:00.000Z`,
eventTypeId: 0,
eventTypeSlug: "group-user-1+group-user-2",
user: "group-user-1+group-user-2",
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
const createdBooking = await handleNewBooking(req);
await expectBookingToBeInDatabase({
description: "",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: createdBooking.uid!,
eventTypeId: null,
status: BookingStatus.ACCEPTED,
iCalUID: createdBooking.iCalUID,
});
},
timeout
);
describe("Availablity Check During Booking", () => {
test(
`should fail a booking if there is already a conflicting booking in the first user's selectedCalendars`,
async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const groupUserId1 = 101;
const groupUserId2 = 102;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const groupUser1 = getOrganizer({
name: "group-user-1",
username: "group-user-1",
email: "group-user-1@example.com",
id: groupUserId1,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const groupUser2 = getOrganizer({
name: "group-user-2",
username: "group-user-2",
email: "group-user-2@example.com",
id: groupUserId2,
schedules: [TestData.schedules.IstWorkHours],
// Second user has no selected calendars and credentials
credentials: [],
selectedCalendars: [],
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [],
users: [groupUser1, groupUser2],
apps: [TestData.apps["google-calendar"]],
})
);
const _calendarMock = mockCalendar("googlecalendar", {
create: {
uid: "MOCK_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
busySlots: [
{
start: `${plus1DateString}T05:00:00.000Z`,
end: `${plus1DateString}T05:15:00.000Z`,
},
],
});
const mockBookingData = getMockRequestDataForDynamicGroupBooking({
data: {
start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`,
end: `${getDate({ dateIncrement: 1 }).dateString}T05:30:00.000Z`,
eventTypeId: 0,
eventTypeSlug: "group-user-1+group-user-2",
user: "group-user-1+group-user-2",
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
await expect(async () => await handleNewBooking(req)).rejects.toThrowError(
ErrorCode.HostsUnavailableForBooking
);
},
timeout
);
test(
`should fail a booking if there is already a conflicting booking in the second user's selectedCalendars`,
async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const groupUserId1 = 101;
const groupUserId2 = 102;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const groupUser1 = getOrganizer({
name: "group-user-1",
username: "group-user-1",
email: "group-user-1@example.com",
id: groupUserId1,
schedules: [TestData.schedules.IstWorkHours],
credentials: [],
selectedCalendars: [],
});
const groupUser2 = getOrganizer({
name: "group-user-2",
username: "group-user-2",
email: "group-user-2@example.com",
id: groupUserId2,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [],
webhooks: [
{
userId: groupUserId1,
eventTriggers: ["BOOKING_CREATED"],
subscriberUrl: "http://my-webhook.example.com",
active: true,
eventTypeId: 1,
appId: null,
},
],
users: [groupUser1, groupUser2],
apps: [TestData.apps["google-calendar"]],
})
);
const _calendarMock = mockCalendar("googlecalendar", {
create: {
uid: "MOCK_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
busySlots: [
{
start: `${plus1DateString}T05:00:00.000Z`,
end: `${plus1DateString}T05:15:00.000Z`,
},
],
});
const mockBookingData = getMockRequestDataForDynamicGroupBooking({
data: {
start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`,
end: `${getDate({ dateIncrement: 1 }).dateString}T05:30:00.000Z`,
eventTypeId: 0,
eventTypeSlug: "group-user-1+group-user-2",
user: "group-user-1+group-user-2",
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
await expect(async () => await handleNewBooking(req)).rejects.toThrowError(
ErrorCode.HostsUnavailableForBooking
);
},
timeout
);
});
});
});
@@ -1712,6 +1712,267 @@ describe("handleNewBooking", () => {
},
timeout
);
test(
`should allow a booking even if there is already a booking in the organizer's selectedCalendars(Single Calendar) with the overlapping time but useEventLevelSelectedCalendars is false and SelectedCalendar is for an event`,
async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const organizerId = 101;
const eventTypeId = 1;
const useEventLevelSelectedCalendars = false;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: organizerId,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [{ ...TestData.selectedCalendars.google, eventTypeId }],
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: eventTypeId,
slotInterval: 30,
length: 30,
useEventLevelSelectedCalendars,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"]],
})
);
const _calendarMock = mockCalendar("googlecalendar", {
create: {
uid: "MOCK_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
busySlots: [
{
start: `${plus1DateString}T05:00:00.000Z`,
end: `${plus1DateString}T05:15:00.000Z`,
},
],
});
const mockBookingData = getMockRequestDataForBooking({
data: {
start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`,
end: `${getDate({ dateIncrement: 1 }).dateString}T05:30:00.000Z`,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
const createdBooking = await handleNewBooking(req);
expectBookingToBeInDatabase({
description: "",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: createdBooking.uid!,
eventTypeId: mockBookingData.eventTypeId,
status: BookingStatus.ACCEPTED,
});
},
timeout
);
describe("Event level selected calendars - useEventLevelSelectedCalendars:true", () => {
test(
`should fail a booking if there is already a booking in the organizer's selectedCalendars(Single Calendar) with the overlapping time as chosen in the event type`,
async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking"))
.default;
const organizerId = 101;
const eventTypeId = 1;
const useEventLevelSelectedCalendars = true;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: organizerId,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [{ ...TestData.selectedCalendars.google, eventTypeId }],
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: eventTypeId,
slotInterval: 30,
length: 30,
useEventLevelSelectedCalendars,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"]],
})
);
const _calendarMock = mockCalendar("googlecalendar", {
create: {
uid: "MOCK_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
busySlots: [
{
start: `${plus1DateString}T05:00:00.000Z`,
end: `${plus1DateString}T05:15:00.000Z`,
},
],
});
const mockBookingData = getMockRequestDataForBooking({
data: {
start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`,
end: `${getDate({ dateIncrement: 1 }).dateString}T05:30:00.000Z`,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
await expect(async () => await handleNewBooking(req)).rejects.toThrowError(
ErrorCode.NoAvailableUsersFound
);
},
timeout
);
test(
`should allow a booking even if there is already a booking in the organizer's selectedCalendars(Single Calendar) with the overlapping time and useEventLevelSelectedCalendars is true but the selectedCalendar is of different eventType`,
async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking"))
.default;
const organizerId = 101;
const eventTypeId = 1;
const anotherEventTypeId = 2;
const useEventLevelSelectedCalendars = true;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: organizerId,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [
{ ...TestData.selectedCalendars.google, eventTypeId: anotherEventTypeId },
],
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: eventTypeId,
slotInterval: 30,
length: 30,
useEventLevelSelectedCalendars,
users: [
{
id: 101,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"]],
})
);
const _calendarMock = mockCalendar("googlecalendar", {
create: {
uid: "MOCK_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
busySlots: [
{
start: `${plus1DateString}T05:00:00.000Z`,
end: `${plus1DateString}T05:15:00.000Z`,
},
],
});
const mockBookingData = getMockRequestDataForBooking({
data: {
start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`,
end: `${getDate({ dateIncrement: 1 }).dateString}T05:30:00.000Z`,
eventTypeId: 1,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
const createdBooking = await handleNewBooking(req);
expectBookingToBeInDatabase({
description: "",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: createdBooking.uid!,
eventTypeId: mockBookingData.eventTypeId,
status: BookingStatus.ACCEPTED,
});
},
timeout
);
});
},
timeout
);
@@ -6,6 +6,7 @@ import type { EventTypeAppsList } from "@calcom/app-store/utils";
import type { DefaultEvent } from "@calcom/lib/defaultEvents";
import type { PaymentAppData } from "@calcom/lib/getPaymentAppData";
import type { userSelect } from "@calcom/prisma";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { Booking } from "./createBooking";
@@ -23,7 +24,7 @@ import type { getEventTypeResponse } from "./getEventTypesFromDB";
import type { BookingType, OriginalRescheduledBooking } from "./getOriginalRescheduledBooking";
import type { LoadedUsers } from "./loadUsers";
type User = Prisma.UserGetPayload<typeof userSelect>;
type User = Omit<Prisma.UserGetPayload<typeof userSelect>, "selectedCalendars">;
export type OrganizerUser = LoadedUsers[number] & {
isFixed?: boolean;
@@ -58,6 +59,8 @@ export type IsFixedAwareUser = User & {
organization?: { slug: string };
priority?: number;
weight?: number;
userLevelSelectedCalendars: SelectedCalendar[];
allSelectedCalendars: SelectedCalendar[];
};
export type NewBookingEventType = DefaultEvent | getEventTypeResponse;
@@ -0,0 +1,23 @@
- googleChannelExpiration is a string which might cause problems with date/number comparisons like `lt` in prisma, which can possibly cause problems with date comparisons. Though not practical right now
## Responsibilities
- File:`/api/calendar-cache/cron` - Runs every minute and takes care of two things:
1. Watching calendars
- Identifies calendars that are not watched(identified by `googleChannelId` being null) and watches them
- When feature is enabled, it starts watching all related calendars
- This is how a newly added SelectedCalendar record gets its googleChannel props set
- CalendarService.watchCalendar ensures that the new subscription is not created unnecessarily, reusing existing SelectedCalendar googleChannel props when possible.
- Identifies calendars that are watched but have their subscription about to expire(identified by `googleChannelExpiration` being less than current tomorrow's date) and watches them again
2. Unwatching calendars
- It takes care of cleaning up when the calendar-cache feature flag is disabled.
- File:`calendar-cache-cleanup`
- Deletes all CalendarCache records that have expired
- File:`googlecalendar/api/webhook`
- Populates CalendarCache records by fetching availability.
## Availability Checking Flow
- CalendarService.getAvailability is called
- It checks if CalendarCache exists for the calendar
- If it does, it fetches availability from CalendarCache
- If it doesn't, it fetches availability from the third party calendar service
- It doesn't populate/update CalendarCache. That is solely the responsibility of webhook
+56 -15
View File
@@ -3,6 +3,7 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import type { SelectedCalendarEventTypeIds } from "@calcom/types/Calendar";
import { CalendarCache } from "../calendar-cache";
@@ -13,28 +14,68 @@ const validateRequest = (req: NextApiRequest) => {
}
};
function logRejected(result: PromiseSettledResult<unknown>) {
if (result.status === "rejected") {
console.error(result.reason);
}
}
function getUniqueCalendarsByExternalId<
T extends { externalId: string; eventTypeId: number | null; credentialId: number | null }
>(calendars: T[]) {
type ExternalId = string;
return calendars.reduce(
(acc, sc) => {
if (!acc[sc.externalId]) {
acc[sc.externalId] = {
eventTypeIds: [sc.eventTypeId],
credentialId: sc.credentialId,
};
} else {
acc[sc.externalId].eventTypeIds.push(sc.eventTypeId);
}
return acc;
},
{} as Record<
ExternalId,
{
eventTypeIds: SelectedCalendarEventTypeIds;
credentialId: number | null;
}
>
);
}
const handleCalendarsToUnwatch = async () => {
const calendarsToUnwatch = await SelectedCalendarRepository.getNextBatchToUnwatch();
const calendarsToUnwatch = await SelectedCalendarRepository.getNextBatchToUnwatch(500);
const calendarsWithEventTypeIdsGroupedTogether = getUniqueCalendarsByExternalId(calendarsToUnwatch);
const result = await Promise.allSettled(
calendarsToUnwatch.map(async (sc) => {
if (!sc.credentialId) return;
const cc = await CalendarCache.initFromCredentialId(sc.credentialId);
await cc.unwatchCalendar({ calendarId: sc.externalId });
})
Object.entries(calendarsWithEventTypeIdsGroupedTogether).map(
async ([externalId, { eventTypeIds, credentialId }]) => {
if (!credentialId) return;
const cc = await CalendarCache.initFromCredentialId(credentialId);
await cc.unwatchCalendar({ calendarId: externalId, eventTypeIds });
}
)
);
result.forEach(logRejected);
return result;
};
const handleCalendarsToWatch = async () => {
const calendarsToWatch = await SelectedCalendarRepository.getNextBatchToWatch();
const result = await Promise.allSettled(
calendarsToWatch.map(async (sc) => {
if (!sc.credentialId) return;
const cc = await CalendarCache.initFromCredentialId(sc.credentialId);
await cc.watchCalendar({ calendarId: sc.externalId });
})
);
const handleCalendarsToWatch = async () => {
const calendarsToWatch = await SelectedCalendarRepository.getNextBatchToWatch(500);
const calendarsWithEventTypeIdsGroupedTogether = getUniqueCalendarsByExternalId(calendarsToWatch);
const result = await Promise.allSettled(
Object.entries(calendarsWithEventTypeIdsGroupedTogether).map(
async ([externalId, { credentialId, eventTypeIds }]) => {
if (!credentialId) return;
const cc = await CalendarCache.initFromCredentialId(credentialId);
await cc.watchCalendar({ calendarId: externalId, eventTypeIds });
}
)
);
result.forEach(logRejected);
return result;
};
@@ -1,10 +1,12 @@
import type { CalendarCache, Prisma } from "@prisma/client";
import type { SelectedCalendarEventTypeIds } from "@calcom/types/Calendar";
export type FreeBusyArgs = { timeMin: string; timeMax: string; items: { id: string }[] };
export interface ICalendarCacheRepository {
watchCalendar(args: { calendarId: string }): Promise<any>;
unwatchCalendar(args: { calendarId: string }): Promise<any>;
watchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }): Promise<any>;
unwatchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }): Promise<any>;
upsertCachedAvailability(
credentialId: number,
args: FreeBusyArgs,
@@ -1,11 +1,12 @@
import type { Prisma } from "@prisma/client";
import { uniqueBy } from "@calcom/lib/array";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import type { Calendar } from "@calcom/types/Calendar";
import type { Calendar, SelectedCalendarEventTypeIds } from "@calcom/types/Calendar";
import type { ICalendarCacheRepository } from "./calendar-cache.repository.interface";
import { watchCalendarSchema } from "./calendar-cache.repository.schema";
import { getTimeMax, getTimeMin } from "./lib/datesForCache";
const log = logger.getSubLogger({ prefix: ["CalendarCacheRepository"] });
@@ -18,8 +19,10 @@ const CACHING_TIME = ONE_MONTH_IN_MS;
export function parseKeyForCache(args: FreeBusyArgs): string {
const { timeMin: _timeMin, timeMax: _timeMax, items } = args;
// Ensure that calendarIds are unique
const uniqueItems = uniqueBy(items, ["id"]);
const { timeMin, timeMax } = handleMinMax(_timeMin, _timeMax);
const key = JSON.stringify({ timeMin, timeMax, items });
const key = JSON.stringify({ timeMin, timeMax, items: uniqueItems });
return key;
}
@@ -39,41 +42,31 @@ export class CalendarCacheRepository implements ICalendarCacheRepository {
constructor(calendar: Calendar | null = null) {
this.calendar = calendar;
}
async watchCalendar(args: { calendarId: string }) {
const { calendarId } = args;
async watchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }) {
const { calendarId, eventTypeIds } = args;
if (typeof this.calendar?.watchCalendar !== "function") {
log.info(
'[handleWatchCalendar] Skipping watching calendar due to calendar not having "watchCalendar" method'
);
return;
}
const response = await this.calendar?.watchCalendar({ calendarId });
const parsedResponse = watchCalendarSchema.safeParse(response);
if (!parsedResponse.success) {
log.info(
"[handleWatchCalendar] Received invalid response from calendar.watchCalendar, skipping watching calendar"
);
return;
}
return parsedResponse.data;
await this.calendar?.watchCalendar({ calendarId, eventTypeIds });
}
async unwatchCalendar(args: { calendarId: string }) {
const { calendarId } = args;
async unwatchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }) {
const { calendarId, eventTypeIds } = args;
if (typeof this.calendar?.unwatchCalendar !== "function") {
log.info(
'[unwatchCalendar] Skipping watching calendar due to calendar not having "watchCalendar" method'
'[unwatchCalendar] Skipping unwatching calendar due to calendar not having "unwatchCalendar" method'
);
return;
}
const response = await this.calendar?.unwatchCalendar({ calendarId });
const response = await this.calendar?.unwatchCalendar({ calendarId, eventTypeIds });
return response;
}
async getCachedAvailability(credentialId: number, args: FreeBusyArgs) {
const key = parseKeyForCache(args);
log.info("Getting cached availability", key);
const cached = await prisma.calendarCache.findUnique({
where: {
credentialId_key: {
@@ -83,6 +76,7 @@ export class CalendarCacheRepository implements ICalendarCacheRepository {
expiresAt: { gte: new Date(Date.now()) },
},
});
log.info("Got cached availability", safeStringify({ key, cached }));
return cached;
}
async upsertCachedAvailability(
+26 -3
View File
@@ -17,9 +17,18 @@ export type ICalendarSwitchProps = {
isLastItemInList?: boolean;
destination?: boolean;
credentialId: number;
eventTypeId: number | null;
disabled?: boolean;
};
type UserCalendarSwitchProps = Omit<ICalendarSwitchProps, "eventTypeId">;
type EventCalendarSwitchProps = ICalendarSwitchProps & {
eventTypeId: number;
};
const CalendarSwitch = (props: ICalendarSwitchProps) => {
const { title, externalId, type, isChecked, name, isLastItemInList = false, credentialId } = props;
const { title, externalId, type, isChecked, name, credentialId, eventTypeId, disabled } = props;
const [checkedInternal, setCheckedInternal] = useState(isChecked);
const utils = trpc.useUtils();
const { t } = useLocale();
@@ -30,6 +39,7 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => {
externalId: externalId,
// new URLSearchParams does not accept numbers
credentialId: String(credentialId),
...(eventTypeId ? { eventTypeId: String(eventTypeId) } : {}),
};
if (isOn) {
@@ -72,14 +82,19 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => {
<Switch
id={externalId}
checked={checkedInternal}
disabled={mutation.isPending}
disabled={disabled || mutation.isPending}
onCheckedChange={async (isOn: boolean) => {
setCheckedInternal(isOn);
await mutation.mutate({ isOn });
}}
/>
</div>
<label className="ml-3 break-all text-sm font-medium leading-5" htmlFor={externalId}>
<label
className={classNames(
"ml-3 break-all text-sm font-medium leading-5",
disabled ? "cursor-not-allowed opacity-25" : "cursor-pointer"
)}
htmlFor={externalId}>
{name}
</label>
{!!props.destination && (
@@ -95,4 +110,12 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => {
);
};
export const UserCalendarSwitch = (props: UserCalendarSwitchProps) => {
return <CalendarSwitch {...props} eventTypeId={null} />;
};
export const EventCalendarSwitch = (props: EventCalendarSwitchProps) => {
return <CalendarSwitch {...props} />;
};
export { CalendarSwitch };
@@ -12,7 +12,7 @@ export enum UsageEvent {
USER = "user",
}
interface ILicenseKeyService {
export interface ILicenseKeyService {
incrementUsage(usageEvent?: UsageEvent): Promise<any>;
checkLicense(): Promise<boolean>;
}
@@ -185,6 +185,7 @@ export default async function handleChildrenEventTypes({
*/
rrSegmentQueryValue: undefined,
assignRRMembersUsingSegment: false,
useEventLevelSelectedCalendars: false,
},
});
})
@@ -1,8 +1,14 @@
import { useState } from "react";
import { useState, Suspense } from "react";
import type { Dispatch, SetStateAction } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { z } from "zod";
import { useAtomsContext, useIsPlatform } from "@calcom/atoms/monorepo";
import {
SelectedCalendarsSettingsWebWrapper,
SelectedCalendarSettingsScope,
SelectedCalendarsSettingsWebWrapperSkeleton,
} from "@calcom/atoms/monorepo";
import type { EventNameObjectType } from "@calcom/core/event";
import { getEventName } from "@calcom/core/event";
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
@@ -102,14 +108,283 @@ export type EventAdvancedBaseProps = Pick<EventTypeSetupProps, "eventType" | "te
};
export type EventAdvancedTabProps = EventAdvancedBaseProps & {
calendarsQueryData?: RouterOutputs["viewer"]["connectedCalendars"];
calendarsQuery: {
data?: RouterOutputs["viewer"]["connectedCalendars"];
isPending: boolean;
error: unknown;
};
showBookerLayoutSelector: boolean;
};
type CalendarSettingsProps = {
eventType: EventAdvancedTabProps["eventType"];
customClassNames?: EventAdvancedTabCustomClassNames;
calendarsQuery: NonNullable<EventAdvancedTabProps["calendarsQuery"]>;
eventNameLocked: {
disabled: boolean;
LockedIcon: false | JSX.Element;
};
eventNamePlaceholder: string;
setShowEventNameTip: Dispatch<SetStateAction<boolean>>;
showToast: EventAdvancedTabProps["showToast"];
verifiedSecondaryEmails: { label: string; value: number }[];
userEmail: string;
isTeamEventType: boolean;
isChildrenManagedEventType: boolean;
};
const destinationCalendarComponents = {
DestinationCalendarSettings({
showConnectedCalendarSettings,
customClassNames,
calendarsQuery,
eventNameLocked,
eventNamePlaceholder,
setShowEventNameTip,
verifiedSecondaryEmails,
userEmail,
isTeamEventType,
showToast,
}: Omit<CalendarSettingsProps, "eventType" | "isChildrenManagedEventType"> & {
showConnectedCalendarSettings: boolean;
}) {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const [useEventTypeDestinationCalendarEmail, setUseEventTypeDestinationCalendarEmail] = useState(
formMethods.getValues("useEventTypeDestinationCalendarEmail")
);
const selectedSecondaryEmailId = formMethods.getValues("secondaryEmailId") || -1;
return (
<div className="border-subtle space-y-6 rounded-lg border p-6">
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
{showConnectedCalendarSettings && (
<div
className={classNames(
"flex w-full flex-col",
customClassNames?.destinationCalendar?.container
)}>
<Label
className={classNames(
"text-emphasis mb-0 font-medium",
customClassNames?.destinationCalendar?.label
)}>
{t("add_to_calendar")}
</Label>
<Controller
name="destinationCalendar"
render={({ field: { onChange, value } }) => (
<DestinationCalendarSelector
value={value ? value.externalId : undefined}
onChange={onChange}
hidePlaceholder
hideAdvancedText
calendarsQueryData={calendarsQuery.data}
customClassNames={customClassNames?.destinationCalendar}
/>
)}
/>
<p className="text-subtle text-sm">{t("select_which_cal")}</p>
</div>
)}
<div className={classNames("w-full", customClassNames?.eventName?.container)}>
<TextField
label={t("event_name_in_calendar")}
labelClassName={customClassNames?.eventName?.label}
addOnClassname={customClassNames?.eventName?.addOn}
className={customClassNames?.eventName?.input}
type="text"
{...eventNameLocked}
placeholder={eventNamePlaceholder}
{...formMethods.register("eventName")}
addOnSuffix={
<Button
color="minimal"
size="sm"
aria-label="edit custom name"
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
onClick={() => setShowEventNameTip((old) => !old)}>
<Icon name="pencil" className="h-4 w-4" />
</Button>
}
/>
</div>
</div>
<div className="space-y-2">
{showConnectedCalendarSettings && (
<div className={classNames("w-full", customClassNames?.addToCalendarEmailOrganizer?.container)}>
<Switch
tooltip={t("if_enabled_email_address_as_organizer")}
label={
<>
{t("display_add_to_calendar_organizer")}
<Icon
name="info"
className="text-default hover:text-attention hover:bg-attention ms-1 inline h-4 w-4 rounded-md"
/>
</>
}
checked={useEventTypeDestinationCalendarEmail}
onCheckedChange={(val) => {
setUseEventTypeDestinationCalendarEmail(val);
formMethods.setValue("useEventTypeDestinationCalendarEmail", val, {
shouldDirty: true,
});
if (val) {
showToast(t("reconnect_calendar_to_use"), "warning");
}
}}
/>
</div>
)}
{!useEventTypeDestinationCalendarEmail &&
verifiedSecondaryEmails.length > 0 &&
!isTeamEventType && (
<div className={cx("flex w-full flex-col", showConnectedCalendarSettings && "pl-11")}>
<SelectField
placeholder={
selectedSecondaryEmailId === -1 && (
<span className="text-default min-w-0 overflow-hidden truncate whitespace-nowrap">
<Badge variant="blue">{t("default")}</Badge> {userEmail}
</span>
)
}
className={customClassNames?.addToCalendarEmailOrganizer?.emailSelect?.select}
containerClassName={customClassNames?.addToCalendarEmailOrganizer?.emailSelect?.container}
onChange={(option) =>
formMethods.setValue("secondaryEmailId", option?.value, { shouldDirty: true })
}
value={verifiedSecondaryEmails.find(
(secondaryEmail) =>
selectedSecondaryEmailId !== -1 && secondaryEmail.value === selectedSecondaryEmailId
)}
options={verifiedSecondaryEmails}
/>
<p
className={classNames(
"text-subtle mt-2 text-sm",
customClassNames?.addToCalendarEmailOrganizer?.emailSelect?.displayEmailLabel
)}>
{t("display_email_as_organizer")}
</p>
</div>
)}
</div>
</div>
);
},
DestinationCalendarSettingsSkeleton() {
return (
<div className="border-subtle space-y-6 rounded-lg border p-6">
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
<div className="flex w-full flex-col">
<div className="h-4 w-32 animate-pulse rounded-md bg-gray-200" />
<div className="mt-2 h-10 w-full animate-pulse rounded-md bg-gray-200" />
<div className="mt-2 h-4 w-48 animate-pulse rounded-md bg-gray-200" />
</div>
<div className="w-full">
<div className="h-4 w-32 animate-pulse rounded-md bg-gray-200" />
<div className="mt-2 h-10 w-full animate-pulse rounded-md bg-gray-200" />
</div>
</div>
<div className="space-y-2">
<div className="h-6 w-64 animate-pulse rounded-md bg-gray-200" />
<div className="h-10 w-full animate-pulse rounded-md bg-gray-200" />
<div className="h-4 w-48 animate-pulse rounded-md bg-gray-200" />
</div>
</div>
);
},
};
const calendarComponents = {
CalendarSettingsSkeleton() {
return (
<div>
<destinationCalendarComponents.DestinationCalendarSettingsSkeleton />
<SelectedCalendarsSettingsWebWrapperSkeleton />
</div>
);
},
CalendarSettings({
eventType,
calendarsQuery,
verifiedSecondaryEmails,
userEmail,
isTeamEventType,
isChildrenManagedEventType,
customClassNames,
eventNameLocked,
eventNamePlaceholder,
setShowEventNameTip,
showToast,
}: CalendarSettingsProps) {
const formMethods = useFormContext<FormValues>();
/**
* Only display calendar selector if user has connected calendars AND if it's not
* a team event. Since we don't have logic to handle each attendee calendar (for now).
*/
const isConnectedCalendarSettingsApplicable = !isTeamEventType || isChildrenManagedEventType;
const isConnectedCalendarSettingsLoading = calendarsQuery.isPending;
const showConnectedCalendarSettings =
!!calendarsQuery.data?.connectedCalendars.length && isConnectedCalendarSettingsApplicable;
const selectedCalendarSettingsScope = formMethods.getValues("useEventLevelSelectedCalendars")
? SelectedCalendarSettingsScope.EventType
: SelectedCalendarSettingsScope.User;
const destinationCalendar = calendarsQuery.data?.destinationCalendar;
if (isConnectedCalendarSettingsLoading && isConnectedCalendarSettingsApplicable) {
return <calendarComponents.CalendarSettingsSkeleton />;
}
return (
<div>
<destinationCalendarComponents.DestinationCalendarSettings
verifiedSecondaryEmails={verifiedSecondaryEmails}
userEmail={userEmail}
isTeamEventType={isTeamEventType}
calendarsQuery={calendarsQuery}
eventNameLocked={eventNameLocked}
eventNamePlaceholder={eventNamePlaceholder}
setShowEventNameTip={setShowEventNameTip}
showToast={showToast}
showConnectedCalendarSettings={showConnectedCalendarSettings}
customClassNames={customClassNames}
/>
<div>
{isConnectedCalendarSettingsApplicable
? showConnectedCalendarSettings && (
<div className="mt-4">
<Suspense fallback={<SelectedCalendarsSettingsWebWrapperSkeleton />}>
<SelectedCalendarsSettingsWebWrapper
eventTypeId={eventType.id}
disabledScope={SelectedCalendarSettingsScope.User}
disableConnectionModification={true}
scope={selectedCalendarSettingsScope}
destinationCalendarId={destinationCalendar?.externalId}
setScope={(scope) => {
const chosenScopeIsEventLevel = scope === SelectedCalendarSettingsScope.EventType;
formMethods.setValue("useEventLevelSelectedCalendars", chosenScopeIsEventLevel, {
shouldDirty: true,
});
}}
/>
</Suspense>
</div>
)
: null}
</div>
</div>
);
},
};
export const EventAdvancedTab = ({
eventType,
team,
calendarsQueryData,
calendarsQuery,
user,
isUserLoading,
showToast,
@@ -128,9 +403,6 @@ export const EventAdvancedTab = ({
formMethods.getValues("multiplePrivateLinks")?.length !== 0
);
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!formMethods.getValues("successRedirectUrl"));
const [useEventTypeDestinationCalendarEmail, setUseEventTypeDestinationCalendarEmail] = useState(
formMethods.getValues("useEventTypeDestinationCalendarEmail")
);
const bookingFields: Prisma.JsonObject = {};
const workflows = eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow);
@@ -221,9 +493,6 @@ export const EventAdvancedTab = ({
}
);
const displayDestinationCalendarSelector =
!!calendarsQueryData?.connectedCalendars?.length && (!team || isChildrenManagedEventType);
let verifiedSecondaryEmails = [
{
label: user?.email || "",
@@ -247,125 +516,23 @@ export const EventAdvancedTab = ({
userEmail = removePlatformClientIdFromEmail(userEmail, platformContext.clientId);
}
const selectedSecondaryEmailId = formMethods.getValues("secondaryEmailId") || -1;
return (
<div className="flex flex-col space-y-4">
{/**
* Only display calendar selector if user has connected calendars AND if it's not
* a team event. Since we don't have logic to handle each attendee calendar (for now).
* This will fallback to each user selected destination calendar.
*/}
<div className="border-subtle space-y-6 rounded-lg border p-6">
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
{displayDestinationCalendarSelector && (
<div
className={classNames(
"flex w-full flex-col",
customClassNames?.destinationCalendar?.container
)}>
<Label
className={classNames(
"text-emphasis mb-0 font-medium",
customClassNames?.destinationCalendar?.label
)}>
{t("add_to_calendar")}
</Label>
<Controller
name="destinationCalendar"
render={({ field: { onChange, value } }) => (
<DestinationCalendarSelector
value={value ? value.externalId : undefined}
onChange={onChange}
hidePlaceholder
hideAdvancedText
calendarsQueryData={calendarsQueryData}
customClassNames={customClassNames?.destinationCalendar}
/>
)}
/>
<p className="text-subtle text-sm">{t("select_which_cal")}</p>
</div>
)}
<div className={classNames("w-full", customClassNames?.eventName?.container)}>
<TextField
label={t("event_name_in_calendar")}
labelClassName={customClassNames?.eventName?.label}
addOnClassname={customClassNames?.eventName?.addOn}
className={customClassNames?.eventName?.input}
type="text"
{...eventNameLocked}
placeholder={eventNamePlaceholder}
{...formMethods.register("eventName")}
addOnSuffix={
<Button
color="minimal"
size="sm"
aria-label="edit custom name"
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
onClick={() => setShowEventNameTip((old) => !old)}>
<Icon name="pencil" className="h-4 w-4" />
</Button>
}
/>
</div>
</div>
<div className="space-y-2">
{displayDestinationCalendarSelector && (
<div className={classNames("w-full", customClassNames?.addToCalendarEmailOrganizer?.container)}>
<Switch
tooltip={t("if_enabled_email_address_as_organizer")}
label={
<>
{t("display_add_to_calendar_organizer")}
<Icon
name="info"
className="text-default hover:text-attention hover:bg-attention ms-1 inline h-4 w-4 rounded-md"
/>
</>
}
checked={useEventTypeDestinationCalendarEmail}
onCheckedChange={(val) => {
setUseEventTypeDestinationCalendarEmail(val);
formMethods.setValue("useEventTypeDestinationCalendarEmail", val, { shouldDirty: true });
if (val) {
showToast(t("reconnect_calendar_to_use"), "warning");
}
}}
/>
</div>
)}
{!useEventTypeDestinationCalendarEmail && verifiedSecondaryEmails.length > 0 && !team && (
<div className={cx("flex w-full flex-col", displayDestinationCalendarSelector && "pl-11")}>
<SelectField
placeholder={
selectedSecondaryEmailId === -1 && (
<span className="text-default min-w-0 overflow-hidden truncate whitespace-nowrap">
<Badge variant="blue">{t("default")}</Badge> {userEmail}
</span>
)
}
className={customClassNames?.addToCalendarEmailOrganizer?.emailSelect?.select}
containerClassName={customClassNames?.addToCalendarEmailOrganizer?.emailSelect?.container}
onChange={(option) =>
formMethods.setValue("secondaryEmailId", option?.value, { shouldDirty: true })
}
value={verifiedSecondaryEmails.find(
(secondaryEmail) =>
selectedSecondaryEmailId !== -1 && secondaryEmail.value === selectedSecondaryEmailId
)}
options={verifiedSecondaryEmails}
/>
<p
className={classNames(
"text-subtle mt-2 text-sm",
customClassNames?.addToCalendarEmailOrganizer?.emailSelect?.displayEmailLabel
)}>
{t("display_email_as_organizer")}
</p>
</div>
)}
</div>
</div>
{!isPlatform && (
<calendarComponents.CalendarSettings
verifiedSecondaryEmails={verifiedSecondaryEmails}
userEmail={userEmail}
calendarsQuery={calendarsQuery}
isTeamEventType={!!team}
isChildrenManagedEventType={isChildrenManagedEventType}
customClassNames={customClassNames}
eventNameLocked={eventNameLocked}
eventNamePlaceholder={eventNamePlaceholder}
setShowEventNameTip={setShowEventNameTip}
showToast={showToast}
eventType={eventType}
/>
)}
{showBookerLayoutSelector && (
<BookerLayoutSelector
fallbackToUserSettings
@@ -93,6 +93,7 @@ export type FormValues = {
aiPhoneCallConfig: PhoneCallConfig;
customInputs: CustomInputParsed[];
schedule: number | null;
useEventLevelSelectedCalendars: boolean;
periodType: PeriodType;
/**
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { uniqueBy } from "./array";
describe("uniqueBy", () => {
it("should remove duplicates based on single key", () => {
const input = [
{ id: 1, name: "John" },
{ id: 1, name: "Jane" },
{ id: 2, name: "Doe" },
];
const result = uniqueBy(input, ["id"]);
expect(result).toHaveLength(2);
expect(result).toEqual([
{ id: 1, name: "John" },
{ id: 2, name: "Doe" },
]);
});
it("should remove duplicates based on multiple keys", () => {
const input = [
{ id: 1, type: "A", value: "first" },
{ id: 1, type: "A", value: "second" },
{ id: 1, type: "B", value: "third" },
{ id: 2, type: "A", value: "fourth" },
];
const result = uniqueBy(input, ["id", "type"]);
expect(result).toHaveLength(3);
expect(result).toEqual([
{ id: 1, type: "A", value: "first" },
{ id: 1, type: "B", value: "third" },
{ id: 2, type: "A", value: "fourth" },
]);
});
it("should handle empty array", () => {
const input: Array<{ id: number }> = [];
const result = uniqueBy(input, ["id"]);
expect(result).toEqual([]);
});
it("should handle array with single item", () => {
const input = [{ id: 1, name: "John" }];
const result = uniqueBy(input, ["id"]);
expect(result).toEqual(input);
});
});
+5
View File
@@ -1 +1,6 @@
export const notUndefined = <T>(val: T | undefined): val is T => Boolean(val);
export const uniqueBy = <T extends { [key: string]: unknown }>(array: T[], keys: (keyof T)[]) => {
return array.filter(
(item, index, self) => index === self.findIndex((t) => keys.every((key) => t[key] === item[key]))
);
};
+19 -12
View File
@@ -1,5 +1,8 @@
import prisma from "@calcom/prisma";
/**
* Will update all the selected calendars with the same userId, integration and externalId including user-level and event-type-level calendars with new credentialId
*/
export async function renewSelectedCalendarCredentialId(
selectedCalendarWhereUnique: {
userId: number;
@@ -8,22 +11,26 @@ export async function renewSelectedCalendarCredentialId(
},
credentialId: number
): Promise<boolean> /* True if renewed, false if not */ {
const selectedCalendar = await prisma.selectedCalendar.findFirst({
// There could be multiple calendars because same combination could be used across multiple events as event-type-level calendars
const selectedCalendars = await prisma.selectedCalendar.findMany({
where: {
...selectedCalendarWhereUnique,
credentialId: null,
},
});
if (selectedCalendar) {
await prisma.selectedCalendar.update({
where: {
userId_integration_externalId: selectedCalendarWhereUnique,
if (!selectedCalendars.length) return false;
await prisma.selectedCalendar.updateMany({
where: {
id: {
in: selectedCalendars.map((selectedCalendar) => selectedCalendar.id),
},
data: {
credentialId: credentialId,
},
});
return true;
}
return false;
},
data: {
credentialId: credentialId,
},
});
return true;
}
+8 -3
View File
@@ -1,4 +1,4 @@
import type { Prisma } from "@prisma/client";
import type { Prisma, SelectedCalendar } from "@prisma/client";
import { DailyLocationType } from "@calcom/app-store/locations";
import slugify from "@calcom/lib/slugify";
@@ -8,7 +8,10 @@ import type { CustomInputSchema } from "@calcom/prisma/zod-utils";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { CredentialPayload } from "@calcom/types/Credential";
type User = Prisma.UserGetPayload<typeof userSelect>;
type User = Omit<Prisma.UserGetPayload<typeof userSelect>, "selectedCalendars"> & {
allSelectedCalendars: SelectedCalendar[];
userLevelSelectedCalendars: SelectedCalendar[];
};
type UsernameSlugLinkProps = {
users: {
@@ -37,7 +40,8 @@ const user: User & { credentials: CredentialPayload[] } = {
id: 0,
startTime: 0,
endTime: 0,
selectedCalendars: [],
allSelectedCalendars: [],
userLevelSelectedCalendars: [],
schedules: [],
defaultScheduleId: null,
locale: "en",
@@ -116,6 +120,7 @@ const commons = {
autoTranslateDescriptionEnabled: false,
fieldTranslations: [],
maxLeadThreshold: null,
useEventLevelSelectedCalendars: false,
};
export const dynamicEvent = {
+247 -101
View File
@@ -1,21 +1,225 @@
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import type { DestinationCalendar, SelectedCalendar, User } from "@calcom/prisma/client";
import { AppCategories } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { EventTypeRepository } from "./server/repository/eventType";
import { SelectedCalendarRepository } from "./server/repository/selectedCalendar";
const log = logger.getSubLogger({ prefix: ["getConnectedDestinationCalendarsAndEnsureDefaultsInDb"] });
type ReturnTypeGetConnectedCalendars = Awaited<ReturnType<typeof getConnectedCalendars>>;
type ConnectedCalendarsFromGetConnectedCalendars = ReturnTypeGetConnectedCalendars["connectedCalendars"];
export type UserWithCalendars = Pick<User, "id"> & {
selectedCalendars: Pick<SelectedCalendar, "externalId" | "integration">[];
allSelectedCalendars: Pick<SelectedCalendar, "externalId" | "integration" | "eventTypeId">[];
userLevelSelectedCalendars: Pick<SelectedCalendar, "externalId" | "integration" | "eventTypeId">[];
destinationCalendar: DestinationCalendar | null;
};
export type ConnectedDestinationCalendars = Awaited<ReturnType<typeof getConnectedDestinationCalendars>>;
export type ConnectedDestinationCalendars = Awaited<
ReturnType<typeof getConnectedDestinationCalendarsAndEnsureDefaultsInDb>
>;
export async function getConnectedDestinationCalendars(
user: UserWithCalendars,
onboarding: boolean,
prisma: PrismaClient
) {
async function handleNoConnectedCalendars(user: UserWithCalendars) {
log.debug(`No connected calendars, deleting destination calendar if it exists for user ${user.id}`);
if (!user.destinationCalendar) return user;
await prisma.destinationCalendar.delete({
where: { userId: user.id },
});
return {
...user,
destinationCalendar: null,
};
}
type ToggledCalendarDetails = {
externalId: string;
integration: string;
};
async function handleNoDestinationCalendar({
user,
connectedCalendars,
onboarding,
}: {
user: UserWithCalendars;
connectedCalendars: ConnectedCalendarsFromGetConnectedCalendars;
onboarding: boolean;
}) {
if (!connectedCalendars.length) {
throw new Error("No connected calendars");
}
// This is the calendar that we will ensure is enabled for conflict check
let calendarToEnsureIsEnabledForConflictCheck: ToggledCalendarDetails | null = null;
log.debug(
`There are connected calendars, but no destination calendar, so create a default destination calendar in DB for user ${user.id}`
);
/*
So create a default destination calendar with the first primary connected calendar
*/
const {
integration = "",
externalId = "",
credentialId,
email: primaryEmail,
} = connectedCalendars[0].primary ?? {};
// Select the first calendar matching the primary by default since that will also be the destination calendar
if (onboarding && externalId) {
const calendarIndex = (connectedCalendars[0].calendars || []).findIndex(
(item) => item.externalId === externalId && item.integration === integration
);
if (calendarIndex >= 0 && connectedCalendars[0].calendars) {
connectedCalendars[0].calendars[calendarIndex].isSelected = true;
calendarToEnsureIsEnabledForConflictCheck = {
externalId,
integration,
};
}
}
user.destinationCalendar = await prisma.destinationCalendar.create({
data: {
userId: user.id,
integration,
externalId,
credentialId,
primaryEmail,
},
});
return {
user,
connectedCalendars,
calendarToEnsureIsEnabledForConflictCheck,
};
}
async function handleDestinationCalendarNotInConnectedCalendars({
user,
connectedCalendars,
onboarding,
}: {
user: UserWithCalendars;
connectedCalendars: ConnectedCalendarsFromGetConnectedCalendars;
onboarding: boolean;
}) {
let calendarToEnsureIsEnabledForConflictCheck: ToggledCalendarDetails | null = null;
log.debug(
`Destination calendar isn't in connectedCalendars, update it to the first primary connected calendar for user ${user.id}`
);
const { integration = "", externalId = "", email: primaryEmail } = connectedCalendars[0].primary ?? {};
// Select the first calendar matching the primary by default since that will also be the destination calendar
if (onboarding && externalId) {
const calendarIndex = (connectedCalendars[0].calendars || []).findIndex(
(item) => item.externalId === externalId && item.integration === integration
);
if (calendarIndex >= 0 && connectedCalendars[0].calendars) {
connectedCalendars[0].calendars[calendarIndex].isSelected = true;
calendarToEnsureIsEnabledForConflictCheck = {
externalId,
integration,
};
}
}
user.destinationCalendar = await prisma.destinationCalendar.update({
where: { userId: user.id },
data: {
integration,
externalId,
primaryEmail,
},
});
return {
user,
connectedCalendars,
calendarToEnsureIsEnabledForConflictCheck,
};
}
function findMatchingCalendar({
connectedCalendars,
calendar,
}: {
connectedCalendars: ConnectedCalendarsFromGetConnectedCalendars;
calendar: DestinationCalendar;
}) {
// Check if destinationCalendar exists in connectedCalendars
const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat();
const matchingCalendar = allCals.find(
(cal) => cal.externalId === calendar.externalId && cal.integration === calendar.integration
);
return matchingCalendar;
}
async function ensureSelectedCalendarIsInDb({
user,
selectedCalendar,
eventTypeId,
}: {
user: UserWithCalendars;
selectedCalendar: {
integration: string;
externalId: string;
};
eventTypeId: number | null;
}) {
console.log(
`Upsert the selectedCalendar record to the DB for user ${user.id} with details ${JSON.stringify(
selectedCalendar
)}`
);
await SelectedCalendarRepository.createIfNotExists({
userId: user.id,
integration: selectedCalendar.integration,
externalId: selectedCalendar.externalId,
eventTypeId,
});
}
function getSelectedCalendars({
user,
eventTypeId,
}: {
user: UserWithCalendars;
eventTypeId: number | null;
}) {
if (eventTypeId) {
return EventTypeRepository.getSelectedCalendarsFromUser({
user,
eventTypeId: eventTypeId ?? null,
});
}
return user.userLevelSelectedCalendars;
}
/**
* Fetches the calendars for the authenticated user or the event-type if provided
* It also takes care of updating the destination calendar in some edge cases
*/
export async function getConnectedDestinationCalendarsAndEnsureDefaultsInDb({
user,
onboarding,
eventTypeId,
prisma,
}: {
user: UserWithCalendars;
onboarding: boolean;
eventTypeId?: number | null;
prisma: PrismaClient;
}) {
const userCredentials = await prisma.credential.findMany({
where: {
userId: user.id,
@@ -27,99 +231,49 @@ export async function getConnectedDestinationCalendars(
select: credentialForCalendarServiceSelect,
});
const selectedCalendars = getSelectedCalendars({ user, eventTypeId: eventTypeId ?? null });
// get user's credentials + their connected integrations
const calendarCredentials = getCalendarCredentials(userCredentials);
// get all the connected integrations' calendars (from third party)
const { connectedCalendars, destinationCalendar } = await getConnectedCalendars(
const getConnectedCalendarsResult = await getConnectedCalendars(
calendarCredentials,
user.selectedCalendars,
selectedCalendars,
user.destinationCalendar?.externalId
);
let toggledCalendarDetails:
| {
externalId: string;
integration: string;
}
| undefined;
let connectedCalendars = getConnectedCalendarsResult.connectedCalendars;
const destinationCalendar = getConnectedCalendarsResult.destinationCalendar;
let calendarToEnsureIsEnabledForConflictCheck: ToggledCalendarDetails | null = null;
if (connectedCalendars.length === 0) {
/* As there are no connected calendars, delete the destination calendar if it exists */
if (user.destinationCalendar) {
await prisma.destinationCalendar.delete({
where: { userId: user.id },
});
user.destinationCalendar = null;
}
user = await handleNoConnectedCalendars(user);
} else if (!user.destinationCalendar) {
/*
There are connected calendars, but no destination calendar
So create a default destination calendar with the first primary connected calendar
*/
const {
integration = "",
externalId = "",
credentialId,
email: primaryEmail,
} = connectedCalendars[0].primary ?? {};
// Select the first calendar matching the primary by default since that will also be the destination calendar
if (onboarding && externalId) {
const calendarIndex = (connectedCalendars[0].calendars || []).findIndex(
(item) => item.externalId === externalId && item.integration === integration
);
if (calendarIndex >= 0 && connectedCalendars[0].calendars) {
connectedCalendars[0].calendars[calendarIndex].isSelected = true;
toggledCalendarDetails = {
externalId,
integration,
};
}
}
user.destinationCalendar = await prisma.destinationCalendar.create({
data: {
userId: user.id,
integration,
externalId,
credentialId,
primaryEmail,
},
});
({ user, calendarToEnsureIsEnabledForConflictCheck, connectedCalendars } =
await handleNoDestinationCalendar({
user,
connectedCalendars,
onboarding,
}));
} else {
/* There are connected calendars and a destination calendar */
// Check if destinationCalendar exists in connectedCalendars
const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat();
const destinationCal = allCals.find(
(cal) =>
cal.externalId === user.destinationCalendar?.externalId &&
cal.integration === user.destinationCalendar?.integration
log.debug(
`There are connected calendars and a destination calendar, so check if destinationCalendar exists in connectedCalendars for user ${user.id}`
);
const destinationCal = findMatchingCalendar({ connectedCalendars, calendar: user.destinationCalendar });
if (!destinationCal) {
// If destinationCalendar is out of date, update it with the first primary connected calendar
const { integration = "", externalId = "", email: primaryEmail } = connectedCalendars[0].primary ?? {};
// Select the first calendar matching the primary by default since that will also be the destination calendar
if (onboarding && externalId) {
const calendarIndex = (connectedCalendars[0].calendars || []).findIndex(
(item) => item.externalId === externalId && item.integration === integration
);
if (calendarIndex >= 0 && connectedCalendars[0].calendars) {
connectedCalendars[0].calendars[calendarIndex].isSelected = true;
toggledCalendarDetails = {
externalId,
integration,
};
}
}
user.destinationCalendar = await prisma.destinationCalendar.update({
where: { userId: user.id },
data: {
integration,
externalId,
primaryEmail,
},
});
({ user, calendarToEnsureIsEnabledForConflictCheck, connectedCalendars } =
await handleDestinationCalendarNotInConnectedCalendars({
user,
connectedCalendars,
onboarding,
}));
} else if (onboarding && !destinationCal.isSelected) {
log.debug(
`Onboarding:Destination calendar is not selected, but in connectedCalendars, so mark it as selected in the calendar list for user ${user.id}`
);
// Mark the destination calendar as selected in the calendar list
// We use every so that we can exit early once we find the matching calendar
connectedCalendars.every((cal) => {
@@ -130,7 +284,7 @@ export async function getConnectedDestinationCalendars(
);
if (index >= 0 && cal.calendars) {
cal.calendars[index].isSelected = true;
toggledCalendarDetails = {
calendarToEnsureIsEnabledForConflictCheck = {
externalId: destinationCal.externalId,
integration: destinationCal.integration || "",
};
@@ -143,22 +297,11 @@ export async function getConnectedDestinationCalendars(
}
// Insert the newly toggled record to the DB
if (toggledCalendarDetails) {
await prisma.selectedCalendar.upsert({
where: {
userId_integration_externalId: {
userId: user.id,
integration: toggledCalendarDetails.integration,
externalId: toggledCalendarDetails.externalId,
},
},
create: {
userId: user.id,
integration: toggledCalendarDetails.integration,
externalId: toggledCalendarDetails.externalId,
},
// already exists
update: {},
if (calendarToEnsureIsEnabledForConflictCheck) {
await ensureSelectedCalendarIsInDb({
user,
selectedCalendar: calendarToEnsureIsEnabledForConflictCheck,
eventTypeId: eventTypeId ?? null,
});
}
@@ -170,3 +313,6 @@ export async function getConnectedDestinationCalendars(
},
};
}
// Legacy export for @calcom/platform-libraries
export const getConnectedDestinationCalendars = getConnectedDestinationCalendarsAndEnsureDefaultsInDb;
+1
View File
@@ -62,4 +62,5 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
rrSegmentQueryValue: true,
assignRRMembersUsingSegment: true,
maxLeadThreshold: true,
useEventLevelSelectedCalendars: true,
});
+3 -3
View File
@@ -70,7 +70,7 @@ interface GetLuckyUserParams<T extends PartialUser> {
id: number;
email: string;
credentials: CredentialPayload[];
selectedCalendars: SelectedCalendar[];
userLevelSelectedCalendars: SelectedCalendar[];
};
createdAt: Date;
weight?: number | null;
@@ -382,7 +382,7 @@ async function getCurrentMonthCalendarBusyTimes(
id: number;
email: string;
credentials: CredentialPayload[];
selectedCalendars: SelectedCalendar[];
userLevelSelectedCalendars: SelectedCalendar[];
}[]
): Promise<{ userId: number; busyTimes: (EventBusyDate & { timeZone?: string })[] }[]> {
return Promise.all(
@@ -391,7 +391,7 @@ async function getCurrentMonthCalendarBusyTimes(
user.credentials,
startOfMonth().toISOString(),
new Date().toISOString(),
user.selectedCalendars,
user.userLevelSelectedCalendars,
true
).then((busyTimes) => ({
userId: user.id,
+173 -2
View File
@@ -2,9 +2,10 @@ import type { EventType as PrismaEventType } from "@prisma/client";
import { Prisma } from "@prisma/client";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import { prisma, availabilityUserSelect } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { EventTypeMetaDataSchema, rrSegmentQueryValueSchema } from "@calcom/prisma/zod-utils";
import type { Ensure } from "@calcom/types/utils";
import { TRPCError } from "@trpc/server";
@@ -12,6 +13,8 @@ import { TRPCError } from "@trpc/server";
import { safeStringify } from "../../safeStringify";
import { eventTypeSelect } from "../eventTypeSelect";
import { LookupTarget, ProfileRepository } from "./profile";
import type { UserWithLegacySelectedCalendars } from "./user";
import { withSelectedCalendars } from "./user";
const log = logger.getSubLogger({ prefix: ["repository/eventType"] });
type NotSupportedProps = "locations";
@@ -28,6 +31,18 @@ type IEventType = Ensure<
"title" | "slug" | "length"
>;
type UserWithSelectedCalendars<TSelectedCalendar extends { eventTypeId: number | null }> = {
allSelectedCalendars: TSelectedCalendar[];
};
type HostWithLegacySelectedCalendars<
TSelectedCalendar extends { eventTypeId: number | null },
THost,
TUser
> = THost & {
user: UserWithLegacySelectedCalendars<TSelectedCalendar, TUser>;
};
const userSelect = Prisma.validator<Prisma.UserSelect>()({
name: true,
avatarUrl: true,
@@ -35,6 +50,22 @@ const userSelect = Prisma.validator<Prisma.UserSelect>()({
id: true,
});
function hostsWithSelectedCalendars<TSelectedCalendar extends { eventTypeId: number | null }, THost, TUser>(
hosts: HostWithLegacySelectedCalendars<TSelectedCalendar, THost, TUser>[]
) {
return hosts.map((host) => ({
...host,
user: withSelectedCalendars(host.user),
}));
}
function usersWithSelectedCalendars<
TSelectedCalendar extends { eventTypeId: number | null },
TUser extends { selectedCalendars: TSelectedCalendar[] }
>(users: UserWithLegacySelectedCalendars<TSelectedCalendar, TUser>[]) {
return users.map((user) => withSelectedCalendars(user));
}
export class EventTypeRepository {
private static generateCreateEventTypeData = (eventTypeCreateData: IEventType) => {
const {
@@ -624,6 +655,7 @@ export class EventTypeRepository {
},
secondaryEmailId: true,
maxLeadThreshold: true,
useEventLevelSelectedCalendars: true,
});
return await prisma.eventType.findFirst({
@@ -670,7 +702,7 @@ export class EventTypeRepository {
}
static async findByIdIncludeHostsAndTeam({ id }: { id: number }) {
return await prisma.eventType.findUnique({
const eventType = await prisma.eventType.findUnique({
where: {
id,
},
@@ -700,6 +732,15 @@ export class EventTypeRepository {
},
},
});
if (!eventType) {
return eventType;
}
return {
...eventType,
hosts: hostsWithSelectedCalendars(eventType.hosts),
};
}
static async findAllByTeamIdIncludeManagedEventTypes({ teamId }: { teamId?: number }) {
@@ -718,4 +759,134 @@ export class EventTypeRepository {
},
});
}
static async findForSlots({ id }: { id: number }) {
const eventType = await prisma.eventType.findUnique({
where: {
id,
},
select: {
id: true,
slug: true,
minimumBookingNotice: true,
length: true,
offsetStart: true,
seatsPerTimeSlot: true,
timeZone: true,
slotInterval: true,
beforeEventBuffer: true,
afterEventBuffer: true,
bookingLimits: true,
durationLimits: true,
assignAllTeamMembers: true,
schedulingType: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
onlyShowFirstAvailableSlot: true,
periodCountCalendarDays: true,
rescheduleWithSameRoundRobinHost: true,
periodDays: true,
metadata: true,
assignRRMembersUsingSegment: true,
rrSegmentQueryValue: true,
maxLeadThreshold: true,
useEventLevelSelectedCalendars: true,
team: {
select: {
id: true,
bookingLimits: true,
includeManagedEventsInLimits: true,
},
},
parent: {
select: {
team: {
select: {
id: true,
bookingLimits: true,
includeManagedEventsInLimits: true,
},
},
},
},
schedule: {
select: {
id: true,
availability: {
select: {
date: true,
startTime: true,
endTime: true,
days: true,
},
},
timeZone: true,
},
},
availability: {
select: {
date: true,
startTime: true,
endTime: true,
days: true,
},
},
hosts: {
select: {
isFixed: true,
createdAt: true,
user: {
select: {
credentials: { select: credentialForCalendarServiceSelect },
...availabilityUserSelect,
},
},
schedule: {
select: {
availability: {
select: {
date: true,
startTime: true,
endTime: true,
days: true,
},
},
timeZone: true,
id: true,
},
},
},
},
users: {
select: {
credentials: { select: credentialForCalendarServiceSelect },
...availabilityUserSelect,
},
},
},
});
if (!eventType) {
return eventType;
}
return {
...eventType,
hosts: hostsWithSelectedCalendars(eventType.hosts),
users: usersWithSelectedCalendars(eventType.users),
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
rrSegmentQueryValue: rrSegmentQueryValueSchema.parse(eventType.rrSegmentQueryValue),
};
}
static getSelectedCalendarsFromUser<TSelectedCalendar extends { eventTypeId: number | null }>({
user,
eventTypeId,
}: {
user: UserWithSelectedCalendars<TSelectedCalendar>;
eventTypeId: number;
}) {
return user.allSelectedCalendars.filter((calendar) => calendar.eventTypeId === eventTypeId);
}
}
+28 -1
View File
@@ -1,11 +1,13 @@
import { prisma } from "@calcom/prisma";
import { availabilityUserSelect, prisma } from "@calcom/prisma";
import type { MembershipRole } from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import logger from "../../logger";
import { safeStringify } from "../../safeStringify";
import { eventTypeSelect } from "../eventTypeSelect";
import { LookupTarget, ProfileRepository } from "./profile";
import { withSelectedCalendars } from "./user";
const log = logger.getSubLogger({ prefix: ["repository/membership"] });
type IMembership = {
@@ -231,4 +233,29 @@ export class MembershipRepository {
},
});
}
static async findByTeamIdForAvailability({ teamId }: { teamId: number }) {
const memberships = await prisma.membership.findMany({
where: { teamId },
include: {
user: {
select: {
credentials: {
select: credentialForCalendarServiceSelect,
}, // needed for getUserAvailability
...availabilityUserSelect,
},
},
},
});
const membershipsWithSelectedCalendars = memberships.map((m) => {
return {
...m,
user: withSelectedCalendars(m.user),
};
});
return membershipsWithSelectedCalendars;
}
}
@@ -0,0 +1,292 @@
import prismock from "../../../../tests/libs/__mocks__/prisma";
import { describe, expect, it, beforeEach } from "vitest";
import prisma from "@calcom/prisma";
import { SelectedCalendarRepository } from "./selectedCalendar";
describe("SelectedCalendarRepository", () => {
beforeEach(() => {
prismock.selectedCalendar.deleteMany();
});
describe("create", () => {
it("should create a selected calendar", async () => {
const data = {
userId: 1,
integration: "google_calendar",
externalId: "test@gmail.com",
credentialId: 1,
};
const result = await SelectedCalendarRepository.create(data);
expect(result).toEqual(expect.objectContaining(data));
});
it("should throw error if we try to create a user-level calendar with same userId_integration_externalId as an existing user-level calendar", async () => {
const data = {
userId: 1,
integration: "google_calendar",
externalId: "test@gmail.com",
credentialId: 1,
eventTypeId: null,
};
await SelectedCalendarRepository.create(data);
await expect(
SelectedCalendarRepository.create({
...data,
credentialId: 2,
})
).rejects.toThrow("Selected calendar already exists");
});
it("should allow creating a user-level calendar with same userId_integration_externalId as an existing event-type level calendar", async () => {
const data = {
userId: 1,
integration: "google_calendar",
externalId: "test@gmail.com",
credentialId: 1,
eventTypeId: 1,
};
await SelectedCalendarRepository.create(data);
const userLevelCalendarData = {
...data,
eventTypeId: null,
};
const created = await SelectedCalendarRepository.create(userLevelCalendarData);
expect(created).toEqual(expect.objectContaining(userLevelCalendarData));
});
});
describe("update", () => {
it("should update a selected calendar and return it", async () => {
const calendarToUpdate = await SelectedCalendarRepository.create({
userId: 1,
integration: "google_calendar",
externalId: "test@gmail.com",
credentialId: 1,
});
const updatedCalendar = await SelectedCalendarRepository.update({
where: { userId: 1, externalId: "test@gmail.com" },
data: { integration: "office365_calendar" },
});
expect(updatedCalendar.id).toBe(calendarToUpdate.id);
expect(updatedCalendar.integration).toBe("office365_calendar");
});
it("should throw error when trying to update multiple calendars", async () => {
await SelectedCalendarRepository.create({
userId: 1,
integration: "google_calendar",
externalId: "test1@gmail.com",
credentialId: 1,
});
await SelectedCalendarRepository.create({
userId: 1,
integration: "google_calendar2",
externalId: "test1@gmail.com",
credentialId: 2,
});
await expect(
SelectedCalendarRepository.update({
where: { userId: 1, externalId: "test1@gmail.com" },
data: { integration: "office365_calendar" },
})
).rejects.toThrow(
"Multiple SelectedCalendar records found to update. updateMany should be used instead"
);
});
});
describe("delete", () => {
it("should delete a selected calendar and return it", async () => {
const calendar = await SelectedCalendarRepository.create({
userId: 1,
integration: "google_calendar",
externalId: "test@gmail.com",
credentialId: 1,
});
const deleted = await SelectedCalendarRepository.delete({
where: {
userId: 1,
integration: "google_calendar",
externalId: "test@gmail.com",
credentialId: 1,
},
});
expect(deleted).toEqual(calendar);
const result = await SelectedCalendarRepository.findFirst({
where: { id: calendar.id },
});
expect(result).toBeNull();
});
it("should throw error when trying to delete non-existent calendar", async () => {
await expect(
SelectedCalendarRepository.delete({
where: {
userId: 999,
integration: "google_calendar",
externalId: "nonexistent@gmail.com",
credentialId: 999,
},
})
).rejects.toThrow("SelectedCalendar not found");
});
});
describe("findUserLevelUniqueOrThrow", () => {
it("should find user level calendar", async () => {
const calendar = await SelectedCalendarRepository.create({
userId: 1,
integration: "google_calendar",
externalId: "test@gmail.com",
credentialId: 1,
eventTypeId: null, // User level calendar
});
const result = await SelectedCalendarRepository.findUserLevelUniqueOrThrow({
where: { userId: 1, externalId: "test@gmail.com" },
});
expect(result).toEqual(
expect.objectContaining({
userId: calendar.userId,
externalId: calendar.externalId,
})
);
});
it("should not find event type level calendar", async () => {
await SelectedCalendarRepository.create({
userId: 1,
integration: "google_calendar",
externalId: "test@gmail.com",
credentialId: 1,
eventTypeId: 1, // Event type level calendar
});
await expect(
SelectedCalendarRepository.findUserLevelUniqueOrThrow({
where: { userId: 1, externalId: "test@gmail.com" },
})
).rejects.toThrow("SelectedCalendar not found");
});
});
describe("upsert", () => {
describe("User Level Calendar", () => {
const eventTypeId = null;
it("should update existing calendar as long as a record with same userId_integration_externalId is present for eventTypeId=null", async () => {
const initialData = {
userId: 1,
integration: "google_calendar",
externalId: "test@gmail.com",
credentialId: 1,
eventTypeId,
};
const existingCalendar = await SelectedCalendarRepository.create(initialData);
const updatedData = {
...initialData,
credentialId: 2,
eventTypeId,
};
const result = await SelectedCalendarRepository.upsert(updatedData);
expect(result.credentialId).toBe(2);
expect(existingCalendar.id).toBe(result.id);
});
it("should create new calendar if no record with same userId_integration_externalId is present when eventTypeId=null", async () => {
const initialData = {
userId: 1,
integration: "google_calendar",
externalId: "test@gmail.com",
credentialId: 1,
eventTypeId,
};
const existingCalendar = await SelectedCalendarRepository.create(initialData);
const updatedData = {
...initialData,
externalId: "test2@gmail.com",
credentialId: 2,
eventTypeId,
};
const result = await SelectedCalendarRepository.upsert(updatedData);
expect(await prisma.selectedCalendar.count()).toBe(2);
expect(result).toEqual(expect.objectContaining(updatedData));
expect(existingCalendar.id).not.toBe(result.id);
});
});
describe("Event Type Level Calendar", () => {
const eventTypeId = 101;
it("should update existing calendar as long as record with same userId_integration_externalId_eventTypeId is present", async () => {
const initialData = {
userId: 1,
integration: "google_calendar",
externalId: "test@gmail.com",
credentialId: 1,
eventTypeId,
};
const existingCalendar = await SelectedCalendarRepository.create(initialData);
const updatedData = {
...initialData,
credentialId: 2,
eventTypeId,
};
const result = await SelectedCalendarRepository.upsert(updatedData);
expect(result.credentialId).toBe(2);
expect(existingCalendar.id).toBe(result.id);
});
it("should create new calendar if no record with same userId_integration_externalId_eventTypeId is present", async () => {
const initialData = {
userId: 1,
integration: "google_calendar",
externalId: "test@gmail.com",
credentialId: 1,
eventTypeId,
};
const existingCalendar = await SelectedCalendarRepository.create(initialData);
const updatedData = {
...initialData,
credentialId: 2,
externalId: "test2@gmail.com",
eventTypeId,
};
const result = await SelectedCalendarRepository.upsert(updatedData);
expect(await prisma.selectedCalendar.count()).toBe(2);
expect(result).toEqual(expect.objectContaining(updatedData));
expect(existingCalendar.id).not.toBe(result.id);
});
});
});
});
@@ -2,28 +2,127 @@ import type { Prisma } from "@prisma/client";
import { prisma } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { SelectedCalendarEventTypeIds } from "@calcom/types/Calendar";
export type UpdateArguments = {
where: FindManyArgs["where"];
data: Prisma.SelectedCalendarUpdateManyArgs["data"];
};
export type FindManyArgs = {
// https://github.com/microsoft/TypeScript/issues/55217 It crashes atoms build with this if we become too generic here. Seems like a TS bug with complex prisma types.
where?: {
userId?:
| number
| {
in: number[];
};
credentialId?: number | null;
integration?: string;
externalId?: string;
eventTypeId?: number | null;
googleChannelId?:
| string
| null
| {
not: null;
};
};
orderBy?: {
userId?: "asc" | "desc";
};
select?: Prisma.SelectedCalendarSelect;
};
const ensureUserLevelWhere = {
eventTypeId: null,
};
export class SelectedCalendarRepository {
private static async findConflicting(data: {
userId: number;
integration: string;
externalId: string;
eventTypeId?: number | null;
}) {
// Because userId_integration_externalId_eventTypeId is a unique constraint but with eventTypeId being nullable,
// it allows for multiple entries with the same (userId, integration, externalId) with eventTypeId=null in DB.
// We restrict it at app level by ensuring that an entry doesn't exist already before creating a new one
return await SelectedCalendarRepository.findFirst({
where: {
userId: data.userId,
integration: data.integration,
externalId: data.externalId,
eventTypeId: data.eventTypeId || null,
},
});
}
static async findById(id: string) {
return await prisma.selectedCalendar.findUnique({ where: { id } });
}
static async create(data: Prisma.SelectedCalendarUncheckedCreateInput) {
const conflictingCalendar = await SelectedCalendarRepository.findConflicting(data);
if (conflictingCalendar) {
throw new Error("Selected calendar already exists");
}
return await prisma.selectedCalendar.create({
data: {
...data,
},
});
}
static async upsert(data: Prisma.SelectedCalendarUncheckedCreateInput) {
return await prisma.selectedCalendar.upsert({
where: {
userId_integration_externalId: {
userId: data.userId,
integration: data.integration,
externalId: data.externalId,
// userId_integration_externalId_eventTypeId is a unique constraint but with eventTypeId being nullable
// So, this unique constraint can't be used in upsert. Prisma doesn't allow that, So, we do create and update separately
const conflictingCalendar = await SelectedCalendarRepository.findConflicting(data);
if (conflictingCalendar) {
return await prisma.selectedCalendar.update({
where: {
id: conflictingCalendar.id,
},
},
create: { ...data },
update: { ...data },
data,
});
}
return await prisma.selectedCalendar.create({
data,
});
}
static async delete({ where }: { where: Prisma.SelectedCalendarUncheckedCreateInput }) {
const calendarsToDelete = await SelectedCalendarRepository.findMany({ where });
if (calendarsToDelete.length === 0) {
// Same behaviour as Prisma.delete as it throws error if no record is found
throw new Error("SelectedCalendar not found");
}
if (calendarsToDelete.length > 1) {
throw new Error("Multiple SelectedCalendar records found to delete. deleteMany should be used instead");
}
// We can't use delete because unique constraint on userId_integration_externalId_eventTypeId has eventTypeId as nullable
return await prisma.selectedCalendar.delete({
where: {
id: calendarsToDelete[0].id,
},
});
}
static async createIfNotExists(data: Prisma.SelectedCalendarUncheckedCreateInput) {
const conflictingCalendar = await SelectedCalendarRepository.findConflicting(data);
if (conflictingCalendar) return conflictingCalendar;
return await prisma.selectedCalendar.create({
data,
});
}
/** Retrieve calendars that need to be watched */
static async getNextBatchToWatch(limit = 100) {
// Get selected calendars from users that belong to a team that has calendar cache enabled
@@ -59,7 +158,10 @@ export class SelectedCalendarRepository {
});
return nextBatch;
}
/** Retrieve calendars that are being watched but shouldn't be anymore */
/**
* Retrieve calendars that are being watched but shouldn't be anymore
* e.g. when a user disables calendar cache for the organization
*/
static async getNextBatchToUnwatch(limit = 100) {
const where: Prisma.SelectedCalendarWhereInput = {
// RN we only support google calendar subscriptions for now
@@ -86,22 +188,21 @@ export class SelectedCalendarRepository {
});
return nextBatch;
}
static async delete(data: Prisma.SelectedCalendarUncheckedCreateInput) {
return await prisma.selectedCalendar.delete({
where: {
userId_integration_externalId: {
userId: data.userId,
externalId: data.externalId,
integration: data.integration,
},
},
});
static async findMany({ where, select, orderBy }: FindManyArgs) {
return await prisma.selectedCalendar.findMany({ where, select, orderBy });
}
static async findMany(args: Prisma.SelectedCalendarFindManyArgs) {
return await prisma.selectedCalendar.findMany(args);
static async findUniqueOrThrow({ where }: { where: Prisma.SelectedCalendarWhereInput }) {
const calendars = await prisma.selectedCalendar.findMany({ where });
if (calendars.length === 0) {
throw new Error("SelectedCalendar not found");
}
return calendars[0];
}
static async findByGoogleChannelId(googleChannelId: string) {
return await prisma.selectedCalendar.findUnique({
static async findFirstByGoogleChannelId(googleChannelId: string) {
return await prisma.selectedCalendar.findFirst({
where: {
googleChannelId,
},
@@ -119,25 +220,107 @@ export class SelectedCalendarRepository {
},
});
}
static async findByExternalId(credentialId: number, externalId: string) {
static async findFirst({ where }: { where: Prisma.SelectedCalendarWhereInput }) {
return await prisma.selectedCalendar.findFirst({
where,
});
}
static async findFirstOrThrow({ where }: { where: Prisma.SelectedCalendarWhereInput }) {
const calendar = await SelectedCalendarRepository.findFirst({ where });
if (!calendar) {
throw new Error("SelectedCalendar not found");
}
return calendar;
}
static async update(args: UpdateArguments) {
const calendarsToUpdate = await SelectedCalendarRepository.findMany({ where: args.where });
if (calendarsToUpdate.length === 0) {
throw new Error("SelectedCalendar not found");
}
if (calendarsToUpdate.length > 1) {
throw new Error("Multiple SelectedCalendar records found to update. updateMany should be used instead");
}
const calendarToUpdate = calendarsToUpdate[0];
return await prisma.selectedCalendar.update({
where: {
credentialId,
externalId,
id: calendarToUpdate.id,
},
select: {
googleChannelResourceId: true,
googleChannelId: true,
credential: {
select: {
selectedCalendars: {
orderBy: {
externalId: "asc",
},
},
},
},
data: args.data,
});
}
/**
* Following methods ensure that they operate on user level calendars only
*/
static async findUserLevelUniqueOrThrow({ where }: { where: Prisma.SelectedCalendarWhereInput }) {
const calendars = await SelectedCalendarRepository.findUniqueOrThrow({
where: {
...where,
...ensureUserLevelWhere,
},
});
if (!calendars) {
throw new Error("SelectedCalendar not found");
}
return calendars;
}
static async findManyUserLevel(args: FindManyArgs) {
return SelectedCalendarRepository.findMany({
...args,
where: {
...args.where,
...ensureUserLevelWhere,
},
});
}
static async updateUserLevel(args: UpdateArguments) {
return SelectedCalendarRepository.update({
where: {
...args.where,
...ensureUserLevelWhere,
},
data: args.data,
});
}
static async deleteUserLevel({ where }: { where: Prisma.SelectedCalendarUncheckedCreateInput }) {
return await SelectedCalendarRepository.delete({
where: {
...where,
...ensureUserLevelWhere,
},
});
}
static async upsertManyForEventTypeIds({
data,
eventTypeIds,
}: {
data: Prisma.SelectedCalendarUncheckedCreateInput;
eventTypeIds: SelectedCalendarEventTypeIds;
}) {
const userId = data.userId;
return await Promise.all(
eventTypeIds.map((eventTypeId) =>
SelectedCalendarRepository.upsert({
...data,
eventTypeId,
userId,
integration: data.integration,
externalId: data.externalId,
credentialId: data.credentialId,
})
)
);
}
}
+175 -1
View File
@@ -6,6 +6,7 @@ import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import { availabilityUserSelect } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import type { User as UserType } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -73,6 +74,32 @@ const userSelect = Prisma.validator<Prisma.UserSelect>()({
isPlatformManaged: true,
});
export type UserWithLegacySelectedCalendars<TCalendar, TUser> = TUser & {
selectedCalendars: TCalendar[];
};
type UserWithSelectedCalendars<TCalendar, TUser> = Omit<TUser, "selectedCalendars"> & {
allSelectedCalendars: TCalendar[];
userLevelSelectedCalendars: TCalendar[];
};
export function withSelectedCalendars<
TCalendar extends {
eventTypeId: number | null;
},
TUser extends {
selectedCalendars: TCalendar[];
}
>(user: UserWithLegacySelectedCalendars<TCalendar, TUser>): UserWithSelectedCalendars<TCalendar, TUser> {
// We are renaming selectedCalendars to allSelectedCalendars to make it clear that it contains all the calendars including eventType calendars
const { selectedCalendars, ...restUser } = user;
return {
...restUser,
allSelectedCalendars: selectedCalendars,
userLevelSelectedCalendars: selectedCalendars.filter((calendar) => !calendar.eventTypeId),
};
}
export class UserRepository {
static async findTeamsByUserId({ userId }: { userId: UserType["id"] }) {
const teamMemberships = await prisma.membership.findMany({
@@ -752,7 +779,7 @@ export class UserRepository {
});
}
static async findUserWithCredentials({ id }: { id: number }) {
return await prisma.user.findUnique({
const user = await prisma.user.findUnique({
where: {
id,
},
@@ -765,5 +792,152 @@ export class UserRepository {
selectedCalendars: true,
},
});
if (!user) {
return null;
}
return withSelectedCalendars(user);
}
static async findForAvailabilityCheck({ where }: { where: Prisma.UserWhereInput }) {
const user = await prisma.user.findFirst({
where,
select: {
...availabilityUserSelect,
selectedCalendars: true,
credentials: {
select: credentialForCalendarServiceSelect,
},
},
});
if (!user) {
return null;
}
const userWithSelectedCalendars = withSelectedCalendars(user);
return userWithSelectedCalendars;
}
static async findUnlockedUserForSession({ userId }: { userId: number }) {
const user = await prisma.user.findUnique({
where: {
id: userId,
// Locked users can't login
locked: false,
},
select: {
id: true,
username: true,
name: true,
email: true,
emailVerified: true,
bio: true,
avatarUrl: true,
timeZone: true,
weekStart: true,
startTime: true,
endTime: true,
defaultScheduleId: true,
bufferTime: true,
theme: true,
appTheme: true,
createdDate: true,
hideBranding: true,
twoFactorEnabled: true,
disableImpersonation: true,
identityProvider: true,
identityProviderId: true,
brandColor: true,
darkBrandColor: true,
movedToProfileId: true,
selectedCalendars: {
select: {
eventTypeId: true,
externalId: true,
integration: true,
},
},
completedOnboarding: true,
destinationCalendar: true,
locale: true,
timeFormat: true,
trialEndsAt: true,
metadata: true,
role: true,
allowDynamicBooking: true,
allowSEOIndexing: true,
receiveMonthlyDigestEmail: true,
profiles: true,
},
});
if (!user) {
return null;
}
return withSelectedCalendars(user);
}
static async getUserStats({ userId }: { userId: number }) {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
select: {
_count: {
select: {
bookings: true,
// We only need user level selected calendars
selectedCalendars: {
where: {
eventTypeId: null,
},
},
teams: true,
eventTypes: true,
},
},
teams: {
select: {
team: {
select: {
eventTypes: {
select: {
id: true,
},
},
},
},
},
},
},
});
if (!user) {
return null;
}
const { _count, ...restUser } = user;
const { selectedCalendars, ...restCount } = _count;
return {
...restUser,
_count: {
...restCount,
userLevelSelectedCalendars: selectedCalendars,
},
};
}
static async findManyByIdsIncludeDestinationAndSelectedCalendars({ ids }: { ids: number[] }) {
const users = await prisma.user.findMany({
where: { id: { in: ids } },
include: {
selectedCalendars: true,
destinationCalendar: true,
},
});
return users.map(withSelectedCalendars);
}
}
+1
View File
@@ -143,6 +143,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
assignRRMembersUsingSegment: false,
rrSegmentQueryValue: null,
autoTranslateDescriptionEnabled: false,
useEventLevelSelectedCalendars: false,
...eventType,
};
};
@@ -130,6 +130,7 @@ export const useEventTypeForm = ({
},
isRRWeightsEnabled: eventType.isRRWeightsEnabled,
maxLeadThreshold: eventType.maxLeadThreshold,
useEventLevelSelectedCalendars: eventType.useEventLevelSelectedCalendars,
};
}, [eventType, periodDates]);
@@ -4,11 +4,11 @@ import { EventAdvancedTab } from "@calcom/features/eventtypes/components/tabs/ad
import { useConnectedCalendars } from "../../hooks/useConnectedCalendars";
const EventAdvancedPlatformWrapper = (props: EventAdvancedBaseProps) => {
const { data: connectedCalendarsQuery } = useConnectedCalendars({});
const { isPending, data: connectedCalendarsQuery, error } = useConnectedCalendars({});
return (
<EventAdvancedTab
{...props}
calendarsQueryData={connectedCalendarsQuery}
calendarsQuery={{ data: connectedCalendarsQuery, isPending, error }}
showBookerLayoutSelector={false}
/>
);
@@ -7,7 +7,11 @@ const EventAdvancedWebWrapper = ({ ...props }: EventAdvancedBaseProps) => {
return (
<EventAdvancedTab
{...props}
calendarsQueryData={connectedCalendarsQuery.data}
calendarsQuery={{
data: connectedCalendarsQuery.data,
isPending: connectedCalendarsQuery.isPending,
error: connectedCalendarsQuery.error,
}}
showBookerLayoutSelector={true}
/>
);
+5 -1
View File
@@ -6,7 +6,11 @@ export { useEventTypeById } from "./hooks/event-types/private/useEventTypeById";
export { useHandleBookEvent } from "./hooks/bookings/useHandleBookEvent";
export * as Dialog from "./src/components/ui/dialog";
export { Timezone } from "./timezone";
export { SelectedCalendarsSettingsWebWrapper } from "./selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper";
export {
SelectedCalendarsSettingsWebWrapper,
SelectedCalendarsSettingsWebWrapperSkeleton,
SelectedCalendarSettingsScope,
} from "./selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper";
export { DestinationCalendarSettingsWebWrapper } from "./destination-calendar/wrappers/DestinationCalendarSettingsWebWrapper";
export * from "./availability";
export { EventTypeWebWrapper as EventType } from "./event-types/wrappers/EventTypeWebWrapper";
@@ -7,49 +7,81 @@ import { CalendarSwitch } from "@calcom/features/calendars/CalendarSwitch";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { QueryCell } from "@calcom/trpc/components/QueryCell";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui";
import { List } from "@calcom/ui";
import { Alert, Select, List } from "@calcom/ui";
import AdditionalCalendarSelector from "@calcom/web/components/apps/AdditionalCalendarSelector";
import { SelectedCalendarsSettings } from "../SelectedCalendarsSettings";
export enum SelectedCalendarSettingsScope {
User = "user",
EventType = "eventType",
}
type SelectedCalendarsSettingsWebWrapperProps = {
onChanged: () => unknown | Promise<unknown>;
onChanged?: () => unknown | Promise<unknown>;
fromOnboarding?: boolean;
destinationCalendarId?: string;
isPending?: boolean;
classNames?: string;
eventTypeId?: number;
disabledScope?: SelectedCalendarSettingsScope;
scope?: SelectedCalendarSettingsScope;
setScope?: (scope: SelectedCalendarSettingsScope) => void;
disableConnectionModification?: boolean;
};
export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSettingsWebWrapperProps) => {
const { t } = useLocale();
const query = trpc.viewer.connectedCalendars.useQuery(undefined, {
suspense: true,
refetchOnWindowFocus: false,
});
const { fromOnboarding, isPending } = props;
const {
scope = SelectedCalendarSettingsScope.User,
setScope = () => {
return;
},
disabledScope,
disableConnectionModification,
eventTypeId = null,
} = props;
const query = trpc.viewer.connectedCalendars.useQuery(
{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
eventTypeId: scope === SelectedCalendarSettingsScope.EventType ? eventTypeId! : null,
},
{
suspense: true,
refetchOnWindowFocus: false,
}
);
const { fromOnboarding, isPending } = props;
const showScopeSelector = !!props.eventTypeId;
const shouldUseEventTypeScope = scope === SelectedCalendarSettingsScope.EventType;
const isDisabled = disabledScope ? disabledScope === scope : false;
const shouldDisableConnectionModification = isDisabled || disableConnectionModification;
return (
<div>
<QueryCell
query={query}
success={({ data }) => {
if (!data.connectedCalendars.length) {
return null;
}
<SelectedCalendarsSettings classNames={props.classNames}>
<SelectedCalendarsSettingsHeading
isConnectedCalendarsPresent={!!query.data?.connectedCalendars.length}
isPending={isPending}
showScopeSelector={showScopeSelector}
setScope={setScope}
scope={scope}
shouldDisableConnectionModification={shouldDisableConnectionModification}
/>
<QueryCell
query={query}
success={({ data }) => {
if (!data.connectedCalendars.length) {
return null;
}
return (
<SelectedCalendarsSettings classNames={props.classNames}>
<SelectedCalendarsSettingsHeading
isConnectedCalendarsPresent={!!data.connectedCalendars.length}
isPending={isPending}
/>
return (
<List noBorderTreatment className="p-6 pt-2">
{data.connectedCalendars.map((connectedCalendar) => {
if (!!connectedCalendar.calendars && connectedCalendar.calendars.length > 0) {
return (
<AppListCard
key={`list-${connectedCalendar.credentialId}`}
key={`list-${connectedCalendar.credentialId}-${scope}`}
shouldHighlight
slug={connectedCalendar.integration.slug}
title={connectedCalendar.integration.name}
@@ -59,14 +91,16 @@ export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSett
}
className="border-subtle mt-4 rounded-lg border"
actions={
<div className="flex w-32 justify-end">
<DisconnectIntegration
credentialId={connectedCalendar.credentialId}
trashIcon
onSuccess={props.onChanged}
buttonProps={{ className: "border border-default" }}
/>
</div>
!disableConnectionModification && (
<div className="flex w-32 justify-end">
<DisconnectIntegration
credentialId={connectedCalendar.credentialId}
trashIcon
onSuccess={props.onChanged}
buttonProps={{ className: "border border-default" }}
/>
</div>
)
}>
<div className="border-subtle border-t">
{!fromOnboarding && (
@@ -77,6 +111,7 @@ export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSett
<ul className="space-y-4 px-5 py-4">
{connectedCalendar.calendars?.map((cal) => (
<CalendarSwitch
disabled={isDisabled}
key={cal.externalId}
externalId={cal.externalId}
title={cal.name || "Nameless calendar"}
@@ -85,6 +120,7 @@ export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSett
isChecked={cal.isSelected}
destination={cal.externalId === props.destinationCalendarId}
credentialId={cal.credentialId}
eventTypeId={shouldUseEventTypeScope ? eventTypeId : null}
/>
))}
</ul>
@@ -122,10 +158,48 @@ export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSett
);
})}
</List>
</SelectedCalendarsSettings>
);
}}
/>
);
}}
/>
</SelectedCalendarsSettings>
</div>
);
};
export const SelectedCalendarsSettingsWebWrapperSkeleton = () => {
return (
<div className="border-subtle mt-6 rounded-lg border">
<div className="border-subtle border-b p-6">
<div className="flex items-center justify-between">
<div>
<div className="h-4 w-32 animate-pulse rounded-md bg-gray-200" />
<div className="mt-2 h-4 w-48 animate-pulse rounded-md bg-gray-200" />
</div>
<div className="h-8 w-32 animate-pulse rounded-md bg-gray-200" />
</div>
</div>
<div className="p-6 pt-2">
<div className="border-subtle mt-4 rounded-lg border p-4">
<div className="flex items-center">
<div className="h-10 w-10 animate-pulse rounded-md bg-gray-200" />
<div className="ml-4 space-y-2">
<div className="h-4 w-32 animate-pulse rounded-md bg-gray-200" />
<div className="h-4 w-48 animate-pulse rounded-md bg-gray-200" />
</div>
</div>
<div className="border-subtle mt-4 space-y-4 border-t pt-4">
<div className="h-4 w-64 animate-pulse rounded-md bg-gray-200" />
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between">
<div className="h-4 w-48 animate-pulse rounded-md bg-gray-200" />
<div className="h-6 w-10 animate-pulse rounded-md bg-gray-200" />
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
@@ -133,9 +207,17 @@ export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSett
const SelectedCalendarsSettingsHeading = (props: {
isConnectedCalendarsPresent: boolean;
isPending?: boolean;
showScopeSelector: boolean;
setScope: (scope: SelectedCalendarSettingsScope) => void;
scope: SelectedCalendarSettingsScope;
shouldDisableConnectionModification?: boolean;
}) => {
const { t } = useLocale();
const optionsToSwitchScope = [
{ label: "User", value: SelectedCalendarSettingsScope.User },
{ label: "Event Type", value: SelectedCalendarSettingsScope.EventType },
];
const switchScopeSelectValue = optionsToSwitchScope.find((option) => option.value === props.scope);
return (
<div className="border-subtle border-b p-6">
<div className="flex items-center justify-between">
@@ -143,14 +225,31 @@ const SelectedCalendarsSettingsHeading = (props: {
<h4 className="text-emphasis text-base font-semibold leading-5">{t("check_for_conflicts")}</h4>
<p className="text-default text-sm leading-tight">{t("select_calendars")}</p>
</div>
<div className="flex flex-col xl:flex-row xl:space-x-5">
{props.isConnectedCalendarsPresent && (
<div className="flex items-center">
<AdditionalCalendarSelector isPending={props.isPending} />
</div>
)}
</div>
{!props.shouldDisableConnectionModification && (
<div className="flex flex-col xl:flex-row xl:space-x-5">
{props.isConnectedCalendarsPresent && (
<div className="flex items-center">
<AdditionalCalendarSelector isPending={props.isPending} />
</div>
)}
</div>
)}
</div>
{props.showScopeSelector && (
<div className="mt-2 flex flex-row items-center space-x-2">
<span className="text-default text-sm">Using</span>
<Select
onChange={(option) => {
if (!option) return;
props.setScope(option.value);
}}
value={switchScopeSelectValue}
options={optionsToSwitchScope}
/>
<span className="text-default text-sm">settings</span>
</div>
)}
</div>
);
};
+1 -1
View File
@@ -82,7 +82,7 @@ export { getAvailableSlots };
export type AvailableSlotsType = Awaited<ReturnType<typeof getAvailableSlots>>;
export { handleNewRecurringBooking } from "@calcom/features/bookings/lib/handleNewRecurringBooking";
export { getConnectedDestinationCalendars } from "@calcom/lib/getConnectedDestinationCalendars";
export { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/lib/getConnectedDestinationCalendars";
export type { ConnectedDestinationCalendars } from "@calcom/lib/getConnectedDestinationCalendars";
export { getConnectedApps } from "@calcom/lib/getConnectedApps";
@@ -0,0 +1,32 @@
/*
Warnings:
- The primary key for the `SelectedCalendar` table will be changed. If it partially fails, the table could be left without primary key constraint.
- A unique constraint covering the columns `[userId,integration,externalId,eventTypeId]` on the table `SelectedCalendar` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[googleChannelId,eventTypeId]` on the table `SelectedCalendar` will be added. If there are existing duplicate values, this will fail.
- Made the column `id` on table `SelectedCalendar` required. This step will fail if there are existing NULL values in that column.
*/
-- DropIndex
DROP INDEX "SelectedCalendar_googleChannelId_key";
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "useEventLevelSelectedCalendars" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "SelectedCalendar" DROP CONSTRAINT "SelectedCalendar_pkey",
ADD COLUMN "eventTypeId" INTEGER,
ALTER COLUMN "id" SET NOT NULL,
ADD CONSTRAINT "SelectedCalendar_pkey" PRIMARY KEY ("id");
-- CreateIndex
CREATE INDEX "SelectedCalendar_eventTypeId_idx" ON "SelectedCalendar"("eventTypeId");
-- CreateIndex
CREATE UNIQUE INDEX "SelectedCalendar_userId_integration_externalId_eventTypeId_key" ON "SelectedCalendar"("userId", "integration", "externalId", "eventTypeId");
-- CreateIndex
CREATE UNIQUE INDEX "SelectedCalendar_googleChannelId_eventTypeId_key" ON "SelectedCalendar"("googleChannelId", "eventTypeId");
-- AddForeignKey
ALTER TABLE "SelectedCalendar" ADD CONSTRAINT "SelectedCalendar_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+13 -4
View File
@@ -91,6 +91,7 @@ model EventType {
availability Availability[]
webhooks Webhook[]
destinationCalendar DestinationCalendar?
useEventLevelSelectedCalendars Boolean @default(false)
eventName String?
customInputs EventTypeCustomInput[]
parentId Int?
@@ -158,6 +159,7 @@ model EventType {
isRRWeightsEnabled Boolean @default(false)
fieldTranslations EventTypeTranslation[]
maxLeadThreshold Int?
selectedCalendars SelectedCalendar[]
/// @zod.custom(imports.eventTypeColor)
eventTypeColor Json?
@@ -709,8 +711,7 @@ model Availability {
}
model SelectedCalendar {
// Will become required and @id in a seperate PR
id String? @default(uuid())
id String @id @default(uuid())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
integration String
@@ -718,7 +719,7 @@ model SelectedCalendar {
credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade)
credentialId Int?
// Used to identify a watched calendar channel in Google Calendar
googleChannelId String? @unique
googleChannelId String?
googleChannelKind String?
googleChannelResourceId String?
googleChannelResourceUri String?
@@ -727,10 +728,18 @@ model SelectedCalendar {
domainWideDelegationCredentialId String?
error String?
@@id([userId, integration, externalId])
eventTypeId Int?
eventType EventType? @relation(fields: [eventTypeId], references: [id])
// It could still allow multiple user-level(eventTypeId is null) selected calendars for same userId, integration, externalId because NULL is not equal to NULL
// We currently ensure uniqueness by checking for the existence of the record before creating a new one
// Think about introducing a generated unique key ${userId}_${integration}_${externalId}_${eventTypeId}
@@unique([userId, integration, externalId, eventTypeId])
@@unique([googleChannelId, eventTypeId])
@@index([userId])
@@index([integration])
@@index([externalId])
@@index([eventTypeId])
}
enum EventTypeCustomInputType {
@@ -15,7 +15,6 @@ import { middleware } from "../trpc";
type Maybe<T> = T | null | undefined;
export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<Session>) {
const { prisma } = ctx;
if (!session) {
return null;
}
@@ -24,56 +23,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
return null;
}
const userFromDb = await prisma.user.findUnique({
where: {
id: session.user.id,
// Locked users can't login
locked: false,
},
select: {
id: true,
username: true,
name: true,
email: true,
emailVerified: true,
bio: true,
avatarUrl: true,
timeZone: true,
weekStart: true,
startTime: true,
endTime: true,
defaultScheduleId: true,
bufferTime: true,
theme: true,
appTheme: true,
createdDate: true,
hideBranding: true,
twoFactorEnabled: true,
disableImpersonation: true,
identityProvider: true,
identityProviderId: true,
brandColor: true,
darkBrandColor: true,
movedToProfileId: true,
selectedCalendars: {
select: {
externalId: true,
integration: true,
},
},
completedOnboarding: true,
destinationCalendar: true,
locale: true,
timeFormat: true,
trialEndsAt: true,
metadata: true,
role: true,
allowDynamicBooking: true,
allowSEOIndexing: true,
receiveMonthlyDigestEmail: true,
profiles: true,
},
});
const userFromDb = await UserRepository.findUnlockedUserForSession({ userId: session.user.id });
// some hacks to make sure `username` and `email` are never inferred as `null`
if (!userFromDb) {
@@ -1,4 +1,4 @@
import { getConnectedDestinationCalendars } from "@calcom/lib/getConnectedDestinationCalendars";
import { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/lib/getConnectedDestinationCalendars";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
@@ -15,11 +15,13 @@ export const connectedCalendarsHandler = async ({ ctx, input }: ConnectedCalenda
const { user } = ctx;
const onboarding = input?.onboarding || false;
const { connectedCalendars, destinationCalendar } = await getConnectedDestinationCalendars(
user,
onboarding,
prisma
);
const { connectedCalendars, destinationCalendar } =
await getConnectedDestinationCalendarsAndEnsureDefaultsInDb({
user,
onboarding,
eventTypeId: input?.eventTypeId ?? null,
prisma,
});
return {
connectedCalendars,
@@ -3,6 +3,9 @@ import { z } from "zod";
export const ZConnectedCalendarsInputSchema = z
.object({
onboarding: z.boolean().optional(),
// Fetches the calendars for this event-type only if present
// Otherwise, fetches the calendars for the authenticated user
eventTypeId: z.number().nullable(),
})
.optional();
@@ -27,7 +27,7 @@ const checkInvalidGoogleCalendarCredentials = async ({ ctx }: Props) => {
const { connectedCalendars } = await getConnectedCalendars(
calendarCredentials,
ctx.user.selectedCalendars,
ctx.user.userLevelSelectedCalendars,
ctx.user.destinationCalendar?.externalId
);
@@ -1,6 +1,6 @@
import type { Session } from "next-auth";
import prisma from "@calcom/prisma";
import { UserRepository } from "@calcom/lib/server/repository/user";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
type MyStatsOptions = {
@@ -13,34 +13,8 @@ type MyStatsOptions = {
export const myStatsHandler = async ({ ctx }: MyStatsOptions) => {
const { user: sessionUser } = ctx;
const additionalUserInfo = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
_count: {
select: {
bookings: true,
selectedCalendars: true,
teams: true,
eventTypes: true,
},
},
teams: {
select: {
team: {
select: {
eventTypes: {
select: {
id: true,
},
},
},
},
},
},
},
});
const additionalUserInfo = await UserRepository.getUserStats({ userId: sessionUser.id });
const sumOfTeamEventTypes = additionalUserInfo?.teams.reduce(
(sum, team) => sum + team.team.eventTypes.length,
0
@@ -49,7 +23,7 @@ export const myStatsHandler = async ({ ctx }: MyStatsOptions) => {
return {
id: sessionUser.id,
sumOfBookings: additionalUserInfo?._count.bookings,
sumOfCalendars: additionalUserInfo?._count.selectedCalendars,
sumOfCalendars: additionalUserInfo?._count.userLevelSelectedCalendars,
sumOfTeams: additionalUserInfo?._count.teams,
sumOfEventTypes: additionalUserInfo?._count.eventTypes,
sumOfTeamEventTypes,
@@ -10,7 +10,7 @@ import type { TSetDestinationCalendarInputSchema } from "./setDestinationCalenda
type SessionUser = NonNullable<TrpcSessionUser>;
type User = {
id: SessionUser["id"];
selectedCalendars: SessionUser["selectedCalendars"];
userLevelSelectedCalendars: SessionUser["userLevelSelectedCalendars"];
};
type SetDestinationCalendarOptions = {
@@ -25,7 +25,10 @@ export const setDestinationCalendarHandler = async ({ ctx, input }: SetDestinati
const { integration, externalId, eventTypeId } = input;
const credentials = await getUsersCredentials(user);
const calendarCredentials = getCalendarCredentials(credentials);
const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
const { connectedCalendars } = await getConnectedCalendars(
calendarCredentials,
user.userLevelSelectedCalendars
);
const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat();
const credentialId = allCals.find(
@@ -70,6 +70,7 @@ const BaseEventTypeUpdateInput = _EventTypeModel
bookingFields: eventTypeBookingFields,
assignRRMembersUsingSegment: z.boolean().optional(),
rrSegmentQueryValue: rrSegmentQueryValueSchema.optional(),
useEventLevelSelectedCalendars: z.boolean().optional(),
})
.partial()
.extend(_EventTypeModel.pick({ id: true }).shape);
@@ -35,7 +35,7 @@ type User = {
profile: {
id: SessionUser["profile"]["id"] | null;
};
selectedCalendars: SessionUser["selectedCalendars"];
userLevelSelectedCalendars: SessionUser["userLevelSelectedCalendars"];
organizationId: number | null;
locale: string;
};
@@ -30,13 +30,13 @@ import {
} from "@calcom/lib/isOutOfBounds";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import { UserRepository, withSelectedCalendars } from "@calcom/lib/server/repository/user";
import getSlots from "@calcom/lib/slots";
import prisma, { availabilityUserSelect } from "@calcom/prisma";
import { PeriodType, Prisma } from "@calcom/prisma/client";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { EventTypeMetaDataSchema, rrSegmentQueryValueSchema } from "@calcom/prisma/zod-utils";
import type { EventBusyDate } from "@calcom/types/Calendar";
import { TRPCError } from "@trpc/server";
@@ -158,120 +158,7 @@ export async function getEventType(
return null;
}
const eventType = await prisma.eventType.findUnique({
where: {
id: eventTypeId,
},
select: {
id: true,
slug: true,
minimumBookingNotice: true,
length: true,
offsetStart: true,
seatsPerTimeSlot: true,
timeZone: true,
slotInterval: true,
beforeEventBuffer: true,
afterEventBuffer: true,
bookingLimits: true,
durationLimits: true,
assignAllTeamMembers: true,
schedulingType: true,
periodType: true,
periodStartDate: true,
periodEndDate: true,
onlyShowFirstAvailableSlot: true,
periodCountCalendarDays: true,
rescheduleWithSameRoundRobinHost: true,
periodDays: true,
metadata: true,
assignRRMembersUsingSegment: true,
rrSegmentQueryValue: true,
maxLeadThreshold: true,
team: {
select: {
id: true,
bookingLimits: true,
includeManagedEventsInLimits: true,
},
},
parent: {
select: {
team: {
select: {
id: true,
bookingLimits: true,
includeManagedEventsInLimits: true,
},
},
},
},
schedule: {
select: {
id: true,
availability: {
select: {
date: true,
startTime: true,
endTime: true,
days: true,
},
},
timeZone: true,
},
},
availability: {
select: {
date: true,
startTime: true,
endTime: true,
days: true,
},
},
hosts: {
select: {
isFixed: true,
createdAt: true,
user: {
select: {
credentials: { select: credentialForCalendarServiceSelect },
...availabilityUserSelect,
},
},
schedule: {
select: {
availability: {
select: {
date: true,
startTime: true,
endTime: true,
days: true,
},
},
timeZone: true,
id: true,
},
},
},
},
users: {
select: {
credentials: { select: credentialForCalendarServiceSelect },
...availabilityUserSelect,
},
},
},
});
if (!eventType) {
return null;
}
return {
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
rrSegmentQueryValue: rrSegmentQueryValueSchema.parse(eventType.rrSegmentQueryValue),
};
return await EventTypeRepository.findForSlots({ id: eventTypeId });
}
export async function getDynamicEventType(
@@ -295,7 +182,9 @@ export async function getDynamicEventType(
? [input.usernameList]
: [],
});
const users = await prisma.user.findMany({
// TODO: Should be moved to UserRepository
const usersWithOldSelectedCalendars = await prisma.user.findMany({
where,
select: {
allowDynamicBooking: true,
@@ -305,6 +194,8 @@ export async function getDynamicEventType(
},
},
});
const users = usersWithOldSelectedCalendars.map((user) => withSelectedCalendars(user));
const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking);
if (!isDynamicAllowed) {
throw new TRPCError({
@@ -1,8 +1,6 @@
import { getUserAvailability } from "@calcom/core/getUserAvailability";
import { isTeamMember } from "@calcom/lib/server/queries/teams";
import { availabilityUserSelect } from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
@@ -21,19 +19,8 @@ export const getMemberAvailabilityHandler = async ({ ctx, input }: GetMemberAvai
if (!team) throw new TRPCError({ code: "UNAUTHORIZED" });
// verify member is in team
const members = await prisma.membership.findMany({
where: { teamId: input.teamId },
include: {
user: {
select: {
credentials: {
select: credentialForCalendarServiceSelect,
}, // needed for getUserAvailability
...availabilityUserSelect,
},
},
},
});
const members = await MembershipRepository.findByTeamIdForAvailability({ teamId: input.teamId });
const member = members?.find((m) => m.userId === input.memberId);
if (!member) throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
if (!member.user.username)
@@ -3,6 +3,7 @@ import { ensureAvailableUsers } from "@calcom/features/bookings/lib/handleNewBoo
import type { IsFixedAwareUser } from "@calcom/features/bookings/lib/handleNewBooking/types";
import { ErrorCode } from "@calcom/lib/errorCodes";
import logger from "@calcom/lib/logger";
import { withSelectedCalendars } from "@calcom/lib/server/repository/user";
import type { PrismaClient } from "@calcom/prisma";
import { userSelect } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
@@ -47,7 +48,7 @@ async function getTeamHostsFromDB({
}),
};
const [totalCount, hosts] = await Promise.all([
const [totalCount, _hosts] = await Promise.all([
prisma.host.count({ where: queryWhere }),
prisma.host.findMany({
where: queryWhere,
@@ -69,6 +70,11 @@ async function getTeamHostsFromDB({
}),
]);
const hosts = _hosts.map((host) => ({
...host,
user: withSelectedCalendars(host.user),
}));
const hasNextPage = hosts.length > limit;
const hosts_subset = hasNextPage ? hosts.slice(0, -1) : hosts;
+13 -3
View File
@@ -248,6 +248,11 @@ export interface IntegrationCalendar extends Ensure<Partial<_SelectedCalendar>,
integrationTitle?: string;
}
/**
* null is to refer to user-level SelectedCalendar
*/
export type SelectedCalendarEventTypeIds = (number | null)[];
export interface Calendar {
createEvent(event: CalendarEvent, credentialId: number): Promise<NewCalendarEventType>;
@@ -277,9 +282,14 @@ export interface Calendar {
listCalendars(event?: CalendarEvent): Promise<IntegrationCalendar[]>;
watchCalendar?(options: { calendarId: string }): Promise<unknown>;
unwatchCalendar?(options: { calendarId: string }): Promise<void>;
watchCalendar?(options: {
calendarId: string;
eventTypeIds: SelectedCalendarEventTypeIds;
}): Promise<unknown>;
unwatchCalendar?(options: {
calendarId: string;
eventTypeIds: SelectedCalendarEventTypeIds;
}): Promise<void>;
}
/**
+71 -5806
View File
File diff suppressed because it is too large. Load diff