Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@steven-tey
Copy link
Collaborator

@steven-tey steven-tey commented Sep 23, 2025

Summary by CodeRabbit

  • New Features

    • HubSpot Settings UI in dashboard to configure the "Closed Won" deal stage ID with save and feedback.
    • Server-side action to persist per-workspace HubSpot settings.
    • New API to update HubSpot contacts with partner link/email.
  • Improvements

    • Sale tracking now respects your configured HubSpot "Closed Won" stage.
    • Integration pages show stored settings alongside credentials and webhook info.
    • HubSpot contact enrichment now captures partner link and partner email post-event.
  • Chores

    • Enabled per-integration settings storage.

@vercel
Copy link
Contributor

vercel bot commented Sep 23, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Sep 24, 2025 5:17am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 23, 2025

Walkthrough

Adds per-installation HubSpot settings to InstalledIntegration, exposes them through types and pages, provides a UI and server action to update closedWonDealStageId, and wires settings into webhook and tracking flows so sale tracking validates against a configurable deal stage.

Changes

Cohort / File(s) Summary
Data model
packages/prisma/schema/integration.prisma
Added settings Json? field to InstalledIntegration.
Types
apps/web/lib/types.ts
InstalledIntegrationInfoProps now includes optional settings?: Prisma.JsonValue.
HubSpot schemas & constants
apps/web/lib/integrations/hubspot/schema.ts, apps/web/lib/integrations/hubspot/constants.ts
Added hubSpotSettingsSchema with closedWonDealStageId; made contact fields nullish and added dub_link/dub_partner_email; added DEFAULT_CLOSED_WON_DEAL_STAGE_ID.
UI: settings component
apps/web/lib/integrations/hubspot/ui/settings.tsx
New HubSpotSettings React component to view/edit closedWonDealStageId and call updateHubSpotSettingsAction.
Server action: save settings
apps/web/lib/integrations/hubspot/update-hubspot-settings.ts
Added updateHubSpotSettingsAction to validate and persist settings and revalidate the settings page.
Page wiring (server -> client)
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page.tsx
Passes installation.settings into integration.settings sent to client.
Client registration
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx
Registers HubSpotSettings in integrationSettings map for HubSpot integration.
Webhook handling
apps/web/app/(ee)/api/hubspot/webhook/route.ts
Parses installation.settings with hubSpotSettingsSchema and passes closedWonDealStageId into sale tracking for deal propertyChange events.
Sale tracking logic
apps/web/lib/integrations/hubspot/track-sale.ts
trackHubSpotSaleEvent now accepts optional closedWonDealStageId (defaults to constant) and validates against it (replaces hardcoded "closedwon").
Lead tracking & contact updates
apps/web/lib/integrations/hubspot/track-lead.ts, apps/web/lib/integrations/hubspot/update-contact.ts, apps/web/lib/integrations/hubspot/get-contact.ts, apps/web/lib/integrations/hubspot/types.ts
Added _updateHubSpotContact/updateHubSpotContact; expanded contact fetch to request dub_link and dub_partner_email; exported HubSpotContact type; lead flows now schedule deferred contact updates via waitUntil.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant UI as HubSpotSettings (Client)
  participant SA as updateHubSpotSettingsAction (Server)
  participant DB as InstalledIntegration.settings
  participant Page as Settings Page

  User->>UI: Enter closedWonDealStageId + Save
  UI->>SA: submit { workspaceId, closedWonDealStageId|null }
  SA->>DB: Update InstalledIntegration.settings
  DB-->>SA: OK
  SA->>Page: Revalidate / refresh
  SA-->>UI: Success
  UI-->>User: Toast: saved
Loading
sequenceDiagram
  autonumber
  participant HS as HubSpot
  participant API as /api/hubspot/webhook
  participant Val as hubSpotSettingsSchema
  participant Sale as trackHubSpotSaleEvent

  HS->>API: webhook (object.propertyChange, deal stage)
  API->>Val: Parse installation.settings
  Val-->>API: { closedWonDealStageId? }
  API->>Sale: track(..., closedWonDealStageId)
  Sale-->>API: Validate stage matches expected
  API-->>HS: 200 or error
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • devkiran

Poem

A rabbit in code with a hop and a wink,
I tuck closed-won stages where settings think.
From page to webhook the little value flows—
Saved, validated, and then the tracking goes. 🐰✨

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "Improve HubSpot integration" is a short, single-sentence summary that correctly identifies the primary area touched by the changeset (settings, schema, tracking, UI, and DB model updates) and therefore relates to the main change. It is somewhat broad but not misleading and is acceptable for a PR title.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch improve-hubspot

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cc3a2c0 and ee119d1.

📒 Files selected for processing (1)
  • apps/web/lib/integrations/hubspot/track-lead.ts (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/integrations/hubspot/track-lead.ts (6)
apps/web/lib/integrations/hubspot/schema.ts (1)
  • hubSpotLeadEventSchema (69-73)
apps/web/lib/integrations/hubspot/get-contact.ts (1)
  • getHubSpotContact (4-39)
apps/web/lib/api/conversions/track-lead.ts (1)
  • trackLead (29-362)
apps/web/lib/integrations/hubspot/types.ts (1)
  • HubSpotContact (12-12)
apps/web/lib/types.ts (1)
  • TrackLeadResponse (401-401)
apps/web/lib/integrations/hubspot/update-contact.ts (1)
  • updateHubSpotContact (3-52)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (4)
apps/web/lib/integrations/hubspot/track-lead.ts (4)

55-63: Good use of background work with waitUntil

Deferring contact enrichment avoids blocking the webhook and keeps the flow resilient.


99-108: Harden deal-path tracking: normalize name, add externalId fallback, and catch trackLead errors

In the 0-3 flow, the current code can produce "undefined undefined" names, lacks a fallback when email is absent, and can throw (e.g., when clickId is "" and no existing customer is found), potentially crashing the webhook. Normalize the name, fall back to contact ID for customerExternalId, and wrap trackLead in try/catch.

Apply this diff:

-    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,
-    });
+    const customerEmail = contactInfo.properties.email || null;
+    const customerExternalId = customerEmail ?? String(contact.id);
+    const customerName =
+      [contactInfo.properties.firstname, contactInfo.properties.lastname]
+        .filter(Boolean)
+        .join(" ") || null;
+
+    let trackLeadResult: TrackLeadResponse | null = null;
+    try {
+      trackLeadResult = await trackLead({
+        clickId: "",
+        eventName: `Deal ${properties.dealstage}`,
+        customerExternalId,
+        customerName,
+        customerEmail,
+        mode: "async",
+        workspace,
+        rawBody: payload,
+      });
+    } catch (err) {
+      console.error(`[HubSpot] trackLead failed for deal ${objectId}:`, err);
+      return;
+    }

134-171: Do not overwrite existing HubSpot fields; build a minimal patch and avoid throwing on missing partner

Currently, you might overwrite existing dub_link/dub_partner_email, log in prod, and throw on missing partner. Build the payload conditionally, gate logs to dev, and avoid throwing on partner lookup.

Apply this diff:

-export const _updateHubSpotContact = async ({
+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<string, string> = {};
-
-  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,
-  });
+  try {
+    if (contact.properties.dub_link && contact.properties.dub_partner_email) {
+      if (process.env.NODE_ENV === "development") {
+        console.log(
+          `[HubSpot] Contact ${contact.id} already has dub_link and dub_partner_email. Skipping update.`,
+        );
+      }
+      return;
+    }
+
+    const properties: { dub_partner_email?: string; dub_link?: string } = {};
+
+    // Only fetch partner email if we actually need to set it.
+    if (
+      !contact.properties.dub_partner_email &&
+      trackLeadResult.link?.partnerId
+    ) {
+      const partner = await prisma.partner.findUnique({
+        where: { id: trackLeadResult.link.partnerId },
+        select: { email: true },
+      });
+      if (partner?.email) {
+        properties.dub_partner_email = partner.email;
+      }
+    }
+
+    // Prefer url, fall back to shortLink; only set if missing on contact.
+    if (!contact.properties.dub_link) {
+      const partnerLink =
+        trackLeadResult.link?.url ?? trackLeadResult.link?.shortLink;
+      if (partnerLink) {
+        properties.dub_link = partnerLink;
+      }
+    }
+
+    if (Object.keys(properties).length === 0) {
+      return;
+    }
+
+    await updateHubSpotContact({
+      contactId: contact.id,
+      accessToken,
+      properties,
+    });
+  } catch (error) {
+    console.error(
+      `[HubSpot] Failed to compute/update contact properties for ${contact.id}:`,
+      error,
+    );
+  }
 };

158-160: OK — TrackLeadResponse.link.shortLink exists; no change needed

trackLeadResponseSchema (apps/web/lib/zod/schemas/leads.ts) includes shortLink on the link object, and the current usage in apps/web/lib/integrations/hubspot/track-lead.ts is correct.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@devkiran devkiran marked this pull request as ready for review September 23, 2025 05:24
@devkiran devkiran self-requested a review September 23, 2025 05:24
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (10)
apps/web/lib/integrations/hubspot/track-lead.ts (1)

84-90: Avoid "undefined undefined" names and use dub_id as clickId when available.

Template-literal concatenation can emit "undefined undefined". Also, we’re losing attribution by hardcoding clickId: "" when the contact may have dub_id.

Apply:

-    return 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,
-    });
+    const c = contactInfo.properties;
+    const customerName =
+      [c.firstname, c.lastname].filter(Boolean).join(" ") || null;
+    return await trackLead({
+      clickId: c.dub_id ?? "",
+      eventName: `Deal ${properties.dealstage}`,
+      customerExternalId: c.email,
+      customerName,
+      customerEmail: c.email,
+      mode: "async",
+      workspace,
+      rawBody: payload,
+    });
apps/web/lib/integrations/hubspot/schema.ts (1)

14-20: Trim and validate the optional deal-stage ID to reduce user input errors.

Consider:

-export const hubSpotSettingsSchema = z.object({
-  closedWonDealStageId: z
-    .string()
-    .nullish()
-    .describe("The ID of the deal stage that represents a closed won deal."),
-});
+export const hubSpotSettingsSchema = z.object({
+  closedWonDealStageId: z
+    .string()
+    .trim()
+    .min(1)
+    .nullish()
+    .describe("The ID of the deal stage that represents a closed won deal."),
+});
apps/web/lib/types.ts (1)

382-382: Exposing settings?: Prisma.JsonValue — LGTM; consider narrowing at use sites.

Where you consume settings (e.g., HubSpot UI/action), prefer a narrow type (e.g., { closedWonDealStageId?: string | null }) to avoid any casts and catch typos at compile time.

apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx (2)

277-281: Brand capitalization nit: “HubSpot”.

-                      title="Hubspot integration is only available on Advanced plans and above. Upgrade to get started."
+                      title="HubSpot integration is only available on Advanced plans and above. Upgrade to get started."

50-50: Use barrel export from @dub/utils
HUBSPOT_INTEGRATION_ID is re-exported by packages/utils/src/index.ts (export * from './constants'), so replace the deep import:

Replace
import { HUBSPOT_INTEGRATION_ID } from "@dub/utils/src/constants/integrations";

with
import { HUBSPOT_INTEGRATION_ID } from "@dub/utils";

apps/web/app/(ee)/api/hubspot/webhook/route.ts (1)

20-50: Consider adding support for X-HubSpot-Signature-v3.

HubSpot’s newer v3 signature uses HMAC with additional components; accepting both headers improves robustness during/after HS migrations.

apps/web/lib/integrations/hubspot/update-hubspot-settings.ts (1)

10-15: Remove unused workspaceId input to avoid trusting client-provided tenant IDs.

You already have ctx.workspace; drop workspaceId from the schema and the z import.

-import { z } from "zod";
-import { hubSpotSettingsSchema } from "./schema";
+import { hubSpotSettingsSchema } from "./schema";
 
-const schema = hubSpotSettingsSchema
-  .pick({ closedWonDealStageId: true })
-  .extend({
-    workspaceId: z.string(),
-  });
+const schema = hubSpotSettingsSchema.pick({ closedWonDealStageId: true });
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page.tsx (1)

60-62: Extract settings logic for consistency.

The pattern mirrors the existing credentials extraction. The logic is correct but could benefit from consolidation.

Consider extracting the installation data once to reduce duplication:

  const installed = integration.installations.length > 0;
+ const installation = installed ? integration.installations[0] : undefined;

  const credentials = installed
-    ? integration.installations[0]?.credentials
+    ? installation?.credentials
    : undefined;

  const settings = installed
-    ? integration.installations[0]?.settings
+    ? installation?.settings
    : undefined;

  // TODO:
  // Fix this, we only displaying the first webhook only
  const webhookId = installed
-    ? integration.installations[0]?.webhooks[0]?.id
+    ? installation?.webhooks[0]?.id
    : undefined;
apps/web/lib/integrations/hubspot/ui/settings.tsx (2)

17-19: Consider safer type casting and input validation.

The type casting (settings as any)?.closedWonDealStageId bypasses TypeScript's type safety. While functional, this could be improved with proper typing.

Consider one of these approaches for better type safety:

Option 1: Define a settings type interface:

interface HubSpotSettingsType {
  closedWonDealStageId?: string;
}

const [closedWonDealStageId, setClosedWonDealStageId] = useState(
  (settings as HubSpotSettingsType)?.closedWonDealStageId || DEFAULT_CLOSED_WON_DEAL_STAGE_ID,
);

Option 2: Use safe property access:

const [closedWonDealStageId, setClosedWonDealStageId] = useState(() => {
  if (settings && typeof settings === 'object' && 'closedWonDealStageId' in settings) {
    return (settings as { closedWonDealStageId?: string }).closedWonDealStageId || DEFAULT_CLOSED_WON_DEAL_STAGE_ID;
  }
  return DEFAULT_CLOSED_WON_DEAL_STAGE_ID;
});

64-72: Add input validation for HubSpot deal stage format.

Based on the HubSpot API documentation, deal stage IDs can be descriptive strings like "closedwon" for default pipelines or numeric strings for custom pipelines. Consider adding client-side validation to guide users.

Add basic input validation and helper text:

          <div className="relative mt-4 rounded-md shadow-sm">
            <input
              className="w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
-              placeholder={`Enter deal stage ID (e.g., ${DEFAULT_CLOSED_WON_DEAL_STAGE_ID})`}
+              placeholder={`Enter deal stage ID (e.g., ${DEFAULT_CLOSED_WON_DEAL_STAGE_ID} or 139921)`}
              type="text"
              autoComplete="off"
              name="closedWonDealStageId"
              value={closedWonDealStageId}
              onChange={(e) => setClosedWonDealStageId(e.target.value)}
            />
          </div>
+          <p className="mt-2 text-xs text-neutral-500">
+            Use descriptive names like "closedwon" for default pipelines, or numeric IDs for custom pipelines. 
+            Find your deal stage ID in HubSpot's pipeline settings.
+          </p>
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 18d42d6 and 9c9ff24.

📒 Files selected for processing (11)
  • apps/web/app/(ee)/api/hubspot/webhook/route.ts (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page.tsx (2 hunks)
  • apps/web/lib/integrations/hubspot/constants.ts (1 hunks)
  • apps/web/lib/integrations/hubspot/schema.ts (2 hunks)
  • apps/web/lib/integrations/hubspot/track-lead.ts (2 hunks)
  • apps/web/lib/integrations/hubspot/track-sale.ts (3 hunks)
  • apps/web/lib/integrations/hubspot/ui/settings.tsx (1 hunks)
  • apps/web/lib/integrations/hubspot/update-hubspot-settings.ts (1 hunks)
  • apps/web/lib/types.ts (1 hunks)
  • packages/prisma/schema/integration.prisma (1 hunks)
🧰 Additional context used
🧠 Learnings (4)
📚 Learning: 2025-09-19T18:46:43.787Z
Learnt from: CR
PR: dubinc/dub#0
File: packages/hubspot-app/CLAUDE.md:0-0
Timestamp: 2025-09-19T18:46:43.787Z
Learning: Applies to packages/hubspot-app/app/settings/**/*.{js,jsx,ts,tsx} : Only use components exported by hubspot/ui-extensions in settings components

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx
  • apps/web/lib/integrations/hubspot/ui/settings.tsx
📚 Learning: 2025-09-19T18:46:43.787Z
Learnt from: CR
PR: dubinc/dub#0
File: packages/hubspot-app/CLAUDE.md:0-0
Timestamp: 2025-09-19T18:46:43.787Z
Learning: Applies to packages/hubspot-app/app/settings/**/*.{js,jsx,ts,tsx} : Do not use React components from hubspot/ui-extensions/crm in settings components

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx
  • apps/web/lib/integrations/hubspot/ui/settings.tsx
📚 Learning: 2025-09-19T18:46:43.787Z
Learnt from: CR
PR: dubinc/dub#0
File: packages/hubspot-app/CLAUDE.md:0-0
Timestamp: 2025-09-19T18:46:43.787Z
Learning: Applies to packages/hubspot-app/app/settings/**/*.{js,jsx,ts,tsx} : Do not use window.fetch in settings components; use hubspot.fetch from hubspot/ui-extensions

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx
  • apps/web/lib/integrations/hubspot/ui/settings.tsx
📚 Learning: 2025-09-19T18:46:43.787Z
Learnt from: CR
PR: dubinc/dub#0
File: packages/hubspot-app/CLAUDE.md:0-0
Timestamp: 2025-09-19T18:46:43.787Z
Learning: Applies to packages/hubspot-app/app/settings/**/*.{js,jsx,ts,tsx} : Settings components must not access the global window object

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx
  • apps/web/lib/integrations/hubspot/ui/settings.tsx
🧬 Code graph analysis (6)
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx (2)
packages/utils/src/constants/integrations.ts (1)
  • HUBSPOT_INTEGRATION_ID (6-6)
apps/web/lib/integrations/hubspot/ui/settings.tsx (1)
  • HubSpotSettings (12-90)
apps/web/app/(ee)/api/hubspot/webhook/route.ts (2)
apps/web/lib/integrations/hubspot/schema.ts (1)
  • hubSpotSettingsSchema (15-20)
apps/web/lib/integrations/hubspot/track-sale.ts (1)
  • trackHubSpotSaleEvent (9-87)
apps/web/lib/integrations/hubspot/update-hubspot-settings.ts (2)
apps/web/lib/integrations/hubspot/schema.ts (1)
  • hubSpotSettingsSchema (15-20)
packages/utils/src/constants/integrations.ts (1)
  • HUBSPOT_INTEGRATION_ID (6-6)
apps/web/lib/integrations/hubspot/ui/settings.tsx (3)
apps/web/lib/types.ts (1)
  • InstalledIntegrationInfoProps (355-384)
apps/web/lib/integrations/hubspot/constants.ts (1)
  • DEFAULT_CLOSED_WON_DEAL_STAGE_ID (27-27)
apps/web/lib/integrations/hubspot/update-hubspot-settings.ts (1)
  • updateHubSpotSettingsAction (16-46)
apps/web/lib/integrations/hubspot/track-sale.ts (3)
apps/web/lib/types.ts (1)
  • WorkspaceProps (186-202)
apps/web/lib/integrations/hubspot/types.ts (1)
  • HubSpotAuthToken (4-4)
apps/web/lib/integrations/hubspot/constants.ts (1)
  • DEFAULT_CLOSED_WON_DEAL_STAGE_ID (27-27)
apps/web/lib/integrations/hubspot/track-lead.ts (1)
apps/web/lib/integrations/hubspot/schema.ts (1)
  • hubSpotLeadEventSchema (67-71)
🔇 Additional comments (12)
apps/web/lib/integrations/hubspot/schema.ts (1)

37-38: dub_id relaxed to nullish — LGTM.

Permits undefined or null, matching upstream variability.

apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx (1)

56-61: Settings component wiring — LGTM.

HubSpot appears correctly added to the integration‐component map.

apps/web/lib/integrations/hubspot/constants.ts (1)

27-27: Default Closed Won stage ID — LGTM.

apps/web/app/(ee)/api/hubspot/webhook/route.ts (1)

128-135: Wiring settings into sale tracking — LGTM.

Parsing installation settings and passing closedWonDealStageId makes the stage check configurable with a sane default downstream.

packages/prisma/schema/integration.prisma (1)

38-38: Confirm Prisma migration adding InstalledIntegration.settings exists and is applied.

Adding settings Json? (packages/prisma/schema/integration.prisma ≈ line 38) is fine; automated search returned no migration matches ("No files were searched"), so verify a committed + deployed Prisma migration that adds this column (or provide the migration file path). Ensure reads tolerate null.

apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page.tsx (1)

89-89: LGTM!

Settings are properly extracted and passed to the client component for display and configuration.

apps/web/lib/integrations/hubspot/track-sale.ts (4)

3-3: LGTM!

The import is correctly added to support the configurable deal stage functionality.


13-13: LGTM!

The function signature is properly updated to support configurable deal stage IDs with appropriate type annotations and optional parameter handling.

Also applies to: 18-18


20-21: LGTM!

The fallback logic correctly uses the default constant when no custom deal stage ID is provided, ensuring backward compatibility.


36-41: Enhanced validation improves debugging.

The dynamic comparison against the configurable deal stage ID and the enhanced error message with the expected value will significantly improve debugging when webhook validation fails.

Based on the web search results, the dealstage property returns internal IDs, and for the default "Sales Pipeline," "Closed Won" has an internal ID of "closedwon". However, for custom pipelines, these internal IDs are typically numeric strings rather than descriptive names. This change properly accommodates both scenarios.

apps/web/lib/integrations/hubspot/ui/settings.tsx (2)

30-41: LGTM!

Form submission logic correctly validates workspace presence and handles empty string conversion to null appropriately. The implementation properly calls the server action with the expected parameters.


43-45: LGTM!

Proper guard clause ensures the component only renders when the integration is installed, preventing unnecessary UI display.

…il` properties to contact updates and modifying the contact fetching logic to include these new fields.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/lib/integrations/hubspot/track-lead.ts (1)

40-53: Guard against missing email to avoid Prisma errors; fall back to contact.id

Without this, customerExternalId can be undefined/null, causing DB lookup failures.

   const customerName =
     [properties.firstname, properties.lastname].filter(Boolean).join(" ") ||
     null;

-    const trackLeadResult = await trackLead({
+    const customerEmail = properties.email || null;
+    const customerExternalId =
+      customerEmail ?? String(contact.id);
+
+    const trackLeadResult = await trackLead({
       clickId: properties.dub_id,
       eventName: "Sign up",
-      customerEmail: properties.email,
-      customerExternalId: properties.email,
+      customerEmail,
+      customerExternalId,
       customerName,
       mode: "deferred",
       workspace,
       rawBody: payload,
     });
🧹 Nitpick comments (5)
apps/web/lib/integrations/hubspot/get-contact.ts (2)

12-19: Harden the request URL: encode contactId, use URLSearchParams, and set Accept header

Prevents path issues with non-numeric IDs and improves readability. Also explicitly request JSON.

Apply this diff:

-    const response = await fetch(
-      `${HUBSPOT_API_HOST}/crm/v3/objects/contacts/${contactId}?properties=email,firstname,lastname,dub_id,dub_link,dub_partner_email`,
-      {
-        method: "GET",
-        headers: {
-          Authorization: `Bearer ${accessToken}`,
-        },
-      },
-    );
+    const params = new URLSearchParams({
+      properties: [
+        "email",
+        "firstname",
+        "lastname",
+        "dub_id",
+        "dub_link",
+        "dub_partner_email",
+      ].join(","),
+    });
+    const response = await fetch(
+      `${HUBSPOT_API_HOST}/crm/v3/objects/contacts/${encodeURIComponent(String(contactId))}?${params.toString()}`,
+      {
+        method: "GET",
+        headers: {
+          Authorization: `Bearer ${accessToken}`,
+          Accept: "application/json",
+        },
+      },
+    );

28-30: More robust error surface on non-OK responses

Fall back to the raw response payload if message is absent.

-    if (!response.ok) {
-      throw new Error(result.message);
-    }
+    if (!response.ok) {
+      throw new Error(result.message || JSON.stringify(result));
+    }
apps/web/lib/integrations/hubspot/track-lead.ts (1)

137-148: Don’t throw in background task if partner missing

Use findUnique and guard email to avoid unhandled rejections in waitUntil.

-  if (trackLeadResult.link?.partnerId) {
-    const partner = await prisma.partner.findUniqueOrThrow({
+  if (trackLeadResult.link?.partnerId) {
+    const partner = await prisma.partner.findUnique({
       where: {
         id: trackLeadResult.link.partnerId,
       },
       select: {
         email: true,
       },
     });
-
-    partnerEmail = partner.email ?? "";
+    if (partner?.email) {
+      partnerEmail = partner.email;
+    }
   }
apps/web/lib/integrations/hubspot/update-contact.ts (2)

15-17: Filter out empty-string values before PATCH to avoid wiping fields

Only send keys with non-empty values.

-  if (Object.keys(properties).length === 0) {
-    return null;
-  }
+  const filteredProperties = Object.fromEntries(
+    Object.entries(properties).filter(
+      ([, v]) => typeof v === "string" && v.length > 0,
+    ),
+  ) as { dub_link?: string; dub_partner_email?: string };
+  if (Object.keys(filteredProperties).length === 0) {
+    return null;
+  }
...
-        body: JSON.stringify({
-          properties,
-        }),
+        body: JSON.stringify({
+          properties: filteredProperties,
+        }),

Also applies to: 35-37


27-34: Harden request: encode contactId path segment and set Accept header

Prevents path issues and explicitly negotiates JSON.

-    const response = await fetch(
-      `${HUBSPOT_API_HOST}/crm/v3/objects/contacts/${contactId}`,
+    const response = await fetch(
+      `${HUBSPOT_API_HOST}/crm/v3/objects/contacts/${encodeURIComponent(String(contactId))}`,
       {
         method: "PATCH",
         headers: {
           Authorization: `Bearer ${accessToken}`,
           "Content-Type": "application/json",
+          Accept: "application/json",
         },
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9c9ff24 and ad297df.

📒 Files selected for processing (5)
  • apps/web/lib/integrations/hubspot/get-contact.ts (1 hunks)
  • apps/web/lib/integrations/hubspot/schema.ts (2 hunks)
  • apps/web/lib/integrations/hubspot/track-lead.ts (5 hunks)
  • apps/web/lib/integrations/hubspot/track-sale.ts (2 hunks)
  • apps/web/lib/integrations/hubspot/update-contact.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/lib/integrations/hubspot/schema.ts
  • apps/web/lib/integrations/hubspot/track-sale.ts
🧰 Additional context used
🧬 Code graph analysis (3)
apps/web/lib/integrations/hubspot/get-contact.ts (1)
apps/web/lib/integrations/hubspot/constants.ts (1)
  • HUBSPOT_API_HOST (11-11)
apps/web/lib/integrations/hubspot/track-lead.ts (4)
apps/web/lib/integrations/hubspot/schema.ts (1)
  • hubSpotLeadEventSchema (69-73)
apps/web/lib/api/conversions/track-lead.ts (1)
  • trackLead (29-362)
apps/web/lib/types.ts (1)
  • TrackLeadResponse (401-401)
apps/web/lib/integrations/hubspot/update-contact.ts (1)
  • updateHubSpotContact (3-56)
apps/web/lib/integrations/hubspot/update-contact.ts (1)
apps/web/lib/integrations/hubspot/constants.ts (1)
  • HUBSPOT_API_HOST (11-11)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (5)
apps/web/lib/integrations/hubspot/get-contact.ts (1)

28-30: Harden error message fallback to avoid “undefined” errors.

HubSpot errors aren’t always in result.message. Prefer a richer fallback.

-    if (!response.ok) {
-      throw new Error(result.message);
-    }
+    if (!response.ok) {
+      const msg =
+        result?.message ??
+        result?.reason ??
+        result?.errors?.[0]?.message ??
+        response.statusText ??
+        "Failed to retrieve contact";
+      throw new Error(`HubSpot ${response.status}: ${msg}`);
+    }
apps/web/lib/integrations/hubspot/update-contact.ts (1)

43-45: Improve error detail on non‑2xx responses.

HubSpot error payloads vary; include robust fallbacks for clearer diagnostics.

-    if (!response.ok) {
-      throw new Error(result.message || "Failed to update contact");
-    }
+    if (!response.ok) {
+      const msg =
+        result?.message ??
+        result?.reason ??
+        result?.errors?.[0]?.message ??
+        response.statusText ??
+        "Failed to update contact";
+      throw new Error(`HubSpot ${response.status}: ${msg}`);
+    }
apps/web/lib/integrations/hubspot/track-lead.ts (3)

55-63: Ensure background task doesn’t raise unhandled rejection.

Attach a catch to the promise passed to waitUntil to avoid noisy unhandled rejections.

-    if (trackLeadResult) {
-      waitUntil(
-        _updateHubSpotContact({
-          contact: contactInfo,
-          trackLeadResult,
-          accessToken: authToken.access_token,
-        }),
-      );
-    }
+    if (trackLeadResult) {
+      waitUntil(
+        _updateHubSpotContact({
+          contact: contactInfo,
+          trackLeadResult,
+          accessToken: authToken.access_token,
+        }).catch((e) => {
+          console.error(
+            `[HubSpot] _updateHubSpotContact failed for contact ${contactInfo.id}:`,
+            e,
+          );
+        }),
+      );
+    }

99-105: Avoid “null null” customer names in 0‑3 flow.

Mirror the safer join used in the 0‑1 branch.

-      customerName: `${contactInfo.properties.firstname} ${contactInfo.properties.lastname}`,
+      customerName:
+        [contactInfo.properties.firstname, contactInfo.properties.lastname]
+          .filter(Boolean)
+          .join(" ") || null,

110-118: Also guard the second background task with a catch.

-    if (trackLeadResult) {
-      waitUntil(
-        _updateHubSpotContact({
-          contact: contactInfo,
-          trackLeadResult,
-          accessToken: authToken.access_token,
-        }),
-      );
-    }
+    if (trackLeadResult) {
+      waitUntil(
+        _updateHubSpotContact({
+          contact: contactInfo,
+          trackLeadResult,
+          accessToken: authToken.access_token,
+        }).catch((e) => {
+          console.error(
+            `[HubSpot] _updateHubSpotContact failed for contact ${contactInfo.id}:`,
+            e,
+          );
+        }),
+      );
+    }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9c9ff24 and 9cec98a.

📒 Files selected for processing (6)
  • apps/web/lib/integrations/hubspot/get-contact.ts (1 hunks)
  • apps/web/lib/integrations/hubspot/schema.ts (2 hunks)
  • apps/web/lib/integrations/hubspot/track-lead.ts (5 hunks)
  • apps/web/lib/integrations/hubspot/track-sale.ts (2 hunks)
  • apps/web/lib/integrations/hubspot/types.ts (1 hunks)
  • apps/web/lib/integrations/hubspot/update-contact.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/lib/integrations/hubspot/schema.ts
  • apps/web/lib/integrations/hubspot/track-sale.ts
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-17T02:53:28.359Z
Learnt from: devkiran
PR: dubinc/dub#2839
File: apps/web/lib/integrations/hubspot/schema.ts:5-12
Timestamp: 2025-09-17T02:53:28.359Z
Learning: HubSpot's OAuth token response returns `scopes` as an array of strings, not as a space-delimited string. The schema `scopes: z.array(z.string())` in hubSpotAuthTokenSchema is correct for HubSpot's actual API response format.

Applied to files:

  • apps/web/lib/integrations/hubspot/types.ts
🧬 Code graph analysis (4)
apps/web/lib/integrations/hubspot/update-contact.ts (1)
apps/web/lib/integrations/hubspot/constants.ts (1)
  • HUBSPOT_API_HOST (11-11)
apps/web/lib/integrations/hubspot/get-contact.ts (1)
apps/web/lib/integrations/hubspot/constants.ts (1)
  • HUBSPOT_API_HOST (11-11)
apps/web/lib/integrations/hubspot/types.ts (1)
apps/web/lib/integrations/hubspot/schema.ts (3)
  • hubSpotAuthTokenSchema (5-12)
  • hubSpotRefreshTokenSchema (22-28)
  • hubSpotContactSchema (31-41)
apps/web/lib/integrations/hubspot/track-lead.ts (5)
apps/web/lib/integrations/hubspot/get-contact.ts (1)
  • getHubSpotContact (4-39)
apps/web/lib/api/conversions/track-lead.ts (1)
  • trackLead (29-362)
apps/web/lib/integrations/hubspot/types.ts (1)
  • HubSpotContact (12-12)
apps/web/lib/types.ts (1)
  • TrackLeadResponse (401-401)
apps/web/lib/integrations/hubspot/update-contact.ts (1)
  • updateHubSpotContact (3-52)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (2)
apps/web/lib/integrations/hubspot/types.ts (1)

2-6: LGTM – exporting HubSpotContact type improves ergonomics.

Import grouping and the new HubSpotContact export look good.

Also applies to: 12-12

apps/web/lib/integrations/hubspot/get-contact.ts (1)

13-13: Confirmed — HubSpot GET tolerates unknown properties (returns 200 and omits them). HubSpot CRM v3 returns 200 and excludes any undefined properties from the response, so the current request is safe.

@devkiran
Copy link
Collaborator

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 23, 2025

✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
apps/web/lib/integrations/hubspot/update-hubspot-settings.ts (2)

18-21: Validate input workspaceId against ctx.workspace.id.

You accept workspaceId in the schema but don’t verify it matches the authenticated workspace. Add a guard to prevent accidental/misleading cross‑tenant updates.

-    const { closedWonDealStageId } = parsedInput;
+    const { workspaceId, closedWonDealStageId } = parsedInput;
+    if (workspaceId !== workspace.id) {
+      throw new Error("Invalid workspaceId for this session.");
+    }

35-35: Avoid any for settings; use a safer shape.

Minor type hygiene to reduce foot‑guns when extending settings.

-    const current = (installedIntegration.settings as any) ?? {};
+    const current =
+      ((installedIntegration.settings ?? {}) as Record<string, unknown>);
apps/web/lib/integrations/hubspot/track-lead.ts (1)

44-66: Ensure robust background processing with error handling.

The deferred contact update using waitUntil is appropriate for non-blocking operations. However, consider adding error handling to prevent silent failures in the background task.

Apply this diff to add error handling:

   if (trackLeadResult) {
     waitUntil(
-      _updateHubSpotContact({
-        contact: contactInfo,
-        trackLeadResult,
-        accessToken: authToken.access_token,
-      }),
+      _updateHubSpotContact({
+        contact: contactInfo,
+        trackLeadResult,
+        accessToken: authToken.access_token,
+      }).catch((error) => {
+        console.error(
+          `[HubSpot] Failed to update contact ${contactInfo.id} in background:`,
+          error,
+        );
+      }),
     );
   }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9cec98a and cc3a2c0.

📒 Files selected for processing (2)
  • apps/web/lib/integrations/hubspot/track-lead.ts (5 hunks)
  • apps/web/lib/integrations/hubspot/update-hubspot-settings.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/lib/integrations/hubspot/track-lead.ts (5)
apps/web/lib/integrations/hubspot/schema.ts (1)
  • hubSpotLeadEventSchema (69-73)
apps/web/lib/integrations/hubspot/get-contact.ts (1)
  • getHubSpotContact (4-39)
apps/web/lib/integrations/hubspot/types.ts (1)
  • HubSpotContact (12-12)
apps/web/lib/types.ts (1)
  • TrackLeadResponse (401-401)
apps/web/lib/integrations/hubspot/update-contact.ts (1)
  • updateHubSpotContact (3-52)
apps/web/lib/integrations/hubspot/update-hubspot-settings.ts (2)
apps/web/lib/integrations/hubspot/schema.ts (1)
  • hubSpotSettingsSchema (15-20)
packages/utils/src/constants/integrations.ts (1)
  • HUBSPOT_INTEGRATION_ID (6-6)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (8)
apps/web/lib/integrations/hubspot/update-hubspot-settings.ts (3)

22-27: Tenant scoping fix looks good.

Filtering by both integrationId and projectId correctly scopes to the current workspace.


37-47: Summary claims a default is applied, but code doesn’t.

The AI summary mentions DEFAULT_CLOSED_WON_DEAL_STAGE_ID, but this action doesn’t apply any default when value is null/undefined. Confirm intended behavior; if a default is desired, import and apply it on nullish input.


49-50: Revalidation path is correct.

This will refresh the HubSpot settings UI for the current workspace after update.

apps/web/lib/integrations/hubspot/track-lead.ts (5)

1-9: LGTM!

The imports are correctly added for the new functionality. The waitUntil utility allows asynchronous tasks to run after the response is sent without blocking the user's response, and the new imports support the deferred contact update functionality.


20-20: LGTM!

The variable name change from contact to contactInfo provides better clarity about the data being retrieved from HubSpot.


24-42: LGTM!

The changes improve code clarity and robustness by:

  • Using the more descriptive variable name contactInfo
  • Adding proper null check for contact info
  • Using the improved customer name derivation logic that filters out falsy values

99-121: LGTM! Customer name construction is improved.

The customer name construction now properly handles the case where firstname or lastname might be missing, addressing the previous issue of "undefined undefined".


125-171: LGTM! Conditional property updates prevent field clearing.

The implementation correctly addresses the previous issues by:

  • Building properties conditionally to avoid sending empty strings
  • Checking partner.email before adding to properties
  • Only calling updateHubSpotContact when there are actual properties to update

This prevents accidentally clearing existing HubSpot fields with empty values.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (13)
apps/web/lib/integrations/hubspot/update-hubspot-settings.ts (1)

10-15: WorkspaceId input appears unused; remove to reduce confusion

ctx.workspace.id is used for scoping; workspaceId in the schema isn’t referenced.

Apply this diff:

-const schema = hubSpotSettingsSchema
-  .pick({ closedWonDealStageId: true })
-  .extend({
-    workspaceId: z.string(),
-  });
+const schema = hubSpotSettingsSchema.pick({ closedWonDealStageId: true });
apps/web/app/(ee)/api/hubspot/webhook/route.ts (1)

128-135: Parse settings defensively to avoid rejecting the whole event on malformed JSON.

Use safeParse and fallback; log parse errors without throwing.

Apply:

-      const settings = hubSpotSettingsSchema.parse(installation.settings ?? {});
+      const parsed = hubSpotSettingsSchema.safeParse(installation.settings ?? {});
+      const closedWonDealStageId =
+        parsed.success ? parsed.data.closedWonDealStageId ?? undefined : undefined;
+      if (!parsed.success) {
+        console.warn(
+          "[HubSpot] Invalid installation.settings shape for portalId",
+          portalId,
+          parsed.error?.message,
+        );
+      }
@@
-        closedWonDealStageId: settings?.closedWonDealStageId,
+        closedWonDealStageId,
apps/web/lib/integrations/hubspot/schema.ts (1)

14-21: Normalize closedWonDealStageId to avoid case/whitespace mismatches.

Helps prevent false negatives when comparing to hubspot dealstage.

Apply:

 export const hubSpotSettingsSchema = z.object({
-  closedWonDealStageId: z
-    .string()
-    .nullish()
-    .describe("The ID of the deal stage that represents a closed won deal."),
+  closedWonDealStageId: z
+    .string()
+    .transform((s) => s.trim().toLowerCase())
+    .nullish()
+    .describe("The ID of the deal stage that represents a closed won deal."),
 });
apps/web/lib/integrations/hubspot/update-contact.ts (2)

1-1: Mark server-only to prevent accidental client bundling of HubSpot access tokens.

Apply:

+import "server-only";
 import { HUBSPOT_API_HOST } from "./constants";

37-45: Harden error handling (non‑JSON responses) and include status in errors.

HubSpot may return text/HTML on errors; response.json() will throw.

Apply:

-    const result = await response.json();
+    const text = await response.text();
+    const result = (() => {
+      try {
+        return JSON.parse(text);
+      } catch {
+        return text;
+      }
+    })();
@@
-    if (!response.ok) {
-      throw new Error(result.message || "Failed to update contact");
-    }
+    if (!response.ok) {
+      const msg =
+        typeof result === "string" ? result : result?.message || "Failed to update contact";
+      throw new Error(`[HubSpot] ${response.status} ${response.statusText}: ${msg}`);
+    }
apps/web/lib/integrations/hubspot/ui/settings.tsx (4)

17-19: Prefer blank initial value; use placeholder for the default.

Pre-filling with the default risks saving it unintentionally.

Apply:

-  const [closedWonDealStageId, setClosedWonDealStageId] = useState(
-    (settings as any)?.closedWonDealStageId || DEFAULT_CLOSED_WON_DEAL_STAGE_ID,
-  );
+  const [closedWonDealStageId, setClosedWonDealStageId] = useState(
+    (settings as any)?.closedWonDealStageId ?? "",
+  );

33-41: Normalize input and surface missing workspace feedback.

Apply:

-    if (!workspaceId) {
-      return;
-    }
+    if (!workspaceId) {
+      toast.error("No workspace selected.");
+      return;
+    }
 
-    await executeAsync({
+    const normalized = closedWonDealStageId.trim();
+    await executeAsync({
       workspaceId,
-      closedWonDealStageId: closedWonDealStageId || null,
+      closedWonDealStageId: normalized || null,
     });

51-53: Add a label for accessibility.

Apply:

-          <p className="text-sm font-medium text-neutral-700">
-            Closed Won Deal Stage ID
-          </p>
+          <label
+            htmlFor="closedWonDealStageId"
+            className="text-sm font-medium text-neutral-700"
+          >
+            Closed Won Deal Stage ID
+          </label>

63-73: Wire input to the label via id.

Apply:

-            <input
+            <input
+              id="closedWonDealStageId"
               className="w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm"
               placeholder={`Enter deal stage ID (e.g., ${DEFAULT_CLOSED_WON_DEAL_STAGE_ID})`}
               type="text"
               autoComplete="off"
               name="closedWonDealStageId"
               value={closedWonDealStageId}
               onChange={(e) => setClosedWonDealStageId(e.target.value)}
             />
apps/web/lib/integrations/hubspot/track-sale.ts (4)

20-22: Avoid parameter reassignment and normalize expected stage once.

Apply:

-  closedWonDealStageId =
-    closedWonDealStageId ?? DEFAULT_CLOSED_WON_DEAL_STAGE_ID;
+  const expectedClosedWonDealStageId = (
+    closedWonDealStageId ?? DEFAULT_CLOSED_WON_DEAL_STAGE_ID
+  ).trim().toLowerCase();

38-41: Compare normalized dealstage values.

Apply:

-  if (propertyValue !== closedWonDealStageId) {
-    console.error(
-      `[HubSpot] Unknown propertyValue ${propertyValue}. Expected ${closedWonDealStageId}.`,
-    );
+  const actualDealstage = String(propertyValue).trim().toLowerCase();
+  if (actualDealstage !== expectedClosedWonDealStageId) {
+    console.error(
+      `[HubSpot] Unknown propertyValue ${propertyValue}. Expected ${expectedClosedWonDealStageId}.`,
+    );

56-60: Validate numeric amount before proceeding.

Apply:

   if (!properties.amount) {
     console.error(`[HubSpot] Amount is not set for deal ${dealId}`);
     return;
   }
+  const cents = Math.round(parseFloat(properties.amount) * 100);
+  if (Number.isNaN(cents)) {
+    console.error(`[HubSpot] Invalid amount for deal ${dealId}: ${properties.amount}`);
+    return;
+  }

80-83: Pass integer cents to trackSale.

Apply:

-    amount: Number(properties.amount) * 100,
+    amount: cents,
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 18d42d6 and cc3a2c0.

📒 Files selected for processing (14)
  • apps/web/app/(ee)/api/hubspot/webhook/route.ts (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page.tsx (2 hunks)
  • apps/web/lib/integrations/hubspot/constants.ts (1 hunks)
  • apps/web/lib/integrations/hubspot/get-contact.ts (1 hunks)
  • apps/web/lib/integrations/hubspot/schema.ts (2 hunks)
  • apps/web/lib/integrations/hubspot/track-lead.ts (5 hunks)
  • apps/web/lib/integrations/hubspot/track-sale.ts (2 hunks)
  • apps/web/lib/integrations/hubspot/types.ts (1 hunks)
  • apps/web/lib/integrations/hubspot/ui/settings.tsx (1 hunks)
  • apps/web/lib/integrations/hubspot/update-contact.ts (1 hunks)
  • apps/web/lib/integrations/hubspot/update-hubspot-settings.ts (1 hunks)
  • apps/web/lib/types.ts (1 hunks)
  • packages/prisma/schema/integration.prisma (1 hunks)
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-09-19T18:46:43.787Z
Learnt from: CR
PR: dubinc/dub#0
File: packages/hubspot-app/CLAUDE.md:0-0
Timestamp: 2025-09-19T18:46:43.787Z
Learning: Applies to packages/hubspot-app/app/settings/**/*.{js,jsx,ts,tsx} : Only use components exported by hubspot/ui-extensions in settings components

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx
  • apps/web/lib/integrations/hubspot/ui/settings.tsx
📚 Learning: 2025-09-19T18:46:43.787Z
Learnt from: CR
PR: dubinc/dub#0
File: packages/hubspot-app/CLAUDE.md:0-0
Timestamp: 2025-09-19T18:46:43.787Z
Learning: Applies to packages/hubspot-app/app/settings/**/*.{js,jsx,ts,tsx} : Do not use React components from hubspot/ui-extensions/crm in settings components

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx
  • apps/web/lib/integrations/hubspot/ui/settings.tsx
📚 Learning: 2025-09-19T18:46:43.787Z
Learnt from: CR
PR: dubinc/dub#0
File: packages/hubspot-app/CLAUDE.md:0-0
Timestamp: 2025-09-19T18:46:43.787Z
Learning: Applies to packages/hubspot-app/app/settings/**/*.{js,jsx,ts,tsx} : Do not use window.fetch in settings components; use hubspot.fetch from hubspot/ui-extensions

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx
  • apps/web/lib/integrations/hubspot/ui/settings.tsx
📚 Learning: 2025-09-19T18:46:43.787Z
Learnt from: CR
PR: dubinc/dub#0
File: packages/hubspot-app/CLAUDE.md:0-0
Timestamp: 2025-09-19T18:46:43.787Z
Learning: Applies to packages/hubspot-app/app/settings/**/*.{js,jsx,ts,tsx} : Settings components must not access the global window object

Applied to files:

  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx
  • apps/web/lib/integrations/hubspot/ui/settings.tsx
📚 Learning: 2025-09-17T02:53:28.359Z
Learnt from: devkiran
PR: dubinc/dub#2839
File: apps/web/lib/integrations/hubspot/schema.ts:5-12
Timestamp: 2025-09-17T02:53:28.359Z
Learning: HubSpot's OAuth token response returns `scopes` as an array of strings, not as a space-delimited string. The schema `scopes: z.array(z.string())` in hubSpotAuthTokenSchema is correct for HubSpot's actual API response format.

Applied to files:

  • apps/web/lib/integrations/hubspot/types.ts
🔇 Additional comments (16)
apps/web/lib/types.ts (1)

355-384: Adding settings to InstalledIntegrationInfoProps: LGTM

Exposing settings?: Prisma.JsonValue aligns with the new InstalledIntegration.settings JSON and downstream UI usage.

packages/prisma/schema/integration.prisma (1)

38-39: Persist per-installation settings: LGTM

New settings Json? on InstalledIntegration fits the flow and keeps credentials/settings split cleanly.

apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page-client.tsx (1)

4-4: Wires HubSpot settings UI correctly

Importing HubSpotSettings and adding it to integrationSettings keyed by HUBSPOT_INTEGRATION_ID looks good.

Minor: import paths for integration IDs vary across files (@dub/utils vs @dub/utils/src/...). Consider standardizing to a single public entrypoint to avoid path drift.

Also applies to: 60-61

apps/web/lib/integrations/hubspot/types.ts (1)

2-6: Type export for HubSpotContact: LGTM

Importing from the schema module and exporting HubSpotContact keeps types aligned with validation.

Also applies to: 12-12

apps/web/lib/integrations/hubspot/get-contact.ts (1)

13-14: Include additional contact properties: LGTM

Adding dub_link and dub_partner_email to the query matches the updated schema and is safe when absent in a portal.

apps/web/lib/integrations/hubspot/constants.ts (1)

27-27: Default closed-won stage constant: LGTM

DEFAULT_CLOSED_WON_DEAL_STAGE_ID = "closedwon" provides a sane fallback for default pipelines.

apps/web/app/app.dub.co/(dashboard)/[slug]/settings/(basic-layout)/integrations/[integrationSlug]/page.tsx (1)

60-63: Propagate installation settings to client: LGTM

Passing settings through alongside credentials enables the new UI.

Note: this page still uses the first installation record. If multiple exist for a workspace, confirm this is intended or adjust the query/ordering.

Also applies to: 89-90

apps/web/lib/integrations/hubspot/update-hubspot-settings.ts (1)

35-47: Guard against writing undefined into a JSON column

Only include closedWonDealStageId when defined to avoid Prisma JSON errors.

Apply this diff:

       data: {
-        settings: {
-          ...current,
-          closedWonDealStageId,
-        },
+        settings: {
+          ...current,
+          ...(closedWonDealStageId !== undefined
+            ? { closedWonDealStageId }
+            : {}),
+        },
       },

If hubSpotSettingsSchema guarantees closedWonDealStageId is never undefined (e.g., string or null), this guard is optional—please confirm.

apps/web/lib/integrations/hubspot/schema.ts (1)

37-40: LGTM: contact schema additions.

Optional fields and nullish semantics look correct.

apps/web/app/(ee)/api/hubspot/webhook/route.ts (1)

4-7: LGTM — no call-site changes required

trackHubSpotSaleEvent uses named-args and closedWonDealStageId is optional with a default; the existing call in apps/web/app/(ee)/api/hubspot/webhook/route.ts is fine.

apps/web/lib/integrations/hubspot/track-lead.ts (6)

2-4: Confirm Node runtime when using Prisma + waitUntil

If this handler can run on the Edge runtime, Prisma will break. Ensure this path is Node.js-only (e.g., runtime: "nodejs") or gate Prisma usage accordingly.


44-53: LGTM: sensible defaults for the 0-1 (contact created) flow

Deferred tracking with safe name construction and dub_id requirement looks good.


55-63: LGTM: defer HubSpot contact update

waitUntil usage to decouple post-track updates is appropriate.


99-108: Deal path: normalize name, add externalId fallback, prefer dub_id clickId, and catch errors

Prevents “undefined undefined”, ensures an externalId when email is absent, and avoids crashing the webhook on trackLead errors.

-    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,
-    });
+    const customerEmail = contactInfo.properties.email || null;
+    const customerExternalId = customerEmail ?? String(contactInfo.id);
+    const customerName =
+      [contactInfo.properties.firstname, contactInfo.properties.lastname]
+        .filter(Boolean)
+        .join(" ") || null;
+    let trackLeadResult: TrackLeadResponse | null = null;
+    try {
+      trackLeadResult = await trackLead({
+        clickId: contactInfo.properties.dub_id || "",
+        eventName: `Deal ${properties.dealstage}`,
+        customerExternalId,
+        customerName,
+        customerEmail,
+        mode: "async",
+        workspace,
+        rawBody: payload,
+      });
+    } catch (err) {
+      console.error(`[HubSpot] trackLead failed for deal ${objectId}:`, err);
+      return;
+    }

110-118: LGTM: defers update after deal-track

Post-track waitUntil scheduling is consistent with the contact flow.


124-171: Update only missing HubSpot fields, avoid throw-on-miss, and wrap in try/catch; limit logs to dev

Prevents overwriting existing values, avoids unhandled rejections from findUniqueOrThrow, and reduces noisy logs.

-  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<string, string> = {};
-
-  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?.url) {
-    properties["dub_link"] = trackLeadResult.link.url;
-  }
-
-  if (Object.keys(properties).length === 0) {
-    return;
-  }
-
-  await updateHubSpotContact({
-    contactId: contact.id,
-    accessToken,
-    properties,
-  });
+  try {
+    if (contact.properties.dub_link && contact.properties.dub_partner_email) {
+      if (process.env.NODE_ENV === "development") {
+        console.log(
+          `[HubSpot] Contact ${contact.id} already has dub_link and dub_partner_email. Skipping update.`,
+        );
+      }
+      return;
+    }
+
+    const patch: { dub_partner_email?: string; dub_link?: string } = {};
+
+    if (trackLeadResult.link?.partnerId && !contact.properties.dub_partner_email) {
+      const partner = await prisma.partner.findUnique({
+        where: { id: trackLeadResult.link.partnerId },
+        select: { email: true },
+      });
+      if (partner?.email) {
+        patch.dub_partner_email = partner.email;
+      }
+    }
+
+    if (trackLeadResult.link?.url && !contact.properties.dub_link) {
+      patch.dub_link = trackLeadResult.link.url;
+    }
+
+    if (Object.keys(patch).length === 0) {
+      return;
+    }
+
+    await updateHubSpotContact({
+      contactId: contact.id,
+      accessToken,
+      properties: patch,
+    });
+  } catch (error) {
+    console.error(`[HubSpot] Failed to update contact ${contact.id}:`, error);
+  }

@steven-tey steven-tey merged commit fcd7d99 into main Sep 24, 2025
7 of 8 checks passed
@steven-tey steven-tey deleted the improve-hubspot branch September 24, 2025 05:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants