diff --git a/.env.example b/.env.example index b903a4add6..331139a58d 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= @@ -135,6 +135,9 @@ NEXT_PUBLIC_TEAM_IMPERSONATION=false # Close.com internal CRM CLOSECOM_API_KEY= +# Sendgrid internal sync service +SENDGRID_SYNC_API_KEY= + # Sendgrid internal email sender SENDGRID_API_KEY= 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/pages/apps/installed/[category].tsx b/apps/web/pages/apps/installed/[category].tsx index ee8209d379..fe52ec2be0 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..871ec10776 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("../../sendgridothercalendar/pages/setup")), }; export const AppSetupPage = (props: { slug: string }) => { diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index 5045918ebe..f172fb087b 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -30,6 +30,7 @@ import { metadata as qr_code_meta } from "./qr_code/_metadata"; import { metadata as rainbow_meta } from "./rainbow/_metadata"; import { metadata as raycast_meta } from "./raycast/_metadata"; import { metadata as riverside_meta } from "./riverside/_metadata"; +import { metadata as sendgridothercalendar_meta } from "./sendgridothercalendar/_metadata"; import { metadata as sirius_video_meta } from "./sirius_video/_metadata"; import { metadata as slackmessaging_meta } from "./slackmessaging/_metadata"; import { metadata as stripepayment_meta } from "./stripepayment/_metadata"; @@ -71,6 +72,7 @@ export const appStoreMetadata = { rainbow: rainbow_meta, raycast: raycast_meta, riverside: riverside_meta, + sendgridothercalendar: sendgridothercalendar_meta, sirius_video: sirius_video_meta, slackmessaging: slackmessaging_meta, stripepayment: stripepayment_meta, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index 88c88c9ad2..c2fb05d871 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -29,6 +29,7 @@ export const apiHandlers = { rainbow: import("./rainbow/api"), raycast: import("./raycast/api"), riverside: import("./riverside/api"), + sendgridothercalendar: import("./sendgridothercalendar/api"), sirius_video: import("./sirius_video/api"), slackmessaging: import("./slackmessaging/api"), stripepayment: import("./stripepayment/api"), diff --git a/packages/app-store/closecomothercalendar/lib/CalendarService.ts b/packages/app-store/closecomothercalendar/lib/CalendarService.ts index 276cb94026..3837fb55fc 100644 --- a/packages/app-store/closecomothercalendar/lib/CalendarService.ts +++ b/packages/app-store/closecomothercalendar/lib/CalendarService.ts @@ -1,7 +1,6 @@ import z from "zod"; import CloseCom, { CloseComFieldOptions } from "@calcom/lib/CloseCom"; -import { getCustomActivityTypeInstanceData } from "@calcom/lib/CloseComeUtils"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import logger from "@calcom/lib/logger"; import type { @@ -74,10 +73,9 @@ export default class CloseComCalendarService implements Calendar { } closeComUpdateCustomActivity = async (uid: string, event: CalendarEvent) => { - const customActivityTypeInstanceData = await getCustomActivityTypeInstanceData( + const customActivityTypeInstanceData = await this.closeCom.getCustomActivityTypeInstanceData( event, - calComCustomActivityFields, - this.closeCom + calComCustomActivityFields ); // Create Custom Activity type instance const customActivityTypeInstance = await this.closeCom.activity.custom.create( @@ -91,10 +89,9 @@ export default class CloseComCalendarService implements Calendar { }; async createEvent(event: CalendarEvent): Promise { - const customActivityTypeInstanceData = await getCustomActivityTypeInstanceData( + const customActivityTypeInstanceData = await this.closeCom.getCustomActivityTypeInstanceData( event, - calComCustomActivityFields, - this.closeCom + calComCustomActivityFields ); // Create Custom Activity type instance const customActivityTypeInstance = await this.closeCom.activity.custom.create( diff --git a/packages/app-store/closecomothercalendar/test/globals.ts b/packages/app-store/closecomothercalendar/test/globals.ts index ecff2dea7a..ada50ba128 100644 --- a/packages/app-store/closecomothercalendar/test/globals.ts +++ b/packages/app-store/closecomothercalendar/test/globals.ts @@ -1,8 +1,11 @@ jest.mock("@calcom/lib/logger", () => ({ - 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..9218754d38 100644 --- a/packages/app-store/closecomothercalendar/test/lib/CalendarService.test.ts +++ b/packages/app-store/closecomothercalendar/test/lib/CalendarService.test.ts @@ -1,20 +1,6 @@ import CloseCom from "@calcom/lib/CloseCom"; -import { - getCloseComContactIds, - getCustomActivityTypeInstanceData, - getCloseComCustomActivityTypeFieldsIds, - getCloseComLeadId, -} from "@calcom/lib/CloseComeUtils"; import type { CalendarEvent } from "@calcom/types/Calendar"; -jest.mock("@calcom/lib/CloseCom", () => ({ - default: class { - constructor() { - /* Mock */ - } - }, -})); - afterEach(() => { jest.resetAllMocks(); }); @@ -26,9 +12,8 @@ test("check generic lead generator: already exists", async () => { data: [{ name: "From Cal.com", id: "abc" }], }), } as any; - - const closeCom = new CloseCom("someKey"); - const id = await getCloseComLeadId(closeCom); + debugger; + const id = await CloseCom.prototype.getCloseComLeadId(); expect(id).toEqual("abc"); }); @@ -41,8 +26,7 @@ test("check generic lead generator: doesn't exist", async () => { create: () => ({ id: "def" }), } as any; - const closeCom = new CloseCom("someKey"); - const id = await getCloseComLeadId(closeCom); + const id = await CloseCom.prototype.getCloseComLeadId(); expect(id).toEqual("def"); }); @@ -61,8 +45,7 @@ test("retrieve contact IDs: all exist", async () => { search: () => ({ data: attendees }), } as any; - const closeCom = new CloseCom("someKey"); - const contactIds = await getCloseComContactIds(event.attendees, closeCom, "leadId"); + const contactIds = await CloseCom.prototype.getCloseComContactIds(event.attendees, "leadId"); expect(contactIds).toEqual(["test1", "test2"]); }); @@ -79,8 +62,7 @@ test("retrieve contact IDs: some don't exist", async () => { create: () => ({ id: "test3" }), } as any; - const closeCom = new CloseCom("someKey"); - const contactIds = await getCloseComContactIds(event.attendees, closeCom, "leadId"); + const contactIds = await CloseCom.prototype.getCloseComContactIds(event.attendees, "leadId"); expect(contactIds).toEqual(["test1", "test3"]); }); @@ -105,15 +87,11 @@ test("retrieve custom fields for custom activity type: type doesn't exist, no fi }, } as any; - const closeCom = new CloseCom("someKey"); - const contactIds = await getCloseComCustomActivityTypeFieldsIds( - [ - ["Attendees", "", true, true], - ["Date & Time", "", true, true], - ["Time Zone", "", true, true], - ], - closeCom - ); + const contactIds = await CloseCom.prototype.getCloseComCustomActivityTypeFieldsIds([ + ["Attendees", "", true, true], + ["Date & Time", "", true, true], + ["Time Zone", "", true, true], + ]); expect(contactIds).toEqual({ activityType: "type1", fields: ["field9A", "field11D", "field9T"], @@ -141,15 +119,11 @@ test("retrieve custom fields for custom activity type: type exists, no field cre }, } as any; - const closeCom = new CloseCom("someKey"); - const contactIds = await getCloseComCustomActivityTypeFieldsIds( - [ - ["Attendees", "", true, true], - ["Date & Time", "", true, true], - ["Time Zone", "", true, true], - ], - closeCom - ); + const contactIds = await CloseCom.prototype.getCloseComCustomActivityTypeFieldsIds([ + ["Attendees", "", true, true], + ["Date & Time", "", true, true], + ["Time Zone", "", true, true], + ]); expect(contactIds).toEqual({ activityType: "typeX", fields: ["fieldY", "field11D", "field9T"], @@ -168,7 +142,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: { @@ -196,16 +170,11 @@ test("prepare data to create custom activity type instance: two attendees, no ad create: () => ({ id: "def" }), } as any; - const closeCom = new CloseCom("someKey"); - const data = await getCustomActivityTypeInstanceData( - event, - [ - ["Attendees", "", true, true], - ["Date & Time", "", true, true], - ["Time Zone", "", true, true], - ], - closeCom - ); + const data = await CloseCom.prototype.getCustomActivityTypeInstanceData(event, [ + ["Attendees", "", true, true], + ["Date & Time", "", true, true], + ["Time Zone", "", true, true], + ]); expect(data).toEqual({ custom_activity_type_id: "type1", lead_id: "def", @@ -252,16 +221,11 @@ test("prepare data to create custom activity type instance: one attendees, with }), } as any; - const closeCom = new CloseCom("someKey"); - const data = await getCustomActivityTypeInstanceData( - event, - [ - ["Attendees", "", true, true], - ["Date & Time", "", true, true], - ["Time Zone", "", true, true], - ], - closeCom - ); + const data = await CloseCom.prototype.getCustomActivityTypeInstanceData(event, [ + ["Attendees", "", true, true], + ["Date & Time", "", true, true], + ["Time Zone", "", true, true], + ]); expect(data).toEqual({ custom_activity_type_id: "type1", lead_id: "abc", diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index 8a2ad3de7f..15846b239c 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 sendgridothercalendar from "./sendgridothercalendar"; import * as slackmessaging from "./slackmessaging"; import * as stripepayment from "./stripepayment"; import * as tandemvideo from "./tandemvideo"; @@ -37,6 +38,7 @@ const appStore = { larkcalendar, office365calendar, office365video, + sendgridothercalendar, slackmessaging, stripepayment, tandemvideo, diff --git a/packages/app-store/sendgridothercalendar/README.mdx b/packages/app-store/sendgridothercalendar/README.mdx new file mode 100644 index 0000000000..a910afaf9f --- /dev/null +++ b/packages/app-store/sendgridothercalendar/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/sendgridothercalendar/1.png +--- + +{description} + +Features: + - Creates event attendees as contacts in Sendgrid diff --git a/packages/app-store/sendgridothercalendar/_metadata.ts b/packages/app-store/sendgridothercalendar/_metadata.ts new file mode 100644 index 0000000000..9c7f2aa320 --- /dev/null +++ b/packages/app-store/sendgridothercalendar/_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/sendgridothercalendar/api/_getAdd.ts b/packages/app-store/sendgridothercalendar/api/_getAdd.ts new file mode 100644 index 0000000000..6f16a414db --- /dev/null +++ b/packages/app-store/sendgridothercalendar/api/_getAdd.ts @@ -0,0 +1,10 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import checkSession from "../../_utils/auth"; +import { checkInstalled } from "../../_utils/installation"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const session = checkSession(req); + await checkInstalled("sendgrid", session.user?.id); + return res.status(200).json({ url: "/apps/sendgrid/setup" }); +} diff --git a/packages/app-store/sendgridothercalendar/api/_postAdd.ts b/packages/app-store/sendgridothercalendar/api/_postAdd.ts new file mode 100644 index 0000000000..1e1f95e46c --- /dev/null +++ b/packages/app-store/sendgridothercalendar/api/_postAdd.ts @@ -0,0 +1,39 @@ +import type { NextApiRequest, NextApiResponse } 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, res: NextApiResponse) { + 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); + return res.status(500).json({ message: "Could not add Sendgrid app" }); + } + + return res.status(200).json({ url: getInstalledAppPath({ variant: "other", slug: "sendgrid" }) }); +} + +export default defaultResponder(getHandler); diff --git a/packages/app-store/sendgridothercalendar/api/_postCheck.ts b/packages/app-store/sendgridothercalendar/api/_postCheck.ts new file mode 100644 index 0000000000..c71bbc7e05 --- /dev/null +++ b/packages/app-store/sendgridothercalendar/api/_postCheck.ts @@ -0,0 +1,29 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import Sendgrid from "@calcom/lib/Sendgrid"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; + +import checkSession from "../../_utils/auth"; + +export async function getHandler(req: NextApiRequest, res: NextApiResponse) { + 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 res.status(200).end(); + } else { + return res.status(404).end(); + } + } catch (e) { + return res.status(500).json({ message: e }); + } +} + +export default defaultResponder(getHandler); diff --git a/packages/app-store/sendgridothercalendar/api/add.ts b/packages/app-store/sendgridothercalendar/api/add.ts new file mode 100644 index 0000000000..9480fb9259 --- /dev/null +++ b/packages/app-store/sendgridothercalendar/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/sendgridothercalendar/api/check.ts b/packages/app-store/sendgridothercalendar/api/check.ts new file mode 100644 index 0000000000..97ed132226 --- /dev/null +++ b/packages/app-store/sendgridothercalendar/api/check.ts @@ -0,0 +1,5 @@ +import { defaultHandler } from "@calcom/lib/server"; + +export default defaultHandler({ + POST: import("./_postCheck"), +}); diff --git a/packages/app-store/sendgridothercalendar/api/index.ts b/packages/app-store/sendgridothercalendar/api/index.ts new file mode 100644 index 0000000000..766afa6405 --- /dev/null +++ b/packages/app-store/sendgridothercalendar/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/sendgridothercalendar/components/.gitkeep b/packages/app-store/sendgridothercalendar/components/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/app-store/sendgridothercalendar/config.json b/packages/app-store/sendgridothercalendar/config.json new file mode 100644 index 0000000000..ea07d7aa1d --- /dev/null +++ b/packages/app-store/sendgridothercalendar/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/sendgridothercalendar/icon.png", + "logo": "/api/app-store/sendgridothercalendar/icon.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/sendgridothercalendar/index.ts b/packages/app-store/sendgridothercalendar/index.ts new file mode 100644 index 0000000000..5373eb04ef --- /dev/null +++ b/packages/app-store/sendgridothercalendar/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/sendgridothercalendar/lib/CalendarService.ts b/packages/app-store/sendgridothercalendar/lib/CalendarService.ts new file mode 100644 index 0000000000..17e69a623b --- /dev/null +++ b/packages/app-store/sendgridothercalendar/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/sendgridothercalendar/lib/index.ts b/packages/app-store/sendgridothercalendar/lib/index.ts new file mode 100644 index 0000000000..e168c149df --- /dev/null +++ b/packages/app-store/sendgridothercalendar/lib/index.ts @@ -0,0 +1 @@ +export { default as CalendarService } from "./CalendarService"; diff --git a/packages/app-store/sendgridothercalendar/package.json b/packages/app-store/sendgridothercalendar/package.json new file mode 100644 index 0000000000..a6b72ebee1 --- /dev/null +++ b/packages/app-store/sendgridothercalendar/package.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/sendgridothercalendar", + "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/sendgridothercalendar/pages/setup/index.tsx b/packages/app-store/sendgridothercalendar/pages/setup/index.tsx new file mode 100644 index 0000000000..607af58c46 --- /dev/null +++ b/packages/app-store/sendgridothercalendar/pages/setup/index.tsx @@ -0,0 +1,157 @@ +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 { Button } from "@calcom/ui"; +import { Icon } from "@calcom/ui/Icon"; +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 ( +
+
+
+
+ {/* eslint-disable @next/next/no-img-element */} + Apple Calendar +
+
+

{t("provide_api_key")}

+ +
+ {t("generate_api_key_description")}{" "} + + Sendgrid + + . {t("it_stored_encrypted")} +
+
+
{ + const res = await fetch("/api/integrations/sendgridothercalendar/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/sendgridothercalendar/static/1.png b/packages/app-store/sendgridothercalendar/static/1.png new file mode 100644 index 0000000000..ead1889ab2 Binary files /dev/null and b/packages/app-store/sendgridothercalendar/static/1.png differ diff --git a/packages/app-store/sendgridothercalendar/static/logo.png b/packages/app-store/sendgridothercalendar/static/logo.png new file mode 100644 index 0000000000..f99bf85fe6 Binary files /dev/null and b/packages/app-store/sendgridothercalendar/static/logo.png differ diff --git a/packages/lib/CloseCom.ts b/packages/lib/CloseCom.ts index 4b0517d0e2..2bc7269f27 100644 --- a/packages/lib/CloseCom.ts +++ b/packages/lib/CloseCom.ts @@ -1,4 +1,5 @@ import logger from "@calcom/lib/logger"; +import { CalendarEvent } from "@calcom/types/Calendar"; export type CloseComLead = { companyName?: string | null | undefined; @@ -299,6 +300,7 @@ export default class CloseCom { private _delete = async ({ urlPath }: { urlPath: string }) => { return this._request({ urlPath, method: "delete" }); }; + private _request = async ({ urlPath, data, @@ -330,6 +332,198 @@ export default class CloseCom { return await response.json(); }); }; + + public async getCloseComContactIds( + persons: { email: string; name: string | null }[], + leadFromCalComId?: string + ): Promise { + // Check if persons exist or to see if any should be created + const closeComContacts = await this.contact.search({ + emails: persons.map((att) => att.email), + }); + // NOTE: If contact is duplicated in Close.com we will get more results + // messing around with the expected number of contacts retrieved + if (closeComContacts.data.length < persons.length && leadFromCalComId) { + // Create missing contacts + const personsEmails = persons.map((att) => att.email); + // Existing contacts based on persons emails: contacts may have more + // than one email, we just need the one used by the event. + const existingContactsEmails = closeComContacts.data.flatMap((cont) => + cont.emails.filter((em) => personsEmails.includes(em.email)).map((ems) => ems.email) + ); + const nonExistingContacts = persons.filter((person) => !existingContactsEmails.includes(person.email)); + const createdContacts = await Promise.all( + nonExistingContacts.map( + async (per) => + await this.contact.create({ + person: per, + leadId: leadFromCalComId, + }) + ) + ); + if (createdContacts.length === nonExistingContacts.length) { + // All non existent contacts where created + return Promise.resolve( + closeComContacts.data.map((cont) => cont.id).concat(createdContacts.map((cont) => cont.id)) + ); + } else { + return Promise.reject("Some contacts were not possible to create in Close.com"); + } + } else { + return Promise.resolve(closeComContacts.data.map((cont) => cont.id)); + } + } + + public async getCustomActivityTypeInstanceData( + event: CalendarEvent, + customFields: CloseComFieldOptions + ): Promise { + // Get Cal.com generic Lead + const leadFromCalComId = await this.getCloseComLeadId(); + // Get Contacts ids + const contactsIds = await this.getCloseComContactIds(event.attendees, leadFromCalComId); + // Get Custom Activity Type id + const customActivityTypeAndFieldsIds = await this.getCloseComCustomActivityTypeFieldsIds(customFields); + // Prepare values for each Custom Activity Fields + const customActivityFieldsValues = [ + contactsIds.length > 1 ? contactsIds.slice(1) : null, // Attendee + event.startTime, // Date & Time + event.attendees[0].timeZone, // Time Zone + contactsIds[0], // Organizer + event.additionalNotes ?? null, // Additional Notes + ]; + // Preparing Custom Activity Instance data for Close.com + return Object.assign( + {}, + { + custom_activity_type_id: customActivityTypeAndFieldsIds.activityType, + lead_id: leadFromCalComId, + }, // This is to add each field as `"custom.FIELD_ID": "value"` in the object + ...customActivityTypeAndFieldsIds.fields.map((fieldId: string, index: number) => { + return { + [`custom.${fieldId}`]: customActivityFieldsValues[index], + }; + }) + ); + } + + public async getCustomFieldsIds( + entity: keyof CloseCom["customField"], + customFields: CloseComFieldOptions, + custom_activity_type_id?: string + ): Promise { + // Get Custom Activity Fields + const allFields: CloseComCustomActivityFieldGet | CloseComCustomContactFieldGet = await this.customField[ + entity + ].get({ + query: { _fields: ["name", "id"].concat(entity === "activity" ? ["custom_activity_type_id"] : []) }, + }); + let relevantFields: { [key: string]: any }[]; + if (entity === "activity") { + relevantFields = (allFields as CloseComCustomActivityFieldGet).data.filter( + (fie) => fie.custom_activity_type_id === custom_activity_type_id + ); + } else { + relevantFields = allFields.data as CloseComCustomActivityFieldGet["data"]; + } + const customFieldsNames = relevantFields.map((fie) => fie.name); + const customFieldsExist = customFields.map((cusFie) => customFieldsNames.includes(cusFie[0])); + return await Promise.all( + customFieldsExist.map(async (exist, idx) => { + if (!exist && entity !== "shared") { + const [name, type, required, multiple] = customFields[idx]; + let created: { [key: string]: any }; + if (entity === "activity" && custom_activity_type_id) { + created = await this.customField[entity].create({ + name, + type, + required, + accepts_multiple_values: multiple, + editable_with_roles: [], + custom_activity_type_id, + }); + return created.id; + } else { + if (entity === "contact") { + created = await this.customField[entity].create({ + name, + type, + required, + accepts_multiple_values: multiple, + editable_with_roles: [], + }); + return created.id; + } + } + } else { + const index = customFieldsNames.findIndex((val) => val === customFields[idx][0]); + if (index >= 0) { + return relevantFields[index].id; + } else { + throw Error("Couldn't find the field index"); + } + } + }) + ); + } + + public async getCloseComCustomActivityTypeFieldsIds(customFields: CloseComFieldOptions) { + // Check if Custom Activity Type exists + const customActivities = await this.customActivity.type.get(); + const calComCustomActivity = customActivities.data.filter((act) => act.name === "Cal.com Activity"); + if (calComCustomActivity.length > 0) { + // Cal.com Custom Activity type exist + // Get Custom Activity Type fields ids + const fields = await this.getCustomFieldsIds("activity", customFields, calComCustomActivity[0].id); + return { + activityType: calComCustomActivity[0].id, + fields, + }; + } else { + // Cal.com Custom Activity type doesn't exist + // Create Custom Activity Type + const { id: activityType } = await this.customActivity.type.create({ + name: "Cal.com Activity", + description: "Bookings in your Cal.com account", + }); + // Create Custom Activity Fields + const fields = await Promise.all( + customFields.map(async ([name, type, required, multiple]) => { + const creation = await this.customField.activity.create({ + custom_activity_type_id: activityType, + name, + type, + required, + accepts_multiple_values: multiple, + editable_with_roles: [], + }); + return creation.id; + }) + ); + return { + activityType, + fields, + }; + } + } + + public async getCloseComLeadId( + leadInfo: CloseComLead = { + companyName: "From Cal.com", + description: "Generic Lead for Contacts created by Cal.com", + } + ): Promise { + debugger; + const closeComLeadNames = await this.lead.list({ query: { _fields: ["name", "id"] } }); + const searchLeadFromCalCom = closeComLeadNames.data.filter((lead) => lead.name === leadInfo.companyName); + if (searchLeadFromCalCom.length === 0) { + // No Lead exists, create it + const createdLeadFromCalCom = await this.lead.create(leadInfo); + return createdLeadFromCalCom.id; + } else { + return searchLeadFromCalCom[0].id; + } + } } export const closeComQueries = { diff --git a/packages/lib/CloseComeUtils.ts b/packages/lib/CloseComeUtils.ts deleted file mode 100644 index 386f852883..0000000000 --- a/packages/lib/CloseComeUtils.ts +++ /dev/null @@ -1,206 +0,0 @@ -import type { CalendarEvent } from "@calcom/types/Calendar"; - -import CloseCom, { - CloseComCustomActivityCreate, - CloseComCustomActivityFieldGet, - CloseComCustomContactFieldGet, - CloseComFieldOptions, - CloseComLead, -} from "./CloseCom"; - -export async function getCloseComContactIds( - persons: { email: string; name: string | null }[], - closeCom: CloseCom, - leadFromCalComId?: string -): Promise { - // Check if persons exist or to see if any should be created - const closeComContacts = await closeCom.contact.search({ - emails: persons.map((att) => att.email), - }); - // NOTE: If contact is duplicated in Close.com we will get more results - // messing around with the expected number of contacts retrieved - if (closeComContacts.data.length < persons.length && leadFromCalComId) { - // Create missing contacts - const personsEmails = persons.map((att) => att.email); - // Existing contacts based on persons emails: contacts may have more - // than one email, we just need the one used by the event. - const existingContactsEmails = closeComContacts.data.flatMap((cont) => - cont.emails.filter((em) => personsEmails.includes(em.email)).map((ems) => ems.email) - ); - const nonExistingContacts = persons.filter((person) => !existingContactsEmails.includes(person.email)); - const createdContacts = await Promise.all( - nonExistingContacts.map( - async (per) => - await closeCom.contact.create({ - person: per, - leadId: leadFromCalComId, - }) - ) - ); - if (createdContacts.length === nonExistingContacts.length) { - // All non existent contacts where created - return Promise.resolve( - closeComContacts.data.map((cont) => cont.id).concat(createdContacts.map((cont) => cont.id)) - ); - } else { - return Promise.reject("Some contacts were not possible to create in Close.com"); - } - } else { - return Promise.resolve(closeComContacts.data.map((cont) => cont.id)); - } -} - -export async function getCustomActivityTypeInstanceData( - event: CalendarEvent, - customFields: CloseComFieldOptions, - closeCom: CloseCom -): Promise { - // Get Cal.com generic Lead - const leadFromCalComId = await getCloseComLeadId(closeCom); - // Get Contacts ids - const contactsIds = await getCloseComContactIds(event.attendees, closeCom, leadFromCalComId); - // Get Custom Activity Type id - const customActivityTypeAndFieldsIds = await getCloseComCustomActivityTypeFieldsIds(customFields, closeCom); - // Prepare values for each Custom Activity Fields - const customActivityFieldsValues = [ - contactsIds.length > 1 ? contactsIds.slice(1) : null, // Attendee - event.startTime, // Date & Time - event.attendees[0].timeZone, // Time Zone - contactsIds[0], // Organizer - event.additionalNotes ?? null, // Additional Notes - ]; - // Preparing Custom Activity Instance data for Close.com - return Object.assign( - {}, - { - custom_activity_type_id: customActivityTypeAndFieldsIds.activityType, - lead_id: leadFromCalComId, - }, // This is to add each field as `"custom.FIELD_ID": "value"` in the object - ...customActivityTypeAndFieldsIds.fields.map((fieldId: string, index: number) => { - return { - [`custom.${fieldId}`]: customActivityFieldsValues[index], - }; - }) - ); -} - -export async function getCustomFieldsIds( - entity: keyof CloseCom["customField"], - customFields: CloseComFieldOptions, - closeCom: CloseCom, - custom_activity_type_id?: string -): Promise { - // Get Custom Activity Fields - const allFields: CloseComCustomActivityFieldGet | CloseComCustomContactFieldGet = - await closeCom.customField[entity].get({ - query: { _fields: ["name", "id"].concat(entity === "activity" ? ["custom_activity_type_id"] : []) }, - }); - let relevantFields: { [key: string]: any }[]; - if (entity === "activity") { - relevantFields = (allFields as CloseComCustomActivityFieldGet).data.filter( - (fie) => fie.custom_activity_type_id === custom_activity_type_id - ); - } else { - relevantFields = allFields.data as CloseComCustomActivityFieldGet["data"]; - } - const customFieldsNames = relevantFields.map((fie) => fie.name); - const customFieldsExist = customFields.map((cusFie) => customFieldsNames.includes(cusFie[0])); - return await Promise.all( - customFieldsExist.map(async (exist, idx) => { - if (!exist && entity !== "shared") { - const [name, type, required, multiple] = customFields[idx]; - let created: { [key: string]: any }; - if (entity === "activity" && custom_activity_type_id) { - created = await closeCom.customField[entity].create({ - name, - type, - required, - accepts_multiple_values: multiple, - editable_with_roles: [], - custom_activity_type_id, - }); - return created.id; - } else { - if (entity === "contact") { - created = await closeCom.customField[entity].create({ - name, - type, - required, - accepts_multiple_values: multiple, - editable_with_roles: [], - }); - return created.id; - } - } - } else { - const index = customFieldsNames.findIndex((val) => val === customFields[idx][0]); - if (index >= 0) { - return relevantFields[index].id; - } else { - throw Error("Couldn't find the field index"); - } - } - }) - ); -} - -export async function getCloseComCustomActivityTypeFieldsIds( - customFields: CloseComFieldOptions, - closeCom: CloseCom -) { - // Check if Custom Activity Type exists - const customActivities = await closeCom.customActivity.type.get(); - const calComCustomActivity = customActivities.data.filter((act) => act.name === "Cal.com Activity"); - if (calComCustomActivity.length > 0) { - // Cal.com Custom Activity type exist - // Get Custom Activity Type fields ids - const fields = await getCustomFieldsIds("activity", customFields, closeCom, calComCustomActivity[0].id); - return { - activityType: calComCustomActivity[0].id, - fields, - }; - } else { - // Cal.com Custom Activity type doesn't exist - // Create Custom Activity Type - const { id: activityType } = await closeCom.customActivity.type.create({ - name: "Cal.com Activity", - description: "Bookings in your Cal.com account", - }); - // Create Custom Activity Fields - const fields = await Promise.all( - customFields.map(async ([name, type, required, multiple]) => { - const creation = await closeCom.customField.activity.create({ - custom_activity_type_id: activityType, - name, - type, - required, - accepts_multiple_values: multiple, - editable_with_roles: [], - }); - return creation.id; - }) - ); - return { - activityType, - fields, - }; - } -} - -export async function getCloseComLeadId( - closeCom: CloseCom, - leadInfo: CloseComLead = { - companyName: "From Cal.com", - description: "Generic Lead for Contacts created by Cal.com", - } -): Promise { - const closeComLeadNames = await closeCom.lead.list({ query: { _fields: ["name", "id"] } }); - const searchLeadFromCalCom = closeComLeadNames.data.filter((lead) => lead.name === leadInfo.companyName); - if (searchLeadFromCalCom.length === 0) { - // No Lead exists, create it - const createdLeadFromCalCom = await closeCom.lead.create(leadInfo); - return createdLeadFromCalCom.id; - } else { - return searchLeadFromCalCom[0].id; - } -} diff --git a/packages/lib/Sendgrid.ts b/packages/lib/Sendgrid.ts new file mode 100644 index 0000000000..e0ffdefcf1 --- /dev/null +++ b/packages/lib/Sendgrid.ts @@ -0,0 +1,131 @@ +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"; + +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 sendgrid: typeof sendgrid; + private log: typeof logger; + + constructor(providedApiKey = "") { + this.log = logger.getChildLogger({ prefix: [`[[lib] sendgrid`] }); + if (!providedApiKey && !environmentApiKey) throw Error("Sendgrid Api Key not present"); + this.sendgrid = sendgrid; + this.sendgrid.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 this.sendgrid.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/sync/services/CloseComService.ts b/packages/lib/sync/services/CloseComService.ts index bece375096..0868a2e842 100644 --- a/packages/lib/sync/services/CloseComService.ts +++ b/packages/lib/sync/services/CloseComService.ts @@ -1,7 +1,6 @@ import { MembershipRole } from "@prisma/client"; import CloseCom, { CloseComFieldOptions, CloseComLead } from "@calcom/lib/CloseCom"; -import { getCloseComContactIds, getCloseComLeadId, getCustomFieldsIds } from "@calcom/lib/CloseComeUtils"; import logger from "@calcom/lib/logger"; import SyncServiceCore, { TeamInfoType } from "@calcom/lib/sync/ISyncService"; import ISyncService, { ConsoleUserInfoType, WebUserInfoType } from "@calcom/lib/sync/ISyncService"; @@ -20,6 +19,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}`] })); } @@ -31,16 +32,16 @@ export default class CloseComService extends SyncServiceCore implements ISyncSer ) => { this.log.debug("sync:closecom:user", { user }); // Get Cal.com Lead - const leadId = await getCloseComLeadId(this.service, leadInfo); + const leadId = await this.service.getCloseComLeadId(leadInfo); this.log.debug("sync:closecom:user:leadId", { leadId }); // Get Contacts ids: already creates contacts - const [contactId] = await getCloseComContactIds([user], this.service, leadId); + const [contactId] = await this.service.getCloseComContactIds([user], leadId); this.log.debug("sync:closecom:user:contactsIds", { contactId }); // Get Custom Contact fields ids - const customFieldsIds = await getCustomFieldsIds("contact", calComCustomContactFields, this.service); + const customFieldsIds = await this.service.getCustomFieldsIds("contact", calComCustomContactFields); this.log.debug("sync:closecom:user:customFieldsIds", { customFieldsIds }); // Get shared fields ids - const sharedFieldsIds = await getCustomFieldsIds("shared", calComSharedFields, this.service); + const sharedFieldsIds = await this.service.getCustomFieldsIds("shared", calComSharedFields); this.log.debug("sync:closecom:user:sharedFieldsIds", { sharedFieldsIds }); const allFields = customFieldsIds.concat(sharedFieldsIds); this.log.debug("sync:closecom:user:allFields", { allFields }); @@ -91,7 +92,7 @@ export default class CloseComService extends SyncServiceCore implements ISyncSer }, delete: async (webUser: WebUserInfoType) => { this.log.debug("sync:closecom:web:user:delete", { webUser }); - const [contactId] = await getCloseComContactIds([webUser], this.service); + const [contactId] = await this.service.getCloseComContactIds([webUser]); this.log.debug("sync:closecom:web:user:delete:contactId", { contactId }); if (contactId) { return this.service.contact.delete(contactId); @@ -112,21 +113,21 @@ export default class CloseComService extends SyncServiceCore implements ISyncSer }, delete: async (team: TeamInfoType) => { this.log.debug("sync:closecom:web:team:delete", { team }); - const leadId = await getCloseComLeadId(this.service, { companyName: team.name }); + const leadId = await this.service.getCloseComLeadId({ companyName: team.name }); this.log.debug("sync:closecom:web:team:delete:leadId", { leadId }); this.service.lead.delete(leadId); }, update: async (prevTeam: TeamInfoType, updatedTeam: TeamInfoType) => { this.log.debug("sync:closecom:web:team:update", { prevTeam, updatedTeam }); - const leadId = await getCloseComLeadId(this.service, { companyName: prevTeam.name }); + const leadId = await this.service.getCloseComLeadId({ 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: { delete: async (webUser: WebUserInfoType) => { this.log.debug("sync:closecom:web:membership:delete", { webUser }); - const [contactId] = await getCloseComContactIds([webUser], this.service); + const [contactId] = await this.service.getCloseComContactIds([webUser]); this.log.debug("sync:closecom:web:membership:delete:contactId", { contactId }); if (contactId) { return this.service.contact.delete(contactId); 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/prisma/seed-app-store.ts b/packages/prisma/seed-app-store.ts index 000b1c8739..bfad16335d 100644 --- a/packages/prisma/seed-app-store.ts +++ b/packages/prisma/seed-app-store.ts @@ -240,6 +240,8 @@ export default async function main() { } // No need to check if environment variable is present, the API Key is set up by the user, not the system await createApp("closecom", "closecomothercalendar", ["other"], "closecom_other_calendar"); + // No need to check if environment variable is present, the API Key is set up by the user, not the system + await createApp("sendgrid", "sendgridothercalendar", ["other"], "sendgrid_other_calendar"); await createApp("wipe-my-cal", "wipemycalother", ["other"], "wipemycal_other"); if (process.env.GIPHY_API_KEY) { await createApp("giphy", "giphy", ["other"], "giphy_other", { diff --git a/turbo.json b/turbo.json index e0b19686ed..d74b2b6e9e 100644 --- a/turbo.json +++ b/turbo.json @@ -181,6 +181,7 @@ "$CI", "$CLOSECOM_API_KEY", "$SENDGRID_API_KEY", + "$SENDGRID_SYNC_API_KEY", "$SENDGRID_EMAIL", "$CRON_API_KEY", "$DAILY_API_KEY", diff --git a/yarn.lock b/yarn.lock index 798c6f3700..5ade5e1d4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26153,7 +26153,7 @@ zod-prisma@^0.5.4: parenthesis "^3.1.8" ts-morph "^13.0.2" -zod@^3.17.3, zod@^3.19.1: +zod@^3.17.3, zod@^3.18.0, zod@^3.19.1: version "3.19.1" resolved "https://registry.yarnpkg.com/zod/-/zod-3.19.1.tgz#112f074a97b50bfc4772d4ad1576814bd8ac4473" integrity sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==