diff --git a/apps/api/v1/pages/api/credential-sync/_post.ts b/apps/api/v1/pages/api/credential-sync/_post.ts index 32d74da2c10a62..e201f24d4e9798 100644 --- a/apps/api/v1/pages/api/credential-sync/_post.ts +++ b/apps/api/v1/pages/api/credential-sync/_post.ts @@ -108,7 +108,7 @@ async function handler(req: NextApiRequest) { // ^ Workaround for the select in `create` not working if (createCalendarResources) { - const calendar = await getCalendar(credential); + const calendar = await getCalendar({ ...credential, delegatedTo: null }); if (!calendar) throw new HttpError({ message: "Calendar missing for credential", statusCode: 500 }); const calendars = await calendar.listCalendars(); const calendarToCreate = calendars.find((calendar) => calendar.primary) || calendars[0]; diff --git a/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts index 064634b2f22ac4..57d9540103cee8 100644 --- a/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts +++ b/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts @@ -80,6 +80,7 @@ type UserCredentialType = { teamId: number | null; key: Prisma.JsonValue; invalid: boolean | null; + domainWideDelegationCredentialId?: string | null; }; export async function patchHandler(req: NextApiRequest) { @@ -185,7 +186,7 @@ async function verifyCredentialsAndGetId({ currentCredentialId: number | null; }) { if (parsedBody.integration && parsedBody.externalId) { - const calendarCredentials = getCalendarCredentials(userCredentials); + const calendarCredentials = await getCalendarCredentials(userCredentials); const { connectedCalendars } = await getConnectedCalendars( calendarCredentials, diff --git a/apps/api/v1/pages/api/destination-calendars/_post.ts b/apps/api/v1/pages/api/destination-calendars/_post.ts index 1d8379335cf0da..519f568f1b5072 100644 --- a/apps/api/v1/pages/api/destination-calendars/_post.ts +++ b/apps/api/v1/pages/api/destination-calendars/_post.ts @@ -82,7 +82,7 @@ async function postHandler(req: NextApiRequest) { message: "Bad request, credential id invalid", }); - const calendarCredentials = getCalendarCredentials(userCredentials); + const calendarCredentials = await getCalendarCredentials(userCredentials); const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, [], parsedBody.externalId); diff --git a/apps/web/app/settings/(settings-layout)/organizations/domain-wide-delegation/domain-wide-delegation-TODO.md b/apps/web/app/settings/(settings-layout)/organizations/domain-wide-delegation/domain-wide-delegation-TODO.md new file mode 100644 index 00000000000000..879ff59e5f405b --- /dev/null +++ b/apps/web/app/settings/(settings-layout)/organizations/domain-wide-delegation/domain-wide-delegation-TODO.md @@ -0,0 +1,74 @@ +## Version 1.0 +### Release Plan + - Read the document(domain-wide-delegation.md) and acknowledge it. + - Deploy + 1. Follow "Setting up Domain-Wide Delegation for Google Calendar API" in domain-wide-delegation.md to create Service Account and create a workspace. + 2. Merge PR and then deploy. + - Enabling for i.cal.com + - 1. Enable DWD for i.cal.com first and then test there + - 2. Wait for 1-2 days and keep monitoring the errors in Sentry and Axiom. + - Enable for a big customer + - 1. Wait for a week and keep monitoring the errors in Sentry and Axiom. +- Followup with sorting the credentials with DWD credentials first +- Monitor the errors in Sentry and Axiom. + +### Important + - Bugs + - [ ] Duplicate Calendar Events in Google Calendar when choosing non-primary calendar as destination. No idea why this is happening. + - [x] Duplicate Calendar connections in 'apps/installed' if a user already had connected calendar and DWD is enabled. + - [x] Calendar Cache has credentialId column which isn't applicable for DWD(Solution: Added userId there) + - Manual Testing + - [ ] Test with Multiple DWD entries for different organizations. Verify that wrong DWD entry isn't used. + - [ ] Location Change of a booking to Google Meet(from Cal Video) + - [ ] RR Team Event + - Booking + - Unavailable slot isn't available for booking. Unavailable user isn't used. + - Reroute + - Reassign + - [ ] Calendar Cache + - [x] Troubleshooter + - [ ] Shows busy times from Claendar + - [x] If a user has connected a calendar, and then DWD is enabled. + - Tested various scenarios for it + - [x] Inviting a new user. + - Verified that Google Calendar is shown pre-installed. + - How about Google Meet(which depends on Google Calendar) - Correctly shows up as installed. + - TODO: + - [x] Troubleshooter + - [x] Google CalendarService unit tests to verify that if DWD credential is provided it uses impersonation to access API otherwise it uses regular user credential API. + - [x] setDestinationCalendar.handler.ts tests to verify that when DWD is enabled it still correctly sets the destination calendar. + - [x] getConnectedDestinationCalendars tests. + - [x] Creating DWD shouldn't immediately enable it. Enabling has separate check to confirm if it is actually configured in google workspace + - [x] Added check to avoid adding same domain for a workspace platform in another organization if it is already enabled in some other organization + - [x] Don't show dwd in menu for non-org-admin users - It errors with something_went_wrong right now + - [x] Don't allow disabled platform to be selected in the UI for creation. + - We have disabled coming the disabled platform to be coming into the list that effectively disables edit of existing dwd and creation of new dwd for that platform. + - [x] Where should we show the user the client ID to enable domain wide delegation? + - [x] It must be shown to the organization owner/admin only + - [x] There could be multiple checkboxes per domain to enable domain wide delegation for a domain + - [x] Which domain to allow + - Any domain can be added by a user + - [x] Support multiple domains in DomainWideDelegation schema for an organization + - [x] Use the domain as well to identify if the domain wide delegation is enabled + - [x] Before enabling Domain-wide delegation, there should be a check to ensure that the clientID has been added to the Workspace Platform + - [x] We should allow setting default conferencing app during onboarding + +### Follow-up release + - [ ] Confirmation for DwD deletion and disabling + - [ ] If DWD is enabled and the org member doesn't exist in Google Workspace, and the user has connected personal account, should we correctly use the personal account? + +### Security + - [x] We don't let any one user see the added service account key from UI. + - [ ] We intend to implement Workload Identity Federation in the future. + +### Documentation +- After enabling domain-wide delegation, the credential is shown pre-installed and the connection can't be removed(or the app can't be uninstalled by user) +- Steps + - App admin will first create a Workspace Platform and then organization owner/admin can enable domain-wide delegation for a domain + - As soon as domain-wide delegation is created, it would start taking preference over the personal credentials of the organization members and it would be used for that. + +Version-2.0 +- Workload Identity Federation to ensure that the service account key is never stored in DB. + + + diff --git a/apps/web/app/settings/(settings-layout)/organizations/domain-wide-delegation/domain-wide-delegation.md b/apps/web/app/settings/(settings-layout)/organizations/domain-wide-delegation/domain-wide-delegation.md new file mode 100644 index 00000000000000..a4373a0f96ca77 --- /dev/null +++ b/apps/web/app/settings/(settings-layout)/organizations/domain-wide-delegation/domain-wide-delegation.md @@ -0,0 +1,87 @@ +## Setting up Domain-Wide Delegation 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 DWD with workspace platform "google" + - User must be a member of the Google Workspace to be able to enable DWD 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 + + +## Restrictions after enabling DWD +- Enabling DWD for a particular workspace in Cal.com(only google supported at the moment) disables the user from disconnecting that credential. + +## Developer Notes +### How DWD works +- We use the Cal.com user's email to impersonate that user using DWD 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 DWD Credential? +- A DWD service account key along with user's email becomes the DWD Credential which is an alternative to regular Credential in DB. +- DWD doesn't completely replace the regular credentials. DWD 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 DWD. 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 dwd is enabled. +- DWD Credential is applicable to Users only. + - For team, we don't use dwd credential as you can impersonate a user and not team through Dwd credential. Currently supported apps(Google Calendar and Google Meet) don't support team installation, so we could simply allow enabling DWD without any issues. +- Disabling a workspace platform stops it from being used for any new organizations and also disables any DWD using the workspace platform from being edited. + - It still all existing DWDs to keep on working +- Adding any number of DWDs for a particular workspace always gives the same Client ID as DWD uses the workspace's default Service Account. +- We should disable DWD and not delete it when we want to stop using it temporarily. Deleting DWD also removes all the seletedCalendar entries connected to it. + +### 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 on existing users booking flow +- There should be no impact on availability on enabling DWD because we keep on using the existing credentials along with new DWD credential. +- When booking the event, we sort the credentials with DWD credentials last, so there should be no impact on creating calendar events. + - NOTE: We will followup with sorting the credentials with DWD credentials first in a followup PR(They are preferred because they don't expire) + +## Impact on APIs - [ To Verify ] +- We don't support DWD through APIs yet. So, all existing APIs would continue to work with non-dwd credentials only. + +## Performance Issues +- There could be 100s of users in an organization with already connected calendars. Enabling DWD adds a duplicate credential for each of them. + - Because a credential isn't aware of which email it is for(without connecting with Google Calendar API itself), we can't deduplicate them. + +## 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 DWD for the organization. + - Note: After changing the email, you would have to logout and login again as required by NextAuth \ No newline at end of file diff --git a/apps/web/components/apps/AppPage.tsx b/apps/web/components/apps/AppPage.tsx index 40dfa7fd850ef8..cb5547f2624492 100644 --- a/apps/web/components/apps/AppPage.tsx +++ b/apps/web/components/apps/AppPage.tsx @@ -79,12 +79,14 @@ export const AppPage = ({ const searchParams = useCompatSearchParams(); const hasDescriptionItems = descriptionItems && descriptionItems.length > 0; + const utils = trpc.useUtils(); const mutation = useAddAppMutation(null, { - onSuccess: (data) => { + onSuccess: async (data) => { if (data?.setupPending) return; setIsLoading(false); - showToast(t("app_successfully_installed"), "success"); + showToast(data?.message || t("app_successfully_installed"), "success"); + await utils.viewer.appCredentialsByType.invalidate({ appType: type }); }, onError: (error) => { if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error"); @@ -161,6 +163,7 @@ export const AppPage = ({ // variant not other allows, an app to be shown in calendar category without requiring an actual calendar connection e.g. vimcal // Such apps, can only be installed once. + const allowedMultipleInstalls = categories.indexOf("calendar") > -1 && variant !== "other"; useEffect(() => { if (searchParams?.get("defaultInstall") === "true") { diff --git a/apps/web/components/getting-started/components/AppConnectionItem.tsx b/apps/web/components/getting-started/components/AppConnectionItem.tsx index 55f6464af5fd35..174f1941fd4459 100644 --- a/apps/web/components/getting-started/components/AppConnectionItem.tsx +++ b/apps/web/components/getting-started/components/AppConnectionItem.tsx @@ -24,11 +24,19 @@ interface IAppConnectionItem { const AppConnectionItem = (props: IAppConnectionItem) => { const { title, logo, type, installed, isDefault, defaultInstall, slug } = props; const { t } = useLocale(); - const setDefaultConferencingApp = trpc.viewer.appsRouter.setDefaultConferencingApp.useMutation(); + const utils = trpc.useUtils(); + const setDefaultConferencingApp = trpc.viewer.appsRouter.setDefaultConferencingApp.useMutation({ + onSuccess: async () => { + await utils.viewer.me.invalidate(); + }, + onError: (error) => { + showToast(t("something_went_wrong"), "error"); + console.error(error); + }, + }); const dependency = props.dependencyData?.find((data) => !data.installed); const [isInstalling, setInstalling] = useState(false); - const utils = trpc.useUtils(); return (
@@ -37,72 +45,90 @@ const AppConnectionItem = (props: IAppConnectionItem) => {

{title}

{isDefault && {t("default")}}
- { - if (defaultInstall && slug) { - setDefaultConferencingApp.mutate({ slug }); - } - setInstalling(false); - utils.viewer.integrations.invalidate(); - showToast(t("app_successfully_installed"), "success"); - }, - onError: (error) => { - if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error"); - }, - }} - render={(buttonProps) => ( - + )} + /> + {/* It is possible that app is already installed here during onboarding due to Domain Wide Delegation enabled at organization level. We allow the user to set it as default */} + {installed && !isDefault && ( + )} - /> + ); }; diff --git a/apps/web/components/getting-started/components/ConnectedCalendarItem.tsx b/apps/web/components/getting-started/components/ConnectedCalendarItem.tsx index ce3adc6e60c083..d3f19043ec4f7f 100644 --- a/apps/web/components/getting-started/components/ConnectedCalendarItem.tsx +++ b/apps/web/components/getting-started/components/ConnectedCalendarItem.tsx @@ -14,6 +14,7 @@ interface IConnectedCalendarItem { userId?: number | undefined; integration?: string | undefined; externalId: string; + domainWideDelegationCredentialId: string | null; }[]; } @@ -61,6 +62,7 @@ const ConnectedCalendarItem = (prop: IConnectedCalendarItem) => { type={integrationType} isChecked={calendar.isSelected} isLastItemInList={i === calendars.length - 1} + domainWideDelegationCredentialId={calendar.domainWideDelegationCredentialId} /> ))} diff --git a/apps/web/components/getting-started/steps-views/ConnectedVideoStep.tsx b/apps/web/components/getting-started/steps-views/ConnectedVideoStep.tsx index 4f81150dc97c79..4edace7be5a05c 100644 --- a/apps/web/components/getting-started/steps-views/ConnectedVideoStep.tsx +++ b/apps/web/components/getting-started/steps-views/ConnectedVideoStep.tsx @@ -17,7 +17,16 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => { const { data: queryConnectedVideoApps, isPending } = trpc.viewer.integrations.useQuery({ variant: "conferencing", onlyInstalled: false, + + /** + * Both props together sort by most popular first, then by installed first. + * So, installed apps are always shown at the top, followed by remaining apps sorted by descending popularity. + * + * This is done because there could be not so popular app already installed by the admin(e.g. through Domain-Wide Delegation) + * and we want to show it at the top so that user can set it as default if he wants to. + */ sortByMostPopular: true, + sortByInstalledFirst: true, }); const { data } = useMeQuery(); const { t } = useLocale(); diff --git a/apps/web/pages/api/availability/calendar.ts b/apps/web/pages/api/availability/calendar.ts index 3d975dfc486e7a..6fb8465778b849 100644 --- a/apps/web/pages/api/availability/calendar.ts +++ b/apps/web/pages/api/availability/calendar.ts @@ -14,6 +14,7 @@ const selectedCalendarSelectSchema = z.object({ integration: z.string(), externalId: z.string(), credentialId: z.coerce.number(), + domainWideDelegationCredentialId: z.string().nullish().default(null), eventTypeId: z.coerce.number().nullish(), }); @@ -41,12 +42,14 @@ type CustomNextApiRequest = NextApiRequest & { async function postHandler(req: CustomNextApiRequest) { if (!req.userWithCredentials) throw new HttpError({ statusCode: 401, message: "Not authenticated" }); const user = req.userWithCredentials; - const { integration, externalId, credentialId, eventTypeId } = selectedCalendarSelectSchema.parse(req.body); + const { integration, externalId, credentialId, eventTypeId, domainWideDelegationCredentialId } = + selectedCalendarSelectSchema.parse(req.body); await SelectedCalendarRepository.upsert({ userId: user.id, integration, externalId, credentialId, + domainWideDelegationCredentialId, eventTypeId: eventTypeId ?? null, }); @@ -56,10 +59,15 @@ async function postHandler(req: CustomNextApiRequest) { async function deleteHandler(req: CustomNextApiRequest) { if (!req.userWithCredentials) throw new HttpError({ statusCode: 401, message: "Not authenticated" }); const user = req.userWithCredentials; - const { integration, externalId, credentialId, eventTypeId } = selectedCalendarSelectSchema.parse( - req.query - ); - const calendarCacheRepository = await CalendarCache.initFromCredentialId(credentialId); + const { integration, externalId, credentialId, eventTypeId, domainWideDelegationCredentialId } = + selectedCalendarSelectSchema.parse(req.query); + + const calendarCacheRepository = await CalendarCache.initFromDwdOrRegularCredential({ + credentialId, + dwdId: domainWideDelegationCredentialId, + userId: user.id, + }); + await calendarCacheRepository.unwatchCalendar({ calendarId: externalId, eventTypeIds: [eventTypeId ?? null], @@ -85,7 +93,7 @@ async function getHandler(req: CustomNextApiRequest) { select: { externalId: true }, }); // get user's credentials + their connected integrations - const calendarCredentials = getCalendarCredentials(user.credentials); + const calendarCredentials = await getCalendarCredentials(user.credentials); // get all the connected integrations' calendars (from third party) const { connectedCalendars } = await getConnectedCalendars( calendarCredentials, diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 7a1877a9f5b9aa..c4df71d2a579b3 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2729,6 +2729,26 @@ "limit_team_booking_frequency_description": "Limit how many times members can be booked across all team event types", "booking_limits_updated_successfully": "Booking limits updated successfully", "you_are_unauthorized_to_make_this_change_to_the_booking": "You are unauthorized to make this change to the booking", + "add_client_id_in_google_workspace_with_below_scope": "Add this Client Id in Google Workspace with the scope below", + "domain_wide_delegation": "Domain-wide delegation", + "domain_wide_delegation_enabled": "Domain-wide delegation enabled", + "domain_wide_delegation_disabled": "Domain-wide delegation disabled", + "domain_wide_delegation_description": "Domain-wide delegation allows you to manage access to Google Workspace calendars for your organization.", + "add_domain_wide_delegation": "Add domain-wide delegation", + "edit_domain_wide_delegation": "Edit domain-wide delegation", + "domain": "Domain", + "no_workspace_platforms": "No workspace platforms", + "workspace_platform": "Workspace platform", + "workspace_platforms": "Workspace platforms", + "workspace_platforms_description": "Manage workspace platforms that can be used for domain-wide delegation", + "edit_workspace_platform": "Edit workspace platform", + "edit_service_account": "Edit service account", + "add_workspace_platform": "Add workspace platform", + "slug": "Slug", + "service_account_key": "Service account key JSON", + "edit_service_account_key": "Edit service account key JSON", + "domain_wide_delegation_restricts_adding_more_than_one_installation": "Domain-wide delegation restricts adding more than one installation", + "app_successfully_installed_and_is_using_delegated_credentials": "App successfully installed and is using delegated credentials", "matching_members": "Matching members", "x_matching_members": "{{x}} matching members", "matching_members_queue_using_attribute_weights": "Matching members queue (using attribute weights)", diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 4aec51d3e51f65..4fb6a44683f223 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -2043,3 +2043,50 @@ export const getDefaultBookingFields = ({ ...bookingFields, ] as Fields; }; + +export const createDwdCredential = async (orgId: number) => { + const workspace = await prismock.workspacePlatform.create({ + data: { + name: "Test Workspace", + slug: "google", + description: "Test Workspace", + defaultServiceAccountKey: { + type: "service_account", + auth_uri: "https://accounts.google.com/o/oauth2/auth", + client_id: "CLIENT_ID", + token_uri: "https://oauth2.googleapis.com/token", + project_id: "PROJECT_ID", + private_key: "PRIVATE_KEY", + client_email: "CLIENT_EMAIL", + private_key_id: "PRIVATE_KEY_ID", + universe_domain: "googleapis.com", + client_x509_cert_url: "CLIENT_X509_CERT_URL", + auth_provider_x509_cert_url: "AUTH_PROVIDER_X509_CERT_URL", + createdAt: new Date(), + updatedAt: new Date(), + }, + enabled: true, + }, + }); + + const dwd = await prismock.domainWideDelegation.create({ + data: { + workspacePlatform: { + connect: { + id: workspace.id, + }, + }, + domain: "example.com", + enabled: true, + organization: { + connect: { + id: orgId, + }, + }, + // @ts-expect-error - TODO: fix this + serviceAccountKey: workspace.defaultServiceAccountKey, + }, + }); + + return dwd; +}; diff --git a/packages/app-store/_appRegistry.ts b/packages/app-store/_appRegistry.ts index 0999a76e53debf..32b87e1a43a658 100644 --- a/packages/app-store/_appRegistry.ts +++ b/packages/app-store/_appRegistry.ts @@ -1,6 +1,7 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; import { getAppFromSlug } from "@calcom/app-store/utils"; import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp"; +import { getAllDomainWideDelegationCredentialsForUser } from "@calcom/lib/domainWideDelegation/server"; import type { UserAdminTeams } from "@calcom/lib/server/repository/user"; import prisma, { safeAppSelect, safeCredentialSelect } from "@calcom/prisma"; import { userMetadata } from "@calcom/prisma/zod-utils"; @@ -79,15 +80,22 @@ export async function getAppRegistryWithCredentials(userId: number, userAdminTea }, }, }); + const user = await prisma.user.findUnique({ where: { id: userId, }, select: { + email: true, + id: true, metadata: true, }, }); + const domainWideDelegationCredentials = user + ? await getAllDomainWideDelegationCredentialsForUser({ user: { id: userId, email: user.email } }) + : []; + const usersDefaultApp = userMetadata.parse(user?.metadata)?.defaultConferencingApp?.appSlug; const apps = [] as (App & { credentials: Credential[]; @@ -95,6 +103,10 @@ export async function getAppRegistryWithCredentials(userId: number, userAdminTea })[]; const installCountPerApp = await getInstallCountPerApp(); for await (const dbapp of dbApps) { + const dbAppDomainWideDelegationCredentials = domainWideDelegationCredentials.filter( + (credential) => credential.appId === dbapp.slug + ); + const allCredentials = [...dbapp.credentials, ...dbAppDomainWideDelegationCredentials]; const app = await getAppWithMetadata(dbapp); if (!app) continue; // Skip if app isn't installed @@ -116,7 +128,7 @@ export async function getAppRegistryWithCredentials(userId: number, userAdminTea apps.push({ ...app, categories: dbapp.categories, - credentials: dbapp.credentials, + credentials: allCredentials, installed: true, installCount: installCountPerApp[dbapp.slug] || 0, isDefault: usersDefaultApp === dbapp.slug, diff --git a/packages/app-store/_utils/getCalendar.ts b/packages/app-store/_utils/getCalendar.ts index 73a9292609328f..399f92ac75b2fc 100644 --- a/packages/app-store/_utils/getCalendar.ts +++ b/packages/app-store/_utils/getCalendar.ts @@ -1,6 +1,6 @@ import logger from "@calcom/lib/logger"; import type { Calendar, CalendarClass } from "@calcom/types/Calendar"; -import type { CredentialPayload } from "@calcom/types/Credential"; +import type { CredentialForCalendarService } from "@calcom/types/Credential"; import appStore from ".."; @@ -23,7 +23,9 @@ const isCalendarService = (x: unknown): x is CalendarApp => !!x.lib && "CalendarService" in x.lib; -export const getCalendar = async (credential: CredentialPayload | null): Promise => { +export const getCalendar = async ( + credential: CredentialForCalendarService | null +): Promise => { if (!credential || !credential.key) return null; let { type: calendarType } = credential; if (calendarType?.endsWith("_other_calendar")) { diff --git a/packages/app-store/_utils/installation.ts b/packages/app-store/_utils/installation.ts index 16d45bed7ff02b..bad4c828075c66 100644 --- a/packages/app-store/_utils/installation.ts +++ b/packages/app-store/_utils/installation.ts @@ -1,21 +1,22 @@ import type { Prisma } from "@prisma/client"; import { HttpError } from "@calcom/lib/http-error"; +import { CredentialRepository } from "@calcom/lib/server/repository/credential"; import prisma from "@calcom/prisma"; import type { UserProfile } from "@calcom/types/UserProfile"; export async function checkInstalled(slug: string, userId: number) { - const alreadyInstalled = await prisma.credential.findFirst({ - where: { - appId: slug, - userId: userId, - }, - }); + const alreadyInstalled = await CredentialRepository.findByAppIdAndUserId({ appId: slug, userId }); if (alreadyInstalled) { throw new HttpError({ statusCode: 422, message: "Already installed" }); } } +export async function isAppInstalled({ appId, userId }: { appId: string; userId: number }) { + const alreadyInstalled = await CredentialRepository.findByAppIdAndUserId({ appId, userId }); + return !!alreadyInstalled; +} + type InstallationArgs = { appType: string; user: { diff --git a/packages/app-store/_utils/useAddAppMutation.ts b/packages/app-store/_utils/useAddAppMutation.ts index b3ef6e6784c39e..819c841e5bdd53 100644 --- a/packages/app-store/_utils/useAddAppMutation.ts +++ b/packages/app-store/_utils/useAddAppMutation.ts @@ -18,7 +18,7 @@ type CustomUseMutationOptions = | Omit, "mutationKey" | "mutationFn" | "onSuccess"> | undefined; -type AddAppMutationData = { setupPending: boolean } | void; +type AddAppMutationData = { setupPending: boolean; message?: string } | void; export type UseAddAppMutationOptions = CustomUseMutationOptions & { onSuccess?: (data: AddAppMutationData) => void; installGoogleVideo?: boolean; @@ -94,18 +94,19 @@ function useAddAppMutation(_type: App["type"] | null, options?: UseAddAppMutatio if (externalUrl) { // TODO: For Omni installation to authenticate and come back to the page where installation was initiated, some changes need to be done in all apps' add callbacks gotoUrl(json.url, json.newTab); - return { setupPending: !json.newTab }; + return { setupPending: !json.newTab, message: json.message }; } else if (json.url) { gotoUrl(json.url, json.newTab); return { setupPending: json?.url?.endsWith("/setup") || json?.url?.includes("/apps/installation/event-types"), + message: json.message, }; } else if (returnTo) { gotoUrl(returnTo, false); - return { setupPending: true }; + return { setupPending: true, message: json.message }; } else { - return { setupPending: false }; + return { setupPending: false, message: json.message }; } }, }); diff --git a/packages/app-store/components.tsx b/packages/app-store/components.tsx index fa704b98e42679..06f239d226b1bc 100644 --- a/packages/app-store/components.tsx +++ b/packages/app-store/components.tsx @@ -20,6 +20,7 @@ import { InstallAppButtonMap } from "./apps.browser.generated"; import type { InstallAppButtonProps } from "./types"; export const InstallAppButtonWithoutPlanCheck = ( + props: { type: App["type"]; options?: UseAddAppMutationOptions; diff --git a/packages/app-store/googlecalendar/_metadata.ts b/packages/app-store/googlecalendar/_metadata.ts index 4701bde4356681..e40949d8ba4b84 100644 --- a/packages/app-store/googlecalendar/_metadata.ts +++ b/packages/app-store/googlecalendar/_metadata.ts @@ -19,6 +19,11 @@ export const metadata = { email: "help@cal.com", dirName: "googlecalendar", isOAuth: true, + domainWideDelegation: { + // This is unused at the moment but should be used in future + // For now, we have hardcoded imports in the codebase that are supported with Google Workspace(i.e. Google Calendar and Google Meet) + workspacePlatformSlug: "google", + }, } as AppMeta; export default metadata; diff --git a/packages/app-store/googlecalendar/api/add.ts b/packages/app-store/googlecalendar/api/add.ts index 4f7edcf4aee3d1..1154165edb0f81 100644 --- a/packages/app-store/googlecalendar/api/add.ts +++ b/packages/app-store/googlecalendar/api/add.ts @@ -2,13 +2,69 @@ import { OAuth2Client } from "googleapis-common"; import type { NextApiRequest, NextApiResponse } from "next"; import { GOOGLE_CALENDAR_SCOPES, SCOPE_USERINFO_PROFILE, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants"; +import { HttpError } from "@calcom/lib/http-error"; import { defaultHandler, defaultResponder } from "@calcom/lib/server"; +import { getTranslation } from "@calcom/lib/server/i18n"; import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState"; import { getGoogleAppKeys } from "../lib/getGoogleAppKeys"; +// We might need the below commented code in the future +// Right now, if DWD is enabled, the install button is disabled and thus this endpoint is never hit but we should handle at backend as well. + +// async function getDomainWideDelegationForApp({ +// user, +// appMetadata, +// }: { +// user: { +// email: string; +// }; +// appMetadata: Pick; +// }) { +// const log = logger.getSubLogger({ prefix: ["getDomainWideDelegationForApp"] }); + +// const domainWideDelegation = await DomainWideDelegationRepository.findUniqueByOrganizationMemberEmail({ +// email: user.email, +// }); + +// if (!domainWideDelegation || !domainWideDelegation.enabled || !appMetadata.domainWideDelegation) { +// log.debug("Domain-wide delegation isn't enabled for this app", { +// domainWideDelegationEnabled: domainWideDelegation?.enabled, +// metadataDomainWideDelegation: appMetadata.domainWideDelegation, +// }); +// return null; +// } + +// if ( +// domainWideDelegation.workspacePlatform.slug !== appMetadata.domainWideDelegation.workspacePlatformSlug +// ) { +// log.info("Domain-wide delegation isn't compatible with this app", { +// domainWideDelegation: domainWideDelegation.workspacePlatform.slug, +// appSlug: metadata.slug, +// }); +// return null; +// } + +// log.debug("Domain-wide delegation is enabled"); + +// return domainWideDelegation; +// } + async function getHandler(req: NextApiRequest, res: NextApiResponse) { - // Get token from Google Calendar API + const loggedInUser = req.session?.user; + + if (!loggedInUser) { + throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" }); + } + + const translate = await getTranslation(loggedInUser.locale ?? "en", "common"); + + // Ideally this should never happen, as email is there in session user but typings aren't accurate it seems + // TODO: So, confirm and later fix the typings + if (!loggedInUser.email) { + throw new HttpError({ statusCode: 400, message: "Session user must have an email" }); + } + const { client_id, client_secret } = await getGoogleAppKeys(); const redirect_uri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/googlecalendar/callback`; const oAuth2Client = new OAuth2Client(client_id, client_secret, redirect_uri); diff --git a/packages/app-store/googlecalendar/api/callback.ts b/packages/app-store/googlecalendar/api/callback.ts index cb98b320dd237f..bb24dbd976c16c 100644 --- a/packages/app-store/googlecalendar/api/callback.ts +++ b/packages/app-store/googlecalendar/api/callback.ts @@ -80,6 +80,7 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) { const gCalService = new GoogleCalendarService({ ...gcalCredential, user: null, + delegatedTo: null, }); const calendar = new calendar_v3.Calendar({ diff --git a/packages/app-store/googlecalendar/api/webhook.ts b/packages/app-store/googlecalendar/api/webhook.ts index a5a65e3cf5bf49..b25e24d1af011a 100644 --- a/packages/app-store/googlecalendar/api/webhook.ts +++ b/packages/app-store/googlecalendar/api/webhook.ts @@ -1,12 +1,17 @@ import type { NextApiRequest } from "next"; +import { getCredentialForCalendarService } from "@calcom/core/CalendarManager"; +import { getDwdCalendarCredentialById } from "@calcom/lib/domainWideDelegation/server"; import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; import { defaultHandler, defaultResponder } from "@calcom/lib/server"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; import { getCalendar } from "../../_utils/getCalendar"; +const log = logger.getSubLogger({ prefix: ["googlecalendar", "api", "webhook"] }); async function postHandler(req: NextApiRequest) { + log.debug("postHandler"); if (req.headers["x-goog-channel-token"] !== process.env.GOOGLE_WEBHOOK_TOKEN) { throw new HttpError({ statusCode: 403, message: "Invalid API key" }); } @@ -28,18 +33,34 @@ async function postHandler(req: NextApiRequest) { message: `No selected calendar found for googleChannelId: ${req.headers["x-goog-channel-id"]}`, }); } - const { credential } = selectedCalendar; - if (!credential) + const { credential, domainWideDelegationCredential, userId } = selectedCalendar; + + let selectedCalendars; + let credentialForCalendarService; + if (credential) { + selectedCalendars = credential.selectedCalendars; + credentialForCalendarService = await getCredentialForCalendarService(credential); + } else if (domainWideDelegationCredential) { + // Use all the selected calendars of the same selectedCalendar's user + // Because same dwd is used across all members of the same organization, we must have userId in the filter + selectedCalendars = domainWideDelegationCredential.selectedCalendars.filter( + (selectedCalendar) => selectedCalendar.userId === userId + ); + const dwdCredential = await getDwdCalendarCredentialById({ + id: domainWideDelegationCredential.id, + userId, + }); + credentialForCalendarService = await getCredentialForCalendarService(dwdCredential); + } else { throw new HttpError({ statusCode: 200, - message: `No credential found for selected calendar for googleChannelId: ${req.headers["x-goog-channel-id"]}`, + message: `No credential or DomainWideDelegation found for selected calendar for googleChannelId: ${req.headers["x-goog-channel-id"]}`, }); - const { selectedCalendars } = credential; - const calendar = await getCalendar(credential); + } - // Make sure to pass unique SelectedCalendars to avoid unnecessary third party api calls - // Necessary to do here so that it is ensure for all calendar apps + const calendar = await getCalendar(credentialForCalendarService); await calendar?.fetchAvailabilityAndSetCache?.(selectedCalendars); + return { message: "ok" }; } diff --git a/packages/app-store/googlecalendar/lib/CalendarService.test.ts b/packages/app-store/googlecalendar/lib/CalendarService.test.ts index c7fa7440faa755..b866109b3b4a8a 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.test.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.test.ts @@ -2,16 +2,39 @@ import prismock from "../../../../tests/libs/__mocks__/prisma"; import oAuthManagerMock, { defaultMockOAuthManager } from "../../tests/__mocks__/OAuthManager"; import { adminMock, calendarMock, setCredentialsMock } from "./__mocks__/googleapis"; -import { expect, describe, test, beforeEach, vi } from "vitest"; +import { JWT } from "googleapis-common"; +import { expect, test, beforeEach, vi, describe } from "vitest"; import "vitest-fetch-mock"; import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; +import type { CredentialForCalendarService } from "@calcom/types/Credential"; import CalendarService from "./CalendarService"; +import { getGoogleAppKeys } from "./getGoogleAppKeys"; vi.stubEnv("GOOGLE_WEBHOOK_TOKEN", "test-webhook-token"); +interface MockJWT { + type: "jwt"; + config: { + email: string; + key: string; + scopes: string[]; + subject: string; + }; + authorize: () => Promise; +} + +interface MockOAuth2Client { + type: "oauth2"; + args: [string, string, string]; + setCredentials: typeof setCredentialsMock; +} + +let lastCreatedJWT: MockJWT | null = null; +let lastCreatedOAuth2Client: MockOAuth2Client | null = null; + vi.mock("@calcom/features/flags/server/utils", () => ({ getFeatureFlag: vi.fn().mockReturnValue(true), })); @@ -24,11 +47,28 @@ vi.mock("./getGoogleAppKeys", () => ({ }), })); -vi.mock("googleapis-common", () => ({ - OAuth2Client: vi.fn().mockImplementation(() => ({ - setCredentials: setCredentialsMock, - })), -})); +vi.mock("googleapis-common", async () => { + const actual = await vi.importActual("googleapis-common"); + return { + ...actual, + OAuth2Client: vi.fn().mockImplementation((...args: [string, string, string]) => { + lastCreatedOAuth2Client = { + type: "oauth2", + args, + setCredentials: setCredentialsMock, + }; + return lastCreatedOAuth2Client; + }), + JWT: vi.fn().mockImplementation((config: MockJWT["config"]) => { + lastCreatedJWT = { + type: "jwt", + config, + authorize: vi.fn().mockResolvedValue(undefined), + }; + return lastCreatedJWT; + }), + }; +}); vi.mock("@googleapis/admin", () => adminMock); vi.mock("@googleapis/calendar", () => calendarMock); @@ -144,14 +184,15 @@ test("Calendar Cache is being read on cache HIT", async () => { // Create cache const calendarCache = await CalendarCache.init(null); - await calendarCache.upsertCachedAvailability( - credentialInDb1.id, - { + await calendarCache.upsertCachedAvailability({ + credentialId: credentialInDb1.id, + userId: credentialInDb1.userId, + args: { timeMin: dateFrom1, timeMax: dateTo1, items: [{ id: testSelectedCalendar.externalId }], }, - JSON.parse( + value: JSON.parse( JSON.stringify({ calendars: [ { @@ -164,8 +205,8 @@ test("Calendar Cache is being read on cache HIT", async () => { }, ], }) - ) - ); + ), + }); oAuthManagerMock.OAuthManager = defaultMockOAuthManager; const calendarService = new CalendarService(credentialInDb1); @@ -563,10 +604,16 @@ test("`updateTokenObject` should update credential in DB as well as myGoogleAuth expect(setCredentialsMock).toHaveBeenCalledWith(newTokenObject); }); -async function createCredentialInDb() { - const user = await prismock.user.create({ +async function createCredentialInDb({ + user = undefined, + delegatedTo = null, +}: { + user?: { email: string | null }; + delegatedTo?: NonNullable | null; +} = {}): Promise { + const defaultUser = await prismock.user.create({ data: { - email: "", + email: user?.email ?? "", }, }); @@ -590,7 +637,7 @@ async function createCredentialInDb() { ...credential, user: { connect: { - id: user.id, + id: defaultUser.id, }, }, app: { @@ -604,5 +651,147 @@ async function createCredentialInDb() { }, }); - return credentialInDb; + return { + ...credentialInDb, + delegatedTo: delegatedTo ?? null, + user: user ? { email: user.email ?? "" } : null, + } as CredentialForCalendarService; } + +describe("GoogleCalendarService credential handling", () => { + beforeEach(() => { + lastCreatedJWT = null; + lastCreatedOAuth2Client = null; + }); + + const delegatedCredential = { + serviceAccountKey: { + client_email: "service@example.com", + client_id: "service-client-id", + private_key: "service-private-key", + }, + } as const; + + test("uses JWT auth with impersonation when DWD credential is provided", async () => { + const credentialWithDWD = await createCredentialInDb({ + user: { email: "user@example.com" }, + delegatedTo: delegatedCredential, + }); + + const calendarService = new CalendarService(credentialWithDWD); + await calendarService.listCalendars(); + + const expectedJWTConfig: MockJWT = { + type: "jwt", + config: { + email: delegatedCredential.serviceAccountKey.client_email, + key: delegatedCredential.serviceAccountKey.private_key, + scopes: ["https://www.googleapis.com/auth/calendar"], + subject: "user@example.com", + }, + authorize: expect.any(Function) as () => Promise, + }; + + expect(lastCreatedJWT).toEqual(expectedJWTConfig); + + expect(calendarMock.calendar_v3.Calendar).toHaveBeenCalledWith({ + auth: lastCreatedJWT, + }); + }); + + test("uses OAuth2 auth when no DWD credential is provided", async () => { + const regularCredential = await createCredentialInDb(); + const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys(); + + const calendarService = new CalendarService(regularCredential); + await calendarService.listCalendars(); + + expect(lastCreatedJWT).toBeNull(); + + const expectedOAuth2Client: MockOAuth2Client = { + type: "oauth2", + args: [client_id, client_secret, redirect_uris[0]], + setCredentials: setCredentialsMock, + }; + + expect(lastCreatedOAuth2Client).toEqual(expectedOAuth2Client); + + expect(setCredentialsMock).toHaveBeenCalledWith(regularCredential.key); + + expect(calendarMock.calendar_v3.Calendar).toHaveBeenCalledWith({ + auth: lastCreatedOAuth2Client, + }); + }); + + test("handles DWD authorization errors appropriately", async () => { + const credentialWithDWD = await createCredentialInDb({ + user: { email: "user@example.com" }, + delegatedTo: delegatedCredential, + }); + + const mockJWTInstance = { + type: "jwt", + config: { + email: delegatedCredential.serviceAccountKey.client_email, + key: delegatedCredential.serviceAccountKey.private_key, + scopes: ["https://www.googleapis.com/auth/calendar"], + subject: "user@example.com", + }, + authorize: vi.fn().mockRejectedValue({ + response: { + data: { + error: "unauthorized_client", + }, + }, + }), + createScoped: vi.fn(), + getRequestMetadataAsync: vi.fn(), + fetchIdToken: vi.fn(), + hasUserScopes: vi.fn(), + getAccessToken: vi.fn(), + getRefreshToken: vi.fn(), + getTokenInfo: vi.fn(), + refreshAccessToken: vi.fn(), + revokeCredentials: vi.fn(), + revokeToken: vi.fn(), + verifyIdToken: vi.fn(), + on: vi.fn(), + setCredentials: vi.fn(), + getCredentials: vi.fn(), + hasAnyScopes: vi.fn(), + authorizeAsync: vi.fn(), + refreshTokenNoCache: vi.fn(), + createGToken: vi.fn(), + }; + + vi.mocked(JWT).mockImplementation(() => mockJWTInstance as unknown as JWT); + + const calendarService = new CalendarService(credentialWithDWD); + + await expect(calendarService.listCalendars()).rejects.toThrow( + "Make sure that the Client ID for the domain wide delegation is added to the Google Workspace Admin Console" + ); + }); + + test("handles missing user email for DWD appropriately", async () => { + const credentialWithDWD = await createCredentialInDb({ + user: { email: null }, + delegatedTo: delegatedCredential, + }); + + const calendarService = new CalendarService(credentialWithDWD); + const { client_id, client_secret, redirect_uris } = await getGoogleAppKeys(); + + await calendarService.listCalendars(); + + expect(lastCreatedJWT).toBeNull(); + + const expectedOAuth2Client: MockOAuth2Client = { + type: "oauth2", + args: [client_id, client_secret, redirect_uris[0]], + setCredentials: setCredentialsMock, + }; + + expect(lastCreatedOAuth2Client).toEqual(expectedOAuth2Client); + }); +}); diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index f2da281f377151..f45d71ea5139f2 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { calendar_v3 } from "@googleapis/calendar"; import type { Prisma } from "@prisma/client"; +import { OAuth2Client, JWT } from "googleapis-common"; import type { GaxiosResponse } from "googleapis-common"; -import { OAuth2Client } from "googleapis-common"; import { RRule } from "rrule"; import { v4 as uuid } from "uuid"; @@ -12,6 +12,11 @@ import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache"; import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cache.repository.interface"; import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache"; import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; +import { + CalendarAppDomainWideDelegationClientIdNotAuthorizedError, + CalendarAppDomainWideDelegationInvalidGrantError, + CalendarAppDomainWideDelegationError, +} from "@calcom/lib/CalendarAppError"; import { uniqueBy } from "@calcom/lib/array"; import { APP_CREDENTIAL_SHARING_ENABLED, @@ -19,6 +24,7 @@ import { CREDENTIAL_SYNC_SECRET, CREDENTIAL_SYNC_SECRET_HEADER_NAME, } from "@calcom/lib/constants"; +import { isDomainWideDelegationCredential } from "@calcom/lib/domainWideDelegation/clientAndServer"; import { formatCalEvent } from "@calcom/lib/formatCalendarEvent"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; @@ -32,7 +38,7 @@ import type { NewCalendarEventType, } from "@calcom/types/Calendar"; import type { SelectedCalendarEventTypeIds } from "@calcom/types/Calendar"; -import type { CredentialPayload } from "@calcom/types/Credential"; +import type { CredentialForCalendarService } from "@calcom/types/Credential"; import { invalidateCredential } from "../../_utils/invalidateCredential"; import { AxiosLikeResponseToFetchResponse } from "../../_utils/oauth/AxiosLikeResponseToFetchResponse"; @@ -66,14 +72,38 @@ type GoogleChannelProps = { expiration?: string | null; }; +const getWhereForSelectedCalendar = ({ + credentialId, + userId, +}: { + credentialId: number; + userId: number | null; +}) => { + let where; + if (isDomainWideDelegationCredential({ credentialId })) { + if (!userId) { + console.error("Skipping querying calendar as `userId` is missing, needed for DWD"); + return; + } + where = { + userId, + }; + } else { + where = { + credentialId, + }; + } + return where; +}; + export default class GoogleCalendarService implements Calendar { private integrationName = ""; private auth: ReturnType; private log: typeof logger; - private credential: CredentialPayload; + private credential: CredentialForCalendarService; private myGoogleAuth!: MyGoogleAuth; private oAuthManagerInstance!: OAuthManager; - constructor(credential: CredentialPayload) { + constructor(credential: CredentialForCalendarService) { this.integrationName = "google_calendar"; this.credential = credential; this.auth = this.initGoogleAuth(credential); @@ -91,7 +121,7 @@ export default class GoogleCalendarService implements Calendar { return this.myGoogleAuth; } - private initGoogleAuth = (credential: CredentialPayload) => { + private initGoogleAuth = (credential: CredentialForCalendarService) => { const currentTokenObject = getTokenObjectFromCredential(credential); const auth = new OAuthManager({ // Keep it false because we are not using auth.request everywhere. That would be done later as it involves many google calendar sdk functionc calls and needs to be tested well. @@ -175,7 +205,83 @@ export default class GoogleCalendarService implements Calendar { }; }; + private getAuthedCalendarFromDwd = async ({ + domainWideDelegation, + emailToImpersonate, + }: { + emailToImpersonate: string; + domainWideDelegation: { + serviceAccountKey: { + client_email: string; + client_id: string; + private_key: string; + }; + }; + }) => { + const serviceAccountClientEmail = domainWideDelegation.serviceAccountKey.client_email; + const serviceAccountClientId = domainWideDelegation.serviceAccountKey.client_id; + const serviceAccountPrivateKey = domainWideDelegation.serviceAccountKey.private_key; + + const authClient = new JWT({ + email: serviceAccountClientEmail, + key: serviceAccountPrivateKey, + scopes: ["https://www.googleapis.com/auth/calendar"], + subject: emailToImpersonate, + }); + + try { + await authClient.authorize(); + } catch (error) { + this.log.error("Error authorizing domain wide delegation", JSON.stringify(error)); + + if ((error as any).response?.data?.error === "unauthorized_client") { + throw new CalendarAppDomainWideDelegationClientIdNotAuthorizedError( + "Make sure that the Client ID for the domain wide delegation is added to the Google Workspace Admin Console" + ); + } + + if ((error as any).response?.data?.error === "invalid_grant") { + throw new CalendarAppDomainWideDelegationInvalidGrantError( + "User might not exist in Google Workspace" + ); + } + + // Catch all error + throw new CalendarAppDomainWideDelegationError("Error authorizing domain wide delegation"); + } + + this.log.debug( + "Using domain wide delegation with service account email", + safeStringify({ + serviceAccountClientEmail, + serviceAccountClientId, + emailToImpersonate, + }) + ); + + return new calendar_v3.Calendar({ + auth: authClient, + }); + }; + public authedCalendar = async () => { + let dwdAuthedCalendar; + + if (this.credential.delegatedTo) { + if (!this.credential.user?.email) { + this.log.error("No email to impersonate found for domain wide delegation"); + } else { + dwdAuthedCalendar = await this.getAuthedCalendarFromDwd({ + domainWideDelegation: this.credential.delegatedTo, + emailToImpersonate: this.credential.user.email, + }); + } + } + + if (dwdAuthedCalendar) { + return dwdAuthedCalendar; + } + const myGoogleAuth = await this.auth.getMyGoogleAuthWithRefreshedToken(); const calendar = new calendar_v3.Calendar({ auth: myGoogleAuth, @@ -269,7 +375,11 @@ export default class GoogleCalendarService implements Calendar { return res.data; } - async createEvent(calEventRaw: CalendarEvent, credentialId: number): Promise { + async createEvent( + calEventRaw: CalendarEvent, + credentialId: number, + overrideExternalIdForDelegatedCredential?: string + ): Promise { this.log.debug("Creating event"); const formattedCalEvent = formatCalEvent(calEventRaw); @@ -318,8 +428,9 @@ export default class GoogleCalendarService implements Calendar { // Find in formattedCalEvent.destinationCalendar the one with the same credentialId const selectedCalendar = - formattedCalEvent.destinationCalendar?.find((cal) => cal.credentialId === credentialId)?.externalId || - "primary"; + overrideExternalIdForDelegatedCredential ?? + (formattedCalEvent.destinationCalendar?.find((cal) => cal.credentialId === credentialId)?.externalId || + "primary"); try { let event: calendar_v3.Schema$Event | undefined; @@ -772,6 +883,13 @@ export default class GoogleCalendarService implements Calendar { } } + // It would error if the domain wide delegation is not set up correctly + async testDomainWideDelegationSetup() { + const calendar = await this.authedCalendar(); + const cals = await calendar.calendarList.list({ fields: "items(id)" }); + return !!cals.data.items; + } + /** * It doesn't check if the subscription has expired or not. * It just creates a new subscription. @@ -783,14 +901,20 @@ 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; } + const where = getWhereForSelectedCalendar({ + credentialId: this.credential.id, + userId: this.credential.userId, + }); + const allCalendarsWithSubscription = await SelectedCalendarRepository.findMany({ where: { - credentialId: this.credential.id, + ...where, externalId: calendarId, integration: this.integrationName, googleChannelId: { @@ -855,13 +979,16 @@ export default class GoogleCalendarService implements Calendar { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds; }) { + log.debug("unwatchCalendar", safeStringify({ calendarId, eventTypeIds })); const credentialId = this.credential.id; const eventTypeIdsToBeUnwatched = eventTypeIds; + const where = getWhereForSelectedCalendar({ + credentialId, + userId: this.credential.userId, + }); const calendarsWithSameCredentialId = await SelectedCalendarRepository.findMany({ - where: { - credentialId, - }, + where, }); const calendarWithSameExternalId = calendarsWithSameCredentialId.filter( @@ -909,6 +1036,8 @@ export default class GoogleCalendarService implements Calendar { // Delete the calendar cache to force a fresh cache await prisma.calendarCache.deleteMany({ where: { credentialId } }); + // Helps in identifying the caches created for DWD credential which doesn't have a valid credentialId + await prisma.calendarCache.deleteMany({ where: { userId: this.credential.userId } }); await this.stopWatchingCalendarsInGoogle(allChannelsForThisCalendarBeingUnwatched); await this.upsertSelectedCalendarsForEventTypeIds( { @@ -932,10 +1061,16 @@ export default class GoogleCalendarService implements Calendar { async setAvailabilityInCache(args: FreeBusyArgs, data: calendar_v3.Schema$FreeBusyResponse): Promise { 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[] diff --git a/packages/app-store/googlecalendar/tests/google-calendar.e2e.ts b/packages/app-store/googlecalendar/tests/google-calendar.e2e.ts index 5c3824316188df..8fb974437e90f9 100644 --- a/packages/app-store/googlecalendar/tests/google-calendar.e2e.ts +++ b/packages/app-store/googlecalendar/tests/google-calendar.e2e.ts @@ -4,7 +4,7 @@ import type { Page } from "@playwright/test"; import dayjs from "@calcom/dayjs"; import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; import prisma from "@calcom/prisma"; -import type { CredentialPayload } from "@calcom/types/Credential"; +import type { CredentialForCalendarService } from "@calcom/types/Credential"; import { test } from "@calcom/web/playwright/lib/fixtures"; import { selectSecondAvailableTimeSlotNextMonth } from "@calcom/web/playwright/lib/testUtils"; @@ -17,7 +17,7 @@ test.describe("Google Calendar", async () => { // eslint-disable-next-line playwright/no-skipped-test test.describe.skip("Test using the primary calendar", async () => { let qaUsername: string; - let qaGCalCredential: CredentialPayload; + let qaGCalCredential: CredentialForCalendarService; test.beforeAll(async () => { let runIntegrationTest = false; const errorMessage = "Could not run test"; @@ -42,22 +42,25 @@ test.describe("Google Calendar", async () => { test.skip(!process.env.E2E_TEST_CALCOM_QA_PASSWORD, "QA password not found"); if (process.env.E2E_TEST_CALCOM_QA_EMAIL && process.env.E2E_TEST_CALCOM_QA_PASSWORD) { - qaGCalCredential = await prisma.credential.findFirstOrThrow({ - where: { - user: { - email: process.env.E2E_TEST_CALCOM_QA_EMAIL, + qaGCalCredential = { + ...(await prisma.credential.findFirstOrThrow({ + where: { + user: { + email: process.env.E2E_TEST_CALCOM_QA_EMAIL, + }, + type: metadata.type, }, - type: metadata.type, - }, - include: { - user: { - select: { - email: true, + include: { + user: { + select: { + email: true, + }, }, }, - }, - }); - test.skip(!qaGCalCredential, "Google QA credential not found"); + })), + delegatedTo: null, + } as CredentialForCalendarService; + test.skip(!qaGCalCredential?.id, "Google QA credential not found"); const qaUserQuery = await prisma.user.findFirstOrThrow({ where: { diff --git a/packages/app-store/server.ts b/packages/app-store/server.ts index f6ee92e4156284..8af39be47c8c17 100644 --- a/packages/app-store/server.ts +++ b/packages/app-store/server.ts @@ -3,6 +3,7 @@ import type { TFunction } from "next-i18next"; import { defaultVideoAppCategories } from "@calcom/app-store/utils"; import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials"; +import { getAllDomainWideDelegationConferencingCredentialsForUser } from "@calcom/lib/domainWideDelegation/server"; import { prisma } from "@calcom/prisma"; import { AppCategories } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; @@ -27,7 +28,7 @@ export async function getLocationGroupedOptions( // don't default to {}, when you do TS no longer determines the right types. let idToSearchObject: Prisma.CredentialWhereInput; - + let user = null; if ("teamId" in userOrTeamId) { const teamId = userOrTeamId.teamId; // See if the team event belongs to an org @@ -52,9 +53,14 @@ export async function getLocationGroupedOptions( } } else { idToSearchObject = { userId: userOrTeamId.userId }; + user = await prisma.user.findFirst({ + where: { + id: userOrTeamId.userId, + }, + }); } - const credentials = await prisma.credential.findMany({ + let credentials = await prisma.credential.findMany({ where: { ...idToSearchObject, app: { @@ -73,6 +79,15 @@ export async function getLocationGroupedOptions( }, }); + if (user) { + const domainWideDelegationCredentials = await getAllDomainWideDelegationConferencingCredentialsForUser({ + user, + }); + + // We only add dwd credentials if the request for location options is for a user because DWD Credential is applicable to Users only. + credentials = [...credentials, ...domainWideDelegationCredentials]; + } + const integrations = await getEnabledAppsFromCredentials(credentials, { filterOnCredentials: true }); integrations.forEach((app) => { diff --git a/packages/app-store/tsconfig.json b/packages/app-store/tsconfig.json index 8e9faeb56eb3a5..8c2e0fb50a97be 100644 --- a/packages/app-store/tsconfig.json +++ b/packages/app-store/tsconfig.json @@ -8,7 +8,7 @@ "@components/*": ["../../apps/web/components/*"], /* A `package` should never import from `apps` ↓ */ "@lib/*": ["../../apps/web/lib/*"], - "@prisma/client/*": ["@calcom/prisma/client/*"] + "@prisma/client/*": ["@calcom/prisma/client/*"], }, "resolveJsonModule": true, "experimentalDecorators": true diff --git a/packages/app-store/utils.ts b/packages/app-store/utils.ts index 3d4e92462429d2..84ed6fb0c92d25 100644 --- a/packages/app-store/utils.ts +++ b/packages/app-store/utils.ts @@ -8,7 +8,7 @@ import logger from "@calcom/lib/logger"; import { getPiiFreeCredential } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; import type { App, AppMeta } from "@calcom/types/App"; -import type { CredentialPayload } from "@calcom/types/Credential"; +import type { CredentialPayload, CredentialForCalendarService } from "@calcom/types/Credential"; export * from "./_utils/getEventTypeAppData"; @@ -39,6 +39,12 @@ export type CredentialDataWithTeamName = CredentialPayload & { } | null; }; +type CredentialForCalendarServiceWithTeamName = CredentialForCalendarService & { + team?: { + name: string; + } | null; +}; + export const ALL_APPS = Object.values(ALL_APPS_MAP); /** diff --git a/packages/core/CalendarManager.test.ts b/packages/core/CalendarManager.test.ts index 2ec7ce4153d042..089dc78528b6e5 100644 --- a/packages/core/CalendarManager.test.ts +++ b/packages/core/CalendarManager.test.ts @@ -4,7 +4,7 @@ import { getCalendarCredentials } from "./CalendarManager"; describe("CalendarManager tests", () => { describe("fn: getCalendarCredentials", () => { - it("should only return credentials for calendar apps", () => { + it("should only return credentials for calendar apps", async () => { const googleCalendarCredentials = { id: "1", appId: "google-calendar", @@ -14,6 +14,7 @@ describe("CalendarManager tests", () => { access_token: "google_calendar_key", }, invalid: false, + delegatedTo: null, }; const credentials = [ @@ -30,9 +31,9 @@ describe("CalendarManager tests", () => { }, ]; - const calendarCredentials = getCalendarCredentials(credentials); + const calendarCredentials = await getCalendarCredentials(credentials); expect(calendarCredentials).toHaveLength(1); - expect(calendarCredentials[0].credential).toBe(googleCalendarCredentials); + expect(calendarCredentials[0].credential).toEqual(googleCalendarCredentials); }); }); }); diff --git a/packages/core/CalendarManager.ts b/packages/core/CalendarManager.ts index cf2a981137e129..b74ebcaf01fe21 100644 --- a/packages/core/CalendarManager.ts +++ b/packages/core/CalendarManager.ts @@ -5,9 +5,12 @@ import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import getApps from "@calcom/app-store/utils"; import dayjs from "@calcom/dayjs"; import { getUid } from "@calcom/lib/CalEventParser"; +import { CalendarAppDomainWideDelegationError } from "@calcom/lib/CalendarAppError"; import logger from "@calcom/lib/logger"; import { getPiiFreeCalendarEvent, getPiiFreeCredential } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; +import type { ServiceAccountKey } from "@calcom/lib/server/repository/domainWideDelegation"; +import { DomainWideDelegationRepository } from "@calcom/lib/server/repository/domainWideDelegation"; import type { CalendarEvent, EventBusyDate, @@ -21,25 +24,132 @@ import type { EventResult } from "@calcom/types/EventManager"; import getCalendarsEvents from "./getCalendarsEvents"; import { getCalendarsEventsWithTimezones } from "./getCalendarsEvents"; +type CredentialForCalendarService = T extends null + ? null + : T & { + delegatedTo: { + serviceAccountKey: { + client_email: string; + private_key: string; + client_id: string; + }; + } | null; + }; + const log = logger.getSubLogger({ prefix: ["CalendarManager"] }); -export const getCalendarCredentials = (credentials: Array) => { - const calendarCredentials = getApps(credentials, true) - .filter((app) => app.type.endsWith("_calendar")) - .flatMap((app) => { - const credentials = app.credentials.flatMap((credential) => { - const calendar = getCalendar(credential); - return app.variant === "calendar" ? [{ integration: app, credential, calendar }] : []; - }); +function _buildDelegatedTo({ + domainWideDelegation, +}: { + domainWideDelegation: { + serviceAccountKey: ServiceAccountKey | null; + } | null; +}) { + if (!domainWideDelegation || !domainWideDelegation.serviceAccountKey) { + return null; + } + + return { + serviceAccountKey: domainWideDelegation.serviceAccountKey, + }; +} + +async function _getCredentialsWithAppAndTheirDwdMap(credentials: Array) { + const calendarApps = getApps(credentials, true).filter((app) => app.type.endsWith("_calendar")); + const credentialsWithApp = calendarApps.flatMap((app) => { + return app.credentials.map((credential) => ({ + credential, + app, + })); + }); - return credentials.length ? credentials : []; - }); + const delegatedToIds = _getDwdIds(credentialsWithApp); + const domainWideDelegations = + await DomainWideDelegationRepository.findByIdsIncludeSensitiveServiceAccountKey(delegatedToIds); + + const dwdMap = new Map(domainWideDelegations.map((d) => [d.id, d])); + + return { + credentialsWithApp, + dwdMap, + }; + + function _getDwdIds(_credentialsWithApp: typeof credentialsWithApp) { + return Array.from( + new Set( + credentialsWithApp + .filter( + ( + credentialWithApp + ): credentialWithApp is typeof credentialWithApp & { + credential: typeof credentialWithApp.credential & { delegatedToId: string }; + } => !!credentialWithApp.credential.delegatedToId + ) + .map(({ credential }) => credential.delegatedToId) + ) + ); + } +} + +/** + * CalendarService needs delegatedTo to have serviceAccountKey for DWD Credential. It fetches that. + */ +export async function getCredentialForCalendarService< + T extends ({ delegatedToId?: string | null } & Record) | null +>(credential: T): Promise> { + // Explicitly handle null case with type assertion + if (credential === null) return null as CredentialForCalendarService; + + // When no delegatedToId, return with delegatedTo as null + if (!credential.delegatedToId) { + return { + ...credential, + delegatedTo: null, + } as CredentialForCalendarService; + } + const domainWideDelegation = await DomainWideDelegationRepository.findByIdIncludeSensitiveServiceAccountKey( + { + id: credential.delegatedToId, + } + ); + + const delegatedTo = _buildDelegatedTo({ + domainWideDelegation, + }); + + return { + ...credential, + delegatedTo, + } as CredentialForCalendarService; +} + +// TODO: Should unit test it. +export const getCalendarCredentials = async (credentials: Array) => { + const { credentialsWithApp, dwdMap } = await _getCredentialsWithAppAndTheirDwdMap(credentials); + + const calendarCredentials = credentialsWithApp.flatMap(({ credential, app }) => { + const domainWideDelegation = credential.delegatedToId + ? dwdMap.get(credential.delegatedToId) || null + : null; + + const credentialForCalendarService = { + ...credential, + delegatedTo: _buildDelegatedTo({ + domainWideDelegation, + }), + }; + + const calendar = getCalendar(credentialForCalendarService); + return app.variant === "calendar" + ? [{ integration: app, credential: credentialForCalendarService, calendar }] + : []; + }); return calendarCredentials; }; export const getConnectedCalendars = async ( - calendarCredentials: ReturnType, + calendarCredentials: Awaited>, selectedCalendars: { externalId: string }[], destinationCalendarExternalId?: string ) => { @@ -51,10 +161,12 @@ export const getConnectedCalendars = async ( const calendar = await item.calendar; // Don't leak credentials to the client const credentialId = credential.id; + const domainWideDelegationCredentialId = credential.delegatedToId ?? null; if (!calendar) { return { integration, credentialId, + domainWideDelegationCredentialId, }; } const cals = await calendar.listCalendars(); @@ -67,6 +179,7 @@ export const getConnectedCalendars = async ( primary: cal.primary || null, isSelected: selectedCalendars.some((selected) => selected.externalId === cal.externalId), credentialId, + domainWideDelegationCredentialId, }; }), ["primary"] @@ -91,6 +204,7 @@ export const getConnectedCalendars = async ( return { integration: cleanIntegrationKeys(integration), credentialId, + domainWideDelegationCredentialId, primary, calendars, }; @@ -104,11 +218,16 @@ export const getConnectedCalendars = async ( } } + if (error instanceof CalendarAppDomainWideDelegationError) { + errorMessage = error.message; + } + log.error("getConnectedCalendars failed", safeStringify(error), safeStringify({ item })); return { integration: cleanIntegrationKeys(item.integration), credentialId: item.credential.id, + domainWideDelegationCredentialId: item.credential.delegatedToId, error: { message: errorMessage, }, @@ -126,7 +245,7 @@ export const getConnectedCalendars = async ( * @returns App */ const cleanIntegrationKeys = ( - appIntegration: ReturnType[number]["integration"] & { + appIntegration: Awaited>[number]["integration"] & { credentials?: Array; credential: CredentialPayload; } @@ -136,24 +255,11 @@ const cleanIntegrationKeys = ( return rest; }; -/** - * Get months between given dates - * @returns ["2023-04", "2024-05"] - */ -const getMonths = (dateFrom: string, dateTo: string): string[] => { - const months: string[] = [dayjs(dateFrom).format("YYYY-MM")]; - for ( - let i = 1; - dayjs(dateFrom).add(i, "month").isBefore(dateTo) || - dayjs(dateFrom).add(i, "month").isSame(dateTo, "month"); - i++ - ) { - months.push(dayjs(dateFrom).add(i, "month").format("YYYY-MM")); - } - return months; -}; - export const getBusyCalendarTimes = async ( + /** + * withCredentials can possibly have a duplicate credential in case DWD 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: CredentialPayload[], dateFrom: string, dateTo: string, @@ -192,7 +298,8 @@ export const createEvent = async ( externalId?: string ): Promise> => { const uid: string = getUid(calEvent); - const calendar = await getCalendar(credential); + const credentialForCalendarService = await getCredentialForCalendarService(credential); + const calendar = await getCalendar(credentialForCalendarService); let success = true; let calError: string | undefined = undefined; @@ -207,10 +314,12 @@ export const createEvent = async ( calEvent.additionalNotes = "Notes have been hidden by the organizer"; // TODO: i18n this string? } + const overrideExternalIdForDelegatedCredential = credential.delegatedToId ? externalId : undefined; + // TODO: Surface success/error messages coming from apps to improve end user visibility const creationResult = calendar ? await calendar - .createEvent(calEvent, credential.id) + .createEvent(calEvent, credential.id, overrideExternalIdForDelegatedCredential) .catch(async (error: { code: number; calError: string }) => { success = false; /** @@ -225,7 +334,8 @@ export const createEvent = async ( } log.error( "createEvent failed", - safeStringify({ error, calEvent: getPiiFreeCalendarEvent(calEvent) }) + safeStringify(error), + safeStringify({ calEvent: getPiiFreeCalendarEvent(calEvent) }) ); // @TODO: This code will be off till we can investigate an error with it //https://github.com/calcom/cal.com/issues/3949 @@ -264,6 +374,7 @@ export const createEvent = async ( calWarnings: creationResult?.additionalInfo?.calWarnings || [], externalId, credentialId: credential.id, + delegatedToId: credential.delegatedToId ?? undefined, }; }; @@ -274,7 +385,8 @@ export const updateEvent = async ( externalCalendarId: string | null ): Promise> => { const uid = getUid(calEvent); - const calendar = await getCalendar(credential); + const credentialForCalendarService = await getCredentialForCalendarService(credential); + const calendar = await getCalendar(credentialForCalendarService); let success = false; let calError: string | undefined = undefined; let calWarnings: string[] | undefined = []; @@ -357,7 +469,8 @@ export const deleteEvent = async ({ event: CalendarEvent; externalCalendarId?: string | null; }): Promise => { - const calendar = await getCalendar(credential); + const credentialForCalendarService = await getCredentialForCalendarService(credential); + const calendar = await getCalendar(credentialForCalendarService); log.debug( "Deleting calendar event", safeStringify({ diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index 1af0d6de314230..70c3bfb97ae034 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -18,6 +18,7 @@ import { getPiiFreeCalendarEvent, } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { CredentialRepository } from "@calcom/lib/server/repository/credential"; import prisma from "@calcom/prisma"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { createdEventSchema } from "@calcom/prisma/zod-utils"; @@ -45,17 +46,18 @@ interface HasId { id: number; } -// The options should have the slug of the apps the option is enabled for -interface AppOptions { - crm: { - skipContactCreation: string[]; - }; -} - const latestCredentialFirst = (a: T, b: T) => { return b.id - a.id; }; +const delegatedCredentialFirst = (a: T, b: T) => { + return (b.delegatedToId ? 1 : 0) - (a.delegatedToId ? 1 : 0); +}; + +const delegatedCredentialLast = (a: T, b: T) => { + return (a.delegatedToId ? 1 : 0) - (b.delegatedToId ? 1 : 0); +}; + export const getLocationRequestFromIntegration = (location: string) => { const eventLocationType = getLocationFromApp(location); if (eventLocationType) { @@ -106,7 +108,6 @@ export default class EventManager { videoCredentials: CredentialPayload[]; crmCredentials: CredentialPayload[]; appOptions?: z.infer; - /** * Takes an array of credentials and initializes a new instance of the EventManager. * @@ -123,8 +124,14 @@ export default class EventManager { // Backwards compatibility until CRM manager is implemented (cred) => cred.type.endsWith("_calendar") && !cred.type.includes("other_calendar") ) - //see https://github.com/calcom/cal.com/issues/11671#issue-1923600672 - .sort(latestCredentialFirst); + // 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 DWD is released and stable, we switch it. Could be an env variable also to toggle this. + .sort(delegatedCredentialLast); + this.videoCredentials = appCredentials .filter((cred) => cred.type.endsWith("_video") || cred.type.endsWith("_conferencing")) // Whenever a new video connection is added, latest credentials are added with the highest ID. @@ -238,7 +245,11 @@ export default class EventManager { meetingPassword: createdEventObj ? createdEventObj.password : result.createdEvent?.password, meetingUrl: createdEventObj ? createdEventObj.onlineMeetingUrl : result.createdEvent?.url, externalCalendarId: isCalendarType ? result.externalId : undefined, - credentialId: result?.credentialId || undefined, + credentialId: + result?.credentialId === -1 || result?.credentialId === 0 + ? undefined + : result?.credentialId ?? undefined, + domainWideDelegationCredentialId: result?.delegatedToId || undefined, }; }); @@ -328,8 +339,10 @@ export default class EventManager { const calendarCredential = await this.getCredentialAndWarnIfNotFound( credentialId, this.calendarCredentials, - credentialType + credentialType, + reference.domainWideDelegationCredentialId ); + if (calendarCredential) { await deleteEvent({ credential: calendarCredential, @@ -358,8 +371,12 @@ export default class EventManager { private async getCredentialAndWarnIfNotFound( credentialId: number | null | undefined, credentials: CredentialPayload[], - type: string + type: string, + domainWideDelegationCredentialId?: string | null ) { + if (domainWideDelegationCredentialId) { + return this.calendarCredentials.find((cred) => cred.delegatedToId === domainWideDelegationCredentialId); + } const credential = credentials.find((cred) => cred.id === credentialId); if (credential) { return credential; @@ -633,7 +650,10 @@ export default class EventManager { const [credential] = this.calendarCredentials.filter((cred) => !cred.type.endsWith("other_calendar")); if (credential) { const createdEvent = await createEvent(credential, event); - log.silly("Created Calendar event", safeStringify({ createdEvent })); + log.silly( + "Created Calendar event using credential", + safeStringify({ credentialId: credential.id, createdEvent }) + ); if (createdEvent) { createdEvents.push(createdEvent); } @@ -663,27 +683,40 @@ export default class EventManager { for (const destination of destinationCalendars) { if (eventCreated) break; log.silly("Creating Calendar event", JSON.stringify({ destination })); - if (destination.credentialId) { - let credential = this.calendarCredentials.find((c) => c.id === destination.credentialId); + if (destination.credentialId || destination.domainWideDelegationCredentialId) { + let credential = destination.domainWideDelegationCredentialId + ? this.calendarCredentials.find( + (c) => c.delegatedToId === destination.domainWideDelegationCredentialId + ) + : this.calendarCredentials.find((c) => c.id === destination.credentialId); if (!credential) { - // Fetch credential from DB - const credentialFromDB = await prisma.credential.findUnique({ - where: { + if (destination.credentialId) { + // Fetch credential from DB + const credentialFromDB = await CredentialRepository.findCredentialForCalendarServiceById({ id: destination.credentialId, - }, - select: credentialForCalendarServiceSelect, - }); - if (credentialFromDB && credentialFromDB.appId) { - credential = { - id: credentialFromDB.id, - type: credentialFromDB.type, - key: credentialFromDB.key, - userId: credentialFromDB.userId, - teamId: credentialFromDB.teamId, - invalid: credentialFromDB.invalid, - appId: credentialFromDB.appId, - user: credentialFromDB.user, - }; + }); + + if (credentialFromDB && credentialFromDB.appId) { + credential = { + id: credentialFromDB.id, + type: credentialFromDB.type, + key: credentialFromDB.key, + userId: credentialFromDB.userId, + teamId: credentialFromDB.teamId, + invalid: credentialFromDB.invalid, + appId: credentialFromDB.appId, + user: credentialFromDB.user, + delegatedToId: credentialFromDB.delegatedToId, + }; + } + } else if (destination.domainWideDelegationCredentialId) { + console.warn("DWD is disabled, falling back to first non-dwd credential"); + // In case DWD is disabled, we land here where the destination calendar is connected to a DWD credential, but the credential isn't available(because DWD is disabled) + // In this case, we fallback to the first non-dwd credential. That would be there for all existing users before DWD was enabled + const firstNonDwdCalendarCredential = this.calendarCredentials.find( + (cred) => !cred.type.endsWith("other_calendar") && !cred.delegatedToId + ); + credential = firstNonDwdCalendarCredential; } } if (credential) { @@ -857,11 +890,8 @@ export default class EventManager { )[0]; if (!credential) { // Fetch credential from DB - const credentialFromDB = await prisma.credential.findUnique({ - where: { - id: reference.credentialId, - }, - select: credentialForCalendarServiceSelect, + const credentialFromDB = await CredentialRepository.findCredentialForCalendarServiceById({ + id: reference.credentialId, }); if (credentialFromDB && credentialFromDB.appId) { credential = { @@ -873,6 +903,7 @@ export default class EventManager { invalid: credentialFromDB.invalid, appId: credentialFromDB.appId, user: credentialFromDB.user, + delegatedToId: credentialFromDB.delegatedToId, }; } } @@ -892,13 +923,11 @@ export default class EventManager { const oldCalendarEvent = booking.references.find((reference) => reference.type.includes("_calendar")); if (oldCalendarEvent?.credentialId) { - const calendarCredential = await prisma.credential.findUnique({ - where: { + const credentialForCalendarService = + await CredentialRepository.findCredentialForCalendarServiceById({ id: oldCalendarEvent.credentialId, - }, - select: credentialForCalendarServiceSelect, - }); - const calendar = await getCalendar(calendarCredential); + }); + const calendar = await getCalendar(credentialForCalendarService); await calendar?.deleteEvent(oldCalendarEvent.uid, event, oldCalendarEvent.externalCalendarId); } } diff --git a/packages/core/getCalendarsEvents.ts b/packages/core/getCalendarsEvents.ts index a80755d5cb18a7..8ef65ffa103da9 100644 --- a/packages/core/getCalendarsEvents.ts +++ b/packages/core/getCalendarsEvents.ts @@ -6,6 +6,8 @@ import { performance } from "@calcom/lib/server/perfObserver"; import type { EventBusyDate, SelectedCalendar } from "@calcom/types/Calendar"; import type { CredentialPayload } from "@calcom/types/Credential"; +import { getCredentialForCalendarService } from "./CalendarManager"; + const log = logger.getSubLogger({ prefix: ["getCalendarsEvents"] }); // only for Google Calendar for now @@ -19,8 +21,15 @@ export const getCalendarsEventsWithTimezones = async ( .filter((credential) => credential.type === "google_calendar") // filter out invalid credentials - these won't work. .filter((credential) => !credential.invalid); + // There might not be many credentials as one credential means one Integration(like Google Calendar, Outlook Calendar, etc.) + // So, it is okay to do getCredentialForCalendarService for all of them, separately here + const credentialsForCalendarService = await Promise.all( + calendarCredentials.map((credential) => getCredentialForCalendarService(credential)) + ); - const calendars = await Promise.all(calendarCredentials.map((credential) => getCalendar(credential))); + const calendars = await Promise.all( + credentialsForCalendarService.map((credential) => getCalendar(credential)) + ); const results = calendars.map(async (c, i) => { /** Filter out nulls */ @@ -57,7 +66,15 @@ 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))); + // There might not be many credentials as one credential means one Integration(like Google Calendar, Outlook Calendar, etc.) + // So, it is okay to do getCredentialForCalendarService for all of them, separately here + const credentialsForCalendarService = await Promise.all( + calendarCredentials.map((credential) => getCredentialForCalendarService(credential)) + ); + + const calendars = await Promise.all( + credentialsForCalendarService.map((credential) => getCalendar(credential)) + ); performance.mark("getBusyCalendarTimesStart"); const results = calendars.map(async (c, i) => { /** Filter out nulls */ diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index 4baa35c80d964d..edf9f29ef5eaac 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -19,8 +19,8 @@ import { ErrorCode } from "@calcom/lib/errorCodes"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { findUsersForAvailabilityCheck } from "@calcom/lib/server/findUsersForAvailabilityCheck"; import { EventTypeRepository } from "@calcom/lib/server/repository/eventType"; -import { UserRepository } from "@calcom/lib/server/repository/user"; import prisma from "@calcom/prisma"; import { SchedulingType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; @@ -152,7 +152,7 @@ const getUser = async (...args: Parameters): Promise { - return UserRepository.findForAvailabilityCheck({ where }); + return findUsersForAvailabilityCheck({ where }); }; type GetUser = Awaited>; diff --git a/packages/features/apps/components/DisconnectIntegration.tsx b/packages/features/apps/components/DisconnectIntegration.tsx index bb3a720beb1112..4d76310e9130ff 100644 --- a/packages/features/apps/components/DisconnectIntegration.tsx +++ b/packages/features/apps/components/DisconnectIntegration.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; +import { isDomainWideDelegationCredential } from "@calcom/lib/domainWideDelegation/clientAndServer"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import type { ButtonProps } from "@calcom/ui"; @@ -36,11 +37,14 @@ export default function DisconnectIntegration(props: { }, }); + // Such a credential is added in-memory and removed when Domain-wide delegation is disabled. + const disableDisconnect = isDomainWideDelegationCredential({ credentialId }); return ( mutation.mutate({ id: credentialId })} isModalOpen={modalOpen} onModalOpen={() => setModalOpen((prevValue) => !prevValue)} + disabled={disableDisconnect} {...props} /> ); diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index 9c69c37c14588b..e8128ace3a8b8f 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -650,6 +650,7 @@ export const getOptions = ({ const gCalService = new GoogleCalendarService({ ...gcalCredential, user: null, + delegatedTo: null, }); if ( diff --git a/packages/features/bookings/components/EventTypeFilter.tsx b/packages/features/bookings/components/EventTypeFilter.tsx index 74276122584744..7fb7ab227901ea 100644 --- a/packages/features/bookings/components/EventTypeFilter.tsx +++ b/packages/features/bookings/components/EventTypeFilter.tsx @@ -5,12 +5,12 @@ import { FilterCheckboxField, FilterCheckboxFieldsContainer, } from "@calcom/features/filters/components/TeamsFilter"; +import { groupBy } from "@calcom/lib/groupBy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import { AnimatedPopover, Divider, Icon } from "@calcom/ui"; -import { groupBy } from "../groupBy"; import { useFilterQuery } from "../lib/useFilterQuery"; export type IEventTypesFilters = RouterOutputs["viewer"]["eventTypes"]["listWithTeam"]; diff --git a/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts b/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts index 7e65d55193ad18..db365be0262849 100644 --- a/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts +++ b/packages/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials.ts @@ -1,5 +1,6 @@ import type z from "zod"; +import { getAllDomainWideDelegationCredentialsForUser } from "@calcom/lib/domainWideDelegation/server"; import { UserRepository } from "@calcom/lib/server/repository/user"; import prisma from "@calcom/prisma"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; @@ -7,18 +8,20 @@ import { eventTypeAppMetadataOptionalSchema } from "@calcom/prisma/zod-utils"; import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { CredentialPayload } from "@calcom/types/Credential"; +export type EventType = { + userId?: number | null; + team?: { id: number | null; parentId: number | null } | null; + parentId?: number | null; + metadata: z.infer; +} | null; + /** * Gets credentials from the user, team, and org if applicable * */ export const getAllCredentials = async ( - user: { id: number; username: string | null; credentials: CredentialPayload[] }, - eventType: { - userId?: number | null; - team?: { id: number | null; parentId: number | null } | null; - parentId?: number | null; - metadata: z.infer; - } | null + user: { id: number; username: string | null; email: string; credentials: CredentialPayload[] }, + eventType: EventType ) => { let allCredentials = user.credentials; @@ -117,5 +120,11 @@ export const getAllCredentials = async ( } }); - return allCredentials; + const domainWideDelegationCredentials = await getAllDomainWideDelegationCredentialsForUser({ + user: { + email: user.email, + id: user.id, + }, + }); + return allCredentials.concat(domainWideDelegationCredentials); }; diff --git a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts index 6de0113f786d15..e559bbd6805bbc 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts @@ -28,6 +28,8 @@ import { mockCalendarToCrashOnCreateEvent, mockVideoAppToCrashOnCreateMeeting, BookingLocations, + createDwdCredential, + createOrganization, } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest"; import { @@ -57,6 +59,7 @@ import { WEBSITE_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { resetTestEmails } from "@calcom/lib/testEmails"; import { BookingStatus } from "@calcom/prisma/enums"; +import { MembershipRole } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; export type CustomNextApiRequest = NextApiRequest & Request; @@ -349,6 +352,211 @@ describe("handleNewBooking", () => { }); const createdBooking = await handleNewBooking(req); + + expect(createdBooking.responses).toEqual( + expect.objectContaining({ + email: booker.email, + name: booker.name, + }) + ); + + expect(createdBooking).toEqual( + expect.objectContaining({ + location: BookingLocations.CalVideo, + }) + ); + + 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.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com/meeting-1", + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "GOOGLE_CALENDAR_EVENT_ID", + meetingId: "GOOGLE_CALENDAR_EVENT_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + }, + ], + iCalUID: createdBooking.iCalUID, + }); + + expectWorkflowToBeTriggered({ emailsToReceive: [organizer.email], emails }); + expectSuccessfulCalendarEventCreationInCalendar(calendarMock, { + videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1", + // We won't be sending evt.destinationCalendar in this case. + // Google Calendar in this case fallbacks to the "primary" calendar - https://github.com/calcom/cal.com/blob/7d5dad7fea78ff24dddbe44f1da5d7e08e1ff568/packages/app-store/googlecalendar/lib/CalendarService.ts#L217 + // Not sure if it's the correct behaviour. Right now, it isn't possible to have an organizer with connected calendar but no destination calendar - As soon as the Google Calendar app is installed, a destination calendar is created. + calendarId: null, + }); + + const iCalUID = expectICalUIDAsString(createdBooking.iCalUID); + + expectSuccessfulBookingCreationEmails({ + booking: { + uid: createdBooking.uid!, + }, + booker, + organizer, + emails, + iCalUID, + }); + expectBookingCreatedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`, + }); + }, + timeout + ); + + test( + `should create a successful booking using the domain wide delegation credential + 1. Should create a booking in the database + 2. Should send emails to the booker as well as organizer + 3. Should fallback to creating the booking in the first connected Calendar when neither event nor organizer has a destination calendar - This doesn't practically happen because organizer is always required to have a schedule set + 3. Should trigger BOOKING_CREATED webhook + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const org = await createOrganization({ + name: "Test Org", + slug: "testorg", + }); + + const childTeam = { + id: 202, + name: "Team 1", + slug: "team-1", + parentId: org.id, + }; + + 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], + teams: [ + { + membership: { + accepted: true, + role: MembershipRole.ADMIN, + }, + team: { + id: org.id, + name: "Test Org", + slug: "testorg", + }, + }, + { + membership: { + accepted: true, + role: MembershipRole.ADMIN, + }, + team: { + id: childTeam.id, + name: "Team 1", + slug: "team-1", + parentId: org.id, + }, + }, + ], + }); + + const dwd = await createDwdCredential(org.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: [ + { + id: 1, + slotInterval: 30, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + // 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", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + expect(createdBooking.responses).toEqual( expect.objectContaining({ email: booker.email, @@ -382,6 +590,8 @@ describe("handleNewBooking", () => { meetingId: "GOOGLE_CALENDAR_EVENT_ID", meetingPassword: "MOCK_PASSWORD", meetingUrl: "https://UNUSED_URL", + // Verify DWD credential was used + domainWideDelegationCredentialId: dwd.id, }, ], iCalUID: createdBooking.iCalUID, diff --git a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts index c12e49cff6f828..5544c68dadc674 100644 --- a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts +++ b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts @@ -7,10 +7,10 @@ import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; +import { CredentialRepository } from "@calcom/lib/server/repository/credential"; import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; import prisma from "@calcom/prisma"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; -import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import { bookingCancelAttendeeSeatSchema } from "@calcom/prisma/zod-utils"; import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; import type { CalendarEvent } from "@calcom/types/Calendar"; @@ -72,23 +72,20 @@ async function cancelAttendeeSeat( for (const reference of bookingToDelete.references) { if (reference.credentialId) { - const credential = await prisma.credential.findUnique({ - where: { - id: reference.credentialId, - }, - select: credentialForCalendarServiceSelect, + const credentialForCalendarService = await CredentialRepository.findCredentialForCalendarServiceById({ + id: reference.credentialId, }); - if (credential) { + if (credentialForCalendarService) { const updatedEvt = { ...evt, attendees: evt.attendees.filter((evtAttendee) => attendee.email !== evtAttendee.email), }; if (reference.type.includes("_video")) { - integrationsToUpdate.push(updateMeeting(credential, updatedEvt, reference)); + integrationsToUpdate.push(updateMeeting(credentialForCalendarService, updatedEvt, reference)); } if (reference.type.includes("_calendar")) { - const calendar = await getCalendar(credential); + const calendar = await getCalendar(credentialForCalendarService); if (calendar) { integrationsToUpdate.push( calendar?.updateEvent(reference.uid, updatedEvt, reference.externalCalendarId) diff --git a/packages/features/bookings/lib/handleSeats/lib/lastAttendeeDeleteBooking.ts b/packages/features/bookings/lib/handleSeats/lib/lastAttendeeDeleteBooking.ts index 4c1eb418cf9935..dcd447d8dc9de0 100644 --- a/packages/features/bookings/lib/handleSeats/lib/lastAttendeeDeleteBooking.ts +++ b/packages/features/bookings/lib/handleSeats/lib/lastAttendeeDeleteBooking.ts @@ -3,9 +3,9 @@ import type { Attendee } from "@prisma/client"; // eslint-disable-next-line no-restricted-imports import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { deleteMeeting } from "@calcom/core/videoClient"; +import { CredentialRepository } from "@calcom/lib/server/repository/credential"; import prisma from "@calcom/prisma"; import { BookingStatus } from "@calcom/prisma/enums"; -import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import type { CalendarEvent } from "@calcom/types/Calendar"; import type { OriginalRescheduledBooking } from "../../handleNewBooking/types"; @@ -23,19 +23,16 @@ const lastAttendeeDeleteBooking = async ( for (const reference of originalRescheduledBooking.references) { if (reference.credentialId) { - const credential = await prisma.credential.findUnique({ - where: { - id: reference.credentialId, - }, - select: credentialForCalendarServiceSelect, + const credentialForCalendarService = await CredentialRepository.findCredentialForCalendarServiceById({ + id: reference.credentialId, }); - if (credential) { + if (credentialForCalendarService) { if (reference.type.includes("_video")) { - integrationsToDelete.push(deleteMeeting(credential, reference.uid)); + integrationsToDelete.push(deleteMeeting(credentialForCalendarService, reference.uid)); } if (reference.type.includes("_calendar") && originalBookingEvt) { - const calendar = await getCalendar(credential); + const calendar = await getCalendar(credentialForCalendarService); if (calendar) { integrationsToDelete.push( calendar?.deleteEvent(reference.uid, originalBookingEvt, reference.externalCalendarId) diff --git a/packages/features/calendar-cache/api/cron.ts b/packages/features/calendar-cache/api/cron.ts index 9044dc177357f8..eabb615bdb76e0 100644 --- a/packages/features/calendar-cache/api/cron.ts +++ b/packages/features/calendar-cache/api/cron.ts @@ -21,7 +21,14 @@ function logRejected(result: PromiseSettledResult) { } function getUniqueCalendarsByExternalId< - T extends { externalId: string; eventTypeId: number | null; credentialId: number | null; id: string } + T extends { + externalId: string; + eventTypeId: number | null; + credentialId: number | null; + userId: number; + id: string; + domainWideDelegationCredentialId: string | null; + } >(calendars: T[]) { type ExternalId = string; return calendars.reduce( @@ -30,7 +37,9 @@ function getUniqueCalendarsByExternalId< acc[sc.externalId] = { eventTypeIds: [sc.eventTypeId], credentialId: sc.credentialId, + userId: sc.userId, id: sc.id, + domainWideDelegationCredentialId: sc.domainWideDelegationCredentialId, }; } else { acc[sc.externalId].eventTypeIds.push(sc.eventTypeId); @@ -43,6 +52,8 @@ function getUniqueCalendarsByExternalId< eventTypeIds: SelectedCalendarEventTypeIds; credentialId: number | null; id: string; + userId: number; + domainWideDelegationCredentialId: string | null; } > ); @@ -53,14 +64,20 @@ const handleCalendarsToUnwatch = async () => { const calendarsWithEventTypeIdsGroupedTogether = getUniqueCalendarsByExternalId(calendarsToUnwatch); const result = await Promise.allSettled( Object.entries(calendarsWithEventTypeIdsGroupedTogether).map( - async ([externalId, { eventTypeIds, credentialId, id }]) => { - if (!credentialId) { + async ([externalId, { eventTypeIds, credentialId, userId, domainWideDelegationCredentialId, id }]) => { + if (!credentialId && !domainWideDelegationCredentialId) { // 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 and domainWideDelegationCredentialId", + }); + console.log("no credentialId and domainWideDelegationCredentialId for SelecedCalendar: ", id); return; } - const cc = await CalendarCache.initFromCredentialId(credentialId); + const cc = await CalendarCache.initFromDwdOrRegularCredential({ + credentialId, + dwdId: domainWideDelegationCredentialId, + userId, + }); await cc.unwatchCalendar({ calendarId: externalId, eventTypeIds }); } ) @@ -75,14 +92,20 @@ const handleCalendarsToWatch = async () => { const calendarsWithEventTypeIdsGroupedTogether = getUniqueCalendarsByExternalId(calendarsToWatch); const result = await Promise.allSettled( Object.entries(calendarsWithEventTypeIdsGroupedTogether).map( - async ([externalId, { credentialId, eventTypeIds, id }]) => { - if (!credentialId) { + async ([externalId, { credentialId, domainWideDelegationCredentialId, eventTypeIds, id, userId }]) => { + if (!credentialId && !domainWideDelegationCredentialId) { // 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 and domainWideDelegationCredentialId", + }); + console.log("no credentialId and domainWideDelegationCredentialId for SelecedCalendar: ", id); return; } - const cc = await CalendarCache.initFromCredentialId(credentialId); + const cc = await CalendarCache.initFromDwdOrRegularCredential({ + credentialId, + dwdId: domainWideDelegationCredentialId, + userId, + }); await cc.watchCalendar({ calendarId: externalId, eventTypeIds }); } ) diff --git a/packages/features/calendar-cache/calendar-cache.repository.interface.ts b/packages/features/calendar-cache/calendar-cache.repository.interface.ts index 96bb7d79a18623..003a0d8b5a4141 100644 --- a/packages/features/calendar-cache/calendar-cache.repository.interface.ts +++ b/packages/features/calendar-cache/calendar-cache.repository.interface.ts @@ -7,10 +7,11 @@ export type FreeBusyArgs = { timeMin: string; timeMax: string; items: { id: stri export interface ICalendarCacheRepository { watchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }): Promise; unwatchCalendar(args: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds }): Promise; - upsertCachedAvailability( - credentialId: number, - args: FreeBusyArgs, - value: Prisma.JsonNullValueInput | Prisma.InputJsonValue - ): Promise; + upsertCachedAvailability(args: { + credentialId: number; + userId: number | null; + args: FreeBusyArgs; + value: Prisma.JsonNullValueInput | Prisma.InputJsonValue; + }): Promise; getCachedAvailability(credentialId: number, args: FreeBusyArgs): Promise; } diff --git a/packages/features/calendar-cache/calendar-cache.repository.ts b/packages/features/calendar-cache/calendar-cache.repository.ts index 916e3fd81904b8..4a4cf3b0f83c67 100644 --- a/packages/features/calendar-cache/calendar-cache.repository.ts +++ b/packages/features/calendar-cache/calendar-cache.repository.ts @@ -1,6 +1,7 @@ import type { Prisma } from "@prisma/client"; import { uniqueBy } from "@calcom/lib/array"; +import { isDomainWideDelegationCredential } from "@calcom/lib/domainWideDelegation/clientAndServer"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; @@ -79,29 +80,59 @@ export class CalendarCacheRepository implements ICalendarCacheRepository { 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; + }) { const key = parseKeyForCache(args); - await prisma.calendarCache.upsert({ - where: { - credentialId_key: { - credentialId, - key, - }, - }, - update: { - value, - expiresAt: new Date(Date.now() + CACHING_TIME), - }, - create: { - value, + let where; + if (isDomainWideDelegationCredential({ credentialId })) { + if (!userId) { + console.error("Could not upsert cached availability for DWD case, userId is required"); + return; + } + where = { + userId, + key, + }; + } else { + where = { credentialId, key, - expiresAt: new Date(Date.now() + CACHING_TIME), - }, + }; + } + + const existingCache = await prisma.calendarCache.findFirst({ + where, }); + + if (existingCache) { + await prisma.calendarCache.update({ + where: { + id: existingCache.id, + }, + data: { + value, + expiresAt: new Date(Date.now() + CACHING_TIME), + }, + }); + } else { + await prisma.calendarCache.create({ + data: { + key, + credentialId: isDomainWideDelegationCredential({ credentialId }) ? null : credentialId, + userId, + value, + expiresAt: new Date(Date.now() + CACHING_TIME), + }, + }); + } } } diff --git a/packages/features/calendar-cache/calendar-cache.ts b/packages/features/calendar-cache/calendar-cache.ts index 5ab9c9567f1a4e..ff5a6e96845d00 100644 --- a/packages/features/calendar-cache/calendar-cache.ts +++ b/packages/features/calendar-cache/calendar-cache.ts @@ -1,7 +1,8 @@ import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; +import { getCredentialForCalendarService } from "@calcom/core/CalendarManager"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; -import prisma from "@calcom/prisma"; -import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; +import { getDwdCalendarCredentialById } from "@calcom/lib/domainWideDelegation/server"; +import { CredentialRepository } from "@calcom/lib/server/repository/credential"; import type { Calendar } from "@calcom/types/Calendar"; import { CalendarCacheRepository } from "./calendar-cache.repository"; @@ -10,13 +11,43 @@ import { CalendarCacheRepositoryMock } from "./calendar-cache.repository.mock"; export class CalendarCache { static async initFromCredentialId(credentialId: number): Promise { - const credential = await prisma.credential.findUnique({ - where: { id: credentialId }, - select: credentialForCalendarServiceSelect, + const credentialForCalendarService = await CredentialRepository.findCredentialForCalendarServiceById({ + id: credentialId, }); - const calendar = await getCalendar(credential); + const calendar = await getCalendar(credentialForCalendarService); return await CalendarCache.init(calendar); } + + static async initFromDwdId({ + dwdId, + userId, + }: { + dwdId: string; + userId: number; + }): Promise { + const dwdCredential = await getDwdCalendarCredentialById({ id: dwdId, userId }); + const credentialForCalendarService = await getCredentialForCalendarService(dwdCredential); + const calendar = await getCalendar(credentialForCalendarService); + return await CalendarCache.init(calendar); + } + + static async initFromDwdOrRegularCredential({ + credentialId, + dwdId, + userId, + }: { + credentialId: number | null; + dwdId: string | null; + userId: number; + }): Promise { + if (dwdId) { + return await CalendarCache.initFromDwdId({ dwdId, userId }); + } else if (credentialId) { + return await CalendarCache.initFromCredentialId(credentialId); + } + throw new Error("No credential or DWD ID provided"); + } + static async init(calendar: Calendar | null): Promise { const featureRepo = new FeaturesRepository(); const isCalendarCacheEnabledGlobally = await featureRepo.checkIfFeatureIsEnabledGlobally( diff --git a/packages/features/calendars/CalendarSwitch.tsx b/packages/features/calendars/CalendarSwitch.tsx index 4a2ffcadd94055..5ba91e4554b39e 100644 --- a/packages/features/calendars/CalendarSwitch.tsx +++ b/packages/features/calendars/CalendarSwitch.tsx @@ -17,6 +17,7 @@ export type ICalendarSwitchProps = { isLastItemInList?: boolean; destination?: boolean; credentialId: number; + domainWideDelegationCredentialId: string | null; eventTypeId: number | null; disabled?: boolean; }; @@ -28,7 +29,17 @@ type EventCalendarSwitchProps = ICalendarSwitchProps & { }; const CalendarSwitch = (props: ICalendarSwitchProps) => { - const { title, externalId, type, isChecked, name, credentialId, eventTypeId, disabled } = props; + const { + title, + externalId, + type, + isChecked, + name, + credentialId, + domainWideDelegationCredentialId, + eventTypeId, + disabled, + } = props; const [checkedInternal, setCheckedInternal] = useState(isChecked); const utils = trpc.useUtils(); const { t } = useLocale(); @@ -37,6 +48,7 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => { const body = { integration: type, externalId: externalId, + ...(domainWideDelegationCredentialId && { domainWideDelegationCredentialId }), // new URLSearchParams does not accept numbers credentialId: String(credentialId), ...(eventTypeId ? { eventTypeId: String(eventTypeId) } : {}), diff --git a/packages/features/credentials/handleDeleteCredential.ts b/packages/features/credentials/handleDeleteCredential.ts index 97bf17763f0847..634cc232b7a2a8 100644 --- a/packages/features/credentials/handleDeleteCredential.ts +++ b/packages/features/credentials/handleDeleteCredential.ts @@ -3,6 +3,7 @@ import z from "zod"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { getCredentialForCalendarService } from "@calcom/core/CalendarManager"; import { DailyLocationType } from "@calcom/core/location"; import { sendCancelledEmailsAndSMS } from "@calcom/emails"; import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses"; @@ -44,7 +45,7 @@ const handleDeleteCredential = async ({ credentialId: number; teamId?: number; }) => { - const credential = await prisma.credential.findFirst({ + const dbCredential = await prisma.credential.findFirst({ where: { id: credentialId, ...(teamId ? { teamId } : { userId }), @@ -61,10 +62,12 @@ const handleDeleteCredential = async ({ }, }); - if (!credential) { + if (!dbCredential) { throw new Error("Credential not found"); } + const credential = await getCredentialForCalendarService(dbCredential); + const eventTypes = await prisma.eventType.findMany({ where: { OR: [ diff --git a/packages/features/domain-wide-delegation/domain-wide-delegation-repository.interface.ts b/packages/features/domain-wide-delegation/domain-wide-delegation-repository.interface.ts deleted file mode 100644 index 9c77dacc9f201e..00000000000000 --- a/packages/features/domain-wide-delegation/domain-wide-delegation-repository.interface.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Prisma } from "@prisma/client"; - -type DomainWideDelegationSafeSelect = { - id: string; - enabled: boolean; - domain: string; - createdAt: Date; - updatedAt: Date; - organizationId: number; - workspacePlatform: { - name: string; - slug: string; - }; -}; - -export interface IDomainWideDelegationRepository { - create(args: { - domain: string; - enabled: boolean; - organizationId: number; - workspacePlatformId: number; - serviceAccountKey: Exclude; - }): Promise; - - findById(args: { id: string }): Promise; - - findByIdIncludeSensitiveServiceAccountKey(args: { - id: string; - }): Promise<(DomainWideDelegationSafeSelect & { serviceAccountKey: any }) | null>; - - findUniqueByOrganizationMemberEmail(args: { - email: string; - }): Promise; - - findByUser(args: { user: { email: string } }): Promise; - - updateById(args: { - id: string; - data: Partial<{ - workspacePlatformId: number; - domain: string; - enabled: boolean; - organizationId: number; - }>; - }): Promise; - - deleteById(args: { id: string }): Promise; - - findDelegationsWithServiceAccount(args: { organizationId: number }): Promise< - (DomainWideDelegationSafeSelect & { - workspacePlatform: { - id: number; - name: string; - slug: string; - }; - serviceAccountKey: any; - })[] - >; -} diff --git a/packages/features/domain-wide-delegation/domain-wide-delegation.ts b/packages/features/domain-wide-delegation/domain-wide-delegation.ts deleted file mode 100644 index dfdf7a63dc6425..00000000000000 --- a/packages/features/domain-wide-delegation/domain-wide-delegation.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { DomainWideDelegationRepository } from "@calcom/lib/server/repository/domainWideDelegation"; -import prisma from "@calcom/prisma"; - -import { MockDomainWideDelegationRepository } from "./mock-domain-wide-delegation.repository"; - -export class DomainWideDelegation { - static async checkIfDwDIsEnabled(userId: number, teamId: number | null) { - if (!teamId) { - return false; - } - const teamFeature = await prisma.teamFeatures.findFirst({ - where: { - teamId: teamId, - featureId: "domain-wide-delegation", - }, - }); - - const membership = await prisma.membership.findFirst({ - where: { - userId: userId, - teamId: teamId, - accepted: true, - }, - }); - - return !!(teamFeature && membership); - } - - static async init(userId: number, teamId: number | null) { - const domainWideDelegationEnabled = await this.checkIfDwDIsEnabled(userId, teamId); - - if (!domainWideDelegationEnabled) { - return new MockDomainWideDelegationRepository(); - } - - return new DomainWideDelegationRepository(); - } -} diff --git a/packages/features/domain-wide-delegation/mock-domain-wide-delegation.repository.ts b/packages/features/domain-wide-delegation/mock-domain-wide-delegation.repository.ts deleted file mode 100644 index a900466bad601b..00000000000000 --- a/packages/features/domain-wide-delegation/mock-domain-wide-delegation.repository.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Prisma } from "@prisma/client"; - -import type { IDomainWideDelegationRepository } from "./domain-wide-delegation-repository.interface"; - -export class MockDomainWideDelegationRepository implements IDomainWideDelegationRepository { - async create(_data: { - domain: string; - enabled: boolean; - organizationId: number; - workspacePlatformId: number; - serviceAccountKey: Exclude; - }) { - return null; - } - - async findById(_args: { id: string }) { - return null; - } - - async findByIdIncludeSensitiveServiceAccountKey(_args: { id: string }) { - return null; - } - - async findUniqueByOrganizationMemberEmail(_args: { email: string }) { - return null; - } - - async findByUser(_args: { user: { email: string } }) { - return null; - } - - static async findAllByDomain(_args: { domain: string }) { - return []; - } - - static async findFirstByOrganizationId(_args: { organizationId: number }) { - return null; - } - - async updateById(_args: { - id: string; - data: Partial<{ - workspacePlatformId: number; - domain: string; - enabled: boolean; - organizationId: number; - }>; - }) { - return null; - } - - async deleteById(_args: { id: string }) { - return null; - } - - async findDelegationsWithServiceAccount(_args: { organizationId: number }) { - return []; - } -} diff --git a/packages/features/ee/organizations/pages/settings/domainWideDelegation.tsx b/packages/features/ee/organizations/pages/settings/domainWideDelegation.tsx index 500ee066be9cc3..82a2b1b98772c4 100644 --- a/packages/features/ee/organizations/pages/settings/domainWideDelegation.tsx +++ b/packages/features/ee/organizations/pages/settings/domainWideDelegation.tsx @@ -90,6 +90,7 @@ function DelegationListItemActions({ { id: "delete", label: t("delete"), + disabled: true, onClick: () => onDelete(delegation.id), icon: "trash", }, diff --git a/packages/features/ee/payments/api/webhook.ts b/packages/features/ee/payments/api/webhook.ts index 02910c013e1942..5d18e2723d53fb 100644 --- a/packages/features/ee/payments/api/webhook.ts +++ b/packages/features/ee/payments/api/webhook.ts @@ -7,6 +7,7 @@ import stripe from "@calcom/app-store/stripepayment/lib/server"; import EventManager from "@calcom/core/EventManager"; import { sendAttendeeRequestEmailAndSMS, sendOrganizerRequestEmail } from "@calcom/emails"; import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation"; +import { getAllCredentials } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials"; import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; import { IS_PRODUCTION } from "@calcom/lib/constants"; import { getErrorFromUnknown } from "@calcom/lib/errors"; @@ -73,8 +74,9 @@ const handleSetupSuccess = async (event: Stripe.Event) => { }, }); if (!requiresConfirmation) { + const allCredentials = await getAllCredentials(user, eventType); const metadata = eventTypeMetaDataSchemaWithTypedApps.parse(eventType?.metadata); - const eventManager = new EventManager(user, metadata?.apps); + const eventManager = new EventManager({ ...user, credentials: allCredentials }, metadata?.apps); const scheduleResult = await eventManager.create(evt); bookingData.references = { create: scheduleResult.referencesToCreate }; bookingData.status = BookingStatus.ACCEPTED; diff --git a/packages/features/ee/round-robin/handleRescheduleEventManager.ts b/packages/features/ee/round-robin/handleRescheduleEventManager.ts index afda6e3f9d26cc..1bd158036af18d 100644 --- a/packages/features/ee/round-robin/handleRescheduleEventManager.ts +++ b/packages/features/ee/round-robin/handleRescheduleEventManager.ts @@ -5,6 +5,8 @@ import { metadata as GoogleMeetMetadata } from "@calcom/app-store/googlevideo/_m import { MeetLocationType } from "@calcom/app-store/locations"; import EventManager from "@calcom/core/EventManager"; import type { EventManagerInitParams } from "@calcom/core/EventManager"; +import { getAllCredentials } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials"; +import type { EventType } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials"; import { getVideoCallDetails } from "@calcom/features/bookings/lib/handleNewBooking/getVideoCallDetails"; import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser"; import logger from "@calcom/lib/logger"; @@ -13,6 +15,17 @@ import { BookingReferenceRepository } from "@calcom/lib/server/repository/bookin import { prisma } from "@calcom/prisma"; import type { CalendarEvent, AdditionalInformation } from "@calcom/types/Calendar"; +type InitParams = { + user: { + id: number; + name: string | null; + email: string; + username: string | null; + } & EventManagerInitParams["user"]; + eventTypeAppMetadata?: EventManagerInitParams["eventTypeAppMetadata"]; + eventType: EventType; +}; + export const handleRescheduleEventManager = async ({ evt, rescheduleUid, @@ -30,7 +43,7 @@ export const handleRescheduleEventManager = async ({ newBookingId?: number; changedOrganizer?: boolean; previousHostDestinationCalendar?: DestinationCalendar[] | null; - initParams: EventManagerInitParams; + initParams: InitParams; bookingLocation: string | null; bookingId: number; bookingICalUID?: string | null; @@ -40,7 +53,12 @@ export const handleRescheduleEventManager = async ({ prefix: ["handleRescheduleEventManager", `${bookingId}`], }); - const eventManager = new EventManager(initParams.user, initParams?.eventTypeAppMetadata); + const allCredentials = await getAllCredentials(initParams.user, initParams?.eventType); + + const eventManager = new EventManager( + { ...initParams.user, credentials: allCredentials }, + initParams?.eventTypeAppMetadata + ); const updateManager = await eventManager.reschedule( evt, diff --git a/packages/features/ee/round-robin/roundRobinManualReassignment.ts b/packages/features/ee/round-robin/roundRobinManualReassignment.ts index 363c4edba7c40e..1f0ba54b214898 100644 --- a/packages/features/ee/round-robin/roundRobinManualReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinManualReassignment.ts @@ -288,6 +288,7 @@ export const roundRobinManualReassignment = async ({ previousHostDestinationCalendar: previousHostDestinationCalendar ? [previousHostDestinationCalendar] : [], initParams: { user: { ...newUser, credentials }, + eventType, }, bookingId, bookingLocation, diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index f3190d9c42c4c0..26a50192a3d379 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -319,7 +319,8 @@ export const roundRobinReassignment = async ({ changedOrganizer: hasOrganizerChanged, previousHostDestinationCalendar: previousHostDestinationCalendar ? [previousHostDestinationCalendar] : [], initParams: { - user: { ...organizer, credentials: [...credentials] }, + user: { ...organizer, credentials }, + eventType, }, bookingId, bookingLocation, diff --git a/packages/features/settings/appDir/SettingsLayoutAppDirClient.tsx b/packages/features/settings/appDir/SettingsLayoutAppDirClient.tsx index e4be7cf03b6453..45023d58dc9c7d 100644 --- a/packages/features/settings/appDir/SettingsLayoutAppDirClient.tsx +++ b/packages/features/settings/appDir/SettingsLayoutAppDirClient.tsx @@ -109,10 +109,10 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { name: "admin_api", href: "https://cal.com/docs/enterprise-features/api/api-reference/bookings#admin-access", }, - // { - // name: "domain_wide_delegation", - // href: "/settings/organizations/domain-wide-delegation", - // }, + { + name: "domain_wide_delegation", + href: "/settings/organizations/domain-wide-delegation", + }, ], }, { @@ -166,7 +166,14 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { // The following keys are assigned to admin only const adminRequiredKeys = ["admin"]; const organizationRequiredKeys = ["organization"]; -const organizationAdminKeys = ["privacy", "billing", "OAuth Clients", "SSO", "directory_sync"]; +const organizationAdminKeys = [ + "privacy", + "billing", + "OAuth Clients", + "SSO", + "directory_sync", + "domain_wide_delegation", +]; const useTabs = () => { const session = useSession(); diff --git a/packages/lib/CalendarAppError.ts b/packages/lib/CalendarAppError.ts new file mode 100644 index 00000000000000..5dfbf7e3dccd9f --- /dev/null +++ b/packages/lib/CalendarAppError.ts @@ -0,0 +1,41 @@ +export class CalendarAppError extends Error { + constructor(message: string) { + super(message); + this.name = "CalendarAppError"; + } +} + +export class CalendarAppDomainWideDelegationError extends CalendarAppError { + constructor(message: string) { + super(message); + this.name = "CalendarAppDomainWideDelegationError"; + } +} + +export class CalendarAppDomainWideDelegationConfigurationError extends CalendarAppDomainWideDelegationError { + constructor(message: string) { + super(message); + this.name = "CalendarAppDomainWideDelegationConfigurationError"; + } +} + +export class CalendarAppDomainWideDelegationInvalidGrantError extends CalendarAppDomainWideDelegationError { + constructor(message: string) { + super(message); + this.name = "CalendarAppDomainWideDelegationInvalidGrantError"; + } +} + +export class CalendarAppDomainWideDelegationClientIdNotAuthorizedError extends CalendarAppDomainWideDelegationConfigurationError { + constructor(message: string) { + super(message); + this.name = "CalendarAppDomainWideDelegationClientIdNotAuthorizedError"; + } +} + +export class CalendarAppDomainWideDelegationNotSetupError extends CalendarAppDomainWideDelegationConfigurationError { + constructor(message: string) { + super(message); + this.name = "CalendarAppDomainWideDelegationNotSetupError"; + } +} \ No newline at end of file diff --git a/packages/lib/apps/getEnabledAppsFromCredentials.ts b/packages/lib/apps/getEnabledAppsFromCredentials.ts index 18904e5b3be07a..d65dfa9b0c174a 100644 --- a/packages/lib/apps/getEnabledAppsFromCredentials.ts +++ b/packages/lib/apps/getEnabledAppsFromCredentials.ts @@ -29,6 +29,9 @@ const getEnabledAppsFromCredentials = async ( }, }, } satisfies Prisma.AppWhereInput; + const domainWideDelegationCredentials = credentials.filter((credential) => { + return credential.id < 0; + }); if (filterOnCredentials) { const userIds: number[] = [], @@ -48,10 +51,26 @@ const getEnabledAppsFromCredentials = async ( ...(filterOnIds.credentials.some.OR.length && filterOnIds), }; - const enabledApps = await prisma.app.findMany({ + let enabledApps = await prisma.app.findMany({ where, select: { slug: true, enabled: true }, }); + + const domainWideDelegationApps = await prisma.app.findMany({ + where: { + slug: { + in: domainWideDelegationCredentials + .filter( + (credential): credential is typeof credential & { appId: string } => credential.appId !== null + ) + .map((credential) => credential.appId), + }, + }, + select: { slug: true, enabled: true }, + }); + + enabledApps = [...enabledApps, ...domainWideDelegationApps]; + const apps = getApps(credentials, filterOnCredentials); const filteredApps = apps.reduce((reducedArray, app) => { const appDbQuery = enabledApps.find((metadata) => metadata.slug === app.slug); diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 81f7422252aa73..2de51d36472896 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -57,6 +57,7 @@ const user: User & { credentials: CredentialPayload[] } = { travelSchedules: [], }; + const customInputs: CustomInputSchema[] = []; const commons = { diff --git a/packages/lib/domainWideDelegation/clientAndServer.ts b/packages/lib/domainWideDelegation/clientAndServer.ts new file mode 100644 index 00000000000000..3830efcf7ac246 --- /dev/null +++ b/packages/lib/domainWideDelegation/clientAndServer.ts @@ -0,0 +1,8 @@ +export function isDomainWideDelegationCredential({ + 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; +} diff --git a/packages/lib/domainWideDelegation/server.test.ts b/packages/lib/domainWideDelegation/server.test.ts new file mode 100644 index 00000000000000..94969bb3a0c06c --- /dev/null +++ b/packages/lib/domainWideDelegation/server.test.ts @@ -0,0 +1,103 @@ +import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { metadata as googleCalendarMetadata } from "@calcom/app-store/googlecalendar/_metadata"; +import { metadata as googleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata"; +import { DomainWideDelegationRepository } from "@calcom/lib/server/repository/domainWideDelegation"; + +import { getAllDomainWideDelegationCredentialsForUser } from "./server"; + +describe("getAllDomainWideDelegationCredentialsForUser", () => { + setupAndTeardown(); + + const mockUser = { + email: "test@example.com", + id: 123, + }; + + let mockFindByUser: ReturnType; + let mockRepository: { findByUser: ReturnType }; + + beforeEach(() => { + mockFindByUser = vi.fn(); + mockRepository = { + findByUser: mockFindByUser, + }; + vi.spyOn(DomainWideDelegationRepository, "findByUser").mockImplementation(mockFindByUser); + }); + + it("should return empty array when no DWD found", async () => { + mockFindByUser.mockResolvedValue(null); + + const result = await getAllDomainWideDelegationCredentialsForUser({ user: mockUser }); + + expect(result).toEqual([]); + expect(mockFindByUser).toHaveBeenCalledWith({ user: { email: mockUser.email } }); + }); + + it("should return empty array when DWD is disabled", async () => { + mockFindByUser.mockResolvedValue({ + enabled: false, + id: "dwd-1", + workspacePlatform: { slug: "google" }, + }); + + const result = await getAllDomainWideDelegationCredentialsForUser({ user: mockUser }); + + expect(result).toEqual([]); + expect(mockFindByUser).toHaveBeenCalledWith({ user: { email: mockUser.email } }); + }); + + it("should return credentials for enabled Google DWD", async () => { + mockFindByUser.mockResolvedValue({ + enabled: true, + id: "dwd-1", + workspacePlatform: { slug: "google" }, + }); + + const result = await getAllDomainWideDelegationCredentialsForUser({ user: mockUser }); + + expect(mockFindByUser).toHaveBeenCalledWith({ user: { email: mockUser.email } }); + expect(result).toHaveLength(2); + expect(result).toEqual([ + { + type: googleCalendarMetadata.type, + appId: googleCalendarMetadata.slug, + id: -1, + delegatedToId: "dwd-1", + userId: mockUser.id, + user: { email: mockUser.email }, + key: { access_token: "NOOP_UNUSED_DELEGATION_TOKEN" }, + invalid: false, + teamId: null, + team: null, + }, + { + type: googleMeetMetadata.type, + appId: googleMeetMetadata.slug, + id: -1, + delegatedToId: "dwd-1", + userId: mockUser.id, + user: { email: mockUser.email }, + key: { access_token: "NOOP_UNUSED_DELEGATION_TOKEN" }, + invalid: false, + teamId: null, + team: null, + }, + ]); + }); + + it("should return empty array for non-Google platforms", async () => { + mockFindByUser.mockResolvedValue({ + enabled: true, + id: "dwd-1", + workspacePlatform: { slug: "microsoft" }, + }); + + const result = await getAllDomainWideDelegationCredentialsForUser({ user: mockUser }); + + expect(result).toEqual([]); + expect(mockFindByUser).toHaveBeenCalledWith({ user: { email: mockUser.email } }); + }); +}); diff --git a/packages/lib/domainWideDelegation/server.ts b/packages/lib/domainWideDelegation/server.ts new file mode 100644 index 00000000000000..e6ec8b0fa9ce54 --- /dev/null +++ b/packages/lib/domainWideDelegation/server.ts @@ -0,0 +1,232 @@ +import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; +import { metadata as googleCalendarMetadata } from "@calcom/app-store/googlecalendar/_metadata"; +import { metadata as googleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import type { ServiceAccountKey } from "@calcom/lib/server/repository/domainWideDelegation"; +import { DomainWideDelegationRepository } from "@calcom/lib/server/repository/domainWideDelegation"; +import { UserRepository } from "@calcom/lib/server/repository/user"; + +const log = logger.getSubLogger({ prefix: ["lib/domainWideDelegation/server"] }); +interface DomainWideDelegation { + id: string; + workspacePlatform: { + slug: string; + }; +} + +interface DomainWideDelegationWithSensitiveServiceAccountKey extends DomainWideDelegation { + serviceAccountKey: ServiceAccountKey; +} + +interface User { + email: string; + id: number; +} + +const buildCommonUserCredential = ({ + domainWideDelegation, + user, +}: { + domainWideDelegation: DomainWideDelegation; + user: User; +}) => { + return { + id: -1, + delegatedToId: domainWideDelegation.id, + userId: user.id, + user: { + email: user.email, + }, + key: { + access_token: "NOOP_UNUSED_DELEGATION_TOKEN", + }, + invalid: false, + teamId: null, + team: null, + }; +}; + +const buildDomainWideDelegationCalendarCredential = ({ + domainWideDelegation, + user, +}: { + domainWideDelegation: DomainWideDelegation; + user: User; +}) => { + log.debug("buildDomainWideDelegationCredential", safeStringify({ domainWideDelegation, user })); + // TODO: Build for other platforms as well + if (domainWideDelegation.workspacePlatform.slug !== "google") { + log.warn( + `Only Google Platform is supported here, skipping ${domainWideDelegation.workspacePlatform.slug}` + ); + return null; + } + return { + type: googleCalendarMetadata.type, + appId: googleCalendarMetadata.slug, + ...buildCommonUserCredential({ domainWideDelegation, user }), + }; +}; + +const buildDomainWideDelegationCalendarCredentialWithServiceAccountKey = ({ + domainWideDelegation, + user, +}: { + domainWideDelegation: DomainWideDelegationWithSensitiveServiceAccountKey; + user: User; +}) => { + const credential = buildDomainWideDelegationCalendarCredential({ domainWideDelegation, user }); + if (!credential) { + return null; + } + return { + ...credential, + delegatedTo: { + serviceAccountKey: domainWideDelegation.serviceAccountKey, + }, + }; +}; + +const buildDomainWideDelegationConferencingCredential = ({ + domainWideDelegation, + user, +}: { + domainWideDelegation: DomainWideDelegation; + user: User; +}) => { + // TODO: Build for other platforms as well + if (domainWideDelegation.workspacePlatform.slug !== "google") { + log.warn( + `Only Google Platform is supported here, skipping ${domainWideDelegation.workspacePlatform.slug}` + ); + return null; + } + return { + type: googleMeetMetadata.type, + appId: googleMeetMetadata.slug, + ...buildCommonUserCredential({ domainWideDelegation, user }), + }; +}; + +export async function getAllDomainWideDelegationCredentialsForUser({ + user, +}: { + user: { email: string; id: number }; +}) { + log.debug("called with", safeStringify({ user })); + // We access the repository without checking for feature flag here. + // In case we need to disable the effects of DWD on credential we need to toggle DWD off from organization settings. + // We could think of the teamFeatures flag to just disable the UI. The actual effect of DWD on credentials is disabled by toggling DWD off from UI + const domainWideDelegation = await DomainWideDelegationRepository.findByUser({ + user: { + email: user.email, + }, + }); + + if (!domainWideDelegation || !domainWideDelegation.enabled) { + return []; + } + + const domainWideDelegationCredentials = [ + buildDomainWideDelegationCalendarCredential({ domainWideDelegation, user }), + buildDomainWideDelegationConferencingCredential({ domainWideDelegation, user }), + ].filter((credential): credential is NonNullable => credential !== null); + + log.debug("Returned", safeStringify({ domainWideDelegationCredentials })); + return domainWideDelegationCredentials; +} + +export async function getAllDomainWideDelegationCalendarCredentialsForUser({ + user, +}: { + user: { email: string; id: number }; +}) { + const domainWideDelegationCredentials = await getAllDomainWideDelegationCredentialsForUser({ user }); + return domainWideDelegationCredentials.filter((credential) => credential.type.endsWith("_calendar")); +} + +export async function getAllDomainWideDelegationConferencingCredentialsForUser({ + user, +}: { + user: { email: string; id: number }; +}) { + const domainWideDelegationCredentials = await getAllDomainWideDelegationCredentialsForUser({ user }); + return domainWideDelegationCredentials.filter( + (credential) => + credential.type.endsWith("_video") || + credential.type.endsWith("_conferencing") || + credential.type.endsWith("_messaging") + ); +} + +export async function checkIfSuccessfullyConfiguredInWorkspace({ + domainWideDelegation, + user, +}: { + domainWideDelegation: DomainWideDelegationWithSensitiveServiceAccountKey; + user: User; +}) { + if (domainWideDelegation.workspacePlatform.slug !== "google") { + log.warn( + `Only Google Platform is supported here, skipping ${domainWideDelegation.workspacePlatform.slug}` + ); + return false; + } + + const credential = buildDomainWideDelegationCalendarCredentialWithServiceAccountKey({ + domainWideDelegation, + user, + }); + + const googleCalendar = await getCalendar(credential); + + if (!googleCalendar) { + throw new Error("Google Calendar App not found"); + } + return await googleCalendar?.testDomainWideDelegationSetup?.(); +} + +export async function getAllDomainWideDelegationCredentialsForUserByAppType({ + user, + appType, +}: { + user: User; + appType: string; +}) { + const domainWideDelegationCredentials = await getAllDomainWideDelegationCredentialsForUser({ + user, + }); + return domainWideDelegationCredentials.filter((credential) => credential.type === appType); +} + +export async function getAllDomainWideDelegationCredentialsForUserByAppSlug({ + user, + appSlug, +}: { + user: User; + appSlug: string; +}) { + const domainWideDelegationCredentials = await getAllDomainWideDelegationCredentialsForUser({ user }); + return domainWideDelegationCredentials.filter((credential) => credential.appId === appSlug); +} + +export async function getDwdCalendarCredentialById({ id, userId }: { id: string; userId: number }) { + const [domainWideDelegation, user] = await Promise.all([ + DomainWideDelegationRepository.findById({ id }), + UserRepository.findById({ id: userId }), + ]); + + if (!domainWideDelegation) { + throw new Error("Domain Wide Delegation not found"); + } + if (!user) { + throw new Error("User not found"); + } + + const dwdCredential = buildDomainWideDelegationCalendarCredential({ + domainWideDelegation, + user, + }); + return dwdCredential; +} diff --git a/packages/lib/getConnectedApps.ts b/packages/lib/getConnectedApps.ts index 92384b943556bf..0912bd32a29225 100644 --- a/packages/lib/getConnectedApps.ts +++ b/packages/lib/getConnectedApps.ts @@ -5,13 +5,13 @@ import { getAppFromSlug } from "@calcom/app-store/utils"; import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials"; import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp"; import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials"; +import { withDelegatedToIdNullArray } from "@calcom/lib/server/repository/credential"; import type { PrismaClient } from "@calcom/prisma"; import type { User } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import type { TeamQuery } from "@calcom/trpc/server/routers/loggedInViewer/integrations.handler"; import type { TIntegrationsInputSchema } from "@calcom/trpc/server/routers/loggedInViewer/integrations.schema"; -import type { CredentialPayload } from "@calcom/types/Credential"; import type { PaymentApp } from "@calcom/types/PaymentService"; export type ConnectedApps = Awaited>; @@ -21,7 +21,7 @@ export async function getConnectedApps({ prisma, input, }: { - user: Pick & { avatar?: string }; + user: Pick & { avatar?: string }; prisma: PrismaClient; input: TIntegrationsInputSchema; }) { @@ -33,6 +33,7 @@ export async function getConnectedApps({ extendsFeature, teamId, sortByMostPopular, + sortByInstalledFirst, appId, } = input; let credentials = await getUsersCredentials(user); @@ -103,8 +104,8 @@ export async function getConnectedApps({ userTeams = [...teamsQuery, ...parentTeams]; - const teamAppCredentials: CredentialPayload[] = userTeams.flatMap((teamApp) => { - return teamApp.credentials ? teamApp.credentials.flat() : []; + const teamAppCredentials = userTeams.flatMap((teamApp) => { + return teamApp.credentials ? withDelegatedToIdNullArray(teamApp.credentials.flat()) : []; }); if (!includeTeamInstalledApps || teamId) { credentials = teamAppCredentials; @@ -226,6 +227,12 @@ export async function getConnectedApps({ }); } + if (sortByInstalledFirst) { + apps.sort((a, b) => { + return (a.isInstalled ? 0 : 1) - (b.isInstalled ? 0 : 1); + }); + } + return { items: apps, }; diff --git a/packages/lib/getConnectedDestinationCalendars.ts b/packages/lib/getConnectedDestinationCalendars.ts index 796d89941004cd..a512b559a8034b 100644 --- a/packages/lib/getConnectedDestinationCalendars.ts +++ b/packages/lib/getConnectedDestinationCalendars.ts @@ -1,4 +1,6 @@ import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; +import { isDomainWideDelegationCredential } from "@calcom/lib/domainWideDelegation/clientAndServer"; +import { getAllDomainWideDelegationCalendarCredentialsForUser } from "@calcom/lib/domainWideDelegation/server"; import logger from "@calcom/lib/logger"; import type { PrismaClient } from "@calcom/prisma"; import prisma from "@calcom/prisma"; @@ -14,7 +16,7 @@ const log = logger.getSubLogger({ prefix: ["getConnectedDestinationCalendarsAndE type ReturnTypeGetConnectedCalendars = Awaited>; type ConnectedCalendarsFromGetConnectedCalendars = ReturnTypeGetConnectedCalendars["connectedCalendars"]; -export type UserWithCalendars = Pick & { +export type UserWithCalendars = Pick & { allSelectedCalendars: Pick[]; userLevelSelectedCalendars: Pick[]; destinationCalendar: DestinationCalendar | null; @@ -24,6 +26,51 @@ export type ConnectedDestinationCalendars = Awaited< ReturnType >; +/** + * Ensures that when DWD is enabled and there is already a calendar connected for the corresponding domain, we only allow the DWD calendar to be returned + * This is to ensure that duplicate calendar connections aren't shown in UI(apps/installed/calendars). We choose DWD connection to be shown because we don't want users to be able to work with individual calendars + */ +const _ensureNoConflictingNonDwdConnectedCalendar = < + T extends { + integration: { slug: string }; + primary?: { email?: string | null | undefined } | undefined; + domainWideDelegationCredentialId?: string | null | undefined; + } +>({ + connectedCalendars, + loggedInUser, +}: { + connectedCalendars: T[]; + loggedInUser: { email: string }; +}) => { + return connectedCalendars.filter((connectedCalendar, index, array) => { + const allCalendarsWithSameAppSlug = array.filter( + (cal) => cal.integration.slug === connectedCalendar.integration.slug + ); + + // If no other calendar with this slug, keep it + if (allCalendarsWithSameAppSlug.length === 1) return true; + + const dwdCalendarsWithSameAppSlug = allCalendarsWithSameAppSlug.filter( + (cal) => cal.domainWideDelegationCredentialId + ); + if (!dwdCalendarsWithSameAppSlug.length) { + return true; + } + + if (connectedCalendar.domainWideDelegationCredentialId) { + return true; + } + + // DWD Credential is always of the loggedInUser + if (!connectedCalendar.primary?.email || connectedCalendar.primary.email !== loggedInUser.email) { + return true; + } + + return false; + }); +}; + async function handleNoConnectedCalendars(user: UserWithCalendars) { log.debug(`No connected calendars, deleting destination calendar if it exists for user ${user.id}`); @@ -69,6 +116,7 @@ async function handleNoDestinationCalendar({ integration = "", externalId = "", credentialId, + domainWideDelegationCredentialId, email: primaryEmail, } = connectedCalendars[0].primary ?? {}; @@ -91,8 +139,14 @@ async function handleNoDestinationCalendar({ userId: user.id, integration, externalId, - credentialId, primaryEmail, + ...(!isDomainWideDelegationCredential({ credentialId }) + ? { + credentialId, + } + : { + domainWideDelegationCredentialId, + }), }, }); @@ -231,9 +285,16 @@ export async function getConnectedDestinationCalendarsAndEnsureDefaultsInDb({ select: credentialForCalendarServiceSelect, }); + const domainWideDelegationCredentials = await getAllDomainWideDelegationCalendarCredentialsForUser({ + user, + }); + + // Show DWD credentials' calendars first in UI + const allCredentials = [...domainWideDelegationCredentials, ...userCredentials]; + const selectedCalendars = getSelectedCalendars({ user, eventTypeId: eventTypeId ?? null }); // get user's credentials + their connected integrations - const calendarCredentials = getCalendarCredentials(userCredentials); + const calendarCredentials = await getCalendarCredentials(allCredentials); // get all the connected integrations' calendars (from third party) const getConnectedCalendarsResult = await getConnectedCalendars( @@ -305,8 +366,12 @@ export async function getConnectedDestinationCalendarsAndEnsureDefaultsInDb({ }); } - return { + const noConflictingNonDwdConnectedCalendars = _ensureNoConflictingNonDwdConnectedCalendar({ connectedCalendars, + loggedInUser: { email: user.email }, + }); + return { + connectedCalendars: noConflictingNonDwdConnectedCalendars, destinationCalendar: { ...(user.destinationCalendar as DestinationCalendar), ...destinationCalendar, diff --git a/packages/features/bookings/groupBy.ts b/packages/lib/groupBy.ts similarity index 100% rename from packages/features/bookings/groupBy.ts rename to packages/lib/groupBy.ts diff --git a/packages/lib/payment/handlePaymentSuccess.ts b/packages/lib/payment/handlePaymentSuccess.ts index 2defd49f5cf118..fc03351d6d4e2e 100644 --- a/packages/lib/payment/handlePaymentSuccess.ts +++ b/packages/lib/payment/handlePaymentSuccess.ts @@ -3,6 +3,7 @@ import type { Prisma } from "@prisma/client"; import EventManager from "@calcom/core/EventManager"; import { sendScheduledEmailsAndSMS } from "@calcom/emails"; import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation"; +import { getAllCredentials } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials"; import { handleBookingRequested } from "@calcom/features/bookings/lib/handleBookingRequested"; import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; import { HttpError as HttpCode } from "@calcom/lib/http-error"; @@ -27,8 +28,9 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number) const isConfirmed = booking.status === BookingStatus.ACCEPTED; if (isConfirmed) { + const allCredentials = await getAllCredentials(userWithCredentials, eventType); const apps = eventTypeAppMetadataOptionalSchema.parse(eventType?.metadata?.apps); - const eventManager = new EventManager(userWithCredentials, apps); + const eventManager = new EventManager({ ...userWithCredentials, credentials: allCredentials }, apps); const scheduleResult = await eventManager.create(evt); bookingData.references = { create: scheduleResult.referencesToCreate }; } diff --git a/packages/lib/server/buildCredentialPayloadForCalendar.ts b/packages/lib/server/buildCredentialPayloadForCalendar.ts new file mode 100644 index 00000000000000..6e860d0d8f24b5 --- /dev/null +++ b/packages/lib/server/buildCredentialPayloadForCalendar.ts @@ -0,0 +1,31 @@ +import { isDomainWideDelegationCredential } from "@calcom/lib/domainWideDelegation/clientAndServer"; + +export function buildCredentialPayloadForCalendar({ + credentialId, + domainWideDelegationCredentialId, +}: { + credentialId: number | null | undefined; + domainWideDelegationCredentialId: string | null | undefined; +}) { + if (credentialId === undefined && domainWideDelegationCredentialId === undefined) { + // If both are undefined, we don't want to change anything in DB + return { + credentialId, + domainWideDelegationCredentialId, + }; + } + + // Only one of credentialId and domainWideDelegationCredentialId can be set at a time. + // Set the other one as null because we want to ensure both credentialId and domainWideDelegationCredentialId are not active at the same time + return { + ...(!isDomainWideDelegationCredential({ credentialId }) + ? { + credentialId, + domainWideDelegationCredentialId: null, + } + : { + domainWideDelegationCredentialId, + credentialId: null, + }), + }; +} diff --git a/packages/lib/server/findUsersForAvailabilityCheck.ts b/packages/lib/server/findUsersForAvailabilityCheck.ts new file mode 100644 index 00000000000000..922fb2a604726a --- /dev/null +++ b/packages/lib/server/findUsersForAvailabilityCheck.ts @@ -0,0 +1,35 @@ +import type { Prisma } from "@prisma/client"; + +import { getAllDomainWideDelegationCalendarCredentialsForUser } from "@calcom/lib/domainWideDelegation/server"; +import { availabilityUserSelect } from "@calcom/prisma"; +import { prisma } from "@calcom/prisma"; +import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; + +import { withSelectedCalendars } from "./withSelectedCalendars"; + +export async function findUsersForAvailabilityCheck({ where }: { where: Prisma.UserWhereInput }) { + const user = await prisma.user.findFirst({ + where, + select: { + ...availabilityUserSelect, + selectedCalendars: true, + credentials: { + select: credentialForCalendarServiceSelect, + }, + }, + }); + + if (!user) { + return null; + } + + const userWithSelectedCalendars = withSelectedCalendars(user); + const { credentials, ...restUser } = userWithSelectedCalendars; + + return { + ...restUser, + credentials: credentials.concat( + await getAllDomainWideDelegationCalendarCredentialsForUser({ user: restUser }) + ), + }; +} diff --git a/packages/lib/server/getDefaultLocations.ts b/packages/lib/server/getDefaultLocations.ts index 7d7bdc36ec2459..09a770aaecea49 100644 --- a/packages/lib/server/getDefaultLocations.ts +++ b/packages/lib/server/getDefaultLocations.ts @@ -9,6 +9,7 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; type SessionUser = NonNullable; type User = { id: SessionUser["id"]; + email: SessionUser["email"]; metadata: SessionUser["metadata"]; }; diff --git a/packages/lib/server/getUsersCredentials.ts b/packages/lib/server/getUsersCredentials.ts index 77560e97cf1420..b0e6d038d1bdb3 100644 --- a/packages/lib/server/getUsersCredentials.ts +++ b/packages/lib/server/getUsersCredentials.ts @@ -1,10 +1,16 @@ +import { getCredentialForCalendarService } from "@calcom/core/CalendarManager"; import { prisma } from "@calcom/prisma"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; +import { getAllDomainWideDelegationCredentialsForUser } from "../domainWideDelegation/server"; + type SessionUser = NonNullable; -type User = { id: SessionUser["id"] }; +type User = { id: SessionUser["id"]; email: SessionUser["email"] }; +/** + * It includes in-memory DWD credentials as well. + */ export async function getUsersCredentials(user: User) { const credentials = await prisma.credential.findMany({ where: { @@ -15,5 +21,22 @@ export async function getUsersCredentials(user: User) { id: "asc", }, }); - return credentials; + + const domainWideDelegationCredentials = await getAllDomainWideDelegationCredentialsForUser({ + user: { + email: user.email, + id: user.id, + }, + }); + + return [ + ...credentials.map((credential) => ({ ...credential, delegatedToId: null })), + ...domainWideDelegationCredentials, + ]; +} + +export async function getUsersCredentialsForCalendarService(user: User) { + const credentials = await getUsersCredentials(user); + + return await Promise.all(credentials.map((credential) => getCredentialForCalendarService(credential))); } diff --git a/packages/lib/server/repository/credential.ts b/packages/lib/server/repository/credential.ts index a4a244a8a78921..cfc3b695bcc75b 100644 --- a/packages/lib/server/repository/credential.ts +++ b/packages/lib/server/repository/credential.ts @@ -1,5 +1,7 @@ +import { getCredentialForCalendarService } from "@calcom/core/CalendarManager"; import { prisma } from "@calcom/prisma"; import { safeCredentialSelect } from "@calcom/prisma/selects/credential"; +import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; type CredentialCreateInput = { type: string; @@ -8,26 +10,61 @@ type CredentialCreateInput = { appId: string; }; +// Allows us to explicitly set delegatedToId to null instead of not setting it. +// Once every credential from Credential table has delegatedToId:null available like this, we can make delegatedToId a required field instead of optional +// It makes us avoid a scenario where on a DWD credential we accidentally forget to set delegatedToId and think of it as non-dwd credential due to that +export const withDelegatedToIdNull = | null>(credential: T) => { + type WithDelegatedCredential = T extends null + ? null + : T & { + delegatedToId: null; + }; + + if (!credential) return null as WithDelegatedCredential; + return { + ...credential, + delegatedToId: null, + } as WithDelegatedCredential; +}; + +export const withDelegatedToIdNullArray = >(credentials: T[]) => { + return credentials.map(withDelegatedToIdNull).filter((credential) => !!credential) as (T & { + delegatedToId: null; + })[]; +}; + export class CredentialRepository { static async create(data: CredentialCreateInput) { - return await prisma.credential.create({ data: { ...data } }); + const credential = await prisma.credential.create({ data: { ...data } }); + return withDelegatedToIdNull(credential); + } + static async findByAppIdAndUserId({ appId, userId }: { appId: string; userId: number }) { + const credential = await prisma.credential.findFirst({ + where: { + appId, + userId, + }, + }); + return withDelegatedToIdNull(credential); } /** * Doesn't retrieve key field as that has credentials */ static async findFirstByIdWithUser({ id }: { id: number }) { - return await prisma.credential.findFirst({ where: { id }, select: safeCredentialSelect }); + const credential = await prisma.credential.findFirst({ where: { id }, select: safeCredentialSelect }); + return withDelegatedToIdNull(credential); } /** * Includes 'key' field which is sensitive data. */ static async findFirstByIdWithKeyAndUser({ id }: { id: number }) { - return await prisma.credential.findFirst({ + const credential = await prisma.credential.findFirst({ where: { id }, select: { ...safeCredentialSelect, key: true }, }); + return withDelegatedToIdNull(credential); } static async findFirstByAppIdAndUserId({ appId, userId }: { appId: string; userId: number }) { @@ -40,10 +77,23 @@ export class CredentialRepository { } static async findFirstByUserIdAndType({ userId, type }: { userId: number; type: string }) { - return await prisma.credential.findFirst({ where: { userId, type } }); + const credential = await prisma.credential.findFirst({ where: { userId, type } }); + return withDelegatedToIdNull(credential); } static async deleteById({ id }: { id: number }) { await prisma.credential.delete({ where: { id } }); } + + static async findCredentialForCalendarServiceById({ id }: { id: number }) { + const dbCredential = await prisma.credential.findUnique({ + where: { id }, + select: credentialForCalendarServiceSelect, + }); + + const credentialForCalendarService = await getCredentialForCalendarService( + withDelegatedToIdNull(dbCredential) + ); + return credentialForCalendarService; + } } diff --git a/packages/lib/server/repository/destinationCalendar.ts b/packages/lib/server/repository/destinationCalendar.ts index b45e7ea4e5dd64..ac68ed9c72ece1 100644 --- a/packages/lib/server/repository/destinationCalendar.ts +++ b/packages/lib/server/repository/destinationCalendar.ts @@ -2,6 +2,8 @@ import type { Prisma } from "@prisma/client"; import { prisma } from "@calcom/prisma"; +import { buildCredentialPayloadForCalendar } from "../buildCredentialPayloadForCalendar"; + export class DestinationCalendarRepository { static async create(data: Prisma.DestinationCalendarCreateInput) { return await prisma.destinationCalendar.create({ @@ -9,7 +11,7 @@ export class DestinationCalendarRepository { }); } - static async getByUserId(userId: string) { + static async getByUserId(userId: number) { return await prisma.destinationCalendar.findFirst({ where: { userId, @@ -24,4 +26,54 @@ export class DestinationCalendarRepository { }, }); } + + static async find({ where }: { where: Prisma.DestinationCalendarWhereInput }) { + return await prisma.destinationCalendar.findFirst({ + where, + }); + } + + static async upsert({ + where, + update, + create, + }: { + where: Prisma.DestinationCalendarUpsertArgs["where"]; + update: { + integration?: string; + externalId?: string; + credentialId?: number | null; + primaryEmail?: string | null; + domainWideDelegationCredentialId?: string | null; + }; + create: { + integration: string; + externalId: string; + credentialId: number | null; + primaryEmail?: string | null; + domainWideDelegationCredentialId?: string | null; + }; + }) { + const credentialPayloadForUpdate = buildCredentialPayloadForCalendar({ + credentialId: update.credentialId, + domainWideDelegationCredentialId: update.domainWideDelegationCredentialId, + }); + + const credentialPayloadForCreate = buildCredentialPayloadForCalendar({ + credentialId: create.credentialId, + domainWideDelegationCredentialId: create.domainWideDelegationCredentialId, + }); + + return await prisma.destinationCalendar.upsert({ + where, + update: { + ...update, + ...credentialPayloadForUpdate, + }, + create: { + ...create, + ...credentialPayloadForCreate, + }, + }); + } } diff --git a/packages/lib/server/repository/domainWideDelegation.ts b/packages/lib/server/repository/domainWideDelegation.ts index 47f530a35feee8..a63015ece4d1f3 100644 --- a/packages/lib/server/repository/domainWideDelegation.ts +++ b/packages/lib/server/repository/domainWideDelegation.ts @@ -1,7 +1,6 @@ import type { Prisma } from "@prisma/client"; import z from "zod"; -import type { IDomainWideDelegationRepository } from "@calcom/features/domain-wide-delegation/domain-wide-delegation-repository.interface"; import { getFeatureFlag } from "@calcom/features/flags/server/utils"; import logger from "@calcom/lib/logger"; import { prisma } from "@calcom/prisma"; @@ -39,7 +38,7 @@ const domainWideDelegationSelectIncludesServiceAccountKey = { serviceAccountKey: true, }; -export class DomainWideDelegationRepository implements IDomainWideDelegationRepository { +export class DomainWideDelegationRepository { private static withParsedServiceAccountKey( domainWideDelegation: T ) { @@ -54,7 +53,7 @@ export class DomainWideDelegationRepository implements IDomainWideDelegationRepo }; } - async create(data: { + static async create(data: { domain: string; enabled: boolean; organizationId: number; @@ -81,14 +80,14 @@ export class DomainWideDelegationRepository implements IDomainWideDelegationRepo }); } - async findById({ id }: { id: string }) { + static async findById({ id }: { id: string }) { return await prisma.domainWideDelegation.findUnique({ where: { id }, select: domainWideDelegationSafeSelect, }); } - async findByIdIncludeSensitiveServiceAccountKey({ id }: { id: string }) { + static async findByIdIncludeSensitiveServiceAccountKey({ id }: { id: string }) { const domainWideDelegation = await prisma.domainWideDelegation.findUnique({ where: { id }, select: domainWideDelegationSelectIncludesServiceAccountKey, @@ -97,7 +96,7 @@ export class DomainWideDelegationRepository implements IDomainWideDelegationRepo return DomainWideDelegationRepository.withParsedServiceAccountKey(domainWideDelegation); } - async findUniqueByOrganizationMemberEmail({ email }: { email: string }) { + static async findUniqueByOrganizationMemberEmail({ email }: { email: string }) { const log = repositoryLogger.getSubLogger({ prefix: ["findUniqueByOrganizationMemberEmail"] }); log.debug("called with", { email }); const organization = await OrganizationRepository.findByMemberEmail({ email }); @@ -120,8 +119,8 @@ export class DomainWideDelegationRepository implements IDomainWideDelegationRepo return domainWideDelegation; } - async findByUser({ user }: { user: { email: string } }) { - return await this.findUniqueByOrganizationMemberEmail({ email: user.email }); + static async findByUser({ user }: { user: { email: string } }) { + return await DomainWideDelegationRepository.findUniqueByOrganizationMemberEmail({ email: user.email }); } static async findAllByDomain({ domain }: { domain: string }) { @@ -143,7 +142,7 @@ export class DomainWideDelegationRepository implements IDomainWideDelegationRepo }); } - async updateById({ + static async updateById({ id, data, }: { @@ -179,13 +178,13 @@ export class DomainWideDelegationRepository implements IDomainWideDelegationRepo }); } - async deleteById({ id }: { id: string }) { + static async deleteById({ id }: { id: string }) { return await prisma.domainWideDelegation.delete({ where: { id }, }); } - async findDelegationsWithServiceAccount({ organizationId }: { organizationId: number }) { + static async findDelegationsWithServiceAccount({ organizationId }: { organizationId: number }) { return await prisma.domainWideDelegation.findMany({ where: { organizationId }, select: { @@ -200,4 +199,21 @@ export class DomainWideDelegationRepository implements IDomainWideDelegationRepo }, }); } + + static async findByIdsIncludeSensitiveServiceAccountKey(ids: string[]) { + if (ids.length === 0) return []; + const domainWideDelegations = await prisma.domainWideDelegation.findMany({ + where: { + id: { + in: ids, + }, + enabled: true, + }, + select: domainWideDelegationSelectIncludesServiceAccountKey, + }); + + return domainWideDelegations + .map((dwd) => DomainWideDelegationRepository.withParsedServiceAccountKey(dwd)) + .filter((dwd): dwd is NonNullable => dwd !== null); + } } diff --git a/packages/lib/server/repository/selectedCalendar.ts b/packages/lib/server/repository/selectedCalendar.ts index f99563ac793022..c30f84ef72bfa4 100644 --- a/packages/lib/server/repository/selectedCalendar.ts +++ b/packages/lib/server/repository/selectedCalendar.ts @@ -4,6 +4,8 @@ import { prisma } from "@calcom/prisma"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import type { SelectedCalendarEventTypeIds } from "@calcom/types/Calendar"; +import { buildCredentialPayloadForCalendar } from "../buildCredentialPayloadForCalendar"; + export type UpdateArguments = { where: FindManyArgs["where"]; data: Prisma.SelectedCalendarUpdateManyArgs["data"]; @@ -79,18 +81,27 @@ export class SelectedCalendarRepository { // userId_integration_externalId_eventTypeId is a unique constraint but with eventTypeId being nullable // So, this unique constraint can't be used in upsert. Prisma doesn't allow that, So, we do create and update separately const conflictingCalendar = await SelectedCalendarRepository.findConflicting(data); - + const credentialPayload = buildCredentialPayloadForCalendar({ + credentialId: data.credentialId, + domainWideDelegationCredentialId: data.domainWideDelegationCredentialId, + }); if (conflictingCalendar) { return await prisma.selectedCalendar.update({ where: { id: conflictingCalendar.id, }, - data, + data: { + ...data, + ...credentialPayload, + }, }); } return await prisma.selectedCalendar.create({ - data, + data: { + ...data, + ...credentialPayload, + }, }); } @@ -207,6 +218,7 @@ export class SelectedCalendarRepository { googleChannelId, }, select: { + userId: true, credential: { select: { ...credentialForCalendarServiceSelect, @@ -217,6 +229,16 @@ export class SelectedCalendarRepository { }, }, }, + domainWideDelegationCredential: { + select: { + id: true, + selectedCalendars: { + orderBy: { + externalId: "asc", + }, + }, + }, + }, }, }); } diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index 89104f72e3dc9b..5264e60a6045fb 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -6,7 +6,6 @@ import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; import prisma from "@calcom/prisma"; -import { availabilityUserSelect } from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; import type { User as UserType } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; @@ -16,9 +15,12 @@ import type { UpId, UserProfile } from "@calcom/types/UserProfile"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "../../availability"; import slugify from "../../slugify"; +import { withSelectedCalendars } from "../withSelectedCalendars"; import { ProfileRepository } from "./profile"; import { getParsedTeam } from "./teamUtils"; +export type { UserWithLegacySelectedCalendars } from "../withSelectedCalendars"; +export { withSelectedCalendars }; export type UserAdminTeams = number[]; const log = logger.getSubLogger({ prefix: ["[repository/user]"] }); @@ -74,32 +76,6 @@ const userSelect = Prisma.validator()({ isPlatformManaged: true, }); -export type UserWithLegacySelectedCalendars = TUser & { - selectedCalendars: TCalendar[]; -}; - -type UserWithSelectedCalendars = Omit & { - allSelectedCalendars: TCalendar[]; - userLevelSelectedCalendars: TCalendar[]; -}; - -export function withSelectedCalendars< - TCalendar extends { - eventTypeId: number | null; - }, - TUser extends { - selectedCalendars: TCalendar[]; - } ->(user: UserWithLegacySelectedCalendars): UserWithSelectedCalendars { - // We are renaming selectedCalendars to allSelectedCalendars to make it clear that it contains all the calendars including eventType calendars - const { selectedCalendars, ...restUser } = user; - return { - ...restUser, - allSelectedCalendars: selectedCalendars, - userLevelSelectedCalendars: selectedCalendars.filter((calendar) => !calendar.eventTypeId), - }; -} - export class UserRepository { static async findTeamsByUserId({ userId }: { userId: UserType["id"] }) { const teamMemberships = await prisma.membership.findMany({ @@ -800,24 +776,17 @@ export class UserRepository { return withSelectedCalendars(user); } - static async findForAvailabilityCheck({ where }: { where: Prisma.UserWhereInput }) { - const user = await prisma.user.findFirst({ - where, - select: { - ...availabilityUserSelect, - selectedCalendars: true, - credentials: { - select: credentialForCalendarServiceSelect, - }, - }, + static async getAvatarUrl(id: number) { + const user = await prisma.user.findUnique({ + where: { id }, + select: { avatarUrl: true }, }); - if (!user) { + if (!user?.avatarUrl) { return null; } - const userWithSelectedCalendars = withSelectedCalendars(user); - return userWithSelectedCalendars; + return user.avatarUrl; } static async findUnlockedUserForSession({ userId }: { userId: number }) { diff --git a/packages/lib/server/withSelectedCalendars.ts b/packages/lib/server/withSelectedCalendars.ts new file mode 100644 index 00000000000000..4410d8615e1a0f --- /dev/null +++ b/packages/lib/server/withSelectedCalendars.ts @@ -0,0 +1,25 @@ +export type UserWithLegacySelectedCalendars = TUser & { + selectedCalendars: TCalendar[]; +}; + +type UserWithSelectedCalendars = Omit & { + allSelectedCalendars: TCalendar[]; + userLevelSelectedCalendars: TCalendar[]; +}; + +export function withSelectedCalendars< + TCalendar extends { + eventTypeId: number | null; + }, + TUser extends { + selectedCalendars: TCalendar[]; + } +>(user: UserWithLegacySelectedCalendars): UserWithSelectedCalendars { + // We are renaming selectedCalendars to allSelectedCalendars to make it clear that it contains all the calendars including eventType calendars + const { selectedCalendars, ...restUser } = user; + return { + ...restUser, + allSelectedCalendars: selectedCalendars, + userLevelSelectedCalendars: selectedCalendars.filter((calendar) => !calendar.eventTypeId), + }; +} diff --git a/packages/platform/atoms/selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper.tsx b/packages/platform/atoms/selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper.tsx index a194a2164fa2d6..eb84bfde3b9d8d 100644 --- a/packages/platform/atoms/selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper.tsx +++ b/packages/platform/atoms/selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper.tsx @@ -91,6 +91,7 @@ export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSett } className="border-subtle mt-4 rounded-lg border" actions={ + !connectedCalendar.domainWideDelegationCredentialId && !disableConnectionModification && (
))} @@ -140,19 +144,21 @@ export const SelectedCalendarsSettingsWebWrapper = (props: SelectedCalendarsSett {connectedCalendar.integration.name} - : {t("calendar_error")} + : {connectedCalendar.error?.message || t("calendar_error")} } iconClassName="h-10 w-10 ml-2 mr-1 mt-0.5" actions={ -
- -
+ !connectedCalendar.domainWideDelegationCredentialId && ( +
+ +
+ ) } /> ); diff --git a/packages/prisma/migrations/20250111101435_add_user_id_calendar_cache/migration.sql b/packages/prisma/migrations/20250111101435_add_user_id_calendar_cache/migration.sql new file mode 100644 index 00000000000000..01911998395caa --- /dev/null +++ b/packages/prisma/migrations/20250111101435_add_user_id_calendar_cache/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "CalendarCache" ADD COLUMN "userId" INTEGER; + +-- AddForeignKey +ALTER TABLE "CalendarCache" ADD CONSTRAINT "CalendarCache_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/migrations/20250111121918_add_id_calendar_cache/migration.sql b/packages/prisma/migrations/20250111121918_add_id_calendar_cache/migration.sql new file mode 100644 index 00000000000000..64a32cbdddd704 --- /dev/null +++ b/packages/prisma/migrations/20250111121918_add_id_calendar_cache/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - The primary key for the `CalendarCache` table will be changed. If it partially fails, the table could be left without primary key constraint. + - The required column `id` was added to the `CalendarCache` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. + +*/ +-- AlterTable +ALTER TABLE "CalendarCache" DROP CONSTRAINT "CalendarCache_pkey", +ADD COLUMN "id" TEXT NOT NULL, +ADD CONSTRAINT "CalendarCache_pkey" PRIMARY KEY ("id"); diff --git a/packages/prisma/migrations/20250111122003_make_credential_id_optional/migration.sql b/packages/prisma/migrations/20250111122003_make_credential_id_optional/migration.sql new file mode 100644 index 00000000000000..001b86b86ee3fa --- /dev/null +++ b/packages/prisma/migrations/20250111122003_make_credential_id_optional/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "CalendarCache" ALTER COLUMN "credentialId" DROP NOT NULL; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 8b4fe4b31a7084..47d33e362fcd88 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1,6 +1,8 @@ // This is your Prisma Schema file // learn more about it in the docs: https://pris.ly/d/prisma-schema +// Comment to purge cache + datasource db { provider = "postgresql" url = env("DATABASE_URL") @@ -366,7 +368,7 @@ model User { updatedTranslations EventTypeTranslation[] @relation("UpdatedEventTypeTranslations") createdWatchlists Watchlist[] @relation("CreatedWatchlists") updatedWatchlists Watchlist[] @relation("UpdatedWatchlists") - + calendarCache CalendarCache[] @@unique([email]) @@unique([email, username]) @@unique([username, organizationId]) @@ -1388,14 +1390,17 @@ view BookingTimeStatus { } model CalendarCache { + id String @id @default(uuid()) // The key would be the unique URL that is requested by the user key String value Json expiresAt DateTime - credentialId Int + credentialId Int? credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade) + + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - @@id([credentialId, key]) @@unique([credentialId, key]) } diff --git a/packages/trpc/react/shared.ts b/packages/trpc/react/shared.ts index 5eb0a52e3fd6c5..6290b3c6d57fd6 100644 --- a/packages/trpc/react/shared.ts +++ b/packages/trpc/react/shared.ts @@ -30,6 +30,7 @@ export const ENDPOINTS = [ "googleWorkspace", "oAuth", "attributes", + "domainWideDelegation", "routingForms", "domainWideDelegation", ] as const; diff --git a/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts index 114c25e4c31739..93724eddde9ae1 100644 --- a/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/appCredentialsByType.handler.ts @@ -1,3 +1,4 @@ +import { getAllDomainWideDelegationCredentialsForUserByAppType } from "@calcom/lib/domainWideDelegation/server"; import { UserRepository } from "@calcom/lib/server/repository/user"; import { prisma } from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; @@ -31,10 +32,15 @@ export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentials }, }); + const domainWideDelegationCredentials = await getAllDomainWideDelegationCredentialsForUserByAppType({ + user: { id: user.id, email: user.email }, + appType: input.appType, + }); + // For app pages need to return which teams the user can install the app on // return user.credentials.filter((app) => app.type == input.appType).map((credential) => credential.id); return { - credentials, + credentials: [...credentials, ...domainWideDelegationCredentials], userAdminTeams: userAdminTeamsIds, }; }; diff --git a/packages/trpc/server/routers/loggedInViewer/getUserTopBanners.handler.ts b/packages/trpc/server/routers/loggedInViewer/getUserTopBanners.handler.ts index dcccb917c290d1..d33f1c3ad17c8e 100644 --- a/packages/trpc/server/routers/loggedInViewer/getUserTopBanners.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/getUserTopBanners.handler.ts @@ -23,7 +23,7 @@ const checkInvalidGoogleCalendarCredentials = async ({ ctx }: Props) => { select: credentialForCalendarServiceSelect, }); - const calendarCredentials = getCalendarCredentials(userCredentials); + const calendarCredentials = await getCalendarCredentials(userCredentials); const { connectedCalendars } = await getConnectedCalendars( calendarCredentials, diff --git a/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts b/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts index 8a0fa0b8b41944..f2951bcc1267bf 100644 --- a/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts +++ b/packages/trpc/server/routers/loggedInViewer/integrations.schema.ts @@ -10,6 +10,7 @@ export const ZIntegrationsInputSchema = z.object({ extendsFeature: z.literal("EventType").optional(), teamId: z.union([z.number(), z.null()]).optional(), sortByMostPopular: z.boolean().optional(), + sortByInstalledFirst: z.boolean().optional(), categories: z.nativeEnum(AppCategories).array().optional(), appId: z.string().optional(), }); diff --git a/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.test.ts b/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.test.ts new file mode 100644 index 00000000000000..d261f079b74496 --- /dev/null +++ b/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.test.ts @@ -0,0 +1,208 @@ +import prisma from "../../../../../tests/libs/__mocks__/prisma"; + +import { + createBookingScenario, + TestData, + getOrganizer, + getScenarioData, + createOrganization, + createDwdCredential, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; + +import { describe, it, vi, expect } from "vitest"; + +import { getConnectedCalendars } from "@calcom/core/CalendarManager"; +import { SchedulingType, MembershipRole } from "@calcom/prisma/enums"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../trpc"; +import { setDestinationCalendarHandler } from "./setDestinationCalendar.handler"; + +vi.mock("@calcom/core/CalendarManager", () => ({ + getConnectedCalendars: vi.fn(), + getCalendarCredentials: vi.fn().mockImplementation((creds) => creds), +})); + +describe("setDestinationCalendarHandler", () => { + setupAndTeardown(); + + it("should successfully set destination calendar with DWD credentials", async () => { + const org = await createOrganization({ + name: "Test Org", + slug: "testorg", + }); + + const childTeam = { + id: 202, + name: "Team 1", + slug: "team-1", + parentId: org.id, + }; + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + teams: [ + { + membership: { + accepted: true, + role: MembershipRole.ADMIN, + }, + team: { + id: org.id, + name: "Test Org", + slug: "testorg", + }, + }, + { + membership: { + accepted: true, + role: MembershipRole.ADMIN, + }, + team: { + id: childTeam.id, + name: "Team 1", + slug: "team-1", + parentId: org.id, + }, + }, + ], + }); + + const dwd = await createDwdCredential(org.id); + + const dwdCredentialId = dwd.id; + const testExternalId = "TEST@group.calendar.google.com"; + + // Mock the getConnectedCalendars response + (getConnectedCalendars as jest.Mock).mockResolvedValue({ + connectedCalendars: [ + { + calendars: [ + { + externalId: organizer.email, + integration: "google_calendar", + readOnly: false, + primary: true, + email: organizer.email, + credentialId: -1, + domainWideDelegationCredentialId: dwdCredentialId, + }, + { + externalId: testExternalId, + integration: "google_calendar", + readOnly: false, + primary: null, + email: organizer.email, + credentialId: -1, + domainWideDelegationCredentialId: dwdCredentialId, + }, + ], + }, + ], + }); + + await createBookingScenario( + getScenarioData( + { + organizer, + eventTypes: [ + { + id: 1, + teamId: childTeam.id, + schedulingType: SchedulingType.ROUND_ROBIN, + length: 30, + hosts: [ + { + userId: 101, + isFixed: false, + }, + ], + }, + ], + selectedCalendars: [ + { + integration: "google_calendar", + externalId: testExternalId, + credentialId: null, + domainWideDelegationCredentialId: dwdCredentialId, + }, + ], + }, + org + ) + ); + + const ctx = { + user: { + id: organizer.id, + email: organizer.email, + selectedCalendars: [ + { + integration: "google_calendar", + externalId: testExternalId, + credentialId: null, + domainWideDelegationCredentialId: dwdCredentialId, + }, + ], + } as NonNullable, + }; + + await setDestinationCalendarHandler({ + ctx, + input: { + integration: "google_calendar", + externalId: testExternalId, + }, + }); + + // Verify the destination calendar was set correctly + const destinationCalendar = await prisma.destinationCalendar.findFirst({ + where: { + userId: organizer.id, + }, + }); + + expect(destinationCalendar).toEqual( + expect.objectContaining({ + integration: "google_calendar", + externalId: testExternalId, + credentialId: null, + domainWideDelegationCredentialId: dwdCredentialId, + }) + ); + }); + + it("should throw error when calendar is not found", async () => { + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + }); + + const ctx = { + user: { + id: organizer.id, + email: organizer.email, + selectedCalendars: [], + } as NonNullable, + }; + + await expect( + setDestinationCalendarHandler({ + ctx, + input: { + integration: "google_calendar", + externalId: "non-existent-calendar", + eventTypeId: null, + }, + }) + ).rejects.toThrow( + new TRPCError({ code: "BAD_REQUEST", message: "Could not find calendar non-existent-calendar" }) + ); + }); +}); diff --git a/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts b/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts index 27ffe7c171f0a1..76c11e8e22454b 100644 --- a/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/setDestinationCalendar.handler.ts @@ -1,5 +1,6 @@ import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager"; import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials"; +import { DestinationCalendarRepository } from "@calcom/lib/server/repository/destinationCalendar"; import { prisma } from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; @@ -10,6 +11,7 @@ import type { TSetDestinationCalendarInputSchema } from "./setDestinationCalenda type SessionUser = NonNullable; type User = { id: SessionUser["id"]; + email: SessionUser["email"]; userLevelSelectedCalendars: SessionUser["userLevelSelectedCalendars"]; }; @@ -20,28 +22,68 @@ type SetDestinationCalendarOptions = { input: TSetDestinationCalendarInputSchema; }; +type ConnectedCalendar = Awaited>["connectedCalendars"][number]; +type ConnectedCalendarCalendar = NonNullable[number]; +export const getFirstConnectedCalendar = ({ + connectedCalendars, + matcher, +}: { + connectedCalendars: ConnectedCalendar[]; + matcher: (calendar: ConnectedCalendarCalendar) => boolean; +}) => { + const calendars = connectedCalendars.flatMap((c) => c.calendars ?? []); + const matchingCalendars = calendars.filter(matcher); + const dwdCredentialCalendar = matchingCalendars.find((cal) => !!cal.domainWideDelegationCredentialId); + + // Prefer DWD credential calendar as there could be other one due to existing connections even after DWD is enabled. + if (dwdCredentialCalendar) { + return dwdCredentialCalendar; + } else { + return matchingCalendars[0]; + } +}; + +/** + * It identifies the destination calendar by externalId, integration and eventTypeId and doesn't consider the `credentialId` or destinationCalendar.id + * Also, DestinationCalendar doesn't have unique constraint on externalId, integration and eventTypeId, so there could be multiple destinationCalendars with same externalId, integration and eventTypeId in DB. + * So, it could update any of the destinationCalendar when there are duplicates in DB. Ideally we should have unique constraint on externalId, integration and eventTypeId. + * + * With the addition of DWD credential, it adds another dimension to the problem. + * A user could have DWD and non-DWD credential for the same calendar and he might be selecting DWD credential connected calendar but it could still be set with nullish destinationCalendar.domainWideDelegationCredentialId. + */ export const setDestinationCalendarHandler = async ({ ctx, input }: SetDestinationCalendarOptions) => { const { user } = ctx; const { integration, externalId, eventTypeId } = input; const credentials = await getUsersCredentials(user); - const calendarCredentials = getCalendarCredentials(credentials); + const calendarCredentials = await getCalendarCredentials(credentials); const { connectedCalendars } = await getConnectedCalendars( calendarCredentials, user.userLevelSelectedCalendars ); + const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat(); - const credentialId = allCals.find( - (cal) => cal.externalId === externalId && cal.integration === integration && cal.readOnly === false - )?.credentialId; + const firstConnectedCalendar = getFirstConnectedCalendar({ + connectedCalendars, + matcher: (cal) => + cal.externalId === externalId && cal.integration === integration && cal.readOnly === false, + }); + + const { credentialId, domainWideDelegationCredentialId } = firstConnectedCalendar || {}; - if (!credentialId) { + let where; + + if (!credentialId && !domainWideDelegationCredentialId) { throw new TRPCError({ code: "BAD_REQUEST", message: `Could not find calendar ${input.externalId}` }); } - const primaryEmail = allCals.find((cal) => cal.primary && cal.credentialId === credentialId)?.email ?? null; - - let where; + const primaryEmail = + allCals.find( + (cal) => + cal.primary && + (cal.credentialId === credentialId || + cal.domainWideDelegationCredentialId === domainWideDelegationCredentialId) + )?.email ?? null; if (eventTypeId) { if ( @@ -61,20 +103,22 @@ export const setDestinationCalendarHandler = async ({ ctx, input }: SetDestinati where = { eventTypeId }; } else where = { userId: user.id }; - await prisma.destinationCalendar.upsert({ + await DestinationCalendarRepository.upsert({ where, update: { integration, externalId, - credentialId, primaryEmail, + credentialId, + domainWideDelegationCredentialId, }, create: { ...where, integration, externalId, - credentialId, primaryEmail, + credentialId, + domainWideDelegationCredentialId, }, }); }; diff --git a/packages/trpc/server/routers/viewer/apps/queryForDependencies.handler.ts b/packages/trpc/server/routers/viewer/apps/queryForDependencies.handler.ts index 5e78c83c6454e8..263318eaf8f93e 100644 --- a/packages/trpc/server/routers/viewer/apps/queryForDependencies.handler.ts +++ b/packages/trpc/server/routers/viewer/apps/queryForDependencies.handler.ts @@ -1,4 +1,5 @@ import { getAppFromSlug } from "@calcom/app-store/utils"; +import { getAllDomainWideDelegationCredentialsForUserByAppSlug } from "@calcom/lib/domainWideDelegation/server"; import { prisma } from "@calcom/prisma"; import type { TrpcSessionUser } from "../../../trpc"; @@ -18,14 +19,21 @@ export const queryForDependenciesHandler = async ({ ctx, input }: QueryForDepend await Promise.all( input.map(async (dependency) => { - const appInstalled = await prisma.credential.findFirst({ + const appId = dependency; + const dbCredential = await prisma.credential.findFirst({ where: { - appId: dependency, + appId, userId: ctx.user.id, }, }); - const app = await getAppFromSlug(dependency); + const domainWideDelegationCredentials = await getAllDomainWideDelegationCredentialsForUserByAppSlug({ + user: ctx.user, + appSlug: appId, + }); + const appInstalled = !!dbCredential || !!domainWideDelegationCredentials.length; + + const app = getAppFromSlug(dependency); dependencyData.push({ name: app?.name || dependency, slug: dependency, installed: !!appInstalled }); }) diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts index d265c4045132a9..3c9a4c5610a036 100644 --- a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts @@ -126,7 +126,7 @@ async function getAllCredentials({ user, conferenceCredentialId, }: { - user: { id: number }; + user: { id: number; email: string }; conferenceCredentialId: number | null; }) { const credentials = await getUsersCredentials(user); diff --git a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts index e5f586bfc565f3..25e63c010bd14b 100644 --- a/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/requestReschedule.handler.ts @@ -18,7 +18,7 @@ import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server"; -import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials"; +import { getUsersCredentialsForCalendarService } from "@calcom/lib/server/getUsersCredentials"; import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; import { prisma } from "@calcom/prisma"; import type { WebhookTriggerEvents } from "@calcom/prisma/enums"; @@ -223,9 +223,9 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule // Handling calendar and videos cancellation // This can set previous time as available, until virtual calendar is done - const credentials = await getUsersCredentials(user); + const credentialsForCalendarService = await getUsersCredentialsForCalendarService(user); const credentialsMap = new Map(); - credentials.forEach((credential) => { + credentialsForCalendarService.forEach((credential) => { credentialsMap.set(credential.type, credential); }); const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter((ref) => @@ -239,12 +239,12 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule if (bookingRef.type.endsWith("_calendar")) { const calendar = await getCalendar( - credentials.find((cred) => cred.id === bookingRef?.credentialId) || null + credentialsForCalendarService.find((cred) => cred.id === bookingRef?.credentialId) || null ); return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent, bookingRef.externalCalendarId); } else if (bookingRef.type.endsWith("_video")) { return deleteMeeting( - credentials.find((cred) => cred?.id === bookingRef?.credentialId) || null, + credentialsForCalendarService.find((cred) => cred?.id === bookingRef?.credentialId) || null, bookingRef.uid ); } diff --git a/packages/trpc/server/routers/viewer/domainWideDelegation/add.handler.ts b/packages/trpc/server/routers/viewer/domainWideDelegation/add.handler.ts index f1812d9c871963..4017e228015301 100644 --- a/packages/trpc/server/routers/viewer/domainWideDelegation/add.handler.ts +++ b/packages/trpc/server/routers/viewer/domainWideDelegation/add.handler.ts @@ -1,6 +1,6 @@ import type { z } from "zod"; -import { DomainWideDelegation } from "@calcom/features/domain-wide-delegation/domain-wide-delegation"; +import { DomainWideDelegationRepository } from "@calcom/lib/server/repository/domainWideDelegation"; import { WorkspacePlatformRepository } from "@calcom/lib/server/repository/workspacePlatform"; import { TRPCError } from "@trpc/server"; @@ -48,9 +48,7 @@ export default async function handler({ dwdBeingUpdatedId: null, }); - const domainWideDelegationRepository = await DomainWideDelegation.init(user.id, organizationId); - - const createdDelegation = await domainWideDelegationRepository.create({ + const createdDelegation = await DomainWideDelegationRepository.create({ workspacePlatformId: workspacePlatform.id, domain, // We don't want to enable by default because enabling requires some checks to be completed and it has a separate flow. diff --git a/packages/trpc/server/routers/viewer/domainWideDelegation/delete.handler.ts b/packages/trpc/server/routers/viewer/domainWideDelegation/delete.handler.ts index 85b5c9d6f8dfef..58df0e3da6fc91 100644 --- a/packages/trpc/server/routers/viewer/domainWideDelegation/delete.handler.ts +++ b/packages/trpc/server/routers/viewer/domainWideDelegation/delete.handler.ts @@ -1,23 +1,21 @@ import type z from "zod"; -import { DomainWideDelegation } from "@calcom/features/domain-wide-delegation/domain-wide-delegation"; +import { DomainWideDelegationRepository } from "@calcom/lib/server/repository/domainWideDelegation"; + +import { TRPCError } from "@trpc/server"; import type { DomainWideDelegationDeleteSchema } from "./schema"; export default async function handler({ - ctx, input, }: { input: z.infer; - ctx: { user: { id: number; organizationId: number | null } }; }) { const { id } = input; - const domainWideDelegationRepository = await DomainWideDelegation.init( - ctx.user.id, - ctx.user.organizationId - ); - await domainWideDelegationRepository.deleteById({ id }); + // We might want to consider allowing this in the future. Right now, toggling off DWD achieves similar but non-destructive effect + throw new TRPCError({ code: "BAD_REQUEST", message: "Not allowed" }); + await DomainWideDelegationRepository.deleteById({ id }); return { id }; } diff --git a/packages/trpc/server/routers/viewer/domainWideDelegation/list.handler.ts b/packages/trpc/server/routers/viewer/domainWideDelegation/list.handler.ts index ae1371084a1b41..8fc3fc15e4753c 100644 --- a/packages/trpc/server/routers/viewer/domainWideDelegation/list.handler.ts +++ b/packages/trpc/server/routers/viewer/domainWideDelegation/list.handler.ts @@ -1,4 +1,4 @@ -import { DomainWideDelegation } from "@calcom/features/domain-wide-delegation/domain-wide-delegation"; +import { DomainWideDelegationRepository } from "@calcom/lib/server/repository/domainWideDelegation"; import type { PrismaClient } from "@calcom/prisma"; import { serviceAccountKeySchema } from "@calcom/prisma/zod-utils"; @@ -16,9 +16,7 @@ export default async function handler({ if (!organizationId) { throw new Error("You must be in an organization to list domain wide delegations"); } - const domainWideDelegationRepository = await DomainWideDelegation.init(user.id, organizationId); - - const domainWideDelegations = await domainWideDelegationRepository.findDelegationsWithServiceAccount({ + const domainWideDelegations = await DomainWideDelegationRepository.findDelegationsWithServiceAccount({ organizationId, }); diff --git a/packages/trpc/server/routers/viewer/domainWideDelegation/toggleEnabled.handler.ts b/packages/trpc/server/routers/viewer/domainWideDelegation/toggleEnabled.handler.ts index a21cfafdb9a0e2..17a513a1502378 100644 --- a/packages/trpc/server/routers/viewer/domainWideDelegation/toggleEnabled.handler.ts +++ b/packages/trpc/server/routers/viewer/domainWideDelegation/toggleEnabled.handler.ts @@ -1,11 +1,18 @@ import type { z } from "zod"; -import { DomainWideDelegation } from "@calcom/features/domain-wide-delegation/domain-wide-delegation"; +import { checkIfSuccessfullyConfiguredInWorkspace } from "@calcom/lib/domainWideDelegation/server"; +import { DomainWideDelegationRepository } from "@calcom/lib/server/repository/domainWideDelegation"; +import type { ServiceAccountKey } from "@calcom/lib/server/repository/domainWideDelegation"; -// import { checkIfSuccessfullyConfiguredInWorkspace } from "@calcom/lib/domainWideDelegation/server"; import type { DomainWideDelegationToggleEnabledSchema } from "./schema"; import { ensureNoServiceAccountKey } from "./utils"; +function hasServiceAccountKey( + domainWideDelegation: T +): domainWideDelegation is T & { serviceAccountKey: ServiceAccountKey } { + return domainWideDelegation.serviceAccountKey !== null; +} + const assertWorkspaceConfigured = async ({ domainWideDelegationId, user, @@ -13,21 +20,28 @@ const assertWorkspaceConfigured = async ({ domainWideDelegationId: string; user: { id: number; email: string; organizationId: number | null }; }) => { - const domainWideDelegationRepository = await DomainWideDelegation.init(user.id, user.organizationId); - const domainWideDelegation = await domainWideDelegationRepository.findById({ id: domainWideDelegationId }); + const domainWideDelegation = await DomainWideDelegationRepository.findByIdIncludeSensitiveServiceAccountKey( + { + id: domainWideDelegationId, + } + ); + if (!domainWideDelegation) { throw new Error("Domain wide delegation not found"); } - // TODO: Uncomment later - // const isSuccessfullyConfigured = await checkIfSuccessfullyConfiguredInWorkspace({ - // domainWideDelegation, - // user, - // }); + if (!hasServiceAccountKey(domainWideDelegation)) { + throw new Error("Domain wide delegation doesn't have service account key"); + } + + const isSuccessfullyConfigured = await checkIfSuccessfullyConfiguredInWorkspace({ + domainWideDelegation, + user, + }); - // if (!isSuccessfullyConfigured) { - // throw new Error("Workspace not successfully configured"); - // } + if (!isSuccessfullyConfigured) { + throw new Error("Workspace not successfully configured"); + } }; export default async function toggleEnabledHandler({ @@ -40,15 +54,13 @@ export default async function toggleEnabledHandler({ const { user: loggedInUser } = ctx; if (input.enabled) { - await assertWorkspaceConfigured({ domainWideDelegationId: input.id, user: loggedInUser }); + await assertWorkspaceConfigured({ + domainWideDelegationId: input.id, + user: loggedInUser, + }); } - const domainWideDelegationRepository = await DomainWideDelegation.init( - loggedInUser.id, - loggedInUser.organizationId - ); - - const updatedDomainWideDelegation = await domainWideDelegationRepository.updateById({ + const updatedDomainWideDelegation = await DomainWideDelegationRepository.updateById({ id: input.id, data: { enabled: input.enabled, diff --git a/packages/trpc/server/routers/viewer/domainWideDelegation/update.handler.ts b/packages/trpc/server/routers/viewer/domainWideDelegation/update.handler.ts index e8cab41fc5f0fe..bc54daffc3b7dc 100644 --- a/packages/trpc/server/routers/viewer/domainWideDelegation/update.handler.ts +++ b/packages/trpc/server/routers/viewer/domainWideDelegation/update.handler.ts @@ -1,6 +1,6 @@ import type { z } from "zod"; -import { DomainWideDelegation } from "@calcom/features/domain-wide-delegation/domain-wide-delegation"; +import { DomainWideDelegationRepository } from "@calcom/lib/server/repository/domainWideDelegation"; import { WorkspacePlatformRepository } from "@calcom/lib/server/repository/workspacePlatform"; import { TRPCError } from "@calcom/trpc/server"; @@ -47,9 +47,7 @@ export default async function handler({ }); } - const domainWideDelegationRepository = await DomainWideDelegation.init(user.id, organizationId); - - const updatedDelegation = await domainWideDelegationRepository.updateById({ + const updatedDelegation = await DomainWideDelegationRepository.updateById({ id, data: { workspacePlatformId: workspacePlatform.id, diff --git a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts index 4e3368b740fd0c..02ae1147e8938d 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/create.handler.ts @@ -24,6 +24,7 @@ type User = { id: SessionUser["id"] | null; }; metadata: SessionUser["metadata"]; + email: SessionUser["email"]; }; type CreateOptions = { diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index ab978caac14faf..700ab2942a653d 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -15,6 +15,7 @@ import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts"; import type { PrismaClient } from "@calcom/prisma"; import { WorkflowTriggerEvents } from "@calcom/prisma/client"; import { SchedulingType, EventTypeAutoTranslatedField } from "@calcom/prisma/enums"; +import { eventTypeAppMetadataOptionalSchema } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; @@ -27,7 +28,6 @@ import { handleCustomInputs, handlePeriodType, } from "./util"; -import { eventTypeAppMetadataOptionalSchema } from "@calcom/prisma/zod-utils"; type SessionUser = NonNullable; type User = { @@ -38,6 +38,7 @@ type User = { }; userLevelSelectedCalendars: SessionUser["userLevelSelectedCalendars"]; organizationId: number | null; + email: SessionUser["email"]; locale: string; }; diff --git a/packages/trpc/server/routers/viewer/slots/util.ts b/packages/trpc/server/routers/viewer/slots/util.ts index 2da2a8bbf32334..8f3fc183a5e53b 100644 --- a/packages/trpc/server/routers/viewer/slots/util.ts +++ b/packages/trpc/server/routers/viewer/slots/util.ts @@ -24,6 +24,7 @@ import { isRerouting, shouldIgnoreContactOwner } from "@calcom/lib/bookings/rout import { RESERVED_SUBDOMAINS } from "@calcom/lib/constants"; import { getUTCOffsetByTimezone } from "@calcom/lib/date-fns"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; +import { getAllDomainWideDelegationCalendarCredentialsForUser } from "@calcom/lib/domainWideDelegation/server"; import { calculatePeriodLimits, isTimeOutOfBounds, @@ -256,6 +257,35 @@ export function getUsersWithCredentialsConsideringContactOwner({ return contactOwnerAndFixedHosts; } +async function getEnrichedUsersWithCredentialsConsideringContactOwner({ + contactOwnerEmail, + hosts, +}: { + contactOwnerEmail: string | null | undefined; + hosts: { + isFixed?: boolean; + user: GetAvailabilityUser; + }[]; +}) { + const hostsWithContactOwner = getUsersWithCredentialsConsideringContactOwner({ + contactOwnerEmail, + hosts, + }); + + const hostsWithDwdCredentials = await Promise.all( + hostsWithContactOwner.map(async (host) => { + const dwdCredentials = await getAllDomainWideDelegationCalendarCredentialsForUser({ + user: host, + }); + return { + ...host, + credentials: [...host.credentials, ...dwdCredentials], + }; + }) + ); + return hostsWithDwdCredentials; +} + const getStartTime = (startTimeInput: string, timeZone?: string, minimumBookingNotice?: number) => { const startTimeMin = dayjs.utc().add(minimumBookingNotice || 1, "minutes"); const startTime = timeZone === "Etc/GMT" ? dayjs.utc(startTimeInput) : dayjs(startTimeInput).tz(timeZone); @@ -696,7 +726,7 @@ async function getExistingBookings( in: "ACCEPTED"[]; }; }, - usersWithCredentials: ReturnType, + usersWithCredentials: Awaited>, allUserIds: number[] ) { const bookingsSelect = Prisma.validator()({ @@ -909,10 +939,13 @@ const calculateHostsAndAvailabilities = async ({ hosts = hosts.filter((host) => host.user.id === originalRescheduledBooking?.userId || 0); } - const usersWithCredentials = monitorCallbackSync(getUsersWithCredentialsConsideringContactOwner, { - contactOwnerEmail, - hosts, - }); + const usersWithCredentials = await monitorCallbackAsync( + getEnrichedUsersWithCredentialsConsideringContactOwner, + { + contactOwnerEmail, + hosts, + } + ); loggerWithEventDetails.debug("Using users", { usersWithCredentials: usersWithCredentials.map((user) => user.email), diff --git a/packages/trpc/tsconfig.json b/packages/trpc/tsconfig.json index e75cc122266758..3659a687b306da 100644 --- a/packages/trpc/tsconfig.json +++ b/packages/trpc/tsconfig.json @@ -15,7 +15,8 @@ "@calcom/platform-types": ["../platform/types/index.ts"], "@calcom/platform-utils": ["../platform/utils/index.ts"], "@calcom/prisma": ["../prisma"], - "@calcom/prisma/*": ["../prisma/*"] + "@calcom/prisma/*": ["../prisma/*"], + "@calcom/repository/*": ["../lib/server/repository/*"] }, "include": [ "../types/@wojtekmaj__react-daterange-picker.d.ts", diff --git a/packages/types/App.d.ts b/packages/types/App.d.ts index 62b14dcc8904e8..1ea589feb332d4 100644 --- a/packages/types/App.d.ts +++ b/packages/types/App.d.ts @@ -167,6 +167,12 @@ export interface App { createdAt?: string; /** Specifies if the App uses an OAuth flow */ isOAuth?: boolean; + /** + * Specifies if the App supports domain-wide delegation + */ + domainWideDelegation?: { + workspacePlatformSlug: string; + }; } export type AppFrontendPayload = Omit & { diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index e1791b768e1a5f..fd0b6f67522ad4 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -15,7 +15,7 @@ import type { Calendar } from "@calcom/features/calendars/weeklyview"; import type { TimeFormat } from "@calcom/lib/timeFormat"; import type { SchedulingType } from "@calcom/prisma/enums"; import type { Frequency } from "@calcom/prisma/zod-utils"; -import type { CredentialPayload } from "@calcom/types/Credential"; +import type { CredentialForCalendarService } from "@calcom/types/Credential"; import type { Ensure } from "./utils"; @@ -80,6 +80,7 @@ export type NewCalendarEventType = { location?: string | null; hangoutLink?: string | null; conferenceData?: ConferenceData; + delegatedToId?: string | null; }; export type CalendarEventType = { @@ -254,7 +255,11 @@ export interface IntegrationCalendar extends Ensure, export type SelectedCalendarEventTypeIds = (number | null)[]; export interface Calendar { - createEvent(event: CalendarEvent, credentialId: number): Promise; + createEvent( + event: CalendarEvent, + credentialId: number, + overrideExternalIdForDelegatedCredential?: string + ): Promise; updateEvent( uid: string, @@ -282,6 +287,8 @@ export interface Calendar { listCalendars(event?: CalendarEvent): Promise; + testDomainWideDelegationSetup?(): Promise; + watchCalendar?(options: { calendarId: string; eventTypeIds: SelectedCalendarEventTypeIds; @@ -297,7 +304,7 @@ export interface Calendar { */ type Class = new (...args: Args) => I; -export type CalendarClass = Class; +export type CalendarClass = Class; export type SelectedCalendar = Pick< _SelectedCalendar, diff --git a/packages/types/Credential.d.ts b/packages/types/Credential.d.ts index b794bbee3f30b7..c983f99c958613 100644 --- a/packages/types/Credential.d.ts +++ b/packages/types/Credential.d.ts @@ -7,7 +7,19 @@ import type { Prisma } from "@prisma/client"; */ export type CredentialPayload = Prisma.CredentialGetPayload<{ select: typeof import("@calcom/prisma/selects/credential").credentialForCalendarServiceSelect; -}>; +}> & { + delegatedToId?: string | null; +}; + +export type CredentialForCalendarService = CredentialPayload & { + delegatedTo: { + serviceAccountKey: { + client_email: string; + client_id: string; + private_key: string; + }; + } | null; +}; export type CredentialFrontendPayload = Omit & { /** We should type error if keys are leaked to the frontend */ diff --git a/packages/types/EventManager.d.ts b/packages/types/EventManager.d.ts index 3c133cc93d63ee..db08f4e2b9bf44 100644 --- a/packages/types/EventManager.d.ts +++ b/packages/types/EventManager.d.ts @@ -10,6 +10,7 @@ export interface PartialReference { meetingUrl?: string | null; externalCalendarId?: string | null; credentialId?: number | null; + domainWideDelegationCredentialId?: string | null; } export interface EventResult { @@ -24,6 +25,7 @@ export interface EventResult { calError?: string; calWarnings?: string[]; credentialId?: number; + delegatedToId?: string | null; externalId?: string | null; } diff --git a/packages/ui/components/disconnect-calendar-integration/DisconnectIntegration.tsx b/packages/ui/components/disconnect-calendar-integration/DisconnectIntegration.tsx index be3209877a0ee6..52cb674d513aa5 100644 --- a/packages/ui/components/disconnect-calendar-integration/DisconnectIntegration.tsx +++ b/packages/ui/components/disconnect-calendar-integration/DisconnectIntegration.tsx @@ -10,6 +10,7 @@ export const DisconnectIntegrationComponent = ({ onModalOpen, onDeletionConfirmation, buttonProps, + disabled, }: { label?: string; trashIcon?: boolean; @@ -18,6 +19,7 @@ export const DisconnectIntegrationComponent = ({ onModalOpen: () => void; onDeletionConfirmation: () => void; buttonProps?: ButtonProps; + disabled?: boolean; }) => { const { t } = useLocale(); @@ -30,7 +32,7 @@ export const DisconnectIntegrationComponent = ({ StartIcon={!trashIcon ? undefined : "trash"} size="base" variant={trashIcon && !label ? "icon" : "button"} - disabled={isGlobal} + disabled={isGlobal || disabled} {...buttonProps}> {label}