diff --git a/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts b/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts
index f07e305871..5b7caba994 100644
--- a/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts
+++ b/apps/api/v1/lib/helpers/rateLimitApiKey.test.ts
@@ -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);
});
diff --git a/apps/api/v1/lib/validations/selected-calendar.ts b/apps/api/v1/lib/validations/selected-calendar.ts
index e1b2138e51..e012efcb6a 100644
--- a/apps/api/v1/lib/validations/selected-calendar.ts
+++ b/apps/api/v1/lib/validations/selected-calendar.ts
@@ -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 */
diff --git a/apps/api/v1/package.json b/apps/api/v1/package.json
index ac2dcb032c..8546d248a6 100644
--- a/apps/api/v1/package.json
+++ b/apps/api/v1/package.json
@@ -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": "*",
diff --git a/apps/api/v1/pages/api/connected-calendars/_get.ts b/apps/api/v1/pages/api/connected-calendars/_get.ts
index a6d5452af0..2478d17761 100644
--- a/apps/api/v1/pages/api/connected-calendars/_get.ts
+++ b/apps/api/v1/pages/api/connected-calendars/_get.ts
@@ -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);
diff --git a/apps/api/v1/pages/api/selected-calendars/[id]/_delete.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_delete.ts
index 899e7a0808..1658ffec1c 100644
--- a/apps/api/v1/pages/api/selected-calendars/[id]/_delete.ts
+++ b/apps/api/v1/pages/api/selected-calendars/[id]/_delete.ts
@@ -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` };
}
diff --git a/apps/api/v1/pages/api/selected-calendars/[id]/_get.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_get.ts
index 9bc044b4bf..f65e6ecd20 100644
--- a/apps/api/v1/pages/api/selected-calendars/[id]/_get.ts
+++ b/apps/api/v1/pages/api/selected-calendars/[id]/_get.ts
@@ -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) };
}
diff --git a/apps/api/v1/pages/api/selected-calendars/[id]/_patch.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_patch.ts
index 602e85b8ff..6de282fd58 100644
--- a/apps/api/v1/pages/api/selected-calendars/[id]/_patch.ts
+++ b/apps/api/v1/pages/api/selected-calendars/[id]/_patch.ts
@@ -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) };
}
diff --git a/apps/api/v1/pages/api/selected-calendars/_get.ts b/apps/api/v1/pages/api/selected-calendars/_get.ts
index 254d3428e2..5b7264e6cd 100644
--- a/apps/api/v1/pages/api/selected-calendars/_get.ts
+++ b/apps/api/v1/pages/api/selected-calendars/_get.ts
@@ -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)) };
}
diff --git a/apps/api/v1/pages/api/selected-calendars/_post.ts b/apps/api/v1/pages/api/selected-calendars/_post.ts
index 5d0a419883..8ff0f31eea 100644
--- a/apps/api/v1/pages/api/selected-calendars/_post.ts
+++ b/apps/api/v1/pages/api/selected-calendars/_post.ts
@@ -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),
diff --git a/apps/api/v1/test/lib/attendees/_post.test.ts b/apps/api/v1/test/lib/attendees/_post.test.ts
index aea3154b5a..dc4c0c3105 100644
--- a/apps/api/v1/test/lib/attendees/_post.test.ts
+++ b/apps/api/v1/test/lib/attendees/_post.test.ts
@@ -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;
diff --git a/apps/api/v1/test/lib/bookings/_post.test.ts b/apps/api/v1/test/lib/bookings/_post.test.ts
index 4d7453cd1e..7950446044 100644
--- a/apps/api/v1/test/lib/bookings/_post.test.ts
+++ b/apps/api/v1/test/lib/bookings/_post.test.ts
@@ -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";
diff --git a/apps/api/v1/test/lib/event-types/[id]/_delete.test.ts b/apps/api/v1/test/lib/event-types/[id]/_delete.test.ts
index b7f7d377be..5b3143df67 100644
--- a/apps/api/v1/test/lib/event-types/[id]/_delete.test.ts
+++ b/apps/api/v1/test/lib/event-types/[id]/_delete.test.ts
@@ -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 = [
diff --git a/apps/api/v1/test/lib/event-types/_post.test.ts b/apps/api/v1/test/lib/event-types/_post.test.ts
index 067e1de550..4b467fe04f 100644
--- a/apps/api/v1/test/lib/event-types/_post.test.ts
+++ b/apps/api/v1/test/lib/event-types/_post.test.ts
@@ -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);
diff --git a/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts b/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts
index d2f06ea177..e306dec592 100644
--- a/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts
+++ b/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts
@@ -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();
diff --git a/apps/api/v1/test/lib/selected-calendars/_post.test.ts b/apps/api/v1/test/lib/selected-calendars/_post.test.ts
index dea72fdaa5..8975e25489 100644
--- a/apps/api/v1/test/lib/selected-calendars/_post.test.ts
+++ b/apps/api/v1/test/lib/selected-calendars/_post.test.ts
@@ -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);
diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json
index 0da5102add..c42ec556c2 100644
--- a/apps/api/v2/package.json
+++ b/apps/api/v2/package.json
@@ -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": "*",
diff --git a/apps/api/v2/src/ee/calendars/services/calendars.service.ts b/apps/api/v2/src/ee/calendars/services/calendars.service.ts
index 23b4236059..6f4a23e839 100644
--- a/apps/api/v2/src/ee/calendars/services/calendars.service.ts
+++ b/apps/api/v2/src/ee/calendars/services/calendars.service.ts
@@ -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(
diff --git a/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts b/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts
index eb0b8d989e..03134c56a8 100644
--- a/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts
+++ b/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts
@@ -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,
},
});
}
diff --git a/apps/web/components/apps/CalendarListContainer.tsx b/apps/web/components/apps/CalendarListContainer.tsx
index 194a7f586a..25b4d38ffd 100644
--- a/apps/web/components/apps/CalendarListContainer.tsx
+++ b/apps/web/components/apps/CalendarListContainer.tsx
@@ -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 && (
<>
-
+ }>
+
+
>
)}
>
diff --git a/apps/web/components/getting-started/components/ConnectedCalendarItem.tsx b/apps/web/components/getting-started/components/ConnectedCalendarItem.tsx
index a611fe69b9..ce3adc6e60 100644
--- a/apps/web/components/getting-started/components/ConnectedCalendarItem.tsx
+++ b/apps/web/components/getting-started/components/ConnectedCalendarItem.tsx
@@ -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) => {
{calendars?.map((calendar, i) => (
- {
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",
diff --git a/apps/web/pages/api/availability/calendar.ts b/apps/web/pages/api/availability/calendar.ts
index dee715c7f6..3d975dfc48 100644
--- a/apps/web/pages/api/availability/calendar.ts
+++ b/apps/web/pages/api/availability/calendar.ts
@@ -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 };
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index d12af473c4..06ef7e3b15 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -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",
diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts
index 5c1debce81..69bd8f0db4 100644
--- a/apps/web/test/lib/getSchedule.test.ts
+++ b/apps/web/test/lib/getSchedule.test.ts
@@ -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 () => {
diff --git a/apps/web/test/lib/getSchedule/calendarEvents.test.ts b/apps/web/test/lib/getSchedule/calendarEvents.test.ts
new file mode 100644
index 0000000000..31a9116889
--- /dev/null
+++ b/apps/web/test/lib/getSchedule/calendarEvents.test.ts
@@ -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,
+ }
+ );
+ });
+ });
+ });
+});
diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts
index 210dc5508f..b7ede1e8ed 100644
--- a/apps/web/test/lib/handleChildrenEventTypes.test.ts
+++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts
@@ -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,
diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts
index 97a4e25d0a..2d6de0a36d 100644
--- a/apps/web/test/utils/bookingScenario/bookingScenario.ts
+++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts
@@ -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 & {
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;
+ organizer?: ReturnType;
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) {
diff --git a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts
index bf64ffc9d0..f610db925b 100644
--- a/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts
+++ b/apps/web/test/utils/bookingScenario/getMockRequestDataForBooking.ts
@@ -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> & {
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> & {
+ eventTypeId: 0;
+ eventTypeSlug: string;
+ user: string;
+ } & CommonPropsMockRequestData;
}) {
return {
...getBasicMockRequestDataForBooking(),
diff --git a/packages/app-store/googlecalendar/api/callback.ts b/packages/app-store/googlecalendar/api/callback.ts
index 0c309bf8a8..cb98b320dd 100644
--- a/packages/app-store/googlecalendar/api/callback.ts
+++ b/packages/app-store/googlecalendar/api/callback.ts
@@ -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) {
diff --git a/packages/app-store/googlecalendar/api/webhook.ts b/packages/app-store/googlecalendar/api/webhook.ts
index 6d4ab4903f..a5a65e3cf5 100644
--- a/packages/app-store/googlecalendar/api/webhook.ts
+++ b/packages/app-store/googlecalendar/api/webhook.ts
@@ -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" };
}
diff --git a/packages/app-store/googlecalendar/lib/CalendarService.test.ts b/packages/app-store/googlecalendar/lib/CalendarService.test.ts
index fc7ae8541a..c7fa7440fa 100644
--- a/packages/app-store/googlecalendar/lib/CalendarService.test.ts
+++ b/packages/app-store/googlecalendar/lib/CalendarService.test.ts
@@ -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 () => {
diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts
index 3456e8a8d8..f2da281f37 100644
--- a/packages/app-store/googlecalendar/lib/CalendarService.ts
+++ b/packages/app-store/googlecalendar/lib/CalendarService.ts
@@ -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 =>
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;
@@ -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 {
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,
+ 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"]
diff --git a/packages/app-store/salesforce/zod.ts b/packages/app-store/salesforce/zod.ts
index 18323d42c4..cf21fa02f0 100644
--- a/packages/app-store/salesforce/zod.ts
+++ b/packages/app-store/salesforce/zod.ts
@@ -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(),
diff --git a/packages/core/builders/CalendarEvent/builder.ts b/packages/core/builders/CalendarEvent/builder.ts
index a3f30b5d0a..0962aa355a 100644
--- a/packages/core/builders/CalendarEvent/builder.ts
+++ b/packages/core/builders/CalendarEvent/builder.ts
@@ -28,7 +28,7 @@ const userSelect = Prisma.validator()({
},
});
-type User = Prisma.UserGetPayload;
+type User = Omit, "selectedCalendars">;
type PersonAttendeeCommonFields = Pick;
interface ICalendarEventBuilder {
calendarEvent: CalendarEventClass;
diff --git a/packages/core/getCalendarsEvents.ts b/packages/core/getCalendarsEvents.ts
index 73f0956139..a80755d5cb 100644
--- a/packages/core/getCalendarsEvents.ts
+++ b/packages/core/getCalendarsEvents.ts
@@ -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 */
diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts
index c3fc904d36..b18d36d945 100644
--- a/packages/core/getUserAvailability.ts
+++ b/packages/core/getUserAvailability.ts
@@ -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): Promise {
- return await prisma.user.findFirst({
- where,
- select: {
- ...availabilityUserSelect,
- credentials: {
- select: credentialForCalendarServiceSelect,
- },
- },
- });
+ return UserRepository.findForAvailabilityCheck({ where });
};
type GetUser = Awaited>;
@@ -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,
diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts
index 1bdd7e3b4d..56ba713459 100644
--- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts
+++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts
@@ -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 || []),
diff --git a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts
index aa30587cc8..b010b9cba5 100644
--- a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts
+++ b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts
@@ -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" });
diff --git a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts
index 3327602179..021260d8cb 100644
--- a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts
+++ b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts
@@ -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,
diff --git a/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts
index 8256a6d777..dc4718ea9d 100644
--- a/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts
+++ b/packages/features/bookings/lib/handleNewBooking/test/dynamic-group-booking.test.ts
@@ -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
+ );
+ });
+ });
});
diff --git a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts
index 8768bc8884..6de0113f78 100644
--- a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts
+++ b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts
@@ -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
);
diff --git a/packages/features/bookings/lib/handleNewBooking/types.ts b/packages/features/bookings/lib/handleNewBooking/types.ts
index cacf5a581f..da8e5eb299 100644
--- a/packages/features/bookings/lib/handleNewBooking/types.ts
+++ b/packages/features/bookings/lib/handleNewBooking/types.ts
@@ -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;
+type User = Omit, "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;
diff --git a/packages/features/calendar-cache/CalendarCache.md b/packages/features/calendar-cache/CalendarCache.md
new file mode 100644
index 0000000000..a049b58730
--- /dev/null
+++ b/packages/features/calendar-cache/CalendarCache.md
@@ -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
\ No newline at end of file
diff --git a/packages/features/calendar-cache/api/cron.ts b/packages/features/calendar-cache/api/cron.ts
index ffa9cf738f..c5ac348130 100644
--- a/packages/features/calendar-cache/api/cron.ts
+++ b/packages/features/calendar-cache/api/cron.ts
@@ -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) {
+ 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;
};
diff --git a/packages/features/calendar-cache/calendar-cache.repository.interface.ts b/packages/features/calendar-cache/calendar-cache.repository.interface.ts
index df203c9bb9..96bb7d79a1 100644
--- a/packages/features/calendar-cache/calendar-cache.repository.interface.ts
+++ b/packages/features/calendar-cache/calendar-cache.repository.interface.ts
@@ -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;
- unwatchCalendar(args: { calendarId: string }): Promise;
+ watchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }): Promise;
+ unwatchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }): Promise;
upsertCachedAvailability(
credentialId: number,
args: FreeBusyArgs,
diff --git a/packages/features/calendar-cache/calendar-cache.repository.ts b/packages/features/calendar-cache/calendar-cache.repository.ts
index e6a0418549..916e3fd819 100644
--- a/packages/features/calendar-cache/calendar-cache.repository.ts
+++ b/packages/features/calendar-cache/calendar-cache.repository.ts
@@ -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(
diff --git a/packages/features/calendars/CalendarSwitch.tsx b/packages/features/calendars/CalendarSwitch.tsx
index 5db0ccb22a..4a2ffcadd9 100644
--- a/packages/features/calendars/CalendarSwitch.tsx
+++ b/packages/features/calendars/CalendarSwitch.tsx
@@ -17,9 +17,18 @@ export type ICalendarSwitchProps = {
isLastItemInList?: boolean;
destination?: boolean;
credentialId: number;
+ eventTypeId: number | null;
+ disabled?: boolean;
};
+
+type UserCalendarSwitchProps = Omit;
+
+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) => {
{
setCheckedInternal(isOn);
await mutation.mutate({ isOn });
}}
/>
-