feat: Reduce bundle size via better imports + install gcal + gvideo by default for google signups (#17810)

* wip

* use light imports

* fix

* perf: Reduce bundle size via better imports with `googleapis` (#17811)

* fix test

* refactor

* update vite.config.js

* fix

* fix unit test failing
This commit is contained in:
Benny Joo
2024-12-06 02:51:48 +00:00
committed by GitHub
parent f75415e414
commit b8219c273d
27 changed files with 383 additions and 279 deletions
+1 -1
View File
@@ -119,7 +119,7 @@ NEXT_PUBLIC_FRESHCHAT_HOST=
# Google OAuth credentials
# To enable Login with Google you need to:
# 1. Set `GOOGLE_API_CREDENTIALS` above
# 1. Set `GOOGLE_API_CREDENTIALS` below
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
@@ -10,7 +10,8 @@ import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Prisma } from "@prisma/client";
import { Request } from "express";
import { google } from "googleapis";
import { OAuth2Client } from "googleapis-common";
import { calendar_v3 } from "@googleapis/calendar";
import { z } from "zod";
import { SUCCESS_STATUS, GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants";
@@ -78,7 +79,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys);
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
const oAuth2Client = new OAuth2Client(client_id, client_secret, redirectUri);
return oAuth2Client;
}
@@ -134,8 +135,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
oAuth2Client.setCredentials(key);
const calendar = google.calendar({
version: "v3",
const calendar = new calendar_v3.Calendar({
auth: oAuth2Client,
});
@@ -27,10 +27,7 @@ import {
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { Prisma } from "@prisma/client";
import { Request } from "express";
import { google } from "googleapis";
import { z } from "zod";
import { APPS_READ, GOOGLE_CALENDAR_TYPE, SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -1,6 +1,6 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { google } from "googleapis";
import { OAuth2Client } from "googleapis-common";
import { z } from "zod";
@Injectable()
@@ -21,7 +21,7 @@ export class GCalService {
const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys);
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
const oAuth2Client = new OAuth2Client(client_id, client_secret, redirectUri);
return oAuth2Client;
}
}
+4 -1
View File
@@ -48,6 +48,9 @@
"@dub/analytics": "^0.0.15",
"@formkit/auto-animate": "1.0.0-beta.5",
"@glidejs/glide": "^3.5.2",
"@googleapis/admin": "^23.0.0",
"@googleapis/calendar": "^9.7.9",
"@googleapis/oauth2": "^1.0.7",
"@hookform/error-message": "^2.0.0",
"@hookform/resolvers": "^2.9.7",
"@next-auth/prisma-adapter": "^1.0.4",
@@ -85,7 +88,6 @@
"dotenv-cli": "^6.0.0",
"entities": "^4.4.0",
"eslint-config-next": "^13.2.1",
"googleapis": "^84.0.0",
"gray-matter": "^4.0.3",
"handlebars": "^4.7.7",
"ical.js": "^1.4.0",
@@ -180,6 +182,7 @@
"deasync": "^0.1.30",
"detect-port": "^1.3.0",
"env-cmd": "^10.1.0",
"google-auth-library": "^9.15.0",
"module-alias": "^2.2.2",
"msw": "^0.42.3",
"node-html-parser": "^6.1.10",
@@ -1,4 +1,4 @@
import { google } from "googleapis";
import { OAuth2Client } from "googleapis-common";
import type { NextApiRequest, NextApiResponse } from "next";
import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
@@ -20,7 +20,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// use differnt callback to normal calendar connection
const redirect_uri = `${WEBAPP_URL}/api/teams/googleworkspace/callback`;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const oAuth2Client = new OAuth2Client(client_id, client_secret, redirect_uri);
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
@@ -1,4 +1,4 @@
import { google } from "googleapis";
import { OAuth2Client } from "googleapis-common";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
@@ -37,7 +37,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(400).json({ message: "Google client_secret missing." });
const redirect_uri = `${WEBAPP_URL}/api/teams/googleworkspace/callback`;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const oAuth2Client = new OAuth2Client(client_id, client_secret, redirect_uri);
if (!code) {
throw new Error("No code provided");
@@ -0,0 +1,17 @@
import { oauth2_v2 } from "@googleapis/oauth2";
import type { OAuth2Client } from "googleapis-common";
import logger from "@calcom/lib/logger";
import { UserRepository } from "@calcom/lib/server/repository/user";
export async function updateProfilePhotoGoogle(oAuth2Client: OAuth2Client, userId: number) {
try {
const oauth2 = new oauth2_v2.Oauth2({ auth: oAuth2Client });
const userDetails = await oauth2.userinfo.get();
if (userDetails.data?.picture) {
await UserRepository.updateAvatar({ id: userId, avatarUrl: userDetails.data.picture });
}
} catch (error) {
logger.error("Error updating avatarUrl from google calendar connect", error);
}
}
+4 -5
View File
@@ -1,22 +1,21 @@
import { google } from "googleapis";
import { OAuth2Client } from "googleapis-common";
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { GOOGLE_CALENDAR_SCOPES, SCOPE_USERINFO_PROFILE, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
import { SCOPES } from "../lib/constants";
import { getGoogleAppKeys } from "../lib/getGoogleAppKeys";
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
// Get token from Google Calendar API
const { client_id, client_secret } = await getGoogleAppKeys();
const redirect_uri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/googlecalendar/callback`;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const oAuth2Client = new OAuth2Client(client_id, client_secret, redirect_uri);
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: SCOPES,
scope: [SCOPE_USERINFO_PROFILE, ...GOOGLE_CALENDAR_SCOPES],
// A refresh token is only returned the first time the user
// consents to providing access. For illustration purposes,
// setting the prompt to 'consent' will force this consent
@@ -1,19 +1,24 @@
import { google } from "googleapis";
import { calendar_v3 } from "@googleapis/calendar";
import { OAuth2Client } from "googleapis-common";
import type { NextApiRequest, NextApiResponse } from "next";
import { updateProfilePhotoGoogle } from "@calcom/app-store/_utils/oauth/updateProfilePhotoGoogle";
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
import { WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import {
GOOGLE_CALENDAR_SCOPES,
SCOPE_USERINFO_PROFILE,
WEBAPP_URL,
WEBAPP_URL_FOR_OAUTH,
} from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { getAllCalendars, updateProfilePhoto } from "@calcom/lib/google";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { GoogleRepository } from "@calcom/lib/server/repository/google";
import { Prisma } from "@calcom/prisma/client";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import { REQUIRED_SCOPES, SCOPE_USERINFO_PROFILE } from "../lib/constants";
import { getGoogleAppKeys } from "../lib/getGoogleAppKeys";
async function getHandler(req: NextApiRequest, res: NextApiResponse) {
@@ -40,14 +45,14 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const redirect_uri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/googlecalendar/callback`;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const oAuth2Client = new OAuth2Client(client_id, client_secret, redirect_uri);
if (code) {
const token = await oAuth2Client.getToken(code);
const key = token.tokens;
const grantedScopes = token.tokens.scope?.split(" ") ?? [];
// Check if we have granted all required permissions
const hasMissingRequiredScopes = REQUIRED_SCOPES.some((scope) => !grantedScopes.includes(scope));
const hasMissingRequiredScopes = GOOGLE_CALENDAR_SCOPES.some((scope) => !grantedScopes.includes(scope));
if (hasMissingRequiredScopes) {
if (!state?.fromApp) {
throw new HttpError({
@@ -63,28 +68,25 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
return;
}
// Set the primary calendar as the first selected calendar
oAuth2Client.setCredentials(key);
const calendar = google.calendar({
version: "v3",
const gcalCredential = await CredentialRepository.create({
userId: req.session.user.id,
key,
appId: "google-calendar",
type: "google_calendar",
});
const gCalService = new GoogleCalendarService({
...gcalCredential,
user: null,
});
const calendar = new calendar_v3.Calendar({
auth: oAuth2Client,
});
const cals = await getAllCalendars(calendar);
const primaryCal = cals.find((cal) => cal.primary) ?? cals[0];
// Only attempt to update the user's profile photo if the user has granted the required scope
if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
await updateProfilePhoto(oAuth2Client, req.session.user.id);
}
const gcalCredential = await GoogleRepository.createGoogleCalendarCredential({
key,
userId: req.session.user.id,
});
const primaryCal = await gCalService.getPrimaryCalendar(calendar);
// If we still don't have a primary calendar skip creating the selected calendar.
// It can be toggled on later.
@@ -96,6 +98,11 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
return;
}
// Only attempt to update the user's profile photo if the user has granted the required scope
if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
}
const selectedCalendarWhereUnique = {
userId: req.session.user.id,
externalId: primaryCal.id,
@@ -105,10 +112,8 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
// Wrapping in a try/catch to reduce chance of race conditions-
// also this improves performance for most of the happy-paths.
try {
await GoogleRepository.upsertSelectedCalendar({
credentialId: gcalCredential.id,
await gCalService.upsertSelectedCalendar({
externalId: selectedCalendarWhereUnique.externalId,
userId: selectedCalendarWhereUnique.userId,
});
} catch (error) {
let errorMessage = "something_went_wrong";
@@ -145,8 +150,9 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
return;
}
const existingGoogleMeetCredential = await GoogleRepository.findGoogleMeetCredential({
const existingGoogleMeetCredential = await CredentialRepository.findFirstByUserIdAndType({
userId: req.session.user.id,
type: "google_video",
});
// If the user already has a google meet credential, there's nothing to do in here
@@ -159,7 +165,12 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
}
// Create a new google meet credential
await GoogleRepository.createGoogleMeetsCredential({ userId: req.session.user.id });
await CredentialRepository.create({
userId: req.session.user.id,
type: "google_video",
key: {},
appId: "google-meet",
});
res.redirect(
getSafeRedirectUrl(`${WEBAPP_URL}/apps/installed/conferencing?hl=google-meet`) ??
getInstalledAppPath({ variant: "conferencing", slug: "google-meet" })
@@ -1,8 +1,8 @@
import prismock from "../../../../tests/libs/__mocks__/prisma";
import oAuthManagerMock, { defaultMockOAuthManager } from "../../tests/__mocks__/OAuthManager";
import { googleapisMock, setCredentialsMock } from "./__mocks__/googleapis";
import { adminMock, calendarMock, setCredentialsMock } from "./__mocks__/googleapis";
import { expect, test, vi } from "vitest";
import { expect, test, beforeEach, vi } from "vitest";
import "vitest-fetch-mock";
import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache";
@@ -22,7 +22,21 @@ vi.mock("./getGoogleAppKeys", () => ({
redirect_uris: ["http://localhost:3000/api/integrations/googlecalendar/callback"],
}),
}));
googleapisMock.google;
vi.mock("googleapis-common", () => ({
OAuth2Client: vi.fn().mockImplementation(() => ({
setCredentials: setCredentialsMock,
})),
}));
vi.mock("@googleapis/admin", () => adminMock);
vi.mock("@googleapis/calendar", () => calendarMock);
beforeEach(() => {
vi.clearAllMocks();
setCredentialsMock.mockClear();
calendarMock.calendar_v3.Calendar.mockClear();
adminMock.admin_directory_v1.Admin.mockClear();
});
const googleTestCredentialKey = {
scope: "https://www.googleapis.com/auth/calendar.events",
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { calendar_v3 } from "@googleapis/calendar";
import type { Prisma } from "@prisma/client";
import type { calendar_v3 } from "googleapis";
import { google } from "googleapis";
import { OAuth2Client } from "googleapis-common";
import { RRule } from "rrule";
import { v4 as uuid } from "uuid";
@@ -18,10 +18,9 @@ import {
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
} from "@calcom/lib/constants";
import { formatCalEvent } from "@calcom/lib/formatCalendarEvent";
import { getAllCalendars } from "@calcom/lib/google";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { GoogleRepository } from "@calcom/lib/server/repository/google";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import prisma from "@calcom/prisma";
import type {
Calendar,
@@ -164,8 +163,7 @@ export default class GoogleCalendarService implements Calendar {
public authedCalendar = async () => {
const myGoogleAuth = await this.auth.getMyGoogleAuthWithRefreshedToken();
const calendar = google.calendar({
version: "v3",
const calendar = new calendar_v3.Calendar({
auth: myGoogleAuth,
});
return calendar;
@@ -541,12 +539,12 @@ export default class GoogleCalendarService implements Calendar {
// Only calendars of other integrations selected
return [];
}
async function getCalIds() {
const getCalIds = async () => {
if (selectedCalendarIds.length !== 0) return selectedCalendarIds;
const cals = await getAllCalendars(calendar, ["id"]);
const cals = await this.getAllCalendars(calendar, ["id"]);
if (!cals.length) return [];
return cals.reduce((c, cal) => (cal.id ? [...c, cal.id] : c), [] as string[]);
}
};
try {
const calsIds = await getCalIds();
@@ -607,7 +605,7 @@ export default class GoogleCalendarService implements Calendar {
status: 200,
statusText: "OK",
data: {
items: await getAllCalendars(calendar),
items: await this.getAllCalendars(calendar),
},
})
);
@@ -652,10 +650,8 @@ export default class GoogleCalendarService implements Calendar {
},
});
const response = res.data;
await GoogleRepository.upsertSelectedCalendar({
userId: this.credential.userId!,
await this.upsertSelectedCalendar({
externalId: calendarId,
credentialId: this.credential.id,
googleChannelId: response?.id,
googleChannelKind: response?.kind,
googleChannelResourceId: response?.resourceId,
@@ -686,10 +682,8 @@ export default class GoogleCalendarService implements Calendar {
.catch((err) => {
console.warn(JSON.stringify(err));
});
await GoogleRepository.upsertSelectedCalendar({
userId: this.credential.userId!,
await this.upsertSelectedCalendar({
externalId: calendarId,
credentialId: this.credential.id,
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
@@ -715,9 +709,97 @@ export default class GoogleCalendarService implements Calendar {
const data = await this.fetchAvailability(parsedArgs);
await this.setAvailabilityInCache(parsedArgs, data);
}
async createSelectedCalendar(
data: Omit<Prisma.SelectedCalendarUncheckedCreateInput, "integration" | "credentialId">
) {
return await SelectedCalendarRepository.create({
...data,
integration: this.integrationName,
credentialId: this.credential.id,
});
}
async upsertSelectedCalendar(
data: Omit<Prisma.SelectedCalendarUncheckedCreateInput, "integration" | "credentialId" | "userId">
) {
if (!this.credential.userId) {
logger.error("upsertSelectedCalendar failed. userId is missing.");
return;
}
return await SelectedCalendarRepository.upsert({
...data,
integration: this.integrationName,
credentialId: this.credential.id,
userId: this.credential.userId,
});
}
async getAllCalendars(
calendar: calendar_v3.Calendar,
fields: string[] = ["id", "summary", "primary", "accessRole"]
): Promise<calendar_v3.Schema$CalendarListEntry[]> {
let allCalendars: calendar_v3.Schema$CalendarListEntry[] = [];
let pageToken: string | undefined;
try {
do {
const response: any = await calendar.calendarList.list({
fields: `items(${fields.join(",")}),nextPageToken`,
pageToken,
maxResults: 250, // 250 is max
});
allCalendars = [...allCalendars, ...(response.data.items ?? [])];
pageToken = response.data.nextPageToken;
} while (pageToken);
return allCalendars;
} catch (error) {
logger.error("Error fetching all Google Calendars", { error });
throw error;
}
}
async getPrimaryCalendar(
calendar: calendar_v3.Calendar,
fields: string[] = ["id", "summary", "primary", "accessRole"]
): Promise<calendar_v3.Schema$CalendarListEntry | null> {
let pageToken: string | undefined;
let firstCalendar: calendar_v3.Schema$CalendarListEntry | undefined;
try {
do {
const response: any = await calendar.calendarList.list({
fields: `items(${fields.join(",")}),nextPageToken`,
pageToken,
maxResults: 250, // 250 is max
});
const cals = response.data.items ?? [];
const primaryCal = cals.find((cal: calendar_v3.Schema$CalendarListEntry) => cal.primary);
if (primaryCal) {
return primaryCal;
}
// Store the first calendar in case no primary is found
if (cals.length > 0 && !firstCalendar) {
firstCalendar = cals[0];
}
pageToken = response.data.nextPageToken;
} while (pageToken);
// should not be reached because Google Cal always has a primary cal
return firstCalendar ?? null;
} catch (error) {
logger.error("Error in `getPrimaryCalendar`", { error });
throw error;
}
}
}
class MyGoogleAuth extends google.auth.OAuth2 {
class MyGoogleAuth extends OAuth2Client {
constructor(client_id: string, client_secret: string, redirect_uri: string) {
super(client_id, client_secret, redirect_uri);
}
@@ -1,50 +1,33 @@
import type * as googleapis from "googleapis";
import { beforeEach, vi } from "vitest";
import { mockDeep, mockReset } from "vitest-mock-extended";
vi.mock("googleapis", () => googleapisMock);
beforeEach(() => {
mockReset(googleapisMock);
});
const googleapisMock = mockDeep<typeof googleapis>({
fallbackMockImplementation: (...args) => {
console.log(args);
throw new Error("Unimplemented");
},
});
import { vi } from "vitest";
const setCredentialsMock = vi.fn();
googleapisMock.google = {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
calendar: vi.fn().mockReturnValue({
channels: {
stop: vi.fn().mockResolvedValue(undefined),
},
events: {
watch: vi.fn().mockResolvedValue({
data: {
kind: "api#channel",
id: "mock-channel-id",
resourceId: "mock-resource-id",
resourceUri: "mock-resource-uri",
expiration: "1111111111",
},
}),
},
}),
auth: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
OAuth2: function MockGoogleOAuth2() {
return {
setCredentials: setCredentialsMock,
};
},
const calendarMockImplementation = {
channels: {
stop: vi.fn().mockResolvedValue(undefined),
},
events: {
watch: vi.fn().mockResolvedValue({
data: {
kind: "api#channel",
id: "mock-channel-id",
resourceId: "mock-resource-id",
resourceUri: "mock-resource-uri",
expiration: "1111111111",
},
}),
},
};
export default googleapisMock;
export { googleapisMock, setCredentialsMock };
const calendarMock = {
calendar_v3: {
Calendar: vi.fn().mockImplementation(() => calendarMockImplementation),
},
};
const adminMock = {
admin_directory_v1: {
Admin: vi.fn(),
},
};
export { calendarMock, adminMock, setCredentialsMock };
@@ -1,7 +0,0 @@
export const SCOPE_USERINFO_PROFILE = "https://www.googleapis.com/auth/userinfo.profile";
export const SCOPE_CALENDAR_READONLY = "https://www.googleapis.com/auth/calendar.readonly";
export const SCOPE_CALENDAR_EVENT = "https://www.googleapis.com/auth/calendar.events";
export const REQUIRED_SCOPES = [SCOPE_CALENDAR_READONLY, SCOPE_CALENDAR_EVENT];
export const SCOPES = [...REQUIRED_SCOPES, SCOPE_USERINFO_PROFILE];
@@ -1,29 +0,0 @@
import type { calendar_v3 } from "googleapis";
import logger from "@calcom/lib/logger";
export async function getAllCalendars(
calendar: calendar_v3.Calendar,
fields: string[] = ["id", "summary", "primary", "accessRole"]
): Promise<calendar_v3.Schema$CalendarListEntry[]> {
let allCalendars: calendar_v3.Schema$CalendarListEntry[] = [];
let pageToken: string | undefined;
try {
do {
const response: any = await calendar.calendarList.list({
fields: `items(${fields.join(",")}),nextPageToken`,
pageToken,
maxResults: 250, // 250 is max
});
allCalendars = [...allCalendars, ...(response.data.items ?? [])];
pageToken = response.data.nextPageToken;
} while (pageToken);
return allCalendars;
} catch (error) {
logger.error("Error fetching all Google Calendars", { error });
throw error;
}
}
@@ -7,7 +7,7 @@
"description": "Google Calendar is a time management and scheduling service developed by Google. Allows users to create and edit events, with options available for type and time. Available to anyone that has a Gmail account on both mobile and web versions.",
"dependencies": {
"@calcom/prisma": "*",
"googleapis": "^84.0.0"
"@googleapis/calendar": "^9.7.9"
},
"devDependencies": {
"@calcom/types": "*"
+1 -2
View File
@@ -6,8 +6,7 @@
"main": "./index.ts",
"description": "Google Meet is Google's web-based video conferencing platform, designed to compete with major conferencing platforms.",
"dependencies": {
"@calcom/prisma": "*",
"googleapis": "^84.0.0"
"@calcom/prisma": "*"
},
"devDependencies": {
"@calcom/types": "*"
@@ -1,5 +1,7 @@
import { calendar_v3 } from "@googleapis/calendar";
import type { Membership, Team, UserPermissionRole } from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import { OAuth2Client } from "googleapis-common";
import type { AuthOptions, Session, User } from "next-auth";
import type { JWT } from "next-auth/jwt";
import { encode } from "next-auth/jwt";
@@ -8,6 +10,8 @@ import CredentialsProvider from "next-auth/providers/credentials";
import EmailProvider from "next-auth/providers/email";
import GoogleProvider from "next-auth/providers/google";
import { updateProfilePhotoGoogle } from "@calcom/app-store/_utils/oauth/updateProfilePhotoGoogle";
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
import createUsersAndConnectToOrg from "@calcom/features/ee/dsync/lib/users/createUsersAndConnectToOrg";
import postHogClient from "@calcom/features/ee/event-tracking/lib/posthog/postHogClient";
@@ -15,7 +19,12 @@ import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/Imperso
import { getOrgFullOrigin, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { clientSecretVerifier, hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { HOSTED_CAL_FEATURES, IS_CALCOM } from "@calcom/lib/constants";
import {
GOOGLE_CALENDAR_SCOPES,
GOOGLE_OAUTH_SCOPES,
HOSTED_CAL_FEATURES,
IS_CALCOM,
} from "@calcom/lib/constants";
import { ENABLE_PROFILE_SWITCHER, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@calcom/lib/crypto";
import { defaultCookies } from "@calcom/lib/default-cookies";
@@ -23,6 +32,7 @@ import { isENVDev } from "@calcom/lib/env";
import logger from "@calcom/lib/logger";
import { randomString } from "@calcom/lib/random";
import { safeStringify } from "@calcom/lib/safeStringify";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
@@ -251,6 +261,13 @@ if (IS_GOOGLE_LOGIN_ENABLED) {
clientId: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET,
allowDangerousEmailAccountLinking: true,
authorization: {
params: {
scope: [...GOOGLE_OAUTH_SCOPES, ...GOOGLE_CALENDAR_SCOPES].join(" "),
access_type: "offline",
prompt: "consent",
},
},
})
);
}
@@ -608,6 +625,63 @@ export const getOptions = ({
return await autoMergeIdentities();
}
const grantedScopes = account.scope?.split(" ") ?? [];
if (
account.provider === "google" &&
!(await CredentialRepository.findFirstByAppIdAndUserId({
userId: user.id as number,
appId: "google-calendar",
})) &&
GOOGLE_CALENDAR_SCOPES.every((scope) => grantedScopes.includes(scope))
) {
// Installing Google Calendar by default
const credentialkey = {
access_token: account.access_token,
refresh_token: account.refresh_token,
id_token: account.id_token,
token_type: account.token_type,
expires_at: account.expires_at,
};
const gcalCredential = await CredentialRepository.create({
userId: user.id as number,
key: credentialkey,
appId: "google-calendar",
type: "google_calendar",
});
const gCalService = new GoogleCalendarService({
...gcalCredential,
user: null,
});
if (
!(await CredentialRepository.findFirstByUserIdAndType({
userId: user.id as number,
type: "google_video",
}))
) {
await CredentialRepository.create({
type: "google_video",
key: {},
userId: user.id as number,
appId: "google-meet",
});
}
const oAuth2Client = new OAuth2Client(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
oAuth2Client.setCredentials(credentialkey);
const calendar = new calendar_v3.Calendar({
auth: oAuth2Client,
});
const primaryCal = await gCalService.getPrimaryCalendar(calendar);
if (primaryCal?.id) {
await gCalService.createSelectedCalendar({
externalId: primaryCal.id,
userId: user.id as number,
});
}
await updateProfilePhotoGoogle(oAuth2Client, user.id as number);
}
return {
...token,
id: existingUser.id,
@@ -632,7 +706,6 @@ export const getOptions = ({
log.debug("callbacks:session - Session callback called", safeStringify({ session, token, user }));
const licenseKeyService = await LicenseKeySingleton.getInstance();
const hasValidLicense = await licenseKeyService.checkLicense();
const profileId = token.profileId;
const calendsoSession: Session = {
...session,
+8
View File
@@ -189,3 +189,11 @@ export const RECORDING_DEFAULT_ICON = IS_PRODUCTION
export const RECORDING_IN_PROGRESS_ICON = IS_PRODUCTION
? `${WEBAPP_URL}/stop-recording.svg`
: `https://app.cal.com/stop-recording.svg`;
export const SCOPE_USERINFO_PROFILE = "https://www.googleapis.com/auth/userinfo.profile";
export const SCOPE_USERINFO_EMAIL = "https://www.googleapis.com/auth/userinfo.email";
export const GOOGLE_OAUTH_SCOPES = [SCOPE_USERINFO_PROFILE, SCOPE_USERINFO_EMAIL];
export const GOOGLE_CALENDAR_SCOPES = [
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/calendar.readonly",
];
-45
View File
@@ -1,45 +0,0 @@
import type { OAuth2Client } from "google-auth-library";
import type { calendar_v3 } from "googleapis";
import { google } from "googleapis";
import logger from "@calcom/lib/logger";
import { UserRepository } from "./server/repository/user";
export async function getAllCalendars(
calendar: calendar_v3.Calendar,
fields: string[] = ["id", "summary", "primary", "accessRole"]
): Promise<calendar_v3.Schema$CalendarListEntry[]> {
let allCalendars: calendar_v3.Schema$CalendarListEntry[] = [];
let pageToken: string | undefined;
try {
do {
const response: any = await calendar.calendarList.list({
fields: `items(${fields.join(",")}),nextPageToken`,
pageToken,
maxResults: 250, // 250 is max
});
allCalendars = [...allCalendars, ...(response.data.items ?? [])];
pageToken = response.data.nextPageToken;
} while (pageToken);
return allCalendars;
} catch (error) {
logger.error("Error fetching all Google Calendars", { error });
throw error;
}
}
export async function updateProfilePhoto(oAuth2Client: OAuth2Client, userId: number) {
try {
const oauth2 = google.oauth2({ version: "v2", auth: oAuth2Client });
const userDetails = await oauth2.userinfo.get();
if (userDetails.data?.picture) {
await UserRepository.updateAvatar({ id: userId, avatarUrl: userDetails.data.picture });
}
} catch (error) {
logger.error("Error updating avatarUrl from google calendar connect", error);
}
}
@@ -30,6 +30,15 @@ export class CredentialRepository {
});
}
static async findFirstByAppIdAndUserId({ appId, userId }: { appId: string; userId: number }) {
return await prisma.credential.findFirst({
where: {
appId,
userId,
},
});
}
static async findFirstByUserIdAndType({ userId, type }: { userId: number; type: string }) {
return await prisma.credential.findFirst({ where: { userId, type } });
}
-48
View File
@@ -1,48 +0,0 @@
import type { Prisma } from "@prisma/client";
import type { Credentials } from "google-auth-library";
import { CredentialRepository } from "./credential";
import { SelectedCalendarRepository } from "./selectedCalendar";
export class GoogleRepository {
static async createGoogleCalendarCredential({ userId, key }: { userId: number; key: Credentials }) {
return await CredentialRepository.create({
type: "google_calendar",
key,
userId,
appId: "google-calendar",
});
}
static async createGoogleMeetsCredential({ userId }: { userId: number }) {
return await CredentialRepository.create({
type: "google_video",
key: {},
userId,
appId: "google-meet",
});
}
static async createSelectedCalendar(data: { credentialId: number; userId: number; externalId: string }) {
return await SelectedCalendarRepository.create({
...data,
integration: "google_calendar",
});
}
static async upsertSelectedCalendar(
data: Omit<Prisma.SelectedCalendarUncheckedCreateInput, "integration">
) {
return await SelectedCalendarRepository.upsert({
...data,
integration: "google_calendar",
});
}
static async findGoogleMeetCredential({ userId }: { userId: number }) {
return await CredentialRepository.findFirstByUserIdAndType({
userId,
type: "google_video",
});
}
}
@@ -3,15 +3,8 @@ import type { Prisma } from "@prisma/client";
import { prisma } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
type SelectedCalendarCreateInput = {
credentialId: number;
userId: number;
externalId: string;
integration: string;
};
export class SelectedCalendarRepository {
static async create(data: SelectedCalendarCreateInput) {
static async create(data: Prisma.SelectedCalendarUncheckedCreateInput) {
return await prisma.selectedCalendar.create({
data: {
...data,
+2 -2
View File
@@ -54,7 +54,7 @@ export default defineConfig({
"ical.js",
"ics",
"tsdav",
"googleapis",
"@googleapis/calendar",
"rrule",
"@hubspot/api-client",
"querystring",
@@ -125,7 +125,7 @@ export default defineConfig({
"ical.js": "ical.js",
ics: "ics",
tsdav: "tsdav",
googleapis: "googleapis",
"@googleapis/calendar": "@googleapis/calendar",
rrule: "rrule",
"@hubspot/api-client": "@hubspot/api-client",
querystring: "querystring",
@@ -1,4 +1,5 @@
import { google } from "googleapis";
import { admin_directory_v1 } from "@googleapis/admin";
import { OAuth2Client } from "googleapis-common";
import { z } from "zod";
import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
@@ -48,14 +49,15 @@ export const getUsersFromGWorkspace = async ({}: CheckForGCalOptions) => {
const credentials = credentialsSchema.parse(hasExistingCredentials.key);
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret);
const oAuth2Client = new OAuth2Client(client_id, client_secret);
// Set users credentials instead of our app credentials - allowing us to make requests on their behalf
oAuth2Client.setCredentials(credentials);
// Create a new instance of the Admin SDK directory API
const directory = google.admin({ version: "directory_v1", auth: oAuth2Client });
const directory = new admin_directory_v1.Admin({
auth: oAuth2Client as any,
});
const { data } = await directory.users.list({
maxResults: 200, // Up this if we ever need to get more than 200 users
customer: "my_customer", // This only works for single domain setups - we'll need to change this if we ever support multi-domain setups (unlikely we'll ever need to)
+1 -1
View File
@@ -5,7 +5,7 @@ import type {
SelectedCalendar as _SelectedCalendar,
} from "@prisma/client";
import type { Dayjs } from "dayjs";
import type { calendar_v3 } from "googleapis";
import type { calendar_v3 } from "@googleapis/calendar";
import type { Time } from "ical.js";
import type { TFunction } from "next-i18next";
import type z from "zod";
+59 -16
View File
@@ -4195,7 +4195,7 @@ __metadata:
dependencies:
"@calcom/prisma": "*"
"@calcom/types": "*"
googleapis: ^84.0.0
"@googleapis/calendar": ^9.7.9
languageName: unknown
linkType: soft
@@ -4205,7 +4205,6 @@ __metadata:
dependencies:
"@calcom/prisma": "*"
"@calcom/types": "*"
googleapis: ^84.0.0
languageName: unknown
linkType: soft
@@ -5016,6 +5015,9 @@ __metadata:
"@dub/analytics": ^0.0.15
"@formkit/auto-animate": 1.0.0-beta.5
"@glidejs/glide": ^3.5.2
"@googleapis/admin": ^23.0.0
"@googleapis/calendar": ^9.7.9
"@googleapis/oauth2": ^1.0.7
"@hookform/error-message": ^2.0.0
"@hookform/resolvers": ^2.9.7
"@microsoft/microsoft-graph-types-beta": 0.15.0-preview
@@ -5084,7 +5086,7 @@ __metadata:
entities: ^4.4.0
env-cmd: ^10.1.0
eslint-config-next: ^13.2.1
googleapis: ^84.0.0
google-auth-library: ^9.15.0
gray-matter: ^4.0.3
handlebars: ^4.7.7
ical.js: ^1.4.0
@@ -6593,6 +6595,33 @@ __metadata:
languageName: node
linkType: hard
"@googleapis/admin@npm:^23.0.0":
version: 23.0.0
resolution: "@googleapis/admin@npm:23.0.0"
dependencies:
googleapis-common: ^7.0.0
checksum: 385ec58507646c4ccbee7309cfbfbe3c6dcb8879ed8b8510d95b3877f2b4eb5340472e68dae8402cf90415cc636b9f64c3353faf63ec8d6ac88388a53a26290c
languageName: node
linkType: hard
"@googleapis/calendar@npm:^9.7.9":
version: 9.7.9
resolution: "@googleapis/calendar@npm:9.7.9"
dependencies:
googleapis-common: ^7.0.0
checksum: f767e1d897b038a9ebada71dcb4fc3564813769299025877fb574c0716cb2cebfc4b5d88f8fbcfbc9ccc73c3eb7448ac0c20ceb94df4239dc0bd0f902657db9f
languageName: node
linkType: hard
"@googleapis/oauth2@npm:^1.0.7":
version: 1.0.7
resolution: "@googleapis/oauth2@npm:1.0.7"
dependencies:
googleapis-common: ^7.0.0
checksum: e99227439409aff14f228d4cfb5316e284072fe51ab7b65e16c02d0b90e7e9c67cbc1c4d7a502aff7bf992410478fc5c29f06ad4f180e7d8923690e9c344f6de
languageName: node
linkType: hard
"@grpc/grpc-js@npm:^1.7.1":
version: 1.9.14
resolution: "@grpc/grpc-js@npm:1.9.14"
@@ -25579,9 +25608,9 @@ __metadata:
linkType: hard
"fast-text-encoding@npm:^1.0.0":
version: 1.0.3
resolution: "fast-text-encoding@npm:1.0.3"
checksum: 3e51365896f06d0dcab128092d095a0037d274deec419fecbd2388bc236d7b387610e0c72f920c6126e00c885ab096fbfaa3645712f5b98f721bef6b064916a8
version: 1.0.6
resolution: "fast-text-encoding@npm:1.0.6"
checksum: 9d58f694314b3283e785bf61954902536da228607ad246905e30256f9ab8331f780ac987e7222c9f5eafd04168d07e12b8054c85cedb76a2c05af0e82387a903
languageName: node
linkType: hard
@@ -26593,15 +26622,15 @@ __metadata:
linkType: hard
"gaxios@npm:^4.0.0":
version: 4.3.2
resolution: "gaxios@npm:4.3.2"
version: 4.3.3
resolution: "gaxios@npm:4.3.3"
dependencies:
abort-controller: ^3.0.0
extend: ^3.0.2
https-proxy-agent: ^5.0.0
is-stream: ^2.0.0
node-fetch: ^2.6.1
checksum: 1305fc6a4b9f888a7424ed160084d1f253a54bec04b4986ecb7929d8aa52382e737fc65feb03f0b0b47c524575286d765806d829868ec49fd383a1be8a973870
node-fetch: ^2.6.7
checksum: 0b72a00875404e2c3d7aca9f32535e931d7b0ebb850dc92fafc1685b99a109b04205c63e4637a2d0d9a261ac50adf83f7d33435f73e256dcca32564ef9358fee
languageName: node
linkType: hard
@@ -27216,14 +27245,28 @@ __metadata:
languageName: node
linkType: hard
"google-p12-pem@npm:^3.1.3":
version: 3.1.3
resolution: "google-p12-pem@npm:3.1.3"
"google-auth-library@npm:^9.15.0":
version: 9.15.0
resolution: "google-auth-library@npm:9.15.0"
dependencies:
node-forge: ^1.0.0
base64-js: ^1.3.0
ecdsa-sig-formatter: ^1.0.11
gaxios: ^6.1.1
gcp-metadata: ^6.1.0
gtoken: ^7.0.0
jws: ^4.0.0
checksum: 7ff63ff755ce43601a1780cfbe6d8b523792bfd2b653a27180e41735b9f0ba5689668eb8b079c915300041e412ec00b7635b5c26e0f2139fecacb346b6f9f12f
languageName: node
linkType: hard
"google-p12-pem@npm:^3.1.3":
version: 3.1.4
resolution: "google-p12-pem@npm:3.1.4"
dependencies:
node-forge: ^1.3.1
bin:
gp12-pem: build/src/bin/gp12-pem.js
checksum: 8628f2bf9b4c9b3bfc7220906c15af2b306e2ef30c7c398327a9cff9d4a12285f104545f00c46b716dd23966615f446d7e5efde44d590f9483d345be78ea115e
checksum: 72ce13b9536c69f21584cb7477ed4c34674325639a5dac42e8d774b78d367cb196ae7d37a52bba868235205760df353ac678a5e1756a4f4ded82e16d29d6cbb1
languageName: node
linkType: hard
@@ -33871,7 +33914,7 @@ __metadata:
languageName: node
linkType: hard
"node-forge@npm:1.3.1, node-forge@npm:^1.0.0":
"node-forge@npm:1.3.1, node-forge@npm:^1.3.1":
version: 1.3.1
resolution: "node-forge@npm:1.3.1"
checksum: 08fb072d3d670599c89a1704b3e9c649ff1b998256737f0e06fbd1a5bf41cae4457ccaee32d95052d80bbafd9ffe01284e078c8071f0267dc9744e51c5ed42a9