diff --git a/apps/web/app/(ee)/api/hubspot/webhook/process/route.ts b/apps/web/app/(ee)/api/hubspot/webhook/process/route.ts new file mode 100644 index 00000000000..b55bff9a0c8 --- /dev/null +++ b/apps/web/app/(ee)/api/hubspot/webhook/process/route.ts @@ -0,0 +1,168 @@ +import { captureWebhookLog } from "@/lib/api-logs/capture-webhook-log"; +import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { withAxiom } from "@/lib/axiom/server"; +import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { hubSpotOAuthProvider } from "@/lib/integrations/hubspot/oauth"; +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 { WorkspaceProps } from "@/lib/types"; +import { prisma } from "@dub/prisma"; +import { logAndRespond } from "../../../cron/utils"; + +// POST /api/hubspot/webhook/process – process individual webhook event +export const POST = withAxiom(async (req) => { + const startTime = Date.now(); + + let body: any; + let workspace: + | Pick + | undefined; + + try { + const rawBody = await req.text(); + + await verifyQstashSignature({ + req, + rawBody, + }); + + body = JSON.parse(rawBody); + + const { objectTypeId, portalId, subscriptionType } = + hubSpotWebhookSchema.parse(body); + + // Find the installation + const installation = await prisma.installedIntegration.findFirst({ + where: { + integration: { + slug: "hubspot", + }, + credentials: { + path: "$.hub_id", + equals: portalId, + }, + }, + include: { + project: { + select: { + id: true, + stripeConnectId: true, + webhookEnabled: true, + }, + }, + }, + }); + + if (!installation) { + return logAndRespond( + `[HubSpot] Installation is not found for portalId ${portalId}.`, + ); + } + + workspace = installation.project; + + // Refresh the access token if needed + const authToken = + await hubSpotOAuthProvider.refreshTokenForInstallation(installation); + + if (!authToken) { + return logAndRespond( + `[HubSpot] Authentication token is not found or valid for portalId ${portalId}.`, + ); + } + + const settings = hubSpotSettingsSchema.parse(installation.settings ?? {}); + + console.log("[HubSpot] Integration settings", settings); + + let response = ""; + + // Contact events + if (objectTypeId === "0-1") { + const isContactCreated = subscriptionType === "object.creation"; + + const isLifecycleStageChanged = + subscriptionType === "object.propertyChange" && + settings.leadTriggerEvent === "lifecycleStageReached"; + + if (isContactCreated || isLifecycleStageChanged) { + response = await trackHubSpotLeadEvent({ + payload: body, + workspace, + authToken, + settings, + }); + } else { + response = `Skipping contact event: subscriptionType "${subscriptionType}" does not match the configured leadTriggerEvent "${settings.leadTriggerEvent}".`; + } + } + + // Deal event + else if (objectTypeId === "0-3") { + const isDealCreated = + subscriptionType === "object.creation" && + settings.leadTriggerEvent === "dealCreated"; + + const isDealUpdated = subscriptionType === "object.propertyChange"; + + // Track the final lead event + if (isDealCreated) { + response = await trackHubSpotLeadEvent({ + payload: body, + workspace, + authToken, + settings, + }); + } + + // Track the sale event when deal is closed won + else if (isDealUpdated) { + response = await trackHubSpotSaleEvent({ + payload: body, + workspace, + authToken, + settings, + }); + } + } + + // Unknown object type + else { + response = `Unknown objectTypeId ${objectTypeId}.`; + } + + await captureWebhookLog({ + workspaceId: workspace.id, + method: "POST", + path: "/hubspot/webhook", + statusCode: 200, + duration: Date.now() - startTime, + requestBody: body, + responseBody: response, + userAgent: req.headers.get("user-agent"), + }); + + return logAndRespond(response); + } catch (error) { + const response = handleAndReturnErrorResponse(error); + + if (workspace) { + await captureWebhookLog({ + workspaceId: workspace.id, + method: "POST", + path: "/hubspot/webhook", + statusCode: response.status, + duration: Date.now() - startTime, + requestBody: body, + responseBody: response, + userAgent: req.headers.get("user-agent"), + }); + } + + return response; + } +}); diff --git a/apps/web/app/(ee)/api/hubspot/webhook/route.ts b/apps/web/app/(ee)/api/hubspot/webhook/route.ts index 77504154166..d0540b1efb1 100644 --- a/apps/web/app/(ee)/api/hubspot/webhook/route.ts +++ b/apps/web/app/(ee)/api/hubspot/webhook/route.ts @@ -1,26 +1,16 @@ -import { captureWebhookLog } from "@/lib/api-logs/capture-webhook-log"; import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { withAxiom } from "@/lib/axiom/server"; -import { hubSpotOAuthProvider } from "@/lib/integrations/hubspot/oauth"; -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 { prisma } from "@dub/prisma"; -import { waitUntil } from "@vercel/functions"; +import { qstash } from "@/lib/cron"; +import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import crypto from "crypto"; -import { NextResponse } from "next/server"; +import { logAndRespond } from "../../cron/utils"; const HUBSPOT_CLIENT_SECRET = process.env.HUBSPOT_CLIENT_SECRET || ""; // POST /api/hubspot/webhook – listen to webhook events from Hubspot export const POST = withAxiom(async (req) => { - const startTime = Date.now(); - try { - const rawPayload = await req.text(); + const rawBody = await req.text(); const signature = req.headers.get("X-HubSpot-Signature"); // Verify webhook signature @@ -39,7 +29,7 @@ export const POST = withAxiom(async (req) => { } // Create expected hash: client_secret + request_body - const sourceString = HUBSPOT_CLIENT_SECRET + rawPayload; + const sourceString = HUBSPOT_CLIENT_SECRET + rawBody; const expectedHash = crypto .createHash("sha256") .update(sourceString) @@ -53,163 +43,27 @@ export const POST = withAxiom(async (req) => { }); } - const payload = JSON.parse(rawPayload) as any[]; - - // HS send multiple events in the same request - // so we need to process each event individually - const results = await Promise.allSettled(payload.map(processWebhookEvent)); - - const responseBody = { message: "Webhook received." }; - const duration = Date.now() - startTime; - - // Collect log entries from fulfilled results, including failures - const logEntries: Array<{ - workspaceId: string; - statusCode: number; - responseBody: unknown; - requestBody: unknown; - }> = []; - - for (let i = 0; i < results.length; i++) { - const r = results[i]; - if (r.status !== "fulfilled" || !r.value) { - continue; - } - - const { workspaceId, errorResponse } = r.value; - - logEntries.push({ - workspaceId, - statusCode: errorResponse ? errorResponse.status : 200, - responseBody: errorResponse ?? responseBody, - requestBody: payload[i], - }); - } + const events = JSON.parse(rawBody) as any[]; + const finalEvents = Array.isArray(events) ? events : [events]; + + // HubSpot can send multiple events in a single request, so we fan them out + // to QStash and process each event independently in /api/hubspot/webhook/process. + // This keeps the webhook handler fast and ensures a slow/failing event doesn't + // block or fail the rest of the batch. + const qstashResponse = await qstash.batchJSON( + finalEvents.map((event) => ({ + url: `${APP_DOMAIN_WITH_NGROK}/api/hubspot/webhook/process`, + body: event, + })), + ); - waitUntil( - Promise.allSettled( - logEntries.map((entry) => - captureWebhookLog({ - workspaceId: entry.workspaceId, - method: req.method, - path: "/hubspot/webhook", - statusCode: entry.statusCode, - duration, - requestBody: entry.requestBody, - responseBody: entry.responseBody, - userAgent: req.headers.get("user-agent"), - }), - ), - ), + console.log( + `[hubspot/webhook] Enqueued ${finalEvents.length} webhook events to be processed.`, + qstashResponse, ); - return NextResponse.json(responseBody); + return logAndRespond("Webhook received."); } catch (error) { return handleAndReturnErrorResponse(error); } }); - -// Process individual event, returns workspaceId and error response if failed -async function processWebhookEvent(event: any) { - const { objectTypeId, portalId, subscriptionType } = - hubSpotWebhookSchema.parse(event); - - // Find the installation - const installation = await prisma.installedIntegration.findFirst({ - where: { - integration: { - slug: "hubspot", - }, - credentials: { - path: "$.hub_id", - equals: portalId, - }, - }, - include: { - project: true, - }, - }); - - if (!installation) { - console.error( - `[HubSpot] Installation is not found for portalId ${portalId}.`, - ); - return; - } - - const { project: workspace } = installation; - - // Refresh the access token if needed - const authToken = - await hubSpotOAuthProvider.refreshTokenForInstallation(installation); - - if (!authToken) { - console.error( - `[HubSpot] Authentication token is not found or valid for portalId ${portalId}.`, - ); - return; - } - - const settings = hubSpotSettingsSchema.parse(installation.settings ?? {}); - - console.log("[HubSpot] Event", event); - console.log("[HubSpot] Integration settings", settings); - - try { - // Contact events - if (objectTypeId === "0-1") { - const isContactCreated = subscriptionType === "object.creation"; - - const isLifecycleStageChanged = - subscriptionType === "object.propertyChange" && - settings.leadTriggerEvent === "lifecycleStageReached"; - - if (isContactCreated || isLifecycleStageChanged) { - await trackHubSpotLeadEvent({ - payload: event, - workspace, - authToken, - settings, - }); - } - } - - // Deal event - if (objectTypeId === "0-3") { - const isDealCreated = - subscriptionType === "object.creation" && - settings.leadTriggerEvent === "dealCreated"; - - const isDealUpdated = subscriptionType === "object.propertyChange"; - - // Track the final lead event - if (isDealCreated) { - await trackHubSpotLeadEvent({ - payload: event, - workspace, - authToken, - settings, - }); - } - - // Track the sale event when deal is closed won - else if (isDealUpdated) { - await trackHubSpotSaleEvent({ - payload: event, - workspace, - authToken, - settings, - }); - } - } - } catch (error) { - return { - workspaceId: workspace.id, - errorResponse: handleAndReturnErrorResponse(error), - }; - } - - return { - workspaceId: workspace.id, - }; -} diff --git a/apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/events/route.ts b/apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/events/route.ts index 1231e2c1a97..ecbda16b126 100644 --- a/apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/events/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/events/route.ts @@ -1,6 +1,6 @@ -import { getPostbackOrThrow } from "@/lib/api/postbacks/get-postback-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; -import { getPostbackEvents } from "@/lib/postback/api/get-postback-events"; +import { getPostbackEvents } from "@/lib/postback/get-postback-events"; +import { getPostbackOrThrow } from "@/lib/postback/get-postback-or-throw"; import { NextResponse } from "next/server"; // GET /api/partner-profile/postbacks/[postbackId]/events diff --git a/apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/rotate-secret/route.ts b/apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/rotate-secret/route.ts index 5a634503277..09ad1f74059 100644 --- a/apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/rotate-secret/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/rotate-secret/route.ts @@ -1,10 +1,10 @@ import { createToken } from "@/lib/api/oauth/utils"; -import { getPostbackOrThrow } from "@/lib/api/postbacks/get-postback-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { POSTBACK_SECRET_LENGTH, POSTBACK_SECRET_PREFIX, } from "@/lib/postback/constants"; +import { getPostbackOrThrow } from "@/lib/postback/get-postback-or-throw"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; diff --git a/apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/route.ts b/apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/route.ts index 0e29517e866..eb1d0165989 100644 --- a/apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/route.ts @@ -1,6 +1,6 @@ -import { getPostbackOrThrow } from "@/lib/api/postbacks/get-postback-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { getPostbackOrThrow } from "@/lib/postback/get-postback-or-throw"; import { postbackSchema, updatePostbackInputSchema, diff --git a/apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/send-test/route.ts b/apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/send-test/route.ts index 3626b2a1ba5..453919f051d 100644 --- a/apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/send-test/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/send-test/route.ts @@ -1,12 +1,12 @@ import { DubApiError } from "@/lib/api/errors"; -import { getPostbackOrThrow } from "@/lib/api/postbacks/get-postback-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withPartnerProfile } from "@/lib/auth/partner"; -import { sendPartnerPostback } from "@/lib/postback/api/send-partner-postback"; +import { getPostbackOrThrow } from "@/lib/postback/get-postback-or-throw"; import commissionCreated from "@/lib/postback/sample-events/commission-created.json"; import leadCreated from "@/lib/postback/sample-events/lead-created.json"; import saleCreated from "@/lib/postback/sample-events/sale-created.json"; import { sendTestPostbackInputSchema } from "@/lib/postback/schemas"; +import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { PostbackTrigger } from "@/lib/types"; import { NextResponse } from "next/server"; diff --git a/apps/web/app/(ee)/api/partner-profile/postbacks/route.ts b/apps/web/app/(ee)/api/partner-profile/postbacks/route.ts index b8019cbb635..c226419dcba 100644 --- a/apps/web/app/(ee)/api/partner-profile/postbacks/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/postbacks/route.ts @@ -3,7 +3,6 @@ import { DubApiError } from "@/lib/api/errors"; import { createToken } from "@/lib/api/oauth/utils"; import { parseRequestBody } from "@/lib/api/utils"; import { withPartnerProfile } from "@/lib/auth/partner"; -import { identifyPostbackChannel } from "@/lib/postback/api/utils"; import { MAX_POSTBACKS, POSTBACK_SECRET_LENGTH, @@ -14,6 +13,7 @@ import { createPostbackOutputSchema, postbackSchema, } from "@/lib/postback/schemas"; +import { identifyPostbackChannel } from "@/lib/postback/utils"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts index 2915c5c4a69..1627cd24778 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts @@ -7,7 +7,7 @@ import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-sta import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { generateRandomName } from "@/lib/names"; import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; -import { sendPartnerPostback } from "@/lib/postback/api/send-partner-postback"; +import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { getClickEvent, getLeadEvent, diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts index 7d55570096e..64bf076b016 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts @@ -5,7 +5,7 @@ import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; -import { sendPartnerPostback } from "@/lib/postback/api/send-partner-postback"; +import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { getLeadEvent, recordSale } from "@/lib/tinybird"; import { StripeMode } from "@/lib/types"; import { redis } from "@/lib/upstash"; diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts index 46d6f0a60b0..2d698f12f3f 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts @@ -3,7 +3,7 @@ import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { generateRandomName } from "@/lib/names"; -import { sendPartnerPostback } from "@/lib/postback/api/send-partner-postback"; +import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { getClickEvent, recordLead } from "@/lib/tinybird"; import { redis } from "@/lib/upstash"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; diff --git a/apps/web/app/(ee)/partners.dub.co/(onboarding)/layout.tsx b/apps/web/app/(ee)/partners.dub.co/(onboarding)/layout.tsx index a5f0e3cbe6b..7780c1f00b6 100644 --- a/apps/web/app/(ee)/partners.dub.co/(onboarding)/layout.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(onboarding)/layout.tsx @@ -65,7 +65,9 @@ export default function PartnerOnboardingLayout({
-
{children}
+
+ {children} +
diff --git a/apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/onboarding-form.tsx b/apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/onboarding-form.tsx index 254b59547d9..019a14185ca 100644 --- a/apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/onboarding-form.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/onboarding-form.tsx @@ -165,9 +165,7 @@ export function OnboardingForm({

Visible to programs and helps with approvals

-

- Max 2 MB -

+

Max 2 MB

diff --git a/apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/payouts/page.tsx b/apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/payouts/page.tsx index 5bc75fcc985..eb82c71c8d6 100644 --- a/apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/payouts/page.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/payouts/page.tsx @@ -9,7 +9,7 @@ import { Suspense } from "react"; export default function OnboardingVerificationPage() { return (
-

+

Connect payouts

diff --git a/apps/web/app/api/postbacks/callback/route.ts b/apps/web/app/api/postbacks/callback/route.ts index c250cdb4fda..951729454a8 100644 --- a/apps/web/app/api/postbacks/callback/route.ts +++ b/apps/web/app/api/postbacks/callback/route.ts @@ -1,5 +1,5 @@ import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; -import { recordPostbackEvent } from "@/lib/postback/api/record-postback-event"; +import { recordPostbackEvent } from "@/lib/postback/record-postback-event"; import { postbackCallbackBodySchema, postbackCallbackParamsSchema, diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx index 3c46c3746d2..e84a04fd70d 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx @@ -668,8 +668,14 @@ function InvitePreviewProgramDetails() {

- } label="Eligible Rewards" /> - } label="Eligible Bounties" /> + } + label="Eligible Rewards" + /> + } + label="Eligible Bounties" + />
{plan.name === "Enterprise" ? ( - - Custom - - ) : ( - <> - + +
+ {features.map(({ title, subtitle, features }, idx) => ( +
+ {title && ( +

+ {title} +

+ )} + {subtitle && ( +

{subtitle}

+ )} +
    + {features.map( + ({ id, text, tooltip, disabled }, idx) => { + const Icon = + id && PLAN_FEATURE_ICONS[id] + ? PLAN_FEATURE_ICONS[id] + : Check; - {plan.name === "Enterprise" ? ( -
    - - - Tailored pricing terms - -
    - ) : ( -
- - )} + ))} +
-

- {PRICING_PLAN_TAGLINES[product][plan.name]} -

- -
- - {plan.name === "Enterprise" ? ( - - -
-
- {features.map(({ title, subtitle, features }, idx) => ( -
- {title && ( -

- {title} -

- )} - {subtitle && ( -

{subtitle}

- )} -
diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index 07fc8acaf54..e500fd8354d 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -4,7 +4,7 @@ import { detectAndRecordFraudEvent } from "@/lib/api/fraud/detect-record-fraud-e import { includeTags } from "@/lib/api/links/include-tags"; import { generateRandomName } from "@/lib/names"; import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; -import { sendPartnerPostback } from "@/lib/postback/api/send-partner-postback"; +import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { isStored, storage } from "@/lib/storage"; import { getClickEvent, recordLead } from "@/lib/tinybird"; import { CustomerSource, WorkspaceProps } from "@/lib/types"; diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index 0b3495a7121..400f5d04e97 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -5,7 +5,7 @@ import { detectAndRecordFraudEvent } from "@/lib/api/fraud/detect-record-fraud-e import { includeTags } from "@/lib/api/links/include-tags"; import { generateRandomName } from "@/lib/names"; import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; -import { sendPartnerPostback } from "@/lib/postback/api/send-partner-postback"; +import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { isStored, storage } from "@/lib/storage"; import { getClickEvent, diff --git a/apps/web/lib/integrations/hubspot/track-lead.ts b/apps/web/lib/integrations/hubspot/track-lead.ts index ab619a2ab9c..99b14be0aca 100644 --- a/apps/web/lib/integrations/hubspot/track-lead.ts +++ b/apps/web/lib/integrations/hubspot/track-lead.ts @@ -1,7 +1,6 @@ import { trackLead } from "@/lib/api/conversions/track-lead"; import { TrackLeadResponse, WorkspaceProps } from "@/lib/types"; import { prisma } from "@dub/prisma"; -import { waitUntil } from "@vercel/functions"; import * as z from "zod/v4"; import { HubSpotAuthToken, HubSpotContact } from "../types"; import { HubSpotApi } from "./api"; @@ -30,14 +29,13 @@ export const trackHubSpotLeadEvent = async ({ const contactInfo = await hubSpotApi.getContact(objectId); if (!contactInfo) { - return; + return `No contact info found for contact ${objectId}.`; } const { properties } = contactInfo; if (!properties.dub_id) { - console.error(`[HubSpot] No dub_id found for contact ${objectId}.`); - return; + return `No dub_id found for contact ${objectId}.`; } const customerName = @@ -56,16 +54,14 @@ export const trackHubSpotLeadEvent = async ({ }); if (trackLeadResult) { - waitUntil( - _updateHubSpotContact({ - contact: contactInfo, - trackLeadResult, - hubSpotApi, - }), - ); + await updateHubSpotContact({ + contact: contactInfo, + trackLeadResult, + hubSpotApi, + }); } - return trackLeadResult; + return `Deferred lead tracked for contact ${objectId}.`; } // Track the final lead event @@ -78,7 +74,7 @@ export const trackHubSpotLeadEvent = async ({ const deal = await hubSpotApi.getDeal(objectId); if (!deal) { - return; + return `No deal found for deal ${objectId}.`; } const { properties, associations } = deal; @@ -87,7 +83,7 @@ export const trackHubSpotLeadEvent = async ({ const contact = associations?.contacts?.results?.[0]; if (!contact) { - return; + return `No contact found for deal ${objectId}.`; } // HubSpot doesn't return the contact properties in the deal associations, @@ -95,7 +91,7 @@ export const trackHubSpotLeadEvent = async ({ const contactInfo = await hubSpotApi.getContact(contact.id); if (!contactInfo) { - return; + return `No contact info found for contact ${contact.id}.`; } const customer = await prisma.customer.findFirst({ @@ -109,10 +105,7 @@ export const trackHubSpotLeadEvent = async ({ }); if (!customer) { - console.error( - `[HubSpot] No customer found for contact ID ${contactInfo.id} or email ${contactInfo.properties.email}.`, - ); - return; + return `No customer found for contact ID ${contactInfo.id} or email ${contactInfo.properties.email}.`; } const trackLeadResult = await trackLead({ @@ -127,16 +120,14 @@ export const trackHubSpotLeadEvent = async ({ }); if (trackLeadResult) { - waitUntil( - _updateHubSpotContact({ - contact: contactInfo, - trackLeadResult, - hubSpotApi, - }), - ); + await updateHubSpotContact({ + contact: contactInfo, + trackLeadResult, + hubSpotApi, + }); } - return trackLeadResult; + return `Lead tracked for deal ${objectId}.`; } // Track the final lead event @@ -147,30 +138,25 @@ export const trackHubSpotLeadEvent = async ({ settings.leadTriggerEvent === "lifecycleStageReached" ) { if (!settings.leadLifecycleStageId) { - console.error(`[HubSpot] leadLifecycleStageId is not set.`); - return; + return `leadLifecycleStageId is not set.`; } const contactInfo = await hubSpotApi.getContact(objectId); if (!contactInfo) { - return; + return `No contact info found for contact ${objectId}.`; } if ( contactInfo.properties.lifecyclestage !== settings.leadLifecycleStageId ) { - console.error( - `[HubSpot] Unknown contact lifecyclestage ${contactInfo.properties.lifecyclestage}. Expected ${settings.leadLifecycleStageId}.`, - ); - return; + return `Unknown contact lifecyclestage ${contactInfo.properties.lifecyclestage}. Expected ${settings.leadLifecycleStageId}.`; } const { properties } = contactInfo; if (!properties.dub_id) { - console.error(`[HubSpot] No dub_id found for contact ${objectId}.`); - return; + return `No dub_id found for contact ${objectId}.`; } const customer = await prisma.customer.findFirst({ @@ -184,10 +170,7 @@ export const trackHubSpotLeadEvent = async ({ }); if (!customer) { - console.error( - `[HubSpot] No customer found for contact ID ${contactInfo.id} or email ${contactInfo.properties.email}.`, - ); - return; + return `No customer found for contact ID ${contactInfo.id} or email ${contactInfo.properties.email}.`; } const trackLeadResult = await trackLead({ @@ -202,21 +185,21 @@ export const trackHubSpotLeadEvent = async ({ }); if (trackLeadResult) { - waitUntil( - _updateHubSpotContact({ - contact: contactInfo, - trackLeadResult, - hubSpotApi, - }), - ); + await updateHubSpotContact({ + contact: contactInfo, + trackLeadResult, + hubSpotApi, + }); } - return trackLeadResult; + return `Lead tracked for contact ${objectId}.`; } + + return `Unknown event: objectTypeId "${objectTypeId}" and subscriptionType "${subscriptionType}".`; }; // Update the HubSpot contact with `dub_link` and `dub_partner_email` -export const _updateHubSpotContact = async ({ +export const updateHubSpotContact = async ({ hubSpotApi, contact, trackLeadResult, @@ -254,6 +237,9 @@ export const _updateHubSpotContact = async ({ } if (Object.keys(properties).length === 0) { + console.log( + `[HubSpot] No properties to update for contact ${contact.id}. Skipping update.`, + ); return; } diff --git a/apps/web/lib/integrations/hubspot/track-sale.ts b/apps/web/lib/integrations/hubspot/track-sale.ts index 5e782db5e8f..32284505682 100644 --- a/apps/web/lib/integrations/hubspot/track-sale.ts +++ b/apps/web/lib/integrations/hubspot/track-sale.ts @@ -21,22 +21,15 @@ export const trackHubSpotSaleEvent = async ({ hubSpotSaleEventSchema.parse(payload); if (subscriptionType !== "object.propertyChange") { - console.log(`[HubSpot] Unknown subscriptionType ${subscriptionType}`); - return; + return `Unknown subscriptionType ${subscriptionType}.`; } if (propertyName !== "dealstage") { - console.log( - `[HubSpot] Unknown propertyName ${propertyName}. Expected dealstage.`, - ); - return; + return `Unknown propertyName ${propertyName}. Expected dealstage.`; } if (propertyValue !== settings.closedWonDealStageId) { - console.error( - `[HubSpot] Unknown propertyValue ${propertyValue}. Expected ${settings.closedWonDealStageId}.`, - ); - return; + return `Unknown propertyValue ${propertyValue}. Expected ${settings.closedWonDealStageId}.`; } const hubSpotApi = new HubSpotApi({ @@ -46,22 +39,20 @@ export const trackHubSpotSaleEvent = async ({ const deal = await hubSpotApi.getDeal(objectId); if (!deal) { - return; + return `No deal found for deal ${objectId}`; } const { id: dealId, properties, associations } = deal; if (!properties.amount) { - console.error(`[HubSpot] Amount is not set for deal ${dealId}`); - return; + return `Amount is not set for deal ${dealId}`; } // Find the contact associated with the deal const contact = associations?.contacts?.results?.[0]; if (!contact) { - console.error(`[HubSpot] No contact associated with deal ${dealId}`); - return; + return `No contact associated with deal ${dealId}`; } // HubSpot doesn't return the contact properties in the deal associations, @@ -69,7 +60,7 @@ export const trackHubSpotSaleEvent = async ({ const contactInfo = await hubSpotApi.getContact(contact.id); if (!contactInfo) { - return; + return `No contact info found for contact ${contact.id}`; } const customer = await prisma.customer.findFirst({ @@ -83,13 +74,10 @@ export const trackHubSpotSaleEvent = async ({ }); if (!customer) { - console.error( - `[HubSpot] No customer found for contact ID ${contactInfo.id} or email ${contactInfo.properties.email}.`, - ); - return; + return `No customer found for contact ID ${contactInfo.id} or email ${contactInfo.properties.email}.`; } - return await trackSale({ + await trackSale({ customerExternalId: customer.externalId!, amount: Number(properties.amount) * 100, eventName: `${properties.dealname} ${properties.dealstage}`, @@ -97,5 +85,8 @@ export const trackHubSpotSaleEvent = async ({ invoiceId: dealId, workspace, rawBody: deal, + metadata: {}, }); + + return `Sale tracked for deal ${dealId}.`; }; diff --git a/apps/web/lib/integrations/shopify/create-lead.ts b/apps/web/lib/integrations/shopify/create-lead.ts index 298322e944d..ef9a3da0872 100644 --- a/apps/web/lib/integrations/shopify/create-lead.ts +++ b/apps/web/lib/integrations/shopify/create-lead.ts @@ -3,7 +3,7 @@ import { DubApiError } from "@/lib/api/errors"; import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { generateRandomName } from "@/lib/names"; -import { sendPartnerPostback } from "@/lib/postback/api/send-partner-postback"; +import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { getClickEvent, recordLead } from "@/lib/tinybird"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { transformLeadEventData } from "@/lib/webhook/transform"; diff --git a/apps/web/lib/integrations/shopify/create-sale.ts b/apps/web/lib/integrations/shopify/create-sale.ts index f94ddecda75..18a5c14b09f 100644 --- a/apps/web/lib/integrations/shopify/create-sale.ts +++ b/apps/web/lib/integrations/shopify/create-sale.ts @@ -4,7 +4,7 @@ import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; -import { sendPartnerPostback } from "@/lib/postback/api/send-partner-postback"; +import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { recordSale } from "@/lib/tinybird"; import { LeadEventTB } from "@/lib/types"; import { redis } from "@/lib/upstash"; diff --git a/apps/web/lib/partners/create-partner-commission.ts b/apps/web/lib/partners/create-partner-commission.ts index b742dc181e5..5d191217d11 100644 --- a/apps/web/lib/partners/create-partner-commission.ts +++ b/apps/web/lib/partners/create-partner-commission.ts @@ -18,7 +18,7 @@ import { getProgramEnrollmentOrThrow } from "../api/programs/get-program-enrollm import { calculateSaleEarnings } from "../api/sales/calculate-sale-earnings"; import { executeWorkflows } from "../api/workflows/execute-workflows"; import { Session } from "../auth"; -import { sendPartnerPostback } from "../postback/api/send-partner-postback"; +import { sendPartnerPostback } from "../postback/send-partner-postback"; import { RewardContext, RewardProps } from "../types"; import { sendWorkspaceWebhook } from "../webhook/publish"; import { CommissionWebhookSchema } from "../zod/schemas/commissions"; diff --git a/apps/web/lib/postback/api/get-postback-events.ts b/apps/web/lib/postback/get-postback-events.ts similarity index 86% rename from apps/web/lib/postback/api/get-postback-events.ts rename to apps/web/lib/postback/get-postback-events.ts index 6340d49b8dc..ef21bb8f3d5 100644 --- a/apps/web/lib/postback/api/get-postback-events.ts +++ b/apps/web/lib/postback/get-postback-events.ts @@ -1,6 +1,6 @@ import { postbackEventOutputSchemaTB } from "@/lib/postback/schemas"; import * as z from "zod/v4"; -import { tb } from "../../tinybird/client"; +import { tb } from "../tinybird/client"; export const getPostbackEvents = tb.buildPipe({ pipe: "get_postback_events", diff --git a/apps/web/lib/api/postbacks/get-postback-or-throw.ts b/apps/web/lib/postback/get-postback-or-throw.ts similarity index 93% rename from apps/web/lib/api/postbacks/get-postback-or-throw.ts rename to apps/web/lib/postback/get-postback-or-throw.ts index 541d97eb062..ece63176741 100644 --- a/apps/web/lib/api/postbacks/get-postback-or-throw.ts +++ b/apps/web/lib/postback/get-postback-or-throw.ts @@ -1,5 +1,5 @@ import { prisma } from "@dub/prisma"; -import { DubApiError } from "../errors"; +import { DubApiError } from "../api/errors"; interface GetPostbackOrThrowParams { postbackId: string; diff --git a/apps/web/lib/postback/api/postback-adapter-custom.ts b/apps/web/lib/postback/postback-adapter-custom.ts similarity index 100% rename from apps/web/lib/postback/api/postback-adapter-custom.ts rename to apps/web/lib/postback/postback-adapter-custom.ts diff --git a/apps/web/lib/postback/api/postback-adapter-slack.ts b/apps/web/lib/postback/postback-adapter-slack.ts similarity index 98% rename from apps/web/lib/postback/api/postback-adapter-slack.ts rename to apps/web/lib/postback/postback-adapter-slack.ts index 9f04e2986f4..0805eb8bd78 100644 --- a/apps/web/lib/postback/api/postback-adapter-slack.ts +++ b/apps/web/lib/postback/postback-adapter-slack.ts @@ -1,12 +1,12 @@ import { PostbackTrigger } from "@/lib/types"; import { Postback } from "@dub/prisma/client"; import type { z } from "zod/v4"; +import { PostbackAdapter } from "./postback-adapters"; import { commissionEventPostbackSchema, leadEventPostbackSchema, saleEventPostbackSchema, -} from "../schemas"; -import { PostbackAdapter } from "./postback-adapters"; +} from "./schemas"; type LeadEventPostback = z.infer; type SaleEventPostback = z.infer; diff --git a/apps/web/lib/postback/api/postback-adapters.ts b/apps/web/lib/postback/postback-adapters.ts similarity index 100% rename from apps/web/lib/postback/api/postback-adapters.ts rename to apps/web/lib/postback/postback-adapters.ts diff --git a/apps/web/lib/postback/api/postback-event-enrichers.ts b/apps/web/lib/postback/postback-event-enrichers.ts similarity index 99% rename from apps/web/lib/postback/api/postback-event-enrichers.ts rename to apps/web/lib/postback/postback-event-enrichers.ts index 6e6b5db91de..e8ac15e7928 100644 --- a/apps/web/lib/postback/api/postback-event-enrichers.ts +++ b/apps/web/lib/postback/postback-event-enrichers.ts @@ -5,7 +5,7 @@ import { commissionEventPostbackSchema, leadEventPostbackSchema, saleEventPostbackSchema, -} from "../schemas"; +} from "./schemas"; interface PostbackEventEnricher { enrich(data: Record): Record; diff --git a/apps/web/lib/postback/api/postback-event-transformers.ts b/apps/web/lib/postback/postback-event-transformers.ts similarity index 100% rename from apps/web/lib/postback/api/postback-event-transformers.ts rename to apps/web/lib/postback/postback-event-transformers.ts diff --git a/apps/web/lib/postback/api/record-postback-event.ts b/apps/web/lib/postback/record-postback-event.ts similarity index 84% rename from apps/web/lib/postback/api/record-postback-event.ts rename to apps/web/lib/postback/record-postback-event.ts index 7dac12df7c4..9a8e19eedc3 100644 --- a/apps/web/lib/postback/api/record-postback-event.ts +++ b/apps/web/lib/postback/record-postback-event.ts @@ -1,5 +1,5 @@ import { postbackEventInputSchemaTB } from "@/lib/postback/schemas"; -import { tb } from "../../tinybird/client"; +import { tb } from "../tinybird/client"; export const recordPostbackEvent = tb.buildIngestEndpoint({ datasource: "dub_postback_events", diff --git a/apps/web/lib/postback/api/send-partner-postback.ts b/apps/web/lib/postback/send-partner-postback.ts similarity index 100% rename from apps/web/lib/postback/api/send-partner-postback.ts rename to apps/web/lib/postback/send-partner-postback.ts diff --git a/apps/web/lib/postback/api/utils.ts b/apps/web/lib/postback/utils.ts similarity index 100% rename from apps/web/lib/postback/api/utils.ts rename to apps/web/lib/postback/utils.ts diff --git a/apps/web/lib/stripe/stripe-v2-schemas.ts b/apps/web/lib/stripe/stripe-v2-schemas.ts index 4298d86e033..6ee889a7f9c 100644 --- a/apps/web/lib/stripe/stripe-v2-schemas.ts +++ b/apps/web/lib/stripe/stripe-v2-schemas.ts @@ -117,21 +117,19 @@ export const outboundPaymentSchema = z.object({ .object({ reason: z.string().default("unknown_failure"), }) - .optional(), + .nullish(), returned: z .object({ reason: z.string().default("unknown_failure"), }) - .optional(), + .nullish(), }) - .nullable() - .optional(), + .nullish(), trace_id: z .object({ value: z.string(), }) - .nullable() - .optional(), + .nullish(), }); export const listPayoutMethodsQuerySchema = z.object({ diff --git a/apps/web/tests/misc/site-visit-tracking-settings.test.ts b/apps/web/tests/misc/site-visit-tracking-settings.test.ts index 4b77be50a73..6c6cb72b142 100644 --- a/apps/web/tests/misc/site-visit-tracking-settings.test.ts +++ b/apps/web/tests/misc/site-visit-tracking-settings.test.ts @@ -25,11 +25,16 @@ describe("parseSiteVisitTrackingSettings", () => { }); it("keeps at most MAX_TRACKED_SITEMAPS_PER_WORKSPACE entries (deterministic slice)", () => { - const many = Array.from({ length: MAX_TRACKED_SITEMAPS_PER_WORKSPACE + 5 }, (_, i) => ({ - url: `https://example.com/s${i}.xml`, - })); + const many = Array.from( + { length: MAX_TRACKED_SITEMAPS_PER_WORKSPACE + 5 }, + (_, i) => ({ + url: `https://example.com/s${i}.xml`, + }), + ); const parsed = parseSiteVisitTrackingSettings({ trackedSitemaps: many }); - expect(parsed.trackedSitemaps).toHaveLength(MAX_TRACKED_SITEMAPS_PER_WORKSPACE); + expect(parsed.trackedSitemaps).toHaveLength( + MAX_TRACKED_SITEMAPS_PER_WORKSPACE, + ); expect(parsed.trackedSitemaps[0]?.url).toBe("https://example.com/s0.xml"); expect(parsed.trackedSitemaps.at(-1)?.url).toBe( `https://example.com/s${MAX_TRACKED_SITEMAPS_PER_WORKSPACE - 1}.xml`,