-
Notifications
You must be signed in to change notification settings - Fork 8.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
307b294
commit 5f6b35c
Showing
17 changed files
with
529 additions
and
23 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" }) | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
export * as api from "./api"; | ||
export * as lib from "./lib"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { default as CrmService } from "./CrmService"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(), | ||
}); |
Oops, something went wrong.