Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Overlay Calendar v2 and Troubleshooter v2 #14693

Open
wants to merge 34 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
40b5ad6
feat: add signin with google for overlay calendar user
kart1ka Apr 21, 2024
161f36d
feat: get event title for Overlay Calendar from Google Calendar service
kart1ka Apr 21, 2024
c368c66
feat: Display Event Title for overlay calendar for Google Calendar Se…
kart1ka Apr 21, 2024
6e6854e
feat: Display Event Titles for Troubleshooter
kart1ka Apr 21, 2024
07120a6
fix
kart1ka Apr 22, 2024
bdb712d
Merge branch 'main' into feat/overlay-v2
sean-brydon Apr 22, 2024
473d209
test: Update getCalendarsEvents.test.ts
kart1ka Apr 24, 2024
434e8e4
Merge branch 'main' into feat/overlay-v2
kart1ka Apr 24, 2024
74aa500
Merge branch 'main' into feat/overlay-v2
kart1ka Apr 30, 2024
620ab34
feat: add Microsoft OAuth Provider for Overlay User Sign Up
kart1ka May 1, 2024
d8e3284
feat: fetch event title from office365 calendar
kart1ka May 1, 2024
f0191b2
Merge branch 'main' into feat/overlay-v2
kart1ka May 1, 2024
79df2d0
test
kart1ka May 1, 2024
ad35cfd
Merge branch 'main' into feat/overlay-v2
joeauyeung May 1, 2024
a99c9e9
Merge branch 'main' into feat/overlay-v2
kart1ka May 2, 2024
65bcf69
Merge branch 'main' into feat/overlay-v2
kart1ka May 5, 2024
a361898
add: create public getEventList method on CalendarService class
kart1ka May 5, 2024
096b7d9
refactor: create public getEventList method on google and office365 c…
kart1ka May 5, 2024
256ff31
test
kart1ka May 5, 2024
e38562c
Merge remote-tracking branch 'origin/main' into feat/overlay-v2
kart1ka May 6, 2024
78df9d4
Merge branch 'main' into feat/overlay-v2
kart1ka May 6, 2024
32da2cd
Merge branch 'main' into feat/overlay-v2
joeauyeung May 6, 2024
1851f11
Merge branch 'main' into feat/overlay-v2
Udit-takkar May 7, 2024
405d1cf
Merge branch 'main' into feat/overlay-v2
kart1ka May 8, 2024
53a9951
Merge branch 'main' into feat/overlay-v2
CarinaWolli May 10, 2024
8961569
Merge branch 'main' into feat/overlay-v2
joeauyeung May 10, 2024
ee61117
revert: microsoft identity provider
kart1ka May 12, 2024
5de7e07
fix
kart1ka May 12, 2024
9f92964
Merge branch 'main' into feat/overlay-v2
kart1ka May 12, 2024
ed69f85
deleting microsoft migration files
kart1ka May 12, 2024
bd69a73
Merge branch 'main' into feat/overlay-v2
kart1ka May 15, 2024
63d57e6
Merge branch 'main' into feat/overlay-v2
kart1ka May 21, 2024
28893c4
Merge branch 'main' into feat/overlay-v2
kart1ka May 23, 2024
6140e5a
Merge branch 'main' into feat/overlay-v2
kart1ka May 25, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -31,7 +31,9 @@ const AppConnectionItem = (props: IAppConnectionItem) => {
loading={buttonProps?.isPending}
onClick={(event) => {
// Save cookie key to return url step
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
document.cookie = `return-to=${encodeURIComponent(
window.location.href
)};path=/;max-age=3600;SameSite=Lax`;
buttonProps && buttonProps.onClick && buttonProps?.onClick(event);
}}>
{installed ? t("installed") : t("connect")}
Expand Down
@@ -1,3 +1,5 @@
import { useSearchParams } from "next/navigation";

import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
Expand All @@ -14,12 +16,23 @@ interface IConnectCalendarsProps {

const ConnectedCalendars = (props: IConnectCalendarsProps) => {
const { nextStep } = props;
const searchParams = useSearchParams();
const callbackBookerUrl = searchParams?.get("callbackUrl");
const overlayProvider = searchParams?.get("overlayProvider");
const queryConnectedCalendars = trpc.viewer.connectedCalendars.useQuery({ onboarding: true });
const { t } = useLocale();
const queryIntegrations = trpc.viewer.integrations.useQuery({
variant: "calendar",
onlyInstalled: false,
sortByMostPopular: true,
appId:
callbackBookerUrl && overlayProvider
? overlayProvider === "google"
? "google-calendar"
: overlayProvider === "azure-ad"
? "office365-calendar"
: undefined
: undefined,
});

const firstCalendar = queryConnectedCalendars.data?.connectedCalendars.find(
Expand Down
24 changes: 23 additions & 1 deletion apps/web/lib/getting-started/[[...step]]/getServerSideProps.tsx
Expand Up @@ -8,7 +8,7 @@ import prisma from "@calcom/prisma";
import { ssrInit } from "@server/lib/ssr";

export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { req } = context;
const { req, query } = context;

const session = await getServerSession({ req });

Expand Down Expand Up @@ -38,13 +38,35 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
},
},
},
credentials: {
select: {
appId: true,
id: true,
},
},
},
});

if (!user) {
throw new Error("User from session not found");
}

if (
query?.step &&
query.step[0] === "connected-calendar" &&
!!(query.callbackUrl && query.overlayProvider)
) {
if (
user.completedOnboarding ||
(user.credentials.length > 0 &&
user.credentials.find(
(credential) => credential.appId === "google-calendar" || credential.appId === "office365-calendar"
))
) {
return { redirect: { permanent: false, destination: query.callbackUrl.toString() } };
}
}

if (user.completedOnboarding) {
return { redirect: { permanent: false, destination: "/event-types" } };
}
Expand Down
22 changes: 17 additions & 5 deletions apps/web/pages/getting-started/[[...step]].tsx
@@ -1,7 +1,7 @@
"use client";

import Head from "next/head";
import { usePathname, useRouter } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { z } from "zod";

Expand Down Expand Up @@ -48,7 +48,7 @@ const stepRouteSchema = z.object({
const OnboardingPage = () => {
const pathname = usePathname();
const params = useParamsWithFallback();

const searchParams = useSearchParams();
const router = useRouter();
const [user] = trpc.viewer.me.useSuspenseQuery();
const { t } = useLocale();
Expand All @@ -60,6 +60,8 @@ const OnboardingPage = () => {

const currentStep = result.success ? result.data.step[0] : INITIAL_STEP;
const from = result.success ? result.data.from : "";
const callbackBookerUrl = searchParams?.get("callbackUrl");
const isOverlayUser = currentStep === "connected-calendar" && !!callbackBookerUrl;
const headers = [
{
title: `${t("welcome_to_cal_header", { appName: APP_NAME })}`,
Expand Down Expand Up @@ -136,14 +138,24 @@ const OnboardingPage = () => {
</p>
))}
</header>
<Steps maxSteps={steps.length} currentStep={currentStepIndex + 1} navigateToStep={goToIndex} />
{!isOverlayUser && (
<Steps
maxSteps={steps.length}
currentStep={currentStepIndex + 1}
navigateToStep={goToIndex}
/>
)}
</div>
<StepCard>
<Suspense fallback={<Icon name="loader" />}>
{currentStep === "user-settings" && (
<UserSettings nextStep={() => goToIndex(1)} hideUsername={from === "signup"} />
)}
{currentStep === "connected-calendar" && <ConnectedCalendars nextStep={() => goToIndex(2)} />}
{currentStep === "connected-calendar" && (
<ConnectedCalendars
nextStep={isOverlayUser ? () => router.push(callbackBookerUrl) : () => goToIndex(2)}
/>
)}

{currentStep === "connected-video" && <ConnectedVideoStep nextStep={() => goToIndex(3)} />}

Expand All @@ -157,7 +169,7 @@ const OnboardingPage = () => {
</Suspense>
</StepCard>

{headers[currentStepIndex]?.skipText && (
{!isOverlayUser && headers[currentStepIndex]?.skipText && (
<div className="flex w-full flex-row justify-center">
<Button
color="minimal"
Expand Down
1 change: 1 addition & 0 deletions apps/web/public/microsoft-icon.svg
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The icon provided here is for demonstrative purposes only. Feel free to replace it with a more fitting one. Thanks!"

Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
188 changes: 134 additions & 54 deletions packages/app-store/googlecalendar/lib/CalendarService.ts
Expand Up @@ -23,6 +23,7 @@ import type {
Calendar,
CalendarEvent,
EventBusyDate,
EventBusyData,
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
Expand Down Expand Up @@ -514,6 +515,128 @@ export default class GoogleCalendarService implements Calendar {
}
}

async getCalIds(selectedCalendars: IntegrationCalendar[]): Promise<string[]> {
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === this.integrationName)
.map((e) => e.externalId);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
return [];
}
if (selectedCalendarIds.length !== 0) return selectedCalendarIds;
const calendar = await this.authedCalendar();
const cals = await calendar.calendarList.list({ fields: "items(id)" });
if (!cals.data.items) return [];
return cals.data.items.reduce((c, cal) => (cal.id ? [...c, cal.id] : c), [] as string[]);
}

// fetches free-busy/events data with date range check
async fetchCalendarDataWithDateRangeCheck(
dateFrom: string,
dateTo: string,
calsIds: string[],
getEventsOrFreeBusyData: (args: {
timeMin: string;
timeMax: string;
items: { id: string }[];
}) => Promise<EventBusyDate[] | null> | Promise<EventBusyData[]>
) {
const originalStartDate = dayjs(dateFrom);
const originalEndDate = dayjs(dateTo);
const diff = originalEndDate.diff(originalStartDate, "days");
if (diff <= 90) {
const data = await getEventsOrFreeBusyData({
timeMin: dateFrom,
timeMax: dateTo,
items: calsIds.map((id) => ({ id })),
});

if (!data) throw new Error("No response from google calendar");

return data;
} else {
const busyData = [];

const loopsNumber = Math.ceil(diff / 90);

let startDate = originalStartDate;
let endDate = originalStartDate.add(90, "days");

for (let i = 0; i < loopsNumber; i++) {
if (endDate.isAfter(originalEndDate)) endDate = originalEndDate;

busyData.push(
...((await getEventsOrFreeBusyData({
timeMin: startDate.format(),
timeMax: endDate.format(),
items: calsIds.map((id) => ({ id })),
})) || [])
);

startDate = endDate.add(1, "minutes");
endDate = startDate.add(90, "days");
}
return busyData;
}
}

async getEventList(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyData[]> {
try {
const calsIds = await this.getCalIds(selectedCalendars);
if (calsIds.length === 0) return [];

const fetchEventData = async (args: {
timeMin: string;
timeMax: string;
items: { id: string }[];
}): Promise<EventBusyData[]> => {
const calendar = await this.authedCalendar();
const { timeMin, timeMax, items } = args;
const events = await Promise.all(
items.map(async (item) => {
const { json: eventData } = await this.oAuthManagerInstance.request(
async () =>
new AxiosLikeResponseToFetchResponse(
await calendar.events.list({
calendarId: item.id,
timeMin: timeMin,
timeMax: timeMax,
fields: "items(summary,start/dateTime, end/dateTime)",
})
)
);

if (!eventData.items || eventData.items?.length === 0) return [];

return eventData.items.map((event) => {
const busyData: EventBusyData = {
start: event.start?.dateTime || "",
end: event.end?.dateTime || "",
title: event.summary || "",
};
return busyData;
});
})
);
if (events.length === 0) return [];
return events.flat();
};

const data = await this.fetchCalendarDataWithDateRangeCheck(dateFrom, dateTo, calsIds, fetchEventData);
return data;
} catch (error) {
this.log.error(
"There was an error getting availability from google calendar: ",
safeStringify({ error, selectedCalendars })
);
throw error;
}
}

async getCacheOrFetchAvailability(args: {
timeMin: string;
timeMax: string;
Expand Down Expand Up @@ -599,62 +722,19 @@ export default class GoogleCalendarService implements Calendar {
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]> {
this.log.debug("Getting availability", safeStringify({ dateFrom, dateTo, selectedCalendars }));
const calendar = await this.authedCalendar();
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === this.integrationName)
.map((e) => e.externalId);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
return [];
}
async function getCalIds() {
if (selectedCalendarIds.length !== 0) return selectedCalendarIds;
const cals = await calendar.calendarList.list({ fields: "items(id)" });
if (!cals.data.items) return [];
return cals.data.items.reduce((c, cal) => (cal.id ? [...c, cal.id] : c), [] as string[]);
}
this.log.debug("Getting availability");

try {
const calsIds = await getCalIds();
const originalStartDate = dayjs(dateFrom);
const originalEndDate = dayjs(dateTo);
const diff = originalEndDate.diff(originalStartDate, "days");

// /freebusy from google api only allows a date range of 90 days
if (diff <= 90) {
const freeBusyData = await this.getCacheOrFetchAvailability({
timeMin: dateFrom,
timeMax: dateTo,
items: calsIds.map((id) => ({ id })),
});
if (!freeBusyData) throw new Error("No response from google calendar");

return freeBusyData;
} else {
const busyData = [];

const loopsNumber = Math.ceil(diff / 90);

let startDate = originalStartDate;
let endDate = originalStartDate.add(90, "days");

for (let i = 0; i < loopsNumber; i++) {
if (endDate.isAfter(originalEndDate)) endDate = originalEndDate;

busyData.push(
...((await this.getCacheOrFetchAvailability({
timeMin: startDate.format(),
timeMax: endDate.format(),
items: calsIds.map((id) => ({ id })),
})) || [])
);

startDate = endDate.add(1, "minutes");
endDate = startDate.add(90, "days");
}
return busyData;
}
const calsIds = await this.getCalIds(selectedCalendars);
if (calsIds.length === 0) return [];

const data = await this.fetchCalendarDataWithDateRangeCheck(
dateFrom,
dateTo,
calsIds,
this.getCacheOrFetchAvailability.bind(this)
);
return data;
} catch (error) {
this.log.error(
"There was an error getting availability from google calendar: ",
Expand Down