chore: Sync Services to update external tools (#3814)
* WIP close.com app * Removing leaked dev key (now invalid) * Misspelled env variable * Making progress still WIP * Progress + tests * Final touches * More unit tests * Finished up tests * Merge main * Removing unneeded stuff + submodules * Removing static props, fields fix * Removing unneeded stuff p2 * Commenting * Refactoring Close.com Calendar Service + initial structure * Progress con CloseComService * Standarizing APIs * Zodifying * Expanding sync services * Sendgrid Sync Service * using own request for sendgrid + debug logs * Making get last booking work for console * Helpscout dynamic app API * Standarizing calls + adding call from booking creation * Strategy change for last booking * Strategy change for last booking on help scout api * Fixing failing build * Implementing user deletion * Fix linting + slight cleaning * Undoing eslint disable * Removing more unsupported eslint properties * Closecom as non-standard sync service * Finishing closecom lead operations * Fixing lint * Guarding app from sync services * Reverting submodules * Applying PR feedback * Reverting API to be plain handler * Cleaning notes Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: zomars <zomars@me.com>pull/3991/head
parent
090a977f72
commit
1d927a8b33
|
@ -130,3 +130,9 @@ EMAIL_SERVER_PASSWORD='<office365_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=
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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" });
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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: `
|
||||
<ul>
|
||||
<li><b>Username:</b> ${user.username}</li>
|
||||
<li><b>Last booking:</b> ${
|
||||
lastBooking && lastBooking.booking
|
||||
? new Date(lastBooking.booking.createdAt).toLocaleDateString("en-US")
|
||||
: "No info"
|
||||
}</li>
|
||||
<li><b>Plan:</b> ${user.plan}</li>
|
||||
<li><b>Account created:</b> ${new Date(user.createdDate).toLocaleDateString("en-US")}</li>
|
||||
</ul>
|
||||
`,
|
||||
});
|
||||
}
|
|
@ -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" });
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<string> => {
|
||||
const closeComLeadNames = await this.closeCom.lead.list({ query: { _fields: ["name", "id"] } });
|
||||
const searchLeadFromCalCom = closeComLeadNames.data.filter((lead) => lead.name === "From Cal.com");
|
||||
if (searchLeadFromCalCom.length === 0) {
|
||||
// No Lead exists, create it
|
||||
const createdLeadFromCalCom = await this.closeCom.lead.create({
|
||||
companyName: "From Cal.com",
|
||||
description: "Generic Lead for Contacts created by Cal.com",
|
||||
});
|
||||
return createdLeadFromCalCom.id;
|
||||
} else {
|
||||
return searchLeadFromCalCom[0].id;
|
||||
}
|
||||
};
|
||||
|
||||
getCloseComCustomActivityTypeFieldsIds = async () => {
|
||||
// Check if Custom Activity Type exists
|
||||
const customActivities = await this.closeCom.customActivity.type.get();
|
||||
const calComCustomActivity = customActivities.data.filter((act) => act.name === "Cal.com Activity");
|
||||
if (calComCustomActivity.length > 0) {
|
||||
// Cal.com Custom Activity type exist
|
||||
// Get Custom Activity Fields
|
||||
const customActivityAllFields = await this.closeCom.customField.activity.get({
|
||||
query: { _fields: ["name", "custom_activity_type_id", "id"] },
|
||||
});
|
||||
const customActivityRelevantFields = customActivityAllFields.data.filter(
|
||||
(fie) => fie.custom_activity_type_id === calComCustomActivity[0].id
|
||||
);
|
||||
const customActivityFieldsNames = customActivityRelevantFields.map((fie) => fie.name);
|
||||
const customActivityFieldsExist = calComCustomActivityFields.map((cusFie) =>
|
||||
customActivityFieldsNames.includes(cusFie[0])
|
||||
);
|
||||
const [attendee, dateTime, timezone, organizer, additionalNotes] = await Promise.all(
|
||||
customActivityFieldsExist.map(async (exist, idx) => {
|
||||
if (!exist) {
|
||||
const [name, type, required, multiple] = calComCustomActivityFields[idx];
|
||||
const created = await this.closeCom.customField.activity.create({
|
||||
custom_activity_type_id: calComCustomActivity[0].id,
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
accepts_multiple_values: multiple,
|
||||
editable_with_roles: [],
|
||||
});
|
||||
return created.id;
|
||||
} else {
|
||||
const index = customActivityFieldsNames.findIndex(
|
||||
(val) => val === calComCustomActivityFields[idx][0]
|
||||
);
|
||||
if (index >= 0) {
|
||||
return customActivityRelevantFields[index].id;
|
||||
} else {
|
||||
throw Error("Couldn't find the field index");
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
return {
|
||||
activityType: calComCustomActivity[0].id,
|
||||
fields: {
|
||||
attendee,
|
||||
dateTime,
|
||||
timezone,
|
||||
organizer,
|
||||
additionalNotes,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// Cal.com Custom Activity type doesn't exist
|
||||
// Create Custom Activity Type
|
||||
const { id: activityType } = await this.closeCom.customActivity.type.create({
|
||||
name: "Cal.com Activity",
|
||||
description: "Bookings in your Cal.com account",
|
||||
});
|
||||
// Create Custom Activity Fields
|
||||
const [attendee, dateTime, timezone, organizer, additionalNotes] = await Promise.all(
|
||||
calComCustomActivityFields.map(async ([name, type, required, multiple]) => {
|
||||
const creation = await this.closeCom.customField.activity.create({
|
||||
custom_activity_type_id: activityType,
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
accepts_multiple_values: multiple,
|
||||
editable_with_roles: [],
|
||||
});
|
||||
return creation.id;
|
||||
})
|
||||
);
|
||||
return {
|
||||
activityType,
|
||||
fields: {
|
||||
attendee,
|
||||
dateTime,
|
||||
timezone,
|
||||
organizer,
|
||||
additionalNotes,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
getCustomActivityTypeInstanceData = async (event: CalendarEvent): Promise<CloseComCustomActivityCreate> => {
|
||||
// Get Cal.com generic Lead
|
||||
const leadFromCalComId = await this.getCloseComGenericLeadId();
|
||||
// Get Contacts ids
|
||||
const contactsIds = await this.getCloseComContactIds(event, leadFromCalComId);
|
||||
// Get Custom Activity Type id
|
||||
const customActivityTypeAndFieldsIds = await this.getCloseComCustomActivityTypeFieldsIds();
|
||||
// Prepare values for each Custom Activity Fields
|
||||
const customActivityFieldsValue = {
|
||||
attendee: contactsIds.length > 1 ? contactsIds.slice(1) : null,
|
||||
dateTime: event.startTime,
|
||||
timezone: event.attendees[0].timeZone,
|
||||
organizer: contactsIds[0],
|
||||
additionalNotes: event.additionalNotes ?? null,
|
||||
};
|
||||
// Preparing Custom Activity Instance data for Close.com
|
||||
return Object.assign(
|
||||
{},
|
||||
{
|
||||
custom_activity_type_id: customActivityTypeAndFieldsIds.activityType,
|
||||
lead_id: leadFromCalComId,
|
||||
}, // This is to add each field as "custom.FIELD_ID": "value" in the object
|
||||
...Object.keys(customActivityTypeAndFieldsIds.fields).map((fieldKey: string) => {
|
||||
const key = fieldKey as keyof typeof customActivityTypeAndFieldsIds.fields;
|
||||
return {
|
||||
[`custom.${customActivityTypeAndFieldsIds.fields[key]}`]: customActivityFieldsValue[key],
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
|
||||
const customActivityTypeInstanceData = await this.getCustomActivityTypeInstanceData(event);
|
||||
const customActivityTypeInstanceData = await getCustomActivityTypeInstanceData(
|
||||
event,
|
||||
calComCustomActivityFields,
|
||||
this.closeCom
|
||||
);
|
||||
// Create Custom Activity type instance
|
||||
const customActivityTypeInstance = await this.closeCom.activity.custom.create(
|
||||
customActivityTypeInstanceData
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<CloseComContactSearch["data"][number]> => {
|
||||
return this._post({ urlPath: "/contact/", data: closeComQueries.contact.create(data) });
|
||||
},
|
||||
update: async ({
|
||||
contactId,
|
||||
...data
|
||||
}: {
|
||||
person: { name: string; email: string };
|
||||
contactId: string;
|
||||
leadId?: string;
|
||||
}): Promise<CloseComContactSearch["data"][number]> => {
|
||||
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<CloseComLeadCreateResult> => {
|
||||
return this._put({
|
||||
urlPath: `/lead/${leadId}`,
|
||||
data,
|
||||
});
|
||||
},
|
||||
create: async (data: CloseComLead): Promise<CloseComLeadCreateResult> => {
|
||||
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<CloseComCustomContactFieldGet["data"][number]> => {
|
||||
return this._post({ urlPath: "/custom_field/contact/", data });
|
||||
},
|
||||
get: async ({ query }: { query: { [key: string]: any } }): Promise<CloseComCustomContactFieldGet> => {
|
||||
return this._get({ urlPath: "/custom_field/contact/", query });
|
||||
},
|
||||
},
|
||||
shared: {
|
||||
get: async ({ query }: { query: { [key: string]: any } }): Promise<CloseComCustomContactFieldGet> => {
|
||||
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,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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<string[]> {
|
||||
// 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<CloseComCustomActivityCreate> {
|
||||
// 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<string[]> {
|
||||
// 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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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<T> {
|
||||
delete(info: T): Promise<any>;
|
||||
}
|
||||
|
||||
export interface IUserCreation<T> {
|
||||
create(info: T): Promise<any>;
|
||||
update(info: T): Promise<any>;
|
||||
upsert?: never;
|
||||
}
|
||||
|
||||
export interface IUserUpsertion<T> {
|
||||
create?: never;
|
||||
update?: never;
|
||||
upsert(info: T): Promise<any>;
|
||||
}
|
||||
|
||||
export interface ISyncService {
|
||||
ready(): boolean;
|
||||
web: {
|
||||
user: (IUserCreation<WebUserInfoType> | IUserUpsertion<WebUserInfoType>) & IUserDeletion<WebUserInfoType>;
|
||||
};
|
||||
console: {
|
||||
user: IUserCreation<ConsoleUserInfoType> | IUserUpsertion<ConsoleUserInfoType>;
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
};
|
|
@ -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");
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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 = <R = ClientResponse>(data: ClientRequest) => Promise<R>;
|
||||
|
||||
// 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<SendgridSearchResult>({
|
||||
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<SendgridFieldDefinitions>({
|
||||
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<SendgridCustomField>({
|
||||
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<SendgridNewContact>({
|
||||
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");
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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;
|
|
@ -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}`;
|
||||
|
|
|
@ -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", {
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue