From d9a867bbde1ff9674b30f98dc6c1bcb593f6e740 Mon Sep 17 00:00:00 2001 From: Om Ray <38233712+om-ray@users.noreply.github.com> Date: Sun, 6 Nov 2022 11:09:10 -0500 Subject: [PATCH 1/5] Change Zap automation UI issues (#5355) * changed styles for Zap automation ui to match figma * Increased margintop value * increase margintop value to mt-4 * fixed some styling issues * fixed issue with buttons being at different heights * changed vertical positioning of text and increased min height Co-authored-by: Peer Richelsen --- .../app-store/_components/AppSettings.tsx | 2 +- .../zapier/components/TemplateCard.tsx | 26 +++++++++++-------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/app-store/_components/AppSettings.tsx b/packages/app-store/_components/AppSettings.tsx index a2a41ba4be..6f27c3b8c2 100644 --- a/packages/app-store/_components/AppSettings.tsx +++ b/packages/app-store/_components/AppSettings.tsx @@ -5,7 +5,7 @@ import { DynamicComponent } from "./DynamicComponent"; export const AppSettings = (props: { slug: string }) => { return ( - wrapperClassName="border-t border-gray-200" + wrapperClassName="border-t border-gray-200 bg-gray-100" componentMap={AppSettingsComponentsMap} {...props} /> diff --git a/packages/app-store/zapier/components/TemplateCard.tsx b/packages/app-store/zapier/components/TemplateCard.tsx index fe2c72e171..e8e9f10245 100644 --- a/packages/app-store/zapier/components/TemplateCard.tsx +++ b/packages/app-store/zapier/components/TemplateCard.tsx @@ -4,24 +4,28 @@ import { Template } from "./AppSettings"; export default function TemplateCard({ template }: { template: Template }) { return ( -
-
+
+
-
+
{template.app}
-
+
-

{template.app}

-

{template.text}

+

{template.app}

+

{template.text}

-
- -
+
+
+
From f1208eadc0faedf41d3a9aa1ed9387094ec4de46 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 6 Nov 2022 19:07:40 +0000 Subject: [PATCH 2/5] New Crowdin translations by Github Action (#5391) Co-authored-by: Crowdin Bot --- apps/web/public/static/locales/ru/common.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/public/static/locales/ru/common.json b/apps/web/public/static/locales/ru/common.json index 0d1136d704..305410ea0d 100644 --- a/apps/web/public/static/locales/ru/common.json +++ b/apps/web/public/static/locales/ru/common.json @@ -1158,7 +1158,7 @@ "skip": "Пропустить", "do_this_later": "Настроить позже", "set_availability_getting_started_subtitle_1": "Укажите время, в которое вы доступны", - "set_availability_getting_started_subtitle_2": "Эти данные можно изменить позже на странице «Доступность».", + "set_availability_getting_started_subtitle_2": "Эти данные можно указать позже на странице «Доступность».", "connect_calendars_from_app_store": "Вы можете добавить другие календари из App Store", "connect_conference_apps": "Подключить приложения для конференций", "connect_calendar_apps": "Подключить приложения календаря", @@ -1186,7 +1186,7 @@ "create_your_first_form_description": "Формы маршрутизации позволяют задавать пользователям уточняющие вопросы, чтобы перенаправлять их к соответствующему лицу или типу события.", "create_your_first_webhook": "Создайте свой первый вебхук", "create_your_first_webhook_description": "Вебхуки позволяют в реальном времени получать оповещения, когда данные о встрече на Cal.com изменяются.", - "for_a_maximum_of": "Максимум:", + "for_a_maximum_of": "Максимальное количество:", "event_one": "событие", "event_other": "событий", "profile_team_description": "Настройка профиля вашей команды", From 22dad1ae7c00786f9de2b5cfb0e255351114555e Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Mon, 7 Nov 2022 10:37:50 +0000 Subject: [PATCH 3/5] Fix lint error that made its way into main --- apps/web/components/booking/pages/BookingPage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index eab526bf66..254ce386e5 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -31,7 +31,6 @@ import { } from "@calcom/embed-core/embed-iframe"; import CustomBranding from "@calcom/lib/CustomBranding"; import classNames from "@calcom/lib/classNames"; -import { formatTime } from "@calcom/lib/date-fns"; import getStripeAppData from "@calcom/lib/getStripeAppData"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useTheme from "@calcom/lib/hooks/useTheme"; @@ -40,8 +39,8 @@ import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { Icon } from "@calcom/ui/Icon"; import { Tooltip } from "@calcom/ui/Tooltip"; -import AddressInput from "@calcom/ui/form/AddressInputLazy"; import { Button } from "@calcom/ui/components"; +import AddressInput from "@calcom/ui/form/AddressInputLazy"; import PhoneInput from "@calcom/ui/form/PhoneInputLazy"; import { EmailInput, Form } from "@calcom/ui/form/fields"; From 82a6ab7957d7330e48161b6c53c0d270eded3b51 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Mon, 7 Nov 2022 15:16:35 +0000 Subject: [PATCH 4/5] Circumvents Dayjs issue by going native (#5363) --- packages/lib/date-fns/index.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/lib/date-fns/index.ts b/packages/lib/date-fns/index.ts index a565ba76db..f816377583 100644 --- a/packages/lib/date-fns/index.ts +++ b/packages/lib/date-fns/index.ts @@ -4,8 +4,13 @@ import dayjs, { Dayjs } from "@calcom/dayjs"; export const yyyymmdd = (date: Date | Dayjs) => date instanceof Date ? dayjs(date).format("YYYY-MM-DD") : date.format("YYYY-MM-DD"); -export const daysInMonth = (date: Date | Dayjs) => - date instanceof Date ? dayjs(date).daysInMonth() : date.daysInMonth(); +// @see: https://github.com/iamkun/dayjs/issues/1272 - for the reason we're not using dayjs to do this. +export const daysInMonth = (date: Date | Dayjs) => { + const [year, month] = + date instanceof Date ? [date.getFullYear(), date.getMonth()] : [date.year(), date.month()]; + // strange JS quirk: new Date(2022, 12, 0).getMonth() = 11 + return new Date(year, month + 1, 0).getDate(); +}; /** * Expects timeFormat to be either 12 or 24, if null or undefined From e5ba861621ad8dd0ec776c7f5287160ae78f5bc5 Mon Sep 17 00:00:00 2001 From: Leo Giovanetti Date: Mon, 7 Nov 2022 14:52:03 -0300 Subject: [PATCH 5/5] Sendgrid app and code simplification (#5269) * Sendgrid app and code simplification * Applying app-store-cli + impl * Fixing types * Adding features to readme * Fixing unit tests * A few last tweaks regarding UX and env vars * Applying feedback * Using calcom icons Co-authored-by: Peer Richelsen --- .env.example | 5 +- app.json | 4 + apps/web/pages/apps/installed/[category].tsx | 4 +- packages/app-store/_pages/setup/index.tsx | 1 + packages/app-store/apps.metadata.generated.ts | 2 + packages/app-store/apps.server.generated.ts | 1 + .../lib/CalendarService.ts | 11 +- .../closecomothercalendar/test/globals.ts | 11 +- .../test/lib/CalendarService.test.ts | 88 +++----- packages/app-store/index.ts | 2 + .../sendgridothercalendar/README.mdx | 10 + .../sendgridothercalendar/_metadata.ts | 10 + .../sendgridothercalendar/api/_getAdd.ts | 10 + .../sendgridothercalendar/api/_postAdd.ts | 39 ++++ .../sendgridothercalendar/api/_postCheck.ts | 29 +++ .../sendgridothercalendar/api/add.ts | 6 + .../sendgridothercalendar/api/check.ts | 5 + .../sendgridothercalendar/api/index.ts | 2 + .../sendgridothercalendar/components/.gitkeep | 0 .../sendgridothercalendar/config.json | 16 ++ .../app-store/sendgridothercalendar/index.ts | 3 + .../lib/CalendarService.ts | 99 +++++++++ .../sendgridothercalendar/lib/index.ts | 1 + .../sendgridothercalendar/package.json | 16 ++ .../pages/setup/index.tsx | 157 +++++++++++++ .../sendgridothercalendar/static/1.png | Bin 0 -> 20222 bytes .../sendgridothercalendar/static/logo.png | Bin 0 -> 4472 bytes packages/lib/CloseCom.ts | 194 +++++++++++++++++ packages/lib/CloseComeUtils.ts | 206 ------------------ packages/lib/Sendgrid.ts | 131 +++++++++++ packages/lib/sync/services/CloseComService.ts | 21 +- packages/lib/sync/services/SendgridService.ts | 122 +---------- packages/prisma/seed-app-store.config.json | 6 + packages/prisma/seed-app-store.ts | 2 + turbo.json | 1 + yarn.lock | 2 +- 36 files changed, 812 insertions(+), 405 deletions(-) create mode 100644 packages/app-store/sendgridothercalendar/README.mdx create mode 100644 packages/app-store/sendgridothercalendar/_metadata.ts create mode 100644 packages/app-store/sendgridothercalendar/api/_getAdd.ts create mode 100644 packages/app-store/sendgridothercalendar/api/_postAdd.ts create mode 100644 packages/app-store/sendgridothercalendar/api/_postCheck.ts create mode 100644 packages/app-store/sendgridothercalendar/api/add.ts create mode 100644 packages/app-store/sendgridothercalendar/api/check.ts create mode 100644 packages/app-store/sendgridothercalendar/api/index.ts create mode 100644 packages/app-store/sendgridothercalendar/components/.gitkeep create mode 100644 packages/app-store/sendgridothercalendar/config.json create mode 100644 packages/app-store/sendgridothercalendar/index.ts create mode 100644 packages/app-store/sendgridothercalendar/lib/CalendarService.ts create mode 100644 packages/app-store/sendgridothercalendar/lib/index.ts create mode 100644 packages/app-store/sendgridothercalendar/package.json create mode 100644 packages/app-store/sendgridothercalendar/pages/setup/index.tsx create mode 100644 packages/app-store/sendgridothercalendar/static/1.png create mode 100644 packages/app-store/sendgridothercalendar/static/logo.png delete mode 100644 packages/lib/CloseComeUtils.ts create mode 100644 packages/lib/Sendgrid.ts 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 0000000000000000000000000000000000000000..ead1889ab2681ebb983bb0897dc6c7581791d443 GIT binary patch literal 20222 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 JU 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 literal 0 HcmV?d00001 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 020c8a7f81..be2a9072b3 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==