From f80e9b255817461808ceba6aadb7f5b2eb85b16a Mon Sep 17 00:00:00 2001 From: "Jon@1599" <55296387+Jonathan1599@users.noreply.github.com> Date: Sun, 20 Aug 2023 01:34:56 +0530 Subject: [PATCH] feat: add basecamp integration to cal.com (#9195) * feat: installing the app works * Update yarn.lock * feat: /api/callback now gets user auth info from basecamp * feat: updated basecamp logo * feat: added project dropdown on event apps page * feat: basecamp event creation and deletion working * feat: basecamp event rescheduling now works * refactor(CalendarService): basecamp CaldendarService code clean up * refactor: code cleanup for basecamp app API * feat: updated event summary text sent to basecamp * chore: updated basecamp images and contact info * fix: fixed typescript errors and added logic to refresh tokens on event settings * refactor(CaldendarService): used refreshAccessToken from helpers.ts instead * chore: updated basecamp description * fix: fixed incorrect import * fix: accidentally deleted props to toggle app for event * chore: updated .env.appStore.example and added README for app * Update .env.appStore.example Co-authored-by: Leo Giovanetti * feat: added basecamp userAgent in env instead of hardcoded value * feat: updated README to include how to set basecamp user agent env * fix: removed unused import * feat: used URLSearchParams to construct url params * fix: fixed typescript errors * chore: updated README to include an example on how to set basecamp user-agent * feat: using TRPC instead of REST * chore: removed old projects REST code --------- Co-authored-by: Leo Giovanetti --- .env.appStore.example | 9 + README.md | 12 + .../web/pages/api/trpc/appBasecamp3/[trpc].ts | 4 + packages/app-store/apps.browser.generated.tsx | 1 + .../app-store/apps.keys-schemas.generated.ts | 2 + packages/app-store/apps.metadata.generated.ts | 2 + packages/app-store/apps.schemas.generated.ts | 2 + packages/app-store/apps.server.generated.ts | 1 + packages/app-store/basecamp3/DESCRIPTION.md | 8 + packages/app-store/basecamp3/api/add.ts | 35 +++ packages/app-store/basecamp3/api/callback.ts | 90 ++++++ packages/app-store/basecamp3/api/index.ts | 2 + .../components/EventTypeAppCardInterface.tsx | 73 +++++ packages/app-store/basecamp3/config.json | 17 ++ packages/app-store/basecamp3/index.ts | 2 + .../basecamp3/lib/CalendarService.ts | 281 ++++++++++++++++++ .../basecamp3/lib/getBasecampKeys.ts | 14 + packages/app-store/basecamp3/lib/helpers.ts | 24 ++ packages/app-store/basecamp3/lib/index.ts | 1 + packages/app-store/basecamp3/package.json | 14 + packages/app-store/basecamp3/static/1.png | Bin 0 -> 487184 bytes packages/app-store/basecamp3/static/2.png | Bin 0 -> 619247 bytes packages/app-store/basecamp3/static/3.png | Bin 0 -> 147489 bytes packages/app-store/basecamp3/static/logo.svg | 1 + packages/app-store/basecamp3/trpc-router.ts | 1 + packages/app-store/basecamp3/trpc/_router.ts | 25 ++ .../basecamp3/trpc/projectMutation.handler.ts | 56 ++++ .../basecamp3/trpc/projectMutation.schema.ts | 7 + .../basecamp3/trpc/projects.handler.ts | 39 +++ packages/app-store/basecamp3/zod.ts | 10 + packages/app-store/index.ts | 1 + packages/prisma/seed-app-store.ts | 11 + packages/trpc/react/trpc.ts | 1 + .../trpc/server/routers/viewer/_router.tsx | 2 + turbo.json | 3 + 35 files changed, 751 insertions(+) create mode 100644 apps/web/pages/api/trpc/appBasecamp3/[trpc].ts create mode 100644 packages/app-store/basecamp3/DESCRIPTION.md create mode 100644 packages/app-store/basecamp3/api/add.ts create mode 100644 packages/app-store/basecamp3/api/callback.ts create mode 100644 packages/app-store/basecamp3/api/index.ts create mode 100644 packages/app-store/basecamp3/components/EventTypeAppCardInterface.tsx create mode 100644 packages/app-store/basecamp3/config.json create mode 100644 packages/app-store/basecamp3/index.ts create mode 100644 packages/app-store/basecamp3/lib/CalendarService.ts create mode 100644 packages/app-store/basecamp3/lib/getBasecampKeys.ts create mode 100644 packages/app-store/basecamp3/lib/helpers.ts create mode 100644 packages/app-store/basecamp3/lib/index.ts create mode 100644 packages/app-store/basecamp3/package.json create mode 100644 packages/app-store/basecamp3/static/1.png create mode 100644 packages/app-store/basecamp3/static/2.png create mode 100644 packages/app-store/basecamp3/static/3.png create mode 100644 packages/app-store/basecamp3/static/logo.svg create mode 100644 packages/app-store/basecamp3/trpc-router.ts create mode 100644 packages/app-store/basecamp3/trpc/_router.ts create mode 100644 packages/app-store/basecamp3/trpc/projectMutation.handler.ts create mode 100644 packages/app-store/basecamp3/trpc/projectMutation.schema.ts create mode 100644 packages/app-store/basecamp3/trpc/projects.handler.ts create mode 100644 packages/app-store/basecamp3/zod.ts diff --git a/.env.appStore.example b/.env.appStore.example index 94943a2fa6..ca90618a53 100644 --- a/.env.appStore.example +++ b/.env.appStore.example @@ -1,6 +1,7 @@ # ********** INDEX ********** # # - APP STORE +# - BASECAMP # - DAILY.CO VIDEO # - GOOGLE CALENDAR/MEET/LOGIN # - HUBSPOT @@ -20,6 +21,14 @@ # - APP STORE ********************************************************************************************** # ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️ + +# - BASECAMP +# Used to enable Basecamp integration with Cal.com +# @see https://github.com/calcom/cal.com#obtaining-basecamp-client-id-and-secret +BASECAMP3_CLIENT_ID= +BASECAMP3_CLIENT_SECRET= +BASECAMP3_USER_AGENT= + # - DAILY.CO VIDEO # Enables Cal Video. to get your key # 1. Visit our [Daily.co Partnership Form](https://go.cal.com/daily) and enter your information diff --git a/README.md b/README.md index a0edf3785c..21b2a3ef3c 100644 --- a/README.md +++ b/README.md @@ -461,6 +461,18 @@ following 4. Now paste the API key to your `.env` file into the `DAILY_API_KEY` field in your `.env` file. 5. If you have the [Daily Scale Plan](https://daily.co/pricing) set the `DAILY_SCALE_PLAN` variable to `true` in order to use features like video recording. +### Obtaining Basecamp Client ID and Secret + +1. Visit the [37 Signals Integrations Dashboard](launchpad.37signals.com/integrations) and sign in. +2. Register a new application by clicking the Register one now link. +3. Fill in your company details. +4. Select Basecamp 4 as the product to integrate with. +5. Set the Redirect URL for OAuth `/api/integrations/basecamp3/callback` replacing Cal.com URL with the URI at which your application runs. +6. Click on done and copy the Client ID and secret into the `BASECAMP3_CLIENT_ID` and `BASECAMP3_CLIENT_SECRET` fields. +7. Set the `BASECAMP3_CLIENT_SECRET` env variable to `{your_domain} ({support_email})`. +For example, `Cal.com (support@cal.com)`. + + ### Obtaining HubSpot Client ID and Secret 1. Open [HubSpot Developer](https://developer.hubspot.com/) and sign into your account, or create a new one. diff --git a/apps/web/pages/api/trpc/appBasecamp3/[trpc].ts b/apps/web/pages/api/trpc/appBasecamp3/[trpc].ts new file mode 100644 index 0000000000..2f77571b8e --- /dev/null +++ b/apps/web/pages/api/trpc/appBasecamp3/[trpc].ts @@ -0,0 +1,4 @@ +import appBasecamp3 from "@calcom/app-store/basecamp3/trpc-router"; +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; + +export default createNextApiHandler(appBasecamp3); diff --git a/packages/app-store/apps.browser.generated.tsx b/packages/app-store/apps.browser.generated.tsx index a879018b4e..eb4c2aee20 100644 --- a/packages/app-store/apps.browser.generated.tsx +++ b/packages/app-store/apps.browser.generated.tsx @@ -20,6 +20,7 @@ export const AppSettingsComponentsMap = { zapier: dynamic(() => import("./zapier/components/AppSettingsInterface")), }; export const EventTypeAddonMap = { + basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppCardInterface")), fathom: dynamic(() => import("./fathom/components/EventTypeAppCardInterface")), ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")), giphy: dynamic(() => import("./giphy/components/EventTypeAppCardInterface")), diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts index b0086b3022..019da1d5b9 100644 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -2,6 +2,7 @@ This file is autogenerated using the command `yarn app-store:build --watch`. Don't modify this file manually. **/ +import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod"; import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod"; import { appKeysSchema as fathom_zod_ts } from "./fathom/zod"; import { appKeysSchema as ga4_zod_ts } from "./ga4/zod"; @@ -32,6 +33,7 @@ import { appKeysSchema as zohocrm_zod_ts } from "./zohocrm/zod"; import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod"; export const appKeysSchemas = { + basecamp3: basecamp3_zod_ts, dailyvideo: dailyvideo_zod_ts, fathom: fathom_zod_ts, ga4: ga4_zod_ts, diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index 17dfaee2e6..768aadcbae 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -5,6 +5,7 @@ import amie_config_json from "./amie/config.json"; import { metadata as applecalendar__metadata_ts } from "./applecalendar/_metadata"; import around_config_json from "./around/config.json"; +import basecamp3_config_json from "./basecamp3/config.json"; import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata"; import campfire_config_json from "./campfire/config.json"; import closecom_config_json from "./closecom/config.json"; @@ -73,6 +74,7 @@ export const appStoreMetadata = { amie: amie_config_json, applecalendar: applecalendar__metadata_ts, around: around_config_json, + basecamp3: basecamp3_config_json, caldavcalendar: caldavcalendar__metadata_ts, campfire: campfire_config_json, closecom: closecom_config_json, diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts index deaabc768f..49ff7731ec 100644 --- a/packages/app-store/apps.schemas.generated.ts +++ b/packages/app-store/apps.schemas.generated.ts @@ -2,6 +2,7 @@ This file is autogenerated using the command `yarn app-store:build --watch`. Don't modify this file manually. **/ +import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod"; import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod"; import { appDataSchema as fathom_zod_ts } from "./fathom/zod"; import { appDataSchema as ga4_zod_ts } from "./ga4/zod"; @@ -32,6 +33,7 @@ import { appDataSchema as zohocrm_zod_ts } from "./zohocrm/zod"; import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod"; export const appDataSchemas = { + basecamp3: basecamp3_zod_ts, dailyvideo: dailyvideo_zod_ts, fathom: fathom_zod_ts, ga4: ga4_zod_ts, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index 01c09129f4..50e90ecd4f 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -6,6 +6,7 @@ export const apiHandlers = { amie: import("./amie/api"), applecalendar: import("./applecalendar/api"), around: import("./around/api"), + basecamp3: import("./basecamp3/api"), caldavcalendar: import("./caldavcalendar/api"), campfire: import("./campfire/api"), closecom: import("./closecom/api"), diff --git a/packages/app-store/basecamp3/DESCRIPTION.md b/packages/app-store/basecamp3/DESCRIPTION.md new file mode 100644 index 0000000000..61e7037d43 --- /dev/null +++ b/packages/app-store/basecamp3/DESCRIPTION.md @@ -0,0 +1,8 @@ +--- +items: + - 1.png + - 2.png + - 3.png +--- + +{DESCRIPTION} diff --git a/packages/app-store/basecamp3/api/add.ts b/packages/app-store/basecamp3/api/add.ts new file mode 100644 index 0000000000..75ad26e8db --- /dev/null +++ b/packages/app-store/basecamp3/api/add.ts @@ -0,0 +1,35 @@ +import type { NextApiRequest } from "next"; +import { stringify } from "querystring"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { getBasecampKeys } from "../lib/getBasecampKeys"; + +async function handler(req: NextApiRequest) { + await prisma.user.findFirstOrThrow({ + where: { + id: req.session?.user?.id, + }, + select: { + id: true, + }, + }); + + const { client_id } = await getBasecampKeys(); + + const params = { + type: "web_server", + client_id, + }; + const query = stringify(params); + const url = `https://launchpad.37signals.com/authorization/new?${query}&redirect_uri=${ + WEBAPP_URL + "/api/integrations/basecamp3/callback" + }`; + return { url }; +} + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/packages/app-store/basecamp3/api/callback.ts b/packages/app-store/basecamp3/api/callback.ts new file mode 100644 index 0000000000..0e152e909c --- /dev/null +++ b/packages/app-store/basecamp3/api/callback.ts @@ -0,0 +1,90 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; + +import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; +import getInstalledAppPath from "../../_utils/getInstalledAppPath"; +import appConfig from "../config.json"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { code } = req.query; + const { client_id, client_secret, user_agent } = await getAppKeysFromSlug("basecamp3"); + + const redirectUri = WEBAPP_URL + "/api/integrations/basecamp3/callback"; + + const params = new URLSearchParams({ + type: "web_server", + client_id: client_id as string, + client_secret: client_secret as string, + redirect_uri: redirectUri, + code: code as string, + }); + // gets access token + const accessTokenResponse = await fetch( + `https://launchpad.37signals.com/authorization/token?${params.toString()}`, + { + method: "POST", + } + ); + + if (accessTokenResponse.status !== 200) { + let errorMessage = "Error with Basecamp 3 API"; + try { + const responseBody = await accessTokenResponse.json(); + errorMessage = responseBody.error; + } catch (e) {} + + res.status(400).json({ message: errorMessage }); + return; + } + + const tokenResponseBody = await accessTokenResponse.json(); + + if (tokenResponseBody.error) { + res.status(400).json({ message: tokenResponseBody.error }); + return; + } + // expiry date of 2 weeks + tokenResponseBody["expires_at"] = Date.now() + 1000 * 3600 * 24 * 14; + // get user details such as projects and account info + const userAuthResponse = await fetch("https://launchpad.37signals.com/authorization.json", { + headers: { + "User-Agent": user_agent as string, + Authorization: `Bearer ${tokenResponseBody.access_token}`, + }, + }); + if (userAuthResponse.status !== 200) { + let errorMessage = "Error with Basecamp 3 API"; + try { + const body = await userAuthResponse.json(); + errorMessage = body.error; + } catch (e) {} + + res.status(400).json({ message: errorMessage }); + return; + } + + const authResponseBody = await userAuthResponse.json(); + const userId = req.session?.user.id; + if (!userId) { + return res.status(404).json({ message: "No user found" }); + } + + await prisma.user.update({ + where: { + id: req.session?.user.id, + }, + data: { + credentials: { + create: { + type: appConfig.type, + key: { ...tokenResponseBody, account: authResponseBody.accounts[0] }, + appId: appConfig.slug, + }, + }, + }, + }); + + res.redirect(getInstalledAppPath({ variant: appConfig.variant, slug: appConfig.slug })); +} diff --git a/packages/app-store/basecamp3/api/index.ts b/packages/app-store/basecamp3/api/index.ts new file mode 100644 index 0000000000..eb12c1b4ed --- /dev/null +++ b/packages/app-store/basecamp3/api/index.ts @@ -0,0 +1,2 @@ +export { default as add } from "./add"; +export { default as callback } from "./callback"; diff --git a/packages/app-store/basecamp3/components/EventTypeAppCardInterface.tsx b/packages/app-store/basecamp3/components/EventTypeAppCardInterface.tsx new file mode 100644 index 0000000000..d742941000 --- /dev/null +++ b/packages/app-store/basecamp3/components/EventTypeAppCardInterface.tsx @@ -0,0 +1,73 @@ +import { useState, useEffect } from "react"; + +import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext"; +import AppCard from "@calcom/app-store/_components/AppCard"; +import type { EventTypeAppCardComponent } from "@calcom/app-store/types"; +import { trpc } from "@calcom/trpc/react"; +import { Select } from "@calcom/ui"; + +import type { appDataSchema } from "../zod"; + +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app }) { + const [getAppData, setAppData] = useAppContextWithSchema(); + const [enabled, setEnabled] = useState(getAppData("enabled")); + const [projects, setProjects] = useState(); + const [selectedProject, setSelectedProject] = useState(); + const { data } = trpc.viewer.appBasecamp3.projects.useQuery(); + const setProject = trpc.viewer.appBasecamp3.projectMutation.useMutation(); + useEffect(() => { + setSelectedProject({ + value: data?.projects.currentProject, + label: data?.projects?.find((project: any) => project.id === data?.currentProject)?.name, + }); + setProjects( + data?.projects?.map((project: any) => { + return { + value: project.id, + label: project.name, + }; + }) + ); + }, [data]); + + return ( + { + if (!e) { + setEnabled(false); + } else { + setEnabled(true); + } + }} + switchChecked={enabled}> +
+
+
+

Link a Basecamp project to this event:

+
+