diff --git a/.env.example b/.env.example index 4673ae3889..4a226e555e 100644 --- a/.env.example +++ b/.env.example @@ -130,3 +130,9 @@ EMAIL_SERVER_PASSWORD='' # Set the following value to true if you wish to enable Team Impersonation NEXT_PUBLIC_TEAM_IMPERSONATION=false + +# Close.com internal CRM +CLOSECOM_API_KEY= + +# Sendgrid internal email sender +SENDGRID_API_KEY= diff --git a/apps/web/package.json b/apps/web/package.json index 18efc8d9a8..386ff59529 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -97,6 +97,7 @@ "nodemailer": "^6.7.7", "otplib": "^12.0.1", "qrcode": "^1.5.1", + "raw-body": "^2.5.1", "react": "^18.2.0", "react-colorful": "^5.5.1", "react-date-picker": "^8.3.6", diff --git a/apps/web/pages/api/auth/signup.ts b/apps/web/pages/api/auth/signup.ts index cbc65fe493..cf8d0b674a 100644 --- a/apps/web/pages/api/auth/signup.ts +++ b/apps/web/pages/api/auth/signup.ts @@ -1,6 +1,7 @@ import { IdentityProvider } from "@prisma/client"; import { NextApiRequest, NextApiResponse } from "next"; +import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; import prisma from "@calcom/prisma"; import { hashPassword } from "@lib/auth"; @@ -71,14 +72,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // If user has been invitedTo a team, we accept the membership if (user.invitedTo) { - await prisma.membership.update({ - where: { - userId_teamId: { userId: user.id, teamId: user.invitedTo }, - }, - data: { - accepted: true, - }, + const team = await prisma.team.findFirst({ + where: { id: user.invitedTo }, }); + + if (team) { + const membership = await prisma.membership.update({ + where: { + userId_teamId: { userId: user.id, teamId: user.invitedTo }, + }, + data: { + accepted: true, + }, + }); + + // Sync Services: Close.com + closeComUpsertTeamUser(team, user, membership.role); + } } res.status(201).json({ message: "Created user" }); diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index ca5b5ae8b3..7658396c26 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -28,6 +28,7 @@ import isOutOfBounds, { BookingDateInPastError } from "@calcom/lib/isOutOfBounds import logger from "@calcom/lib/logger"; import { getLuckyUser } from "@calcom/lib/server"; import { defaultResponder } from "@calcom/lib/server"; +import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager"; import prisma, { userSelect } from "@calcom/prisma"; import { extendedBookingCreateBody } from "@calcom/prisma/zod-utils"; import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime"; @@ -673,6 +674,14 @@ async function handler(req: NextApiRequest) { let booking: Booking | null = null; try { booking = await createBooking(); + // Sync Services + await syncServicesUpdateWebUser( + currentUser && + (await prisma.user.findFirst({ + where: { id: currentUser.id }, + select: { id: true, email: true, name: true, plan: true, username: true, createdDate: true }, + })) + ); evt.uid = booking?.uid ?? null; } catch (_err) { const err = getErrorFromUnknown(_err); diff --git a/apps/web/pages/api/sync/helpscout/index.ts b/apps/web/pages/api/sync/helpscout/index.ts new file mode 100644 index 0000000000..94ae16bc81 --- /dev/null +++ b/apps/web/pages/api/sync/helpscout/index.ts @@ -0,0 +1,89 @@ +import { createHmac } from "crypto"; +import type { NextApiRequest, NextApiResponse } from "next"; +import getRawBody from "raw-body"; +import z from "zod"; + +import { default as webPrisma } from "@calcom/prisma"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +const helpscoutRequestBodySchema = z.object({ + customer: z.object({ + email: z.string().email(), + }), +}); + +/** + * API for Helpscout to retrieve key information about a user from a ticket + * Note: HelpScout expects a JSON with a `html` prop to show its content as HTML + */ +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" }); + + const hsSignature = req.headers["x-helpscout-signature"]; + if (!hsSignature) return res.status(400).end(); + + if (!process.env.CALENDSO_ENCRYPTION_KEY) return res.status(500).end(); + + const rawBody = await getRawBody(req); + const parsedBody = helpscoutRequestBodySchema.safeParse(JSON.parse(rawBody.toString())); + + if (!parsedBody.success) return res.status(400).end(); + + const calculatedSig = createHmac("sha1", process.env.CALENDSO_ENCRYPTION_KEY) + .update(rawBody) + .digest("base64"); + + if (req.headers["x-helpscout-signature"] !== calculatedSig) return res.status(400).end(); + + const user = await webPrisma.user.findFirst({ + where: { + email: parsedBody.data.customer.email, + }, + select: { + username: true, + id: true, + plan: true, + createdDate: true, + }, + }); + + if (!user) return res.status(200).json({ html: "User not found" }); + + const lastBooking = await webPrisma.attendee.findFirst({ + where: { + email: parsedBody.data.customer.email, + }, + select: { + booking: { + select: { + createdAt: true, + }, + }, + }, + orderBy: { + booking: { + createdAt: "desc", + }, + }, + }); + + return res.status(200).json({ + html: ` + + `, + }); +} diff --git a/apps/web/pages/api/teams.ts b/apps/web/pages/api/teams.ts index 9cdc9eb33f..10403e0b71 100644 --- a/apps/web/pages/api/teams.ts +++ b/apps/web/pages/api/teams.ts @@ -1,5 +1,7 @@ +import { MembershipRole } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; +import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; import prisma from "@calcom/prisma"; import { getSession } from "@lib/auth"; @@ -13,6 +15,20 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return; } + const ownerUser = await prisma.user.findFirst({ + where: { + id: session.user.id, + }, + select: { + id: true, + username: true, + createdDate: true, + name: true, + plan: true, + email: true, + }, + }); + if (req.method === "POST") { const slug = slugify(req.body.name); @@ -37,11 +53,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) data: { teamId: createTeam.id, userId: session.user.id, - role: "OWNER", + role: MembershipRole.OWNER, accepted: true, }, }); + // Sync Services: Close.com + closeComUpsertTeamUser(createTeam, ownerUser, MembershipRole.OWNER); + return res.status(201).json({ message: "Team created" }); } diff --git a/apps/web/pages/api/teams/[team]/index.ts b/apps/web/pages/api/teams/[team]/index.ts index 26722fa701..79f18236fb 100644 --- a/apps/web/pages/api/teams/[team]/index.ts +++ b/apps/web/pages/api/teams/[team]/index.ts @@ -1,6 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getTeamWithMembers } from "@calcom/lib/server/queries/teams"; +import { closeComDeleteTeam } from "@calcom/lib/sync/SyncServiceManager"; import prisma from "@calcom/prisma"; import { getSession } from "@lib/auth"; @@ -45,11 +46,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) userId_teamId: { userId: session.user.id, teamId }, }, }); - await prisma.team.delete({ + const deletedTeam = await prisma.team.delete({ where: { id: teamId, }, }); + + // Sync Services: Close.com + closeComDeleteTeam(deletedTeam); + return res.status(204).send(null); } } diff --git a/apps/web/pages/api/teams/[team]/membership.ts b/apps/web/pages/api/teams/[team]/membership.ts index 31247c1b57..ea86bd7617 100644 --- a/apps/web/pages/api/teams/[team]/membership.ts +++ b/apps/web/pages/api/teams/[team]/membership.ts @@ -1,5 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import { closeComDeleteTeamMembership } from "@calcom/lib/sync/SyncServiceManager"; import prisma from "@calcom/prisma"; import { getSession } from "@lib/auth"; @@ -63,11 +64,19 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Cancel a membership (invite) if (req.method === "DELETE") { + const user = await prisma.user.findFirst({ + where: { + id: req.body.userId, + }, + }); await prisma.membership.delete({ where: { userId_teamId: { userId: req.body.userId, teamId: parseInt(req.query.team as string) }, }, }); + // Sync Services: Close.com + closeComDeleteTeamMembership(user); + return res.status(204).send(null); } diff --git a/apps/web/pages/api/user/me.ts b/apps/web/pages/api/user/me.ts index 0b7ecf430c..759b261eeb 100644 --- a/apps/web/pages/api/user/me.ts +++ b/apps/web/pages/api/user/me.ts @@ -1,6 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer"; +import { deleteWebUser as syncServicesDeleteWebUser } from "@calcom/lib/sync/SyncServiceManager"; import prisma from "@calcom/prisma"; import { getSession } from "@lib/auth"; @@ -24,8 +25,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) id: session.user?.id, }, select: { + id: true, email: true, metadata: true, + username: true, + createdDate: true, + name: true, + plan: true, }, }); // Delete from stripe @@ -37,6 +43,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }, }); + // Sync Services + syncServicesDeleteWebUser(user); + return res.status(204).end(); } } diff --git a/packages/app-store/closecomothercalendar/lib/CalendarService.ts b/packages/app-store/closecomothercalendar/lib/CalendarService.ts index 56c0b7b8f7..dcd07ef1a5 100644 --- a/packages/app-store/closecomothercalendar/lib/CalendarService.ts +++ b/packages/app-store/closecomothercalendar/lib/CalendarService.ts @@ -1,7 +1,8 @@ import { Credential } from "@prisma/client"; import z from "zod"; -import CloseCom, { CloseComCustomActivityCreate } from "@calcom/lib/CloseCom"; +import CloseCom, { CloseComFieldOptions } from "@calcom/lib/CloseCom"; +import { getCustomActivityTypeInstanceData } from "@calcom/lib/CloseComeUtils"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import logger from "@calcom/lib/logger"; import type { @@ -19,7 +20,7 @@ const apiKeySchema = z.object({ const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || ""; // Cal.com Custom Activity Fields -const calComCustomActivityFields: [string, string, boolean, boolean][] = [ +const calComCustomActivityFields: CloseComFieldOptions = [ // Field name, field type, required?, multiple values? ["Attendees", "contact", false, true], ["Date & Time", "datetime", true, false], @@ -72,194 +73,29 @@ export default class CloseComCalendarService implements Calendar { } } - private async closeComUpdateCustomActivity(uid: string, event: CalendarEvent) { - const customActivityTypeInstanceData = await this.getCustomActivityTypeInstanceData(event); + closeComUpdateCustomActivity = async (uid: string, event: CalendarEvent) => { + const customActivityTypeInstanceData = await getCustomActivityTypeInstanceData( + event, + calComCustomActivityFields, + this.closeCom + ); // 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) { + closeComDeleteCustomActivity = async (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); + const customActivityTypeInstanceData = await getCustomActivityTypeInstanceData( + event, + calComCustomActivityFields, + this.closeCom + ); // Create Custom Activity type instance const customActivityTypeInstance = await this.closeCom.activity.custom.create( customActivityTypeInstanceData diff --git a/packages/app-store/closecomothercalendar/test/lib/CalendarService.test.ts b/packages/app-store/closecomothercalendar/test/lib/CalendarService.test.ts index a1fa4bbaab..e8a0f52a53 100644 --- a/packages/app-store/closecomothercalendar/test/lib/CalendarService.test.ts +++ b/packages/app-store/closecomothercalendar/test/lib/CalendarService.test.ts @@ -1,5 +1,10 @@ -import CloseComCalendarService from "@calcom/closecom/lib/CalendarService"; import CloseCom from "@calcom/lib/CloseCom"; +import { + getCloseComContactIds, + getCustomActivityTypeInstanceData, + getCloseComCustomActivityTypeFieldsIds, + getCloseComGenericLeadId, +} from "@calcom/lib/CloseComeUtils"; import { CalendarEvent } from "@calcom/types/Calendar"; jest.mock("@calcom/lib/CloseCom", () => { @@ -14,14 +19,6 @@ afterEach(() => { jest.resetAllMocks(); }); -const mockedCredential = { - id: 1, - key: "", - appId: "", - type: "", - userId: 1, -}; - // getCloseComGenericLeadId test("check generic lead generator: already exists", async () => { CloseCom.prototype.lead = { @@ -30,13 +27,9 @@ test("check generic lead generator: already exists", async () => { }), } 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"); - } + const closeCom = new CloseCom("someKey"); + const id = await getCloseComGenericLeadId(closeCom); + expect(id).toEqual("abc"); }); // getCloseComGenericLeadId @@ -48,13 +41,9 @@ test("check generic lead generator: doesn't exist", async () => { 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"); - } + const closeCom = new CloseCom("someKey"); + const id = await getCloseComGenericLeadId(closeCom); + expect(id).toEqual("def"); }); // getCloseComContactIds @@ -72,13 +61,9 @@ test("retrieve contact IDs: all exist", async () => { 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"]); - } + const closeCom = new CloseCom("someKey"); + const contactIds = await getCloseComContactIds(event.attendees, "leadId", closeCom); + expect(contactIds).toEqual(["test1", "test2"]); }); // getCloseComContactIds @@ -94,13 +79,9 @@ test("retrieve contact IDs: some don't exist", async () => { 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"]); - } + const closeCom = new CloseCom("someKey"); + const contactIds = await getCloseComContactIds(event.attendees, "leadId", closeCom); + expect(contactIds).toEqual(["test1", "test3"]); }); // getCloseComCustomActivityTypeFieldsIds @@ -124,22 +105,19 @@ test("retrieve custom fields for custom activity type: type doesn't exist, no fi }, } 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", - }, - }); - } + const closeCom = new CloseCom("someKey"); + const contactIds = await getCloseComCustomActivityTypeFieldsIds( + [ + ["Attendees", "", true, true], + ["Date & Time", "", true, true], + ["Time Zone", "", true, true], + ], + closeCom + ); + expect(contactIds).toEqual({ + activityType: "type1", + fields: ["field9A", "field11D", "field9T"], + }); }); // getCloseComCustomActivityTypeFieldsIds @@ -163,22 +141,19 @@ test("retrieve custom fields for custom activity type: type exists, no field cre }, } 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", - }, - }); - } + const closeCom = new CloseCom("someKey"); + const contactIds = await getCloseComCustomActivityTypeFieldsIds( + [ + ["Attendees", "", true, true], + ["Date & Time", "", true, true], + ["Time Zone", "", true, true], + ], + closeCom + ); + expect(contactIds).toEqual({ + activityType: "typeX", + fields: ["fieldY", "field11D", "field9T"], + }); }); // getCustomActivityTypeInstanceData @@ -221,21 +196,23 @@ test("prepare data to create custom activity type instance: two attendees, no ad 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, - }); - } + const closeCom = new CloseCom("someKey"); + const data = await getCustomActivityTypeInstanceData( + event, + [ + ["Attendees", "", true, true], + ["Date & Time", "", true, true], + ["Time Zone", "", true, true], + ], + closeCom + ); + expect(data).toEqual({ + custom_activity_type_id: "type1", + lead_id: "def", + "custom.field9A": ["test3"], + "custom.field11D": now.toISOString(), + "custom.field9T": "America/Montevideo", + }); }); // getCustomActivityTypeInstanceData @@ -275,19 +252,21 @@ test("prepare data to create custom activity type instance: one attendees, with }), } 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!", - }); - } + const closeCom = new CloseCom("someKey"); + const data = await getCustomActivityTypeInstanceData( + event, + [ + ["Attendees", "", true, true], + ["Date & Time", "", true, true], + ["Time Zone", "", true, true], + ], + closeCom + ); + expect(data).toEqual({ + custom_activity_type_id: "type1", + lead_id: "abc", + "custom.field9A": null, + "custom.field11D": now.toISOString(), + "custom.field9T": "America/Montevideo", + }); }); diff --git a/packages/lib/CloseCom.ts b/packages/lib/CloseCom.ts index 23891de58e..4b0517d0e2 100644 --- a/packages/lib/CloseCom.ts +++ b/packages/lib/CloseCom.ts @@ -1,13 +1,14 @@ import logger from "@calcom/lib/logger"; -import { Person } from "@calcom/types/Calendar"; export type CloseComLead = { - companyName?: string; + companyName?: string | null | undefined; contactName?: string; contactEmail?: string; description?: string; }; +export type CloseComFieldOptions = [string, string, boolean, boolean][]; + export type CloseComLeadCreateResult = { status_id: string; status_label: string; @@ -61,33 +62,55 @@ export type CloseComCustomActivityTypeGet = { cursor: null; }; -export type CloseComCustomActivityFieldCreate = { +export type CloseComCustomActivityFieldCreate = CloseComCustomContactFieldCreate & { custom_activity_type_id: string; - name: string; - type: string; - required: boolean; - accepts_multiple_values: boolean; - editable_with_roles: string[]; }; -export type CloseComCustomActivityFieldGet = { +export type CloseComCustomContactFieldGet = { data: { - custom_activity_type_id: string; id: string; name: string; description: string; type: string; - required: boolean; + choices?: string[]; accepts_multiple_values: boolean; editable_with_roles: string[]; - created_by: string; - updated_by: string; date_created: string; date_updated: string; + created_by: string; + updated_by: string; organization_id: string; }[]; }; +export type CloseComCustomContactFieldCreate = { + name: string; + description?: string; + type: string; + required: boolean; + accepts_multiple_values: boolean; + editable_with_roles: string[]; + choices?: string[]; +}; + +export type CloseComCustomActivityFieldGet = { + data: { + id: string; + name: string; + description: string; + type: string; + choices?: string[]; + accepts_multiple_values: boolean; + editable_with_roles: string[]; + date_created: string; + date_updated: string; + created_by: string; + updated_by: string; + organization_id: string; + custom_activity_type_id: string; + }[]; +}; + export type CloseComCustomActivityCreate = { custom_activity_type_id: string; lead_id: string; @@ -131,13 +154,9 @@ export default class CloseCom { private log: typeof logger; constructor(providedApiKey = "") { + this.log = logger.getChildLogger({ prefix: [`[[lib] close.com`] }); 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 () => { @@ -152,11 +171,29 @@ export default class CloseCom { }); }, create: async (data: { - attendee: Person; + person: { name: string | null; email: string }; leadId: string; }): Promise => { return this._post({ urlPath: "/contact/", data: closeComQueries.contact.create(data) }); }, + update: async ({ + contactId, + ...data + }: { + person: { name: string; email: string }; + contactId: string; + leadId?: string; + }): Promise => { + return this._put({ + urlPath: `/contact/${contactId}/`, + data: closeComQueries.contact.update(data), + }); + }, + delete: async (contactId: string) => { + return this._delete({ + urlPath: `/contact/${contactId}/`, + }); + }, }; public lead = { @@ -170,12 +207,21 @@ export default class CloseCom { status: async () => { return this._get({ urlPath: `/status/lead/` }); }, + update: async (leadId: string, data: CloseComLead): Promise => { + return this._put({ + urlPath: `/lead/${leadId}`, + data, + }); + }, create: async (data: CloseComLead): Promise => { return this._post({ urlPath: "/lead/", data: closeComQueries.lead.create(data), }); }, + delete: async (leadId: string) => { + return this._delete({ urlPath: `/lead/${leadId}/` }); + }, }; public customActivity = { @@ -205,6 +251,21 @@ export default class CloseCom { return this._get({ urlPath: "/custom_field/activity/", query }); }, }, + contact: { + create: async ( + data: CloseComCustomContactFieldCreate + ): Promise => { + return this._post({ urlPath: "/custom_field/contact/", data }); + }, + get: async ({ query }: { query: { [key: string]: any } }): Promise => { + return this._get({ urlPath: "/custom_field/contact/", query }); + }, + }, + shared: { + get: async ({ query }: { query: { [key: string]: any } }): Promise => { + return this._get({ urlPath: "/custom_field/shared/", query }); + }, + }, }; public activity = { @@ -324,11 +385,19 @@ export const closeComQueries = { sort: [], }; }, - create(data: { attendee: Person; leadId: string }) { + create(data: { person: { name: string | null; email: string }; leadId: string }) { return { lead_id: data.leadId, - name: data.attendee.name ?? data.attendee.email, - emails: [{ email: data.attendee.email, type: "office" }], + name: data.person.name ?? data.person.email, + emails: [{ email: data.person.email, type: "office" }], + }; + }, + update({ person, leadId, ...rest }: { person: { name: string; email: string }; leadId?: string }) { + return { + ...(leadId && { lead_id: leadId }), + name: person.name ?? person.email, + emails: [{ email: person.email, type: "office" }], + ...rest, }; }, }, diff --git a/packages/lib/CloseComeUtils.ts b/packages/lib/CloseComeUtils.ts new file mode 100644 index 0000000000..83f1a59350 --- /dev/null +++ b/packages/lib/CloseComeUtils.ts @@ -0,0 +1,206 @@ +import { CalendarEvent } from "@calcom/types/Calendar"; + +import CloseCom, { + CloseComCustomActivityCreate, + CloseComCustomActivityFieldGet, + CloseComCustomContactFieldGet, + CloseComFieldOptions, + CloseComLead, +} from "./CloseCom"; + +export async function getCloseComContactIds( + persons: { email: string; name: string | null }[], + closeCom: CloseCom, + leadFromCalComId?: string +): Promise { + // Check if persons exist or to see if any should be created + const closeComContacts = await closeCom.contact.search({ + emails: persons.map((att) => att.email), + }); + // NOTE: If contact is duplicated in Close.com we will get more results + // messing around with the expected number of contacts retrieved + if (closeComContacts.data.length < persons.length && leadFromCalComId) { + // Create missing contacts + const personsEmails = persons.map((att) => att.email); + // Existing contacts based on persons emails: contacts may have more + // than one email, we just need the one used by the event. + const existingContactsEmails = closeComContacts.data.flatMap((cont) => + cont.emails.filter((em) => personsEmails.includes(em.email)).map((ems) => ems.email) + ); + const nonExistingContacts = persons.filter((person) => !existingContactsEmails.includes(person.email)); + const createdContacts = await Promise.all( + nonExistingContacts.map( + async (per) => + await closeCom.contact.create({ + person: per, + leadId: leadFromCalComId, + }) + ) + ); + if (createdContacts.length === nonExistingContacts.length) { + // All non existent contacts where created + return Promise.resolve( + closeComContacts.data.map((cont) => cont.id).concat(createdContacts.map((cont) => cont.id)) + ); + } else { + return Promise.reject("Some contacts were not possible to create in Close.com"); + } + } else { + return Promise.resolve(closeComContacts.data.map((cont) => cont.id)); + } +} + +export async function getCustomActivityTypeInstanceData( + event: CalendarEvent, + customFields: CloseComFieldOptions, + closeCom: CloseCom +): Promise { + // Get Cal.com generic Lead + const leadFromCalComId = await getCloseComLeadId(closeCom); + // Get Contacts ids + const contactsIds = await getCloseComContactIds(event.attendees, closeCom, leadFromCalComId); + // Get Custom Activity Type id + const customActivityTypeAndFieldsIds = await getCloseComCustomActivityTypeFieldsIds(customFields, closeCom); + // Prepare values for each Custom Activity Fields + const customActivityFieldsValues = [ + contactsIds.length > 1 ? contactsIds.slice(1) : null, // Attendee + event.startTime, // Date & Time + event.attendees[0].timeZone, // Time Zone + contactsIds[0], // Organizer + event.additionalNotes ?? null, // Additional Notes + ]; + // Preparing Custom Activity Instance data for Close.com + return Object.assign( + {}, + { + custom_activity_type_id: customActivityTypeAndFieldsIds.activityType, + lead_id: leadFromCalComId, + }, // This is to add each field as `"custom.FIELD_ID": "value"` in the object + ...customActivityTypeAndFieldsIds.fields.map((fieldId: string, index: number) => { + return { + [`custom.${fieldId}`]: customActivityFieldsValues[index], + }; + }) + ); +} + +export async function getCustomFieldsIds( + entity: keyof CloseCom["customField"], + customFields: CloseComFieldOptions, + closeCom: CloseCom, + custom_activity_type_id?: string +): Promise { + // Get Custom Activity Fields + const allFields: CloseComCustomActivityFieldGet | CloseComCustomContactFieldGet = + await closeCom.customField[entity].get({ + query: { _fields: ["name", "id"].concat(entity === "activity" ? ["custom_activity_type_id"] : []) }, + }); + let relevantFields: { [key: string]: any }[]; + if (entity === "activity") { + relevantFields = (allFields as CloseComCustomActivityFieldGet).data.filter( + (fie) => fie.custom_activity_type_id === custom_activity_type_id + ); + } else { + relevantFields = allFields.data as CloseComCustomActivityFieldGet["data"]; + } + const customFieldsNames = relevantFields.map((fie) => fie.name); + const customFieldsExist = customFields.map((cusFie) => customFieldsNames.includes(cusFie[0])); + return await Promise.all( + customFieldsExist.map(async (exist, idx) => { + if (!exist && entity !== "shared") { + const [name, type, required, multiple] = customFields[idx]; + let created: { [key: string]: any }; + if (entity === "activity" && custom_activity_type_id) { + created = await closeCom.customField[entity].create({ + name, + type, + required, + accepts_multiple_values: multiple, + editable_with_roles: [], + custom_activity_type_id, + }); + return created.id; + } else { + if (entity === "contact") { + created = await closeCom.customField[entity].create({ + name, + type, + required, + accepts_multiple_values: multiple, + editable_with_roles: [], + }); + return created.id; + } + } + } else { + const index = customFieldsNames.findIndex((val) => val === customFields[idx][0]); + if (index >= 0) { + return relevantFields[index].id; + } else { + throw Error("Couldn't find the field index"); + } + } + }) + ); +} + +export async function getCloseComCustomActivityTypeFieldsIds( + customFields: CloseComFieldOptions, + closeCom: CloseCom +) { + // Check if Custom Activity Type exists + const customActivities = await closeCom.customActivity.type.get(); + const calComCustomActivity = customActivities.data.filter((act) => act.name === "Cal.com Activity"); + if (calComCustomActivity.length > 0) { + // Cal.com Custom Activity type exist + // Get Custom Activity Type fields ids + const fields = await getCustomFieldsIds("activity", customFields, closeCom, calComCustomActivity[0].id); + return { + activityType: calComCustomActivity[0].id, + fields, + }; + } else { + // Cal.com Custom Activity type doesn't exist + // Create Custom Activity Type + const { id: activityType } = await closeCom.customActivity.type.create({ + name: "Cal.com Activity", + description: "Bookings in your Cal.com account", + }); + // Create Custom Activity Fields + const fields = await Promise.all( + customFields.map(async ([name, type, required, multiple]) => { + const creation = await closeCom.customField.activity.create({ + custom_activity_type_id: activityType, + name, + type, + required, + accepts_multiple_values: multiple, + editable_with_roles: [], + }); + return creation.id; + }) + ); + return { + activityType, + fields, + }; + } +} + +export async function getCloseComLeadId( + closeCom: CloseCom, + leadInfo: CloseComLead = { + companyName: "From Cal.com", + description: "Generic Lead for Contacts created by Cal.com", + } +): Promise { + const closeComLeadNames = await closeCom.lead.list({ query: { _fields: ["name", "id"] } }); + const searchLeadFromCalCom = closeComLeadNames.data.filter((lead) => lead.name === leadInfo.companyName); + if (searchLeadFromCalCom.length === 0) { + // No Lead exists, create it + const createdLeadFromCalCom = await closeCom.lead.create(leadInfo); + return createdLeadFromCalCom.id; + } else { + return searchLeadFromCalCom[0].id; + } +} diff --git a/packages/lib/package.json b/packages/lib/package.json index 69bf143e73..8404d74d50 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -15,6 +15,7 @@ "@calcom/config": "*", "@calcom/dayjs": "*", "@prisma/client": "^4.2.1", + "@sendgrid/client": "^7.7.0", "bcryptjs": "^2.4.3", "ical.js": "^1.4.0", "ics": "^2.37.0", diff --git a/packages/lib/sync/ISyncService.ts b/packages/lib/sync/ISyncService.ts new file mode 100644 index 0000000000..888efc9636 --- /dev/null +++ b/packages/lib/sync/ISyncService.ts @@ -0,0 +1,95 @@ +// import { DeploymentType } from "@prisma/admin-client"; +import { User } from "@prisma/client"; + +import logger from "@calcom/lib/logger"; +import { default as webPrisma } from "@calcom/prisma"; + +export type UserInfo = { + email: string; + name: string | null; + id: number; + username: string | null; + createdDate: Date; +}; + +export type TeamInfoType = { + name: string | undefined | null; +}; + +export type WebUserInfoType = UserInfo & { + plan: User["plan"]; +}; + +export type ConsoleUserInfoType = UserInfo & { + plan: "CLOUD" | "SELFHOSTED"; // DeploymentType; +}; + +export interface IUserDeletion { + delete(info: T): Promise; +} + +export interface IUserCreation { + create(info: T): Promise; + update(info: T): Promise; + upsert?: never; +} + +export interface IUserUpsertion { + create?: never; + update?: never; + upsert(info: T): Promise; +} + +export interface ISyncService { + ready(): boolean; + web: { + user: (IUserCreation | IUserUpsertion) & IUserDeletion; + }; + console: { + user: IUserCreation | IUserUpsertion; + }; +} + +export default class SyncServiceCore { + protected serviceName: string; + protected service: any; + protected log: typeof logger; + + constructor(serviceName: string, service: any, log: typeof logger) { + this.serviceName = serviceName; + this.log = log; + try { + this.service = new service(); + } catch (e) { + this.log.warn("Couldn't instantiate sync service:", (e as Error).message); + } + } + + ready() { + return this.service !== undefined; + } + + async getUserLastBooking(user: { email: string }): Promise<{ booking: { createdAt: Date } | null } | null> { + return await webPrisma.attendee.findFirst({ + where: { + email: user.email, + }, + select: { + booking: { + select: { + createdAt: true, + }, + }, + }, + orderBy: { + booking: { + createdAt: "desc", + }, + }, + }); + } +} + +export interface ISyncServices { + new (): ISyncService; +} diff --git a/packages/lib/sync/SyncServiceManager.ts b/packages/lib/sync/SyncServiceManager.ts new file mode 100644 index 0000000000..990eaab306 --- /dev/null +++ b/packages/lib/sync/SyncServiceManager.ts @@ -0,0 +1,168 @@ +import { MembershipRole } from "@prisma/client"; + +import logger from "@calcom/lib/logger"; + +import { ConsoleUserInfoType, TeamInfoType, WebUserInfoType } from "./ISyncService"; +import services from "./services"; +import CloseComService from "./services/CloseComService"; + +const log = logger.getChildLogger({ prefix: [`[[SyncServiceManager] `] }); + +export const createConsoleUser = async (user: ConsoleUserInfoType | null | undefined) => { + if (user) { + log.debug("createConsoleUser", { user }); + try { + Promise.all( + services.map(async (serviceClass) => { + const service = new serviceClass(); + if (service.ready()) { + if (service.console.user.upsert) { + await service.console.user.upsert(user); + } else { + await service.console.user.create(user); + } + } + }) + ); + } catch (e) { + log.warn("createConsoleUser", e); + } + } else { + log.warn("createConsoleUser:noUser", { user }); + } +}; + +export const createWebUser = async (user: WebUserInfoType | null | undefined) => { + if (user) { + log.debug("createWebUser", { user }); + try { + Promise.all( + services.map(async (serviceClass) => { + const service = new serviceClass(); + if (service.ready()) { + if (service.web.user.upsert) { + await service.web.user.upsert(user); + } else { + await service.web.user.create(user); + } + } + }) + ); + } catch (e) { + log.warn("createWebUser", e); + } + } else { + log.warn("createWebUser:noUser", { user }); + } +}; + +export const updateWebUser = async (user: WebUserInfoType | null | undefined) => { + if (user) { + log.debug("updateWebUser", { user }); + try { + Promise.all( + services.map(async (serviceClass) => { + const service = new serviceClass(); + if (service.ready()) { + if (service.web.user.upsert) { + await service.web.user.upsert(user); + } else { + await service.web.user.update(user); + } + } + }) + ); + } catch (e) { + log.warn("updateWebUser", e); + } + } else { + log.warn("updateWebUser:noUser", { user }); + } +}; + +export const deleteWebUser = async (user: WebUserInfoType | null | undefined) => { + if (user) { + log.debug("deleteWebUser", { user }); + try { + Promise.all( + services.map(async (serviceClass) => { + const service = new serviceClass(); + if (service.ready()) { + await service.web.user.delete(user); + } + }) + ); + } catch (e) { + log.warn("deleteWebUser", e); + } + } else { + log.warn("deleteWebUser:noUser", { user }); + } +}; + +export const closeComUpsertTeamUser = async ( + team: TeamInfoType, + user: WebUserInfoType | null | undefined, + role: MembershipRole +) => { + if (user && team && role) { + log.debug("closeComUpsertTeamUser", { team, user, role }); + try { + const closeComService = new CloseComService(); + if (closeComService.ready()) { + await closeComService.web.team.create(team, user, role); + } + } catch (e) { + log.warn("closeComUpsertTeamUser", e); + } + } else { + log.warn("closeComUpsertTeamUser:noTeamOrUserOrRole", { team, user, role }); + } +}; + +export const closeComDeleteTeam = async (team: TeamInfoType) => { + if (team) { + log.debug("closeComDeleteTeamUser", { team }); + try { + const closeComService = new CloseComService(); + if (closeComService.ready()) { + await closeComService.web.team.delete(team); + } + } catch (e) { + log.warn("closeComDeleteTeamUser", e); + } + } else { + log.warn("closeComDeleteTeamUser:noTeam"); + } +}; + +export const closeComDeleteTeamMembership = async (user: WebUserInfoType | null | undefined) => { + if (user) { + log.debug("closeComDeleteTeamMembership", { user }); + try { + const closeComService = new CloseComService(); + if (closeComService.ready()) { + await closeComService.web.membership.delete(user); + } + } catch (e) { + log.warn("closeComDeleteTeamMembership", e); + } + } else { + log.warn("closeComDeleteTeamMembership:noUser"); + } +}; + +export const closeComUpdateTeam = async (prevTeam: TeamInfoType, updatedTeam: TeamInfoType) => { + if (prevTeam && updatedTeam) { + try { + const closeComService = new CloseComService(); + if (closeComService.ready()) { + await closeComService.web.team.update(prevTeam, updatedTeam); + } + } catch (e) { + log.warn("closeComUpdateTeam", e); + } + } else { + log.warn("closeComUpdateTeam:noPrevTeamOrUpdatedTeam"); + } +}; diff --git a/packages/lib/sync/services/CloseComService.ts b/packages/lib/sync/services/CloseComService.ts new file mode 100644 index 0000000000..efce222a7e --- /dev/null +++ b/packages/lib/sync/services/CloseComService.ts @@ -0,0 +1,140 @@ +import { MembershipRole } from "@prisma/client"; + +import CloseCom, { CloseComFieldOptions, CloseComLead } from "@calcom/lib/CloseCom"; +import { getCloseComContactIds, getCloseComLeadId, getCustomFieldsIds } from "@calcom/lib/CloseComeUtils"; +import logger from "@calcom/lib/logger"; +import SyncServiceCore, { TeamInfoType } from "@calcom/lib/sync/ISyncService"; +import ISyncService, { ConsoleUserInfoType, WebUserInfoType } from "@calcom/lib/sync/ISyncService"; + +// Cal.com Custom Contact Fields +const calComCustomContactFields: CloseComFieldOptions = [ + // Field name, field type, required?, multiple values? + ["Username", "text", false, false], + ["Plan", "text", true, false], + ["Last booking", "date", false, false], + ["Created at", "date", true, false], +]; + +const calComSharedFields: CloseComFieldOptions = [["Contact Role", "text", false, false]]; + +const serviceName = "closecom_service"; + +export default class CloseComService extends SyncServiceCore implements ISyncService { + constructor() { + super(serviceName, CloseCom, logger.getChildLogger({ prefix: [`[[sync] ${serviceName}`] })); + } + + upsertAnyUser = async ( + user: WebUserInfoType | ConsoleUserInfoType, + leadInfo?: CloseComLead, + role?: string + ) => { + this.log.debug("sync:closecom:user", { user }); + // Get Cal.com Lead + const leadId = await getCloseComLeadId(this.service, leadInfo); + this.log.debug("sync:closecom:user:leadId", { leadId }); + // Get Contacts ids: already creates contacts + const [contactId] = await getCloseComContactIds([user], this.service, leadId); + this.log.debug("sync:closecom:user:contactsIds", { contactId }); + // Get Custom Contact fields ids + const customFieldsIds = await getCustomFieldsIds("contact", calComCustomContactFields, this.service); + this.log.debug("sync:closecom:user:customFieldsIds", { customFieldsIds }); + debugger; + // Get shared fields ids + const sharedFieldsIds = await getCustomFieldsIds("shared", calComSharedFields, this.service); + this.log.debug("sync:closecom:user:sharedFieldsIds", { sharedFieldsIds }); + const allFields = customFieldsIds.concat(sharedFieldsIds); + this.log.debug("sync:closecom:user:allFields", { allFields }); + const lastBooking = "email" in user ? await this.getUserLastBooking(user) : null; + this.log.debug("sync:closecom:user:lastBooking", { lastBooking }); + const username = "username" in user ? user.username : null; + // Prepare values for each Custom Contact Fields + const allFieldsValues = [ + username, // Username + user.plan, // Plan + lastBooking && lastBooking.booking + ? new Date(lastBooking.booking.createdAt).toLocaleDateString("en-US") + : null, // Last Booking + user.createdDate, + role === MembershipRole.OWNER ? "Point of Contact" : "", + ]; + this.log.debug("sync:closecom:contact:allFieldsValues", { allFieldsValues }); + // Preparing Custom Activity Instance data for Close.com + const person = Object.assign( + {}, + { + person: user, + lead_id: leadId, + contactId, + }, + ...allFields.map((fieldId: string, index: number) => { + return { + [`custom.${fieldId}`]: allFieldsValues[index], + }; + }) + ); + // Create Custom Activity type instance + return await this.service.contact.update(person); + }; + + public console = { + user: { + upsert: async (consoleUser: ConsoleUserInfoType) => { + return this.upsertAnyUser(consoleUser); + }, + }, + }; + + public web = { + user: { + upsert: async (webUser: WebUserInfoType) => { + return this.upsertAnyUser(webUser); + }, + delete: async (webUser: WebUserInfoType) => { + this.log.debug("sync:closecom:web:user:delete", { webUser }); + const [contactId] = await getCloseComContactIds([webUser], this.service); + this.log.debug("sync:closecom:web:user:delete:contactId", { contactId }); + if (contactId) { + return this.service.contact.delete(contactId); + } else { + throw Error("Web user not found in service"); + } + }, + }, + team: { + create: async (team: TeamInfoType, webUser: WebUserInfoType, role: MembershipRole) => { + return this.upsertAnyUser( + webUser, + { + companyName: team.name, + }, + role + ); + }, + delete: async (team: TeamInfoType) => { + this.log.debug("sync:closecom:web:team:delete", { team }); + const leadId = await getCloseComLeadId(this.service, { companyName: team.name }); + this.log.debug("sync:closecom:web:team:delete:leadId", { leadId }); + this.service.lead.delete(leadId); + }, + update: async (prevTeam: TeamInfoType, updatedTeam: TeamInfoType) => { + this.log.debug("sync:closecom:web:team:update", { prevTeam, updatedTeam }); + const leadId = await getCloseComLeadId(this.service, { companyName: prevTeam.name }); + this.log.debug("sync:closecom:web:team:update:leadId", { leadId }); + this.service.lead.update(leadId, updatedTeam); + }, + }, + membership: { + delete: async (webUser: WebUserInfoType) => { + this.log.debug("sync:closecom:web:membership:delete", { webUser }); + const [contactId] = await getCloseComContactIds([webUser], this.service); + this.log.debug("sync:closecom:web:membership:delete:contactId", { contactId }); + if (contactId) { + return this.service.contact.delete(contactId); + } else { + throw Error("Web user not found in service"); + } + }, + }, + }; +} diff --git a/packages/lib/sync/services/SendgridService.ts b/packages/lib/sync/services/SendgridService.ts new file mode 100644 index 0000000000..d2e679ede9 --- /dev/null +++ b/packages/lib/sync/services/SendgridService.ts @@ -0,0 +1,203 @@ +import sendgrid from "@sendgrid/client"; +import { ClientRequest } from "@sendgrid/client/src/request"; +import { ClientResponse } from "@sendgrid/client/src/response"; + +import logger from "@calcom/lib/logger"; +import ISyncService, { ConsoleUserInfoType, WebUserInfoType } from "@calcom/lib/sync/ISyncService"; +import SyncServiceCore from "@calcom/lib/sync/ISyncService"; + +type SendgridCustomField = { + id: string; + name: string; + field_type: string; + _metadata: { + self: string; + }; +}; + +type SendgridContact = { + id: string; + first_name: string; + last_name: string; + email: string; +}; + +type SendgridSearchResult = { + result: SendgridContact[]; +}; + +type SendgridFieldDefinitions = { + custom_fields: SendgridCustomField[]; +}; + +type SendgridNewContact = { + job_id: string; +}; + +// Cal.com Custom Contact Fields +const calComCustomContactFields: [string, string][] = [ + // Field name, field type + ["username", "Text"], + ["plan", "Text"], + ["last_booking", "Date"], // Sendgrid custom fields only allow alphanumeric characters (letters A-Z, numbers 0-9) and underscores. + ["createdAt", "Date"], +]; + +type SendgridRequest = (data: ClientRequest) => Promise; + +// TODO: When creating Sendgrid app, move this to the corresponding file +class Sendgrid { + constructor() { + if (!process.env.SENDGRID_API_KEY) throw Error("Sendgrid Api Key not present"); + sendgrid.setApiKey(process.env.SENDGRID_API_KEY); + return sendgrid; + } +} + +const serviceName = "sendgrid_service"; + +export default class SendgridService extends SyncServiceCore implements ISyncService { + constructor() { + super(serviceName, Sendgrid, logger.getChildLogger({ prefix: [`[[sync] ${serviceName}`] })); + } + + sendgridRequest: SendgridRequest = async (data: ClientRequest) => { + this.log.debug("sendgridRequest:request", data); + const results = await this.service.request(data); + this.log.debug("sendgridRequest:results", results); + if (results[1].errors) throw Error(`Sendgrid request error: ${results[1].errors}`); + return results[1]; + }; + + getSendgridContactId = async (email: string) => { + const search = await this.sendgridRequest({ + url: `/v3/marketing/contacts/search`, + method: "POST", + body: { + query: `email LIKE '${email}'`, + }, + }); + this.log.debug("sync:sendgrid:getSendgridContactId:search", search); + return search.result || []; + }; + + getSendgridCustomFieldsIds = async () => { + // Get Custom Activity Fields + const allFields = await this.sendgridRequest({ + url: `/v3/marketing/field_definitions`, + method: "GET", + }); + allFields.custom_fields = allFields.custom_fields ?? []; + this.log.debug("sync:sendgrid:getCustomFieldsIds:allFields", allFields); + const customFieldsNames = allFields.custom_fields.map((fie) => fie.name); + this.log.debug("sync:sendgrid:getCustomFieldsIds:customFieldsNames", customFieldsNames); + const customFieldsExist = calComCustomContactFields.map((cusFie) => + customFieldsNames.includes(cusFie[0]) + ); + this.log.debug("sync:sendgrid:getCustomFieldsIds:customFieldsExist", customFieldsExist); + return await Promise.all( + customFieldsExist.map(async (exist, idx) => { + if (!exist) { + const [name, field_type] = calComCustomContactFields[idx]; + const created = await this.sendgridRequest({ + url: `/v3/marketing/field_definitions`, + method: "POST", + body: { + name, + field_type, + }, + }); + this.log.debug("sync:sendgrid:getCustomFieldsIds:customField:created", created); + return created.id; + } else { + const index = customFieldsNames.findIndex((val) => val === calComCustomContactFields[idx][0]); + if (index >= 0) { + this.log.debug( + "sync:sendgrid:getCustomFieldsIds:customField:existed", + allFields.custom_fields[index].id + ); + return allFields.custom_fields[index].id; + } else { + throw Error("Couldn't find the field index"); + } + } + }) + ); + }; + + upsert = async (user: WebUserInfoType | ConsoleUserInfoType) => { + this.log.debug("sync:sendgrid:user", user); + // Get Custom Contact fields ids + const customFieldsIds = await this.getSendgridCustomFieldsIds(); + this.log.debug("sync:sendgrid:user:customFieldsIds", customFieldsIds); + const lastBooking = "email" in user ? await this.getUserLastBooking(user) : null; + this.log.debug("sync:sendgrid:user:lastBooking", lastBooking); + const username = "username" in user ? user.username : null; + // Prepare values for each Custom Contact Fields + const customContactFieldsValues = [ + username, // Username + user.plan, // Plan + lastBooking && lastBooking.booking + ? new Date(lastBooking.booking.createdAt).toLocaleDateString("en-US") + : null, // Last Booking + user.createdDate, + ]; + this.log.debug("sync:sendgrid:contact:customContactFieldsValues", customContactFieldsValues); + // Preparing Custom Activity Instance data for Sendgrid + const contactData = { + first_name: user.name, + email: user.email, + custom_fields: Object.assign( + {}, + ...customFieldsIds.map((fieldId: string, index: number) => { + if (customContactFieldsValues[index] !== null) { + return { + [fieldId]: customContactFieldsValues[index], + }; + } + }) + ), + }; + this.log.debug("sync:sendgrid:contact:contactData", contactData); + const newContact = await this.sendgridRequest({ + url: `/v3/marketing/contacts`, + method: "PUT", + body: { + contacts: [contactData], + }, + }); + // Create contact + this.log.debug("sync:sendgrid:contact:newContact", newContact); + return newContact; + }; + + public console = { + user: { + upsert: async (consoleUser: ConsoleUserInfoType) => { + return this.upsert(consoleUser); + }, + }, + }; + + public web = { + user: { + upsert: async (webUser: WebUserInfoType) => { + return this.upsert(webUser); + }, + delete: async (webUser: WebUserInfoType) => { + const [contactId] = await this.getSendgridContactId(webUser.email); + if (contactId) { + return this.sendgridRequest({ + url: `/v3/marketing/contacts`, + method: "DELETE", + qs: { + ids: contactId.id, + }, + }); + } else { + throw Error("Web user not found in service"); + } + }, + }, + }; +} diff --git a/packages/lib/sync/services/index.ts b/packages/lib/sync/services/index.ts new file mode 100644 index 0000000000..bf91d8c967 --- /dev/null +++ b/packages/lib/sync/services/index.ts @@ -0,0 +1,9 @@ +import { ISyncServices } from "../ISyncService"; +import SendgridService from "./SendgridService"; + +const services: ISyncServices[] = [ + //CloseComService, This service gets a special treatment after deciding it shouldn't get the same treatment as Sendgrid + SendgridService, +]; + +export default services; diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx index b4c644e791..56a7a0ba22 100644 --- a/packages/trpc/server/routers/viewer.tsx +++ b/packages/trpc/server/routers/viewer.tsx @@ -12,7 +12,6 @@ import { DailyLocationType } from "@calcom/core/location"; import dayjs from "@calcom/dayjs"; import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; -import { CAL_URL } from "@calcom/lib/constants"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; import jackson from "@calcom/lib/jackson"; import { @@ -28,6 +27,10 @@ import { checkUsername } from "@calcom/lib/server/checkUsername"; import { getTranslation } from "@calcom/lib/server/i18n"; import { isTeamOwner } from "@calcom/lib/server/queries/teams"; import slugify from "@calcom/lib/slugify"; +import { + updateWebUser as syncServicesUpdateWebUser, + deleteWebUser as syncServicesDeleteWebUser, +} from "@calcom/lib/sync/SyncServiceManager"; import prisma, { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma"; import { resizeBase64Image } from "@calcom/web/server/lib/resizeBase64Image"; @@ -108,11 +111,15 @@ const loggedInViewerRouter = createProtectedRouter() // Remove me from Stripe // Remove my account - await ctx.prisma.user.delete({ + const deletedUser = await ctx.prisma.user.delete({ where: { id: ctx.user.id, }, }); + + // Sync Services + syncServicesDeleteWebUser(deletedUser); + return; }, }) @@ -723,9 +730,15 @@ const loggedInViewerRouter = createProtectedRouter() username: true, email: true, metadata: true, + name: true, + plan: true, + createdDate: true, }, }); + // Sync Services + await syncServicesUpdateWebUser(updatedUser); + // Notify stripe about the change if (updatedUser && updatedUser.metadata && hasKeyInMetadata(updatedUser, "stripeCustomerId")) { const stripeCustomerId = `${updatedUser.metadata.stripeCustomerId}`; diff --git a/packages/trpc/server/routers/viewer/teams.tsx b/packages/trpc/server/routers/viewer/teams.tsx index 6bb01a261c..dc873439cc 100644 --- a/packages/trpc/server/routers/viewer/teams.tsx +++ b/packages/trpc/server/routers/viewer/teams.tsx @@ -16,6 +16,12 @@ import { HOSTED_CAL_FEATURES, WEBAPP_URL } from "@calcom/lib/constants"; import { getTranslation } from "@calcom/lib/server/i18n"; import { getTeamWithMembers, isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams"; import slugify from "@calcom/lib/slugify"; +import { + closeComDeleteTeam, + closeComDeleteTeamMembership, + closeComUpdateTeam, + closeComUpsertTeamUser, +} from "@calcom/lib/sync/SyncServiceManager"; import { availabilityUserSelect } from "@calcom/prisma"; import { TRPCError } from "@trpc/server"; @@ -99,10 +105,13 @@ export const viewerTeamsRouter = createProtectedRouter() data: { teamId: createTeam.id, userId: ctx.user.id, - role: "OWNER", + role: MembershipRole.OWNER, accepted: true, }, }); + + // Sync Services: Close.com + closeComUpsertTeamUser(createTeam, ctx.user, MembershipRole.OWNER); }, }) // Allows team owner to update team metadata @@ -126,7 +135,14 @@ export const viewerTeamsRouter = createProtectedRouter() }); if (userConflict.some((t) => t.id !== input.id)) return; } - await ctx.prisma.team.update({ + + const prevTeam = await ctx.prisma.team.findFirst({ + where: { + id: input.id, + }, + }); + + const updatedTeam = await ctx.prisma.team.update({ where: { id: input.id, }, @@ -138,6 +154,9 @@ export const viewerTeamsRouter = createProtectedRouter() hideBranding: input.hideBranding, }, }); + + // Sync Services: Close.com + if (prevTeam) closeComUpdateTeam(prevTeam, updatedTeam); }, }) .mutation("delete", { @@ -158,11 +177,14 @@ export const viewerTeamsRouter = createProtectedRouter() }, }); - await ctx.prisma.team.delete({ + const deletedTeam = await ctx.prisma.team.delete({ where: { id: input.teamId, }, }); + + // Sync Services: Close.cm + closeComDeleteTeam(deletedTeam); }, }) // Allows owner to remove member from team @@ -184,12 +206,19 @@ export const viewerTeamsRouter = createProtectedRouter() code: "FORBIDDEN", message: "You can not remove yourself from a team you own.", }); - await ctx.prisma.membership.delete({ + + const membership = await ctx.prisma.membership.delete({ where: { userId_teamId: { userId: input.memberId, teamId: input.teamId }, }, + include: { + user: true, + }, }); + // Sync Services + closeComDeleteTeamMembership(membership.user); + if (HOSTED_CAL_FEATURES) await removeSeat(ctx.user.id, input.teamId, input.memberId); }, }) @@ -314,31 +343,41 @@ export const viewerTeamsRouter = createProtectedRouter() }), async resolve({ ctx, input }) { if (input.accept) { - await ctx.prisma.membership.update({ + const membership = await ctx.prisma.membership.update({ where: { userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, }, data: { accepted: true, }, + include: { + team: true, + }, }); + + closeComUpsertTeamUser(membership.team, ctx.user, membership.role); } else { try { //get team owner so we can alter their subscription seat count const teamOwner = await ctx.prisma.membership.findFirst({ where: { teamId: input.teamId, role: MembershipRole.OWNER }, + include: { team: true }, }); // TODO: disable if not hosted by Cal if (teamOwner) await removeSeat(teamOwner.userId, input.teamId, ctx.user.id); + + const membership = await ctx.prisma.membership.delete({ + where: { + userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, + }, + }); + + // Sync Services: Close.com + if (teamOwner) closeComUpsertTeamUser(teamOwner.team, ctx.user, membership.role); } catch (e) { console.log(e); } - await ctx.prisma.membership.delete({ - where: { - userId_teamId: { userId: ctx.user.id, teamId: input.teamId }, - }, - }); } }, }) @@ -384,14 +423,21 @@ export const viewerTeamsRouter = createProtectedRouter() }); } - await ctx.prisma.membership.update({ + const membership = await ctx.prisma.membership.update({ where: { userId_teamId: { userId: input.memberId, teamId: input.teamId }, }, data: { role: input.role, }, + include: { + team: true, + user: true, + }, }); + + // Sync Services: Close.com + closeComUpsertTeamUser(membership.team, membership.user, membership.role); }, }) .query("getMemberAvailability", { diff --git a/turbo.json b/turbo.json index 3e63780668..5e78df236e 100644 --- a/turbo.json +++ b/turbo.json @@ -192,8 +192,10 @@ "$CALCOM_LICENSE_KEY", "$CALCOM_TELEMETRY_DISABLED", "$CALENDSO_ENCRYPTION_KEY", + "$SEND_FEEDBACK_EMAIL", "$CI", "$CLOSECOM_API_KEY", + "$SENDGRID_API_KEY", "$CRON_API_KEY", "$DAILY_API_KEY", "$DAILY_SCALE_PLAN",