diff --git a/.env.example b/.env.example index 2a9bfb6b43..028249407e 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,8 @@ NEXT_PUBLIC_LICENSE_CONSENT='' DATABASE_URL="postgresql://postgres:@localhost:5432/calendso?schema=public" GOOGLE_API_CREDENTIALS='secret' +GOOGLE_REDIRECT_URL='https://localhost:3000/integrations/googlecalendar/callback' + BASE_URL='http://localhost:3000' NEXT_PUBLIC_APP_URL='http://localhost:3000' diff --git a/components/Dialog.tsx b/components/Dialog.tsx index c629c81183..9465662c9f 100644 --- a/components/Dialog.tsx +++ b/components/Dialog.tsx @@ -1,7 +1,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"; -import React from "react"; +import React, { ReactNode } from "react"; -type DialogProps = React.ComponentProps; +export type DialogProps = React.ComponentProps; export function Dialog(props: DialogProps) { const { children, ...other } = props; return ( @@ -35,9 +35,15 @@ export function DialogHeader({ title, subtitle }: DialogHeaderProps) { -
-

{subtitle}

-
+
{subtitle}
+ + ); +} + +export function DialogFooter(props: { children: ReactNode }) { + return ( +
+
{props.children}
); } diff --git a/components/List.tsx b/components/List.tsx new file mode 100644 index 0000000000..996d84a0ca --- /dev/null +++ b/components/List.tsx @@ -0,0 +1,72 @@ +import Link from "next/link"; +import { createElement } from "react"; + +import classNames from "@lib/classNames"; + +export function List(props: JSX.IntrinsicElements["ul"]) { + return ( + + ); +} + +export type ListItemProps = { expanded?: boolean } & ({ href?: never } & JSX.IntrinsicElements["li"]); + +export function ListItem(props: ListItemProps) { + const { href, expanded, ...passThroughProps } = props; + + const elementType = href ? "a" : "li"; + + const element = createElement( + elementType, + { + ...passThroughProps, + className: classNames( + "items-center bg-white min-w-0 flex-1 flex border-gray-200", + expanded ? "my-2 border" : "border -mb-px last:mb-0", + props.className, + (props.onClick || href) && "hover:bg-neutral-50" + ), + }, + props.children + ); + + return href ? ( + + {element} + + ) : ( + element + ); +} + +export function ListItemTitle( + props: JSX.IntrinsicElements[TComponent] & { component?: TComponent } +) { + const { component = "span", ...passThroughProps } = props; + + return createElement( + component, + { + ...passThroughProps, + className: classNames("text-sm font-medium text-neutral-900 truncate", props.className), + }, + props.children + ); +} + +export function ListItemText( + props: JSX.IntrinsicElements[TComponent] & { component?: TComponent } +) { + const { component = "span", ...passThroughProps } = props; + + return createElement( + component, + { + ...passThroughProps, + className: classNames("text-sm text-gray-500", props.className), + }, + props.children + ); +} diff --git a/components/Shell.tsx b/components/Shell.tsx index 5f631cc7e3..2b7d26dbae 100644 --- a/components/Shell.tsx +++ b/components/Shell.tsx @@ -19,6 +19,7 @@ import LicenseBanner from "@ee/components/LicenseBanner"; import HelpMenuItemDynamic from "@ee/lib/intercom/HelpMenuItemDynamic"; import classNames from "@lib/classNames"; +import { shouldShowOnboarding } from "@lib/getting-started"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; import { trpc } from "@lib/trpc"; @@ -29,10 +30,7 @@ import Logo from "./Logo"; function useMeQuery() { const [session] = useSession(); - const meQuery = trpc.useQuery(["viewer.me"], { - // refetch max once per 5s - staleTime: 5000, - }); + const meQuery = trpc.useQuery(["viewer.me"]); useEffect(() => { // refetch if sesion changes @@ -59,6 +57,26 @@ function useRedirectToLoginIfUnauthenticated() { }, [loading, session, router]); } +export function ShellSubHeading(props: { + title: ReactNode; + subtitle?: ReactNode; + actions?: ReactNode; + className?: string; +}) { + return ( +
+
+ {/* TODO should be Roboto */} +

+ {props.title} +

+ {props.subtitle &&

{props.subtitle}

} +
+ {props.actions &&
{props.actions}
} +
+ ); +} + export default function Shell(props: { centered?: boolean; title?: string; @@ -74,6 +92,15 @@ export default function Shell(props: { const telemetry = useTelemetry(); const query = useMeQuery(); + useEffect( + function redirectToOnboardingIfNeeded() { + if (query.data && shouldShowOnboarding(query.data)) { + router.push("/getting-started"); + } + }, + [query.data, router] + ); + const navigation = [ { name: "Event Types", @@ -209,7 +236,6 @@ export default function Shell(props: {
{props.CTA}
{props.children}
- {/* show bottom navigation for md and smaller (tablet and phones) */} - {/* add padding to content for mobile navigation*/}
diff --git a/components/form/fields.tsx b/components/form/fields.tsx new file mode 100644 index 0000000000..88b0c7155b --- /dev/null +++ b/components/form/fields.tsx @@ -0,0 +1,63 @@ +import { useId } from "@radix-ui/react-id"; +import { forwardRef, ReactNode } from "react"; +import { FormProvider, UseFormReturn } from "react-hook-form"; + +import classNames from "@lib/classNames"; + +export const Input = forwardRef(function Input(props, ref) { + return ( + + ); +}); + +export function Label(props: JSX.IntrinsicElements["label"]) { + return ( + + ); +} + +export const TextField = forwardRef< + HTMLInputElement, + { + label: ReactNode; + } & React.ComponentProps & { + labelProps?: React.ComponentProps; + } +>(function TextField(props, ref) { + const id = useId(); + const { label, ...passThroughToInput } = props; + + // TODO: use `useForm()` from RHF and get error state here too! + return ( +
+ + +
+ ); +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const Form = forwardRef } & JSX.IntrinsicElements["form"]>( + function Form(props, ref) { + const { form, ...passThrough } = props; + + return ( + +
+ {props.children} +
+
+ ); + } +); diff --git a/components/integrations/IntegrationList.tsx b/components/integrations/IntegrationList.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/components/ui/Alert.tsx b/components/ui/Alert.tsx index 4b2f6adbde..ef682c4c11 100644 --- a/components/ui/Alert.tsx +++ b/components/ui/Alert.tsx @@ -5,6 +5,7 @@ import { ReactNode } from "react"; export interface AlertProps { title?: ReactNode; message?: ReactNode; + actions?: ReactNode; className?: string; severity: "success" | "warning" | "error"; } @@ -36,6 +37,7 @@ export function Alert(props: AlertProps) {

{props.title}

{props.message}
+ {props.actions &&
{props.actions}
} ); diff --git a/components/ui/Badge.tsx b/components/ui/Badge.tsx index 6f9508fb72..dcd54f700d 100644 --- a/components/ui/Badge.tsx +++ b/components/ui/Badge.tsx @@ -13,7 +13,7 @@ export const Badge = function Badge(props: BadgeProps) { & { href: LinkProps["href"] }) - | (JSX.IntrinsicElements["button"] & { href?: never }) -); +}; +export type ButtonProps = ButtonBaseProps & + ( + | (Omit & { href: LinkProps["href"] }) + | (JSX.IntrinsicElements["button"] & { href?: never }) + ); export const Button = forwardRef(function Button( props: ButtonProps, diff --git a/components/ui/Switch.tsx b/components/ui/Switch.tsx index 360f5d93f7..20bbb386eb 100644 --- a/components/ui/Switch.tsx +++ b/components/ui/Switch.tsx @@ -1,3 +1,4 @@ +import { useId } from "@radix-ui/react-id"; import * as Label from "@radix-ui/react-label"; import * as PrimitiveSwitch from "@radix-ui/react-switch"; import { useState } from "react"; @@ -16,7 +17,7 @@ export default function Switch(props) { } setChecked(change); }; - + const id = useId(); return (
{label && ( - + {label} )} diff --git a/environment.d.ts b/environment.d.ts index c5a965c90d..65a7dff1c6 100644 --- a/environment.d.ts +++ b/environment.d.ts @@ -3,6 +3,7 @@ declare namespace NodeJS { readonly CALENDSO_ENCRYPTION_KEY: string | undefined; readonly DATABASE_URL: string | undefined; readonly GOOGLE_API_CREDENTIALS: string | undefined; + readonly GOOGLE_REDIRECT_URL: string | undefined; readonly BASE_URL: string | undefined; readonly NEXT_PUBLIC_BASE_URL: string | undefined; readonly NEXT_PUBLIC_APP_URL: string | undefined; diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts index ef43eae027..8e1afd73ce 100644 --- a/lib/calendarClient.ts +++ b/lib/calendarClient.ts @@ -517,8 +517,26 @@ const GoogleCalendar = (credential): CalendarApiAdapter => { }; }; -// factory -const calendars = (withCredentials): CalendarApiAdapter[] => +function getCalendarAdapterOrNull(credential: Credential): CalendarApiAdapter | null { + switch (credential.type) { + case "google_calendar": + return GoogleCalendar(credential); + case "office365_calendar": + return MicrosoftOffice365Calendar(credential); + case "caldav_calendar": + // FIXME types wrong & type casting should not be needed + return new CalDavCalendar(credential) as never as CalendarApiAdapter; + case "apple_calendar": + // FIXME types wrong & type casting should not be needed + return new AppleCalendar(credential) as never as CalendarApiAdapter; + } + return null; +} + +/** + * @deprecated + */ +const calendars = (withCredentials: Credential[]): CalendarApiAdapter[] => withCredentials .map((cred) => { switch (cred.type) { @@ -534,7 +552,7 @@ const calendars = (withCredentials): CalendarApiAdapter[] => return; // unknown credential, could be legacy? In any case, ignore } }) - .filter(Boolean); + .flatMap((item) => (item ? [item as CalendarApiAdapter] : [])); const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) => Promise.all( @@ -543,6 +561,11 @@ const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalenda return results.reduce((acc, availability) => acc.concat(availability), []); }); +/** + * + * @param withCredentials + * @deprecated + */ const listCalendars = (withCredentials) => Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) => results.reduce((acc, calendars) => acc.concat(calendars), []).filter((c) => c != null) @@ -650,4 +673,11 @@ const deleteEvent = (credential: Credential, uid: string): Promise => { return Promise.resolve({}); }; -export { getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, listCalendars }; +export { + getBusyCalendarTimes, + createEvent, + updateEvent, + deleteEvent, + listCalendars, + getCalendarAdapterOrNull, +}; diff --git a/lib/integrations/Apple/AppleCalendarAdapter.ts b/lib/integrations/Apple/AppleCalendarAdapter.ts index a619e8204b..5a30e7c4ee 100644 --- a/lib/integrations/Apple/AppleCalendarAdapter.ts +++ b/lib/integrations/Apple/AppleCalendarAdapter.ts @@ -255,10 +255,11 @@ export class AppleCalendar implements CalendarApiAdapter { .filter((calendar) => { return calendar.components?.includes("VEVENT"); }) - .map((calendar) => ({ + .map((calendar, index) => ({ externalId: calendar.url, name: calendar.displayName ?? "", - primary: false, + // FIXME Find a better way to set the primary calendar + primary: index === 0, integration: this.integrationName, })); } catch (reason) { diff --git a/lib/integrations/Apple/components/AddAppleIntegration.tsx b/lib/integrations/Apple/components/AddAppleIntegration.tsx index 5ab5fe8a98..a18e62f167 100644 --- a/lib/integrations/Apple/components/AddAppleIntegration.tsx +++ b/lib/integrations/Apple/components/AddAppleIntegration.tsx @@ -1,4 +1,17 @@ -import React from "react"; +import React, { useState } from "react"; +import { useForm } from "react-hook-form"; + +import { + DialogHeader, + DialogProps, + Dialog, + DialogContent, + DialogClose, + DialogFooter, +} from "@components/Dialog"; +import { Form, TextField } from "@components/form/fields"; +import { Alert } from "@components/ui/Alert"; +import Button from "@components/ui/Button"; type Props = { onSubmit: () => void; @@ -6,6 +19,95 @@ type Props = { export const ADD_APPLE_INTEGRATION_FORM_TITLE = "addAppleIntegration"; +export function AddAppleIntegrationModal(props: DialogProps) { + const form = useForm({ + defaultValues: { + username: "", + password: "", + }, + }); + const [errorMessage, setErrorMessage] = useState(""); + return ( + + + + Generate an app specific password to use with Cal.com at{" "} + + https://appleid.apple.com/account/manage + + . Your credentials will be stored and encrypted. + + } + /> + +
{ + setErrorMessage(""); + const res = await fetch("/api/integrations/apple/add", { + method: "POST", + body: JSON.stringify(values), + headers: { + "Content-Type": "application/json", + }, + }); + const json = await res.json(); + if (!res.ok) { + setErrorMessage(json?.message || "Something went wrong"); + } else { + props.onOpenChange?.(false); + } + })}> +
+ + +
+ + {errorMessage && } + + { + props.onOpenChange?.(false); + }} + asChild> + + + + + + +
+
+ ); +} + +/** + * @deprecated + */ const AddAppleIntegration = React.forwardRef((props, ref) => { const onSubmit = (event) => { event.preventDefault(); diff --git a/lib/integrations/CalDav/CalDavCalendarAdapter.ts b/lib/integrations/CalDav/CalDavCalendarAdapter.ts index 633b5a02ee..62dc8ad95f 100644 --- a/lib/integrations/CalDav/CalDavCalendarAdapter.ts +++ b/lib/integrations/CalDav/CalDavCalendarAdapter.ts @@ -79,6 +79,7 @@ export class CalDavCalendar implements CalendarApiAdapter { const { error, value: iCalString } = await createEvent({ uid, startInputType: "utc", + // FIXME types start: this.convertDate(event.startTime), duration: this.getDuration(event.startTime, event.endTime), title: event.title, @@ -137,6 +138,7 @@ export class CalDavCalendar implements CalendarApiAdapter { const { error, value: iCalString } = await createEvent({ uid, startInputType: "utc", + // FIXME - types wrong start: this.convertDate(event.startTime), duration: this.getDuration(event.startTime, event.endTime), title: event.title, @@ -203,6 +205,7 @@ export class CalDavCalendar implements CalendarApiAdapter { } } + // FIXME - types wrong async getAvailability( dateFrom: string, dateTo: string, @@ -258,10 +261,11 @@ export class CalDavCalendar implements CalendarApiAdapter { .filter((calendar) => { return calendar.components?.includes("VEVENT"); }) - .map((calendar) => ({ + .map((calendar, index) => ({ externalId: calendar.url, name: calendar.displayName ?? "", - primary: false, + // FIXME Find a better way to set the primary calendar + primary: index === 0, integration: this.integrationName, })); } catch (reason) { diff --git a/lib/integrations/CalDav/components/AddCalDavIntegration.tsx b/lib/integrations/CalDav/components/AddCalDavIntegration.tsx index 33a00f04e5..ee15401699 100644 --- a/lib/integrations/CalDav/components/AddCalDavIntegration.tsx +++ b/lib/integrations/CalDav/components/AddCalDavIntegration.tsx @@ -1,4 +1,17 @@ -import React from "react"; +import React, { useState } from "react"; +import { useForm } from "react-hook-form"; + +import { + DialogHeader, + DialogProps, + Dialog, + DialogContent, + DialogClose, + DialogFooter, +} from "@components/Dialog"; +import { Form, TextField } from "@components/form/fields"; +import { Alert } from "@components/ui/Alert"; +import Button from "@components/ui/Button"; type Props = { onSubmit: () => void; @@ -11,8 +24,93 @@ export type AddCalDavIntegrationRequest = { password: string; }; +export function AddCalDavIntegrationModal(props: DialogProps) { + const form = useForm({ + defaultValues: { + url: "", + username: "", + password: "", + }, + }); + const [errorMessage, setErrorMessage] = useState(""); + return ( + + + + +
{ + setErrorMessage(""); + const res = await fetch("/api/integrations/caldav/add", { + method: "POST", + body: JSON.stringify(values), + headers: { + "Content-Type": "application/json", + }, + }); + const json = await res.json(); + if (!res.ok) { + setErrorMessage(json?.message || "Something went wrong"); + } else { + props.onOpenChange?.(false); + } + })}> +
+ + + +
+ + {errorMessage && } + + { + props.onOpenChange?.(false); + }} + asChild> + + + + + + +
+
+ ); +} + +/** + * @deprecated + */ const AddCalDavIntegration = React.forwardRef((props, ref) => { - const onSubmit = (event) => { + const onSubmit = (event: any) => { event.preventDefault(); event.stopPropagation(); diff --git a/lib/integrations/getIntegrations.ts b/lib/integrations/getIntegrations.ts index a42c6605f3..5fb4f0227b 100644 --- a/lib/integrations/getIntegrations.ts +++ b/lib/integrations/getIntegrations.ts @@ -8,65 +8,75 @@ const credentialData = Prisma.validator()({ type CredentialData = Prisma.CredentialGetPayload; +export const ALL_INTEGRATIONS = [ + { + installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)), + type: "google_calendar", + title: "Google Calendar", + imageSrc: "integrations/google-calendar.svg", + description: "For personal and business calendars", + variant: "calendar", + }, + { + installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET), + type: "office365_calendar", + title: "Office 365 / Outlook.com Calendar", + imageSrc: "integrations/outlook.svg", + description: "For personal and business calendars", + variant: "calendar", + }, + { + installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET), + type: "zoom_video", + title: "Zoom", + imageSrc: "integrations/zoom.svg", + description: "Video Conferencing", + variant: "conferencing", + }, + { + installed: true, + type: "caldav_calendar", + title: "CalDav Server", + imageSrc: "integrations/caldav.svg", + description: "For personal and business calendars", + variant: "calendar", + }, + { + installed: true, + type: "apple_calendar", + title: "Apple Calendar", + imageSrc: "integrations/apple-calendar.svg", + description: "For personal and business calendars", + variant: "calendar", + }, + { + installed: !!( + process.env.STRIPE_CLIENT_ID && + process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && + process.env.STRIPE_PRIVATE_KEY + ), + type: "stripe_payment", + title: "Stripe", + imageSrc: "integrations/stripe.svg", + description: "Receive payments", + variant: "payment", + }, +] as const; function getIntegrations(credentials: CredentialData[]) { - const integrations = [ - { - installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)), - credential: credentials.find((integration) => integration.type === "google_calendar") || null, - type: "google_calendar", - title: "Google Calendar", - imageSrc: "integrations/google-calendar.svg", - description: "For personal and business calendars", - }, - { - installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET), - type: "office365_calendar", - credential: credentials.find((integration) => integration.type === "office365_calendar") || null, - title: "Office 365 / Outlook.com Calendar", - imageSrc: "integrations/outlook.svg", - description: "For personal and business calendars", - }, - { - installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET), - type: "zoom_video", - credential: credentials.find((integration) => integration.type === "zoom_video") || null, - title: "Zoom", - imageSrc: "integrations/zoom.svg", - description: "Video Conferencing", - }, - { - installed: true, - type: "caldav_calendar", - credential: credentials.find((integration) => integration.type === "caldav_calendar") || null, - title: "CalDav Server", - imageSrc: "integrations/caldav.svg", - description: "For personal and business calendars", - }, - { - installed: true, - type: "apple_calendar", - credential: credentials.find((integration) => integration.type === "apple_calendar") || null, - title: "Apple Calendar", - imageSrc: "integrations/apple-calendar.svg", - description: "For personal and business calendars", - }, - { - installed: !!( - process.env.STRIPE_CLIENT_ID && - process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && - process.env.STRIPE_PRIVATE_KEY - ), - type: "stripe_payment", - credential: credentials.find((integration) => integration.type === "stripe_payment") || null, - title: "Stripe", - imageSrc: "integrations/stripe.svg", - description: "Receive payments", - }, - ]; + const integrations = ALL_INTEGRATIONS.map((integration) => ({ + ...integration, + /** + * @deprecated use `credentials. + */ + credential: credentials.find((credential) => credential.type === integration.type) || null, + credentials: credentials.filter((credential) => credential.type === integration.type) || null, + })); return integrations; } +export type IntegraionMeta = ReturnType; + export function hasIntegration(integrations: ReturnType, type: string): boolean { return !!integrations.find((i) => i.type === type && !!i.installed && !!i.credential); } diff --git a/pages/api/availability/calendar.ts b/pages/api/availability/calendar.ts index f036a48254..6f976a70e7 100644 --- a/pages/api/availability/calendar.ts +++ b/pages/api/availability/calendar.ts @@ -8,12 +8,12 @@ import prisma from "../../../lib/prisma"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const session = await getSession({ req: req }); - if (!session) { + if (!session?.user?.id) { res.status(401).json({ message: "Not authenticated" }); return; } - const currentUser = await prisma.user.findFirst({ + const currentUser = await prisma.user.findUnique({ where: { id: session.user.id, }, @@ -24,17 +24,27 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); + if (!currentUser) { + res.status(401).json({ message: "Not authenticated" }); + return; + } + if (req.method == "POST") { - await prisma.selectedCalendar.create({ - data: { - user: { - connect: { - id: currentUser.id, - }, + await prisma.selectedCalendar.upsert({ + where: { + userId_integration_externalId: { + userId: currentUser.id, + integration: req.body.integration, + externalId: req.body.externalId, }, + }, + create: { + userId: currentUser.id, integration: req.body.integration, externalId: req.body.externalId, }, + // already exists + update: {}, }); res.status(200).json({ message: "Calendar Selection Saved" }); } diff --git a/pages/api/integrations/googlecalendar/add.ts b/pages/api/integrations/googlecalendar/add.ts index fe70f8add7..cb624f2bf4 100644 --- a/pages/api/integrations/googlecalendar/add.ts +++ b/pages/api/integrations/googlecalendar/add.ts @@ -21,7 +21,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Get token from Google Calendar API const { client_secret, client_id, redirect_uris } = JSON.parse(credentials).web; - const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); + const redirect_uri = process.env.GOOGLE_REDIRECT_URL || redirect_uris[0]; + const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri); const authUrl = oAuth2Client.generateAuthUrl({ access_type: "offline", diff --git a/pages/api/integrations/googlecalendar/callback.ts b/pages/api/integrations/googlecalendar/callback.ts index 36793f9a76..62066d6417 100644 --- a/pages/api/integrations/googlecalendar/callback.ts +++ b/pages/api/integrations/googlecalendar/callback.ts @@ -2,10 +2,9 @@ import { google } from "googleapis"; import type { NextApiRequest, NextApiResponse } from "next"; import { getSession } from "@lib/auth"; +import prisma from "@lib/prisma"; -import prisma from "../../../../lib/prisma"; - -const credentials = process.env.GOOGLE_API_CREDENTIALS!; +const credentials = process.env.GOOGLE_API_CREDENTIALS; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { code } = req.query; @@ -13,7 +12,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Check that user is authenticated const session = await getSession({ req: req }); - if (!session) { + if (!session?.user?.id) { res.status(401).json({ message: "You must be logged in to do this" }); return; } @@ -21,9 +20,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) res.status(400).json({ message: "`code` must be a string" }); return; } + if (!credentials) { + res.status(400).json({ message: "There are no Google Credentials installed." }); + return; + } const { client_secret, client_id, redirect_uris } = JSON.parse(credentials).web; - const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); + const redirect_uri = process.env.GOOGLE_REDIRECT_URL || redirect_uris[0]; + const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri); const token = await oAuth2Client.getToken(code); const key = token.res?.data; await prisma.credential.create({ diff --git a/pages/getting-started.tsx b/pages/getting-started.tsx index b9db02a14f..83f34c21f1 100644 --- a/pages/getting-started.tsx +++ b/pages/getting-started.tsx @@ -16,7 +16,7 @@ import { NextPageContext } from "next"; import { useSession } from "next-auth/client"; import Head from "next/head"; import { useRouter } from "next/router"; -import { Integration } from "pages/integrations"; +import { Integration } from "pages/integrations/_new"; import React, { useEffect, useRef, useState } from "react"; import TimezoneSelect from "react-timezone-select"; diff --git a/pages/integrations/[integration].tsx b/pages/integrations/[integration].tsx index 76ea262854..1d431eed67 100644 --- a/pages/integrations/[integration].tsx +++ b/pages/integrations/[integration].tsx @@ -1,103 +1,9 @@ -import { useSession } from "next-auth/client"; -import { useRouter } from "next/router"; - -import { getSession } from "@lib/auth"; -import { getIntegrationName, getIntegrationType } from "@lib/integrations"; -import prisma from "@lib/prisma"; - -import Loader from "@components/Loader"; -import Shell from "@components/Shell"; - -export default function Integration(props) { - const router = useRouter(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [session, loading] = useSession(); - - if (loading) { - return ; - } - - async function deleteIntegrationHandler(event) { - event.preventDefault(); - - /*eslint-disable */ - const response = await fetch("/api/integrations", { - method: "DELETE", - body: JSON.stringify({ id: props.integration.id }), - headers: { - "Content-Type": "application/json", - }, - }); - /*eslint-enable */ - - router.push("/integrations"); - } - - return ( -
- -
-
-
-

Integration Details

-

- Information about your {getIntegrationName(props.integration.type)} App. -

-
-
-
-
-
App name
-
{getIntegrationName(props.integration.type)}
-
-
-
App Category
-
{getIntegrationType(props.integration.type)}
-
-
-
-
-
-
-
-

Delete this app

-
-

Once you delete this app, it will be permanently removed.

-
-
- -
-
-
-
-
-
-
- ); +function RedirectPage() { + return null; } -export async function getServerSideProps(context) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const session = await getSession(context); - - const integration = await prisma.credential.findFirst({ - where: { - id: parseInt(context.query.integration), - }, - select: { - id: true, - type: true, - key: true, - }, - }); - return { - props: { session, integration }, - }; +export async function getServerSideProps() { + return { redirect: { permanent: false, destination: "/integrations" } }; } + +export default RedirectPage; diff --git a/pages/integrations/index.tsx b/pages/integrations/index.tsx index 81d36a1202..75b98cdfd0 100644 --- a/pages/integrations/index.tsx +++ b/pages/integrations/index.tsx @@ -1,510 +1,410 @@ -import { InformationCircleIcon } from "@heroicons/react/outline"; -import { CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon } from "@heroicons/react/solid"; -import { GetServerSidePropsContext } from "next"; -import { useSession } from "next-auth/client"; -import Link from "next/link"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { Maybe } from "@trpc/server"; +import Image from "next/image"; +import { ReactNode, useEffect, useState } from "react"; +import { useMutation } from "react-query"; -import { getSession } from "@lib/auth"; -import { ONBOARDING_NEXT_REDIRECT, shouldShowOnboarding } from "@lib/getting-started"; -import AddAppleIntegration, { - ADD_APPLE_INTEGRATION_FORM_TITLE, -} from "@lib/integrations/Apple/components/AddAppleIntegration"; -import AddCalDavIntegration, { - ADD_CALDAV_INTEGRATION_FORM_TITLE, -} from "@lib/integrations/CalDav/components/AddCalDavIntegration"; -import getIntegrations from "@lib/integrations/getIntegrations"; -import prisma from "@lib/prisma"; -import { inferSSRProps } from "@lib/types/inferSSRProps"; +import { QueryCell } from "@lib/QueryCell"; +import classNames from "@lib/classNames"; +import { AddAppleIntegrationModal } from "@lib/integrations/Apple/components/AddAppleIntegration"; +import { AddCalDavIntegrationModal } from "@lib/integrations/CalDav/components/AddCalDavIntegration"; +import showToast from "@lib/notification"; +import { inferQueryOutput, trpc } from "@lib/trpc"; -import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTrigger } from "@components/Dialog"; -import Loader from "@components/Loader"; -import Shell from "@components/Shell"; -import Button from "@components/ui/Button"; +import { Dialog } from "@components/Dialog"; +import { List, ListItem, ListItemText, ListItemTitle } from "@components/List"; +import Shell, { ShellSubHeading } from "@components/Shell"; +import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent"; +import { Alert } from "@components/ui/Alert"; +import Badge from "@components/ui/Badge"; +import Button, { ButtonBaseProps } from "@components/ui/Button"; import Switch from "@components/ui/Switch"; -export default function Home({ integrations }: inferSSRProps) { - const [, loading] = useSession(); +type IntegrationCalendar = inferQueryOutput<"viewer.integrations">["calendar"]["items"][number]; - const [selectableCalendars, setSelectableCalendars] = useState([]); - const addCalDavIntegrationRef = useRef(null); - const [isAddCalDavIntegrationDialogOpen, setIsAddCalDavIntegrationDialogOpen] = useState(false); - const [addCalDavError, setAddCalDavError] = useState<{ message: string } | null>(null); +function pluralize(opts: { num: number; plural: string; singular: string }) { + if (opts.num === 0) { + return opts.singular; + } + return opts.singular; +} - const addAppleIntegrationRef = useRef(null); - const [isAddAppleIntegrationDialogOpen, setIsAddAppleIntegrationDialogOpen] = useState(false); - const [addAppleError, setAddAppleError] = useState<{ message: string } | null>(null); +function SubHeadingTitleWithConnections(props: { title: ReactNode; numConnections?: number }) { + const num = props.numConnections; + return ( + <> + {props.title} + {num ? ( + + {num}{" "} + {pluralize({ + num, + singular: "connection", + plural: "connections", + })} + + ) : null} + + ); +} - useEffect(loadCalendars, [integrations]); +function ConnectIntegration(props: { + type: IntegrationCalendar["type"]; + render: (renderProps: ButtonBaseProps) => JSX.Element; +}) { + const { type } = props; + const [isLoading, setIsLoading] = useState(false); + const mutation = useMutation(async () => { + const res = await fetch("/api/integrations/" + type.replace("_", "") + "/add"); + if (!res.ok) { + throw new Error("Something went wrong"); + } + const json = await res.json(); + window.location.href = json.url; + setIsLoading(true); + }); + const [isModalOpen, setIsModalOpen] = useState(false); - function loadCalendars() { - fetch("api/availability/calendar") - .then((response) => response.json()) - .then((data) => { - setSelectableCalendars(data); + // refetch intergrations when modal closes + const utils = trpc.useContext(); + useEffect(() => { + utils.invalidateQueries(["viewer.integrations"]); + }, [isModalOpen, utils]); + + return ( + <> + {props.render({ + onClick() { + if (["caldav_calendar", "apple_calendar"].includes(type)) { + // special handlers + setIsModalOpen(true); + return; + } + + mutation.mutate(); + }, + loading: mutation.isLoading || isLoading, + disabled: isModalOpen, + })} + {type === "caldav_calendar" && ( + + )} + + {type === "apple_calendar" && ( + + )} + + ); +} + +function DisconnectIntegration(props: { + /** + * Integration credential id + */ + id: number; + render: (renderProps: ButtonBaseProps) => JSX.Element; +}) { + const utils = trpc.useContext(); + const [modalOpen, setModalOpen] = useState(false); + const mutation = useMutation( + async () => { + const res = await fetch("/api/integrations", { + method: "DELETE", + body: JSON.stringify({ id: props.id }), + headers: { + "Content-Type": "application/json", + }, }); - } - - function integrationHandler(type) { - if (type === "caldav_calendar") { - setAddCalDavError(null); - setIsAddCalDavIntegrationDialogOpen(true); - return; - } - - if (type === "apple_calendar") { - setAddAppleError(null); - setIsAddAppleIntegrationDialogOpen(true); - return; - } - - fetch("/api/integrations/" + type.replace("_", "") + "/add") - .then((response) => response.json()) - .then((data) => (window.location.href = data.url)); - } - - const handleAddCalDavIntegration = async ({ url, username, password }) => { - const requestBody = JSON.stringify({ - url, - username, - password, - }); - - return await fetch("/api/integrations/caldav/add", { - method: "POST", - body: requestBody, - headers: { - "Content-Type": "application/json", + if (!res.ok) { + throw new Error("Something went wrong"); + } + }, + { + async onSettled() { + await utils.invalidateQueries(["viewer.integrations"]); }, - }); - }; - - const handleAddAppleIntegration = async ({ username, password }) => { - const requestBody = JSON.stringify({ - username, - password, - }); - - return await fetch("/api/integrations/apple/add", { - method: "POST", - body: requestBody, - headers: { - "Content-Type": "application/json", + onSuccess() { + setModalOpen(false); }, - }); - }; + } + ); + return ( + <> + + { + mutation.mutate(); + }}> + Are you sure you want to disconnect this integration? + + + {props.render({ + onClick() { + setModalOpen(true); + }, + disabled: modalOpen, + loading: mutation.isLoading, + })} + + ); +} - function calendarSelectionHandler(calendar) { - return (selected) => { - const i = selectableCalendars.findIndex((c) => c.externalId === calendar.externalId); - selectableCalendars[i].selected = selected; - if (selected) { - fetch("api/availability/calendar", { +function ConnectOrDisconnectIntegrationButton(props: { + // + credential: Maybe<{ id: number }>; + type: IntegrationCalendar["type"]; + installed: boolean; +}) { + if (props.credential) { + return ( + ( + + )} + /> + ); + } + if (!props.installed) { + return ; + } + return ( + } /> + ); +} + +function IntegrationListItem(props: { + imageSrc: string; + title: string; + description: string; + actions?: ReactNode; + children?: ReactNode; +}) { + return ( + +
+
+ {props.title} +
+
+ {props.title} + {props.description} +
+
{props.actions}
+
+ {props.children &&
{props.children}
} +
+ ); +} + +export function CalendarSwitch(props: { + type: IntegrationCalendar["type"]; + externalId: string; + title: string; + defaultSelected: boolean; +}) { + const utils = trpc.useContext(); + + const mutation = useMutation< + unknown, + unknown, + { + isOn: boolean; + } + >( + async ({ isOn }) => { + const body = { + integration: props.type, + externalId: props.externalId, + }; + if (isOn) { + const res = await fetch("/api/availability/calendar", { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify(selectableCalendars[i]), - }).then((response) => response.json()); + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error("Something went wrong"); + } } else { - fetch("api/availability/calendar", { + const res = await fetch("/api/availability/calendar", { method: "DELETE", headers: { "Content-Type": "application/json", }, - body: JSON.stringify(selectableCalendars[i]), - }).then((response) => response.json()); + body: JSON.stringify(body), + }); + + if (!res.ok) { + throw new Error("Something went wrong"); + } } - }; - } - - function getCalendarIntegrationImage(integrationType: string) { - switch (integrationType) { - case "google_calendar": - return "integrations/google-calendar.svg"; - case "office365_calendar": - return "integrations/outlook.svg"; - case "caldav_calendar": - return "integrations/caldav.svg"; - case "apple_calendar": - return "integrations/apple-calendar.svg"; - default: - return ""; + }, + { + async onSettled() { + await utils.invalidateQueries(["viewer.integrations"]); + }, + onError() { + showToast(`Something went wrong when toggling "${props.title}""`, "error"); + }, } - } - - function onCloseSelectCalendar() { - setSelectableCalendars([...selectableCalendars]); - } - - const ConnectNewAppDialog = () => ( - - - - Connect a new App - - - - -
-
    - {integrations - .filter((integration) => integration.installed) - .map((integration) => { - return ( -
  • -
    - {integration.title} -
    -
    -

    {integration.title}

    -

    {integration.description}

    -
    -
    - -
    -
  • - ); - })} -
-
-
- - - -
-
-
); - - const SelectCalendarDialog = () => ( - !open && onCloseSelectCalendar()}> - - Select calendars - - - - -
-
    - {selectableCalendars.map((calendar) => ( -
  • -
    - {calendar.integration} -
    -
    -

    {calendar.name}

    -
    -
    - -
    -
  • - ))} -
-
-
- - - -
-
-
+ return ( + { + mutation.mutate({ isOn }); + }} + /> ); +} - const handleAddCalDavIntegrationSaveButtonPress = async () => { - const form = addCalDavIntegrationRef.current.elements; - const url = form.url.value; - const password = form.password.value; - const username = form.username.value; - - try { - setAddCalDavError(null); - const addCalDavIntegrationResponse = await handleAddCalDavIntegration({ username, password, url }); - if (addCalDavIntegrationResponse.ok) { - setIsAddCalDavIntegrationDialogOpen(false); - } else { - const j = await addCalDavIntegrationResponse.json(); - setAddCalDavError({ message: j.message }); - } - } catch (reason) { - console.error(reason); - } - }; - - const handleAddAppleIntegrationSaveButtonPress = async () => { - const form = addAppleIntegrationRef.current.elements; - const password = form.password.value; - const username = form.username.value; - - try { - setAddAppleError(null); - const addAppleIntegrationResponse = await handleAddAppleIntegration({ username, password }); - if (addAppleIntegrationResponse.ok) { - setIsAddAppleIntegrationDialogOpen(false); - } else { - const j = await addAppleIntegrationResponse.json(); - setAddAppleError({ message: j.message }); - } - } catch (reason) { - console.error(reason); - } - }; - - const ConnectCalDavServerDialog = useCallback(() => { - return ( - setIsAddCalDavIntegrationDialogOpen(isOpen)}> - - -
- {addCalDavError && ( -

- Error: - {addCalDavError.message} -

- )} - -
-
- - { - setIsAddCalDavIntegrationDialogOpen(false); - }} - asChild> - - -
-
-
- ); - }, [isAddCalDavIntegrationDialogOpen, addCalDavError]); - - const ConnectAppleServerDialog = useCallback(() => { - return ( - setIsAddAppleIntegrationDialogOpen(isOpen)}> - - - Generate an app specific password to use with Cal.com at{" "} - - https://appleid.apple.com/account/manage - - . Your credentials will be stored and encrypted. -

- } - /> -
- {addAppleError && ( -

- Error: - {addAppleError.message} -

- )} - -
-
- - { - setIsAddAppleIntegrationDialogOpen(false); - }} - asChild> - - -
-
-
- ); - }, [isAddAppleIntegrationDialogOpen, addAppleError]); - - if (loading) { - return ; - } +export default function IntegrationsPage() { + const query = trpc.useQuery(["viewer.integrations"]); return ( -
- }> -
- {integrations.filter((ig) => ig.credential).length !== 0 ? ( - - ) : ( -
-
-
- -
-
-

- You don't have any apps connected. -

-
-

- You currently do not have any apps connected. Connect your first app to get started. -

-
- -
-
-
- )} -
-
-
-

Select calendars

-
-

Select which calendars are checked for availability to prevent double bookings.

-
- -
-
-
-
-

Launch your own App

-
-

If you want to add your own App here, get in touch with us.

-
- -
-
- - -
-
+ + + + } + /> + + {data.payment.items.map((item) => ( + } + /> + ))} + + + + } + subtitle={ + <> + Configure how your links integrate with your calendars. +
+ You can override these settings on a per event basis. + + } + /> + + {data.connectedCalendars.length > 0 && ( + <> + + {data.connectedCalendars.map((item, index) => ( +
  • + {item.calendars ? ( + ( + + )} + /> + }> +
      + {item.calendars.map((cal) => ( + + ))} +
    +
    + ) : ( + ( + + )} + /> + } + /> + )} +
  • + ))} + +

    + Connect an additional calendar +

    +
    + + )} + + {data.calendar.items.map((item) => ( + } + /> + } + /> + ))} + + + ); + }} + /> + ); } - -export async function getServerSideProps(context: GetServerSidePropsContext) { - const session = await getSession(context); - if (!session?.user?.email) { - return { redirect: { permanent: false, destination: "/auth/login" } }; - } - const user = await prisma.user.findFirst({ - where: { - email: session.user.email, - }, - select: { - id: true, - credentials: { - select: { - id: true, - type: true, - key: true, - }, - }, - completedOnboarding: true, - createdDate: true, - }, - }); - - if (!user) - return { - redirect: { permanent: false, destination: "/auth/login" }, - }; - - if ( - shouldShowOnboarding({ completedOnboarding: user.completedOnboarding, createdDate: user.createdDate }) - ) { - return ONBOARDING_NEXT_REDIRECT; - } - - const integrations = getIntegrations(user.credentials); - - return { - props: { session, integrations }, - }; -} diff --git a/pages/sandbox/Alert.tsx b/pages/sandbox/Alert.tsx index 43b28450e0..0e920db7aa 100644 --- a/pages/sandbox/Alert.tsx +++ b/pages/sandbox/Alert.tsx @@ -1,9 +1,10 @@ -import Head from "next/head"; import React from "react"; import { Alert, AlertProps } from "@components/ui/Alert"; -export default function AlertPage() { +import { sandboxPage } from "."; + +const page = sandboxPage(function AlertPage() { const list: AlertProps[] = [ { title: "Something went wrong", severity: "error" }, { title: "Something went kinda wrong", severity: "warning" }, @@ -23,9 +24,6 @@ export default function AlertPage() { ]; return ( <> - - -

    Alert component

    @@ -52,4 +50,7 @@ export default function AlertPage() {
    ); -} +}); + +export default page.default; +export const getStaticProps = page.getStaticProps; diff --git a/pages/sandbox/Badge.tsx b/pages/sandbox/Badge.tsx new file mode 100644 index 0000000000..f61027ec14 --- /dev/null +++ b/pages/sandbox/Badge.tsx @@ -0,0 +1,43 @@ +import { Badge, BadgeProps } from "@components/ui/Badge"; + +import { sandboxPage } from "."; + +const page = sandboxPage(function BadgePage() { + const list: BadgeProps[] = [ + // + { variant: "success" }, + { variant: "gray" }, + { variant: "success" }, + ]; + return ( + <> +
    +

    Badge component

    +
    + {list.map((props, index) => ( +
    +

    + + {JSON.stringify( + props, + (key, value) => { + if (key.includes("Icon")) { + return ".."; + } + return value; + }, + 2 + )} + +

    + Badge text +
    + ))} +
    +
    + + ); +}); + +export default page.default; +export const getStaticProps = page.getStaticProps; diff --git a/pages/sandbox/Button.tsx b/pages/sandbox/Button.tsx index be7d205174..d7c00bcc45 100644 --- a/pages/sandbox/Button.tsx +++ b/pages/sandbox/Button.tsx @@ -1,11 +1,12 @@ import { PlusIcon } from "@heroicons/react/solid"; -import Head from "next/head"; import React from "react"; -import { Button, ButtonProps } from "@components/ui/Button"; +import { Button, ButtonBaseProps } from "@components/ui/Button"; -export default function ButtonPage() { - const list: ButtonProps[] = [ +import { sandboxPage } from "."; + +const page = sandboxPage(function ButtonPage() { + const list: ButtonBaseProps[] = [ // primary { color: "primary" }, { color: "primary", disabled: true }, @@ -26,18 +27,15 @@ export default function ButtonPage() { { color: "primary", size: "base" }, { color: "primary", size: "lg" }, - // href - { href: "/staging" }, - { href: "/staging", disabled: true }, + // // href + // { href: "/staging" }, + // { href: "/staging", disabled: true }, { StartIcon: PlusIcon }, { EndIcon: PlusIcon }, ]; return ( <> - - -

    Button component

    @@ -57,11 +55,14 @@ export default function ButtonPage() { )} - +
    ))}
    ); -} +}); + +export default page.default; +export const getStaticProps = page.getStaticProps; diff --git a/pages/sandbox/List.tsx b/pages/sandbox/List.tsx new file mode 100644 index 0000000000..38b7b35995 --- /dev/null +++ b/pages/sandbox/List.tsx @@ -0,0 +1,42 @@ +import React, { useState } from "react"; + +import { List, ListItem } from "@components/List"; +import Button from "@components/ui/Button"; + +import { sandboxPage } from "."; + +const page = sandboxPage(() => { + const [expanded, setExpanded] = useState(false); + return ( +
    + Unstyled -{" "} + + + + An item + + + An item + + + An item + + + An item + + + One expanded + + An item + Spaced + An item + An item + +
    + ); +}); + +export default page.default; +export const getStaticProps = page.getStaticProps; diff --git a/pages/sandbox/form.tsx b/pages/sandbox/form.tsx new file mode 100644 index 0000000000..ee4444c82c --- /dev/null +++ b/pages/sandbox/form.tsx @@ -0,0 +1,22 @@ +import React from "react"; + +import { Label, Input, TextField } from "@components/form/fields"; + +import { sandboxPage } from "."; + +const page = sandboxPage(() => ( +
    +
    + +
    +
    + +
    +
    + +
    +
    +)); + +export default page.default; +export const getStaticProps = page.getStaticProps; diff --git a/pages/sandbox/index.tsx b/pages/sandbox/index.tsx new file mode 100644 index 0000000000..389d8b7103 --- /dev/null +++ b/pages/sandbox/index.tsx @@ -0,0 +1,73 @@ +import fs from "fs"; +import { NextPage } from "next"; +import Head from "next/head"; +import Link from "next/link"; +import path from "path"; + +import { inferSSRProps } from "@lib/types/inferSSRProps"; + +async function _getStaticProps() { + const dir = path.join(process.cwd(), "pages", "sandbox"); + + const pages = fs + .readdirSync(dir) + .filter((file) => !file.startsWith(".")) + .map((file) => { + const parts = file.split("."); + // remove extension + parts.pop(); + return parts.join("."); + }); + return { + props: { + pages, + }, + }; +} +type PageProps = inferSSRProps; + +const SandboxPage: NextPage = (props) => { + return ( + <> + + + + +
    + {props.children} +
    + + ); +}; +export function sandboxPage(Component: NextPage) { + const Wrapper: NextPage = (props) => { + return ( + <> + + + + + ); + }; + return { + default: Wrapper, + getStaticProps: _getStaticProps, + }; +} + +const page = sandboxPage(() => { + return

    Click a component above

    ; +}); + +export default page.default; +export const getStaticProps = page.getStaticProps; diff --git a/scripts/seed.ts b/scripts/seed.ts index 58d6ffe3db..dc166327dc 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -7,7 +7,14 @@ import { hashPassword } from "../lib/auth"; const prisma = new PrismaClient(); async function createUserAndEventType(opts: { - user: { email: string; password: string; username: string; plan: UserPlan; name: string }; + user: { + email: string; + password: string; + username: string; + plan: UserPlan; + name: string; + completedOnboarding?: boolean; + }; eventTypes: Array< Prisma.EventTypeCreateInput & { _bookings?: Prisma.BookingCreateInput[]; @@ -18,7 +25,7 @@ async function createUserAndEventType(opts: { ...opts.user, password: await hashPassword(opts.user.password), emailVerified: new Date(), - completedOnboarding: true, + completedOnboarding: opts.user.completedOnboarding ?? true, }; const user = await prisma.user.upsert({ where: { email: opts.user.email }, @@ -97,24 +104,14 @@ async function createUserAndEventType(opts: { async function main() { await createUserAndEventType({ user: { - email: "free@example.com", - password: "free", - username: "free", - name: "Free Example", - plan: "FREE", + email: "onboarding@example.com", + password: "onboarding", + username: "onboarding", + name: "onboarding", + plan: "TRIAL", + completedOnboarding: false, }, - eventTypes: [ - { - title: "30min", - slug: "30min", - length: 30, - }, - { - title: "60min", - slug: "60min", - length: 30, - }, - ], + eventTypes: [], }); await createUserAndEventType({ @@ -199,6 +196,28 @@ async function main() { ], }); + await createUserAndEventType({ + user: { + email: "free@example.com", + password: "free", + username: "free", + name: "Free Example", + plan: "FREE", + }, + eventTypes: [ + { + title: "30min", + slug: "30min", + length: 30, + }, + { + title: "60min", + slug: "60min", + length: 30, + }, + ], + }); + await prisma.$disconnect(); } diff --git a/server/createContext.ts b/server/createContext.ts index 80e352706e..004119ca9e 100644 --- a/server/createContext.ts +++ b/server/createContext.ts @@ -32,6 +32,20 @@ async function getUserFromSession({ session, req }: { session: Maybe; r createdDate: true, hideBranding: true, avatar: true, + credentials: { + select: { + id: true, + type: true, + key: true, + }, + }, + selectedCalendars: { + select: { + externalId: true, + integration: true, + }, + }, + completedOnboarding: true, locale: true, }, }); diff --git a/server/routers/viewer.tsx b/server/routers/viewer.tsx index bf9eb49db5..515dc74e09 100644 --- a/server/routers/viewer.tsx +++ b/server/routers/viewer.tsx @@ -1,12 +1,15 @@ -import { Prisma, BookingStatus } from "@prisma/client"; +import { BookingStatus, Prisma } from "@prisma/client"; import { TRPCError } from "@trpc/server"; +import { getErrorFromUnknown } from "pages/_error"; import { z } from "zod"; import { checkPremiumUsername } from "@ee/lib/core/checkPremiumUsername"; import { checkRegularUsername } from "@lib/core/checkRegularUsername"; +import getIntegrations, { ALL_INTEGRATIONS } from "@lib/integrations/getIntegrations"; import slugify from "@lib/slugify"; +import { getCalendarAdapterOrNull } from "../../lib/calendarClient"; import { createProtectedRouter } from "../createRouter"; import { resizeBase64Image } from "../lib/resizeBase64Image"; @@ -91,6 +94,88 @@ export const viewerRouter = createProtectedRouter() return bookings; }, }) + .query("integrations", { + async resolve({ ctx }) { + const { user } = ctx; + const { credentials } = user; + const integrations = getIntegrations(credentials); + + function countActive(items: { credentials: unknown[] }[]) { + return items.reduce((acc, item) => acc + item.credentials.length, 0); + } + const conferencing = integrations.flatMap((item) => (item.variant === "conferencing" ? [item] : [])); + const payment = integrations.flatMap((item) => (item.variant === "payment" ? [item] : [])); + const calendar = integrations.flatMap((item) => (item.variant === "calendar" ? [item] : [])); + + // get user's credentials + their connected integrations + const calendarCredentials = user.credentials + .filter((credential) => credential.type.endsWith("_calendar")) + .flatMap((credential) => { + const integration = ALL_INTEGRATIONS.find((integration) => integration.type === credential.type); + + const adapter = getCalendarAdapterOrNull({ + ...credential, + userId: user.id, + }); + return integration && adapter && integration.variant === "calendar" + ? [{ integration, credential, adapter }] + : []; + }); + + // get all the connected integrations' calendars (from third party) + const connectedCalendars = await Promise.all( + calendarCredentials.map(async (item) => { + const { adapter, integration, credential } = item; + try { + const _calendars = await adapter.listCalendars(); + const calendars = _calendars.map((cal) => ({ + ...cal, + isSelected: !!user.selectedCalendars.find((selected) => selected.externalId === cal.externalId), + })); + const primary = calendars.find((item) => item.primary) ?? calendars[0]; + if (!primary) { + return { + integration, + credentialId: credential.id, + error: { + message: "No primary calendar found", + }, + }; + } + return { + integration, + credentialId: credential.id, + primary, + calendars, + }; + } catch (_error) { + const error = getErrorFromUnknown(_error); + return { + integration, + error: { + message: error.message, + }, + }; + } + }) + ); + return { + conferencing: { + items: conferencing, + numActive: countActive(conferencing), + }, + calendar: { + items: calendar, + numActive: countActive(calendar), + }, + payment: { + items: payment, + numActive: countActive(payment), + }, + connectedCalendars, + }; + }, + }) .mutation("updateProfile", { input: z.object({ username: z.string().optional(),