Skip to content

Commit

Permalink
feat: Attio integration
Browse files Browse the repository at this point in the history
  • Loading branch information
asadath1395 committed Feb 26, 2025
1 parent 307b294 commit 5f6b35c
Show file tree
Hide file tree
Showing 17 changed files with 529 additions and 23 deletions.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ NEXT_PUBLIC_TEAM_IMPERSONATION=false
CLOSECOM_CLIENT_ID=
CLOSECOM_CLIENT_SECRET=

# Attio internal CRM
ATTIO_CLIENT_ID=
ATTIO_CLIENT_SECRET=

# Sendgrid internal sync service
SENDGRID_SYNC_API_KEY=

Expand Down
2 changes: 2 additions & 0 deletions packages/app-store/apps.keys-schemas.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Don't modify this file manually.
**/
import { appKeysSchema as alby_zod_ts } from "./alby/zod";
import { appKeysSchema as attio_zod_ts } from "./attio/zod";
import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod";
import { appKeysSchema as campsite_zod_ts } from "./campsite/zod";
import { appKeysSchema as closecom_zod_ts } from "./closecom/zod";
Expand Down Expand Up @@ -53,6 +54,7 @@ import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";

export const appKeysSchemas = {
alby: alby_zod_ts,
attio: attio_zod_ts,
basecamp3: basecamp3_zod_ts,
campsite: campsite_zod_ts,
closecom: closecom_zod_ts,
Expand Down
2 changes: 2 additions & 0 deletions packages/app-store/apps.schemas.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Don't modify this file manually.
**/
import { appDataSchema as alby_zod_ts } from "./alby/zod";
import { appDataSchema as attio_zod_ts } from "./attio/zod";
import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod";
import { appDataSchema as campsite_zod_ts } from "./campsite/zod";
import { appDataSchema as closecom_zod_ts } from "./closecom/zod";
Expand Down Expand Up @@ -53,6 +54,7 @@ import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";

export const appDataSchemas = {
alby: alby_zod_ts,
attio: attio_zod_ts,
basecamp3: basecamp3_zod_ts,
campsite: campsite_zod_ts,
closecom: closecom_zod_ts,
Expand Down
44 changes: 24 additions & 20 deletions packages/app-store/attio/api/add.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";

import appConfig from "../config.json";

const handler: AppDeclarativeHandler = {
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
redirect: {
newTab: false,
url: "/apps/installed/crm",
},
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
};

export default handler;
import type { NextApiRequest, NextApiResponse } from "next";

import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";

import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";

let client_id = "";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "GET") return res.status(405).json({ message: "Method not allowed" });

const appKeys = await getAppKeysFromSlug("attio");
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (!client_id) return res.status(400).json({ message: "Attio client id missing." });

const state = encodeOAuthState(req);

const url = `https://app.attio.com/authorize?client_id=${client_id}&redirect_uri=${encodeURIComponent(
`${WEBAPP_URL_FOR_OAUTH}/api/integrations/attio/callback`
)}&response_type=code${state ? `&state=${state}` : ""}`;

res.status(200).json({ url });
}
68 changes: 68 additions & 0 deletions packages/app-store/attio/api/callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { NextApiRequest, NextApiResponse } from "next";

import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { HttpError } from "@calcom/lib/http-error";

import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import appConfig from "../config.json";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
const state = decodeOAuthState(req);

if (code === undefined || typeof code !== "string") {
if (state?.onErrorReturnTo || state?.returnTo) {
res.redirect(
getSafeRedirectUrl(state.onErrorReturnTo) ??
getSafeRedirectUrl(state?.returnTo) ??
getInstalledAppPath({ variant: "other", slug: "attio" })
);
return;
}
throw new HttpError({ statusCode: 400, message: "`code` must be a string" });
}

if (!req.session?.user?.id) {
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}

const appKeys = await getAppKeysFromSlug("attio");
const { client_id, client_secret } = appKeys;

if (!client_id || typeof client_id !== "string") {
return res.status(400).json({ message: "Attio client id missing." });
}
if (!client_secret || typeof client_secret !== "string") {
return res.status(400).json({ message: "Attio client secret missing." });
}

const response = await fetch("https://app.attio.com/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id,
client_secret,
grant_type: "authorization_code",
code: code,
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/attio/callback`,
}),
});

if (!response.ok) {
throw new HttpError({ statusCode: 400, message: "Failed to get Attio access token" });
}

const responseBody = await response.json();

await createOAuthAppCredential({ appId: appConfig.slug, type: appConfig.type }, responseBody, req);

res.redirect(
getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "other", slug: "attio" })
);
}
1 change: 1 addition & 0 deletions packages/app-store/attio/api/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default as add } from "./add";
export { default as callback } from "./callback";
4 changes: 3 additions & 1 deletion packages/app-store/attio/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
"url": "/apps/installed/crm",
"variant": "crm",
"categories": ["crm"],
"extendsFeature": "EventType",
"publisher": "Cal.com, Inc.",
"email": "[email protected]",
"description": "Attio is the AI-native CRM that builds, scales and grows your company to the next level.",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "link-as-an-app"
"__template": "link-as-an-app",
"isOAuth": true
}
1 change: 1 addition & 0 deletions packages/app-store/attio/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * as api from "./api";
export * as lib from "./lib";
185 changes: 185 additions & 0 deletions packages/app-store/attio/lib/CrmService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import z from "zod";

import Attio from "@calcom/lib/Attio";
import logger from "@calcom/lib/logger";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { CRM, Contact, ContactCreateInput, CrmEvent } from "@calcom/types/CrmService";

// Schema that supports OAuth credentials
const credentialSchema = z.object({
access_token: z.string(),
refresh_token: z.string().optional(),
expires_in: z.number().optional(),
token_type: z.string(),
});

export default class AttioCRMService implements CRM {
private integrationName = "attio_crm";
private log: typeof logger;
private attio: Attio;
private appOptions: any;

constructor(credential: CredentialPayload, appOptions: any) {
this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.appOptions = appOptions;

const parsedKey = credentialSchema.safeParse(credential.key);

if (!parsedKey.success) {
throw new Error(
`Invalid credentials for userId ${credential.userId} and appId ${credential.appId}: ${parsedKey.error}`
);
}

this.attio = new Attio(parsedKey.data.access_token);
}

async createEvent(event: CalendarEvent, contacts: Contact[]): Promise<CrmEvent> {
try {
const contactEmails = contacts.map((contact) => contact.email);
const contactDetails = await this.getContacts({ emails: contactEmails });

const userDetails = await this.attio.user.getSelf();

// Extract record IDs from valid contacts only
const linkedRecords = contactDetails
.map((contact) => {
const [_, recordId] = contact.id.split(":");
if (!recordId) {
return null;
}
return {
target_object: "people",
target_record_id: recordId,
};
})
.filter((record): record is { target_object: string; target_record_id: string } => record !== null);

// Create a single task linked to all contacts
const taskResponse = await this.attio.task.create({
format: "plaintext",
is_completed: false,
linked_records: linkedRecords,
assignees: [
{
referenced_actor_type: "workspace-member",
referenced_actor_id: userDetails.authorized_by_workspace_member_id,
},
],
content: `${event.title}\n\nDescription: ${event.description || ""}\nOrganizer: ${
event.organizer.name
} (${event.organizer.email})`,
deadline_at: event.startTime,
});

const createdEvent = {
id: taskResponse.data.id.task_id,
uid: `${taskResponse.data.id.workspace_id}:${taskResponse.data.id.task_id}`,
type: this.integrationName,
url: "",
additionalInfo: { contacts, taskResponse },
password: "",
};

this.log.debug("Created Attio event", { event: createdEvent });
return createdEvent;
} catch (error) {
this.log.error("Error creating event in Attio", error);
throw error;
}
}

async updateEvent(uid: string, event: CalendarEvent): Promise<CrmEvent> {
try {
const response = await this.attio.task.update(uid, {
deadline_at: event.startTime,
});

const updatedEvent = {
id: response.data.id.task_id,
uid: `${response.data.id.workspace_id}:${response.data.id.task_id}`,
type: this.integrationName,
url: "",
additionalInfo: { response },
password: "",
};

this.log.debug("Updated Attio event", { event: updatedEvent });
return updatedEvent;
} catch (error) {
this.log.error("Error updating event in Attio", { error, uid, event });
throw error;
}
}

async deleteEvent(uid: string): Promise<void> {
try {
await this.attio.task.delete(uid);
this.log.debug("Successfully deleted Attio event", { uid });
} catch (error) {
this.log.error("Error deleting event in Attio", { error, uid });
throw error;
}
}

async getContacts({ emails }: { emails: string | string[] }): Promise<Contact[]> {
try {
const emailArray = Array.isArray(emails) ? emails : [emails];
const response = await this.attio.contact.search({ emails: emailArray });

return response.data.map((record) => {
const activeEmail = record.values.email_addresses.find(
(email) => email.active_until === null
)?.email_address;
if (!activeEmail) {
throw new Error(`No active email address found for record ${record.id.record_id}`);
}
return {
id: `${record.id.workspace_id}:${record.id.record_id}`,
email: activeEmail,
};
});
} catch (error) {
this.log.error("Error getting contacts from Attio", error);
throw error;
}
}

async createContacts(contactsToCreate: ContactCreateInput[]): Promise<Contact[]> {
try {
const response = await this.attio.contact.create({
values: {
email_addresses: contactsToCreate.map((contact) => ({
email_address: contact.email,
})),
name: contactsToCreate.map((contact) => ({
first_name: contact.name,
last_name: " ",
full_name: contact.name,
})),
},
});

const activeEmail = response.data.values.email_addresses.find(
(email) => email.active_until === null
)?.email_address;
if (!activeEmail) {
throw new Error(`No active email address found for record ${response.data.id.record_id}`);
}
return [
{
id: `${response.data.id.workspace_id}:${response.data.id.record_id}`,
email: activeEmail,
},
];
} catch (error) {
this.log.error("Error creating contacts in Attio", error);
throw error;
}
}

public getAppOptions() {
return this.appOptions;
}
}
12 changes: 12 additions & 0 deletions packages/app-store/attio/lib/getAttioAppKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { z } from "zod";

import getParsedAppKeysFromSlug from "../../_utils/getParsedAppKeysFromSlug";

const attioAppKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});

export const getAttioAppKeys = async () => {
return getParsedAppKeysFromSlug("attio", attioAppKeysSchema);
};
1 change: 1 addition & 0 deletions packages/app-store/attio/lib/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as CrmService } from "./CrmService";
3 changes: 2 additions & 1 deletion packages/app-store/attio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*"
"@calcom/lib": "*",
"@calcom/prisma": "*"
},
"devDependencies": {
"@calcom/types": "*"
Expand Down
10 changes: 10 additions & 0 deletions packages/app-store/attio/zod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { z } from "zod";

import { eventTypeAppCardZod } from "../eventTypeAppCardZod";

export const appDataSchema = eventTypeAppCardZod;

export const appKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});
Loading

0 comments on commit 5f6b35c

Please sign in to comment.