feat: dry run behaviour for connect atoms (#20699)

* add dry run prop for mocking actual behaviour without actual api calls

* fix e2e tests

* fixup

* remove await present twice

* fix: adjust success toast for apple connect dry run

Co-Authored-By: rajiv@cal.com <rajiv@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: rajiv@cal.com <rajiv@cal.com>
This commit is contained in:
Rajiv Sahal
2025-04-15 17:45:14 +00:00
committed by GitHub
co-authored by rajiv@cal.com <rajiv@cal.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> rajiv@cal.com <rajiv@cal.com>
parent b1f50fe22d
commit d66f983f56
9 changed files with 88 additions and 28 deletions
@@ -129,7 +129,7 @@ describe("Platform Calendars Endpoints", () => {
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar OAuth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`)
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect?redir=https://cal.com&isDryRun=false`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
@@ -34,6 +34,7 @@ import {
BadRequestException,
Post,
Body,
ParseBoolPipe,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiParam, ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger";
import { User } from "@prisma/client";
@@ -148,13 +149,14 @@ export class CalendarsController {
@Req() req: Request,
@Headers("Authorization") authorization: string,
@Param("calendar") calendar: string,
@Query("redir") redir?: string | null
@Query("redir") redir?: string | null,
@Query("isDryRun", ParseBoolPipe) isDryRun?: boolean
): Promise<ApiResponse<{ authUrl: string }>> {
switch (calendar) {
case OFFICE_365_CALENDAR:
return await this.outlookService.connect(authorization, req, redir ?? "");
return await this.outlookService.connect(authorization, req, redir ?? "", isDryRun);
case GOOGLE_CALENDAR:
return await this.googleCalendarService.connect(authorization, req, redir ?? "");
return await this.googleCalendarService.connect(authorization, req, redir ?? "", isDryRun);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
@@ -179,18 +181,30 @@ export class CalendarsController {
): Promise<{ url: string }> {
// state params contains our user access token
const stateParams = new URLSearchParams(state);
const { accessToken, origin, redir } = z
.object({ accessToken: z.string(), origin: z.string(), redir: z.string().nullish().optional() })
const { accessToken, origin, redir, isDryRun } = z
.object({
accessToken: z.string(),
origin: z.string(),
redir: z.string().nullish().optional(),
isDryRun: z.string().nullish().optional(),
})
.parse({
accessToken: stateParams.get("accessToken"),
origin: stateParams.get("origin"),
redir: stateParams.get("redir"),
isDryRun: stateParams.get("isDryRun"),
});
switch (calendar) {
case OFFICE_365_CALENDAR:
return await this.outlookService.save(code, accessToken, origin, redir ?? "");
return await this.outlookService.save(code, accessToken, origin, redir ?? "", isDryRun === "true");
case GOOGLE_CALENDAR:
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "");
return await this.googleCalendarService.save(
code,
accessToken,
origin,
redir ?? "",
isDryRun === "true"
);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
@@ -39,31 +39,38 @@ export class GoogleCalendarService implements OAuthCalendarApp {
async connect(
authorization: string,
req: Request,
redir?: string
redir?: string,
isDryRun?: boolean
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
const accessToken = authorization.replace("Bearer ", "");
const origin = req.get("origin") ?? req.get("host");
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir, isDryRun);
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
}
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
async save(
code: string,
accessToken: string,
origin: string,
redir?: string,
isDryRun?: boolean
): Promise<{ url: string }> {
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir, isDryRun);
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
return await this.checkIfCalendarConnected(userId);
}
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: CALENDAR_SCOPES,
prompt: "consent",
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
});
return authUrl;
@@ -115,7 +122,8 @@ export class GoogleCalendarService implements OAuthCalendarApp {
code: string,
accessToken: string,
origin: string,
redir?: string
redir?: string,
isDryRun?: boolean
) {
// User chose not to authorize your app or didn't authorize your app
// redirect directly without oauth code
@@ -123,6 +131,11 @@ export class GoogleCalendarService implements OAuthCalendarApp {
return { url: redir || origin };
}
// if isDryRun is true we know its a dry run so we just redirect straight away
if (isDryRun) {
return { url: redir || origin };
}
const parsedCode = z.string().parse(code);
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
@@ -33,24 +33,31 @@ export class OutlookService implements OAuthCalendarApp {
async connect(
authorization: string,
req: Request,
redir?: string
redir?: string,
isDryRun?: boolean
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
const accessToken = authorization.replace("Bearer ", "");
const origin = req.get("origin") ?? req.get("host");
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir, isDryRun);
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
}
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
async save(
code: string,
accessToken: string,
origin: string,
redir?: string,
isDryRun?: boolean
): Promise<{ url: string }> {
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir, isDryRun);
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
return await this.checkIfCalendarConnected(userId);
}
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
@@ -60,7 +67,7 @@ export class OutlookService implements OAuthCalendarApp {
client_id,
prompt: "select_account",
redirect_uri: this.redirectUri,
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
};
const query = stringify(params);
@@ -148,13 +155,19 @@ export class OutlookService implements OAuthCalendarApp {
code: string,
accessToken: string,
origin: string,
redir?: string
redir?: string,
isDryRun?: boolean
) {
// if code is not defined, user denied to authorize office 365 app, just redirect straight away
if (!code || code === "undefined") {
return { url: redir || origin };
}
// if isDryRun is true we know its a dry run so we just redirect straight away
if (isDryRun) {
return { url: redir || origin };
}
const parsedCode = z.string().parse(code);
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
@@ -26,6 +26,7 @@ export type OAuthConnectProps = {
tooltipSide?: "top" | "bottom" | "left" | "right";
isClickable?: boolean;
onSuccess?: () => void;
isDryRun?: boolean;
};
export const OAuthConnect: FC<
@@ -46,8 +47,9 @@ export const OAuthConnect: FC<
tooltipSide = "bottom",
isClickable,
onSuccess,
isDryRun,
}) => {
const { connect } = useConnect(calendar, redir);
const { connect } = useConnect(calendar, redir, isDryRun);
const { allowConnect, checked } = useCheck({
onCheckError,
calendar: calendar,
@@ -38,6 +38,7 @@ export const AppleConnect: FC<Partial<Omit<OAuthConnectProps, "redir">>> = ({
tooltipSide = "bottom",
isClickable,
onSuccess,
isDryRun = false,
}) => {
const { t } = useLocale();
const form = useForm({
@@ -134,7 +135,16 @@ export const AppleConnect: FC<Partial<Omit<OAuthConnectProps, "redir">>> = ({
handleSubmit={async (values) => {
const { username, password } = values;
await saveCredentials({ calendar: "apple", username, password });
if (isDryRun) {
form.reset();
setIsDialogOpen(false);
toast({
description: "Calendar credentials added successfully",
});
onSuccess?.();
} else {
await saveCredentials({ calendar: "apple", username, password });
}
}}>
<fieldset
className="space-y-4"
@@ -20,6 +20,7 @@ export const GcalConnect: FC<Partial<OAuthConnectProps>> = ({
tooltipSide,
tooltip,
isClickable,
isDryRun,
}) => {
const { t } = useLocale();
return (
@@ -36,6 +37,7 @@ export const GcalConnect: FC<Partial<OAuthConnectProps>> = ({
tooltipSide={tooltipSide}
tooltip={tooltip}
isClickable={isClickable}
isDryRun={isDryRun}
/>
);
};
@@ -20,6 +20,7 @@ export const OutlookConnect: FC<Partial<OAuthConnectProps>> = ({
tooltipSide,
tooltip,
isClickable,
isDryRun,
}) => {
const { t } = useLocale();
return (
@@ -36,6 +37,7 @@ export const OutlookConnect: FC<Partial<OAuthConnectProps>> = ({
tooltipSide={tooltipSide}
tooltip={tooltip}
isClickable={isClickable}
isDryRun={isDryRun}
/>
);
};
@@ -13,7 +13,11 @@ interface IPUpdateOAuthCredentials {
onError?: (err: ApiErrorResponse) => void;
}
export const useGetRedirectUrl = (calendar: (typeof CALENDARS)[number], redir?: string) => {
export const useGetRedirectUrl = (
calendar: (typeof CALENDARS)[number],
redir?: string,
isDryRun?: boolean
) => {
const authUrl = useQuery({
queryKey: getQueryKey(calendar),
staleTime: Infinity,
@@ -21,7 +25,7 @@ export const useGetRedirectUrl = (calendar: (typeof CALENDARS)[number], redir?:
queryFn: () => {
return http
?.get<ApiResponse<{ authUrl: string }>>(
`/calendars/${calendar}/connect${redir ? `?redir=${redir}` : ""}`
`/calendars/${calendar}/connect?redir=${redir ?? ""}&isDryRun=${isDryRun ?? ""}`
)
.then(({ data: responseBody }) => {
if (responseBody.status === SUCCESS_STATUS) {
@@ -36,8 +40,8 @@ export const useGetRedirectUrl = (calendar: (typeof CALENDARS)[number], redir?:
return authUrl;
};
export const useConnect = (calendar: (typeof CALENDARS)[number], redir?: string) => {
const { refetch } = useGetRedirectUrl(calendar, redir);
export const useConnect = (calendar: (typeof CALENDARS)[number], redir?: string, isDryRun?: boolean) => {
const { refetch } = useGetRedirectUrl(calendar, redir, isDryRun);
const connect = async () => {
const redirectUri = await refetch();