fix: Handle calendar-cache with Delegation Credentials
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
This commit is contained in:
+5
-1
@@ -443,4 +443,8 @@ NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
|
||||
NEXT_PUBLIC_HEAD_SCRIPTS=
|
||||
NEXT_PUBLIC_BODY_SCRIPTS=
|
||||
|
||||
DATABASE_CHUNK_SIZE=
|
||||
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=
|
||||
|
||||
+15
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -119,6 +119,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
teamId: null,
|
||||
appId: APPLE_CALENDAR_ID,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
const dav = new CalendarService({
|
||||
|
||||
@@ -35,6 +35,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
||||
teamId: null,
|
||||
appId: ICS_CALENDAR,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
+124
@@ -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)
|
||||
<https://www.googleapis.com/auth/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 <owner1-acme@example.com>
|
||||
- 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
|
||||
@@ -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);
|
||||
@@ -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<string, typeof allDelegationUserCredentials>);
|
||||
|
||||
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);
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -31,6 +31,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
teamId: null,
|
||||
appId: "caldav-calendar",
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
|
||||
@@ -38,6 +38,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
teamId: null,
|
||||
appId: "exchange2013-calendar",
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -38,6 +38,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
teamId: null,
|
||||
appId: "exchange2016-calendar",
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
teamId: null,
|
||||
appId: "exchange",
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<calendar_v3.Schema$FreeBusyResponse> {
|
||||
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<calendar_v3.Schema$FreeBusyResponse> {
|
||||
if (shouldServeCache === false) return await this.fetchAvailability(args);
|
||||
const calendarCache = await CalendarCache.init(null);
|
||||
const cached = await calendarCache.getCachedAvailability(this.credential.id, {
|
||||
// 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<T extends { id?: string | null }>(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<EventBusyDate[]> {
|
||||
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<IntegrationCalendar[]> {
|
||||
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<void> {
|
||||
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<Prisma.SelectedCalendarUncheckedCreateInput, "integration" | "credentialId" | "userId">,
|
||||
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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -29,6 +29,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
teamId: null,
|
||||
appId: appConfig.slug,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -55,6 +55,7 @@ const testCredential = {
|
||||
user: { email: "example@cal.com" },
|
||||
teamId: 1,
|
||||
delegatedTo: null,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
describe("createMeeting", () => {
|
||||
|
||||
@@ -113,6 +113,7 @@ describe("SalesforceCRMService", () => {
|
||||
user: {
|
||||
email: "test-user@example.com",
|
||||
},
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
service = new SalesforceCRMService(mockCredential, {}, true);
|
||||
|
||||
@@ -74,6 +74,7 @@ const createMockCredential = () => {
|
||||
teamId: 1,
|
||||
appId: "test_app_id",
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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<Response>) => {
|
||||
const res = await fn();
|
||||
return {
|
||||
json: await res.json(),
|
||||
};
|
||||
};
|
||||
|
||||
const oAuthManagerMock = mockDeep<typeof OAuthManager>({
|
||||
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 };
|
||||
|
||||
@@ -67,6 +67,7 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials?
|
||||
invalid: false,
|
||||
delegatedTo: null,
|
||||
delegatedToId: null,
|
||||
delegationCredentialId: null,
|
||||
team: {
|
||||
name: "Global",
|
||||
},
|
||||
|
||||
+179
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<unknown>) {
|
||||
}
|
||||
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -7,10 +7,24 @@ export type FreeBusyArgs = { timeMin: string; timeMax: string; items: { id: stri
|
||||
export interface ICalendarCacheRepository {
|
||||
watchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }): Promise<any>;
|
||||
unwatchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }): Promise<any>;
|
||||
upsertCachedAvailability(
|
||||
credentialId: number,
|
||||
args: FreeBusyArgs,
|
||||
value: Prisma.JsonNullValueInput | Prisma.InputJsonValue
|
||||
): Promise<void>;
|
||||
getCachedAvailability(credentialId: number, args: FreeBusyArgs): Promise<CalendarCache | null>;
|
||||
upsertCachedAvailability({
|
||||
credentialId,
|
||||
userId,
|
||||
args,
|
||||
value,
|
||||
}: {
|
||||
credentialId: number;
|
||||
userId: number | null;
|
||||
args: FreeBusyArgs;
|
||||
value: Prisma.JsonNullValueInput | Prisma.InputJsonValue;
|
||||
}): Promise<void>;
|
||||
getCachedAvailability({
|
||||
credentialId,
|
||||
userId,
|
||||
args,
|
||||
}: {
|
||||
credentialId: number;
|
||||
userId: number | null;
|
||||
args: FreeBusyArgs;
|
||||
}): Promise<CalendarCache | null>;
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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<ICalendarCacheRepository> {
|
||||
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<ICalendarCacheRepository> {
|
||||
const featureRepo = new FeaturesRepository();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof evt.destinationCalendar>[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<NewCalendarEventType>[] = [];
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -6,7 +6,7 @@ class CRMScheduler {
|
||||
return tasker.create(
|
||||
"createCRMEvent",
|
||||
{ bookingUid },
|
||||
{ maxAttempts: tasksConfig.createCRMEvent.maxAttempts ?? 5 }
|
||||
{ maxAttempts: tasksConfig.createCRMEvent?.maxAttempts ?? 5 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = <T extends Record<string, unknown> | 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 = <T extends Record<string, unknown>>(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;
|
||||
})[];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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([[]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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!,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
+11
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -13,6 +13,7 @@ export const credentialForCalendarServiceSelect = Prisma.validator<Prisma.Creden
|
||||
teamId: true,
|
||||
key: true,
|
||||
invalid: true,
|
||||
delegationCredentialId: true,
|
||||
});
|
||||
|
||||
export const safeCredentialSelect = Prisma.validator<Prisma.CredentialSelect>()({
|
||||
@@ -29,4 +30,5 @@ export const safeCredentialSelect = Prisma.validator<Prisma.CredentialSelect>()(
|
||||
teamId: true,
|
||||
appId: true,
|
||||
invalid: true,
|
||||
delegationCredentialId: true,
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ export const calendarOverlayHandler = async ({ ctx, input }: ListOptions) => {
|
||||
teamId: true,
|
||||
appId: true,
|
||||
invalid: true,
|
||||
delegationCredentialId: true,
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
|
||||
Vendored
+5
-2
@@ -256,6 +256,7 @@ export interface IntegrationCalendar extends Ensure<Partial<_SelectedCalendar>,
|
||||
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<EventBusyDate[]>;
|
||||
|
||||
// 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<unknown>;
|
||||
|
||||
Reference in New Issue
Block a user