diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 9aed1a1c5d..5c6d1ad94c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/app-store/_pages/setup/index.tsx b/packages/app-store/_pages/setup/index.tsx index edc69d4576..09818d602c 100644 --- a/packages/app-store/_pages/setup/index.tsx +++ b/packages/app-store/_pages/setup/index.tsx @@ -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 }) => { diff --git a/packages/app-store/apps.browser.generated.tsx b/packages/app-store/apps.browser.generated.tsx index de5c7960ac..ed1a807f80 100644 --- a/packages/app-store/apps.browser.generated.tsx +++ b/packages/app-store/apps.browser.generated.tsx @@ -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")), diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index 9a4f6abe62..30d382ff7d 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -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"), diff --git a/packages/app-store/closecomothercalendar/README.mdx b/packages/app-store/closecomothercalendar/README.mdx new file mode 100644 index 0000000000..39cec15859 --- /dev/null +++ b/packages/app-store/closecomothercalendar/README.mdx @@ -0,0 +1,8 @@ +--- +items: + - /api/app-store/closecomothercalendar/screenshot.png +--- + + + +Close is the inside sales CRM of choice for startups and SMBs. Make more calls, send more emails and close more deals starting today. diff --git a/packages/app-store/closecomothercalendar/_metadata.ts b/packages/app-store/closecomothercalendar/_metadata.ts new file mode 100644 index 0000000000..5092c47c4a --- /dev/null +++ b/packages/app-store/closecomothercalendar/_metadata.ts @@ -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; diff --git a/packages/app-store/closecomothercalendar/api/_getAdd.ts b/packages/app-store/closecomothercalendar/api/_getAdd.ts new file mode 100644 index 0000000000..2b95ec6eef --- /dev/null +++ b/packages/app-store/closecomothercalendar/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("closecom", session.user?.id); + return res.status(200).json({ url: "/apps/closecom/setup" }); +} diff --git a/packages/app-store/closecomothercalendar/api/_postAdd.ts b/packages/app-store/closecomothercalendar/api/_postAdd.ts new file mode 100644 index 0000000000..9c1d371dbf --- /dev/null +++ b/packages/app-store/closecomothercalendar/api/_postAdd.ts @@ -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); diff --git a/packages/app-store/closecomothercalendar/api/_postCheck.ts b/packages/app-store/closecomothercalendar/api/_postCheck.ts new file mode 100644 index 0000000000..f7e0b58e65 --- /dev/null +++ b/packages/app-store/closecomothercalendar/api/_postCheck.ts @@ -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); diff --git a/packages/app-store/closecomothercalendar/api/add.ts b/packages/app-store/closecomothercalendar/api/add.ts new file mode 100644 index 0000000000..9480fb9259 --- /dev/null +++ b/packages/app-store/closecomothercalendar/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/closecomothercalendar/api/check.ts b/packages/app-store/closecomothercalendar/api/check.ts new file mode 100644 index 0000000000..97ed132226 --- /dev/null +++ b/packages/app-store/closecomothercalendar/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/closecomothercalendar/api/index.ts b/packages/app-store/closecomothercalendar/api/index.ts new file mode 100644 index 0000000000..766afa6405 --- /dev/null +++ b/packages/app-store/closecomothercalendar/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/closecomothercalendar/components/InstallAppButton.tsx b/packages/app-store/closecomothercalendar/components/InstallAppButton.tsx new file mode 100644 index 0000000000..8aab36df70 --- /dev/null +++ b/packages/app-store/closecomothercalendar/components/InstallAppButton.tsx @@ -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, + })} + > + ); +} diff --git a/packages/app-store/closecomothercalendar/components/index.ts b/packages/app-store/closecomothercalendar/components/index.ts new file mode 100644 index 0000000000..0d6008d4ca --- /dev/null +++ b/packages/app-store/closecomothercalendar/components/index.ts @@ -0,0 +1 @@ +export { default as InstallAppButton } from "./InstallAppButton"; diff --git a/packages/app-store/closecomothercalendar/config.json b/packages/app-store/closecomothercalendar/config.json new file mode 100644 index 0000000000..c1bb3f781a --- /dev/null +++ b/packages/app-store/closecomothercalendar/config.json @@ -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 +} diff --git a/packages/app-store/closecomothercalendar/index.ts b/packages/app-store/closecomothercalendar/index.ts new file mode 100644 index 0000000000..5373eb04ef --- /dev/null +++ b/packages/app-store/closecomothercalendar/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/closecomothercalendar/jest.config.ts b/packages/app-store/closecomothercalendar/jest.config.ts new file mode 100644 index 0000000000..c6a095247a --- /dev/null +++ b/packages/app-store/closecomothercalendar/jest.config.ts @@ -0,0 +1,15 @@ +import type { Config } from "@jest/types"; + +const config: Config.InitialOptions = { + verbose: true, + roots: [""], + testMatch: ["**/test/lib/**/*.(spec|test).(ts|tsx|js)"], + transform: { + "^.+\\.ts?$": "ts-jest", + }, + transformIgnorePatterns: ["/node_modules/", "^.+\\.module\\.(css|sass|scss)$"], + testEnvironment: "jsdom", + setupFiles: ["/test/globals.ts"], +}; + +export default config; diff --git a/packages/app-store/closecomothercalendar/lib/CalendarService.ts b/packages/app-store/closecomothercalendar/lib/CalendarService.ts new file mode 100644 index 0000000000..56c0b7b8f7 --- /dev/null +++ b/packages/app-store/closecomothercalendar/lib/CalendarService.ts @@ -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 => { + 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 => { + // 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 { + 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 { + return await this.closeComUpdateCustomActivity(uid, event); + } + + async deleteEvent(uid: string): Promise { + return await this.closeComDeleteCustomActivity(uid); + } + + 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/closecomothercalendar/lib/index.ts b/packages/app-store/closecomothercalendar/lib/index.ts new file mode 100644 index 0000000000..e168c149df --- /dev/null +++ b/packages/app-store/closecomothercalendar/lib/index.ts @@ -0,0 +1 @@ +export { default as CalendarService } from "./CalendarService"; diff --git a/packages/app-store/closecomothercalendar/package.json b/packages/app-store/closecomothercalendar/package.json new file mode 100644 index 0000000000..e1e07d322e --- /dev/null +++ b/packages/app-store/closecomothercalendar/package.json @@ -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" + } +} diff --git a/packages/app-store/closecomothercalendar/pages/setup/index.tsx b/packages/app-store/closecomothercalendar/pages/setup/index.tsx new file mode 100644 index 0000000000..fef2d96b7a --- /dev/null +++ b/packages/app-store/closecomothercalendar/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 { 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(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 */} + + + + {t("provide_api_key")} + + + {t("generate_api_key_description")}{" "} + + Close.com + + . {t("it_stored_encrypted")} + + + { + 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"); + } + }}> + + ( + { + onChange(e.target.value); + form.setValue("api_key", e.target.value); + await form.trigger("api_key"); + }} + /> + )} + /> + + + router.back()}> + {t("cancel")} + + { + 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" + )} + + + {t("save")} + + + + + + + + + + ); +} diff --git a/packages/app-store/closecomothercalendar/static/icon.svg b/packages/app-store/closecomothercalendar/static/icon.svg new file mode 100644 index 0000000000..9956b5bd4f --- /dev/null +++ b/packages/app-store/closecomothercalendar/static/icon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/app-store/closecomothercalendar/static/screenshot.png b/packages/app-store/closecomothercalendar/static/screenshot.png new file mode 100644 index 0000000000..5f591db4bc Binary files /dev/null and b/packages/app-store/closecomothercalendar/static/screenshot.png differ diff --git a/packages/app-store/closecomothercalendar/test/globals.ts b/packages/app-store/closecomothercalendar/test/globals.ts new file mode 100644 index 0000000000..ecff2dea7a --- /dev/null +++ b/packages/app-store/closecomothercalendar/test/globals.ts @@ -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" + }`, +})); diff --git a/packages/app-store/closecomothercalendar/test/lib/CalendarService.test.ts b/packages/app-store/closecomothercalendar/test/lib/CalendarService.test.ts new file mode 100644 index 0000000000..a1fa4bbaab --- /dev/null +++ b/packages/app-store/closecomothercalendar/test/lib/CalendarService.test.ts @@ -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!", + }); + } +}); diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index 67896296d2..df0d07b3be 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -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, diff --git a/packages/lib/CloseCom.ts b/packages/lib/CloseCom.ts index 37a5ba3c92..23891de58e 100644 --- a/packages/lib/CloseCom.ts +++ b/packages/lib/CloseCom.ts @@ -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; + 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; +}; + +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; +}; + +type CloseComCustomActivityCustomField = `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 => { 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 => { + 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 => { 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 => { + return this._post({ + urlPath: "/custom_activity", + data: closeComQueries.customActivity.type.create(data), + }); + }, + get: async (): Promise => { + return this._get({ urlPath: "/custom_activity" }); + }, + }, }; - private _post = async ({ url, data, ...rest }: { url: string; data: Record }) => { - return this._request({ url, method: "post", data, ...rest }); + + public customField = { + activity: { + create: async ( + data: CloseComCustomActivityFieldCreate + ): Promise => { + return this._post({ urlPath: "/custom_field/activity/", data }); + }, + get: async ({ query }: { query: { [key: string]: any } }): Promise => { + 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 => { + return this._post({ urlPath: "/activity/custom/", data }); + }, + delete: async (uuid: string) => { + return this._delete({ urlPath: `/activity/custom/${uuid}/` }); + }, + update: async ( + uuid: string, + data: Partial + ): Promise => { + 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 }) => { + return this._request({ urlPath, method: "post", data }); + }; + private _put = async ({ urlPath, data }: { urlPath: string; data: Record }) => { + 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; }) => { + 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: [], + }; + }, + }, + }, }; diff --git a/packages/lib/deriveAppDictKeyFromType.ts b/packages/lib/deriveAppDictKeyFromType.ts index 1d814cfd16..9bdcaaf136 100644 --- a/packages/lib/deriveAppDictKeyFromType.ts +++ b/packages/lib/deriveAppDictKeyFromType.ts @@ -22,6 +22,7 @@ export function deriveAppDictKeyFromType(appType: string, dict: Record