Close.com App (#3709)

* WIP close.com app

* Removing leaked dev key (now invalid)

* Misspelled env variable

* Making progress still WIP

* Progress + tests

* Final touches

* More unit tests

* Finished up tests

* Merge main

* Removing unneeded stuff + submodules

* Removing static props, fields fix

* Removing unneeded stuff p2

* Commenting

* Standarizing APIs

* Zodifying

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Leo Giovanetti
2022-08-15 20:45:15 +00:00
committed by GitHub
co-authored by kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> zomars
parent 8d0caa522e
commit 4ce6b91a79
31 changed files with 1297 additions and 53 deletions
@@ -805,7 +805,12 @@
"enter_number_of_seats": "Enter number of seats",
"you_can_manage_your_schedules": "You can manage your schedules on the Availability page.",
"booking_full": "No more seats available",
"api_keys": "API Keys",
"api_keys": "API keys",
"api_key": "API key",
"test_api_key": "Test API key",
"test_passed": "Test passed!",
"test_failed": "Test failed",
"provide_api_key": "Provide API key",
"api_key_modal_subtitle": "API keys allow you to make API calls for your own account.",
"api_keys_subtitle": "Generate API keys to use for accessing your own account.",
"create_api_key": "Create an API key",
@@ -869,7 +874,8 @@
"how_you_want_add_cal_site": "How do you want to add Cal to your site?",
"choose_ways_put_cal_site": "Choose one of the following ways to put Cal on your site.",
"setting_up_zapier": "Setting up your Zapier integration",
"generate_api_key": "Generate Api Key",
"generate_api_key": "Generate API key",
"generate_api_key_description": "Generate an API key to use with Cal.com at",
"your_unique_api_key": "Your unique API key",
"copy_safe_api_key": "Copy this API key and save it somewhere safe. If you lose this key you have to generate a new one.",
"zapier_setup_instructions": "<0>Log into your Zapier account and create a new Zap.</0><1>Select Cal.com as your Trigger app. Also choose a Trigger event.</1><2>Choose your account and then enter your Unique API Key.</2><3>Test your Trigger.</3><4>You're set!</4>",
@@ -879,6 +885,7 @@
"calendar_url": "Calendar URL",
"apple_server_generate_password": "Generate an app specific password to use with Cal.com at",
"credentials_stored_encrypted": "Your credentials will be stored and encrypted.",
"it_stored_encrypted": "It will be stored and encrypted.",
"go_to_app_store": "Go to App Store",
"calendar_error": "Something went wrong, try reconnecting your calendar with all necessary permissions",
"set_your_phone_number": "Set a phone number for the meeting",
@@ -8,6 +8,7 @@ export const AppSetupMap = {
"exchange2016-calendar": dynamic(() => import("../../exchange2016calendar/pages/setup")),
"caldav-calendar": dynamic(() => import("../../caldavcalendar/pages/setup")),
zapier: dynamic(() => import("../../zapier/pages/setup")),
closecom: dynamic(() => import("../../closecomothercalendar/pages/setup")),
};
export const AppSetupPage = (props: { slug: string }) => {
@@ -7,6 +7,7 @@ import dynamic from "next/dynamic";
import { metadata as applecalendar_meta } from "./applecalendar/_metadata";
import { metadata as around_meta } from "./around/_metadata";
import { metadata as caldavcalendar_meta } from "./caldavcalendar/_metadata";
import { metadata as closecomothercalendar_meta } from "./closecomothercalendar/_metadata";
import { metadata as dailyvideo_meta } from "./dailyvideo/_metadata";
import { metadata as routing_forms_meta } from "./ee/routing_forms/_metadata";
import { metadata as exchange2013calendar_meta } from "./exchange2013calendar/_metadata";
@@ -37,6 +38,7 @@ export const appStoreMetadata = {
applecalendar: applecalendar_meta,
around: around_meta,
caldavcalendar: caldavcalendar_meta,
closecomothercalendar: closecomothercalendar_meta,
dailyvideo: dailyvideo_meta,
routing_forms: routing_forms_meta,
exchange2013calendar: exchange2013calendar_meta,
@@ -67,6 +69,7 @@ export const InstallAppButtonMap = {
applecalendar: dynamic(() => import("./applecalendar/components/InstallAppButton")),
around: dynamic(() => import("./around/components/InstallAppButton")),
caldavcalendar: dynamic(() => import("./caldavcalendar/components/InstallAppButton")),
closecomothercalendar: dynamic(() => import("./closecomothercalendar/components/InstallAppButton")),
exchange2013calendar: dynamic(() => import("./exchange2013calendar/components/InstallAppButton")),
exchange2016calendar: dynamic(() => import("./exchange2016calendar/components/InstallAppButton")),
giphy: dynamic(() => import("./giphy/components/InstallAppButton")),
@@ -6,6 +6,7 @@ export const apiHandlers = {
applecalendar: import("./applecalendar/api"),
around: import("./around/api"),
caldavcalendar: import("./caldavcalendar/api"),
closecomothercalendar: import("./closecomothercalendar/api"),
routing_forms: import("./ee/routing_forms/api"),
exchange2013calendar: import("./exchange2013calendar/api"),
exchange2016calendar: import("./exchange2016calendar/api"),
@@ -0,0 +1,8 @@
---
items:
- /api/app-store/closecomothercalendar/screenshot.png
---
<Slider items={items} />
Close is the inside sales CRM of choice for startups and SMBs. Make more calls, send more emails and close more deals starting today.
@@ -0,0 +1,18 @@
import type { App } from "@calcom/types/App";
import config from "./config.json";
export const metadata = {
category: "other",
// FIXME: Currently for an app to be shown as installed, it must have this variable set. Either hardcoded or if it depends on some env variable, that should be checked here
installed: true,
rating: 0,
reviews: 0,
trending: true,
verified: true,
licenseRequired: true,
isProOnly: true,
...config,
} as App;
export default metadata;
@@ -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("closecom", session.user?.id);
return res.status(200).json({ url: "/apps/closecom/setup" });
}
@@ -0,0 +1,38 @@
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";
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 provoided to check" });
const encrypted = symmetricEncrypt(JSON.stringify({ api_key }), process.env.CALENDSO_ENCRYPTION_KEY || "");
const data = {
type: "closecom_other_calendar",
key: { encrypted },
userId: session.user?.id,
appId: "closecom",
};
try {
await prisma.credential.create({
data,
});
} catch (reason) {
logger.error("Could not add Close.com app", reason);
return res.status(500).json({ message: "Could not add Close.com app" });
}
return res.status(200).json({ url: "/apps/installed" });
}
export default defaultResponder(getHandler);
@@ -0,0 +1,29 @@
import type { NextApiRequest, NextApiResponse } from "next";
import CloseCom from "@calcom/lib/CloseCom";
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 closeCom: CloseCom = new CloseCom(api_key);
try {
const userInfo = await closeCom.me();
if (userInfo.first_name) {
return res.status(200).end();
} else {
return res.status(404).end();
}
} catch (e) {
return res.status(500).json({ message: e });
}
}
export default defaultResponder(getHandler);
@@ -0,0 +1,6 @@
import { defaultHandler } from "@calcom/lib/server";
export default defaultHandler({
GET: import("./_getAdd"),
POST: import("./_postAdd"),
});
@@ -0,0 +1,5 @@
import { defaultHandler } from "@calcom/lib/server";
export default defaultHandler({
POST: import("./_postCheck"),
});
@@ -0,0 +1,2 @@
export { default as add } from "./add";
export { default as check } from "./check";
@@ -0,0 +1,18 @@
import type { InstallAppButtonProps } from "@calcom/app-store/types";
import useAddAppMutation from "../../_utils/useAddAppMutation";
export default function InstallAppButton(props: InstallAppButtonProps) {
const mutation = useAddAppMutation("closecom_other_calendar");
return (
<>
{props.render({
onClick() {
mutation.mutate("");
},
loading: mutation.isLoading,
})}
</>
);
}
@@ -0,0 +1 @@
export { default as InstallAppButton } from "./InstallAppButton";
@@ -0,0 +1,16 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Close.com",
"title": "Close.com",
"slug": "closecom",
"type": "closecom_other_calendar",
"imageSrc": "/api/app-store/closecomothercalendar/icon.svg",
"logo": "/api/app-store/closecomothercalendar/icon.svg",
"url": "https://cal.com/apps/closecom",
"variant": "other",
"categories": ["other"],
"publisher": "Cal.com",
"email": "help@cal.com",
"description": "Close is the inside sales CRM of choice for startups and SMBs. Make more calls, send more emails and close more deals starting today.",
"__createdUsingCli": true
}
@@ -0,0 +1,3 @@
export * as api from "./api";
export * as lib from "./lib";
export { metadata } from "./_metadata";
@@ -0,0 +1,15 @@
import type { Config } from "@jest/types";
const config: Config.InitialOptions = {
verbose: true,
roots: ["<rootDir>"],
testMatch: ["**/test/lib/**/*.(spec|test).(ts|tsx|js)"],
transform: {
"^.+\\.ts?$": "ts-jest",
},
transformIgnorePatterns: ["/node_modules/", "^.+\\.module\\.(css|sass|scss)$"],
testEnvironment: "jsdom",
setupFiles: ["<rootDir>/test/globals.ts"],
};
export default config;
@@ -0,0 +1,298 @@
import { Credential } from "@prisma/client";
import z from "zod";
import CloseCom, { CloseComCustomActivityCreate } from "@calcom/lib/CloseCom";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import logger from "@calcom/lib/logger";
import type {
Calendar,
CalendarEvent,
EventBusyDate,
IntegrationCalendar,
NewCalendarEventType,
} from "@calcom/types/Calendar";
const apiKeySchema = z.object({
encrypted: z.string(),
});
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
// Cal.com Custom Activity Fields
const calComCustomActivityFields: [string, string, boolean, boolean][] = [
// Field name, field type, required?, multiple values?
["Attendees", "contact", false, true],
["Date & Time", "datetime", true, false],
["Time zone", "text", true, false],
["Organizer", "contact", true, false],
["Additional notes", "text", false, false],
];
/**
* Authentication
* Close.com 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
* Close.com App in order to instruct how to create such resource and to obtain it.
*
* Meeting creation
* Close.com does not expose a "Meeting" API, it may be available in the future.
*
* Per Close.com documentation (https://developer.close.com/resources/custom-activities):
* "To work with Custom Activities, you will need to create a Custom Activity Type and
* likely add one or more Activity Custom Fields to that type. Once the Custom Activity
* Type is defined, you can create Custom Activity instances of that type as you would
* any other activity."
*
* Contact creation
* Every contact in Close.com need to belong to a Lead. When creating a contact in
* Close.com as part of this integration, a new generic Lead will be created in order
* to assign every contact created by this process, and it is named "From Cal.com"
*/
export default class CloseComCalendarService implements Calendar {
private integrationName = "";
private closeCom: CloseCom;
private log: typeof logger;
constructor(credential: Credential) {
this.integrationName = "closecom_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.closeCom = new CloseCom(api_key);
} else {
throw Error(
`No API Key found for userId ${credential.userId} and appId ${credential.appId}: ${parsedCredentialKey.error}`
);
}
}
private async closeComUpdateCustomActivity(uid: string, event: CalendarEvent) {
const customActivityTypeInstanceData = await this.getCustomActivityTypeInstanceData(event);
// Create Custom Activity type instance
const customActivityTypeInstance = await this.closeCom.activity.custom.create(
customActivityTypeInstanceData
);
return this.closeCom.activity.custom.update(uid, customActivityTypeInstance);
}
private async closeComDeleteCustomActivity(uid: string) {
return this.closeCom.activity.custom.delete(uid);
}
getCloseComContactIds = async (event: CalendarEvent, leadFromCalComId: string) => {
// Check if attendees exist or to see if any should be created
const closeComContacts = await this.closeCom.contact.search({
emails: event.attendees.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 < event.attendees.length) {
// Create missing contacts
const attendeesEmails = event.attendees.map((att) => att.email);
// Existing contacts based on attendees 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) => attendeesEmails.includes(em.email)).map((ems) => ems.email)
);
const nonExistingContacts = event.attendees.filter(
(attendee) => !existingContactsEmails.includes(attendee.email)
);
const createdContacts = await Promise.all(
nonExistingContacts.map(
async (att) =>
await this.closeCom.contact.create({
attendee: att,
leadId: leadFromCalComId,
})
)
);
if (createdContacts.length === nonExistingContacts.length) {
// All non existent contacts where created
return 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 closeComContacts.data.map((cont) => cont.id);
}
};
/**
* Check if generic "From Cal.com" Lead exists, create it if not
*/
getCloseComGenericLeadId = async (): Promise<string> => {
const closeComLeadNames = await this.closeCom.lead.list({ query: { _fields: ["name", "id"] } });
const searchLeadFromCalCom = closeComLeadNames.data.filter((lead) => lead.name === "From Cal.com");
if (searchLeadFromCalCom.length === 0) {
// No Lead exists, create it
const createdLeadFromCalCom = await this.closeCom.lead.create({
companyName: "From Cal.com",
description: "Generic Lead for Contacts created by Cal.com",
});
return createdLeadFromCalCom.id;
} else {
return searchLeadFromCalCom[0].id;
}
};
getCloseComCustomActivityTypeFieldsIds = async () => {
// Check if Custom Activity Type exists
const customActivities = await this.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 Fields
const customActivityAllFields = await this.closeCom.customField.activity.get({
query: { _fields: ["name", "custom_activity_type_id", "id"] },
});
const customActivityRelevantFields = customActivityAllFields.data.filter(
(fie) => fie.custom_activity_type_id === calComCustomActivity[0].id
);
const customActivityFieldsNames = customActivityRelevantFields.map((fie) => fie.name);
const customActivityFieldsExist = calComCustomActivityFields.map((cusFie) =>
customActivityFieldsNames.includes(cusFie[0])
);
const [attendee, dateTime, timezone, organizer, additionalNotes] = await Promise.all(
customActivityFieldsExist.map(async (exist, idx) => {
if (!exist) {
const [name, type, required, multiple] = calComCustomActivityFields[idx];
const created = await this.closeCom.customField.activity.create({
custom_activity_type_id: calComCustomActivity[0].id,
name,
type,
required,
accepts_multiple_values: multiple,
editable_with_roles: [],
});
return created.id;
} else {
const index = customActivityFieldsNames.findIndex(
(val) => val === calComCustomActivityFields[idx][0]
);
if (index >= 0) {
return customActivityRelevantFields[index].id;
} else {
throw Error("Couldn't find the field index");
}
}
})
);
return {
activityType: calComCustomActivity[0].id,
fields: {
attendee,
dateTime,
timezone,
organizer,
additionalNotes,
},
};
} else {
// Cal.com Custom Activity type doesn't exist
// Create Custom Activity Type
const { id: activityType } = await this.closeCom.customActivity.type.create({
name: "Cal.com Activity",
description: "Bookings in your Cal.com account",
});
// Create Custom Activity Fields
const [attendee, dateTime, timezone, organizer, additionalNotes] = await Promise.all(
calComCustomActivityFields.map(async ([name, type, required, multiple]) => {
const creation = await this.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: {
attendee,
dateTime,
timezone,
organizer,
additionalNotes,
},
};
}
};
getCustomActivityTypeInstanceData = async (event: CalendarEvent): Promise<CloseComCustomActivityCreate> => {
// Get Cal.com generic Lead
const leadFromCalComId = await this.getCloseComGenericLeadId();
// Get Contacts ids
const contactsIds = await this.getCloseComContactIds(event, leadFromCalComId);
// Get Custom Activity Type id
const customActivityTypeAndFieldsIds = await this.getCloseComCustomActivityTypeFieldsIds();
// Prepare values for each Custom Activity Fields
const customActivityFieldsValue = {
attendee: contactsIds.length > 1 ? contactsIds.slice(1) : null,
dateTime: event.startTime,
timezone: event.attendees[0].timeZone,
organizer: contactsIds[0],
additionalNotes: event.additionalNotes ?? null,
};
// 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
...Object.keys(customActivityTypeAndFieldsIds.fields).map((fieldKey: string) => {
const key = fieldKey as keyof typeof customActivityTypeAndFieldsIds.fields;
return {
[`custom.${customActivityTypeAndFieldsIds.fields[key]}`]: customActivityFieldsValue[key],
};
})
);
};
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
const customActivityTypeInstanceData = await this.getCustomActivityTypeInstanceData(event);
// Create Custom Activity type instance
const customActivityTypeInstance = await this.closeCom.activity.custom.create(
customActivityTypeInstanceData
);
return Promise.resolve({
uid: customActivityTypeInstance.id,
id: customActivityTypeInstance.id,
type: this.integrationName,
password: "",
url: "",
additionalInfo: {
customActivityTypeInstanceData,
},
});
}
async updateEvent(uid: string, event: CalendarEvent): Promise<any> {
return await this.closeComUpdateCustomActivity(uid, event);
}
async deleteEvent(uid: string): Promise<void> {
return await this.closeComDeleteCustomActivity(uid);
}
async getAvailability(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]> {
return Promise.resolve([]);
}
async listCalendars(event?: CalendarEvent): Promise<IntegrationCalendar[]> {
return Promise.resolve([]);
}
}
@@ -0,0 +1 @@
export { default as CalendarService } from "./CalendarService";
@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/closecom",
"version": "0.0.0",
"main": "./index.ts",
"description": "Close is the inside sales CRM of choice for startups and SMBs. Make more calls, send more emails and close more deals starting today.",
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage"
},
"dependencies": {
"@calcom/lib": "*",
"@calcom/prisma": "*"
},
"devDependencies": {
"@calcom/types": "*",
"@types/jest": "^26.0.0",
"jest": "^26.0.0",
"ts-jest": "^26.0.0"
}
}
@@ -0,0 +1,157 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/router";
import { useState, useEffect } from "react";
import { Check, X } from "react-feather";
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 showToast from "@calcom/lib/notification";
import Button from "@calcom/ui/v2/Button";
import { Form, TextField } from "@calcom/ui/v2/form/fields";
const formSchema = z.object({
api_key: z.string(),
});
export default function CloseComSetup() {
const { t } = useLocale();
const router = useRouter();
const [testPassed, setTestPassed] = useState<boolean | undefined>(undefined);
const [testLoading, setTestLoading] = useState<boolean>(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 (
<div className="flex h-screen bg-gray-200">
<div className="m-auto rounded bg-white p-5 md:w-[520px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-y-0 md:space-x-5">
<div>
{/* eslint-disable @next/next/no-img-element */}
<img
src="/api/app-store/closecomothercalendar/icon.svg"
alt="Apple Calendar"
className="h-12 w-12 max-w-2xl"
/>
</div>
<div>
<h1 className="text-gray-600">{t("provide_api_key")}</h1>
<div className="mt-1 text-sm">
{t("generate_api_key_description")}{" "}
<a
className="text-indigo-400"
href="https://app.close.com/settings/api/"
target="_blank"
rel="noopener noreferrer">
Close.com
</a>
. {t("it_stored_encrypted")}
</div>
<div className="my-2 mt-3">
<Form
form={form}
handleSubmit={async (values) => {
const res = await fetch("/api/integrations/closecomothercalendar/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");
}
}}>
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
<Controller
name="api_key"
control={form.control}
render={({ field: { onBlur, onChange } }) => (
<TextField
className="my-0"
onBlur={onBlur}
disabled={testPassed === true}
name="api_key"
placeholder="api_xyz..."
onChange={async (e) => {
onChange(e.target.value);
form.setValue("api_key", e.target.value);
await form.trigger("api_key");
}}
/>
)}
/>
</fieldset>
<div className="mt-5 justify-end space-x-2 sm:mt-4 sm:flex">
<Button type="button" color="secondary" onClick={() => router.back()}>
{t("cancel")}
</Button>
<Button
type="submit"
loading={testLoading}
disabled={testPassed === true}
StartIcon={testPassed !== undefined ? (testPassed ? Check : X) : undefined}
className={
testPassed !== undefined
? testPassed
? " !bg-green-100 !text-green-700 hover:bg-green-100"
: "!border-red-700 bg-red-100 !text-red-700 hover:bg-red-100"
: "secondary"
}
color={testPassed === true ? "minimal" : "secondary"}
onClick={async () => {
const check = await form.trigger("api_key");
if (!check) return;
const api_key = form.getValues("api_key");
setTestLoading(true);
const res = await fetch("/api/integrations/closecomothercalendar/check", {
method: "POST",
body: JSON.stringify({ api_key }),
headers: {
"Content-Type": "application/json",
},
});
if (res.status === 200) {
setTestPassed(true);
} else {
setTestPassed(false);
}
setTestLoading(false);
}}>
{t(
testPassed !== undefined ? (testPassed ? "test_passed" : "test_failed") : "test_api_key"
)}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("save")}
</Button>
</div>
</Form>
</div>
</div>
</div>
</div>
<Toaster position="bottom-right" />
</div>
);
}
@@ -0,0 +1,27 @@
<svg viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M63.9,33.3 L63.9,30.6 C63.9,28.3 63.0,26.2 61.3,24.2 C60.4,23.2 59.2,22.1 57.8,21.2 C57.1,20.7 56.3,20.2 55.4,19.7 C54.7,19.4 54.1,19.0 53.4,18.7 C51.5,17.9 49.5,17.2 47.3,16.6 C47.6,17.5 47.8,18.5 48.0,19.4 C48.2,20.4 48.4,21.4 48.6,22.4 C49.7,22.8 50.9,23.2 51.9,23.7 C52.4,23.9 52.9,24.1 53.4,24.4 C53.6,24.5 53.9,24.6 54.1,24.7 C55.0,25.2 55.7,25.7 56.4,26.2 C59.0,27.9 60.6,29.9 61.1,31.9 C60.6,34.0 59.0,36.0 56.4,37.7 C56.7,38.5 57.0,39.2 57.2,39.9 C57.5,40.8 57.7,41.8 57.8,42.7 C59.2,41.8 60.4,40.7 61.3,39.7 C63.0,37.7 63.9,35.6 63.9,33.3" id="Fill-1" fill="#2F577E"></path>
<path d="M24.9,48.9 C23.8,48.0 22.7,47.1 21.7,46.1 C21.9,46.9 22.1,47.8 22.4,48.6 C23.2,48.7 24.1,48.8 24.9,48.9" id="Fill-2" fill="#2F577E"></path>
<path d="M30.7,46.6 C31.1,46.9 31.5,47.2 31.9,47.5 C32.8,48.0 33.6,48.6 34.4,49.0 C34.5,49.1 34.6,49.2 34.7,49.2 C36.1,49.2 37.6,49.1 39.0,48.9 C39.8,48.8 40.7,48.7 41.5,48.6 C42.5,48.4 43.5,48.2 44.5,48.0 C45.4,47.8 46.4,47.6 47.3,47.3 C49.5,46.7 51.5,46.0 53.4,45.2 C53.2,44.3 53.0,43.4 52.7,42.5 C52.6,42.3 52.6,42.0 52.5,41.7 C52.3,41.2 52.1,40.7 51.9,40.2 C50.9,40.7 49.7,41.1 48.6,41.5 C47.8,41.8 46.9,42.0 46.1,42.2 C46.0,42.3 45.9,42.3 45.8,42.3 C44.8,42.6 43.9,42.8 42.9,42.9 C42.4,43.0 41.9,43.1 41.4,43.2 C39.9,43.5 38.4,43.6 36.8,43.8 C35.2,43.9 33.6,44.0 31.9,44.0 C30.3,44.0 28.7,43.9 27.1,43.8 C28.2,44.8 29.5,45.7 30.7,46.6" id="Fill-3" fill="#2F577E"></path>
<path d="M11.2,21.4 C11.2,21.6 11.3,21.9 11.4,22.2 C11.6,22.7 11.8,23.2 12.0,23.7 C13.0,23.2 14.2,22.8 15.3,22.4 C16.1,22.1 17.0,21.9 17.8,21.7 C17.9,21.6 18.0,21.6 18.1,21.6 C19.1,21.4 20.0,21.1 21.0,21.0 C21.5,20.9 22.0,20.8 22.5,20.7 C24.0,20.5 25.5,20.3 27.1,20.1 C28.7,20.0 30.3,20.0 31.9,20.0 C33.6,20.0 35.2,20.0 36.8,20.1 C35.7,19.1 34.4,18.2 33.2,17.3 C32.8,17.0 32.4,16.7 31.9,16.4 C31.1,15.9 30.3,15.3 29.5,14.9 C29.4,14.8 29.3,14.7 29.2,14.7 C27.7,14.7 26.3,14.9 24.9,15.0 C24.1,15.1 23.2,15.2 22.4,15.3 C21.4,15.5 20.4,15.7 19.4,15.9 C18.5,16.1 17.5,16.3 16.6,16.6 C14.4,17.2 12.4,17.9 10.5,18.7 C10.7,19.6 10.9,20.5 11.2,21.4" id="Fill-4" fill="#2F577E"></path>
<path d="M39.0,15.0 C40.1,15.9 41.2,16.8 42.2,17.8 C42.0,17.0 41.8,16.1 41.5,15.3 C40.7,15.2 39.8,15.1 39.0,15.0" id="Fill-5" fill="#2F577E"></path>
<path d="M2.8,31.9 C3.3,29.9 4.9,27.9 7.5,26.2 C7.2,25.4 6.9,24.7 6.7,24.0 C6.4,23.1 6.2,22.1 6.0,21.2 C4.7,22.1 3.5,23.2 2.6,24.2 C0.9,26.2 6.7e-14,28.3 6.7e-14,30.6 L6.7e-14,33.3 C6.7e-14,35.6 0.9,37.7 2.6,39.7 C3.5,40.7 4.7,41.7 6.0,42.7 C6.8,43.2 7.6,43.7 8.5,44.2 C9.2,44.5 9.8,44.9 10.5,45.2 C12.4,46.0 14.4,46.7 16.6,47.3 C16.3,46.4 16.1,45.4 15.9,44.5 C15.7,43.5 15.5,42.5 15.3,41.5 C14.2,41.1 13.0,40.7 12.0,40.2 C11.5,40.0 11.0,39.8 10.5,39.5 C10.3,39.4 10.0,39.3 9.8,39.2 C8.9,38.7 8.2,38.2 7.5,37.7 C4.9,36.0 3.3,34.0 2.8,31.9" id="Fill-6" fill="#2F577E"></path>
<path d="M39.0,48.9 C37.6,49.0 36.1,49.2 34.7,49.2 C35.4,49.7 36.2,50.1 36.9,50.4 C37.6,49.9 38.3,49.4 39.0,48.9" id="Fill-7" fill="#F4B563"></path>
<path d="M55.5,10.3 L53.6,8.4 C52.0,6.8 49.8,5.9 47.2,5.7 C45.8,5.6 44.3,5.8 42.7,6.1 C41.8,6.2 40.8,6.5 39.9,6.7 C39.2,6.9 38.5,7.2 37.7,7.5 C35.9,8.2 33.9,9.1 31.9,10.2 C32.8,10.7 33.6,11.2 34.5,11.8 C35.3,12.3 36.1,12.9 36.9,13.5 C38.0,12.9 39.1,12.4 40.2,12.0 C40.7,11.8 41.2,11.6 41.7,11.4 C42.0,11.3 42.3,11.3 42.5,11.2 C43.4,10.9 44.3,10.7 45.2,10.5 C48.2,10.0 50.8,10.3 52.6,11.3" id="Fill-8" fill="#F4B563"></path>
<path d="M52.6,11.3 C53.6,13.1 53.9,15.7 53.4,18.7 C54.1,19.0 54.7,19.4 55.4,19.7 C56.3,20.2 57.1,20.7 57.8,21.2 C58.1,19.6 58.3,18.1 58.2,16.7 C58.0,14.1 57.1,11.9 55.5,10.3 C55.2,9.9 54.9,9.7 54.5,9.4" id="Fill-9" fill="#F4B563"></path>
<path d="M41.4,43.2 C41.9,43.1 42.4,43.0 42.9,42.9 C43.9,42.8 44.8,42.6 45.8,42.3 C45.9,42.3 46.0,42.3 46.1,42.2 C47.1,41.2 48.0,40.1 48.9,39.0 C49.4,38.3 50.0,37.6 50.4,36.9 C51.0,36.1 51.6,35.3 52.2,34.5 C52.7,33.6 53.2,32.8 53.7,32.0 C54.8,30.0 55.7,28.0 56.4,26.2 C55.7,25.7 55.0,25.2 54.1,24.7 C53.9,24.6 53.6,24.5 53.4,24.4 C52.9,24.1 52.4,23.9 51.9,23.7 C51.5,24.8 51.0,25.9 50.4,27.0 C50.1,27.7 49.7,28.5 49.2,29.2 C49.2,29.3 49.1,29.4 49.0,29.5 C48.6,30.3 48.0,31.1 47.5,32.0 C47.2,32.4 46.9,32.8 46.6,33.2 C45.7,34.4 44.8,35.7 43.8,36.8 C42.7,38.1 41.6,39.3 40.4,40.4 C39.3,41.6 38.1,42.7 36.8,43.8 C38.4,43.6 39.9,43.5 41.4,43.2" id="Fill-10" fill="#F4B563"></path>
<path d="M24.9,15.0 C26.3,14.8 27.7,14.7 29.2,14.7 C28.5,14.2 27.7,13.8 27.0,13.5 C26.3,13.9 25.6,14.5 24.9,15.0" id="Fill-11" fill="#F4B563"></path>
<path d="M11.3,52.6 C10.3,50.8 10.0,48.2 10.5,45.2 C9.8,44.9 9.2,44.5 8.5,44.2 C7.6,43.7 6.8,43.2 6.0,42.7 C5.8,44.3 5.6,45.8 5.7,47.2 C5.9,49.8 6.8,52.0 8.4,53.6 L10.3,55.5 C11.9,57.1 14.1,58.0 16.7,58.2 C18.1,58.3 19.6,58.1 21.2,57.9 C22.1,57.7 23.1,57.5 24.0,57.2 C24.7,57.0 25.4,56.7 26.2,56.4 C28.0,55.7 30.0,54.8 31.9,53.7 C31.1,53.2 30.3,52.7 29.4,52.2 C28.6,51.6 27.8,51.1 27.0,50.5 C25.9,51.0 24.8,51.5 23.7,51.9 C23.2,52.1 22.7,52.3 22.2,52.5 C21.9,52.6 21.6,52.7 21.4,52.7 C20.5,53.0 19.6,53.2 18.7,53.4 C15.7,53.9 13.1,53.6 11.3,52.6" id="Fill-12" fill="#F4B563"></path>
<path d="M9.8,39.2 C10.0,39.3 10.3,39.4 10.5,39.5 C11.0,39.8 11.5,40.0 12.0,40.2 C12.4,39.1 12.9,38.0 13.5,36.9 C13.8,36.2 14.2,35.4 14.7,34.7 C14.7,34.6 14.8,34.5 14.9,34.4 C15.3,33.6 15.9,32.8 16.4,31.9 C16.7,31.5 17.0,31.1 17.3,30.7 C18.2,29.5 19.1,28.2 20.1,27.1 C21.2,25.8 22.3,24.6 23.5,23.5 C24.6,22.3 25.8,21.2 27.1,20.1 C25.5,20.3 24.0,20.4 22.5,20.7 C22.0,20.8 21.5,20.9 21.0,21.0 C20.0,21.1 19.1,21.4 18.1,21.6 C18.0,21.6 17.9,21.6 17.8,21.7 C16.8,22.7 15.9,23.8 15.0,24.9 C14.5,25.6 13.9,26.3 13.4,27.0 C12.9,27.8 12.3,28.6 11.7,29.4 C11.2,30.3 10.7,31.1 10.2,31.9 C9.1,33.9 8.2,35.9 7.5,37.7 C8.2,38.2 8.9,38.7 9.8,39.2" id="Fill-13" fill="#F4B563"></path>
<path d="M15.0,39.0 C14.8,37.6 14.7,36.1 14.7,34.7 C14.2,35.4 13.8,36.2 13.5,36.9 C13.9,37.6 14.5,38.3 15.0,39.0" id="Fill-14" fill="#5CAFCD"></path>
<path d="M11.3,11.3 C13.1,10.3 15.7,10.0 18.7,10.5 C19.0,9.8 19.4,9.2 19.7,8.5 C20.2,7.6 20.7,6.8 21.2,6.1 C19.6,5.8 18.1,5.6 16.7,5.7 C14.1,5.9 11.9,6.8 10.3,8.4 C9.9,8.7 9.7,9.0 9.4,9.4 C9.7,9.0 9.9,8.7 10.3,8.4 L8.4,10.3 C6.8,11.9 5.9,14.1 5.7,16.7 C5.6,18.1 5.8,19.6 6.0,21.2 C6.2,22.1 6.4,23.1 6.7,24.0 C6.9,24.7 7.2,25.4 7.5,26.2 C8.2,28.0 9.1,30.0 10.2,32.0 C10.7,31.1 11.2,30.3 11.7,29.4 C12.3,28.6 12.9,27.8 13.4,27.0 C12.9,25.9 12.4,24.8 12.0,23.7 C11.8,23.2 11.6,22.7 11.4,22.2 C11.3,21.9 11.2,21.6 11.2,21.4 C10.9,20.5 10.7,19.6 10.5,18.7 C10.0,15.7 10.3,13.1 11.3,11.3" id="Fill-15" fill="#5CAFCD"></path>
<path d="M48.9,24.9 C49.0,26.3 49.2,27.8 49.2,29.2 C49.7,28.5 50.1,27.7 50.4,27.0 C50.0,26.3 49.4,25.6 48.9,24.9" id="Fill-16" fill="#5CAFCD"></path>
<path d="M43.2,22.5 C43.1,22.0 43.0,21.5 42.9,21.0 C42.8,20.0 42.5,19.1 42.3,18.1 C42.3,18.0 42.3,17.9 42.2,17.8 C41.2,16.8 40.1,15.9 39.0,15.0 C38.3,14.5 37.6,13.9 36.9,13.5 C36.1,12.9 35.3,12.3 34.5,11.7 C33.6,11.2 32.8,10.7 31.9,10.2 C30.0,9.1 28.0,8.2 26.2,7.5 C25.7,8.2 25.2,8.9 24.7,9.8 C24.6,10.0 24.5,10.3 24.4,10.5 C24.1,11.0 23.9,11.5 23.7,12.0 C24.8,12.4 25.9,12.9 27.0,13.5 C27.7,13.8 28.5,14.2 29.2,14.7 C29.3,14.7 29.4,14.8 29.5,14.9 C30.3,15.3 31.1,15.9 31.9,16.4 C32.4,16.7 32.8,17.0 33.2,17.3 C34.4,18.2 35.7,19.1 36.8,20.1 C38.1,21.2 39.3,22.3 40.4,23.5 C41.6,24.6 42.7,25.8 43.8,27.1 C43.6,25.5 43.4,24.0 43.2,22.5" id="Fill-17" fill="#5CAFCD"></path>
<path d="M20.7,41.4 C20.8,41.9 20.9,42.4 21.0,42.9 C21.1,43.9 21.4,44.8 21.6,45.8 C21.6,45.9 21.6,46.0 21.7,46.1 C22.7,47.1 23.8,48.0 24.9,48.9 C25.6,49.4 26.3,50.0 27.0,50.4 C27.8,51.0 28.6,51.6 29.4,52.2 C30.3,52.7 31.1,53.2 31.9,53.7 C33.9,54.8 35.9,55.7 37.7,56.4 C38.2,55.7 38.7,55.0 39.2,54.1 C39.3,53.9 39.4,53.6 39.5,53.4 C39.8,52.9 40.0,52.4 40.2,51.9 C39.1,51.5 38.0,51.0 36.9,50.4 C36.2,50.1 35.4,49.7 34.7,49.2 C34.6,49.2 34.5,49.1 34.4,49.0 C33.6,48.6 32.8,48.0 31.9,47.5 C31.5,47.2 31.1,46.9 30.7,46.6 C29.5,45.7 28.2,44.8 27.1,43.8 C25.8,42.7 24.6,41.6 23.5,40.4 C22.3,39.3 21.2,38.1 20.1,36.8 C20.3,38.4 20.4,39.9 20.7,41.4" id="Fill-18" fill="#5CAFCD"></path>
<path d="M53.6,55.5 L55.5,53.6 C57.1,52.0 58.0,49.8 58.2,47.2 C58.3,45.8 58.1,44.3 57.8,42.7 C57.7,41.8 57.5,40.8 57.2,39.9 C57.0,39.2 56.7,38.5 56.4,37.7 C55.7,35.9 54.8,33.9 53.7,32 C53.2,32.8 52.7,33.6 52.2,34.5 C51.6,35.3 51.0,36.1 50.4,36.9 C51.0,38.0 51.5,39.1 51.9,40.2 C52.1,40.7 52.3,41.2 52.5,41.7 C52.6,42.0 52.6,42.3 52.7,42.5 C53.0,43.4 53.2,44.3 53.4,45.2 C53.9,48.2 53.6,50.8 52.6,52.6 C50.8,53.6 48.2,53.9 45.2,53.4 C44.9,54.1 44.5,54.7 44.2,55.4 C43.7,56.3 43.2,57.1 42.7,57.8 C44.3,58.1 45.8,58.3 47.2,58.2 C49.8,58.0 52.0,57.1 53.6,55.5" id="Fill-19" fill="#5CAFCD"></path>
<path d="M17.3,30.7 C17.0,31.1 16.7,31.5 16.4,32.0 C15.9,32.8 15.3,33.6 14.9,34.4 C14.8,34.5 14.7,34.6 14.7,34.7 C14.7,36.1 14.8,37.6 15.0,39.0 C15.1,39.8 15.2,40.7 15.3,41.5 C15.5,42.5 15.7,43.5 15.9,44.5 C16.1,45.4 16.3,46.4 16.6,47.3 C17.2,49.5 17.9,51.5 18.7,53.4 C19.6,53.2 20.5,53.0 21.4,52.7 C21.6,52.6 21.9,52.6 22.2,52.5 C22.7,52.3 23.2,52.1 23.7,51.9 C23.2,50.9 22.8,49.7 22.4,48.6 C22.1,47.8 21.9,46.9 21.7,46.1 C21.6,46.0 21.6,45.9 21.6,45.8 C21.4,44.8 21.1,43.9 21.0,42.9 C20.9,42.4 20.8,41.9 20.7,41.4 C20.4,39.9 20.3,38.4 20.1,36.8 C20.0,35.2 19.9,33.6 19.9,32.0 C19.9,30.3 20.0,28.7 20.1,27.1 C19.1,28.2 18.2,29.5 17.3,30.7" id="Fill-20" fill="#47B178"></path>
<path d="M48.9,39.0 C48.0,40.1 47.1,41.2 46.1,42.2 C46.9,42.0 47.8,41.8 48.6,41.5 C48.7,40.7 48.8,39.8 48.9,39.0" id="Fill-21" fill="#47B178"></path>
<path d="M15.0,24.9 C15.9,23.8 16.8,22.8 17.8,21.7 C17.0,21.9 16.1,22.1 15.3,22.4 C15.2,23.2 15.1,24.1 15.0,24.9" id="Fill-22" fill="#47B178"></path>
<path d="M46.6,33.2 C46.9,32.8 47.2,32.4 47.5,32.0 C48.0,31.1 48.6,30.3 49.0,29.5 C49.1,29.4 49.2,29.3 49.2,29.2 C49.2,27.8 49.0,26.3 48.9,24.9 C48.8,24.1 48.7,23.2 48.6,22.4 C48.4,21.4 48.2,20.4 48.0,19.4 C47.8,18.5 47.6,17.5 47.3,16.6 C46.7,14.4 46.0,12.4 45.2,10.5 C44.3,10.7 43.4,10.9 42.5,11.2 C42.3,11.3 42.0,11.3 41.7,11.4 C41.2,11.6 40.7,11.8 40.2,12.0 C40.7,13.0 41.1,14.2 41.5,15.3 C41.8,16.1 42.0,17.0 42.2,17.8 C42.3,17.9 42.3,18.0 42.3,18.1 C42.5,19.1 42.8,20.0 42.9,21.0 C43.0,21.5 43.1,22.0 43.2,22.5 C43.4,24.0 43.6,25.5 43.8,27.1 C43.9,28.7 43.9,30.3 43.9,32.0 C43.9,33.6 43.9,35.2 43.8,36.8 C44.8,35.7 45.7,34.4 46.6,33.2" id="Fill-23" fill="#47B178"></path>
<path d="M42.7,6.0 C41.7,4.7 40.7,3.5 39.7,2.6 C37.7,0.9 35.6,0 33.3,0 L30.6,0 C28.3,0 26.2,0.9 24.2,2.6 C23.2,3.5 22.1,4.7 21.2,6.0 C20.7,6.8 20.2,7.6 19.7,8.5 C19.4,9.2 19.0,9.8 18.7,10.5 C17.9,12.4 17.2,14.4 16.6,16.6 C17.5,16.3 18.5,16.1 19.4,15.9 C20.4,15.7 21.4,15.5 22.4,15.3 C22.8,14.2 23.2,13.0 23.7,12.0 C23.9,11.5 24.1,11.0 24.4,10.5 C24.5,10.3 24.6,10.0 24.7,9.8 C25.2,8.9 25.7,8.2 26.2,7.5 C27.9,4.9 29.9,3.3 31.9,2.8 C34.0,3.3 36.0,4.9 37.7,7.5 C38.5,7.2 39.2,6.9 39.9,6.7" id="Fill-24" fill="#47B178"></path>
<path d="M30.6,63.9 L33.3,63.9 C35.6,63.9 37.7,63.0 39.7,61.3 C40.7,60.4 41.7,59.2 42.7,57.8 C43.2,57.1 43.7,56.3 44.2,55.4 C44.5,54.7 44.9,54.1 45.2,53.4 C46.0,51.5 46.7,49.5 47.3,47.3 L44.5,48.0 C43.5,48.2 42.5,48.4 41.5,48.6 C41.1,49.7 40.7,50.9 40.2,51.9 C40.0,52.4 39.8,52.9 39.5,53.4 C39.4,53.6 39.3,53.9 39.2,54.1 C38.7,55.0 38.2,55.7 37.7,56.4 C36.0,59.0 34.0,60.6 31.9,61.1 C29.9,60.6 27.9,59.0 26.2,56.4 C25.4,56.7 24.7,57.0 24.0,57.2 L21.2,57.8 C22.1,59.2 23.2,60.4 24.2,61.3 C26.2,63.0 28.3,63.9 30.6,63.9" id="Fill-26" fill="#47B178"></path>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

@@ -0,0 +1,12 @@
jest.mock("@calcom/lib/logger", () => ({
debug: jest.fn(),
error: jest.fn(),
log: jest.fn(),
getChildLogger: jest.fn(),
}));
jest.mock("@calcom/lib/crypto", () => ({
symmetricDecrypt: () => `{
"userApiKey": "test"
}`,
}));
@@ -0,0 +1,293 @@
import CloseComCalendarService from "@calcom/closecom/lib/CalendarService";
import CloseCom from "@calcom/lib/CloseCom";
import { CalendarEvent } from "@calcom/types/Calendar";
jest.mock("@calcom/lib/CloseCom", () => {
return class {
constructor() {
/* Mock */
}
};
});
afterEach(() => {
jest.resetAllMocks();
});
const mockedCredential = {
id: 1,
key: "",
appId: "",
type: "",
userId: 1,
};
// getCloseComGenericLeadId
test("check generic lead generator: already exists", async () => {
CloseCom.prototype.lead = {
list: () => ({
data: [{ name: "From Cal.com", id: "abc" }],
}),
} as any;
const closeComCalendarService = new CloseComCalendarService(mockedCredential);
const spy = jest.spyOn(closeComCalendarService, "getCloseComGenericLeadId");
const mockedGetCloseComGenericLeadId = spy.getMockImplementation();
if (mockedGetCloseComGenericLeadId) {
const id = await mockedGetCloseComGenericLeadId();
expect(id).toEqual("abc");
}
});
// getCloseComGenericLeadId
test("check generic lead generator: doesn't exist", async () => {
CloseCom.prototype.lead = {
list: () => ({
data: [],
}),
create: () => ({ id: "def" }),
} as any;
const closeComCalendarService = new CloseComCalendarService(mockedCredential);
const spy = jest.spyOn(closeComCalendarService, "getCloseComGenericLeadId");
const mockedGetCloseComGenericLeadId = spy.getMockImplementation();
if (mockedGetCloseComGenericLeadId) {
const id = await mockedGetCloseComGenericLeadId();
expect(id).toEqual("def");
}
});
// getCloseComContactIds
test("retrieve contact IDs: all exist", async () => {
const attendees = [
{ email: "test1@example.com", id: "test1" },
{ email: "test2@example.com", id: "test2" },
];
const event = {
attendees,
} as CalendarEvent;
CloseCom.prototype.contact = {
search: () => ({ data: attendees }),
} as any;
const closeComCalendarService = new CloseComCalendarService(mockedCredential);
const spy = jest.spyOn(closeComCalendarService, "getCloseComContactIds");
const mockedGetCloseComContactIds = spy.getMockImplementation();
if (mockedGetCloseComContactIds) {
const contactIds = await mockedGetCloseComContactIds(event, "leadId");
expect(contactIds).toEqual(["test1", "test2"]);
}
});
// getCloseComContactIds
test("retrieve contact IDs: some don't exist", async () => {
const attendees = [{ email: "test1@example.com", id: "test1" }, { email: "test2@example.com" }];
const event = {
attendees,
} as CalendarEvent;
CloseCom.prototype.contact = {
search: () => ({ data: [{ emails: [{ email: "test1@example.com" }], id: "test1" }] }),
create: () => ({ id: "test3" }),
} as any;
const closeComCalendarService = new CloseComCalendarService(mockedCredential);
const spy = jest.spyOn(closeComCalendarService, "getCloseComContactIds");
const mockedGetCloseComContactIds = spy.getMockImplementation();
if (mockedGetCloseComContactIds) {
const contactIds = await mockedGetCloseComContactIds(event, "leadId");
expect(contactIds).toEqual(["test1", "test3"]);
}
});
// getCloseComCustomActivityTypeFieldsIds
test("retrieve custom fields for custom activity type: type doesn't exist, no field created", async () => {
CloseCom.prototype.activity = {
type: {
get: () => [],
},
} as any;
CloseCom.prototype.customActivity = {
type: {
get: () => ({ data: [] }),
create: () => ({ id: "type1" }),
},
} as any;
CloseCom.prototype.customField = {
activity: {
create: (data: { name: string }) => ({ id: `field${data.name.length}${data.name[0]}` }),
},
} as any;
const closeComCalendarService = new CloseComCalendarService(mockedCredential);
const spy = jest.spyOn(closeComCalendarService, "getCloseComCustomActivityTypeFieldsIds");
const mockedGetCloseComCustomActivityTypeFieldsIds = spy.getMockImplementation();
if (mockedGetCloseComCustomActivityTypeFieldsIds) {
const contactIds = await mockedGetCloseComCustomActivityTypeFieldsIds();
expect(contactIds).toEqual({
activityType: "type1",
fields: {
attendee: "field9A",
dateTime: "field11D",
timezone: "field9T",
organizer: "field9O",
additionalNotes: "field16A",
},
});
}
});
// getCloseComCustomActivityTypeFieldsIds
test("retrieve custom fields for custom activity type: type exists, no field created", async () => {
CloseCom.prototype.activity = {
type: {
get: () => [],
},
} as any;
CloseCom.prototype.customActivity = {
type: {
get: () => ({ data: [{ id: "typeX", name: "Cal.com Activity" }] }),
},
} as any;
CloseCom.prototype.customField = {
activity: {
get: () => ({ data: [{ id: "fieldY", custom_activity_type_id: "typeX", name: "Attendees" }] }),
create: (data: { name: string }) => ({ id: `field${data.name.length}${data.name[0]}` }),
},
} as any;
const closeComCalendarService = new CloseComCalendarService(mockedCredential);
const spy = jest.spyOn(closeComCalendarService, "getCloseComCustomActivityTypeFieldsIds");
const mockedGetCloseComCustomActivityTypeFieldsIds = spy.getMockImplementation();
if (mockedGetCloseComCustomActivityTypeFieldsIds) {
const contactIds = await mockedGetCloseComCustomActivityTypeFieldsIds();
expect(contactIds).toEqual({
activityType: "typeX",
fields: {
attendee: "fieldY",
dateTime: "field11D",
timezone: "field9T",
organizer: "field9O",
additionalNotes: "field16A",
},
});
}
});
// getCustomActivityTypeInstanceData
test("prepare data to create custom activity type instance: two attendees, no additional notes", async () => {
const attendees = [
{ email: "test1@example.com", id: "test1", timeZone: "America/Montevideo" },
{ email: "test2@example.com" },
];
const now = new Date();
const event = {
attendees,
startTime: now.toISOString(),
} as CalendarEvent;
CloseCom.prototype.activity = {
type: {
get: () => [],
},
} as any;
CloseCom.prototype.customActivity = {
type: {
get: () => ({ data: [] }),
create: () => ({ id: "type1" }),
},
} as any;
CloseCom.prototype.customField = {
activity: {
create: (data: { name: string }) => ({ id: `field${data.name.length}${data.name[0]}` }),
},
} as any;
CloseCom.prototype.lead = {
list: () => ({
data: [],
}),
create: () => ({ id: "def" }),
} as any;
const closeComCalendarService = new CloseComCalendarService(mockedCredential);
const spy = jest.spyOn(closeComCalendarService, "getCustomActivityTypeInstanceData");
const mockedGetCustomActivityTypeInstanceData = spy.getMockImplementation();
if (mockedGetCustomActivityTypeInstanceData) {
const data = await mockedGetCustomActivityTypeInstanceData(event);
expect(data).toEqual({
custom_activity_type_id: "type1",
lead_id: "def",
"custom.field9A": ["test3"],
"custom.field11D": now.toISOString(),
"custom.field9T": "America/Montevideo",
"custom.field9O": "test1",
"custom.field16A": null,
});
}
});
// getCustomActivityTypeInstanceData
test("prepare data to create custom activity type instance: one attendees, with additional notes", async () => {
const attendees = [{ email: "test1@example.com", id: "test1", timeZone: "America/Montevideo" }];
const now = new Date();
const event = {
attendees,
startTime: now.toISOString(),
additionalNotes: "Some comment!",
} as CalendarEvent;
CloseCom.prototype.activity = {
type: {
get: () => [],
},
} as any;
CloseCom.prototype.customActivity = {
type: {
get: () => ({ data: [] }),
create: () => ({ id: "type1" }),
},
} as any;
CloseCom.prototype.customField = {
activity: {
create: (data: { name: string }) => ({ id: `field${data.name.length}${data.name[0]}` }),
},
} as any;
CloseCom.prototype.lead = {
list: () => ({
data: [{ name: "From Cal.com", id: "abc" }],
}),
} as any;
const closeComCalendarService = new CloseComCalendarService(mockedCredential);
const spy = jest.spyOn(closeComCalendarService, "getCustomActivityTypeInstanceData");
const mockedGetCustomActivityTypeInstanceData = spy.getMockImplementation();
if (mockedGetCustomActivityTypeInstanceData) {
const data = await mockedGetCustomActivityTypeInstanceData(event);
expect(data).toEqual({
custom_activity_type_id: "type1",
lead_id: "abc",
"custom.field9A": null,
"custom.field11D": now.toISOString(),
"custom.field9T": "America/Montevideo",
"custom.field9O": "test1",
"custom.field16A": "Some comment!",
});
}
});
+2
View File
@@ -1,6 +1,7 @@
// import * as example from "./_example";
import * as applecalendar from "./applecalendar";
import * as caldavcalendar from "./caldavcalendar";
import * as closecomothercalendar from "./closecomothercalendar";
import * as dailyvideo from "./dailyvideo";
import * as exchange2013calendar from "./exchange2013calendar";
import * as exchange2016calendar from "./exchange2016calendar";
@@ -26,6 +27,7 @@ const appStore = {
// example,
applecalendar,
caldavcalendar,
closecomothercalendar,
dailyvideo,
googlecalendar,
googlevideo,
+281 -43
View File
@@ -1,7 +1,22 @@
import logger from "@calcom/lib/logger";
import { Person } from "@calcom/types/Calendar";
export type CloseComLead = {
companyName?: string;
contactName?: string;
contactEmail?: string;
description?: string;
};
export type CloseComLeadCreateResult = {
status_id: string;
status_label: string;
display_name: string;
addresses: { [key: string]: any }[];
name: string;
contacts: { [key: string]: any }[];
[key: CloseComCustomActivityCustomField<string>]: string;
id: string;
};
export type CloseComStatus = {
@@ -10,66 +25,245 @@ export type CloseComStatus = {
label: string;
};
export default class CloseCom {
private apiUrl = "https://api.close.com/api/v1/";
private apiKey: string | undefined = undefined;
export type CloseComCustomActivityTypeCreate = {
name: string;
description: string;
};
constructor() {
if (!process.env.CLOSECOM_API_KEY) throw Error("Close.com Api Key not present");
this.apiKey = process.env.CLOSECOM_API_KEY;
export type CloseComContactSearch = {
data: {
__object_type: "contact";
emails: {
email: string;
type: string;
}[];
id: string;
lead_id: string;
name: string;
}[];
cursor: null;
};
export type CloseComCustomActivityTypeGet = {
data: {
api_create_only: boolean;
created_by: string;
date_created: string;
date_updated: string;
description: string;
editable_with_roles: string[];
fields: CloseComCustomActivityFieldGet["data"][number][];
id: string;
name: string;
organization_id: string;
updated_by: string;
}[];
cursor: null;
};
export type CloseComCustomActivityFieldCreate = {
custom_activity_type_id: string;
name: string;
type: string;
required: boolean;
accepts_multiple_values: boolean;
editable_with_roles: string[];
};
export type CloseComCustomActivityFieldGet = {
data: {
custom_activity_type_id: string;
id: string;
name: string;
description: string;
type: string;
required: boolean;
accepts_multiple_values: boolean;
editable_with_roles: string[];
created_by: string;
updated_by: string;
date_created: string;
date_updated: string;
organization_id: string;
}[];
};
export type CloseComCustomActivityCreate = {
custom_activity_type_id: string;
lead_id: string;
[key: CloseComCustomActivityCustomField<string>]: string;
};
export type typeCloseComCustomActivityGet = {
organization_id: string;
contact_id: any;
date_updated: string;
user_name: string;
created_by_name: "Bruce Wayne";
id: string;
created_by: string;
status: string;
user_id: string;
users: any[];
lead_id: string;
_type: string;
updated_by: string;
custom_activity_type_id: string;
date_created: string;
updated_by_name: string;
[key: CloseComCustomActivityCustomField<string>]: string;
};
type CloseComCustomActivityCustomField<T extends string> = `custom.${T}`;
const environmentApiKey = process.env.CLOSECOM_API_KEY || "";
/**
* This class to instance communicating to Close.com 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 Close.com is just for
* one account only, not configurable by any user at any moment.
*/
export default class CloseCom {
private apiUrl = "https://api.close.com/api/v1";
private apiKey: string | undefined = undefined;
private log: typeof logger;
constructor(providedApiKey = "") {
if (!providedApiKey && !environmentApiKey) throw Error("Close.com Api Key not present");
this.apiKey = providedApiKey || environmentApiKey;
this.log = logger.getChildLogger({ prefix: [`[[lib] close.com`] });
}
public static lead(): [CloseCom, CloseComLead] {
return [new this(), {} as CloseComLead];
}
public me = async () => {
return this._get({ urlPath: "/me/" });
};
public contact = {
search: async ({ emails }: { emails: string[] }) => {
search: async ({ emails }: { emails: string[] }): Promise<CloseComContactSearch> => {
return this._post({
url: `${this.apiUrl}/data/search/`,
data: closeComQueries.contact.getContactSearchQuery(emails),
urlPath: "/data/search/",
data: closeComQueries.contact.search(emails),
});
},
create: async (data: {
attendee: Person;
leadId: string;
}): Promise<CloseComContactSearch["data"][number]> => {
return this._post({ urlPath: "/contact/", data: closeComQueries.contact.create(data) });
},
};
public lead = {
status: async () => {
return this._get({ url: `${this.apiUrl}/status/lead/` });
list: async ({
query,
}: {
query: { [key: string]: any };
}): Promise<{ data: { [key: string]: any }[] }> => {
return this._get({ urlPath: "/lead", query });
},
create: async (data: CloseComLead) => {
status: async () => {
return this._get({ urlPath: `/status/lead/` });
},
create: async (data: CloseComLead): Promise<CloseComLeadCreateResult> => {
return this._post({
url: `${this.apiUrl}/lead/`,
data: closeComQueries.lead.getCreateLeadQuery(data),
urlPath: "/lead/",
data: closeComQueries.lead.create(data),
});
},
};
private _get = async ({ url, ...rest }: { url: string }) => {
return await this._request({ url, method: "get", ...rest });
public customActivity = {
type: {
create: async (
data: CloseComCustomActivityTypeCreate
): Promise<CloseComCustomActivityTypeGet["data"][number]> => {
return this._post({
urlPath: "/custom_activity",
data: closeComQueries.customActivity.type.create(data),
});
},
get: async (): Promise<CloseComCustomActivityTypeGet> => {
return this._get({ urlPath: "/custom_activity" });
},
},
};
private _post = async ({ url, data, ...rest }: { url: string; data: Record<string, unknown> }) => {
return this._request({ url, method: "post", data, ...rest });
public customField = {
activity: {
create: async (
data: CloseComCustomActivityFieldCreate
): Promise<CloseComCustomActivityFieldGet["data"][number]> => {
return this._post({ urlPath: "/custom_field/activity/", data });
},
get: async ({ query }: { query: { [key: string]: any } }): Promise<CloseComCustomActivityFieldGet> => {
return this._get({ urlPath: "/custom_field/activity/", query });
},
},
};
private _delete = async ({ url, ...rest }: { url: string }) => {
return this._request({ url, method: "delete", ...rest });
public activity = {
custom: {
create: async (
data: CloseComCustomActivityCreate
): Promise<CloseComCustomActivityTypeGet["data"][number]> => {
return this._post({ urlPath: "/activity/custom/", data });
},
delete: async (uuid: string) => {
return this._delete({ urlPath: `/activity/custom/${uuid}/` });
},
update: async (
uuid: string,
data: Partial<CloseComCustomActivityCreate>
): Promise<CloseComCustomActivityTypeGet["data"][number]> => {
return this._put({ urlPath: `/activity/custom/${uuid}/`, data });
},
},
};
private _get = async ({ urlPath, query }: { urlPath: string; query?: { [key: string]: any } }) => {
return await this._request({ urlPath, method: "get", query });
};
private _post = async ({ urlPath, data }: { urlPath: string; data: Record<string, unknown> }) => {
return this._request({ urlPath, method: "post", data });
};
private _put = async ({ urlPath, data }: { urlPath: string; data: Record<string, unknown> }) => {
return this._request({ urlPath, method: "put", data });
};
private _delete = async ({ urlPath }: { urlPath: string }) => {
return this._request({ urlPath, method: "delete" });
};
private _request = async ({
url,
urlPath,
data,
...rest
method,
query,
}: {
url: string;
urlPath: string;
method: string;
query?: { [key: string]: any };
data?: Record<string, unknown>;
}) => {
this.log.debug(method, urlPath, query, data);
const credentials = Buffer.from(`${this.apiKey}:`).toString("base64");
const headers = {
Authorization: `Basic ${credentials}`,
"Content-Type": "application/json",
};
return await fetch(url, { headers, body: JSON.stringify(data), ...rest }).then(async (response) => {
const queryString = query ? `?${new URLSearchParams(query).toString()}` : "";
return await fetch(`${this.apiUrl}${urlPath}${queryString}`, {
headers,
method,
body: JSON.stringify(data),
}).then(async (response) => {
if (!response.ok) {
const message = `An error has occured: ${response.status}`;
const message = `[Close.com app] An error has occured: ${response.status}`;
this.log.error(await response.json());
throw new Error(message);
}
return await response.json();
@@ -79,11 +273,11 @@ export default class CloseCom {
export const closeComQueries = {
contact: {
getContactSearchQuery(contactEmails: string[]) {
search(contactEmails: string[]) {
return {
limit: null,
_fields: {
contact: ["id", "name"],
contact: ["id", "name", "emails"],
},
query: {
negate: false,
@@ -130,24 +324,68 @@ export const closeComQueries = {
sort: [],
};
},
},
lead: {
getCreateLeadQuery({ companyName, contactEmail, contactName }: CloseComLead) {
create(data: { attendee: Person; leadId: string }) {
return {
name: companyName,
contacts: [
{
name: contactName,
email: contactEmail,
emails: [
{
type: "office",
email: contactEmail,
},
],
},
],
lead_id: data.leadId,
name: data.attendee.name ?? data.attendee.email,
emails: [{ email: data.attendee.email, type: "office" }],
};
},
},
lead: {
create({ companyName, contactEmail, contactName, description }: CloseComLead) {
return {
name: companyName,
...(description ? { description } : {}),
...(contactEmail && contactName
? {
contacts: [
{
name: contactName,
email: contactEmail,
emails: [
{
type: "office",
email: contactEmail,
},
],
},
],
}
: {}),
};
},
},
customActivity: {
type: {
create({ name, description }: CloseComCustomActivityTypeCreate) {
return {
name: name,
description: description,
api_create_only: false,
editable_with_roles: ["admin"],
};
},
},
},
customField: {
activity: {
create({
custom_activity_type_id,
name,
type,
required,
accepts_multiple_values,
}: CloseComCustomActivityFieldCreate) {
return {
custom_activity_type_id,
name,
type,
required,
accepts_multiple_values,
editable_with_roles: [],
};
},
},
},
};
+1
View File
@@ -22,6 +22,7 @@ export function deriveAppDictKeyFromType(appType: string, dict: Record<string, u
}
// Transform as last resort removing all underscores, applies to `hubspot_other_calendar` to be `hubsporothercalendar`
// and `closecom_other_calendar` to be `closecomothercalendar`
const appTypeVariant3 = appType.replace(/_/g, "");
handlers = dict[appTypeVariant3];
if (handlers) {
+8 -4
View File
@@ -1,17 +1,17 @@
import { Prisma } from "@prisma/client";
import dotEnv from "dotenv";
import fs from "fs";
import path from "path";
import prisma from ".";
dotEnv.config({ path: "../../.env.appStore" });
export const seededForm = {
id: "948ae412-d995-4865-875a-48302588de03",
name: "Seeded Form - Pro",
};
// eslint-disable-next-line @typescript-eslint/no-var-requires
require("dotenv").config({ path: "../../.env.appStore" });
async function seedAppData() {
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
@@ -175,7 +175,9 @@ export default async function main() {
await createApp("exchange2013-calendar", "exchange2013calendar", ["calendar"], "exchange2013_calendar");
await createApp("exchange2016-calendar", "exchange2016calendar", ["calendar"], "exchange2016_calendar");
try {
const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web;
const { client_secret, client_id, redirect_uris } = JSON.parse(
process.env.GOOGLE_API_CREDENTIALS || ""
).web;
await createApp("google-calendar", "googlecalendar", ["calendar"], "google_calendar", {
client_id,
client_secret,
@@ -238,6 +240,8 @@ export default async function main() {
client_secret: process.env.HUBSPOT_CLIENT_SECRET,
});
}
// 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");
await createApp("wipe-my-cal", "wipemycalother", ["other"], "wipemycal_other");
if (process.env.GIPHY_API_KEY) {
await createApp("giphy", "giphy", ["other"], "giphy_other", {
+1 -1
View File
@@ -82,7 +82,7 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
ref: forwardedRef,
className: classNames(
// base styles independent what type of button it is
"inline-flex justify-center items-center text-sm font-medium",
"inline-flex justify-center items-center text-sm font-medium relative",
// different styles depending on size
size === "base" && "h-9 px-4 py-2.5 rounded-md ",
size === "lg" && "h-[36px] px-4 py-2.5 rounded-md",
+11 -3
View File
@@ -6101,6 +6101,14 @@
dependencies:
"@types/istanbul-lib-report" "*"
"@types/jest@^26.0.0":
version "26.0.24"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.24.tgz#943d11976b16739185913a1936e0de0c4a7d595a"
integrity sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w==
dependencies:
jest-diff "^26.0.0"
pretty-format "^26.0.0"
"@types/jest@^27.5.1":
version "27.5.1"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.5.1.tgz#2c8b6dc6ff85c33bcd07d0b62cb3d19ddfdb3ab9"
@@ -14497,7 +14505,7 @@ jest-config@^28.1.3:
slash "^3.0.0"
strip-json-comments "^3.1.1"
jest-diff@^26.6.2:
jest-diff@^26.0.0, jest-diff@^26.6.2:
version "26.6.2"
resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-26.6.2.tgz#1aa7468b52c3a68d7d5c5fdcdfcd5e49bd164394"
integrity sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==
@@ -17363,7 +17371,7 @@ next-api-middleware@^1.0.1:
dependencies:
debug "^4.3.2"
next-auth@^4.10.3:
next-auth@^4.10.3, next-auth@^4.9.0:
version "4.10.3"
resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-4.10.3.tgz#0a952dd5004fd2ac2ba414c990922cf9b33951a3"
integrity sha512-7zc4aXYc/EEln7Pkcsn21V1IevaTZsMLJwapfbnKA4+JY0+jFzWbt5p/ljugesGIrN4VOZhpZIw50EaFZyghJQ==
@@ -19066,7 +19074,7 @@ pretty-error@^4.0.0:
lodash "^4.17.20"
renderkid "^3.0.0"
pretty-format@^26.6.2:
pretty-format@^26.0.0, pretty-format@^26.6.2:
version "26.6.2"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93"
integrity sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==