Skip to content

Commit

Permalink
feat: apple calendar connect atom (#15510)
Browse files Browse the repository at this point in the history
* init apple calendar service

* update apple calendar service logic

* update typing for CalendarApp interface

* add id and type for apple calendar

* fixup: logic for apple calendar service

* update logic for checkIfCalendarConnected function

* add apple calendar service to calendars module

* fixup

* update calendars to include cases for apple calendar

* fix imports

* init frontend for apple calendar connect

* add apple connect atom to connect atom

* fixup

* fixup

* add alias for latest version of platform library

* fix import paths

* custom hook for saving apple calendar credentials

* add apple calendar to examples app

* add interface for credentials syncing calendars

* bring calendars controller to orginal state

* refactor

* update custom hook to sync credentials

* update atom

* resolve merge conflicts

* add endpoint for syncing calendar credentials

* fixup

* update atom

* update useCheck to invalidate queries onSubmit

* fix typo

* fixup

* remove unused tokens respository

* rename controller

* cleanup

* fix: reset react queryClient on access token change

---------

Co-authored-by: Rajiv Sahal <[email protected]>
Co-authored-by: Morgan <[email protected]>
Co-authored-by: Morgan Vernay <[email protected]>
  • Loading branch information
4 people authored Jun 27, 2024
1 parent d19172d commit 28eac06
Show file tree
Hide file tree
Showing 12 changed files with 353 additions and 11 deletions.
1 change: 1 addition & 0 deletions apps/api/v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@calcom/platform-libraries-0.0.13": "npm:@calcom/[email protected]",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/[email protected]",
"@calcom/platform-libraries-0.0.4": "npm:@calcom/[email protected]",
"@calcom/platform-libraries-0.0.14": "npm:@calcom/[email protected]",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
Expand Down
5 changes: 5 additions & 0 deletions apps/api/v2/src/ee/calendars/calendars.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ export interface CalendarApp {
check(userId: number): Promise<ApiResponse>;
}

export interface CredentialSyncCalendarApp {
save(userId: number, userEmail: string, username: string, password: string): Promise<{ status: string }>;
check(userId: number): Promise<ApiResponse>;
}

export interface OAuthCalendarApp extends CalendarApp {
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
}
2 changes: 2 additions & 0 deletions apps/api/v2/src/ee/calendars/calendars.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
Expand All @@ -17,6 +18,7 @@ import { Module } from "@nestjs/common";
CalendarsService,
OutlookService,
GoogleCalendarService,
AppleCalendarService,
SelectedCalendarsRepository,
AppsRepository,
],
Expand Down
37 changes: 35 additions & 2 deletions apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
Expand All @@ -21,13 +22,22 @@ import {
Headers,
Redirect,
BadRequestException,
Post,
Body,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { User } from "@prisma/client";
import { Request } from "express";
import { z } from "zod";

import { APPS_READ } from "@calcom/platform-constants";
import { SUCCESS_STATUS, CALENDARS, GOOGLE_CALENDAR, OFFICE_365_CALENDAR } from "@calcom/platform-constants";
import {
SUCCESS_STATUS,
CALENDARS,
GOOGLE_CALENDAR,
OFFICE_365_CALENDAR,
APPLE_CALENDAR,
} from "@calcom/platform-constants";
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";

@Controller({
Expand All @@ -39,7 +49,8 @@ export class CalendarsController {
constructor(
private readonly calendarsService: CalendarsService,
private readonly outlookService: OutlookService,
private readonly googleCalendarService: GoogleCalendarService
private readonly googleCalendarService: GoogleCalendarService,
private readonly appleCalendarService: AppleCalendarService
) {}

@UseGuards(ApiAuthGuard)
Expand Down Expand Up @@ -133,6 +144,26 @@ export class CalendarsController {
}
}

@UseGuards(ApiAuthGuard)
@Post("/:calendar/credentials")
async syncCredentials(
@GetUser() user: User,
@Param("calendar") calendar: string,
@Body() body: { username: string; password: string }
): Promise<{ status: string }> {
const { username, password } = body;

switch (calendar) {
case APPLE_CALENDAR:
return await this.appleCalendarService.save(user.id, user.email, username, password);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
CALENDARS.join(", ")
);
}
}

@Get("/:calendar/check")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard, PermissionsGuard)
Expand All @@ -143,6 +174,8 @@ export class CalendarsController {
return await this.outlookService.check(userId);
case GOOGLE_CALENDAR:
return await this.googleCalendarService.check(userId);
case APPLE_CALENDAR:
return await this.appleCalendarService.check(userId);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
Expand Down
92 changes: 92 additions & 0 deletions apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { CredentialSyncCalendarApp } from "@/ee/calendars/calendars.interface";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";

import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.14";

@Injectable()
export class AppleCalendarService implements CredentialSyncCalendarApp {
constructor(
private readonly calendarsService: CalendarsService,
private readonly credentialRepository: CredentialsRepository
) {}

async save(
userId: number,
userEmail: string,
username: string,
password: string
): Promise<{ status: string }> {
return await this.saveCalendarCredentials(userId, userEmail, username, password);
}

async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
return await this.checkIfCalendarConnected(userId);
}

async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const appleCalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
APPLE_CALENDAR_TYPE,
userId
);

if (!appleCalendarCredentials) {
throw new BadRequestException("Credentials for apple calendar not found.");
}

if (appleCalendarCredentials.invalid) {
throw new BadRequestException("Invalid apple calendar credentials.");
}

const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
const appleCalendar = connectedCalendars.find(
(cal: { integration: { type: string } }) => cal.integration.type === APPLE_CALENDAR_TYPE
);
if (!appleCalendar) {
throw new UnauthorizedException("Apple calendar not connected.");
}
if (appleCalendar.error?.message) {
throw new UnauthorizedException(appleCalendar.error?.message);
}

return {
status: SUCCESS_STATUS,
};
}

async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
if (username.length <= 1 || password.length <= 1)
throw new BadRequestException(`Username or password cannot be empty`);

const data = {
type: APPLE_CALENDAR_TYPE,
key: symmetricEncrypt(
JSON.stringify({ username, password }),
process.env.CALENDSO_ENCRYPTION_KEY || ""
),
userId: userId,
teamId: null,
appId: APPLE_CALENDAR_ID,
invalid: false,
};

try {
const dav = new CalendarService({
id: 0,
...data,
user: { email: userEmail },
});
await dav?.listCalendars();
await this.credentialRepository.createAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
} catch (reason) {
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
}

return {
status: SUCCESS_STATUS,
};
}
}
6 changes: 6 additions & 0 deletions packages/platform/atoms/cal-provider/CalProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export function CalProvider({
http.setVersionHeader(version);
}, [version]);

useEffect(() => {
if (accessToken) {
queryClient.resetQueries();
}
}, [accessToken]);

return (
<QueryClientProvider client={queryClient}>
<BaseCalProvider
Expand Down
141 changes: 141 additions & 0 deletions packages/platform/atoms/connect/apple/AppleConnect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogHeader,
DialogDescription,
} from "@/components/ui/dialog";
import type { FC } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";

import { Button, Form, PasswordField, TextField } from "@calcom/ui";

import { SUCCESS_STATUS } from "../../../constants/api";
import { useCheck } from "../../hooks/connect/useCheck";
import { useSaveCalendarCredentials } from "../../hooks/connect/useConnect";
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
import { useToast } from "../../src/components/ui/use-toast";
import { cn } from "../../src/lib/utils";
import type { OAuthConnectProps } from "../OAuthConnect";

export const AppleConnect: FC<Partial<Omit<OAuthConnectProps, "redir">>> = ({
label = "Connect Apple Calendar",
alreadyConnectedLabel = "Connected Apple Calendar",
loadingLabel = "Checking Apple Calendar",
className,
}) => {
const form = useForm({
defaultValues: {
username: "",
password: "",
},
});
const { toast } = useToast();
const { allowConnect, checked, refetch } = useCheck({
calendar: "apple",
});

const [isDialogOpen, setIsDialogOpen] = useState(false);
let displayedLabel = label;

const { mutate: saveCredentials, isPending: isSaving } = useSaveCalendarCredentials({
onSuccess: (res) => {
if (res.status === SUCCESS_STATUS) {
form.reset();
setIsDialogOpen(false);
refetch();
toast({
description: "Calendar credentials added successfully",
});
}
},
onError: (err) => {
toast({
description: `Error: ${err}`,
});
},
});

const isChecking = !checked;
const isDisabled = isChecking || !allowConnect;

if (isChecking) {
displayedLabel = loadingLabel;
} else if (!allowConnect) {
displayedLabel = alreadyConnectedLabel;
}

return (
<AtomsWrapper>
<Dialog open={isDialogOpen}>
<DialogTrigger>
<Button
StartIcon="calendar-days"
color="primary"
disabled={isDisabled}
className={cn("", className, isDisabled && "cursor-not-allowed", !isDisabled && "cursor-pointer")}
onClick={() => setIsDialogOpen(true)}>
{displayedLabel}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Connect to Apple Server</DialogTitle>
<DialogDescription>
Generate an app specific password to use with Cal.com at{" "}
<span className="font-bold">https://appleid.apple.com/account/manage</span>. Your credentials
will be stored and encrypted.
</DialogDescription>
</DialogHeader>
<Form
form={form}
handleSubmit={async (values) => {
const { username, password } = values;

await saveCredentials({ calendar: "apple", username, password });
}}>
<fieldset
className="space-y-4"
disabled={form.formState.isSubmitting}
data-testid="apple-calendar-form">
<TextField
required
type="text"
{...form.register("username")}
label="Apple ID"
placeholder="[email protected]"
data-testid="apple-calendar-email"
/>
<PasswordField
required
{...form.register("password")}
label="Password"
placeholder="•••••••••••••"
autoComplete="password"
data-testid="apple-calendar-password"
/>
</fieldset>
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
<Button
disabled={isSaving}
type="button"
color="secondary"
onClick={() => setIsDialogOpen(false)}>
Cancel
</Button>
<Button
disabled={isSaving}
type="submit"
loading={form.formState.isSubmitting}
data-testid="apple-calendar-login-button">
Save
</Button>
</div>
</Form>
</DialogContent>
</Dialog>
</AtomsWrapper>
);
};
1 change: 1 addition & 0 deletions packages/platform/atoms/connect/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { GcalConnect as GoogleCalendar } from "./google/GcalConnect";
export { OutlookConnect as OutlookCalendar } from "./outlook/OutlookConnect";
export { AppleConnect as AppleCalendar } from "./apple/AppleConnect";
Loading

0 comments on commit 28eac06

Please sign in to comment.