From e3bd90c4f2e41624fde1c9ffae121b1abf4d9ef7 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Tue, 29 Apr 2025 02:41:29 +0530 Subject: [PATCH] fix: Handle calendar-cache with Delegation Credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes CAL-5372 # Delegation Credentials with CalendarCache. Following content is a snapshot of the [internal document](https://calendso.slack.com/docs/T08B8KA2BNF/F08L5JYU3V3) **Problem-1 :** CalendarCache needs SelectedCalendar records to work but SelectedCalendar record is only created when a user connects their calendar and then enables some calendar for conflict checking. Because with Delegation, no manual connection is done by any of the members, we need a way to create SelectedCalendar records automatically. **Problem-2** CalendarCache connects to credential(regular credential) which doesn’t exist for Delegation Credential scenario. Also, DelegationCredential is common for all the members(different from Credential which is different for different members) of the organization and we need to identify to which user the CalendarCache belongs. **Solution for both problems** - Create credential records for Delegation Credentials as well - Through Cron(new - we could schedule it every 5mins) - Now create SelectedCalendar records for those Credential records - Through another Cron(new - we could schedule it every 5mins) - Now CalendarCache records will automatically be created for those SelectedCalendar records -existing cron ## Fixed some Delegation Credentials bugs unrelated to calendar-cache - If DestinationCalendar wasn't set(which is possible only with Delegation Credentials), then Google Meet wasn't used as a conferencing app - [Added a test] - If no SelectedCalendar is there but Google Calendar connection exists(possible only with Delegation Credential) then we were not doing conflict checking. It is expected to not do it for Regular Credentials, but for Delegation Credential we must check for conflict in that case too [Added a test] - Earlier if a user has Regular Credential as well as Delegation Credential for the same external id which is the member email(say member1@acme.com) then availability were retrieved twice because we weren't deduplicating credentials as it wasn't a trivial thing to do. Now that is being done. **Env Variables:** Note this PR doesn't introduce any new env variable. The existing env variable has been added to .env.example. But if this env variable isn't already set, it must be set. `CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY={SAME_AS_SET_FOR_V2_API}` **Deployment Plan:** 1. Add Observability for SelectedCalendar when _error_ field is set 2. Follow https://github.com/calcom/cal.com/blob/calendar-cache-dwd-support/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/delegation-credential/delegation-credential.md#setting-up-delegation-credential-for-google-calendar-api to enable Delegation Credential for i.cal.com 3. Note that to be able to see the option to enable Delegation Credential for an organization, you need to enable `teamFeature` and `feature` for `delegation-credential` ## Automation Tests - Introduced tests for calendar-cache.repository.ts - Tests all methods of the repository - Added more tests for handleNewBooking/delegation-credential flow. - Added test to verify the bug fix when no DestinationCalendar exists and Google Meet should be used still - Added more tests for Google Calendar/CalendarService targeting DelegationCredential - Added more tests for getCalendarsEvents. - To test the new logic of calling getAvailability still if there are no selectedCalendars in case of Delegation Credential - Also introduced tests for `getAvailabitlityWithTimezones` which was an existing function but now has some new changes. - Added tests for deduplication logic in CalendarManager.ts ## How to Test Enable Calendar Cache and Delegation Credential feature for acme org through `features` and `teamFeatures` tables. - Enable Delegation Credential for acme org - Enable atleast 1 calendar for conflict checking for one of the users(say owner1) - Ensure GOOGLE_WEBHOOK_TOKEN is set in .env file - Ensure GOOGLE_WEBHOOK_URL is set to ngrok url of webapp in .env file - Hit cron endpoint `curl http://localhost:3000/api/calendar-cache/cron\?apiKey\={API_KEY}` that would cache the freebusy result for the selected calendars Followup - https://github.com/calcom/cal.com/pull/20698 - https://github.com/calcom/cal.com/pull/18619/files#r2046795643 --- .env.example | 6 +- PR_TODO.md | 15 + .../api/destination-calendars/[id]/_patch.ts | 7 +- .../services/apple-calendar.service.ts | 1 + .../ee/calendars/services/ics-feed.service.ts | 1 + .../delegation-credential.md | 124 ++ apps/web/app/api/cron/credentials/route.ts | 152 +++ .../app/api/cron/selected-calendars/route.ts | 178 +++ .../web/test/utils/bookingScenario/expects.ts | 66 +- packages/app-store/applecalendar/api/add.ts | 1 + packages/app-store/caldavcalendar/api/add.ts | 1 + .../dailyvideo/lib/VideoApiAdapter.ts | 1 + .../app-store/exchange2013calendar/api/add.ts | 1 + .../app-store/exchange2016calendar/api/add.ts | 1 + .../exchangecalendar/api/_postAdd.ts | 1 + .../app-store/googlecalendar/api/webhook.ts | 30 +- .../lib/CalendarService.test.ts | 1171 +++++++++++++---- .../googlecalendar/lib/CalendarService.ts | 105 +- .../lib/__mocks__/googleapis.ts | 11 +- .../app-store/ics-feedcalendar/api/add.ts | 1 + .../lib/VideoApiAdapter.test.ts | 1 + .../salesforce/lib/CrmService.test.ts | 1 + .../__tests__/CrmService.integration.test.ts | 1 + .../app-store/tests/__mocks__/OAuthManager.ts | 30 +- packages/app-store/utils.ts | 1 + .../test/delegation-credential.test.ts | 180 ++- .../features/calendar-cache/CalendarCache.md | 3 +- packages/features/calendar-cache/api/cron.ts | 54 +- .../calendar-cache.repository.interface.ts | 26 +- .../calendar-cache.repository.mock.ts | 4 + .../calendar-cache.repository.test.ts | 190 +++ .../calendar-cache.repository.ts | 89 +- .../features/calendar-cache/calendar-cache.ts | 16 +- packages/lib/CalendarManager.test.ts | 332 ++++- packages/lib/CalendarManager.ts | 104 +- packages/lib/EventManager.ts | 38 +- packages/lib/__mocks__/logger.ts | 14 +- .../lib/crmManager/tasker/crmScheduler.ts | 2 +- .../delegationCredential/clientAndServer.ts | 31 +- .../lib/delegationCredential/server.test.ts | 4 + packages/lib/delegationCredential/server.ts | 87 +- packages/lib/getCalendarsEvents.test.ts | 608 ++++++--- packages/lib/getCalendarsEvents.ts | 77 +- .../buildCredentialPayloadForCalendar.ts | 23 +- .../server/getLuckyUser.integration-test.ts | 2 + packages/lib/server/repository/credential.ts | 67 + .../server/repository/delegationCredential.ts | 119 ++ .../server/repository/destinationCalendar.ts | 4 + .../repository/selectedCalendar.test.ts | 6 +- packages/lib/videoClient.ts | 9 + .../migration.sql | 11 + packages/prisma/schema.prisma | 42 +- packages/prisma/selects/credential.ts | 2 + .../availability/calendarOverlay.handler.ts | 1 + packages/types/Calendar.d.ts | 7 +- 55 files changed, 3420 insertions(+), 640 deletions(-) create mode 100644 PR_TODO.md create mode 100644 apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/delegation-credential/delegation-credential.md create mode 100644 apps/web/app/api/cron/credentials/route.ts create mode 100644 apps/web/app/api/cron/selected-calendars/route.ts create mode 100644 packages/features/calendar-cache/calendar-cache.repository.test.ts create mode 100644 packages/prisma/migrations/20250419103412_delegation_credential_calendar_cache/migration.sql diff --git a/.env.example b/.env.example index 32a2738d97..64f0c01bc3 100644 --- a/.env.example +++ b/.env.example @@ -443,4 +443,8 @@ NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10 NEXT_PUBLIC_HEAD_SCRIPTS= NEXT_PUBLIC_BODY_SCRIPTS= -DATABASE_CHUNK_SIZE= \ No newline at end of file +DATABASE_CHUNK_SIZE= + +# Service Account Encryption Key for encrypting/decrypting service account keys +# You can use: `openssl rand -base64 24` to generate one +CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY= diff --git a/PR_TODO.md b/PR_TODO.md new file mode 100644 index 0000000000..e68c73c9f5 --- /dev/null +++ b/PR_TODO.md @@ -0,0 +1,15 @@ +Possible issue: +- We won't be able to use already built calendar-cache when Delegation Credential is enabled because existing CalendarCache entries don't have userId set. + +## Approach of always using SelectedCalendar.credentialId for CalendarCache even for DelegationCredentials +- Delete Credentials when DelegationCredential is disabled + + +TO Test +- New members beyond the batch size are processed + + + + + + diff --git a/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts index 63b63f48cd..08f6072002 100644 --- a/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts +++ b/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts @@ -185,7 +185,12 @@ async function verifyCredentialsAndGetId({ currentCredentialId: number | null; }) { if (parsedBody.integration && parsedBody.externalId) { - const calendarCredentials = getCalendarCredentialsWithoutDelegation(userCredentials); + const calendarCredentials = getCalendarCredentialsWithoutDelegation( + userCredentials.map((cred) => ({ + ...cred, + delegationCredentialId: null, + })) + ); const { connectedCalendars } = await getConnectedCalendars( calendarCredentials, diff --git a/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts b/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts index 9a79276dea..7a2188cd70 100644 --- a/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts +++ b/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts @@ -119,6 +119,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp { teamId: null, appId: APPLE_CALENDAR_ID, invalid: false, + delegationCredentialId: null, }; const dav = new CalendarService({ diff --git a/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts b/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts index 74ce98c8ba..725d4bebae 100644 --- a/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts +++ b/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts @@ -35,6 +35,7 @@ export class IcsFeedService implements ICSFeedCalendarApp { teamId: null, appId: ICS_CALENDAR, invalid: false, + delegationCredentialId: null, }; try { diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/delegation-credential/delegation-credential.md b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/delegation-credential/delegation-credential.md new file mode 100644 index 0000000000..85293a30aa --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/delegation-credential/delegation-credential.md @@ -0,0 +1,124 @@ +# Delegation Credentials + +## Setting up Delegation Credential for Google Calendar API + +Step 1: Create a Google Cloud Project + +Before you can create a service account, you'll need to set up a Google Cloud project. + + 1. Create a Google Cloud Project: + - Go to the Google Cloud Console + - Select Create Project + - Give your project a name and select your billing account (if applicable) + - Click Create + 2. Enable the Google Calendar API: + 1. Go to the Google Cloud Console + 2. Select API & Services → Library + 3. Search for "Google Calendar API" + 4. Click Enable + +Step 2: Create a Service Account + +A service account is needed to act on behalf of users + + 1. Navigate to the Service Accounts page: + - In the Google Cloud Console, go to IAM & Admin → Service Accounts + 2. Create a New Service Account: + - Click on Create Service Account + - Give your service account a name and description + - Click Create and Continue + +Step 3: To Be taken by Cal.com instance admin: + +- Create a Workspace Platform with slug="google". Slug has to be exactly this. This is how we know we need to use Google Calendar and Google Meet. + +Last Step (To Be Taken By Cal.com organization Owner/Admin): Assign Specific API Permissions via OAuth Scopes: + - Create Delegation Credential with workspace platform "google" + - User must be a member of the Google Workspace to be able to enable Delegation Credential as there is a validation if the user's calendar can be accessed through the service account + - Get the Client ID from there + - Go to your Google Admin Console (admin-google-com) + - Navigate to Security → Access and Data Controls -> API controls -> Manage Domain-Wide Delegation + - Here, you'll authorize the Client ID(Unique ID) to access the Google Calendar API + - Add the necessary API scopes for Google Calendar(Full access to Google Calendar) + + +## Onboarding Improvements + +- Just adding a member to the organization would do the following: + - Member to receive events in their calendar, even if they don't login to their account and complete the onboarding process. + - The booking location would be Google Meet, even if the user hasn't set it as default(Though Cal Video would show up as default, but we still use Google Meet in this case. We will fix it later.) + - It would still not use their calendar for conflict checking, but user can complete the onboarding(just select one calendar there for conflict checking) +- Onboarding process: Google Calendar is pre-installed for any new member of the organization(assuming the user has an email of the Delegation Credential domain) and Destination Calendar and Selected Calendar are configurable. On next step, Google Meet is pre-installed and shown at the top and could be set as default. + +## Restrictions after enabling Delegation Credential + +- Enabling Delegation Credential for a particular workspace in Cal.com(only google/outlook supported at the moment) disables the user from disconnecting that credential. + +## Who can create Delegation Credential and enable it? + +- Only the owner/admin of the organization can create Delegation Credential +- Only the owner/admin of the organization can enable the created Delegation Credential. Following additional requirements are there: + - The client ID must be added to the Google Admin Console + - The user's email must be a member of the Google Workspace + - The user's email must be verified. + +## Disabling Delegation Credential + +- Disabling Delegation **Credential** maintains the user's preferences in terms of SelectedCalendars and DestinationCalendar. + +## Developer Notes + +### Terminology + +- Delegation Credential: A Delegation Credential service account key along with user's email becomes the Delegation Credential which is an alternative to regular Credential in DB. +- DWD: Domain Wide Delegation +- non-dwd credential: Regular credentials that are stored in Credentials table + +### How Delegation Credential works + +- We use the Cal.com user's email to impersonate that user using Delegation Credential(which is just a service account key at the moment) + - That gives us read/write permission to get availability of the user and create new events in their calendar. + +### What is a Delegation Credential? + +- A Delegation Credential service account key along with user's email becomes the Delegation Credential which is an alternative to regular Credential in DB. +- Delegation Credential doesn't completely replace the regular credentials. Delegation Credential gives access to the cal.com user's email in Google Calendar. So, if the user needs to connect to some other email's calendar, we need to use the regular credentials. + +### Important Points + +- No Credential table entry is created when enabling Delegation Credential. The workspace platform's related apps will be considered as "installed" for the users with email matching dwd domain. An in-memory credential like object is created for this purpose. It allows avoiding creation of thousands of records for all the members of the organization when Delegation Credential is enabled. +- Delegation Credential is applicable to Users only. + - For team, we don't use Delegation Credential as you can impersonate a user and not team through Delegation Credential. Currently supported apps(Google Calendar and Google Meet) don't support team installation, so we could simply allow enabling Delegation Credential without any issues. +- Disabling a workspace platform stops it from being used for any new organizations and also disables any Delegation Credential using the workspace platform from being edited. + - It still all existing Delegation Credentials to keep on working +- Workspace default service account is unused and is to be removed. + +### How apps/installed loads the credentials + +1. Identify the logged in user's email +2. Identify the domainWideDelegations for that email's domain +3. Build in-memory credentials for the domainWideDelegations and use them along with the actual credentials(that user might have connected) of the user +4. We don't show the non-dwd connected calendar(if there is a corresponding dwd connected calendar). Though we use the non-dwd credentials to identify the selected calendars, for the dwd connected calendar. + +### Impact of disabling Delegation Credential + +Disabling effectively stops generating in-memory delegation user credentials. So, any members who haven't manually connected their Calendar and thus their calendar connections were working only because of Delegation Credential, would have their connections broken. + +#### What would not work correctly ? + +- Calendar won't be checked for conflicts. So, they could get booked at a time when they are marked busy in their calendar. + - Cal.com bookings would still be checked for conflicts. +- Bookings might not appear in the attendee and host's Google Calendar. Because we would be unable to use the API to create the events in calendar and Google doesn't always add events to calendar automatically based on .ics file alone. +- Cal Video would be used as the booking location instead of Google Meet. + +#### What would work correctly ? + +- Bookings would still go through. People relying on Salesforce for booking details, would face no issues. +- Cal.com bookings would still be checked for conflicts. + +### Notes when testing locally + +- You need to enable the feature through feature flag. +- You could use Acme org and login as +- Make sure to change the email of the user above to your workspace owner's email(other member's email might also work). This is necessary otherwise you won't be able to enable Delegation Credential for the organization. + - Note: After changing the email, you would have to logout and login again diff --git a/apps/web/app/api/cron/credentials/route.ts b/apps/web/app/api/cron/credentials/route.ts new file mode 100644 index 0000000000..e9eb87502e --- /dev/null +++ b/apps/web/app/api/cron/credentials/route.ts @@ -0,0 +1,152 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { CredentialRepository } from "@calcom/lib/server/repository/credential"; +import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential"; + +import { defaultResponderForAppDir } from "../../defaultResponderForAppDir"; + +const log = logger.getSubLogger({ prefix: ["CreateCredentials"] }); +const batchSizeToCreateCredentials = 100; +const validateRequest = (req: NextRequest) => { + const url = new URL(req.url); + const apiKey = req.headers.get("authorization") || url.searchParams.get("apiKey"); + if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) { + throw new HttpError({ statusCode: 401, message: "Unauthorized" }); + } +}; + +async function handleCreateCredentials() { + const delegationCredentials = await DelegationCredentialRepository.findAllEnabledIncludeDelegatedMembers(); + + if (!delegationCredentials.length) { + return { + message: "No enabled delegation credentials found", + success: 0, + failures: 0, + }; + } + let totalSuccess = 0; + let totalFailures = 0; + for (const delegationCredential of delegationCredentials) { + const { workspacePlatform, organization } = delegationCredential; + + if (workspacePlatform.slug !== "google") { + log.info( + `Skipping credential creation for workspace platform ${workspacePlatform.slug} - only google is supported` + ); + return; + } + + // We can't know by looking at a Credential record in DB, if it has access to the `user.email` calendar in Google Calendar. + // It could be a credential of a personal calendar of the same user. So, we can't just reuse any existing credential for the user, we have to check if this specific delegationCredential's UserCredential exists or not + const existingCredentials = + await CredentialRepository.findAllDelegationByUserIdsListAndDelegationCredentialIdAndType({ + userIds: organization.delegatedMembers.map((member) => member.userId), + delegationCredentialId: delegationCredential.id, + type: "google_calendar", + }); + + const existingCredentialUserIds = new Set(existingCredentials.map((cred) => cred.userId)); + + const membersNeedingCredentials = organization.delegatedMembers.filter( + (member) => !existingCredentialUserIds.has(member.userId) + ); + + const toProcessMembers = membersNeedingCredentials.slice(0, batchSizeToCreateCredentials); + + log.info( + `Creating credentials for ${toProcessMembers.length} members in organization ${organization.id}` + ); + + const credentialCreationPromises = toProcessMembers.map(async (member) => { + try { + await CredentialRepository.create({ + type: "google_calendar", + key: { + // The in-memory credential that we create for Delegation Credential has access_token as "NOOP_UNUSED_DELEGATION_TOKEN. This is to mark it is in DB, in case we need to identify that. + access_token: "NOOP_UNUSED_DELEGATION_TOKEN_DB", + }, + userId: member.userId, + appId: "google-calendar", + delegationCredentialId: delegationCredential.id, + }); + log.info(`Created credential for member ${member.userId}`); + } catch (error) { + log.error(`Error creating credential for member ${member.userId}:`, error); + throw error; + } + }); + const results = await Promise.allSettled(credentialCreationPromises); + const successCount = results.filter((r) => r.status === "fulfilled").length; + const failureCount = results.filter((r) => r.status === "rejected").length; + + totalSuccess += successCount; + totalFailures += failureCount; + } + + return { + executedAt: new Date().toISOString(), + success: totalSuccess, + failures: totalFailures, + }; +} + +async function handleDeleteCredentials() { + const delegationCredentials = + await DelegationCredentialRepository.findAllDisabledAndIncludeNextBatchOfMembersToProcess(); + + if (!delegationCredentials.length) { + return { + message: "No disabled delegation credentials found", + success: 0, + failures: 0, + }; + } + let totalSuccess = 0; + for (const delegationCredential of delegationCredentials) { + const { workspacePlatform } = delegationCredential; + + if (workspacePlatform.slug !== "google") { + log.info( + `Skipping credential deletion for workspace platform ${workspacePlatform.slug} - only google is supported` + ); + continue; + } + + // DB?: can deleting all credentials at once be problematic? + const { count } = await CredentialRepository.deleteAllByDelegationCredentialId({ + delegationCredentialId: delegationCredential.id, + }); + totalSuccess += count; + } + log.info(`Deleted ${totalSuccess} credentials for disabled delegation credentials`); + + return { + executedAt: new Date().toISOString(), + success: totalSuccess, + failures: 0, + }; +} + +const handler = async (request: NextRequest) => { + validateRequest(request); + const result = await Promise.allSettled([handleCreateCredentials(), handleDeleteCredentials()]); + const response = result.map((r, index) => { + if (r.status === "fulfilled") { + return { + ...r.value, + message: `Successfully executed ${index === 0 ? "create" : "delete"} credentials`, + }; + } + return { + ...r.reason, + message: `Failed to execute ${index === 0 ? "create" : "delete"} credentials`, + }; + }); + return NextResponse.json(response); +}; + +export const GET = defaultResponderForAppDir(handler); diff --git a/apps/web/app/api/cron/selected-calendars/route.ts b/apps/web/app/api/cron/selected-calendars/route.ts new file mode 100644 index 0000000000..897c3483a7 --- /dev/null +++ b/apps/web/app/api/cron/selected-calendars/route.ts @@ -0,0 +1,178 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService"; +import { findUniqueDelegationCalendarCredential } from "@calcom/lib/delegationCredential/server"; +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { CredentialRepository } from "@calcom/lib/server/repository/credential"; +import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; +import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential"; +import type { Ensure } from "@calcom/types/utils"; + +import { defaultResponderForAppDir } from "../../defaultResponderForAppDir"; + +const limitOnQueryingGoogleCalendar = 50; +const log = logger.getSubLogger({ prefix: ["[api] selected-calendars/cron"] }); +const validateRequest = (req: NextRequest) => { + const url = new URL(req.url); + const apiKey = req.headers.get("authorization") || url.searchParams.get("apiKey"); + if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) { + throw new HttpError({ statusCode: 401, message: "Unauthorized" }); + } +}; + +async function handleCreateSelectedCalendars() { + // These are in DB delegation user credentials in contrast to in-memory delegation user credentials that are used elsewhere + const allDelegationUserCredentials = await CredentialRepository.findAllDelegationByTypeIncludeUserAndTake({ + type: "google_calendar", + take: limitOnQueryingGoogleCalendar, + }); + + if (!allDelegationUserCredentials.length) { + return { + message: "No delegation credentials found", + success: 0, + failures: 0, + }; + } + + // Groups delegationUserCredentials by delegationCredentialId + const groupedDelegationUserCredentials = allDelegationUserCredentials.reduce((acc, curr) => { + acc[curr.delegationCredentialId] = [...(acc[curr.delegationCredentialId] || []), curr]; + return acc; + }, {} as Record); + + let totalSuccess = 0; + let totalFailures = 0; + + for (const [delegationCredentialId, delegationUserCredentials] of Object.entries( + groupedDelegationUserCredentials + )) { + // First, create an array of promises that check if each credential should be processed + const checkedDelegationUserCredentials = await Promise.all( + delegationUserCredentials.map(async (delegationUserCredential) => { + if (!delegationUserCredential.user) { + log.error(`Credential ${delegationUserCredential.id} has no user`); + return null; + } + + // Get all selectedCalendars for this user, regardless of credentialId as we want to reuse the existing SelectedCalendar(which has their googleChannel related column set) after Delegation Credential has been enabled + const hasSelectedCalendarForUserEmail = !!(await SelectedCalendarRepository.findFirst({ + where: { + userId: delegationUserCredential.user.id, + externalId: delegationUserCredential.user.email, + }, + })); + + if (hasSelectedCalendarForUserEmail) { + log.info(`Found a reusable SelectedCalendar for ${delegationUserCredential.user.id}`); + return null; + } + return delegationUserCredential; + }) + ); + + const delegationUserCredentialsToProcess = checkedDelegationUserCredentials.filter( + (credential): credential is Ensure<(typeof delegationUserCredentials)[number], "user"> => + credential !== null + ); + + log.info( + `Found ${delegationUserCredentialsToProcess.length} delegationUserCredentials to process for delegationCredentialId: ${delegationCredentialId}` + ); + + const results = await Promise.allSettled( + delegationUserCredentialsToProcess.map(async (delegationUserCredential) => { + try { + const credentialForCalendarService = await findUniqueDelegationCalendarCredential({ + userId: delegationUserCredential.user.id, + delegationCredentialId: delegationUserCredential.delegationCredentialId, + }); + + if (!credentialForCalendarService) { + log.error( + `Credential not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.user.id}` + ); + return; + } + + if ( + !credentialForCalendarService.delegatedTo || + !credentialForCalendarService.delegatedTo.serviceAccountKey || + !credentialForCalendarService.delegatedTo.serviceAccountKey.client_email + ) { + log.error( + `Invalid delegatedTo for delegationCredentialId: ${delegationUserCredential.delegationCredentialId}`, + safeStringify({ + delegatedToSet: !!credentialForCalendarService.delegatedTo, + serviceAccountKeySet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey, + clientEmailSet: !!credentialForCalendarService.delegatedTo?.serviceAccountKey?.client_email, + }) + ); + return; + } + + const googleCalendarService = new GoogleCalendarService( + credentialForCalendarService as CredentialForCalendarServiceWithEmail + ); + + let primaryCalendar; + try { + primaryCalendar = await googleCalendarService.fetchPrimaryCalendar(); + } catch (error) { + log.error( + `Error fetching primary calendar for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.userId}`, + safeStringify(error) + ); + return; + } + + if (!primaryCalendar || !primaryCalendar.id) { + log.error( + `Primary calendar not found for delegationCredentialId: ${delegationUserCredential.delegationCredentialId} and userId: ${delegationUserCredential.userId}` + ); + return; + } + + await SelectedCalendarRepository.create({ + integration: "google_calendar", + externalId: primaryCalendar.id, + userId: delegationUserCredential.user.id, + delegationCredentialId: delegationUserCredential.delegationCredentialId, + credentialId: delegationUserCredential.id, + }); + + log.debug(`Created SelectedCalendar for user ${delegationUserCredential.userId}`); + } catch (error) { + log.error(`Error processing credential ${delegationUserCredential.id}:`, safeStringify(error)); + throw error; + } + }) + ); + const successCount = results.filter((r) => r.status === "fulfilled").length; + const failureCount = results.filter((r) => r.status === "rejected").length; + + totalSuccess += successCount; + totalFailures += failureCount; + } + + log.info( + `Completed processing all selected calendars. Total Success: ${totalSuccess}, Total Failures: ${totalFailures}` + ); + + return { + message: "All selected calendars processed", + executedAt: new Date().toISOString(), + success: totalSuccess, + failures: totalFailures, + }; +} + +const handler = async (request: NextRequest) => { + validateRequest(request); + return NextResponse.json(await handleCreateSelectedCalendars()); +}; + +export const GET = defaultResponderForAppDir(handler); diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 4b7705b177..e73fc8a893 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -1192,6 +1192,7 @@ export function expectBookingPaymentIntiatedWebhookToHaveBeenFired({ type ExpectedForSuccessfulCalendarEventCreationInCalendar = { calendarId?: string | null; + calendarIdUsingFallbackOfFirstCalendarCredential?: string | null; /** * explciityl set to null if you don't want to match on videoCallUrl */ @@ -1208,35 +1209,60 @@ export function expectSuccessfulCalendarEventCreationInCalendar( ) { const expecteds = expected instanceof Array ? expected : [expected]; expect(calendarMock.createEventCalls.length).toBe(expecteds.length); + for (let i = 0; i < calendarMock.createEventCalls.length; i++) { const expected = expecteds[i]; const createEventCall = calendarMock.createEventCalls[i]; + const { credential } = createEventCall.calendarServiceConstructorArgs; const { calEvent } = createEventCall.args; + if (expected.credential) { expect(credential).toEqual(expect.objectContaining(expected.credential)); } - expect(calEvent).toEqual( - expect.objectContaining({ - destinationCalendar: expected.calendarId - ? [ - expect.objectContaining({ - externalId: expected.calendarId, - }), - ] - : expected.destinationCalendars - ? expect.arrayContaining(expected.destinationCalendars.map((cal) => expect.objectContaining(cal))) - : null, - ...(expected.videoCallUrl !== null - ? { - videoCallData: expect.objectContaining({ - url: expected.videoCallUrl, - }), - } - : {}), - }) - ); + if (expected.calendarId) { + expect(calEvent).toEqual( + expect.objectContaining({ + destinationCalendar: [ + expect.objectContaining({ + externalId: expected.calendarId, + }), + ], + }) + ); + } else if (expected.destinationCalendars) { + expect(calEvent).toEqual( + expect.objectContaining({ + destinationCalendar: expected.destinationCalendars + ? expect.arrayContaining(expected.destinationCalendars.map((cal) => expect.objectContaining(cal))) + : null, + }) + ); + } else if (expected.calendarIdUsingFallbackOfFirstCalendarCredential) { + // In case of fallback, there is no destinationCalendar set + expect(calEvent).toEqual( + expect.objectContaining({ + destinationCalendar: null, + }) + ); + } else { + expect(calEvent).toEqual( + expect.objectContaining({ + destinationCalendar: null, + }) + ); + } + + if (expected.videoCallUrl !== null) { + expect(calEvent).toEqual( + expect.objectContaining({ + videoCallData: expect.objectContaining({ + url: expected.videoCallUrl, + }), + }) + ); + } } } diff --git a/packages/app-store/applecalendar/api/add.ts b/packages/app-store/applecalendar/api/add.ts index c3335040fa..ac0e6c6fe1 100644 --- a/packages/app-store/applecalendar/api/add.ts +++ b/packages/app-store/applecalendar/api/add.ts @@ -60,6 +60,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) id: 0, ...data, user: { email: user.email }, + delegationCredentialId: null, }); await dav?.listCalendars(); await prisma.credential.upsert({ diff --git a/packages/app-store/caldavcalendar/api/add.ts b/packages/app-store/caldavcalendar/api/add.ts index f0eca39878..6148254c7f 100644 --- a/packages/app-store/caldavcalendar/api/add.ts +++ b/packages/app-store/caldavcalendar/api/add.ts @@ -31,6 +31,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) teamId: null, appId: "caldav-calendar", invalid: false, + delegationCredentialId: null, }; try { diff --git a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts index 47279135ad..abc8b0e16d 100644 --- a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts @@ -59,6 +59,7 @@ export const FAKE_DAILY_CREDENTIAL: CredentialForCalendarService & { invalid: bo teamId: null, delegatedToId: null, delegatedTo: null, + delegationCredentialId: null, }; function postToDailyAPI(endpoint: string, body: Record) { diff --git a/packages/app-store/exchange2013calendar/api/add.ts b/packages/app-store/exchange2013calendar/api/add.ts index 05e5876f03..95287b06bc 100644 --- a/packages/app-store/exchange2013calendar/api/add.ts +++ b/packages/app-store/exchange2013calendar/api/add.ts @@ -38,6 +38,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { teamId: null, appId: "exchange2013-calendar", invalid: false, + delegationCredentialId: null, }; try { diff --git a/packages/app-store/exchange2016calendar/api/add.ts b/packages/app-store/exchange2016calendar/api/add.ts index b717254cd0..a8ce3398ae 100644 --- a/packages/app-store/exchange2016calendar/api/add.ts +++ b/packages/app-store/exchange2016calendar/api/add.ts @@ -38,6 +38,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) { teamId: null, appId: "exchange2016-calendar", invalid: false, + delegationCredentialId: null, }; try { diff --git a/packages/app-store/exchangecalendar/api/_postAdd.ts b/packages/app-store/exchangecalendar/api/_postAdd.ts index 238b275bb9..fbb93a703f 100644 --- a/packages/app-store/exchangecalendar/api/_postAdd.ts +++ b/packages/app-store/exchangecalendar/api/_postAdd.ts @@ -34,6 +34,7 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) { teamId: null, appId: "exchange", invalid: false, + delegationCredentialId: null, }; try { diff --git a/packages/app-store/googlecalendar/api/webhook.ts b/packages/app-store/googlecalendar/api/webhook.ts index 275d1c558a..9041da6e6e 100644 --- a/packages/app-store/googlecalendar/api/webhook.ts +++ b/packages/app-store/googlecalendar/api/webhook.ts @@ -1,18 +1,26 @@ import type { NextApiRequest } from "next"; -import { buildNonDelegationCredential } from "@calcom/lib/delegationCredential/server"; +import { getCredentialForCalendarCache } from "@calcom/lib/delegationCredential/server"; import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import { defaultHandler } from "@calcom/lib/server/defaultHandler"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; import { getCalendar } from "../../_utils/getCalendar"; +const log = logger.getSubLogger({ prefix: ["GoogleCalendarWebhook"] }); + async function postHandler(req: NextApiRequest) { - if (req.headers["x-goog-channel-token"] !== process.env.GOOGLE_WEBHOOK_TOKEN) { + const channelToken = req.headers["x-goog-channel-token"]; + const channelId = req.headers["x-goog-channel-id"]; + + log.debug("postHandler", safeStringify({ channelToken, channelId })); + if (channelToken !== process.env.GOOGLE_WEBHOOK_TOKEN) { throw new HttpError({ statusCode: 403, message: "Invalid API key" }); } - if (typeof req.headers["x-goog-channel-id"] !== "string") { + if (typeof channelId !== "string") { throw new HttpError({ statusCode: 403, message: "Missing Channel ID" }); } @@ -20,29 +28,25 @@ async function postHandler(req: NextApiRequest) { // 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"] - ); + const selectedCalendar = await SelectedCalendarRepository.findFirstByGoogleChannelId(channelId); if (!selectedCalendar) { throw new HttpError({ statusCode: 200, - message: `No selected calendar found for googleChannelId: ${req.headers["x-goog-channel-id"]}`, + message: `No selected calendar found for googleChannelId: ${channelId}`, }); } const { credential } = selectedCalendar; if (!credential) throw new HttpError({ statusCode: 200, - message: `No credential found for selected calendar for googleChannelId: ${req.headers["x-goog-channel-id"]}`, + message: `No credential found for selected calendar for googleChannelId: ${channelId}`, }); const { selectedCalendars } = credential; + const credentialForCalendarCache = await getCredentialForCalendarCache({ credentialId: credential.id }); + const calendarServiceForCalendarCache = await getCalendar(credentialForCalendarCache); - const calendar = await getCalendar(buildNonDelegationCredential(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); + await calendarServiceForCalendarCache?.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 059d11dcb7..23d5003719 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.test.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.test.ts @@ -1,19 +1,31 @@ import prismock from "../../../../tests/libs/__mocks__/prisma"; -import oAuthManagerMock, { defaultMockOAuthManager } from "../../tests/__mocks__/OAuthManager"; -import { adminMock, calendarMock, setCredentialsMock } from "./__mocks__/googleapis"; +import oAuthManagerMock, { + defaultMockOAuthManager, + setFullMockOAuthManagerRequest, +} from "../../tests/__mocks__/OAuthManager"; +import { + adminMock, + calendarMock, + setCredentialsMock, + freebusyQueryMock, + calendarListMock, +} from "./__mocks__/googleapis"; import { JWT } from "googleapis-common"; import { expect, test, beforeEach, vi, describe } from "vitest"; import "vitest-fetch-mock"; import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache"; -import { getTimeMin, getTimeMax } from "@calcom/features/calendar-cache/lib/datesForCache"; +import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; import type { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential"; import CalendarService from "./CalendarService"; import { getGoogleAppKeys } from "./getGoogleAppKeys"; +const log = logger.getSubLogger({ prefix: ["CalendarService.test"] }); vi.stubEnv("GOOGLE_WEBHOOK_TOKEN", "test-webhook-token"); interface MockJWT { @@ -73,13 +85,279 @@ vi.mock("googleapis-common", async () => { vi.mock("@googleapis/admin", () => adminMock); vi.mock("@googleapis/calendar", () => calendarMock); -beforeEach(() => { - vi.clearAllMocks(); - setCredentialsMock.mockClear(); - oAuthManagerMock.OAuthManager = defaultMockOAuthManager; - calendarMock.calendar_v3.Calendar.mockClear(); - adminMock.admin_directory_v1.Admin.mockClear(); -}); +async function expectCacheToBeNotSet({ credentialId }: { credentialId: number }) { + const caches = await prismock.calendarCache.findMany({ + where: { + credentialId, + }, + }); + + 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({ + items: itemsInKey, + }) + ); +} + +function createInMemoryCredential({ + userId, + delegationCredentialId, + delegatedTo, +}: { + userId: number; + delegationCredentialId: string | null; + delegatedTo: NonNullable; +}) { + return { + id: -1, + userId, + key: { + access_token: "NOOP_UNUSED_DELEGATION_TOKEN", + }, + invalid: false, + teamId: null, + team: null, + type: "google_calendar", + appId: "google-calendar", + delegatedToId: delegationCredentialId, + delegatedTo: delegatedTo.serviceAccountKey + ? { + serviceAccountKey: delegatedTo.serviceAccountKey, + } + : null, + }; +} + +async function createCredentialForCalendarService({ + user = undefined, + delegatedTo = null, + delegationCredentialId = null, +}: { + user?: { email: string | null }; + delegatedTo?: NonNullable | null; + delegationCredentialId?: string | null; +} = {}): Promise { + const defaultUser = await prismock.user.create({ + data: { + email: user?.email ?? "", + }, + }); + + const app = await prismock.app.create({ + data: { + slug: "google-calendar", + dirName: "google-calendar", + }, + }); + + const credential = { + ...getSampleCredential(), + ...(delegationCredentialId ? { delegationCredential: { connect: { id: delegationCredentialId } } } : {}), + key: { + ...googleTestCredentialKey, + expiry_date: Date.now() - 1000, + }, + }; + + const credentialInDb = !delegatedTo + ? await prismock.credential.create({ + data: { + ...credential, + user: { + connect: { + id: defaultUser.id, + }, + }, + app: { + connect: { + slug: app.slug, + }, + }, + }, + include: { + user: true, + }, + }) + : createInMemoryCredential({ + userId: defaultUser.id, + delegationCredentialId, + delegatedTo, + }); + + return { + ...credentialInDb, + user: user ? { email: user.email ?? "" } : null, + delegatedTo, + } as CredentialForCalendarServiceWithEmail; +} + +async function createSelectedCalendarForDelegationCredential(data: { + userId: number; + credentialId: number | null; + delegationCredentialId: string; + externalId: string; + integration: string; + googleChannelId: string | null; + googleChannelKind: string | null; + googleChannelResourceId: string | null; + googleChannelResourceUri: string | null; + googleChannelExpiration: string | null; +}) { + if (!data.delegationCredentialId) { + throw new Error("delegationCredentialId is required"); + } + return await prismock.selectedCalendar.create({ + data: { + ...data, + }, + }); +} + +async function createSelectedCalendarForRegularCredential(data: { + userId: number; + delegationCredentialId: null; + credentialId: number; + externalId: string; + integration: string; + googleChannelId: string | null; + googleChannelKind: string | null; + googleChannelResourceId: string | null; + googleChannelResourceUri: string | null; + googleChannelExpiration: string | null; +}) { + if (!data.credentialId) { + throw new Error("credentialId is required"); + } + + if (data.credentialId < 0) { + throw new Error("credentialId cannot be negative"); + } + + return await prismock.selectedCalendar.create({ + data: { + ...data, + delegationCredentialId: null, + credentialId: data.credentialId, + }, + }); +} + +const defaultDelegatedCredential = { + serviceAccountKey: { + client_email: "service@example.com", + client_id: "service-client-id", + private_key: "service-private-key", + }, +} as const; + +async function createDelegationCredentialForCalendarService({ + user, + delegatedTo, + delegationCredentialId, +}: { + user?: { email: string } | null; + delegatedTo?: typeof defaultDelegatedCredential; + delegationCredentialId: string; +}) { + return await createCredentialForCalendarService({ + user: user || { + email: "service@example.com", + }, + delegatedTo: delegatedTo || defaultDelegatedCredential, + delegationCredentialId, + }); +} + +/** + * The flow that sets CalendarCache must use CredentialForCalendarCache + */ + +async function createDelegationCredentialForCalendarCache({ + user, + delegatedTo, + delegationCredentialId, +}: { + user?: { email: string } | null; + delegatedTo?: typeof defaultDelegatedCredential; + delegationCredentialId: string; +}) { + delegatedTo = delegatedTo || defaultDelegatedCredential; + const credentialInDb = await createCredentialForCalendarService({ + user: user || { + email: "service@example.com", + }, + }); + + return { + ...createInMemoryCredential({ + userId: credentialInDb.userId!, + delegationCredentialId, + delegatedTo, + }), + ...credentialInDb, + }; +} + +const createMockJWTInstance = ({ + email = "user@example.com", + authorizeError, +}: { + email?: string; + authorizeError?: { response?: { data?: { error?: string } } } | Error; +}) => { + const mockJWTInstance = { + type: "jwt" as const, + config: { + email: defaultDelegatedCredential.serviceAccountKey.client_email, + key: defaultDelegatedCredential.serviceAccountKey.private_key, + scopes: ["https://www.googleapis.com/auth/calendar"], + subject: email, + }, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + authorize: authorizeError ? vi.fn().mockRejectedValue(authorizeError) : vi.fn().mockResolvedValue(), + createScoped: vi.fn(), + getRequestMetadataAsync: vi.fn(), + fetchIdToken: vi.fn(), + hasUserScopes: vi.fn(), + getAccessToken: vi.fn(), + getRefreshToken: vi.fn(), + getTokenInfo: vi.fn(), + refreshAccessToken: vi.fn(), + revokeCredentials: vi.fn(), + revokeToken: vi.fn(), + verifyIdToken: vi.fn(), + on: vi.fn(), + setCredentials: vi.fn(), + getCredentials: vi.fn(), + hasAnyScopes: vi.fn(), + authorizeAsync: vi.fn(), + refreshTokenNoCache: vi.fn(), + createGToken: vi.fn(), + }; + + vi.mocked(JWT).mockImplementation(() => { + lastCreatedJWT = mockJWTInstance; + return mockJWTInstance as unknown as JWT; + }); + return mockJWTInstance; +}; const googleTestCredentialKey = { scope: "https://www.googleapis.com/auth/calendar.events", @@ -103,6 +381,63 @@ const testSelectedCalendar = { externalId: "example@cal.com", }; +const calendarCacheHelpers = { + FUTURE_EXPIRATION_DATE: new Date(Date.now() + 100000000), + getDatePair: () => { + // Keep it small to not go to next month + const timeDiffInMinutes = 1; + const dateFrom = new Date(Date.now()).toISOString(); + const dateTo = new Date(Date.now() + timeDiffInMinutes * 60 * 1000).toISOString(); + return { dateFrom, dateTo, minDateFrom: getTimeMin(dateFrom), maxDateTo: getTimeMax(dateTo) }; + }, + setCache: async ({ + credentialId, + key, + value, + userId, + expiresAt, + }: { + credentialId: number; + key: string; + value: string; + userId: number | null; + expiresAt: Date; + }) => { + log.info("Setting Calendar Cache", safeStringify({ key, value, expiresAt, credentialId, userId })); + await prismock.calendarCache.create({ + data: { + key, + value, + expiresAt, + credentialId, + userId, + }, + }); + }, + + setRegularCredentialCache: async ({ + credentialId, + userId, + key, + value, + expiresAt, + }: { + credentialId: number; + userId: number; + key: string; + value: string; + expiresAt: Date; + }) => { + await calendarCacheHelpers.setCache({ + credentialId, + key, + value, + userId, + expiresAt, + }); + }, +}; + function expectGoogleSubscriptionToHaveOccurredAndClearMock({ calendarId }: { calendarId: string }) { expect(calendarMock.calendar_v3.Calendar().events.watch).toHaveBeenCalledTimes(1); expect(calendarMock.calendar_v3.Calendar().events.watch).toHaveBeenCalledWith( @@ -178,109 +513,235 @@ async function expectSelectedCalendarToNotHaveGoogleChannelProps(selectedCalenda ); } -test("Calendar Cache is being read on cache HIT", async () => { - const credentialInDb1 = await createCredentialInDb(); - const dateFrom1 = new Date().toISOString(); - const dateTo1 = new Date().toISOString(); - - // Create cache - const calendarCache = await CalendarCache.init(null); - await calendarCache.upsertCachedAvailability( - credentialInDb1.id, - { - timeMin: getTimeMin(dateFrom1), - timeMax: getTimeMax(dateTo1), - items: [{ id: testSelectedCalendar.externalId }], - }, - JSON.parse( - JSON.stringify({ - calendars: [ - { - busy: [ - { - start: "2023-12-01T18:00:00Z", - end: "2023-12-01T19:00:00Z", - }, - ], - }, - ], - }) - ) - ); - +beforeEach(() => { + vi.clearAllMocks(); + setCredentialsMock.mockClear(); oAuthManagerMock.OAuthManager = defaultMockOAuthManager; - const calendarService = new CalendarService(credentialInDb1); + calendarMock.calendar_v3.Calendar.mockClear(); + adminMock.admin_directory_v1.Admin.mockClear(); - // Test cache hit - const data = await calendarService.getAvailability(dateFrom1, dateTo1, [testSelectedCalendar]); - expect(data).toEqual([ - { - start: "2023-12-01T18:00:00Z", - end: "2023-12-01T19:00:00Z", - }, - ]); + lastCreatedJWT = null; + lastCreatedOAuth2Client = null; + createMockJWTInstance({}); }); -test("Calendar Cache is being ignored on cache MISS", async () => { - const calendarCache = await CalendarCache.init(null); - const credentialInDb = await createCredentialInDb(); - const dateFrom = new Date(Date.now()).toISOString(); - // Tweak date so that it's a cache miss - const dateTo = new Date(Date.now() + 100000000).toISOString(); - const calendarService = new CalendarService(credentialInDb); +describe("Calendar Cache", () => { + test("Calendar Cache is being read on cache HIT", async () => { + const credentialInDb1 = await createCredentialForCalendarService(); + const dateFrom1 = new Date().toISOString(); + const dateTo1 = new Date().toISOString(); - // Test Cache Miss - await calendarService.getAvailability(dateFrom, dateTo, [testSelectedCalendar]); + // Create cache + const calendarCache = await CalendarCache.init(null); + await calendarCache.upsertCachedAvailability({ + credentialId: credentialInDb1.id, + userId: credentialInDb1.userId, + args: { + timeMin: getTimeMin(dateFrom1), + timeMax: getTimeMax(dateTo1), + items: [{ id: testSelectedCalendar.externalId }], + }, + value: JSON.parse( + JSON.stringify({ + calendars: [ + { + busy: [ + { + start: "2023-12-01T18:00:00Z", + end: "2023-12-01T19:00:00Z", + }, + ], + }, + ], + }) + ), + }); - // Expect cache to be ignored in case of a MISS - const cachedAvailability = await calendarCache.getCachedAvailability(credentialInDb.id, { - timeMin: dateFrom, - timeMax: dateTo, - items: [{ id: testSelectedCalendar.externalId }], + oAuthManagerMock.OAuthManager = defaultMockOAuthManager; + const calendarService = new CalendarService(credentialInDb1); + + // Test cache hit + const data = await calendarService.getAvailability(dateFrom1, dateTo1, [testSelectedCalendar]); + expect(data).toEqual([ + { + start: "2023-12-01T18:00:00Z", + end: "2023-12-01T19:00:00Z", + }, + ]); }); - expect(cachedAvailability).toBeNull(); + test("Calendar Cache is being ignored on cache MISS", async () => { + const calendarCache = await CalendarCache.init(null); + const credentialInDb = await createCredentialForCalendarService(); + const dateFrom = new Date(Date.now()).toISOString(); + // Tweak date so that it's a cache miss + const dateTo = new Date(Date.now() + 100000000).toISOString(); + const calendarService = new CalendarService(credentialInDb); + + // Test Cache Miss + await calendarService.getAvailability(dateFrom, dateTo, [testSelectedCalendar]); + + // Expect cache to be ignored in case of a MISS + const cachedAvailability = await calendarCache.getCachedAvailability({ + credentialId: credentialInDb.id, + userId: null, + args: { + timeMin: dateFrom, + timeMax: dateTo, + items: [{ id: testSelectedCalendar.externalId }], + }, + }); + + expect(cachedAvailability).toBeNull(); + }); + + test("fetchAvailabilityAndSetCache should fetch and cache availability for selected calendars grouped by eventTypeId", async () => { + const credentialInDb = await createCredentialForCalendarService(); + 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("A cache set through fetchAvailabilityAndSetCache should be used when doing getAvailability", async () => { + const credentialInDb = await createCredentialForCalendarService(); + const calendarService = new CalendarService(credentialInDb); + vi.setSystemTime(new Date("2025-04-01T00:00:00.000Z")); + setFullMockOAuthManagerRequest(); + const selectedCalendars = [ + { + externalId: "calendar1@test.com", + integration: "google_calendar", + eventTypeId: null, + credentialId: credentialInDb.id, + userId: credentialInDb.userId!, + }, + ]; + + const mockedBusyTimes = [{ end: "2025-04-02T18:30:00Z", start: "2025-04-01T18:30:00Z" }]; + // Mock Once so that the getAvailability call doesn't accidentally reuse this mock result + freebusyQueryMock.mockResolvedValueOnce({ + data: { + calendars: { + "calendar1@test.com": { + busy: mockedBusyTimes, + }, + }, + }, + }); + + const calendarCachesBefore = await prismock.calendarCache.findMany(); + expect(calendarCachesBefore).toHaveLength(0); + await calendarService.fetchAvailabilityAndSetCache(selectedCalendars); + const calendarCachesAfter = await prismock.calendarCache.findMany(); + console.log({ calendarCachesAfter }); + expect(calendarCachesAfter).toHaveLength(1); + const datesForWhichCachedAvailabilityIsUsed = [ + { + dateFrom: "2025-04-01T00:00:00.000Z", + dateTo: "2025-06-01T00:00:00.000Z", + }, + // Add more dates for which cached availability should be used + // { + // dateFrom: "2025-04-01T00:00:00.000Z", + // dateTo: "2025-04-30T00:00:00.000Z", + // }, + ]; + try { + for (const { dateFrom, dateTo } of datesForWhichCachedAvailabilityIsUsed) { + const result = await calendarService.getAvailability(dateFrom, dateTo, selectedCalendars, true); + expect(result).toEqual(mockedBusyTimes); + } + } catch (error) { + console.log({ error }); + throw "Looks like cache was not used"; + } + + // The following is not working because getBusyCalendarTimes expands the date range a bit. + // const busyCalendarTimesResult = await getBusyCalendarTimes( + // [credentialInDb], + // "2025-04-01T00:00:00.000Z", + // "2025-04-30T00:00:00.000Z", + // selectedCalendars, + // true + // ); + + // expect(busyCalendarTimesResult).toEqual(mockedBusyTimes); + }); }); -async function expectCacheToBeNotSet({ credentialId }: { credentialId: number }) { - const caches = await prismock.calendarCache.findMany({ - where: { - credentialId, - }, - }); - - 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({ - 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({ + const credentialInDb1 = await createCredentialForCalendarService(); + const calendarService = new CalendarService(credentialInDb1); + + await calendarService.watchCalendar({ calendarId: testSelectedCalendar.externalId, eventTypeIds: [null], }); + + // Watching a non-existent selectedCalendar creates it const watchedCalendar = await prismock.selectedCalendar.findFirst({ where: { userId: credentialInDb1.userId!, @@ -307,7 +768,7 @@ describe("Watching and unwatching calendar", () => { expect(watchedCalendar?.id).toBeDefined(); - await calendarCache.unwatchCalendar({ + await calendarService.unwatchCalendar({ calendarId: testSelectedCalendar.externalId, eventTypeIds: [null], }); @@ -337,8 +798,87 @@ describe("Watching and unwatching calendar", () => { expect(calendarAfterUnwatch?.id).toBeDefined(); }); + describe("Delegation Credential", () => { + test("On watching a SelectedCalendar having delegationCredential, it should set googleChannelId and other props", async () => { + const delegationCredential1Member1 = await createDelegationCredentialForCalendarService({ + user: { email: "user1@example.com" }, + delegationCredentialId: "delegation-credential-id-1", + }); + + await prismock.selectedCalendar.create({ + data: { + userId: delegationCredential1Member1.userId!, + externalId: testSelectedCalendar.externalId, + integration: "google_calendar", + }, + }); + + const calendarService = new CalendarService(delegationCredential1Member1); + await calendarService.watchCalendar({ + calendarId: testSelectedCalendar.externalId, + eventTypeIds: [null], + }); + + expectGoogleSubscriptionToHaveOccurredAndClearMock({ + calendarId: testSelectedCalendar.externalId, + }); + + const calendars = await prismock.selectedCalendar.findMany(); + // Ensure no new calendar is created + expect(calendars).toHaveLength(1); + const watchedCalendar = calendars[0]; + + await expectSelectedCalendarToHaveGoogleChannelProps(watchedCalendar.id, { + googleChannelId: "mock-channel-id", + googleChannelKind: "api#channel", + googleChannelResourceId: "mock-resource-id", + googleChannelResourceUri: "mock-resource-uri", + googleChannelExpiration: "1111111111", + }); + }); + + test("On unwatching a SelectedCalendar connected to Delegation Credential, it should remove googleChannelId and other props", async () => { + const delegationCredential1Member1 = await createDelegationCredentialForCalendarCache({ + user: { email: "user1@example.com" }, + delegationCredentialId: "delegation-credential-id-1", + }); + + const selectedCalendar = await createSelectedCalendarForDelegationCredential({ + userId: delegationCredential1Member1.userId!, + delegationCredentialId: delegationCredential1Member1.delegatedToId!, + credentialId: delegationCredential1Member1.id, + externalId: testSelectedCalendar.externalId, + integration: "google_calendar", + googleChannelId: "mock-channel-id", + googleChannelKind: "api#channel", + googleChannelResourceId: "mock-resource-id", + googleChannelResourceUri: "mock-resource-uri", + googleChannelExpiration: "1111111111", + }); + + const calendarService = new CalendarService(delegationCredential1Member1); + await calendarService.unwatchCalendar({ + calendarId: selectedCalendar.externalId, + eventTypeIds: [null], + }); + + expectGoogleUnsubscriptionToHaveOccurredAndClearMock([ + { + resourceId: "mock-resource-id", + channelId: "mock-channel-id", + }, + ]); + + const calendars = await prismock.selectedCalendar.findMany(); + expect(calendars).toHaveLength(1); + const calendarAfterUnwatch = calendars[0]; + + expectSelectedCalendarToNotHaveGoogleChannelProps(calendarAfterUnwatch.id); + }); + }); + test("watchCalendar should not do google subscription if already subscribed for the same calendarId", async () => { - const credentialInDb1 = await createCredentialInDb(); + const credentialInDb1 = await createCredentialForCalendarService(); const calendarCache = await CalendarCache.initFromCredentialId(credentialInDb1.id); const userLevelCalendar = await SelectedCalendarRepository.create({ userId: credentialInDb1.userId!, @@ -390,11 +930,67 @@ describe("Watching and unwatching calendar", () => { }); }); + test("watchCalendar should do google subscription if already subscribed but for different calendarId", async () => { + const credentialInDb1 = await createCredentialForCalendarService(); + const calendarCache = await CalendarCache.initFromCredentialId(credentialInDb1.id); + const userLevelCalendar = await SelectedCalendarRepository.create({ + userId: credentialInDb1.userId!, + externalId: "externalId@cal.com", + integration: "google_calendar", + eventTypeId: null, + credentialId: credentialInDb1.id, + }); + + const eventTypeLevelCalendar = await SelectedCalendarRepository.create({ + userId: credentialInDb1.userId!, + externalId: "externalId2@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", + }); + + // Watch different selectedcalendar with same externalId and credentialId + await calendarCache.watchCalendar({ + calendarId: eventTypeLevelCalendar.externalId, + eventTypeIds: [eventTypeLevelCalendar.eventTypeId], + }); + + expectGoogleSubscriptionToHaveOccurredAndClearMock({ + calendarId: eventTypeLevelCalendar.externalId, + }); + + // 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", + }); + }); + test("unwatchCalendar should not unsubscribe from google if there is another selectedCalendar with same externalId and credentialId", async () => { - const credentialInDb1 = await createCredentialInDb(); + const credentialInDb1 = await createCredentialForCalendarService(); const calendarCache = await CalendarCache.initFromCredentialId(credentialInDb1.id); - const concernedCache = await prismock.calendarCache.create({ + await prismock.calendarCache.create({ data: { key: "test-key", value: "test-value", @@ -468,12 +1064,12 @@ describe("Watching and unwatching calendar", () => { ]); // Concerned cache will just have remaining externalIds - expectCacheToBeSet({ - credentialId: concernedCache.credentialId, + await expectCacheToBeSet({ + credentialId: credentialInDb1.id, itemsInKey: [{ id: eventTypeLevelCalendarForSomeOtherExternalIdButSameCredentialId.externalId }], }); - expectCacheToBeSet({ + await expectCacheToBeSet({ credentialId: someOtherCache.credentialId, itemsInKey: JSON.parse(someOtherCache.key).items, }); @@ -487,77 +1083,9 @@ describe("Watching and unwatching calendar", () => { ); }); }); -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 () => { - const credentialInDb = await createCredentialInDb(); + const credentialInDb = await createCredentialForCalendarService(); // eslint-disable-next-line @typescript-eslint/no-explicit-any let updateTokenObject: any; @@ -604,118 +1132,119 @@ test("`updateTokenObject` should update credential in DB as well as myGoogleAuth expect(setCredentialsMock).toHaveBeenCalledWith(newTokenObject); }); -async function createCredentialInDb({ - user = undefined, - delegatedTo = null, -}: { - user?: { email: string | null }; - delegatedTo?: NonNullable | null; -} = {}): Promise { - const defaultUser = await prismock.user.create({ - data: { - email: user?.email ?? "", - }, - }); +describe("Delegation Credential Error handling", () => { + test("handles clientId not added to Google Workspace Admin Console error", async () => { + const credentialWithDelegation = await createCredentialForCalendarService({ + user: { email: "user@example.com" }, + delegatedTo: defaultDelegatedCredential, + }); - const app = await prismock.app.create({ - data: { - slug: "google-calendar", - dirName: "google-calendar", - }, - }); - - const credential = { - ...getSampleCredential(), - key: { - ...googleTestCredentialKey, - expiry_date: Date.now() - 1000, - }, - }; - - const credentialInDb = await prismock.credential.create({ - data: { - ...credential, - user: { - connect: { - id: defaultUser.id, + createMockJWTInstance({ + authorizeError: { + response: { + data: { + error: "unauthorized_client", + }, }, }, - app: { - connect: { - slug: app.slug, + }); + + const calendarService = new CalendarService(credentialWithDelegation); + + await expect(calendarService.listCalendars()).rejects.toThrow( + "Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console" + ); + }); + + test("handles DelegationCredential authorization errors appropriately", async () => { + const credentialWithDelegation = await createCredentialForCalendarService({ + user: { email: "user@example.com" }, + delegatedTo: defaultDelegatedCredential, + }); + + createMockJWTInstance({ + authorizeError: { + response: { + data: { + error: "unauthorized_client", + }, }, }, - }, - include: { - user: true, - }, + }); + + const calendarService = new CalendarService(credentialWithDelegation); + + await expect(calendarService.listCalendars()).rejects.toThrow( + "Make sure that the Client ID for the delegation credential is added to the Google Workspace Admin Console" + ); }); - return { - ...credentialInDb, - delegatedTo: delegatedTo ?? null, - user: user ? { email: user.email ?? "" } : null, - } as CredentialForCalendarServiceWithEmail; -} + test("handles invalid_grant error (user not in workspace) appropriately", async () => { + const credentialWithDelegation = await createCredentialForCalendarService({ + user: { email: "user@example.com" }, + delegatedTo: defaultDelegatedCredential, + }); -describe("GoogleCalendarService credential handling", () => { - beforeEach(() => { - lastCreatedJWT = null; - lastCreatedOAuth2Client = null; - }); - - const delegatedCredential = { - serviceAccountKey: { - client_email: "service@example.com", - client_id: "service-client-id", - private_key: "service-private-key", - }, - } as const; - - const createMockJWTInstance = ({ - email = "user@example.com", - authorizeError, - }: { - email?: string; - authorizeError?: { response?: { data?: { error?: string } } } | Error; - }) => { - const mockJWTInstance = { - type: "jwt", - config: { - email: delegatedCredential.serviceAccountKey.client_email, - key: delegatedCredential.serviceAccountKey.private_key, - scopes: ["https://www.googleapis.com/auth/calendar"], - subject: email, + createMockJWTInstance({ + authorizeError: { + response: { + data: { + error: "invalid_grant", + }, + }, }, - authorize: vi.fn().mockRejectedValue(authorizeError ?? new Error("Default error")), - createScoped: vi.fn(), - getRequestMetadataAsync: vi.fn(), - fetchIdToken: vi.fn(), - hasUserScopes: vi.fn(), - getAccessToken: vi.fn(), - getRefreshToken: vi.fn(), - getTokenInfo: vi.fn(), - refreshAccessToken: vi.fn(), - revokeCredentials: vi.fn(), - revokeToken: vi.fn(), - verifyIdToken: vi.fn(), - on: vi.fn(), - setCredentials: vi.fn(), - getCredentials: vi.fn(), - hasAnyScopes: vi.fn(), - authorizeAsync: vi.fn(), - refreshTokenNoCache: vi.fn(), - createGToken: vi.fn(), + }); + + const calendarService = new CalendarService(credentialWithDelegation); + + await expect(calendarService.listCalendars()).rejects.toThrow( + `User ${credentialWithDelegation.user?.email} might not exist in Google Workspace` + ); + }); + + test("handles general DelegationCredential authorization errors appropriately", async () => { + const credentialWithDelegation = await createCredentialForCalendarService({ + user: { email: "user@example.com" }, + delegatedTo: defaultDelegatedCredential, + }); + + createMockJWTInstance({ + authorizeError: new Error("Some unexpected error"), + }); + + const calendarService = new CalendarService(credentialWithDelegation); + + await expect(calendarService.listCalendars()).rejects.toThrow("Error authorizing delegation credential"); + }); + + test("handles missing user email for DelegationCredential appropriately", async () => { + const credentialWithDelegation = await createCredentialForCalendarService({ + user: { email: null }, + delegatedTo: defaultDelegatedCredential, + }); + + const calendarService = new CalendarService(credentialWithDelegation); + const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys(); + + await calendarService.listCalendars(); + + expect(lastCreatedJWT).toBeNull(); + + const expectedOAuth2Client: MockOAuth2Client = { + type: "oauth2", + args: [client_id, client_secret, redirect_uris[0]], + setCredentials: setCredentialsMock, }; - vi.mocked(JWT).mockImplementation(() => mockJWTInstance as unknown as JWT); - return mockJWTInstance; - }; + expect(lastCreatedOAuth2Client).toEqual(expectedOAuth2Client); + }); +}); +describe("GoogleCalendarService credential handling", () => { test("uses JWT auth with impersonation when Delegation credential is provided", async () => { - const credentialWithDelegation = await createCredentialInDb({ + const credentialWithDelegation = await createCredentialForCalendarService({ user: { email: "user@example.com" }, - delegatedTo: delegatedCredential, + delegatedTo: defaultDelegatedCredential, }); const calendarService = new CalendarService(credentialWithDelegation); @@ -724,15 +1253,15 @@ describe("GoogleCalendarService credential handling", () => { const expectedJWTConfig: MockJWT = { type: "jwt", config: { - email: delegatedCredential.serviceAccountKey.client_email, - key: delegatedCredential.serviceAccountKey.private_key, + email: defaultDelegatedCredential.serviceAccountKey.client_email, + key: defaultDelegatedCredential.serviceAccountKey.private_key, scopes: ["https://www.googleapis.com/auth/calendar"], subject: "user@example.com", }, authorize: expect.any(Function) as () => Promise, }; - expect(lastCreatedJWT).toEqual(expectedJWTConfig); + expect(lastCreatedJWT).toEqual(expect.objectContaining(expectedJWTConfig)); expect(calendarMock.calendar_v3.Calendar).toHaveBeenCalledWith({ auth: lastCreatedJWT, @@ -740,7 +1269,7 @@ describe("GoogleCalendarService credential handling", () => { }); test("uses OAuth2 auth when no Delegation credential is provided", async () => { - const regularCredential = await createCredentialInDb(); + const regularCredential = await createCredentialForCalendarService(); const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys(); const calendarService = new CalendarService(regularCredential); @@ -764,9 +1293,9 @@ describe("GoogleCalendarService credential handling", () => { }); test("handles DelegationCredential authorization errors appropriately", async () => { - const credentialWithDelegation = await createCredentialInDb({ + const credentialWithDelegation = await createCredentialForCalendarService({ user: { email: "user@example.com" }, - delegatedTo: delegatedCredential, + delegatedTo: defaultDelegatedCredential, }); createMockJWTInstance({ @@ -787,9 +1316,9 @@ describe("GoogleCalendarService credential handling", () => { }); test("handles invalid_grant error (user not in workspace) appropriately", async () => { - const credentialWithDelegation = await createCredentialInDb({ + const credentialWithDelegation = await createCredentialForCalendarService({ user: { email: "user@example.com" }, - delegatedTo: delegatedCredential, + delegatedTo: defaultDelegatedCredential, }); createMockJWTInstance({ @@ -804,13 +1333,15 @@ describe("GoogleCalendarService credential handling", () => { const calendarService = new CalendarService(credentialWithDelegation); - await expect(calendarService.listCalendars()).rejects.toThrow("User might not exist in Google Workspace"); + await expect(calendarService.listCalendars()).rejects.toThrow( + `User ${credentialWithDelegation.user?.email} might not exist in Google Workspace` + ); }); test("handles general DelegationCredential authorization errors appropriately", async () => { - const credentialWithDelegation = await createCredentialInDb({ + const credentialWithDelegation = await createCredentialForCalendarService({ user: { email: "user@example.com" }, - delegatedTo: delegatedCredential, + delegatedTo: defaultDelegatedCredential, }); createMockJWTInstance({ @@ -823,9 +1354,9 @@ describe("GoogleCalendarService credential handling", () => { }); test("handles missing user email for DelegationCredential appropriately", async () => { - const credentialWithDelegation = await createCredentialInDb({ + const credentialWithDelegation = await createCredentialForCalendarService({ user: { email: null }, - delegatedTo: delegatedCredential, + delegatedTo: defaultDelegatedCredential, }); const calendarService = new CalendarService(credentialWithDelegation); @@ -844,3 +1375,73 @@ describe("GoogleCalendarService credential handling", () => { expect(lastCreatedOAuth2Client).toEqual(expectedOAuth2Client); }); }); + +describe("getAvailability", () => { + test("returns availability for selected calendars", async () => { + const credential = await createCredentialForCalendarService(); + const calendarService = new CalendarService(credential); + setFullMockOAuthManagerRequest(); + const mockedBusyTimes1 = [ + { + start: "2024-01-01", + end: "2024-01-02", + }, + ]; + const mockedBusyTimes2 = [ + { + start: "2024-01-03", + end: "2024-01-04", + }, + ]; + + const mockedBusyTimes = [mockedBusyTimes1, mockedBusyTimes2]; + calendarListMock.mockImplementation(() => { + return { + data: { + items: [ + { + id: "calendar1@test.com", + }, + { + id: "calendar2@test.com", + }, + ], + }, + }; + }); + // Mock Once so that the getAvailability call doesn't accidentally reuse this mock result + freebusyQueryMock.mockImplementation(({ requestBody }: { requestBody: any }) => { + const calendarsObject: any = {}; + requestBody.items.forEach((item: any, index: number) => { + calendarsObject[item.id] = { + busy: mockedBusyTimes[index], + }; + }); + return { + data: { + calendars: calendarsObject, + }, + }; + }); + + const availabilityWithPrimaryAsFallback = await calendarService.getAvailability( + "2024-01-01", + "2024-01-02", + [], + false, + true + ); + + expect(availabilityWithPrimaryAsFallback).toEqual(mockedBusyTimes1); + + const availabilityWithAllCalendarsAsFallback = await calendarService.getAvailability( + "2024-01-01", + "2024-01-02", + [], + false, + false + ); + + expect(availabilityWithAllCalendarsAsFallback).toEqual([...mockedBusyTimes1, ...mockedBusyTimes2]); + }); +}); diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 04fdb4345d..361a146072 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -85,6 +85,10 @@ export default class GoogleCalendarService implements Calendar { this.log = log.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] }); } + public getCredentialId() { + return this.credential.id; + } + private async getMyGoogleAuthSingleton() { if (this.myGoogleAuth) { return this.myGoogleAuth; @@ -217,7 +221,7 @@ export default class GoogleCalendarService implements Calendar { if ((error as any).response?.data?.error === "invalid_grant") { throw new CalendarAppDelegationCredentialInvalidGrantError( - "User might not exist in Google Workspace" + `User ${emailToImpersonate} might not exist in Google Workspace` ); } @@ -337,7 +341,7 @@ export default class GoogleCalendarService implements Calendar { private async startWatchingCalendarsInGoogle({ calendarId }: { calendarId: string }) { const calendar = await this.authedCalendar(); - logger.debug(`Subscribing to calendar ${calendarId}`); + logger.debug(`Subscribing to calendar ${calendarId}`, safeStringify({ GOOGLE_WEBHOOK_URL })); 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. @@ -650,6 +654,7 @@ export default class GoogleCalendarService implements Calendar { } async fetchAvailability(requestBody: FreeBusyArgs): Promise { + log.debug("fetchAvailability", safeStringify({ requestBody })); const calendar = await this.authedCalendar(); const apiResponse = await this.oAuthManagerInstance.request( async () => new AxiosLikeResponseToFetchResponse(await calendar.freebusy.query({ requestBody })) @@ -663,17 +668,38 @@ export default class GoogleCalendarService implements Calendar { ): Promise { if (shouldServeCache === false) return await this.fetchAvailability(args); const calendarCache = await CalendarCache.init(null); - const cached = await calendarCache.getCachedAvailability(this.credential.id, { - // Expand the start date to the start of the month to increase cache hits - timeMin: getTimeMin(args.timeMin), - // Expand the end date to the end of the month to increase cache hits - timeMax: getTimeMax(args.timeMax), - items: args.items, + const cached = await calendarCache.getCachedAvailability({ + credentialId: this.credential.id, + userId: this.credential.userId, + args: { + // Expand the start date to the start of the month to increase cache hits + timeMin: getTimeMin(args.timeMin), + // Expand the end date to the end of the month to increase cache hits + timeMax: getTimeMax(args.timeMax), + items: args.items, + }, }); - if (cached) return cached.value as unknown as calendar_v3.Schema$FreeBusyResponse; + if (cached) { + log.debug("[Cache Hit] Returning cached freebusy result", safeStringify({ cached, args })); + return cached.value as unknown as calendar_v3.Schema$FreeBusyResponse; + } + log.debug("[Cache Miss] Fetching freebusy result", safeStringify({ args })); return await this.fetchAvailability(args); } + getValidCalendars(cals: T[]) { + return cals.filter((cal): cal is T & { id: string } => !!cal.id); + } + + filterPrimaryCalendar(cals: calendar_v3.Schema$CalendarListEntry[]) { + const validCals = this.getValidCalendars(cals); + const primaryCal = validCals.find((cal) => !!cal.primary); + if (primaryCal) { + return primaryCal; + } + return validCals[0]; + } + async getCacheOrFetchAvailability( args: FreeBusyArgs, shouldServeCache?: boolean @@ -698,7 +724,11 @@ export default class GoogleCalendarService implements Calendar { async getAvailabilityWithTimeZones( dateFrom: string, dateTo: string, - selectedCalendars: IntegrationCalendar[] + selectedCalendars: IntegrationCalendar[], + /** + * If true, we will fallback to the primary calendar if no valid selected calendars are found + */ + fallbackToPrimary?: boolean ): Promise<{ start: Date | string; end: Date | string; timeZone: string }[]> { const calendar = await this.authedCalendar(); const selectedCalendarIds = selectedCalendars @@ -722,9 +752,16 @@ export default class GoogleCalendarService implements Calendar { }; }); } + if (!fallbackToPrimary) return []; - // we ever reach that code, we already check before if selectedCalendarIds is empty - return []; + const primaryCalendar = this.filterPrimaryCalendar(cals); + if (!primaryCalendar) return []; + return [ + { + id: primaryCalendar.id, + timeZone: primaryCalendar.timeZone || "", + }, + ]; }; try { @@ -765,7 +802,11 @@ export default class GoogleCalendarService implements Calendar { dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[], - shouldServeCache?: boolean + shouldServeCache?: boolean, + /** + * If true, we will fallback to the primary calendar if no valid selected calendars are found + */ + fallbackToPrimary?: boolean ): Promise { this.log.debug("Getting availability", safeStringify({ dateFrom, dateTo, selectedCalendars })); const calendar = await this.authedCalendar(); @@ -778,8 +819,13 @@ export default class GoogleCalendarService implements Calendar { } const getCalIds = async () => { if (selectedCalendarIds.length !== 0) return selectedCalendarIds; - const cals = await this.getAllCalendars(calendar, ["id"]); + const cals = await this.getAllCalendars(calendar, ["id", "primary"]); if (!cals.length) return []; + if (!fallbackToPrimary) return this.getValidCalendars(cals).map((cal) => cal.id); + + const primaryCalendar = this.filterPrimaryCalendar(cals); + if (!primaryCalendar) return []; + return [primaryCalendar.id]; return cals.reduce((c, cal) => (cal.id ? [...c, cal.id] : c), [] as string[]); }; @@ -838,6 +884,15 @@ export default class GoogleCalendarService implements Calendar { } } + /** + * Better alternative to `getPrimaryCalendar` that doesn't require any argument + */ + async fetchPrimaryCalendar() { + const calendar = await this.authedCalendar(); + const primaryCalendar = await this.getPrimaryCalendar(calendar); + return primaryCalendar; + } + async listCalendars(): Promise { this.log.debug("Listing calendars"); const calendar = await this.authedCalendar(); @@ -879,6 +934,7 @@ export default class GoogleCalendarService implements Calendar { } /** + * calendarId is the externalId for the SelectedCalendar * It doesn't check if the subscription has expired or not. * It just creates a new subscription. */ @@ -889,6 +945,7 @@ export default class GoogleCalendarService implements Calendar { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds; }) { + log.debug("watchCalendar", safeStringify({ calendarId, eventTypeIds })); if (!process.env.GOOGLE_WEBHOOK_TOKEN) { log.warn("GOOGLE_WEBHOOK_TOKEN is not set, skipping watching calendar"); return; @@ -971,7 +1028,7 @@ export default class GoogleCalendarService implements Calendar { }); const calendarWithSameExternalId = calendarsWithSameCredentialId.filter( - (sc) => sc.externalId === calendarId + (sc) => sc.externalId === calendarId && sc.integration === this.integrationName ); const calendarsWithSameExternalIdThatAreBeingWatched = calendarWithSameExternalId.filter( @@ -1030,18 +1087,27 @@ export default class GoogleCalendarService implements Calendar { // Populate the cache back for the remaining calendars, if any const remainingCalendars = - calendarsWithSameCredentialId.filter((sc) => sc.externalId !== calendarId) || []; + calendarsWithSameCredentialId.filter( + (sc) => sc.externalId !== calendarId && sc.integration === this.integrationName + ) || []; if (remainingCalendars.length > 0) { await this.fetchAvailabilityAndSetCache(remainingCalendars); } } async setAvailabilityInCache(args: FreeBusyArgs, data: calendar_v3.Schema$FreeBusyResponse): Promise { + log.debug("setAvailabilityInCache", safeStringify({ args, data })); const calendarCache = await CalendarCache.init(null); - await calendarCache.upsertCachedAvailability(this.credential.id, args, JSON.parse(JSON.stringify(data))); + await calendarCache.upsertCachedAvailability({ + credentialId: this.credential.id, + userId: this.credential.userId, + args, + value: JSON.parse(JSON.stringify(data)), + }); } async fetchAvailabilityAndSetCache(selectedCalendars: IntegrationCalendar[]) { + this.log.debug("fetchAvailabilityAndSetCache", safeStringify({ selectedCalendars })); const selectedCalendarsPerEventType = new Map< SelectedCalendarEventTypeIds[number], IntegrationCalendar[] @@ -1104,6 +1170,10 @@ export default class GoogleCalendarService implements Calendar { data: Omit, eventTypeIds: SelectedCalendarEventTypeIds ) { + log.debug( + "upsertSelectedCalendarsForEventTypeIds", + safeStringify({ data, eventTypeIds, credential: this.credential }) + ); if (!this.credential.userId) { logger.error("upsertSelectedCalendarsForEventTypeIds failed. userId is missing."); return; @@ -1114,6 +1184,7 @@ export default class GoogleCalendarService implements Calendar { ...data, integration: this.integrationName, credentialId: this.credential.id, + delegationCredentialId: this.credential.delegatedToId ?? null, userId: this.credential.userId, }, eventTypeIds, diff --git a/packages/app-store/googlecalendar/lib/__mocks__/googleapis.ts b/packages/app-store/googlecalendar/lib/__mocks__/googleapis.ts index a7b883c6b3..7c06b73959 100644 --- a/packages/app-store/googlecalendar/lib/__mocks__/googleapis.ts +++ b/packages/app-store/googlecalendar/lib/__mocks__/googleapis.ts @@ -1,11 +1,15 @@ import { vi } from "vitest"; const setCredentialsMock = vi.fn(); - +const freebusyQueryMock = vi.fn(); +const calendarListMock = vi.fn(); const calendarMockImplementation = { channels: { stop: vi.fn().mockResolvedValue(undefined), }, + calendarList: { + list: calendarListMock, + }, events: { watch: vi.fn().mockResolvedValue({ data: { @@ -17,6 +21,9 @@ const calendarMockImplementation = { }, }), }, + freebusy: { + query: freebusyQueryMock, + }, }; const calendarMock = { @@ -30,4 +37,4 @@ const adminMock = { }, }; -export { calendarMock, adminMock, setCredentialsMock }; +export { calendarMock, adminMock, setCredentialsMock, freebusyQueryMock, calendarListMock }; diff --git a/packages/app-store/ics-feedcalendar/api/add.ts b/packages/app-store/ics-feedcalendar/api/add.ts index 1ae3d89049..90d8cd0758 100644 --- a/packages/app-store/ics-feedcalendar/api/add.ts +++ b/packages/app-store/ics-feedcalendar/api/add.ts @@ -29,6 +29,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) teamId: null, appId: appConfig.slug, invalid: false, + delegationCredentialId: null, }; try { diff --git a/packages/app-store/office365video/lib/VideoApiAdapter.test.ts b/packages/app-store/office365video/lib/VideoApiAdapter.test.ts index 8567f5bb61..7d3917b8b1 100644 --- a/packages/app-store/office365video/lib/VideoApiAdapter.test.ts +++ b/packages/app-store/office365video/lib/VideoApiAdapter.test.ts @@ -55,6 +55,7 @@ const testCredential = { user: { email: "example@cal.com" }, teamId: 1, delegatedTo: null, + delegationCredentialId: null, }; describe("createMeeting", () => { diff --git a/packages/app-store/salesforce/lib/CrmService.test.ts b/packages/app-store/salesforce/lib/CrmService.test.ts index 6954453c9f..3c499c793c 100644 --- a/packages/app-store/salesforce/lib/CrmService.test.ts +++ b/packages/app-store/salesforce/lib/CrmService.test.ts @@ -113,6 +113,7 @@ describe("SalesforceCRMService", () => { user: { email: "test-user@example.com", }, + delegationCredentialId: null, }; service = new SalesforceCRMService(mockCredential, {}, true); diff --git a/packages/app-store/salesforce/lib/__tests__/CrmService.integration.test.ts b/packages/app-store/salesforce/lib/__tests__/CrmService.integration.test.ts index bddb48b226..968dd42ca9 100644 --- a/packages/app-store/salesforce/lib/__tests__/CrmService.integration.test.ts +++ b/packages/app-store/salesforce/lib/__tests__/CrmService.integration.test.ts @@ -74,6 +74,7 @@ const createMockCredential = () => { teamId: 1, appId: "test_app_id", invalid: false, + delegationCredentialId: null, }; }; diff --git a/packages/app-store/tests/__mocks__/OAuthManager.ts b/packages/app-store/tests/__mocks__/OAuthManager.ts index e210d9b860..b7c536324f 100644 --- a/packages/app-store/tests/__mocks__/OAuthManager.ts +++ b/packages/app-store/tests/__mocks__/OAuthManager.ts @@ -4,17 +4,30 @@ import { mockClear, mockDeep } from "vitest-mock-extended"; import type * as OAuthManager from "../../_utils/oauth/OAuthManager"; vi.mock("../../_utils/oauth/OAuthManager", () => oAuthManagerMock); +let useFullMockOAuthManagerRequest: boolean | null = null; beforeEach(() => { mockClear(oAuthManagerMock); + useFullMockOAuthManagerRequest = null; }); +const oAuthManagerRequestFullMock = async (fn: () => Promise) => { + const res = await fn(); + return { + json: await res.json(), + }; +}; + const oAuthManagerMock = mockDeep({ fallbackMockImplementation: () => { throw new Error("Unimplemented"); }, }); + export default oAuthManagerMock; +const setFullMockOAuthManagerRequest = () => { + useFullMockOAuthManagerRequest = true; +}; const defaultMockOAuthManager = vi.fn().mockImplementation(() => { return { getTokenObjectOrFetch: vi.fn().mockImplementation(() => { @@ -24,12 +37,19 @@ const defaultMockOAuthManager = vi.fn().mockImplementation(() => { }, }; }), - request: vi.fn().mockResolvedValue({ - json: { - calendars: [], - }, + request: vi.fn().mockImplementation((fn) => { + if (useFullMockOAuthManagerRequest) { + console.log("OAuthManager.request full mock being used"); + return oAuthManagerRequestFullMock(fn); + } + console.log("OAuthManager.request default mock being used"); + return { + json: { + calendars: [], + }, + }; }), }; }); -export { oAuthManagerMock, defaultMockOAuthManager }; +export { oAuthManagerMock, defaultMockOAuthManager, setFullMockOAuthManagerRequest }; diff --git a/packages/app-store/utils.ts b/packages/app-store/utils.ts index 9f63a3be96..0c48c600d0 100644 --- a/packages/app-store/utils.ts +++ b/packages/app-store/utils.ts @@ -67,6 +67,7 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials? invalid: false, delegatedTo: null, delegatedToId: null, + delegationCredentialId: null, team: { name: "Global", }, diff --git a/packages/features/bookings/lib/handleNewBooking/test/delegation-credential.test.ts b/packages/features/bookings/lib/handleNewBooking/test/delegation-credential.test.ts index 0568508d55..2adf102f79 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/delegation-credential.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/delegation-credential.test.ts @@ -51,7 +51,7 @@ describe("handleNewBooking", () => { setupAndTeardown(); describe("Delegation Credential", () => { test( - `should create a successful booking using the delegation credential + `should create a successful booking using the delegation credential when User's destination calendar is set toGoogle Calendar 1. Should create a booking in the database with reference having Delegation credential 2. Should create an event in calendar with Delegation credential 3. Should use Google Meet as the location even when not explicitly set. @@ -229,6 +229,184 @@ describe("handleNewBooking", () => { timeout ); + test( + `should create a successful booking using the delegation credential event when User/EventType's destination calendar is not set + 1. Should create a booking in the database with reference having Delegation credential + 2. Should create an event in calendar with Delegation credential + 3. Should use Google Meet as the location even when not explicitly set and no destination calendar is set for User/EventType +`, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const org = await createOrganization({ + name: "Test Org", + slug: "testorg", + }); + + const payloadToMakePartOfOrganization = [ + { + membership: { + accepted: true, + role: MembershipRole.ADMIN, + }, + team: { + id: org.id, + name: "Test Org", + slug: "testorg", + }, + }, + ]; + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + selectedCalendars: [TestData.selectedCalendars.google], + // User must be part of organization to be able to use that organization's Delegation credential + teams: payloadToMakePartOfOrganization, + // No regular credentials provided + credentials: [], + }); + + const delegationCredential = await createDelegationCredential(org.id); + const payloadToCreateUserEventTypeForOrganizer = { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: organizer.id, + }, + ], + }; + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + workflows: [ + { + userId: organizer.id, + trigger: "NEW_EVENT", + action: "EMAIL_HOST", + template: "REMINDER", + activeOn: [1], + }, + ], + eventTypes: [payloadToCreateUserEventTypeForOrganizer], + organizer, + apps: [TestData.apps["daily-video"], TestData.apps["google-calendar"]], + }) + ); + + // Mock a Scenario where iCalUID isn't returned by Google Calendar in which case booking UID is used as the ics UID + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "GOOGLE_CALENDAR_EVENT_ID", + uid: "MOCK_ID", + appSpecificData: { + googleCalendar: { + hangoutLink: "https://GOOGLE_MEET_URL_IN_CALENDAR_EVENT", + }, + }, + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.GoogleMeet }, + }, + }, + }); + + const createdBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(createdBooking.responses).toEqual( + expect.objectContaining({ + email: booker.email, + name: booker.name, + }) + ); + + expect(createdBooking).toEqual( + expect.objectContaining({ + location: BookingLocations.GoogleMeet, + }) + ); + + await expectBookingToBeInDatabase({ + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + references: [ + { + type: appStoreMetadata.googlecalendar.type, + uid: "GOOGLE_CALENDAR_EVENT_ID", + meetingId: "GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://GOOGLE_MEET_URL_IN_CALENDAR_EVENT", + // Verify Delegation credential was used + delegationCredentialId: delegationCredential.id, + }, + ], + iCalUID: createdBooking.iCalUID, + }); + + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + credential: buildDelegationCredential({ + serviceAccountKey: delegationCredential.serviceAccountKey, + }), + calendarIdUsingFallbackOfFirstCalendarCredential: TestData.selectedCalendars.google.externalId, + // There would be no videoCallUrl in this case as it is not a dedicated conferencing case and hangoutLink is used instead + videoCallUrl: null, + }); + + const iCalUID = expectICalUIDAsString(createdBooking.iCalUID); + + expectSuccessfulBookingCreationEmails({ + booking: { + uid: createdBooking.uid!, + }, + booker, + organizer, + emails, + iCalUID, + }); + + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.GoogleMeet, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: "https://GOOGLE_MEET_URL_IN_CALENDAR_EVENT", + }); + }, + timeout + ); + test( `should create a successful booking using the office365 delegation credential with Cal Video as the location 1. Should create a booking in the database with reference having Delegation credential diff --git a/packages/features/calendar-cache/CalendarCache.md b/packages/features/calendar-cache/CalendarCache.md index a049b58730..de561e8019 100644 --- a/packages/features/calendar-cache/CalendarCache.md +++ b/packages/features/calendar-cache/CalendarCache.md @@ -3,8 +3,9 @@ ## 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 + - Identifies selectedCalendars that are not watched(identified by `googleChannelId` being null) and watches them - When feature is enabled, it starts watching all related calendars + - SelectedCalendars with same `externalId` are considered same from CalendarCache perspective - 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 diff --git a/packages/features/calendar-cache/api/cron.ts b/packages/features/calendar-cache/api/cron.ts index 32b9ed9150..f5a58016cf 100644 --- a/packages/features/calendar-cache/api/cron.ts +++ b/packages/features/calendar-cache/api/cron.ts @@ -1,6 +1,7 @@ import type { NextApiRequest } from "next"; import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; import { defaultHandler } from "@calcom/lib/server/defaultHandler"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; @@ -8,6 +9,8 @@ import type { SelectedCalendarEventTypeIds } from "@calcom/types/Calendar"; import { CalendarCache } from "../calendar-cache"; +const log = logger.getSubLogger({ prefix: ["CalendarCacheCron"] }); + const validateRequest = (req: NextApiRequest) => { const apiKey = req.headers.authorization || req.query.apiKey; if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) { @@ -22,7 +25,12 @@ function logRejected(result: PromiseSettledResult) { } function getUniqueCalendarsByExternalId< - T extends { externalId: string; eventTypeId: number | null; credentialId: number | null; id: string } + T extends { + externalId: string; + eventTypeId: number | null; + credentialId: number | null; + id: string; + } >(calendars: T[]) { type ExternalId = string; return calendars.reduce( @@ -57,12 +65,27 @@ const handleCalendarsToUnwatch = async () => { async ([externalId, { eventTypeIds, credentialId, id }]) => { if (!credentialId) { // So we don't retry on next cron run - await SelectedCalendarRepository.updateById(id, { error: "Missing credentialId" }); - console.log("no credentialId for SelecedCalendar: ", id); + + // FIXME: There could actually be multiple calendars with the same externalId and thus we need to technically update error for all of them + await SelectedCalendarRepository.updateById(id, { + error: "Missing credentialId", + }); + log.error("no credentialId for SelectedCalendar: ", id); return; } - const cc = await CalendarCache.initFromCredentialId(credentialId); - await cc.unwatchCalendar({ calendarId: externalId, eventTypeIds }); + + try { + const cc = await CalendarCache.initFromCredentialId(credentialId); + await cc.unwatchCalendar({ calendarId: externalId, eventTypeIds }); + } catch (error) { + let errorMessage = "Unknown error"; + if (error instanceof Error) { + errorMessage = error.message; + } + await SelectedCalendarRepository.updateById(id, { + error: `Error unwatching calendar: ${errorMessage}`, + }); + } } ) ); @@ -79,12 +102,25 @@ const handleCalendarsToWatch = async () => { async ([externalId, { credentialId, eventTypeIds, id }]) => { if (!credentialId) { // So we don't retry on next cron run - await SelectedCalendarRepository.updateById(id, { error: "Missing credentialId" }); - console.log("no credentialId for SelecedCalendar: ", id); + await SelectedCalendarRepository.updateById(id, { + error: "Missing credentialId", + }); + log.error("no credentialId for SelectedCalendar: ", id); return; } - const cc = await CalendarCache.initFromCredentialId(credentialId); - await cc.watchCalendar({ calendarId: externalId, eventTypeIds }); + + try { + const cc = await CalendarCache.initFromCredentialId(credentialId); + await cc.watchCalendar({ calendarId: externalId, eventTypeIds }); + } catch (error) { + let errorMessage = "Unknown error"; + if (error instanceof Error) { + errorMessage = error.message; + } + await SelectedCalendarRepository.updateById(id, { + error: `Error watching calendar: ${errorMessage}`, + }); + } } ) ); diff --git a/packages/features/calendar-cache/calendar-cache.repository.interface.ts b/packages/features/calendar-cache/calendar-cache.repository.interface.ts index 96bb7d79a1..02832f1ba6 100644 --- a/packages/features/calendar-cache/calendar-cache.repository.interface.ts +++ b/packages/features/calendar-cache/calendar-cache.repository.interface.ts @@ -7,10 +7,24 @@ export type FreeBusyArgs = { timeMin: string; timeMax: string; items: { id: stri export interface ICalendarCacheRepository { watchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }): Promise; unwatchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }): Promise; - upsertCachedAvailability( - credentialId: number, - args: FreeBusyArgs, - value: Prisma.JsonNullValueInput | Prisma.InputJsonValue - ): Promise; - getCachedAvailability(credentialId: number, args: FreeBusyArgs): Promise; + upsertCachedAvailability({ + credentialId, + userId, + args, + value, + }: { + credentialId: number; + userId: number | null; + args: FreeBusyArgs; + value: Prisma.JsonNullValueInput | Prisma.InputJsonValue; + }): Promise; + getCachedAvailability({ + credentialId, + userId, + args, + }: { + credentialId: number; + userId: number | null; + args: FreeBusyArgs; + }): Promise; } diff --git a/packages/features/calendar-cache/calendar-cache.repository.mock.ts b/packages/features/calendar-cache/calendar-cache.repository.mock.ts index 98e303645c..94936d0714 100644 --- a/packages/features/calendar-cache/calendar-cache.repository.mock.ts +++ b/packages/features/calendar-cache/calendar-cache.repository.mock.ts @@ -19,4 +19,8 @@ export class CalendarCacheRepositoryMock implements ICalendarCacheRepository { async unwatchCalendar() { log.info(`Skipping unwatchCalendar due to calendar-cache being disabled`); } + + async deleteManyByCredential() { + log.info(`Skipping deleteManyByCredential due to calendar-cache being disabled`); + } } diff --git a/packages/features/calendar-cache/calendar-cache.repository.test.ts b/packages/features/calendar-cache/calendar-cache.repository.test.ts new file mode 100644 index 0000000000..cc52dff94f --- /dev/null +++ b/packages/features/calendar-cache/calendar-cache.repository.test.ts @@ -0,0 +1,190 @@ +import prismock from "../../../tests/libs/__mocks__/prisma"; + +import { describe, expect, it, beforeEach, vi } from "vitest"; + +import type { Calendar } from "@calcom/types/Calendar"; + +import { CalendarCacheRepository } from "./calendar-cache.repository"; + +vi.mock("./lib/datesForCache", () => ({ + getTimeMin: vi.fn().mockImplementation((timeMin) => timeMin), + getTimeMax: vi.fn().mockImplementation((timeMax) => timeMax), +})); + +describe("CalendarCacheRepository", () => { + let mockCalendar: Calendar; + + beforeEach(() => { + // Clear the database before each test + prismock.calendarCache.deleteMany(); + + // Setup mock calendar + mockCalendar = { + watchCalendar: vi.fn(), + unwatchCalendar: vi.fn(), + } as unknown as Calendar; + }); + + // It needs to consider parseKeyForCache being called + // eslint-disable-next-line playwright/no-skipped-test + describe("getCachedAvailability", () => { + it("should return cached availability data", async () => { + const repository = new CalendarCacheRepository(); + const args = { + timeMin: "2023-01-01T00:00:00Z", + timeMax: "2023-01-02T00:00:00Z", + items: [{ id: "calendar-1" }], + }; + + const testData = { + credentialId: 1, + userId: 1, + key: JSON.stringify({ + timeMin: args.timeMin, + timeMax: args.timeMax, + items: args.items, + }), + value: { busy: [] }, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }; + + await prismock.calendarCache.create({ data: testData }); + + const result = await repository.getCachedAvailability({ + credentialId: 1, + userId: 1, + args, + }); + + expect(result).toEqual( + expect.objectContaining({ + key: testData.key, + value: testData.value, + }) + ); + }); + + it("should return cached availability data even if credentialId is -1", async () => { + const repository = new CalendarCacheRepository(); + const args = { + timeMin: "2023-01-01T00:00:00Z", + timeMax: "2023-01-02T00:00:00Z", + items: [{ id: "calendar-1" }], + }; + + const testData = { + credentialId: -1, + userId: 1, + key: JSON.stringify({ + timeMin: args.timeMin, + timeMax: args.timeMax, + items: args.items, + }), + value: { busy: [] }, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }; + + await prismock.calendarCache.create({ data: testData }); + + const resultWithWrongUserId = await repository.getCachedAvailability({ + credentialId: -1, + userId: 2, + args, + }); + + expect(resultWithWrongUserId).toBeNull(); + const resultWithCorrectUserId = await repository.getCachedAvailability({ + credentialId: -1, + userId: 1, + args, + }); + + expect(resultWithCorrectUserId).toEqual( + expect.objectContaining({ + key: testData.key, + value: testData.value, + }) + ); + }); + }); + + describe("upsertCachedAvailability(mocks min and max time fns)", () => { + it("should create new cache entry when none exists", async () => { + const repository = new CalendarCacheRepository(); + const args = { + timeMin: "2023-01-01T00:00:00Z", + timeMax: "2023-01-02T00:00:00Z", + items: [{ id: "calendar-1" }], + }; + const value = { busy: [] }; + + await repository.upsertCachedAvailability({ + credentialId: 1, + userId: 1, + args, + value, + }); + + const result = await prismock.calendarCache.findFirst({ + where: { + credentialId: 1, + }, + }); + + expect(result).toEqual( + expect.objectContaining({ + credentialId: 1, + value, + }) + ); + }); + + it("should update existing cache entry", async () => { + const repository = new CalendarCacheRepository(); + const args = { + timeMin: "2023-01-01T00:00:00Z", + timeMax: "2023-01-02T00:00:00Z", + items: [{ id: "calendar-1" }], + }; + + const key = JSON.stringify({ + timeMin: args.timeMin, + timeMax: args.timeMax, + items: args.items, + }); + + // Create initial cache entry + const initialData = { + credentialId: 1, + key, + value: { busy: [] }, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }; + await prismock.calendarCache.create({ data: initialData }); + + // Update with new value + const newValue = { busy: [{ start: "2023-01-01T10:00:00Z", end: "2023-01-01T11:00:00Z" }] }; + await repository.upsertCachedAvailability({ + credentialId: 1, + userId: 1, + args, + value: newValue, + }); + + const result = await prismock.calendarCache.findFirst({ + where: { + credentialId: 1, + key, + }, + }); + + expect(result).toEqual( + expect.objectContaining({ + credentialId: 1, + key, + value: newValue, + }) + ); + }); + }); +}); diff --git a/packages/features/calendar-cache/calendar-cache.repository.ts b/packages/features/calendar-cache/calendar-cache.repository.ts index 6c941b1c3e..8260672951 100644 --- a/packages/features/calendar-cache/calendar-cache.repository.ts +++ b/packages/features/calendar-cache/calendar-cache.repository.ts @@ -1,6 +1,7 @@ import type { Prisma } from "@prisma/client"; import { uniqueBy } from "@calcom/lib/array"; +import { isInMemoryDelegationCredential } from "@calcom/lib/delegationCredential/clientAndServer"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; @@ -27,12 +28,33 @@ function parseKeyForCache(args: FreeBusyArgs): string { type FreeBusyArgs = { timeMin: string; timeMax: string; items: { id: string }[] }; +/** + * It means that caller can only work with DB Credentials + * In-memory delegation credentials aren't supported here. Delegation User Credentials, that are in DB and have credential.delegationCredential relation can be used though + */ +function assertCalendarHasDbCredential(calendar: Calendar | null) { + if (!calendar?.getCredentialId) { + return; + } + const credentialId = calendar.getCredentialId(); + if (credentialId < 0) { + throw new Error(`Received invalid credentialId ${credentialId}`); + } +} +/** + * It means that caller can work with in-memory credential + */ +function declareCanWorkWithInMemoryCredential() { + // No assertion required here, it is for readability who reads the caller's code +} + export class CalendarCacheRepository implements ICalendarCacheRepository { calendar: Calendar | null; constructor(calendar: Calendar | null = null) { this.calendar = calendar; } async watchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }) { + assertCalendarHasDbCredential(this.calendar); const { calendarId, eventTypeIds } = args; if (typeof this.calendar?.watchCalendar !== "function") { log.info( @@ -44,6 +66,7 @@ export class CalendarCacheRepository implements ICalendarCacheRepository { } async unwatchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }) { + assertCalendarHasDbCredential(this.calendar); const { calendarId, eventTypeIds } = args; if (typeof this.calendar?.unwatchCalendar !== "function") { log.info( @@ -55,25 +78,64 @@ export class CalendarCacheRepository implements ICalendarCacheRepository { return response; } - async getCachedAvailability(credentialId: number, args: FreeBusyArgs) { + async getCachedAvailability({ + credentialId, + userId, + args, + }: { + credentialId: number; + userId: number | null; + args: FreeBusyArgs; + }) { + declareCanWorkWithInMemoryCredential(); + log.debug("Getting cached availability", safeStringify({ credentialId, userId, args })); const key = parseKeyForCache(args); - const cached = await prisma.calendarCache.findUnique({ - where: { - credentialId_key: { - credentialId, + let cached; + if (isInMemoryDelegationCredential({ credentialId })) { + if (!userId) { + log.warn("userId is not available when querying cache for in-memory delegation credential"); + return null; + } + // We don't have credentialId available when querying the cache, as we use in-memory delegation credentials for this which don't have valid credentialId + // Also, we would prefer to reuse the existing calendar-cache(connected to regular credentials) when enabling delegation credentials, for which we can't use credentialId in querying as that is not in DB + // Security/Privacy wise, it is fine to query solely based on userId as userId and key(which has external email Ids in there) together can be used to uniquely identify the cache + // A user could have multiple third party calendars connected, but they key would still be different for each case in calendar-cache because of the presence of emails in there. + // Sample key: {"timeMin":"2025-04-01T00:00:00.000Z","timeMax":"2025-08-01T00:00:00.000Z","items":[{"id":"owner@example.com"}]} <- Notice it has emailId in there for which busytimes are fetched, we could assume that these emailIds would be unique across different calendars like Google/Outlook + cached = await prisma.calendarCache.findFirst({ + // We have index on userId and key, so this should be fast + // TODO: Should we consider index on all three - userId, key and expiresAt? + where: { + userId, key, + expiresAt: { gte: new Date(Date.now()) }, }, - expiresAt: { gte: new Date(Date.now()) }, - }, - }); + }); + } else { + cached = await prisma.calendarCache.findUnique({ + where: { + credentialId_key: { + credentialId, + key, + }, + expiresAt: { gte: new Date(Date.now()) }, + }, + }); + } log.info("Got cached availability", safeStringify({ key, cached })); return cached; } - async upsertCachedAvailability( - credentialId: number, - args: FreeBusyArgs, - value: Prisma.JsonNullValueInput | Prisma.InputJsonValue - ) { + async upsertCachedAvailability({ + credentialId, + userId, + args, + value, + }: { + credentialId: number; + userId: number | null; + args: FreeBusyArgs; + value: Prisma.JsonNullValueInput | Prisma.InputJsonValue; + }) { + assertCalendarHasDbCredential(this.calendar); const key = parseKeyForCache(args); await prisma.calendarCache.upsert({ where: { @@ -89,6 +151,7 @@ export class CalendarCacheRepository implements ICalendarCacheRepository { create: { value, credentialId, + userId, key, expiresAt: new Date(Date.now() + CACHING_TIME), }, diff --git a/packages/features/calendar-cache/calendar-cache.ts b/packages/features/calendar-cache/calendar-cache.ts index 078a654bbd..dde2b98ea7 100644 --- a/packages/features/calendar-cache/calendar-cache.ts +++ b/packages/features/calendar-cache/calendar-cache.ts @@ -1,19 +1,23 @@ import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; -import { CredentialRepository } from "@calcom/lib/server/repository/credential"; +import { getCredentialForCalendarCache } from "@calcom/lib/delegationCredential/server"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; import type { Calendar } from "@calcom/types/Calendar"; import { CalendarCacheRepository } from "./calendar-cache.repository"; import type { ICalendarCacheRepository } from "./calendar-cache.repository.interface"; import { CalendarCacheRepositoryMock } from "./calendar-cache.repository.mock"; +const log = logger.getSubLogger({ prefix: ["CalendarCache"] }); + export class CalendarCache { static async initFromCredentialId(credentialId: number): Promise { - const credential = await CredentialRepository.findCredentialForCalendarServiceById({ - id: credentialId, - }); - const calendar = await getCalendar(credential); - return await CalendarCache.init(calendar); + log.debug("initFromCredentialId", safeStringify({ credentialId })); + const credentialForCalendarCache = await getCredentialForCalendarCache({ credentialId }); + + const calendarForCalendarCache = await getCalendar(credentialForCalendarCache); + return await CalendarCache.init(calendarForCalendarCache); } static async init(calendar: Calendar | null): Promise { const featureRepo = new FeaturesRepository(); diff --git a/packages/lib/CalendarManager.test.ts b/packages/lib/CalendarManager.test.ts index 827cf0fcfb..c73121a208 100644 --- a/packages/lib/CalendarManager.test.ts +++ b/packages/lib/CalendarManager.test.ts @@ -1,6 +1,30 @@ import { describe, expect, it } from "vitest"; -import { getCalendarCredentials } from "./CalendarManager"; +import { getCalendarCredentials, deduplicateCredentialsBasedOnSelectedCalendars } from "./CalendarManager"; + +function buildCredential(data: { + type: string; + appId: string; + id: number; + delegatedToId: string | null; + user: { email: string } | null; +}) { + return { + ...data, + teamId: null, + invalid: false, + key: { access_token: "DONT_MATTER" }, + userId: 10000, + delegatedTo: { + serviceAccountKey: { + client_email: "DONT_MATTER", + tenant_id: "DONT_MATTER", + client_id: "DONT_MATTER", + private_key: "DONT_MATTER", + }, + }, + }; +} describe("CalendarManager tests", () => { describe("fn: getCalendarCredentials", () => { @@ -36,4 +60,310 @@ describe("CalendarManager tests", () => { expect(calendarCredentials[0].credential).toEqual(googleCalendarCredentials); }); }); + + describe("fn: deduplicateCredentialsBasedOnSelectedCalendars", () => { + it("should remove a regular credential for which a delegation credential exists i.e. both are fetching events for same externalId", () => { + const calcomUser = { + email: "owner@hariombalhara.net", + }; + + const externalIdSameAsCalcomUserEmail = calcomUser.email; + const externalIdDifferentFromCalcomUserEmail = "hariombalhara@gmail.com"; + + // Delegation Credential(Calendar) + const delegationCredentialCalendarForCalcomEmail = buildCredential({ + type: "google_calendar", + appId: "google-calendar", + id: -1, + delegatedToId: "45fa8f04-e891-417b-98e1-abf48e8ae18a", + user: calcomUser, + }); + + // Delegation Credential(Conferencing) + const delegationCredentialConferencingForCalcomEmail = buildCredential({ + type: "google_video", + appId: "google-meet", + id: -1, + delegatedToId: "45fa8f04-e891-417b-98e1-abf48e8ae18a", + user: calcomUser, + }); + + // Regular Credential for owner@hariombalhara.net + const regularCredentialForCalcomEmail = buildCredential({ + id: 2, + appId: "google-calendar", + type: "google_calendar", + delegatedToId: null, + user: calcomUser, + }); + + // Regular Credential for hariombalhara@gmail.com + const regularCredentialForSomeOtherEmail = buildCredential({ + id: 3, + appId: "google-calendar", + type: "google_calendar", + delegatedToId: null, + user: calcomUser, + }); + + const credentials = [ + delegationCredentialCalendarForCalcomEmail, + delegationCredentialConferencingForCalcomEmail, + // This one is duplicate + regularCredentialForCalcomEmail, + // Regular Credential for hariombalhara@gmail.com + regularCredentialForSomeOtherEmail, + ]; + + const selectedCalendars = [ + { + id: "62f4f3b7-c65b-4199-8052-25b91ee25ff2", + userId: 23, + integration: "google_calendar", + externalId: externalIdDifferentFromCalcomUserEmail, + credentialId: regularCredentialForSomeOtherEmail.id, + delegationCredentialId: null, + eventTypeId: null, + }, + { + id: "418e1bd0-7bde-4ea6-b03a-b2b6de6af497", + userId: 23, + integration: "google_calendar", + externalId: externalIdSameAsCalcomUserEmail, + credentialId: regularCredentialForCalcomEmail.id, + delegationCredentialId: null, + eventTypeId: null, + }, + ]; + + const uniqueCredentials = deduplicateCredentialsBasedOnSelectedCalendars({ + credentials, + selectedCalendars, + }); + expect(uniqueCredentials).toHaveLength(3); + expect(uniqueCredentials).toEqual([ + delegationCredentialCalendarForCalcomEmail, + delegationCredentialConferencingForCalcomEmail, + regularCredentialForSomeOtherEmail, + ]); + }); + + it("should not remove a regular credential for which a delegation credential exists i.e. both are fetching events for same externalId if integration is different", () => { + const calcomUser = { + email: "owner@hariombalhara.net", + }; + + const externalIdSameAsCalcomUserEmail = calcomUser.email; + const externalIdDifferentFromCalcomUserEmail = "hariombalhara@gmail.com"; + + // Delegation Credential(Calendar) - wrong integration type + const delegationCredentialCalendarForCalcomEmail = buildCredential({ + type: "google_calendar_wrong_type", + appId: "google-calendar", + id: -1, + delegatedToId: "45fa8f04-e891-417b-98e1-abf48e8ae18a", + user: calcomUser, + }); + + // Delegation Credential(Conferencing) + const delegationCredentialConferencingForCalcomEmail = buildCredential({ + type: "google_video", + appId: "google-meet", + id: -1, + delegatedToId: "45fa8f04-e891-417b-98e1-abf48e8ae18a", + user: calcomUser, + }); + + // Regular Credential for owner@hariombalhara.net + const regularCredentialForCalcomEmail = buildCredential({ + id: 2, + appId: "google-calendar", + type: "google_calendar", + delegatedToId: null, + user: calcomUser, + }); + + // Regular Credential for hariombalhara@gmail.com + const regularCredentialForSomeOtherEmail = buildCredential({ + id: 3, + appId: "google-calendar", + type: "google_calendar", + delegatedToId: null, + user: calcomUser, + }); + + const credentials = [ + delegationCredentialCalendarForCalcomEmail, + delegationCredentialConferencingForCalcomEmail, + // This one won't be removed as integration is different + regularCredentialForCalcomEmail, + // Regular Credential for hariombalhara@gmail.com + regularCredentialForSomeOtherEmail, + ]; + + const selectedCalendars = [ + { + id: "62f4f3b7-c65b-4199-8052-25b91ee25ff2", + userId: 23, + integration: "google_calendar", + externalId: externalIdDifferentFromCalcomUserEmail, + credentialId: regularCredentialForSomeOtherEmail.id, + delegationCredentialId: null, + eventTypeId: null, + }, + { + id: "418e1bd0-7bde-4ea6-b03a-b2b6de6af497", + userId: 23, + integration: "google_calendar", + externalId: externalIdSameAsCalcomUserEmail, + credentialId: regularCredentialForCalcomEmail.id, + delegationCredentialId: null, + eventTypeId: null, + }, + ]; + + const uniqueCredentials = deduplicateCredentialsBasedOnSelectedCalendars({ + credentials, + selectedCalendars, + }); + expect(uniqueCredentials).toHaveLength(4); + expect(uniqueCredentials).toEqual([ + delegationCredentialCalendarForCalcomEmail, + delegationCredentialConferencingForCalcomEmail, + regularCredentialForCalcomEmail, + regularCredentialForSomeOtherEmail, + ]); + }); + + it("should return empty array when credentials array is empty", () => { + const selectedCalendars = [ + { + id: "calendar-1", + userId: 23, + integration: "google_calendar", + externalId: "test@example.com", + credentialId: 1, + delegationCredentialId: null, + eventTypeId: null, + }, + ]; + + const uniqueCredentials = deduplicateCredentialsBasedOnSelectedCalendars({ + credentials: [], + selectedCalendars, + }); + expect(uniqueCredentials).toHaveLength(0); + }); + + it("should return original credentials when user email is not present", () => { + const credentialsWithoutUserEmail = [ + buildCredential({ + type: "google_calendar", + appId: "google-calendar", + id: 1, + delegatedToId: null, + user: null, + }), + buildCredential({ + type: "google_calendar", + appId: "google-calendar", + id: -1, + delegatedToId: "delegation-1", + user: null, + }), + ]; + + const selectedCalendars = [ + { + id: "calendar-1", + userId: 23, + integration: "google_calendar", + externalId: "test@example.com", + credentialId: 1, + delegationCredentialId: null, + eventTypeId: null, + }, + ]; + + const uniqueCredentials = deduplicateCredentialsBasedOnSelectedCalendars({ + credentials: credentialsWithoutUserEmail, + selectedCalendars, + }); + expect(uniqueCredentials).toEqual(credentialsWithoutUserEmail); + }); + + it("should return original credentials when no delegation credentials exist", () => { + const regularCredentials = [ + buildCredential({ + type: "google_calendar", + appId: "google-calendar", + id: 1, + delegatedToId: null, + user: { email: "test@example.com" }, + }), + buildCredential({ + type: "google_calendar", + appId: "google-calendar", + id: 2, + delegatedToId: null, + user: { email: "test@example.com" }, + }), + ]; + + const selectedCalendars = [ + { + id: "calendar-1", + userId: 23, + integration: "google_calendar", + externalId: "test@example.com", + credentialId: 1, + delegationCredentialId: null, + eventTypeId: null, + }, + ]; + + const uniqueCredentials = deduplicateCredentialsBasedOnSelectedCalendars({ + credentials: regularCredentials, + selectedCalendars, + }); + expect(uniqueCredentials).toEqual(regularCredentials); + }); + + it("should return original credentials when no matching selected calendars exist", () => { + const credentials = [ + buildCredential({ + type: "google_calendar", + appId: "google-calendar", + id: 1, + delegatedToId: "delegation-1", + user: { email: "test@example.com" }, + }), + buildCredential({ + type: "google_calendar", + appId: "google-calendar", + id: 2, + delegatedToId: null, + user: { email: "test@example.com" }, + }), + ]; + + const selectedCalendars = [ + { + id: "calendar-1", + userId: 23, + integration: "google_calendar", + externalId: "different@example.com", // Different email than the credentials + credentialId: 1, + delegationCredentialId: null, + eventTypeId: null, + }, + ]; + + const uniqueCredentials = deduplicateCredentialsBasedOnSelectedCalendars({ + credentials, + selectedCalendars, + }); + expect(uniqueCredentials).toEqual(credentials); + }); + }); }); diff --git a/packages/lib/CalendarManager.ts b/packages/lib/CalendarManager.ts index ddd988fdd9..1d2675d49c 100644 --- a/packages/lib/CalendarManager.ts +++ b/packages/lib/CalendarManager.ts @@ -54,13 +54,14 @@ export const getConnectedCalendars = async ( calendarCredentials.map(async (item) => { try { const { integration, credential } = item; + const safeToSendIntegration = cleanIntegrationKeys(integration); const calendar = await item.calendar; // Don't leak credentials to the client const credentialId = credential.id; const delegationCredentialId = credential.delegatedToId ?? null; if (!calendar) { return { - integration, + integration: safeToSendIntegration, credentialId, delegationCredentialId, }; @@ -83,7 +84,7 @@ export const getConnectedCalendars = async ( const primary = calendars.find((item) => item.primary) ?? calendars.find((cal) => cal !== undefined); if (!primary) { return { - integration, + integration: safeToSendIntegration, credentialId, error: { message: "No primary calendar found", @@ -98,7 +99,7 @@ export const getConnectedCalendars = async ( } return { - integration: cleanIntegrationKeys(integration), + integration: safeToSendIntegration, credentialId, delegationCredentialId, primary, @@ -151,10 +152,70 @@ const cleanIntegrationKeys = ( return rest; }; +/** + * This function deduplicates credentials based on selected calendars + * It removes regular credentials for which corresponding delegation credentials exist which can be identified only associating them with selected calendars + * + * This is important to prevent unnecessary/duplicate calls to calendar APIs + */ +export const deduplicateCredentialsBasedOnSelectedCalendars = ({ + credentials, + selectedCalendars, +}: { + credentials: CredentialForCalendarService[]; + selectedCalendars: SelectedCalendar[]; +}) => { + // Only proceed if we have credentials + if (credentials.length === 0) { + return credentials; + } + + // Get the user email from the first credential + const userEmail = credentials[0].user?.email; + + // If no email, we can't identify which credential is duplicate + if (!userEmail) { + return credentials; + } + + // Check if there are delegation credentials for the same integration types + const delegationCredentials = credentials.filter((credential) => credential.delegatedToId); + + // Find all selected calendars with externalId matching the user's email and having a regular credential + const selectedCalendarsWithUserEmailConnectedWithRegularCredential = selectedCalendars.filter( + (calendar) => calendar.externalId === userEmail && calendar.credentialId && calendar.credentialId > 0 + ); + + // If no delegation credentials or no regular credentials connected selected calendars, return original credentials + if ( + delegationCredentials.length === 0 || + selectedCalendarsWithUserEmailConnectedWithRegularCredential.length === 0 + ) { + return credentials; + } + + const deduplicatedCredentials = [...credentials]; + + // For each selected calendar with user email, check if a delegation credential exists for the same integration. + // If yes, we remove such a regular credential as that is a duplicate + const credentialIdsToRemove = selectedCalendarsWithUserEmailConnectedWithRegularCredential + .filter((calendar) => + delegationCredentials.some((credential) => credential.type === calendar.integration) + ) + .map((calendar) => calendar.credentialId); + + // Remove the regular credentials that are now handled by delegation credentials + return deduplicatedCredentials.filter((credential) => !credentialIdsToRemove.includes(credential.id)); +}; + export const getBusyCalendarTimes = async ( /** - * withCredentials can possibly have a duplicate credential in case DelegationCredential is enabled. - * There is no way to deduplicate that at the moment because a `credential` doesn't directly know for which email it is, + * withCredentials can possibly have duplicate credential in case DelegationCredential is enabled. + * There is no way to deduplicate that at the moment because a `credential` doesn't directly know to which external_id(or email it is connected to). + * So, there could be multiple credentials for the same user. + * 1. Delegated Credential - that fetches events for john@acme.com + * 2. Regular Credential - that fetches events for john@personal.com + * */ withCredentials: CredentialForCalendarService[], dateFrom: string, @@ -164,6 +225,21 @@ export const getBusyCalendarTimes = async ( includeTimeZone?: boolean ) => { let results: (EventBusyDate & { timeZone?: string })[][] = []; + + const deduplicatedCredentials = deduplicateCredentialsBasedOnSelectedCalendars({ + credentials: withCredentials, + selectedCalendars, + }); + + if (deduplicatedCredentials.length !== withCredentials.length) { + log.info( + "Deduplicated credentials and removed", + withCredentials.length - deduplicatedCredentials.length, + "duplicates. Total number of credentials now is", + deduplicatedCredentials.length + ); + } + // const months = getMonths(dateFrom, dateTo); try { // Subtract 11 hours from the start date to avoid problems in UTC- time zones. @@ -171,11 +247,25 @@ export const getBusyCalendarTimes = async ( // Add 14 hours from the start date to avoid problems in UTC+ time zones. const endDate = dayjs(dateTo).add(14, "hours").format(); + log.debug( + "getBusyCalendarTimes manipulated dates", + safeStringify({ + newStartDate: startDate, + newEndDate: endDate, + oldStartDate: dateFrom, + oldEndDate: dateTo, + }) + ); if (includeTimeZone) { - results = await getCalendarsEventsWithTimezones(withCredentials, startDate, endDate, selectedCalendars); + results = await getCalendarsEventsWithTimezones( + deduplicatedCredentials, + startDate, + endDate, + selectedCalendars + ); } else { results = await getCalendarsEvents( - withCredentials, + deduplicatedCredentials, startDate, endDate, selectedCalendars, diff --git a/packages/lib/EventManager.ts b/packages/lib/EventManager.ts index facf3dbf65..ac77fd7fb2 100644 --- a/packages/lib/EventManager.ts +++ b/packages/lib/EventManager.ts @@ -36,6 +36,7 @@ import type { import { createEvent, updateEvent, deleteEvent } from "./CalendarManager"; import CrmManager from "./crmManager/crmManager"; +import { isDelegationCredential } from "./delegationCredential/clientAndServer"; import { createMeeting, updateMeeting, deleteMeeting } from "./videoClient"; const log = logger.getSubLogger({ prefix: ["EventManager"] }); @@ -153,10 +154,9 @@ export default class EventManager { // see https://github.com/calcom/cal.com/issues/11671#issue-1923600672 // This sorting is mostly applicable for fallback which happens when there is no explicity destinationCalendar set. That could be true for really old accounts but not for new .sort(latestCredentialFirst) - // TODO: Change it to delegatedCredentialFirst in a followup PR. - // We are keeping delegated credentials at the end so that there is no impact on existing users connections as we still use their existing credentials - // Soon after DelegationCredential is released and stable, we switch it. Could be an env variable also to toggle this. - .sort(delegatedCredentialLast); + // Keep Delegation Credentials first so because those credentials never expire and are preferred. + // Also, those credentials have consistent permission for all the members avoiding the scenario where user doesn't give all permissions + .sort(delegatedCredentialFirst); this.videoCredentials = appCredentials .filter((cred) => cred.type.endsWith("_video") || cred.type.endsWith("_conferencing")) @@ -200,14 +200,23 @@ export default class EventManager { log.warn("Falling back to cal video as no location is set"); } - // Fallback to Cal Video if Google Meet is selected w/o a Google Cal - // @NOTE: destinationCalendar it's an array now so as a fallback we will only check the first one const [mainHostDestinationCalendar] = (evt.destinationCalendar as [undefined | NonNullable[number]]) ?? []; + + // Fallback to Cal Video if Google Meet is selected w/o a Google Calendar connection if (evt.location === MeetLocationType && mainHostDestinationCalendar?.integration !== "google_calendar") { - log.warn("Falling back to Cal Video integration as Google Calendar is not set as destination calendar"); - evt["location"] = "integrations:daily"; - evt["conferenceCredentialId"] = undefined; + const [googleCalendarCredential] = this.calendarCredentials.filter( + (cred) => cred.type === "google_calendar" + ); + // Delegation Credential case won't normally have DestinationCalendar set and thus fallback of using Google Calendar credential would be used. Identify that case. + // TODO: We could extend this logic to Regular Credentials also. Having a Google Calendar credential would cause fallback to use that credential to create calendar and thus we could have Google Meet link + if (!isDelegationCredential({ credentialId: googleCalendarCredential?.id })) { + log.warn( + "Falling back to Cal Video integration for Regular Credential as Google Calendar is not set as destination calendar" + ); + evt["location"] = "integrations:daily"; + evt["conferenceCredentialId"] = undefined; + } } const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null; @@ -673,12 +682,13 @@ export default class EventManager { let createdEvents: EventResult[] = []; const fallbackToFirstCalendarInTheList = async () => { - /** - * Not ideal but, if we don't find a destination calendar, - * fallback to the first connected calendar - Shouldn't be a CRM calendar - */ const [credential] = this.calendarCredentials.filter((cred) => !cred.type.endsWith("other_calendar")); if (credential) { + if (!isDelegationCredential({ credentialId: credential.id })) { + log.warn("Check the User setup, it isn't normal to fallback for regular credential."); + } else { + // It is completely normal to fallback for delegation credential as in that case by default no destination calendar would be set. + } const createdEvent = await createEvent(credential, event); log.silly("Created Calendar event using credential", safeStringify({ credential, createdEvent })); if (createdEvent) { @@ -737,6 +747,7 @@ export default class EventManager { user: credentialFromDB.user, delegatedToId: credentialFromDB.delegatedToId, delegatedTo: credentialFromDB.delegatedTo, + delegationCredentialId: credentialFromDB.delegationCredentialId, }; } } else if (destination.delegationCredentialId) { @@ -937,6 +948,7 @@ export default class EventManager { user: credentialFromDB.user, delegatedToId: credentialFromDB.delegatedToId, delegatedTo: credentialFromDB.delegatedTo, + delegationCredentialId: credentialFromDB.delegationCredentialId, }; } } diff --git a/packages/lib/__mocks__/logger.ts b/packages/lib/__mocks__/logger.ts index 976bcafbe4..b9319dc442 100644 --- a/packages/lib/__mocks__/logger.ts +++ b/packages/lib/__mocks__/logger.ts @@ -1,18 +1,18 @@ import { vi } from "vitest"; const mockLogger = { + getSubLogger: () => ({ + debug: vi.fn(), + error: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }), debug: vi.fn(), error: vi.fn(), info: vi.fn(), log: vi.fn(), warn: vi.fn(), - getSubLogger: vi.fn(() => ({ - debug: vi.fn(), - error: vi.fn(), - info: vi.fn(), - log: vi.fn(), - warn: vi.fn(), - })), }; vi.mock("@calcom/lib/logger", () => ({ diff --git a/packages/lib/crmManager/tasker/crmScheduler.ts b/packages/lib/crmManager/tasker/crmScheduler.ts index 7bf0ed80d3..1b3c4d0f17 100644 --- a/packages/lib/crmManager/tasker/crmScheduler.ts +++ b/packages/lib/crmManager/tasker/crmScheduler.ts @@ -6,7 +6,7 @@ class CRMScheduler { return tasker.create( "createCRMEvent", { bookingUid }, - { maxAttempts: tasksConfig.createCRMEvent.maxAttempts ?? 5 } + { maxAttempts: tasksConfig.createCRMEvent?.maxAttempts ?? 5 } ); } } diff --git a/packages/lib/delegationCredential/clientAndServer.ts b/packages/lib/delegationCredential/clientAndServer.ts index 261eefe1e1..c82c457acd 100644 --- a/packages/lib/delegationCredential/clientAndServer.ts +++ b/packages/lib/delegationCredential/clientAndServer.ts @@ -1,26 +1,51 @@ -export function isDelegationCredential({ credentialId }: { credentialId: number | null | undefined }) { +export function isInMemoryDelegationCredential({ + credentialId, +}: { + credentialId: number | null | undefined; +}) { // Though it is set as -1 right now, but we might want to set it to some other negative value. return typeof credentialId === "number" && credentialId < 0; } +// Backward compatibility with platform code till it is changed +export const isDelegationCredential = isInMemoryDelegationCredential; + +const isInDbDelegationCredential = (credential: { + id?: number | null; + delegationCredentialId?: number | null; +}) => { + if (!credential.id) return false; + return ( + !!credential.delegationCredentialId && !isInMemoryDelegationCredential({ credentialId: credential.id }) + ); +}; + export const buildNonDelegationCredential = | null>(credential: T) => { type WithDelegationCredential = T extends null ? null : T & { delegatedTo: null; delegatedToId: null; + delegationCredentialId: null; }; - if (!credential) return null as WithDelegationCredential; + // We are building a non-delegation credential and thus a Delegation Credential mustn't be passed from here + // In-Db Delegation User Credentials might right here. + if (!credential || isInDbDelegationCredential(credential)) return null as WithDelegationCredential; return { ...credential, delegatedTo: null, delegatedToId: null, + delegationCredentialId: null, } as WithDelegationCredential; }; export const buildNonDelegationCredentials = >(credentials: T[]) => { - return credentials.map(buildNonDelegationCredential).filter((credential) => !!credential) as (T & { + // There could be User Delegation Credentials in DB as well that would reach here, because they have credential.id > 0, we would absolutely not want to use them here because there would already be in-memory delegation credential that would be used instead. + const nonDelegationCredentials = credentials.filter( + (credential) => !isInDbDelegationCredential(credential) + ); + return nonDelegationCredentials.map(buildNonDelegationCredential) as (T & { delegatedTo: null; delegatedToId: null; })[]; diff --git a/packages/lib/delegationCredential/server.test.ts b/packages/lib/delegationCredential/server.test.ts index efaa1e4562..199dd2492e 100644 --- a/packages/lib/delegationCredential/server.test.ts +++ b/packages/lib/delegationCredential/server.test.ts @@ -117,6 +117,8 @@ const buildDelegationCredential = (overrides = {}) => ({ invalid: false, teamId: null, team: null, + // In-memory delegation credential has it set to null + delegationCredentialId: mockDelegationCredential.id, delegatedTo: { serviceAccountKey: mockServiceAccountKey, }, @@ -150,6 +152,8 @@ const buildRegularCredential = (overrides = {}): CredentialForCalendarService => teamId: null, delegatedToId: null, delegatedTo: null, + // Regular credentials have it set to null always + delegationCredentialId: null, ...overrides, }); diff --git a/packages/lib/delegationCredential/server.ts b/packages/lib/delegationCredential/server.ts index c4762afa1e..ae527d9848 100644 --- a/packages/lib/delegationCredential/server.ts +++ b/packages/lib/delegationCredential/server.ts @@ -10,7 +10,12 @@ import type { ServiceAccountKey } from "@calcom/lib/server/repository/delegation import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential"; import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential"; -import { buildNonDelegationCredentials, isDelegationCredential } from "./clientAndServer"; +import { UserRepository } from "../server/repository/user"; +import { + buildNonDelegationCredential, + buildNonDelegationCredentials, + isDelegationCredential, +} from "./clientAndServer"; export { buildNonDelegationCredentials, buildNonDelegationCredential } from "./clientAndServer"; @@ -89,6 +94,7 @@ const _buildCommonUserCredential = ({ invalid: false, teamId: null, team: null, + delegationCredentialId: delegationCredential.id, delegatedTo: delegationCredential.serviceAccountKey ? { serviceAccountKey: delegationCredential.serviceAccountKey, @@ -104,7 +110,18 @@ const _buildDelegatedCalendarCredential = ({ delegationCredential: DelegationCredential; user: User; }) => { - log.debug("buildDelegationCredential", safeStringify({ delegationCredential, user })); + log.debug( + "buildDelegationCredential", + safeStringify({ + delegationCredential: { + id: delegationCredential.id, + }, + user: { + id: user.id, + email: user.email, + }, + }) + ); // TODO: Build for other platforms as well if (!isValidWorkspaceSlug(delegationCredential.workspacePlatform.slug)) { log.warn( @@ -505,3 +522,69 @@ export function getFirstDelegationConferencingCredentialAppLocation({ } return null; } + +export async function findUniqueDelegationCalendarCredential({ + userId, + delegationCredentialId, +}: { + userId: number; + delegationCredentialId: string; +}) { + const [delegationCredential, user] = await Promise.all([ + DelegationCredentialRepository.findByIdIncludeSensitiveServiceAccountKey({ id: delegationCredentialId }), + UserRepository.findById({ id: userId }), + ]); + + if (!delegationCredential) { + throw new Error("Delegation Credential not found"); + } + + if (!delegationCredential.enabled) { + return null; + } + + if (!user) { + throw new Error("User not found"); + } + + const dwdCredential = _buildDelegatedCalendarCredential({ + delegationCredential, + user, + }); + return dwdCredential; +} + +/** + * CredentialForCalendarCache is different from CredentialForCalendarService in the sense that CredentialForCalendarCache.id is greater than 0 and CredentialForCalendarService.id is -1 + * Thus it is a Credential from DB and and also a Delegation User Credential(when CredentialForCalendarCache.delegatedTo is not null) + */ +export async function getCredentialForCalendarCache({ credentialId }: { credentialId: number }) { + const credential = await CredentialRepository.findByIdIncludeDelegationCredential({ + id: credentialId, + }); + + let credentialForCalendarService; + + if (credential?.delegationCredential) { + if (!credential.userId) { + throw new Error(`Credential ${credentialId} doesn't have a user`); + } + const delegationCredential = await findUniqueDelegationCalendarCredential({ + userId: credential.userId, + delegationCredentialId: credential.delegationCredential.id, + }); + + if (!delegationCredential) { + credentialForCalendarService = null; + } else { + // We preparare a credential that is in-db(in constrast with an in-memory credential used elsewhere where we generate CredentialForCalendarService) + credentialForCalendarService = { + ...delegationCredential, + id: credential.id, + }; + } + } else { + credentialForCalendarService = buildNonDelegationCredential(credential); + } + return credentialForCalendarService; +} diff --git a/packages/lib/getCalendarsEvents.test.ts b/packages/lib/getCalendarsEvents.test.ts index 0f14d300b1..189643b7ce 100644 --- a/packages/lib/getCalendarsEvents.test.ts +++ b/packages/lib/getCalendarsEvents.test.ts @@ -5,9 +5,53 @@ import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/Calendar import OfficeCalendarService from "@calcom/app-store/office365calendar/lib/CalendarService"; import logger from "@calcom/lib/logger"; import type { EventBusyDate } from "@calcom/types/Calendar"; -import type { CredentialPayload } from "@calcom/types/Credential"; +import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential"; -import getCalendarsEvents from "./getCalendarsEvents"; +import getCalendarsEvents, { getCalendarsEventsWithTimezones } from "./getCalendarsEvents"; + +function buildDelegationCredential(credential: CredentialPayload): CredentialForCalendarService { + return { + ...credential, + id: -1, + delegatedTo: { + serviceAccountKey: { + client_email: "client_email", + tenant_id: "tenant_id", + client_id: "client_id", + private_key: "private_key", + }, + }, + }; +} + +function buildRegularCredential(credential: CredentialPayload): CredentialForCalendarService { + return { + ...credential, + delegatedTo: null, + delegatedToId: null, + }; +} + +function buildSelectedCalendar(credential: { + credentialId: number; + externalId: string; + integration: string; + userId: number; + id: string; +}): SelectedCalendar { + return { + googleChannelId: null, + googleChannelKind: null, + googleChannelResourceId: null, + eventTypeId: null, + googleChannelResourceUri: null, + googleChannelExpiration: null, + delegationCredentialId: null, + domainWideDelegationCredentialId: null, + error: null, + ...credential, + }; +} describe("getCalendarsEvents", () => { let credential: CredentialPayload; @@ -28,9 +72,6 @@ describe("getCalendarsEvents", () => { userId: 808, teamId: null, appId: "exampleApp", - subscriptionId: null, - paymentStatus: null, - billingCycleStart: null, invalid: false, }; }); @@ -39,183 +80,394 @@ describe("getCalendarsEvents", () => { vi.restoreAllMocks(); }); - it("should return empty array if no calendar credentials", async () => { - const result = await getCalendarsEvents( - [ - { - ...credential, - type: "totally_unrelated", - }, - ], - "2010-12-01", - "2010-12-02", - [] - ); + describe("Regular Credentials", () => { + it("should return empty array if no calendar credentials", async () => { + const result = await getCalendarsEvents( + [ + buildRegularCredential({ + ...credential, + type: "totally_unrelated", + }), + ], + "2010-12-01", + "2010-12-02", + [] + ); - expect(result).toEqual([]); + expect(result).toEqual([]); + }); + + it("should return unknown calendars as empty", async () => { + const result = await getCalendarsEvents( + [ + buildRegularCredential({ + ...credential, + type: "unknown_calendar", + }), + ], + "2010-12-01", + "2010-12-02", + [] + ); + + expect(result).toEqual([[]]); + }); + + it("should return unmatched calendars as empty", async () => { + const selectedCalendar: SelectedCalendar = buildSelectedCalendar({ + credentialId: 100, + externalId: "externalId", + integration: "office365_calendar", + userId: 200, + id: "id", + }); + const result = await getCalendarsEvents( + [ + buildRegularCredential({ + ...credential, + type: "google_calendar", + }), + ], + "2010-12-01", + "2010-12-02", + [selectedCalendar] + ); + + expect(result).toEqual([[]]); + }); + + it("should return availability from selected calendar", async () => { + const availability: EventBusyDate[] = [ + { + start: new Date(2010, 11, 2), + end: new Date(2010, 11, 3), + }, + { + start: new Date(2010, 11, 2, 4), + end: new Date(2010, 11, 2, 16), + }, + ]; + + const getAvailabilitySpy = vi + .spyOn(GoogleCalendarService.prototype, "getAvailability") + .mockReturnValue(Promise.resolve(availability)); + + const selectedCalendar: SelectedCalendar = buildSelectedCalendar({ + credentialId: 100, + externalId: "externalId", + integration: "google_calendar", + userId: 200, + id: "id", + }); + const result = await getCalendarsEvents( + [ + buildRegularCredential({ + ...credential, + type: "google_calendar", + }), + ], + "2010-12-01", + "2010-12-04", + [selectedCalendar] + ); + + expect(getAvailabilitySpy).toHaveBeenCalledWith( + "2010-12-01", + "2010-12-04", + [selectedCalendar], + undefined, + false + ); + expect(result).toEqual([ + availability.map((av) => ({ + ...av, + source: "exampleApp", + })), + ]); + }); + + it("should return availability from multiple calendars", async () => { + const googleAvailability: EventBusyDate[] = [ + { + start: new Date(2010, 11, 2), + end: new Date(2010, 11, 3), + }, + ]; + const officeAvailability: EventBusyDate[] = [ + { + start: new Date(2010, 11, 2, 4), + end: new Date(2010, 11, 2, 16), + }, + ]; + + const getGoogleAvailabilitySpy = vi + .spyOn(GoogleCalendarService.prototype, "getAvailability") + .mockReturnValue(Promise.resolve(googleAvailability)); + const getOfficeAvailabilitySpy = vi + .spyOn(OfficeCalendarService.prototype, "getAvailability") + .mockReturnValue(Promise.resolve(officeAvailability)); + + const selectedGoogleCalendar: SelectedCalendar = buildSelectedCalendar({ + credentialId: 100, + externalId: "externalId", + integration: "google_calendar", + userId: 200, + id: "id", + }); + const selectedOfficeCalendar: SelectedCalendar = buildSelectedCalendar({ + credentialId: 100, + externalId: "externalId", + integration: "office365_calendar", + userId: 200, + id: "id", + }); + const result = await getCalendarsEvents( + [ + buildRegularCredential({ + ...credential, + type: "google_calendar", + }), + buildRegularCredential({ + ...credential, + type: "office365_calendar", + key: { + access_token: "access", + refresh_token: "refresh", + expires_in: Date.now() + 86400, + }, + }), + ], + "2010-12-01", + "2010-12-04", + [selectedGoogleCalendar, selectedOfficeCalendar] + ); + + expect(getGoogleAvailabilitySpy).toHaveBeenCalledWith( + "2010-12-01", + "2010-12-04", + [selectedGoogleCalendar], + undefined, + false + ); + expect(getOfficeAvailabilitySpy).toHaveBeenCalledWith( + "2010-12-01", + "2010-12-04", + [selectedOfficeCalendar], + undefined, + false + ); + expect(result).toEqual([ + googleAvailability.map((av) => ({ + ...av, + source: "exampleApp", + })), + officeAvailability.map((av) => ({ + ...av, + source: "exampleApp", + })), + ]); + }); + + it("should not call getAvailability if selectedCalendars is empty", async () => { + const getAvailabilitySpy = vi + .spyOn(GoogleCalendarService.prototype, "getAvailability") + .mockReturnValue(Promise.resolve([])); + + const result = await getCalendarsEvents( + [buildRegularCredential(credential)], + "2010-12-01", + "2010-12-02", + [] + ); + + expect(getAvailabilitySpy).not.toHaveBeenCalled(); + expect(result).toEqual([[]]); + }); }); - it("should return unknown calendars as empty", async () => { - const result = await getCalendarsEvents( - [ - { - ...credential, - type: "unknown_calendar", - }, - ], - "2010-12-01", - "2010-12-02", - [] - ); + describe("Delegation Credentials", () => { + it("should allow getAvailability call even without any selected calendars with allowFallbackToPrimary=true", async () => { + const startDate = "2010-12-01"; + const endDate = "2010-12-02"; + const delegationCredential: CredentialForCalendarService = buildDelegationCredential(credential); + const credentials = [delegationCredential]; + const getAvailabilitySpy = vi + .spyOn(GoogleCalendarService.prototype, "getAvailability") + .mockReturnValue(Promise.resolve([])); - expect(result).toEqual([[]]); - }); + const result = await getCalendarsEvents(credentials, startDate, endDate, []); - it("should return unmatched calendars as empty", async () => { - const selectedCalendar: SelectedCalendar = { - credentialId: 100, - externalId: "externalId", - integration: "office365_calendar", - userId: 200, - }; - const result = await getCalendarsEvents( - [ - { - ...credential, - type: "google_calendar", - }, - ], - "2010-12-01", - "2010-12-02", - [selectedCalendar] - ); - - expect(result).toEqual([[]]); - }); - - it("should return availability from selected calendar", async () => { - const availability: EventBusyDate[] = [ - { - start: new Date(2010, 11, 2), - end: new Date(2010, 11, 3), - }, - { - start: new Date(2010, 11, 2, 4), - end: new Date(2010, 11, 2, 16), - }, - ]; - - const getAvailabilitySpy = vi - .spyOn(GoogleCalendarService.prototype, "getAvailability") - .mockReturnValue(Promise.resolve(availability)); - - const selectedCalendar: SelectedCalendar = { - credentialId: 100, - externalId: "externalId", - integration: "google_calendar", - userId: 200, - }; - const result = await getCalendarsEvents( - [ - { - ...credential, - type: "google_calendar", - }, - ], - "2010-12-01", - "2010-12-04", - [selectedCalendar] - ); - - expect(getAvailabilitySpy).toHaveBeenCalledWith( - "2010-12-01", - "2010-12-04", - [selectedCalendar], - undefined - ); - expect(result).toEqual([ - availability.map((av) => ({ - ...av, - source: "exampleApp", - })), - ]); - }); - - it("should return availability from multiple calendars", async () => { - const googleAvailability: EventBusyDate[] = [ - { - start: new Date(2010, 11, 2), - end: new Date(2010, 11, 3), - }, - ]; - const officeAvailability: EventBusyDate[] = [ - { - start: new Date(2010, 11, 2, 4), - end: new Date(2010, 11, 2, 16), - }, - ]; - - const getGoogleAvailabilitySpy = vi - .spyOn(GoogleCalendarService.prototype, "getAvailability") - .mockReturnValue(Promise.resolve(googleAvailability)); - const getOfficeAvailabilitySpy = vi - .spyOn(OfficeCalendarService.prototype, "getAvailability") - .mockReturnValue(Promise.resolve(officeAvailability)); - - const selectedGoogleCalendar: SelectedCalendar = { - credentialId: 100, - externalId: "externalId", - integration: "google_calendar", - userId: 200, - }; - const selectedOfficeCalendar: SelectedCalendar = { - credentialId: 100, - externalId: "externalId", - integration: "office365_calendar", - userId: 200, - }; - const result = await getCalendarsEvents( - [ - { - ...credential, - type: "google_calendar", - }, - { - ...credential, - type: "office365_calendar", - key: { - access_token: "access", - refresh_token: "refresh", - expires_in: Date.now() + 86400, - }, - }, - ], - "2010-12-01", - "2010-12-04", - [selectedGoogleCalendar, selectedOfficeCalendar] - ); - - expect(getGoogleAvailabilitySpy).toHaveBeenCalledWith( - "2010-12-01", - "2010-12-04", - [selectedGoogleCalendar], - undefined - ); - expect(getOfficeAvailabilitySpy).toHaveBeenCalledWith( - "2010-12-01", - "2010-12-04", - [selectedOfficeCalendar], - undefined - ); - expect(result).toEqual([ - googleAvailability.map((av) => ({ - ...av, - source: "exampleApp", - })), - officeAvailability.map((av) => ({ - ...av, - source: "exampleApp", - })), - ]); + expect(getAvailabilitySpy).toHaveBeenCalledWith(startDate, endDate, [], undefined, true); + expect(result).toEqual([[]]); + }); + }); +}); + +describe("getCalendarsEventsWithTimezones", () => { + let credential: CredentialPayload; + + beforeEach(() => { + vi.spyOn(logger.constructor.prototype, "debug"); + + credential = { + id: 303, + type: "google_calendar", + key: { + scope: "example scope", + token_type: "Bearer", + expiry_date: Date.now() + 84000, + access_token: "access token", + refresh_token: "refresh token", + }, + userId: 808, + teamId: null, + user: null, + appId: "exampleApp", + invalid: false, + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("Regular Credentials", () => { + it("should return empty array if no calendar credentials", async () => { + const result = await getCalendarsEventsWithTimezones( + [ + buildRegularCredential({ + ...credential, + type: "totally_unrelated", + }), + ], + "2010-12-01", + "2010-12-02", + [] + ); + + expect(result).toEqual([]); + }); + + it("should return unknown calendars as empty", async () => { + const result = await getCalendarsEventsWithTimezones( + [ + buildRegularCredential({ + ...credential, + type: "unknown_calendar", + }), + ], + "2010-12-01", + "2010-12-02", + [] + ); + + expect(result).toEqual([]); + }); + + it("should return unmatched calendars as empty", async () => { + const selectedCalendar: SelectedCalendar = buildSelectedCalendar({ + credentialId: 100, + externalId: "externalId", + integration: "office365_calendar", + userId: 200, + id: "id", + }); + const result = await getCalendarsEventsWithTimezones( + [ + buildRegularCredential({ + ...credential, + type: "google_calendar", + }), + ], + "2010-12-01", + "2010-12-02", + [selectedCalendar] + ); + + expect(result).toEqual([[]]); + }); + + it("should return availability from selected calendar", async () => { + const availability = [ + { + start: new Date(2010, 11, 2), + end: new Date(2010, 11, 3), + timeZone: "America/New_York", + }, + { + start: new Date(2010, 11, 2, 4), + end: new Date(2010, 11, 2, 16), + timeZone: "America/New_York", + }, + ]; + + const getAvailabilityWithTimezonesSpy = vi + .spyOn(GoogleCalendarService.prototype, "getAvailabilityWithTimeZones") + .mockReturnValue(Promise.resolve(availability)); + + const selectedCalendar: SelectedCalendar = buildSelectedCalendar({ + credentialId: 100, + externalId: "externalId", + integration: "google_calendar", + userId: 200, + id: "id", + }); + const result = await getCalendarsEventsWithTimezones( + [ + buildRegularCredential({ + ...credential, + type: "google_calendar", + }), + ], + "2010-12-01", + "2010-12-04", + [selectedCalendar] + ); + + expect(getAvailabilityWithTimezonesSpy).toHaveBeenCalledWith( + "2010-12-01", + "2010-12-04", + [selectedCalendar], + false + ); + expect(result).toEqual([ + availability.map((av) => ({ + ...av, + })), + ]); + }); + + it("should not call getAvailabilityWithTimezones if selectedCalendars is empty", async () => { + const getAvailabilityWithTimezonesSpy = vi + .spyOn(GoogleCalendarService.prototype, "getAvailabilityWithTimeZones") + .mockReturnValue(Promise.resolve([])); + + const result = await getCalendarsEventsWithTimezones( + [buildRegularCredential(credential)], + "2010-12-01", + "2010-12-02", + [] + ); + + expect(getAvailabilityWithTimezonesSpy).not.toHaveBeenCalled(); + expect(result).toEqual([[]]); + }); + }); + + describe("Delegation Credentials", () => { + it("should allow getAvailabilityWithTimezones call even without any selected calendars with allowFallbackToPrimary=true", async () => { + const startDate = "2010-12-01"; + const endDate = "2010-12-02"; + const delegationCredential: CredentialForCalendarService = buildDelegationCredential(credential); + const credentials = [delegationCredential]; + const getAvailabilityWithTimezonesSpy = vi + .spyOn(GoogleCalendarService.prototype, "getAvailabilityWithTimeZones") + .mockReturnValue(Promise.resolve([])); + + const result = await getCalendarsEventsWithTimezones(credentials, startDate, endDate, []); + + expect(getAvailabilityWithTimezonesSpy).toHaveBeenCalledWith(startDate, endDate, [], true); + expect(result).toEqual([[]]); + }); }); }); diff --git a/packages/lib/getCalendarsEvents.ts b/packages/lib/getCalendarsEvents.ts index c3320e4a61..4b058f1acc 100644 --- a/packages/lib/getCalendarsEvents.ts +++ b/packages/lib/getCalendarsEvents.ts @@ -1,6 +1,7 @@ import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; +import { isDelegationCredential } from "@calcom/lib/delegationCredential/clientAndServer"; import logger from "@calcom/lib/logger"; -import { getPiiFreeCredential, getPiiFreeSelectedCalendar } from "@calcom/lib/piiFreeData"; +import { getPiiFreeSelectedCalendar, getPiiFreeCredential } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; import { performance } from "@calcom/lib/server/perfObserver"; import type { EventBusyDate, SelectedCalendar } from "@calcom/types/Calendar"; @@ -19,7 +20,15 @@ export const getCalendarsEventsWithTimezones = async ( // filter out invalid credentials - these won't work. .filter((credential) => !credential.invalid); - const calendars = await Promise.all(calendarCredentials.map((credential) => getCalendar(credential))); + const calendarAndCredentialPairs = await Promise.all( + calendarCredentials.map(async (credential) => { + const calendar = await getCalendar(credential); + return [calendar, credential] as const; + }) + ); + + const calendars = calendarAndCredentialPairs.map(([calendar]) => calendar); + const calendarToCredentialMap = new Map(calendarAndCredentialPairs); const results = calendars.map(async (c, i) => { /** Filter out nulls */ @@ -33,10 +42,31 @@ export const getCalendarsEventsWithTimezones = async ( .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 []; + + const credential = calendarToCredentialMap.get(c); + const isADelegationCredential = credential && isDelegationCredential({ credentialId: credential.id }); + // We want to fallback to primary calendar when no selectedCalendars are passed + // Default behaviour for Google Calendar is to use all available calendars, which isn't good default. + const allowFallbackToPrimary = isADelegationCredential; + if (!passedSelectedCalendars.length) { + if (!isADelegationCredential) { + // It was done to fix the secondary calendar connections from always checking the conflicts even if intentional no calendars are selected. + // https://github.com/calcom/cal.com/issues/8929 + log.error("No selected calendars for non DWD credential: Skipping getAvailability call"); + return []; + } + // For delegation credential, we should allow getAvailability even without any selected calendars. It ensures that enabling Delegation Credential at Organization level always ensure one selected calendar for conflicts checking, without requiring any manual action from organization members + // This is also, similar to how Google Calendar connect flow(through /googlecalendar/api/callback) sets the primary calendar as the selected calendar automatically. + log.info("Allowing getAvailability even without any selected calendars for Delegation Credential"); + } /** We extract external Ids so we don't cache too much */ const eventBusyDates = - (await c.getAvailabilityWithTimeZones?.(dateFrom, dateTo, passedSelectedCalendars)) || []; + (await c.getAvailabilityWithTimeZones?.( + dateFrom, + dateTo, + passedSelectedCalendars, + allowFallbackToPrimary + )) || []; return eventBusyDates; }); @@ -56,11 +86,19 @@ const getCalendarsEvents = async ( // filter out invalid credentials - these won't work. .filter((credential) => !credential.invalid); - const calendars = await Promise.all(calendarCredentials.map((credential) => getCalendar(credential))); + const calendarAndCredentialPairs = await Promise.all( + calendarCredentials.map(async (credential) => { + const calendar = await getCalendar(credential); + return [calendar, credential] as const; + }) + ); + + const calendars = calendarAndCredentialPairs.map(([calendar]) => calendar); + const calendarToCredentialMap = new Map(calendarAndCredentialPairs); performance.mark("getBusyCalendarTimesStart"); - const results = calendars.map(async (c, i) => { + const results = calendars.map(async (calendarService, i) => { /** Filter out nulls */ - if (!c) return []; + if (!calendarService) return []; /** We rely on the index so we can match credentials with calendars */ const { type, appId } = calendarCredentials[i]; /** We just pass the calendars that matched the credential type, @@ -71,8 +109,22 @@ const getCalendarsEvents = async ( .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 []; + const credential = calendarToCredentialMap.get(calendarService); + const isADelegationCredential = credential && isDelegationCredential({ credentialId: credential.id }); + // We want to fallback to primary calendar when no selectedCalendars are passed + // Default behaviour for Google Calendar is to use all available calendars, which isn't good default. + const allowFallbackToPrimary = isADelegationCredential; + if (!passedSelectedCalendars.length) { + if (!isADelegationCredential) { + // It was done to fix the secondary calendar connections from always checking the conflicts even if intentional no calendars are selected. + // https://github.com/calcom/cal.com/issues/8929 + log.error("No selected calendars for non DWD credential: Skipping getAvailability call"); + return []; + } + // For delegation credential, we should allow getAvailability even without any selected calendars. It ensures that enabling Delegation Credential at Organization level always ensure one selected calendar for conflicts checking, without requiring any manual action from organization members + // This is also, similar to how Google Calendar connect flow(through /googlecalendar/api/callback) sets the primary calendar as the selected calendar automatically. + log.info("Allowing getAvailability even without any selected calendars for Delegation Credential"); + } /** We extract external Ids so we don't cache too much */ const selectedCalendarIds = passedSelectedCalendars.map((sc) => sc.externalId); @@ -81,15 +133,16 @@ const getCalendarsEvents = async ( log.debug( `Getting availability for`, safeStringify({ - calendarService: c.constructor.name, + calendarService: calendarService.constructor.name, selectedCalendars: passedSelectedCalendars.map(getPiiFreeSelectedCalendar), }) ); - const eventBusyDates = await c.getAvailability( + const eventBusyDates = await calendarService.getAvailability( dateFrom, dateTo, passedSelectedCalendars, - shouldServeCache + shouldServeCache, + allowFallbackToPrimary ); performance.mark("eventBusyDatesEnd"); performance.measure( diff --git a/packages/lib/server/buildCredentialPayloadForCalendar.ts b/packages/lib/server/buildCredentialPayloadForCalendar.ts index 8221b778ac..86cb26b898 100644 --- a/packages/lib/server/buildCredentialPayloadForCalendar.ts +++ b/packages/lib/server/buildCredentialPayloadForCalendar.ts @@ -1,5 +1,3 @@ -import { isDelegationCredential } from "@calcom/lib/delegationCredential/clientAndServer"; - export function buildCredentialPayloadForPrisma({ credentialId, delegationCredentialId, @@ -7,22 +5,17 @@ export function buildCredentialPayloadForPrisma({ credentialId: number | null | undefined; delegationCredentialId: string | null | undefined; }) { - if (credentialId === undefined) { - return { - credentialId, - delegationCredentialId, - }; - } - - if (isDelegationCredential({ credentialId })) { + if (credentialId && credentialId < 0) { + // Avoid crashing the query by not passing negative credentialId return { credentialId: null, delegationCredentialId, }; - } else { - return { - credentialId, - delegationCredentialId: null, - }; } + // It is possible for both credentialId and delegationCredentialId to be present on a SelectedCalendar. So, we allow both to be + // This is because a Delegation User Credential can also exist in DB(as created through credentials/cron.ts) and thus could have credentialId as well. + return { + credentialId, + delegationCredentialId, + }; } diff --git a/packages/lib/server/getLuckyUser.integration-test.ts b/packages/lib/server/getLuckyUser.integration-test.ts index 92c5fc0b0f..0076e510c3 100644 --- a/packages/lib/server/getLuckyUser.integration-test.ts +++ b/packages/lib/server/getLuckyUser.integration-test.ts @@ -115,6 +115,7 @@ const createUserWithBookings = async ({ }, include: { bookings: true, + credentials: true, }, }); userIds.push(user.id); @@ -154,6 +155,7 @@ const createHostWithBookings = async ({ user: { include: { bookings: true, + credentials: true, }, }, }, diff --git a/packages/lib/server/repository/credential.ts b/packages/lib/server/repository/credential.ts index ed865f91f8..3ffafedff0 100644 --- a/packages/lib/server/repository/credential.ts +++ b/packages/lib/server/repository/credential.ts @@ -9,6 +9,7 @@ type CredentialCreateInput = { key: any; userId: number; appId: string; + delegationCredentialId?: string | null; }; export class CredentialRepository { @@ -63,6 +64,14 @@ export class CredentialRepository { await prisma.credential.delete({ where: { id } }); } + static async deleteAllByDelegationCredentialId({ + delegationCredentialId, + }: { + delegationCredentialId: string; + }) { + return prisma.credential.deleteMany({ where: { delegationCredentialId } }); + } + static async findCredentialForCalendarServiceById({ id }: { id: number }) { const dbCredential = await prisma.credential.findUnique({ where: { id }, @@ -75,4 +84,62 @@ export class CredentialRepository { return buildNonDelegationCredential(dbCredential); } + + static async findByIdIncludeDelegationCredential({ id }: { id: number }) { + const dbCredential = await prisma.credential.findUnique({ + where: { id }, + select: { ...credentialForCalendarServiceSelect, delegationCredential: true }, + }); + + return dbCredential; + } + + static async findAllDelegationByUserIdsListAndDelegationCredentialIdAndType({ + userIds, + delegationCredentialId, + type, + }: { + userIds: number[]; + delegationCredentialId: string; + type: string; + }) { + return prisma.credential.findMany({ + where: { + userId: { + in: userIds, + }, + delegationCredentialId, + type, + }, + select: { + userId: true, + }, + }); + } + + static async findAllDelegationByTypeIncludeUserAndTake({ type, take }: { type: string; take: number }) { + const delegationUserCredentials = await prisma.credential.findMany({ + where: { + delegationCredentialId: { not: null }, + type, + }, + include: { + user: { + select: { + email: true, + id: true, + }, + }, + }, + take, + }); + return delegationUserCredentials.map(({ delegationCredentialId, ...rest }) => { + return { + ...rest, + // We queried only those where delegationCredentialId is not null + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + delegationCredentialId: delegationCredentialId!, + }; + }); + } } diff --git a/packages/lib/server/repository/delegationCredential.ts b/packages/lib/server/repository/delegationCredential.ts index dd8ad6fb13..ddcda0241d 100644 --- a/packages/lib/server/repository/delegationCredential.ts +++ b/packages/lib/server/repository/delegationCredential.ts @@ -34,6 +34,18 @@ const delegationCredentialSelectIncludesServiceAccountKey = { serviceAccountKey: true, }; +function doesEmailMatchDelegationCredentialDomain({ + memberEmail, + delegationCredentialEmailDomain, +}: { + memberEmail: string | null; + delegationCredentialEmailDomain: string; +}) { + if (!memberEmail) return false; + const memberEmailDomain = memberEmail.split("@")[1]; + return memberEmailDomain === delegationCredentialEmailDomain; +} + export class DelegationCredentialRepository { private static encryptServiceAccountKey(serviceAccountKey: ServiceAccountKey): EncryptedServiceAccountKey { return encryptServiceAccountKey(serviceAccountKey); @@ -208,4 +220,111 @@ export class DelegationCredentialRepository { }, }); } + + static async findAllEnabledIncludeDelegatedMembers() { + const delegationCredentials = await prisma.delegationCredential.findMany({ + where: { + enabled: true, + }, + include: { + workspacePlatform: { + select: { + slug: true, + }, + }, + organization: { + include: { + members: { + select: { + id: true, + userId: true, + user: { + select: { + id: true, + email: true, + }, + }, + accepted: true, + }, + where: { + // Note: We don't maintain anything on Membership that can identify if a Membership has been processed, so we send all and let it be on the caller to filter out + accepted: true, + }, + }, + }, + }, + }, + }); + + return delegationCredentials.map((delegationCredential) => { + // Members for whom delegation credential is applicable + const delegatedMembers = delegationCredential.organization.members.filter((member) => { + return doesEmailMatchDelegationCredentialDomain({ + memberEmail: member.user.email, + delegationCredentialEmailDomain: delegationCredential.domain, + }); + }); + + return { + ...delegationCredential, + organization: { + ...delegationCredential.organization, + delegatedMembers, + }, + }; + }); + } + + static async findAllDisabledAndIncludeNextBatchOfMembersToProcess() { + const delegationCredentials = await prisma.delegationCredential.findMany({ + where: { + enabled: false, + }, + include: { + workspacePlatform: { + select: { + slug: true, + }, + }, + organization: { + include: { + members: { + select: { + id: true, + userId: true, + user: { + select: { + id: true, + email: true, + }, + }, + accepted: true, + }, + where: { + // Note: We don't maintain anything on Membership that can identify if a Membership has been processed, so we send all and let it be on the caller to filter out + accepted: true, + }, + }, + }, + }, + }, + }); + + return delegationCredentials.map((delegationCredential) => { + const members = delegationCredential.organization.members; + const membersThatAreActuallyPartOfDelegationCredential = members.filter((member) => { + return doesEmailMatchDelegationCredentialDomain({ + memberEmail: member.user.email, + delegationCredentialEmailDomain: delegationCredential.domain, + }); + }); + return { + ...delegationCredential, + organization: { + ...delegationCredential.organization, + members: membersThatAreActuallyPartOfDelegationCredential, + }, + }; + }); + } } diff --git a/packages/lib/server/repository/destinationCalendar.ts b/packages/lib/server/repository/destinationCalendar.ts index 37de4b0a57..a4a1735053 100644 --- a/packages/lib/server/repository/destinationCalendar.ts +++ b/packages/lib/server/repository/destinationCalendar.ts @@ -1,9 +1,12 @@ import type { Prisma } from "@prisma/client"; +import logger from "@calcom/lib/logger"; import { prisma } from "@calcom/prisma"; import { buildCredentialPayloadForPrisma } from "../buildCredentialPayloadForCalendar"; +const log = logger.getSubLogger({ prefix: ["DestinationCalendarRepository"] }); + export class DestinationCalendarRepository { static async create(data: Prisma.DestinationCalendarCreateInput) { return await prisma.destinationCalendar.create({ @@ -54,6 +57,7 @@ export class DestinationCalendarRepository { delegationCredentialId?: string | null; }; }) { + log.debug("upsert", { where, update, create }); const credentialPayloadForUpdate = buildCredentialPayloadForPrisma({ credentialId: update.credentialId, delegationCredentialId: update.delegationCredentialId, diff --git a/packages/lib/server/repository/selectedCalendar.test.ts b/packages/lib/server/repository/selectedCalendar.test.ts index 025863259a..0729918055 100644 --- a/packages/lib/server/repository/selectedCalendar.test.ts +++ b/packages/lib/server/repository/selectedCalendar.test.ts @@ -350,12 +350,13 @@ describe("SelectedCalendarRepository", () => { credentialId: 2, delegationCredentialId: "delegationCredential-123", }; + const beforeDelegationCredentialId = data.delegationCredentialId; const result = await SelectedCalendarRepository.upsert(data); expect(result.id).not.toBe(null); expect(result.id).toBe(existingCalendar.id); expect(result.credentialId).toBe(data.credentialId); - expect(result.delegationCredentialId).toBeNull(); + expect(result.delegationCredentialId).toBe(beforeDelegationCredentialId); }); }); @@ -398,6 +399,7 @@ describe("SelectedCalendarRepository", () => { credentialId: 1, }; + const beforeDelegationCredentialId = initialData.delegationCredentialId; const existingCalendar = await SelectedCalendarRepository.create(initialData); const data = { @@ -410,7 +412,7 @@ describe("SelectedCalendarRepository", () => { const result = await SelectedCalendarRepository.upsert(data); expect(result.id).toBe(existingCalendar.id); expect(result.credentialId).toBe(existingCalendar.credentialId); - expect(result.delegationCredentialId).toBe(null); + expect(result.delegationCredentialId).toBe(beforeDelegationCredentialId); }); it("shouldnt update delegationCredentialId if it is undefined", async () => { diff --git a/packages/lib/videoClient.ts b/packages/lib/videoClient.ts index acb7aeecfc..b7a524724d 100644 --- a/packages/lib/videoClient.ts +++ b/packages/lib/videoClient.ts @@ -203,6 +203,7 @@ const createMeetingWithCalVideo = async (calEvent: CalendarEvent) => { teamId: null, key: dailyAppKeys, invalid: false, + delegationCredentialId: null, }, ]); return videoAdapter?.createMeeting(calEvent); @@ -225,6 +226,7 @@ export const createInstantMeetingWithCalVideo = async (endTime: string) => { teamId: null, key: dailyAppKeys, invalid: false, + delegationCredentialId: null, }, ]); return videoAdapter?.createInstantCalVideoRoom?.(endTime); @@ -250,6 +252,7 @@ const getRecordingsOfCalVideoByRoomName = async ( teamId: null, key: dailyAppKeys, invalid: false, + delegationCredentialId: null, }, ]); return videoAdapter?.getRecordings?.(roomName); @@ -273,6 +276,7 @@ const getDownloadLinkOfCalVideoByRecordingId = async (recordingId: string) => { teamId: null, key: dailyAppKeys, invalid: false, + delegationCredentialId: null, }, ]); return videoAdapter?.getRecordingDownloadLink?.(recordingId); @@ -296,6 +300,7 @@ const getAllTranscriptsAccessLinkFromRoomName = async (roomName: string) => { teamId: null, key: dailyAppKeys, invalid: false, + delegationCredentialId: null, }, ]); return videoAdapter?.getAllTranscriptsAccessLinkFromRoomName?.(roomName); @@ -319,6 +324,7 @@ const getAllTranscriptsAccessLinkFromMeetingId = async (meetingId: string) => { teamId: null, key: dailyAppKeys, invalid: false, + delegationCredentialId: null, }, ]); return videoAdapter?.getAllTranscriptsAccessLinkFromMeetingId?.(meetingId); @@ -342,6 +348,7 @@ const submitBatchProcessorTranscriptionJob = async (recordingId: string) => { teamId: null, key: dailyAppKeys, invalid: false, + delegationCredentialId: null, }, ]); @@ -377,6 +384,7 @@ const getTranscriptsAccessLinkFromRecordingId = async (recordingId: string) => { teamId: null, key: dailyAppKeys, invalid: false, + delegationCredentialId: null, }, ]); @@ -401,6 +409,7 @@ const checkIfRoomNameMatchesInRecording = async (roomName: string, recordingId: teamId: null, key: dailyAppKeys, invalid: false, + delegationCredentialId: null, }, ]); diff --git a/packages/prisma/migrations/20250419103412_delegation_credential_calendar_cache/migration.sql b/packages/prisma/migrations/20250419103412_delegation_credential_calendar_cache/migration.sql new file mode 100644 index 0000000000..5409a58c67 --- /dev/null +++ b/packages/prisma/migrations/20250419103412_delegation_credential_calendar_cache/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "CalendarCache" ADD COLUMN "userId" INTEGER; + +-- AlterTable +ALTER TABLE "Credential" ADD COLUMN "delegationCredentialId" TEXT; + +-- CreateIndex +CREATE INDEX "CalendarCache_userId_key_idx" ON "CalendarCache"("userId", "key"); + +-- AddForeignKey +ALTER TABLE "Credential" ADD CONSTRAINT "Credential_delegationCredentialId_fkey" FOREIGN KEY ("delegationCredentialId") REFERENCES "DelegationCredential"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index f75a33f6ab..f202ec8cd4 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -174,7 +174,7 @@ model EventType { rescheduleWithSameRoundRobinHost Boolean @default(false) secondaryEmailId Int? - secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade) + secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade) @@unique([userId, slug]) @@unique([teamId, slug]) @@ -210,6 +210,8 @@ model Credential { invalid Boolean? @default(false) CalendarCache CalendarCache[] references BookingReference[] + delegationCredentialId String? + delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: Cascade) @@index([userId]) @@index([appId]) @@ -759,21 +761,24 @@ model Availability { } model SelectedCalendar { - id String @id @default(uuid()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId Int - integration String - externalId String - credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade) - credentialId Int? + id String @id @default(uuid()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + integration String + externalId String + credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade) + credentialId Int? // Used to identify a watched calendar channel in Google Calendar - googleChannelId String? - googleChannelKind String? - googleChannelResourceId String? - googleChannelResourceUri String? - googleChannelExpiration String? - delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: Cascade) - delegationCredentialId String? + googleChannelId String? + googleChannelKind String? + googleChannelResourceId String? + googleChannelResourceUri String? + googleChannelExpiration String? + + delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: Cascade) + delegationCredentialId String? + + // Deprecated and unused: Use delegationCredential instead domainWideDelegationCredential DomainWideDelegation? @relation(fields: [domainWideDelegationCredentialId], references: [id], onDelete: Cascade) domainWideDelegationCredentialId String? error String? @@ -1461,15 +1466,16 @@ view BookingTimeStatus { model CalendarCache { // To be made required in a followup id String? @default(uuid()) - + // The key would be the unique URL that is requested by the user key String value Json expiresAt DateTime credentialId Int + userId Int? credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade) - @@id([credentialId, key]) + @@index([userId, key]) @@unique([credentialId, key]) } @@ -1833,12 +1839,14 @@ model DelegationCredential { bookingReferences BookingReference[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + credentials Credential[] // Should be fair to assume that one domain can be only on one workspace platform at a time. So, one can't have two different workspace platforms for the same domain // Because we don't know which domain the organization might have, we couldn't make "domain" unique here as that would prevent an actual owner of the domain to be unable to use that domain if it is used by someone else. @@unique([organizationId, domain]) } +// Deprecated and probably unused - Use DelegationCredential instead model DomainWideDelegation { id String @id @default(uuid()) workspacePlatform WorkspacePlatform @relation(fields: [workspacePlatformId], references: [id], onDelete: Cascade) diff --git a/packages/prisma/selects/credential.ts b/packages/prisma/selects/credential.ts index ad0730ccad..33043af965 100644 --- a/packages/prisma/selects/credential.ts +++ b/packages/prisma/selects/credential.ts @@ -13,6 +13,7 @@ export const credentialForCalendarServiceSelect = Prisma.validator()({ @@ -29,4 +30,5 @@ export const safeCredentialSelect = Prisma.validator()( teamId: true, appId: true, invalid: true, + delegationCredentialId: true, }); diff --git a/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts b/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts index f25c32b39a..473cbd69e1 100644 --- a/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts +++ b/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts @@ -45,6 +45,7 @@ export const calendarOverlayHandler = async ({ ctx, input }: ListOptions) => { teamId: true, appId: true, invalid: true, + delegationCredentialId: true, user: { select: { email: true, diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index a9a5244070..e856dc20ea 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -256,6 +256,7 @@ export interface IntegrationCalendar extends Ensure, export type SelectedCalendarEventTypeIds = (number | null)[]; export interface Calendar { + getCredentialId?(): number; createEvent( event: CalendarEvent, credentialId: number, @@ -274,14 +275,16 @@ export interface Calendar { dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[], - shouldServeCache?: boolean + shouldServeCache?: boolean, + fallbackToPrimary?: boolean ): Promise; // for OOO calibration (only google calendar for now) getAvailabilityWithTimeZones?( dateFrom: string, dateTo: string, - selectedCalendars: IntegrationCalendar[] + selectedCalendars: IntegrationCalendar[], + fallbackToPrimary?: boolean ): Promise<{ start: Date | string; end: Date | string; timeZone: string }[]>; fetchAvailabilityAndSetCache?(selectedCalendars: IntegrationCalendar[]): Promise;