diff --git a/apps/web/app/(ee)/api/hubspot/webhook/route.ts b/apps/web/app/(ee)/api/hubspot/webhook/route.ts index 70207b2de5c..6d2f5bf05ed 100644 --- a/apps/web/app/(ee)/api/hubspot/webhook/route.ts +++ b/apps/web/app/(ee)/api/hubspot/webhook/route.ts @@ -1,7 +1,10 @@ import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { HUBSPOT_CLIENT_SECRET } from "@/lib/integrations/hubspot/constants"; import { refreshAccessToken } from "@/lib/integrations/hubspot/refresh-token"; -import { hubSpotWebhookSchema } from "@/lib/integrations/hubspot/schema"; +import { + hubSpotSettingsSchema, + hubSpotWebhookSchema, +} from "@/lib/integrations/hubspot/schema"; import { trackHubSpotLeadEvent } from "@/lib/integrations/hubspot/track-lead"; import { trackHubSpotSaleEvent } from "@/lib/integrations/hubspot/track-sale"; import { HubSpotAuthToken } from "@/lib/integrations/hubspot/types"; @@ -122,10 +125,13 @@ async function processWebhookEvent(event: any) { // Track the sale event when deal is closed won if (subscriptionType === "object.propertyChange") { + const settings = hubSpotSettingsSchema.parse(installation.settings ?? {}); + await trackHubSpotSaleEvent({ payload: event, workspace, authToken, + closedWonDealStageId: settings?.closedWonDealStageId, }); } } diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx index f8268b32337..bf37ef194c3 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx @@ -1,6 +1,7 @@ "use client"; import { getIntegrationInstallUrl } from "@/lib/actions/get-integration-install-url"; +import { HubSpotSettings } from "@/lib/integrations/hubspot/ui/settings"; import { SegmentSettings } from "@/lib/integrations/segment/ui/settings"; import { SlackSettings } from "@/lib/integrations/slack/ui/settings"; import { ZapierSettings } from "@/lib/integrations/zapier/ui/settings"; @@ -56,6 +57,7 @@ const integrationSettings = { [ZAPIER_INTEGRATION_ID]: ZapierSettings, [SLACK_INTEGRATION_ID]: SlackSettings, [SEGMENT_INTEGRATION_ID]: SegmentSettings, + [HUBSPOT_INTEGRATION_ID]: HubSpotSettings, }; export default function IntegrationPageClient({ diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page.tsx index 1771f0dfa20..87e7f6c5002 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page.tsx @@ -57,6 +57,10 @@ export default async function IntegrationPage({ ? integration.installations[0]?.credentials : undefined; + const settings = installed + ? integration.installations[0]?.settings + : undefined; + // TODO: // Fix this, we only displaying the first webhook only const webhookId = installed @@ -82,6 +86,7 @@ export default async function IntegrationPage({ } : null, credentials, + settings, webhookId, }} /> diff --git a/apps/web/lib/integrations/hubspot/constants.ts b/apps/web/lib/integrations/hubspot/constants.ts index 3045244b6a9..30ed9008f2a 100644 --- a/apps/web/lib/integrations/hubspot/constants.ts +++ b/apps/web/lib/integrations/hubspot/constants.ts @@ -23,3 +23,5 @@ export const HUBSPOT_OBJECT_TYPE_IDS = [ "0-1", // contact "0-3", // deal ] as const; + +export const DEFAULT_CLOSED_WON_DEAL_STAGE_ID = "closedwon"; diff --git a/apps/web/lib/integrations/hubspot/get-contact.ts b/apps/web/lib/integrations/hubspot/get-contact.ts index 1c089640d3c..883272c805f 100644 --- a/apps/web/lib/integrations/hubspot/get-contact.ts +++ b/apps/web/lib/integrations/hubspot/get-contact.ts @@ -10,7 +10,7 @@ export async function getHubSpotContact({ }) { try { const response = await fetch( - `${HUBSPOT_API_HOST}/crm/v3/objects/contacts/${contactId}?properties=email,firstname,lastname,dub_id`, + `${HUBSPOT_API_HOST}/crm/v3/objects/contacts/${contactId}?properties=email,firstname,lastname,dub_id,dub_link,dub_partner_email`, { method: "GET", headers: { diff --git a/apps/web/lib/integrations/hubspot/schema.ts b/apps/web/lib/integrations/hubspot/schema.ts index 8e5454af70f..0c04d3dbfd0 100644 --- a/apps/web/lib/integrations/hubspot/schema.ts +++ b/apps/web/lib/integrations/hubspot/schema.ts @@ -11,6 +11,14 @@ export const hubSpotAuthTokenSchema = z.object({ created_at: z.number(), }); +// Integration settings +export const hubSpotSettingsSchema = z.object({ + closedWonDealStageId: z + .string() + .nullish() + .describe("The ID of the deal stage that represents a closed won deal."), +}); + export const hubSpotRefreshTokenSchema = z.object({ access_token: z.string(), refresh_token: z.string(), @@ -26,7 +34,9 @@ export const hubSpotContactSchema = z.object({ email: z.string(), firstname: z.string().nullable(), lastname: z.string().nullable(), - dub_id: z.string().nullable(), + dub_id: z.string().nullish(), + dub_link: z.string().nullish(), + dub_partner_email: z.string().nullish(), }), }); diff --git a/apps/web/lib/integrations/hubspot/track-lead.ts b/apps/web/lib/integrations/hubspot/track-lead.ts index 30ab19cd8d4..2ccce64ef56 100644 --- a/apps/web/lib/integrations/hubspot/track-lead.ts +++ b/apps/web/lib/integrations/hubspot/track-lead.ts @@ -1,9 +1,12 @@ import { trackLead } from "@/lib/api/conversions/track-lead"; -import { WorkspaceProps } from "@/lib/types"; +import { TrackLeadResponse, WorkspaceProps } from "@/lib/types"; +import { prisma } from "@dub/prisma"; +import { waitUntil } from "@vercel/functions"; import { getHubSpotContact } from "./get-contact"; import { getHubSpotDeal } from "./get-deal"; import { hubSpotLeadEventSchema } from "./schema"; -import { HubSpotAuthToken } from "./types"; +import { HubSpotAuthToken, HubSpotContact } from "./types"; +import { updateHubSpotContact } from "./update-contact"; export const trackHubSpotLeadEvent = async ({ payload, @@ -14,21 +17,20 @@ export const trackHubSpotLeadEvent = async ({ workspace: Pick; authToken: HubSpotAuthToken; }) => { - const { objectId, objectTypeId, subscriptionType } = - hubSpotLeadEventSchema.parse(payload); + const { objectId, objectTypeId } = hubSpotLeadEventSchema.parse(payload); // A new contact is created (deferred lead tracking) if (objectTypeId === "0-1") { - const contact = await getHubSpotContact({ + const contactInfo = await getHubSpotContact({ contactId: objectId, accessToken: authToken.access_token, }); - if (!contact) { + if (!contactInfo) { return; } - const { properties } = contact; + const { properties } = contactInfo; if (!properties.dub_id) { console.error(`[HubSpot] No dub_id found for contact ${objectId}.`); @@ -39,7 +41,7 @@ export const trackHubSpotLeadEvent = async ({ [properties.firstname, properties.lastname].filter(Boolean).join(" ") || null; - return await trackLead({ + const trackLeadResult = await trackLead({ clickId: properties.dub_id, eventName: "Sign up", customerEmail: properties.email, @@ -49,6 +51,18 @@ export const trackHubSpotLeadEvent = async ({ workspace, rawBody: payload, }); + + if (trackLeadResult) { + waitUntil( + _updateHubSpotContact({ + contact: contactInfo, + trackLeadResult, + accessToken: authToken.access_token, + }), + ); + } + + return trackLeadResult; } // A deal is created for the contact (Eg: lead is tracked) @@ -82,13 +96,76 @@ export const trackHubSpotLeadEvent = async ({ return; } - return await trackLead({ + const trackLeadResult = await trackLead({ clickId: "", eventName: `Deal ${properties.dealstage}`, customerExternalId: contactInfo.properties.email, + customerName: `${contactInfo.properties.firstname} ${contactInfo.properties.lastname}`, + customerEmail: contactInfo.properties.email, mode: "async", workspace, rawBody: payload, }); + + if (trackLeadResult) { + waitUntil( + _updateHubSpotContact({ + contact: contactInfo, + trackLeadResult, + accessToken: authToken.access_token, + }), + ); + } + + return trackLeadResult; } }; + +// Update the HubSpot contact with `dub_link` and `dub_partner_email` +export const _updateHubSpotContact = async ({ + accessToken, + contact, + trackLeadResult, +}: { + accessToken: string; + contact: HubSpotContact; + trackLeadResult: TrackLeadResponse; +}) => { + if (contact.properties.dub_link && contact.properties.dub_partner_email) { + console.log( + `[HubSpot] Contact ${contact.id} already has dub_link and dub_partner_email. Skipping update.`, + ); + return; + } + + const properties: Record = {}; + + if (trackLeadResult.link?.partnerId) { + const partner = await prisma.partner.findUniqueOrThrow({ + where: { + id: trackLeadResult.link.partnerId, + }, + select: { + email: true, + }, + }); + + if (partner.email) { + properties["dub_partner_email"] = partner.email; + } + } + + if (trackLeadResult.link?.shortLink) { + properties["dub_link"] = trackLeadResult.link.shortLink; + } + + if (Object.keys(properties).length === 0) { + return; + } + + await updateHubSpotContact({ + contactId: contact.id, + accessToken, + properties, + }); +}; diff --git a/apps/web/lib/integrations/hubspot/track-sale.ts b/apps/web/lib/integrations/hubspot/track-sale.ts index 7315895c331..cbb872cbe05 100644 --- a/apps/web/lib/integrations/hubspot/track-sale.ts +++ b/apps/web/lib/integrations/hubspot/track-sale.ts @@ -1,5 +1,6 @@ import { trackSale } from "@/lib/api/conversions/track-sale"; import { WorkspaceProps } from "@/lib/types"; +import { DEFAULT_CLOSED_WON_DEAL_STAGE_ID } from "./constants"; import { getHubSpotContact } from "./get-contact"; import { getHubSpotDeal } from "./get-deal"; import { hubSpotSaleEventSchema } from "./schema"; @@ -9,26 +10,35 @@ export const trackHubSpotSaleEvent = async ({ payload, workspace, authToken, + closedWonDealStageId, }: { payload: Record; workspace: Pick; authToken: HubSpotAuthToken; + closedWonDealStageId?: string | null; }) => { + closedWonDealStageId = + closedWonDealStageId ?? DEFAULT_CLOSED_WON_DEAL_STAGE_ID; + const { objectId, subscriptionType, propertyName, propertyValue } = hubSpotSaleEventSchema.parse(payload); if (subscriptionType !== "object.propertyChange") { - console.error(`[HubSpot] Unknown subscriptionType ${subscriptionType}`); + console.log(`[HubSpot] Unknown subscriptionType ${subscriptionType}`); return; } if (propertyName !== "dealstage") { - console.error(`[HubSpot] Unknown propertyName ${propertyName}`); + console.log( + `[HubSpot] Unknown propertyName ${propertyName}. Expected dealstage.`, + ); return; } - if (propertyValue !== "closedwon") { - console.error(`[HubSpot] Unknown propertyValue ${propertyValue}`); + if (propertyValue !== closedWonDealStageId) { + console.error( + `[HubSpot] Unknown propertyValue ${propertyValue}. Expected ${closedWonDealStageId}.`, + ); return; } diff --git a/apps/web/lib/integrations/hubspot/types.ts b/apps/web/lib/integrations/hubspot/types.ts index d92bd63c13a..e2386a41a8b 100644 --- a/apps/web/lib/integrations/hubspot/types.ts +++ b/apps/web/lib/integrations/hubspot/types.ts @@ -1,6 +1,12 @@ import { z } from "zod"; -import { hubSpotAuthTokenSchema, hubSpotRefreshTokenSchema } from "./schema"; +import { + hubSpotAuthTokenSchema, + hubSpotContactSchema, + hubSpotRefreshTokenSchema, +} from "./schema"; export type HubSpotAuthToken = z.infer; export type HubSpotRefreshToken = z.infer; + +export type HubSpotContact = z.infer; diff --git a/apps/web/lib/integrations/hubspot/ui/settings.tsx b/apps/web/lib/integrations/hubspot/ui/settings.tsx new file mode 100644 index 00000000000..00db1b44e22 --- /dev/null +++ b/apps/web/lib/integrations/hubspot/ui/settings.tsx @@ -0,0 +1,90 @@ +"use client"; + +import useWorkspace from "@/lib/swr/use-workspace"; +import { InstalledIntegrationInfoProps } from "@/lib/types"; +import { Button } from "@dub/ui"; +import { useAction } from "next-safe-action/hooks"; +import { useState } from "react"; +import { toast } from "sonner"; +import { DEFAULT_CLOSED_WON_DEAL_STAGE_ID } from "../constants"; +import { updateHubSpotSettingsAction } from "../update-hubspot-settings"; + +export const HubSpotSettings = ({ + installed, + settings, +}: InstalledIntegrationInfoProps) => { + const { id: workspaceId } = useWorkspace(); + const [closedWonDealStageId, setClosedWonDealStageId] = useState( + (settings as any)?.closedWonDealStageId || DEFAULT_CLOSED_WON_DEAL_STAGE_ID, + ); + + const { executeAsync, isPending } = useAction(updateHubSpotSettingsAction, { + async onSuccess() { + toast.success("HubSpot settings updated successfully."); + }, + onError({ error }) { + toast.error(error.serverError || "Failed to update HubSpot settings."); + }, + }); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!workspaceId) { + return; + } + + await executeAsync({ + workspaceId, + closedWonDealStageId: closedWonDealStageId || null, + }); + }; + + if (!installed) { + return null; + } + + return ( +
+
+
+

+ Closed Won Deal Stage ID +

+
+ +
+

+ Enter the HubSpot deal stage ID that represents a closed won deal. + This will be used to track when deals are marked as closed won in + HubSpot. +

+ +
+ setClosedWonDealStageId(e.target.value)} + /> +
+
+ +
+
+
+
+
+
+ ); +}; diff --git a/apps/web/lib/integrations/hubspot/update-contact.ts b/apps/web/lib/integrations/hubspot/update-contact.ts new file mode 100644 index 00000000000..3b59e2aad46 --- /dev/null +++ b/apps/web/lib/integrations/hubspot/update-contact.ts @@ -0,0 +1,52 @@ +import { HUBSPOT_API_HOST } from "./constants"; + +export async function updateHubSpotContact({ + contactId, + accessToken, + properties, +}: { + contactId: number | string; + accessToken: string; + properties: { + dub_link?: string; + dub_partner_email?: string; + }; +}) { + try { + if (process.env.NODE_ENV === "development") { + console.log( + `Updating HubSpot contact ${contactId} with properties`, + properties, + ); + } + + const response = await fetch( + `${HUBSPOT_API_HOST}/crm/v3/objects/contacts/${contactId}`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + properties, + }), + }, + ); + + const result = await response.json(); + + if (process.env.NODE_ENV === "development") { + console.log("HubSpot contact update result", result); + } + + if (!response.ok) { + throw new Error(result.message || "Failed to update contact"); + } + + return result; + } catch (error) { + console.error(`[HubSpot] Failed to update contact ${contactId}: ${error}`); + throw error; + } +} diff --git a/apps/web/lib/integrations/hubspot/update-hubspot-settings.ts b/apps/web/lib/integrations/hubspot/update-hubspot-settings.ts new file mode 100644 index 00000000000..a23b8f707a3 --- /dev/null +++ b/apps/web/lib/integrations/hubspot/update-hubspot-settings.ts @@ -0,0 +1,50 @@ +"use server"; + +import { authActionClient } from "@/lib/actions/safe-action"; +import { prisma } from "@dub/prisma"; +import { HUBSPOT_INTEGRATION_ID } from "@dub/utils"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { hubSpotSettingsSchema } from "./schema"; + +const schema = hubSpotSettingsSchema + .pick({ closedWonDealStageId: true }) + .extend({ + workspaceId: z.string(), + }); + +export const updateHubSpotSettingsAction = authActionClient + .schema(schema) + .action(async ({ parsedInput, ctx }) => { + const { workspace } = ctx; + const { closedWonDealStageId } = parsedInput; + + const installedIntegration = await prisma.installedIntegration.findFirst({ + where: { + integrationId: HUBSPOT_INTEGRATION_ID, + projectId: workspace.id, + }, + }); + + if (!installedIntegration) { + throw new Error( + "HubSpot integration is not installed on your workspace.", + ); + } + + const current = (installedIntegration.settings as any) ?? {}; + + await prisma.installedIntegration.update({ + where: { + id: installedIntegration.id, + }, + data: { + settings: { + ...current, + closedWonDealStageId, + }, + }, + }); + + revalidatePath(`/${workspace.slug}/settings/integrations/hubspot`); + }); diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 35df3dfedf3..d6112714d12 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -379,6 +379,7 @@ export type InstalledIntegrationInfoProps = Pick< }; } | null; credentials?: Prisma.JsonValue; + settings?: Prisma.JsonValue; webhookId?: string; // Only if the webhook is managed by an integration }; diff --git a/packages/prisma/schema/integration.prisma b/packages/prisma/schema/integration.prisma index 170ab82f640..043709fee96 100644 --- a/packages/prisma/schema/integration.prisma +++ b/packages/prisma/schema/integration.prisma @@ -35,6 +35,7 @@ model InstalledIntegration { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt credentials Json? + settings Json? user User @relation(fields: [userId], references: [id], onDelete: Cascade) integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade)