Google Meet - installable app (#5904)

* Abstract app category navigation

* Send key schema to frontend

Co-authored-by: Omar López <zomars@users.noreply.github.com>

* Render keys for apps on admin

* Add enabled col for apps

* Save app keys to DB

* Add checks for admin role

* Abstract setup components

* Add AdminAppsList to setup wizard

* Migrate to v10 tRPC

* Default hide keys

* Display enabled apps

* Merge branch 'main' into admin-apps-ui

* Toggle calendars

* WIP

* Add params and include AppCategoryNavigation

* Refactor getEnabledApps

* Add warning for disabling apps

* Fallback to cal video when a video app is disabled

* WIP send disabled email

* Send email to all users of  event types with payment app

* Disable Stripe when app is disabled

* Disable apps in event types

* Send email to users on disabled apps

* Send email based on what app was disabled

* WIP type fix

* Disable navigation to apps list if already setup

* UI import fixes

* Waits for session data before redirecting

* Updates admin seeded password

To comply with admin password requirements

* Update yarn.lock

* Flex fixes

* Adds admin middleware

* Clean up

* WIP

* WIP

* NTS

* Add dirName to app metadata

* Upsert app if not in db

* Upsert app if not in db

* Add dirName to app metadata

* Add keys to app packages w/ keys

* Merge with main

* Toggle show keys & on enable

* Fix empty keys

* Fix lark calendar metadata

* Fix some type errors

* Fix Lark metadata & check for category when upserting

* More type fixes

* Fix types & add keys to google cal

* WIP

* WIP

* WIP

* More type fixes

* Fix type errors

* Fix type errors

* More type fixes

* More type fixes

* More type fixes

* Feedback

* Fixes default value

* Feedback

* Migrate credential invalid col default value "false"

* Upsert app on saving keys

* Clean up

* Validate app keys on frontend

* Add nonempty to app keys schemas

* Add web3

* Listlocale filter on categories / category

* Grab app metadata via category or categories

* Show empty screen if no apps are enabled

* Fix type checks

* Fix type checks

* Fix type checks

* Fix type checks

* Fix type checks

* Fix type checks

* Replace .nonempty() w/ .min(1)

* Fix type error

* Address feedback

* Draft Google Meet install button

* Add install button and warning dialog

* WIP

* WIP

* Display warning when Meet is selected

* Display Google Meet warning on email to organizer

* Fix email

* Fix type errors

* Fix type error

* Add connected account component

* Add warning message

* Address comments

* Address feedback

* Clean up & add MeetLocationType

* Use useApp hook

* Translate to new API approach

* Remove console.log

* Refactor

* Fix missing backup Cal video link

* WIP

* Address feedback

* Update submodules

* Feedback

* Submodule sync

Co-authored-by: Omar López <zomars@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
This commit is contained in:
Joe Au-Yeung 2023-01-09 21:01:57 -05:00 committed by GitHub
parent 4e65c30e18
commit 75aef09338
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 312 additions and 17 deletions

View File

@ -1,16 +1,18 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { isValidPhoneNumber } from "libphonenumber-js";
import { Trans } from "next-i18next";
import Link from "next/link";
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useState } from "react";
import { Controller, useForm, useFormContext } from "react-hook-form";
import { MultiValue } from "react-select";
import { z } from "zod";
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
import { EventLocationType, getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Icon, Label, Select, Skeleton, TextField, SettingsToggle } from "@calcom/ui";
import { Button, Icon, Label, Select, SettingsToggle, Skeleton, TextField } from "@calcom/ui";
import { slugify } from "@lib/slugify";
@ -195,6 +197,23 @@ export const EventSetupTab = (
</li>
);
})}
{validLocations.some((location) => location.type === MeetLocationType) && (
<div className="flex text-sm text-gray-600">
<Icon.FiCheck className="mt-0.5 mr-1.5 h-2 w-2.5" />
<Trans i18nKey="event_type_requres_google_cal">
<p>
The Add to calendar for this event type needs to be a Google Calendar for Meet to work.
Change it{" "}
<Link
href={`${CAL_URL}/event-types/${eventType.id}?tabName=advanced`}
className="underline">
here.
</Link>{" "}
We will fall back to Cal video if you do not change it.
</p>
</Trans>
</div>
)}
{validLocations.length > 0 && validLocations.length !== locationOptions.length && (
<li>
<Button StartIcon={Icon.FiPlus} color="minimal" onClick={() => setShowLocationModal(true)}>
@ -326,6 +345,7 @@ export const EventSetupTab = (
<Skeleton as={Label} loadingClassName="w-16">
{t("location")}
</Skeleton>
<Controller
name="locations"
control={formMethods.control}

View File

@ -35,6 +35,24 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
uid: "xxyPr4cg2xx4XoS2KeMEQy",
metadata: {},
recurringEvent: null,
appsStatus: [
{
appName: "Outlook Calendar",
type: "office365_calendar",
success: 1,
failures: 0,
errors: [],
warnings: [],
},
{
appName: "Google Meet",
type: "conferencing",
success: 0,
failures: 1,
errors: [],
warnings: ["In order to use Google Meet you must set your destination calendar to a Google Calendar"],
},
],
};
req.statusCode = 200;
@ -42,9 +60,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
res.setHeader("Content-Type", "text/html");
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
res.write(
renderEmail("OrganizerRequestEmail", {
attendee: evt.attendees[0],
renderEmail("OrganizerScheduledEmail", {
calEvent: evt,
attendee: evt.organizer,
})
);
res.end();

View File

@ -2,11 +2,11 @@ import { NextApiRequest, NextApiResponse } from "next";
import type { Session } from "next-auth";
import getInstalledAppPath from "@calcom/app-store/_utils/getInstalledAppPath";
import { getSession } from "@calcom/lib/auth";
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
import prisma from "@calcom/prisma";
import type { AppDeclarativeHandler, AppHandler } from "@calcom/types/AppHandler";
import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error";
const defaultIntegrationAddHandler = async ({

View File

@ -5,6 +5,7 @@ import { GetStaticPaths, GetStaticPropsContext } from "next";
import path from "path";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import ExisitingGoogleCal from "@calcom/app-store/googlevideo/components/ExistingGoogleCal";
import prisma from "@calcom/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -36,7 +37,12 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
images={source.data?.items as string[] | undefined}
// tos="https://zoom.us/terms"
// privacy="https://zoom.us/privacy"
body={<div dangerouslySetInnerHTML={{ __html: md.render(source.content) }} />}
body={
<>
{data.slug === "google-meet" && <ExisitingGoogleCal />}
<div dangerouslySetInnerHTML={{ __html: md.render(source.content) }} />
</>
}
/>
);
}

View File

@ -1456,6 +1456,7 @@
"enter_option": "Enter Option {{index}}",
"add_an_option": "Add an option",
"radio": "Radio",
"google_meet_warning": "In order to use Google Meet you must set your destination calendar to a Google Calendar",
"individual":"Individual",
"all_bookings_filter_label":"All Bookings",
"all_users_filter_label":"All Users",
@ -1490,5 +1491,11 @@
"disable_success_page":"Disable Success Page (only works if you have a redirect URL)",
"invalid_admin_password": "You are set as an admin but you do not have a password length of at least 15 characters",
"change_password_admin": "Change Password to gain admin access",
"username_already_taken": "Username is already taken"
"username_already_taken": "Username is already taken",
"requires_google_calendar": "This app requires a Google Calendar connection",
"connected_google_calendar": "You have connected a Google Calendar account.",
"using_meet_requires_calendar": "Using Google Meet requires a connected Google Calendar",
"continue_to_install_google_calendar": "Continue to install Google Calendar",
"install_google_meet": "Install Google Meet",
"install_google_calendar": "Install Google Calendar"
}

View File

@ -20,7 +20,8 @@ type CustomUseMutationOptions =
type AddAppMutationData = { setupPending: boolean } | void;
type UseAddAppMutationOptions = CustomUseMutationOptions & {
onSuccess: (data: AddAppMutationData) => void;
onSuccess?: (data: AddAppMutationData) => void;
installGoogleVideo?: boolean;
returnTo?: string;
};
@ -42,6 +43,10 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta
if (type?.endsWith("_other_calendar")) {
type = type.split("_other_calendar")[0];
}
if (options?.installGoogleVideo && type !== "google_calendar")
throw new Error("Could not install Google Meet");
const state: IntegrationOAuthCallbackState = {
returnTo:
returnTo ||
@ -50,6 +55,7 @@ function useAddAppMutation(_type: App["type"] | null, allOptions?: UseAddAppMuta
{ variant: variables && variables.variant, slug: variables && variables.slug },
location.search
),
...(type === "google_calendar" && { installGoogleVideo: options?.installGoogleVideo }),
};
const stateStr = encodeURIComponent(JSON.stringify(state));
const searchParams = `?state=${stateStr}`;

View File

@ -13,6 +13,7 @@ export const InstallAppButtonMap = {
exchange2016calendar: dynamic(() => import("./exchange2016calendar/components/InstallAppButton")),
exchangecalendar: dynamic(() => import("./exchangecalendar/components/InstallAppButton")),
googlecalendar: dynamic(() => import("./googlecalendar/components/InstallAppButton")),
googlevideo: dynamic(() => import("./googlevideo/components/InstallAppButton")),
hubspot: dynamic(() => import("./hubspot/components/InstallAppButton")),
huddle01video: dynamic(() => import("./huddle01video/components/InstallAppButton")),
jitsivideo: dynamic(() => import("./jitsivideo/components/InstallAppButton")),

View File

@ -17,6 +17,7 @@ export const apiHandlers = {
ga4: import("./ga4/api"),
giphy: import("./giphy/api"),
googlecalendar: import("./googlecalendar/api"),
googlevideo: import("./googlevideo/api"),
hubspot: import("./hubspot/api"),
huddle01video: import("./huddle01video/api"),
jitsivideo: import("./jitsivideo/api"),

View File

@ -1,7 +1,7 @@
import { google } from "googleapis";
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { WEBAPP_URL, CAL_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";
@ -14,6 +14,8 @@ let client_secret = "";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
const state = decodeOAuthState(req);
if (code && typeof code !== "string") {
res.status(400).json({ message: "`code` must be a string" });
return;
@ -47,7 +49,31 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
appId: "google-calendar",
},
});
const state = decodeOAuthState(req);
if (state?.installGoogleVideo) {
const existingGoogleMeetCredential = await prisma.credential.findFirst({
where: {
userId: req.session.user.id,
type: "google_video",
},
});
if (!existingGoogleMeetCredential) {
await prisma.credential.create({
data: {
type: "google_video",
key: {},
userId: req.session.user.id,
appId: "google-meet",
},
});
res.redirect(
getSafeRedirectUrl(CAL_URL + "/apps/installed/conferencing?hl=google-meet") ??
getInstalledAppPath({ variant: "conferencing", slug: "google-meet" })
);
}
}
res.redirect(
getSafeRedirectUrl(state?.returnTo) ??
getInstalledAppPath({ variant: "calendar", slug: "google-calendar" })

View File

@ -1,6 +1,7 @@
import { Prisma } from "@prisma/client";
import { calendar_v3, google } from "googleapis";
import { MeetLocationType } from "@calcom/app-store/locations";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import CalendarService from "@calcom/lib/CalendarService";
import logger from "@calcom/lib/logger";
@ -105,7 +106,7 @@ export default class GoogleCalendarService implements Calendar {
payload["location"] = getLocation(calEventRaw);
}
if (calEventRaw.conferenceData && calEventRaw.location === "integrations:google:meet") {
if (calEventRaw.conferenceData && calEventRaw.location === MeetLocationType) {
payload["conferenceData"] = calEventRaw.conferenceData;
}
const calendar = google.calendar({
@ -195,7 +196,7 @@ export default class GoogleCalendarService implements Calendar {
payload["location"] = getLocation(event);
}
if (event.conferenceData && event.location === "integrations:google:meet") {
if (event.conferenceData && event.location === MeetLocationType) {
payload["conferenceData"] = event.conferenceData;
}
@ -225,7 +226,7 @@ export default class GoogleCalendarService implements Calendar {
return reject(err);
}
if (evt && evt.data.id && evt.data.hangoutLink && event.location === "integrations:google:meet") {
if (evt && evt.data.id && evt.data.hangoutLink && event.location === MeetLocationType) {
calendar.events.patch({
// Update the same event but this time we know the hangout link
calendarId: selectedCalendar,

View File

@ -5,3 +5,5 @@ items:
---
Google Meet is Google's web-based video conferencing platform, designed to compete with major conferencing platforms.
Note this requires a connected Google Calendar.

View File

@ -21,7 +21,7 @@ export const metadata = {
trending: false,
url: "https://cal.com/",
verified: true,
isGlobal: true,
isGlobal: false,
email: "help@cal.com",
appData: {
location: {

View File

@ -0,0 +1,40 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
}
const appType = "google_video";
try {
const alreadyInstalled = await prisma.credential.findFirst({
where: {
type: appType,
userId: req.session.user.id,
},
});
if (alreadyInstalled) {
throw new Error("Already installed");
}
const installation = await prisma.credential.create({
data: {
type: appType,
key: {},
userId: req.session.user.id,
appId: "google-meet",
},
});
if (!installation) {
throw new Error("Unable to create user credential for google_video");
}
} catch (error: unknown) {
if (error instanceof Error) {
return res.status(500).json({ message: error.message });
}
return res.status(500);
}
return res.status(200).json({ url: getInstalledAppPath({ variant: "conferencing", slug: "google-meet" }) });
}

View File

@ -0,0 +1,5 @@
import { defaultHandler } from "@calcom/lib/server";
export default defaultHandler({
GET: import("./_getAdd"),
});

View File

@ -0,0 +1 @@
export { default as add } from "./add";

View File

@ -0,0 +1,43 @@
import { Trans } from "next-i18next";
import Link from "next/link";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Icon, SkeletonText } from "@calcom/ui";
const ExistingGoogleCal = () => {
const { t } = useLocale();
const { isLoading, data: hasGoogleCal } = trpc.viewer.appsRouter.checkForGCal.useQuery();
return (
<div className="rounded-md bg-blue-100 py-3 px-4 text-blue-900">
<div className="flex items-start space-x-2.5">
<div>
<Icon.FiAlertCircle className="font-semibold" />
</div>
<div>
<span className="font-semibold">{t("requires_google_calendar")}</span>
<div>
<>
{isLoading ? (
<SkeletonText className="h-4 w-full" />
) : hasGoogleCal ? (
t("connected_google_calendar")
) : (
<Trans i18nKey="no_google_calendar">
Please connect your Google Calendar account{" "}
<Link href={`${CAL_URL}/apps/google-calendar`} className="font-semibold text-blue-900">
here
</Link>
</Trans>
)}
</>
</div>
</div>
</div>
</div>
);
};
export default ExistingGoogleCal;

View File

@ -0,0 +1,64 @@
import { useState, useEffect } from "react";
import type { InstallAppButtonProps } from "@calcom/app-store/types";
import useApp from "@calcom/lib/hooks/useApp";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { DialogProps } from "@calcom/ui";
import { Button } from "@calcom/ui";
import { Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui";
import useAddAppMutation from "../../_utils/useAddAppMutation";
export default function InstallAppButton(props: InstallAppButtonProps) {
const [showWarningDialog, setShowWarningDialog] = useState(false);
return (
<>
{props.render({
onClick() {
setShowWarningDialog(true);
// mutation.mutate("");
},
disabled: showWarningDialog,
})}
<WarningDialog open={showWarningDialog} onOpenChange={setShowWarningDialog} />
</>
);
}
function WarningDialog(props: DialogProps) {
const { t } = useLocale();
const googleCalendarData = useApp("google-calendar");
const googleCalendarPresent = googleCalendarData.data?.isInstalled;
const mutation = useAddAppMutation(googleCalendarPresent ? "google_video" : "google_calendar", {
installGoogleVideo: !googleCalendarPresent,
});
return (
<Dialog name="Account check" open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent
type="creation"
title={t("using_meet_requires_calendar")}
description={googleCalendarPresent ? "" : t("continue_to_install_google_calendar")}>
<DialogFooter>
<>
<DialogClose
type="button"
color="secondary"
tabIndex={-1}
onClick={() => {
props.onOpenChange?.(false);
}}>
{t("cancel")}
</DialogClose>
<Button type="button" onClick={() => mutation.mutate("")}>
{googleCalendarPresent ? t("install_google_meet") : t("install_google_calendar")}
</Button>
</>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1 @@
export { default as InstallAppButton } from "./InstallAppButton";

View File

@ -40,6 +40,8 @@ export type EventLocationType = DefaultEventLocationType | EventLocationTypeFrom
export const DailyLocationType = "integrations:daily";
export const MeetLocationType = "integrations:google:meet";
export enum DefaultEventLocationTypeEnum {
/**
* Booker Address

View File

@ -7,6 +7,7 @@ import { ButtonProps } from "@calcom/ui";
export type IntegrationOAuthCallbackState = {
returnTo: string;
installGoogleVideo?: boolean;
};
export interface InstallAppButtonProps {

View File

@ -5,6 +5,7 @@ import { z } from "zod";
import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { getEventLocationTypeFromApp } from "@calcom/app-store/locations";
import { MeetLocationType } from "@calcom/app-store/locations";
import getApps from "@calcom/app-store/utils";
import prisma from "@calcom/prisma";
import { Attendee } from "@calcom/prisma/client";
@ -28,7 +29,7 @@ import { createEvent, updateEvent } from "./CalendarManager";
import { createMeeting, updateMeeting } from "./videoClient";
export const isDedicatedIntegration = (location: string): boolean => {
return location !== "integrations:google:meet" && location.includes("integrations:");
return location !== MeetLocationType && location.includes("integrations:");
};
export const getLocationRequestFromIntegration = (location: string) => {
@ -102,9 +103,15 @@ export default class EventManager {
const evt = processLocation(event);
// Fallback to cal video if no location is set
if (!evt.location) evt["location"] = "integrations:daily";
// Fallback to Cal Video if Google Meet is selected w/o a Google Cal
if (evt.location === MeetLocationType && evt.destinationCalendar?.integration !== "google_calendar") {
evt["location"] = "integrations:daily";
}
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
const results: Array<EventResult<Exclude<Event, AdditionalInformation>>> = [];
// If and only if event type is a dedicated meeting, create a dedicated video meeting.
if (isDedicated) {
const result = await this.createVideoEvent(evt);

View File

@ -16,6 +16,7 @@ export const AppsStatus = (props: { calEvent: CalendarEvent; t: TFunction }) =>
<li key={status.type} style={{ fontWeight: 400 }}>
{status.appName}{" "}
{status.success >= 1 && `${status.success > 1 ? `(x${status.success})` : ""}`}
{status.failures >= 1 && `${status.failures > 1 ? `(x${status.failures})` : ""}`}
{status.warnings && status.warnings.length >= 1 && (
<ul style={{ fontSize: "14px" }}>
{status.warnings.map((warning, i) => (
@ -23,7 +24,6 @@ export const AppsStatus = (props: { calEvent: CalendarEvent; t: TFunction }) =>
))}
</ul>
)}
{status.failures >= 1 && `${status.failures > 1 ? `(x${status.failures})` : ""}`}
{status.errors.length >= 1 && (
<ul>
{status.errors.map((error, i) => (

View File

@ -14,7 +14,9 @@ import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import z from "zod";
import { metadata as GoogleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata";
import { getLocationValueForDB, LocationObject } from "@calcom/app-store/locations";
import { MeetLocationType } from "@calcom/app-store/locations";
import { handleEthSignature } from "@calcom/app-store/rainbow/utils/ethereum";
import { handlePayment } from "@calcom/app-store/stripepayment/lib/server";
import { getEventTypeAppData } from "@calcom/app-store/utils";
@ -911,6 +913,38 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
const metadata: AdditionalInformation = {};
if (results.length) {
// Handle Google Meet results
// We use the original booking location since the evt location changes to daily
if (bookingLocation === MeetLocationType) {
const googleMeetResult = {
appName: GoogleMeetMetadata.name,
type: "conferencing",
uid: results[0].uid,
originalEvent: results[0].originalEvent,
};
const googleCalResult = results.find((result) => result.type === "google_calendar");
if (!googleCalResult) {
results.push({
...googleMeetResult,
success: false,
calWarnings: [tOrganizer("google_meet_warning")],
});
}
if (googleCalResult?.createdEvent?.hangoutLink) {
results.push({
...googleMeetResult,
success: true,
});
} else if (googleCalResult && !googleCalResult.createdEvent?.hangoutLink) {
results.push({
...googleMeetResult,
success: false,
});
}
}
// TODO: Handle created event metadata more elegantly
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData;

View File

@ -10,7 +10,7 @@ import { getTranslation } from "@calcom/lib/server/i18n";
import { TRPCError } from "@trpc/server";
import { authedAdminProcedure, router } from "../../trpc";
import { authedAdminProcedure, authedProcedure, router } from "../../trpc";
interface FilteredApp {
name: string;
@ -268,4 +268,13 @@ export const appsRouter = router({
},
});
}),
checkForGCal: authedProcedure.query(async ({ ctx }) => {
const gCalPresent = await ctx.prisma.credential.findFirst({
where: {
type: "google_calendar",
userId: ctx.user.id,
},
});
return !!gCalPresent;
}),
});