diff --git a/.env.example b/.env.example index 295447add0..221c962a6d 100644 --- a/.env.example +++ b/.env.example @@ -77,7 +77,7 @@ NEXT_PUBLIC_HELPSCOUT_KEY= SEND_FEEDBACK_EMAIL= # Sengrid -# Used for email reminders in workflows +# Used for email reminders in workflows and internal sync services SENDGRID_API_KEY= SENDGRID_EMAIL= @@ -134,8 +134,8 @@ NEXT_PUBLIC_TEAM_IMPERSONATION=false # Close.com internal CRM CLOSECOM_API_KEY= -# Sendgrid internal email sender -SENDGRID_API_KEY= +# Sendgrid internal sync service +SENDGRID_SYNC_API_KEY= # Sentry NEXT_PUBLIC_SENTRY_DSN= diff --git a/app.json b/app.json index 2ad52b916c..ba670bab19 100644 --- a/app.json +++ b/app.json @@ -50,6 +50,10 @@ "description": "Sendgrid api key. Used for email reminders in workflows", "value": "" }, + "SENDGRID_SYNC_API_KEY": { + "description": "Sendgrid internal sync service", + "value": "" + }, "SENDGRID_EMAIL": { "description": "Sendgrid email. Used for email reminders in workflows", "value": "" diff --git a/apps/web/components/apps/App.tsx b/apps/web/components/apps/App.tsx index 4dd6a739f9..f04d2083dc 100644 --- a/apps/web/components/apps/App.tsx +++ b/apps/web/components/apps/App.tsx @@ -43,7 +43,8 @@ const Component = ({ const router = useRouter(); const mutation = useAddAppMutation(null, { - onSuccess: () => { + onSuccess: (data) => { + if (data.setupPending) return; showToast(t("app_successfully_installed"), "success"); }, onError: (error) => { diff --git a/apps/web/components/apps/IntegrationListItem.tsx b/apps/web/components/apps/IntegrationListItem.tsx index 03198de203..a27d424371 100644 --- a/apps/web/components/apps/IntegrationListItem.tsx +++ b/apps/web/components/apps/IntegrationListItem.tsx @@ -4,6 +4,7 @@ import { ReactNode, useEffect, useState } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Icon } from "@calcom/ui"; +import { showToast } from "@calcom/ui/v2"; import { ListItem, ListItemText, ListItemTitle } from "@calcom/ui/v2/core/List"; import classNames from "@lib/classNames"; @@ -27,6 +28,13 @@ function IntegrationListItem(props: { const [highlight, setHighlight] = useState(hl === props.slug); const title = props.name || props.title; + // The highlight is to show a newly installed app, coming from the app's + // redirection after installation, so we proceed to show the corresponding + // message + if (highlight) { + showToast(t("app_successfully_installed"), "success"); + } + useEffect(() => { const timer = setTimeout(() => setHighlight(false), 3000); return () => { diff --git a/apps/web/components/apps/OmniInstallAppButton.tsx b/apps/web/components/apps/OmniInstallAppButton.tsx index b76e09bbb0..0883016823 100644 --- a/apps/web/components/apps/OmniInstallAppButton.tsx +++ b/apps/web/components/apps/OmniInstallAppButton.tsx @@ -18,10 +18,11 @@ export default function OmniInstallAppButton({ appId, className }: { appId: stri const utils = trpc.useContext(); const mutation = useAddAppMutation(null, { - onSuccess: () => { + onSuccess: (data) => { //TODO: viewer.appById might be replaced with viewer.apps so that a single query needs to be invalidated. utils.viewer.appById.invalidate({ appId }); utils.viewer.apps.invalidate({ extendsFeature: "EventType" }); + if (data.setupPending) return; showToast(t("app_successfully_installed"), "success"); }, onError: (error) => { diff --git a/apps/web/pages/apps/installed/[category].tsx b/apps/web/pages/apps/installed/[category].tsx index 7a091f795b..da92ec8117 100644 --- a/apps/web/pages/apps/installed/[category].tsx +++ b/apps/web/pages/apps/installed/[category].tsx @@ -179,7 +179,9 @@ const IntegrationsContainer = ({ variant, exclude }: IntegrationsContainerProps) })} description={t(`no_category_apps_description_${variant || "other"}`)} buttonRaw={ - } diff --git a/packages/app-store/_pages/setup/index.tsx b/packages/app-store/_pages/setup/index.tsx index bb08cc9e6c..9259f750ad 100644 --- a/packages/app-store/_pages/setup/index.tsx +++ b/packages/app-store/_pages/setup/index.tsx @@ -10,6 +10,7 @@ export const AppSetupMap = { "caldav-calendar": dynamic(() => import("../../caldavcalendar/pages/setup")), zapier: dynamic(() => import("../../zapier/pages/setup")), closecom: dynamic(() => import("../../closecomothercalendar/pages/setup")), + sendgrid: dynamic(() => import("../../sendgrid/pages/setup")), }; export const AppSetupPage = (props: { slug: string }) => { diff --git a/packages/app-store/_utils/getCalendar.ts b/packages/app-store/_utils/getCalendar.ts index 742ac51a7a..213f150ae0 100644 --- a/packages/app-store/_utils/getCalendar.ts +++ b/packages/app-store/_utils/getCalendar.ts @@ -8,7 +8,10 @@ const log = logger.getChildLogger({ prefix: ["CalendarManager"] }); export const getCalendar = (credential: CredentialPayload | null): Calendar | null => { if (!credential || !credential.key) return null; - const { type: calendarType } = credential; + let { type: calendarType } = credential; + if (calendarType === "sendgrid_other_calendar") { + calendarType = "sendgrid"; + } const calendarApp = appStore[calendarType.split("_").join("") as keyof typeof appStore]; if (!(calendarApp && "lib" in calendarApp && "CalendarService" in calendarApp.lib)) { log.warn(`calendar of type ${calendarType} is not implemented`); diff --git a/packages/app-store/_utils/useAddAppMutation.ts b/packages/app-store/_utils/useAddAppMutation.ts index 5cdf68d60e..e85de87d76 100644 --- a/packages/app-store/_utils/useAddAppMutation.ts +++ b/packages/app-store/_utils/useAddAppMutation.ts @@ -1,4 +1,4 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, UseMutationOptions } from "@tanstack/react-query"; import type { IntegrationOAuthCallbackState } from "@calcom/app-store/types"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -14,9 +14,17 @@ function gotoUrl(url: string, newTab?: boolean) { window.location.href = url; } -function useAddAppMutation(_type: App["type"] | null, options?: Parameters[2]) { +type CustomUseMutationOptions = + | Omit, "mutationKey" | "mutationFn" | "onSuccess"> + | undefined; + +type UseAddAppMutationOptions = CustomUseMutationOptions & { + onSuccess: (data: { setupPending: boolean }) => void; +}; + +function useAddAppMutation(_type: App["type"] | null, options?: UseAddAppMutationOptions) { const mutation = useMutation< - unknown, + { setupPending: boolean }, Error, { type?: App["type"]; variant?: string; slug?: string; isOmniInstall?: boolean } | "" >(async (variables) => { @@ -28,6 +36,9 @@ function useAddAppMutation(_type: App["type"] | null, options?: Parameters ({ - debug: jest.fn(), - error: jest.fn(), - log: jest.fn(), - getChildLogger: jest.fn(), + default: { + getChildLogger: () => ({ + debug: jest.fn(), + error: jest.fn(), + log: jest.fn(), + }), + }, })); jest.mock("@calcom/lib/crypto", () => ({ diff --git a/packages/app-store/closecomothercalendar/test/lib/CalendarService.test.ts b/packages/app-store/closecomothercalendar/test/lib/CalendarService.test.ts index da4e316a6d..ee9d6b2284 100644 --- a/packages/app-store/closecomothercalendar/test/lib/CalendarService.test.ts +++ b/packages/app-store/closecomothercalendar/test/lib/CalendarService.test.ts @@ -168,7 +168,7 @@ test("prepare data to create custom activity type instance: two attendees, no ad const event = { attendees, startTime: now.toISOString(), - } as CalendarEvent; + } as unknown as CalendarEvent; CloseCom.prototype.activity = { type: { diff --git a/packages/app-store/hubspotothercalendar/api/callback.ts b/packages/app-store/hubspotothercalendar/api/callback.ts index 66de827e24..0ce3498522 100644 --- a/packages/app-store/hubspotothercalendar/api/callback.ts +++ b/packages/app-store/hubspotothercalendar/api/callback.ts @@ -57,6 +57,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const state = decodeOAuthState(req); res.redirect( - getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "other_calendar", slug: "hubspot" }) + getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "other", slug: "hubspot" }) ); } diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index 6c87e4b0aa..4082532457 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -15,6 +15,7 @@ import * as jitsivideo from "./jitsivideo"; import * as larkcalendar from "./larkcalendar"; import * as office365calendar from "./office365calendar"; import * as office365video from "./office365video"; +import * as sendgrid from "./sendgrid"; import * as stripepayment from "./stripepayment"; import * as tandemvideo from "./tandemvideo"; import * as vital from "./vital"; @@ -36,6 +37,7 @@ const appStore = { larkcalendar, office365calendar, office365video, + sendgrid, stripepayment, tandemvideo, vital, diff --git a/packages/app-store/sendgrid/README.mdx b/packages/app-store/sendgrid/README.mdx new file mode 100644 index 0000000000..c716fa4ba4 --- /dev/null +++ b/packages/app-store/sendgrid/README.mdx @@ -0,0 +1,10 @@ +--- +description: SendGrid delivers your transactional and marketing emails through the world's largest cloud-based email delivery platform. +items: + - /api/app-store/sendgrid/1.png +--- + +{description} + +Features: + - Creates event attendees as contacts in Sendgrid diff --git a/packages/app-store/sendgrid/_metadata.ts b/packages/app-store/sendgrid/_metadata.ts new file mode 100644 index 0000000000..9c7f2aa320 --- /dev/null +++ b/packages/app-store/sendgrid/_metadata.ts @@ -0,0 +1,10 @@ +import type { AppMeta } from "@calcom/types/App"; + +import config from "./config.json"; + +export const metadata = { + category: "other", + ...config, +} as AppMeta; + +export default metadata; diff --git a/packages/app-store/sendgrid/api/_getAdd.ts b/packages/app-store/sendgrid/api/_getAdd.ts new file mode 100644 index 0000000000..2b7ae8c535 --- /dev/null +++ b/packages/app-store/sendgrid/api/_getAdd.ts @@ -0,0 +1,14 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; + +import checkSession from "../../_utils/auth"; +import { checkInstalled } from "../../_utils/installation"; + +export async function getHandler(req: NextApiRequest) { + const session = checkSession(req); + await checkInstalled("sendgrid", session.user?.id); + return { url: "/apps/sendgrid/setup" }; +} + +export default defaultResponder(getHandler); diff --git a/packages/app-store/sendgrid/api/_postAdd.ts b/packages/app-store/sendgrid/api/_postAdd.ts new file mode 100644 index 0000000000..1cf672fb95 --- /dev/null +++ b/packages/app-store/sendgrid/api/_postAdd.ts @@ -0,0 +1,39 @@ +import type { NextApiRequest } from "next"; + +import { symmetricEncrypt } from "@calcom/lib/crypto"; +import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import checkSession from "../../_utils/auth"; +import getInstalledAppPath from "../../_utils/getInstalledAppPath"; + +export async function getHandler(req: NextApiRequest) { + const session = checkSession(req); + + const { api_key } = req.body; + if (!api_key) throw new HttpError({ statusCode: 400, message: "No Api Key provided to check" }); + + const encrypted = symmetricEncrypt(JSON.stringify({ api_key }), process.env.CALENDSO_ENCRYPTION_KEY || ""); + + const data = { + type: "sendgrid_other_calendar", + key: { encrypted }, + userId: session.user?.id, + appId: "sendgrid", + }; + + try { + await prisma.credential.create({ + data, + }); + } catch (reason) { + logger.error("Could not add Sendgrid app", reason); + throw new HttpError({ statusCode: 500, message: "Could not add Sendgrid app" }); + } + + return { url: getInstalledAppPath({ variant: "other", slug: "sendgrid" }) }; +} + +export default defaultResponder(getHandler); diff --git a/packages/app-store/sendgrid/api/add.ts b/packages/app-store/sendgrid/api/add.ts new file mode 100644 index 0000000000..9480fb9259 --- /dev/null +++ b/packages/app-store/sendgrid/api/add.ts @@ -0,0 +1,6 @@ +import { defaultHandler } from "@calcom/lib/server"; + +export default defaultHandler({ + GET: import("./_getAdd"), + POST: import("./_postAdd"), +}); diff --git a/packages/app-store/sendgrid/api/check.ts b/packages/app-store/sendgrid/api/check.ts new file mode 100644 index 0000000000..98c695dd40 --- /dev/null +++ b/packages/app-store/sendgrid/api/check.ts @@ -0,0 +1,31 @@ +import type { NextApiRequest } from "next"; + +import Sendgrid from "@calcom/lib/Sendgrid"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +import checkSession from "../../_utils/auth"; + +export async function getHandler(req: NextApiRequest) { + const { api_key } = req.body; + if (!api_key) throw new HttpError({ statusCode: 400, message: "No Api Key provoided to check" }); + + checkSession(req); + + const sendgrid: Sendgrid = new Sendgrid(api_key); + + try { + const usernameInfo = await sendgrid.username(); + if (usernameInfo.username) { + return {}; + } else { + throw new HttpError({ statusCode: 404 }); + } + } catch (e) { + throw new HttpError({ statusCode: 500, message: e as string }); + } +} + +export default defaultHandler({ + POST: Promise.resolve({ default: defaultResponder(getHandler) }), +}); diff --git a/packages/app-store/sendgrid/api/index.ts b/packages/app-store/sendgrid/api/index.ts new file mode 100644 index 0000000000..766afa6405 --- /dev/null +++ b/packages/app-store/sendgrid/api/index.ts @@ -0,0 +1,2 @@ +export { default as add } from "./add"; +export { default as check } from "./check"; diff --git a/packages/app-store/sendgrid/config.json b/packages/app-store/sendgrid/config.json new file mode 100644 index 0000000000..d95d4b11ce --- /dev/null +++ b/packages/app-store/sendgrid/config.json @@ -0,0 +1,16 @@ +{ + "/*": "Don't modify slug - If required, do it using cli edit command", + "name": "Sendgrid", + "slug": "sendgrid", + "type": "sendgrid_other_calendar", + "imageSrc": "/api/app-store/sendgrid/logo.png", + "logo": "/api/app-store/sendgrid/logo.png", + "url": "https://cal.com/apps/sendgrid", + "variant": "other_calendar", + "categories": ["other"], + "publisher": "Cal.com", + "email": "help@cal.com", + "description": "SendGrid delivers your transactional and marketing emails through the world's largest cloud-based email delivery platform.", + "extendsFeature": "User", + "__createdUsingCli": true +} diff --git a/packages/app-store/sendgrid/index.ts b/packages/app-store/sendgrid/index.ts new file mode 100644 index 0000000000..5373eb04ef --- /dev/null +++ b/packages/app-store/sendgrid/index.ts @@ -0,0 +1,3 @@ +export * as api from "./api"; +export * as lib from "./lib"; +export { metadata } from "./_metadata"; diff --git a/packages/app-store/sendgrid/lib/CalendarService.ts b/packages/app-store/sendgrid/lib/CalendarService.ts new file mode 100644 index 0000000000..17e69a623b --- /dev/null +++ b/packages/app-store/sendgrid/lib/CalendarService.ts @@ -0,0 +1,99 @@ +import z from "zod"; + +import Sendgrid, { SendgridNewContact } from "@calcom/lib/Sendgrid"; +import { symmetricDecrypt } from "@calcom/lib/crypto"; +import logger from "@calcom/lib/logger"; +import type { + Calendar, + CalendarEvent, + EventBusyDate, + IntegrationCalendar, + NewCalendarEventType, +} from "@calcom/types/Calendar"; +import { CredentialPayload } from "@calcom/types/Credential"; + +const apiKeySchema = z.object({ + encrypted: z.string(), +}); + +const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || ""; + +/** + * Authentication + * Sendgrid requires Basic Auth for any request to their APIs, which is far from + * ideal considering that such a strategy requires generating an API Key by the + * user and input it in our system. A Setup page was created when trying to install + * Sendgrid in order to instruct how to create such resource and to obtain it. + */ +export default class CloseComCalendarService implements Calendar { + private integrationName = ""; + private sendgrid: Sendgrid; + private log: typeof logger; + + constructor(credential: CredentialPayload) { + this.integrationName = "sendgrid_other_calendar"; + this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] }); + + const parsedCredentialKey = apiKeySchema.safeParse(credential.key); + + let decrypted; + if (parsedCredentialKey.success) { + decrypted = symmetricDecrypt(parsedCredentialKey.data.encrypted, CALENDSO_ENCRYPTION_KEY); + const { api_key } = JSON.parse(decrypted); + this.sendgrid = new Sendgrid(api_key); + } else { + throw Error( + `No API Key found for userId ${credential.userId} and appId ${credential.appId}: ${parsedCredentialKey.error}` + ); + } + } + + async createEvent(event: CalendarEvent): Promise { + // Proceeding to just creating the user in Sendgrid, no event entity exists in Sendgrid + const contactsData = event.attendees.map((attendee) => ({ + first_name: attendee.name, + email: attendee.email, + })); + const result = await this.sendgrid.sendgridRequest({ + url: `/v3/marketing/contacts`, + method: "PUT", + body: { + contacts: contactsData, + }, + }); + return Promise.resolve({ + id: "", + uid: result.job_id, + password: "", + url: "", + type: this.integrationName, + additionalInfo: { + result, + }, + }); + } + + async updateEvent(uid: string, event: CalendarEvent): Promise { + // Unless we want to be able to support modifying an event to add more attendees + // to have them created in Sendgrid, ingoring this use case for now + return Promise.resolve(); + } + + async deleteEvent(uid: string): Promise { + // Unless we want to delete the contact in Sendgrid once the event + // is deleted just ingoring this use case for now + return Promise.resolve(); + } + + async getAvailability( + dateFrom: string, + dateTo: string, + selectedCalendars: IntegrationCalendar[] + ): Promise { + return Promise.resolve([]); + } + + async listCalendars(event?: CalendarEvent): Promise { + return Promise.resolve([]); + } +} diff --git a/packages/app-store/sendgrid/lib/index.ts b/packages/app-store/sendgrid/lib/index.ts new file mode 100644 index 0000000000..e168c149df --- /dev/null +++ b/packages/app-store/sendgrid/lib/index.ts @@ -0,0 +1 @@ +export { default as CalendarService } from "./CalendarService"; diff --git a/packages/app-store/sendgrid/package.json b/packages/app-store/sendgrid/package.json new file mode 100644 index 0000000000..0292fb930d --- /dev/null +++ b/packages/app-store/sendgrid/package.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/sendgrid", + "version": "0.0.0", + "main": "./index.ts", + "description": "SendGrid delivers your transactional and marketing emails through the world's largest cloud-based email delivery platform.", + "dependencies": { + "@calcom/lib": "*", + "@calcom/prisma": "*", + "@sendgrid/client": "^7.7.0" + }, + "devDependencies": { + "@calcom/types": "*" + } +} diff --git a/packages/app-store/sendgrid/pages/setup/index.tsx b/packages/app-store/sendgrid/pages/setup/index.tsx new file mode 100644 index 0000000000..9fecd4df81 --- /dev/null +++ b/packages/app-store/sendgrid/pages/setup/index.tsx @@ -0,0 +1,152 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/router"; +import { useState, useEffect } from "react"; +import { useForm, Controller } from "react-hook-form"; +import { Toaster } from "react-hot-toast"; +import z from "zod"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Icon } from "@calcom/ui/Icon"; +import { Button } from "@calcom/ui/components/button"; +import { Form, TextField } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/v2"; + +const formSchema = z.object({ + api_key: z.string(), +}); + +export default function SendgridSetup() { + const { t } = useLocale(); + const router = useRouter(); + const [testPassed, setTestPassed] = useState(undefined); + const [testLoading, setTestLoading] = useState(false); + + const form = useForm<{ + api_key: string; + }>({ + resolver: zodResolver(formSchema), + }); + + useEffect(() => { + const timer = setTimeout(() => { + if (testPassed === false) { + setTestPassed(undefined); + } + }, 3000); + return () => clearTimeout(timer); + }, [testPassed]); + + return ( +
+
+
+
+ Sendgrid +
+
+

{t("provide_api_key")}

+ +
+ {t("generate_api_key_description")}{" "} + + Sendgrid + + . {t("it_stored_encrypted")} +
+
+
{ + const res = await fetch("/api/integrations/sendgrid/add", { + method: "POST", + body: JSON.stringify(values), + headers: { + "Content-Type": "application/json", + }, + }); + const json = await res.json(); + + if (res.ok) { + router.push(json.url); + } else { + showToast(json.message, "error"); + } + }}> +
+ ( + { + onChange(e.target.value); + form.setValue("api_key", e.target.value); + await form.trigger("api_key"); + }} + /> + )} + /> +
+
+ + + +
+
+
+
+
+
+ +
+ ); +} diff --git a/packages/app-store/sendgrid/static/1.png b/packages/app-store/sendgrid/static/1.png new file mode 100644 index 0000000000..ead1889ab2 Binary files /dev/null and b/packages/app-store/sendgrid/static/1.png differ diff --git a/packages/app-store/sendgrid/static/logo.png b/packages/app-store/sendgrid/static/logo.png new file mode 100644 index 0000000000..f99bf85fe6 Binary files /dev/null and b/packages/app-store/sendgrid/static/logo.png differ diff --git a/packages/app-store/zapier/pages/setup/index.tsx b/packages/app-store/zapier/pages/setup/index.tsx index ce79c651ec..5501bfcb95 100644 --- a/packages/app-store/zapier/pages/setup/index.tsx +++ b/packages/app-store/zapier/pages/setup/index.tsx @@ -105,7 +105,7 @@ export default function ZapierSetup(props: IZapierSetupProps) {
  • You're set!
  • - + diff --git a/packages/lib/Sendgrid.ts b/packages/lib/Sendgrid.ts new file mode 100644 index 0000000000..bf9fe90809 --- /dev/null +++ b/packages/lib/Sendgrid.ts @@ -0,0 +1,129 @@ +import client from "@sendgrid/client"; +import { ClientRequest } from "@sendgrid/client/src/request"; +import { ClientResponse } from "@sendgrid/client/src/response"; + +import logger from "@calcom/lib/logger"; + +export type SendgridFieldOptions = [string, string][]; + +type SendgridUsernameResult = { + username: string; + user_id: number; +}; + +export type SendgridCustomField = { + id: string; + name: string; + field_type: string; + _metadata: { + self: string; + }; +}; + +export type SendgridContact = { + id: string; + first_name: string; + last_name: string; + email: string; +}; + +export type SendgridSearchResult = { + result: SendgridContact[]; +}; + +export type SendgridFieldDefinitions = { + custom_fields: SendgridCustomField[]; +}; + +export type SendgridNewContact = { + job_id: string; +}; + +const environmentApiKey = process.env.SENDGRID_SYNC_API_KEY || ""; + +/** + * This class to instance communicating to Sendgrid APIs requires an API Key. + * + * You can either pass to the constructor an API Key or have one defined as an + * environment variable in case the communication to Sendgrid is just for + * one account only, not configurable by any user at any moment. + */ +export default class Sendgrid { + private log: typeof logger; + + constructor(providedApiKey = "") { + this.log = logger.getChildLogger({ prefix: [`[[lib] sendgrid`] }); + if (!providedApiKey && !environmentApiKey) throw Error("Sendgrid Api Key not present"); + client.setApiKey(providedApiKey || environmentApiKey); + } + + public username = async () => { + const username = await this.sendgridRequest({ + url: `/v3/user/username`, + method: "GET", + }); + return username; + }; + + public async sendgridRequest(data: ClientRequest): Promise { + this.log.debug("sendgridRequest:request", data); + const results = await client.request(data); + this.log.debug("sendgridRequest:results", results); + if (results[1].errors) throw Error(`Sendgrid request error: ${results[1].errors}`); + return results[1]; + } + + public async getSendgridContactId(email: string) { + const search = await this.sendgridRequest({ + url: `/v3/marketing/contacts/search`, + method: "POST", + body: { + query: `email LIKE '${email}'`, + }, + }); + this.log.debug("sync:sendgrid:getSendgridContactId:search", search); + return search.result || []; + } + + public async getSendgridCustomFieldsIds(customFields: SendgridFieldOptions) { + // Get Custom Activity Fields + const allFields = await this.sendgridRequest({ + url: `/v3/marketing/field_definitions`, + method: "GET", + }); + allFields.custom_fields = allFields.custom_fields ?? []; + this.log.debug("sync:sendgrid:getCustomFieldsIds:allFields", allFields); + const customFieldsNames = allFields.custom_fields.map((fie) => fie.name); + this.log.debug("sync:sendgrid:getCustomFieldsIds:customFieldsNames", customFieldsNames); + const customFieldsExist = customFields.map((cusFie) => customFieldsNames.includes(cusFie[0])); + this.log.debug("sync:sendgrid:getCustomFieldsIds:customFieldsExist", customFieldsExist); + return await Promise.all( + customFieldsExist.map(async (exist, idx) => { + if (!exist) { + const [name, field_type] = customFields[idx]; + const created = await this.sendgridRequest({ + url: `/v3/marketing/field_definitions`, + method: "POST", + body: { + name, + field_type, + }, + }); + this.log.debug("sync:sendgrid:getCustomFieldsIds:customField:created", created); + return created.id; + } else { + const index = customFieldsNames.findIndex((val) => val === customFields[idx][0]); + if (index >= 0) { + this.log.debug( + "sync:sendgrid:getCustomFieldsIds:customField:existed", + allFields.custom_fields[index].id + ); + return allFields.custom_fields[index].id; + } else { + throw Error("Couldn't find the field index"); + } + } + }) + ); + } +} diff --git a/packages/lib/server/defaultHandler.ts b/packages/lib/server/defaultHandler.ts index ded7ad9956..1cfa351bd7 100644 --- a/packages/lib/server/defaultHandler.ts +++ b/packages/lib/server/defaultHandler.ts @@ -5,7 +5,7 @@ type Handlers = { }; /** Allows us to split big API handlers by method */ -const defaultHandler = (handlers: Handlers) => async (req: NextApiRequest, res: NextApiResponse) => { +export const defaultHandler = (handlers: Handlers) => async (req: NextApiRequest, res: NextApiResponse) => { const handler = (await handlers[req.method as keyof typeof handlers])?.default; // auto catch unsupported methods. if (!handler) { @@ -22,5 +22,3 @@ const defaultHandler = (handlers: Handlers) => async (req: NextApiRequest, res: return res.status(500).json({ message: "Something went wrong" }); } }; - -export default defaultHandler; diff --git a/packages/lib/server/defaultResponder.ts b/packages/lib/server/defaultResponder.ts index 4a24bc350b..7caf1e1b51 100644 --- a/packages/lib/server/defaultResponder.ts +++ b/packages/lib/server/defaultResponder.ts @@ -6,7 +6,7 @@ import { performance } from "./perfObserver"; type Handle = (req: NextApiRequest, res: NextApiResponse) => Promise; /** Allows us to get type inference from API handler responses */ -function defaultResponder(f: Handle) { +export function defaultResponder(f: Handle) { return async (req: NextApiRequest, res: NextApiResponse) => { let ok = false; try { @@ -25,5 +25,3 @@ function defaultResponder(f: Handle) { } }; } - -export default defaultResponder; diff --git a/packages/lib/server/index.ts b/packages/lib/server/index.ts index 64f13128d3..53253fe2c4 100644 --- a/packages/lib/server/index.ts +++ b/packages/lib/server/index.ts @@ -1,7 +1,7 @@ export { checkBookingLimits, checkLimit } from "./checkBookingLimits"; -export { default as defaultHandler } from "./defaultHandler"; -export { default as defaultResponder } from "./defaultResponder"; +export { defaultHandler } from "./defaultHandler"; +export { defaultResponder } from "./defaultResponder"; export { getLuckyUser } from "./getLuckyUser"; export { getServerErrorFromUnknown } from "./getServerErrorFromUnknown"; export { getTranslation } from "./i18n"; diff --git a/packages/lib/sync/services/CloseComService.ts b/packages/lib/sync/services/CloseComService.ts index bece375096..8acbd3e6c5 100644 --- a/packages/lib/sync/services/CloseComService.ts +++ b/packages/lib/sync/services/CloseComService.ts @@ -20,6 +20,8 @@ const calComSharedFields: CloseComFieldOptions = [["Contact Role", "text", false const serviceName = "closecom_service"; export default class CloseComService extends SyncServiceCore implements ISyncService { + protected declare service: CloseCom; + constructor() { super(serviceName, CloseCom, logger.getChildLogger({ prefix: [`[[sync] ${serviceName}`] })); } @@ -120,7 +122,7 @@ export default class CloseComService extends SyncServiceCore implements ISyncSer this.log.debug("sync:closecom:web:team:update", { prevTeam, updatedTeam }); const leadId = await getCloseComLeadId(this.service, { companyName: prevTeam.name }); this.log.debug("sync:closecom:web:team:update:leadId", { leadId }); - this.service.lead.update(leadId, updatedTeam); + this.service.lead.update(leadId, { companyName: updatedTeam.name }); }, }, membership: { diff --git a/packages/lib/sync/services/SendgridService.ts b/packages/lib/sync/services/SendgridService.ts index d2e679ede9..cfc26e460a 100644 --- a/packages/lib/sync/services/SendgridService.ts +++ b/packages/lib/sync/services/SendgridService.ts @@ -1,41 +1,11 @@ -import sendgrid from "@sendgrid/client"; -import { ClientRequest } from "@sendgrid/client/src/request"; -import { ClientResponse } from "@sendgrid/client/src/response"; - import logger from "@calcom/lib/logger"; -import ISyncService, { ConsoleUserInfoType, WebUserInfoType } from "@calcom/lib/sync/ISyncService"; -import SyncServiceCore from "@calcom/lib/sync/ISyncService"; -type SendgridCustomField = { - id: string; - name: string; - field_type: string; - _metadata: { - self: string; - }; -}; - -type SendgridContact = { - id: string; - first_name: string; - last_name: string; - email: string; -}; - -type SendgridSearchResult = { - result: SendgridContact[]; -}; - -type SendgridFieldDefinitions = { - custom_fields: SendgridCustomField[]; -}; - -type SendgridNewContact = { - job_id: string; -}; +import Sendgrid, { SendgridFieldOptions, SendgridNewContact } from "../../Sendgrid"; +import ISyncService, { ConsoleUserInfoType, WebUserInfoType } from "../ISyncService"; +import SyncServiceCore from "../ISyncService"; // Cal.com Custom Contact Fields -const calComCustomContactFields: [string, string][] = [ +const calComCustomContactFields: SendgridFieldOptions = [ // Field name, field type ["username", "Text"], ["plan", "Text"], @@ -43,92 +13,18 @@ const calComCustomContactFields: [string, string][] = [ ["createdAt", "Date"], ]; -type SendgridRequest = (data: ClientRequest) => Promise; - -// TODO: When creating Sendgrid app, move this to the corresponding file -class Sendgrid { - constructor() { - if (!process.env.SENDGRID_API_KEY) throw Error("Sendgrid Api Key not present"); - sendgrid.setApiKey(process.env.SENDGRID_API_KEY); - return sendgrid; - } -} - const serviceName = "sendgrid_service"; export default class SendgridService extends SyncServiceCore implements ISyncService { + protected declare service: Sendgrid; constructor() { super(serviceName, Sendgrid, logger.getChildLogger({ prefix: [`[[sync] ${serviceName}`] })); } - sendgridRequest: SendgridRequest = async (data: ClientRequest) => { - this.log.debug("sendgridRequest:request", data); - const results = await this.service.request(data); - this.log.debug("sendgridRequest:results", results); - if (results[1].errors) throw Error(`Sendgrid request error: ${results[1].errors}`); - return results[1]; - }; - - getSendgridContactId = async (email: string) => { - const search = await this.sendgridRequest({ - url: `/v3/marketing/contacts/search`, - method: "POST", - body: { - query: `email LIKE '${email}'`, - }, - }); - this.log.debug("sync:sendgrid:getSendgridContactId:search", search); - return search.result || []; - }; - - getSendgridCustomFieldsIds = async () => { - // Get Custom Activity Fields - const allFields = await this.sendgridRequest({ - url: `/v3/marketing/field_definitions`, - method: "GET", - }); - allFields.custom_fields = allFields.custom_fields ?? []; - this.log.debug("sync:sendgrid:getCustomFieldsIds:allFields", allFields); - const customFieldsNames = allFields.custom_fields.map((fie) => fie.name); - this.log.debug("sync:sendgrid:getCustomFieldsIds:customFieldsNames", customFieldsNames); - const customFieldsExist = calComCustomContactFields.map((cusFie) => - customFieldsNames.includes(cusFie[0]) - ); - this.log.debug("sync:sendgrid:getCustomFieldsIds:customFieldsExist", customFieldsExist); - return await Promise.all( - customFieldsExist.map(async (exist, idx) => { - if (!exist) { - const [name, field_type] = calComCustomContactFields[idx]; - const created = await this.sendgridRequest({ - url: `/v3/marketing/field_definitions`, - method: "POST", - body: { - name, - field_type, - }, - }); - this.log.debug("sync:sendgrid:getCustomFieldsIds:customField:created", created); - return created.id; - } else { - const index = customFieldsNames.findIndex((val) => val === calComCustomContactFields[idx][0]); - if (index >= 0) { - this.log.debug( - "sync:sendgrid:getCustomFieldsIds:customField:existed", - allFields.custom_fields[index].id - ); - return allFields.custom_fields[index].id; - } else { - throw Error("Couldn't find the field index"); - } - } - }) - ); - }; - upsert = async (user: WebUserInfoType | ConsoleUserInfoType) => { this.log.debug("sync:sendgrid:user", user); // Get Custom Contact fields ids - const customFieldsIds = await this.getSendgridCustomFieldsIds(); + const customFieldsIds = await this.service.getSendgridCustomFieldsIds(calComCustomContactFields); this.log.debug("sync:sendgrid:user:customFieldsIds", customFieldsIds); const lastBooking = "email" in user ? await this.getUserLastBooking(user) : null; this.log.debug("sync:sendgrid:user:lastBooking", lastBooking); @@ -159,7 +55,7 @@ export default class SendgridService extends SyncServiceCore implements ISyncSer ), }; this.log.debug("sync:sendgrid:contact:contactData", contactData); - const newContact = await this.sendgridRequest({ + const newContact = await this.service.sendgridRequest({ url: `/v3/marketing/contacts`, method: "PUT", body: { @@ -185,9 +81,9 @@ export default class SendgridService extends SyncServiceCore implements ISyncSer return this.upsert(webUser); }, delete: async (webUser: WebUserInfoType) => { - const [contactId] = await this.getSendgridContactId(webUser.email); + const [contactId] = await this.service.getSendgridContactId(webUser.email); if (contactId) { - return this.sendgridRequest({ + return this.service.sendgridRequest({ url: `/v3/marketing/contacts`, method: "DELETE", qs: { diff --git a/packages/prisma/seed-app-store.config.json b/packages/prisma/seed-app-store.config.json index 4b468c0f02..0c8594a081 100644 --- a/packages/prisma/seed-app-store.config.json +++ b/packages/prisma/seed-app-store.config.json @@ -101,5 +101,11 @@ "categories": ["video"], "slug": "sirius_video", "type": "sirius_video_video" + }, + { + "dirName": "sendgrid", + "categories": ["other"], + "slug": "sendgrid", + "type": "sendgrid_other_calendar" } ] diff --git a/packages/ui/v2/core/apps/AppCard.tsx b/packages/ui/v2/core/apps/AppCard.tsx index 62151fd8dc..9f4986271f 100644 --- a/packages/ui/v2/core/apps/AppCard.tsx +++ b/packages/ui/v2/core/apps/AppCard.tsx @@ -18,9 +18,10 @@ export default function AppCard({ app, credentials }: AppCardProps) { const { t } = useLocale(); const router = useRouter(); const mutation = useAddAppMutation(null, { - onSuccess: () => { + onSuccess: (data) => { // Refresh SSR page content without actual reload router.replace(router.asPath); + if (data.setupPending) return; showToast(t("app_successfully_installed"), "success"); }, onError: (error) => { diff --git a/turbo.json b/turbo.json index eb7071d799..2a23beb910 100644 --- a/turbo.json +++ b/turbo.json @@ -182,6 +182,7 @@ "$CI", "$CLOSECOM_API_KEY", "$SENDGRID_API_KEY", + "$SENDGRID_SYNC_API_KEY", "$SENDGRID_EMAIL", "$TWILIO_TOKEN", "$TWILIO_SID",