From 25119f1311d93d62303bac04079bba52f20a8f1a Mon Sep 17 00:00:00 2001 From: James Kurz Date: Thu, 22 Oct 2026 16:34:31 -0400 Subject: [PATCH 1/6] WIP: HubSpot handoff contracts, read adapter and storage --- packages/contracts/src/hubspot.test.ts | 450 ++++++ packages/contracts/src/hubspot.ts | 1207 +++++++++++++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/partners.ts | 40 +- packages/contracts/src/primitives.ts | 2 + packages/db/src/index.ts | 1 + .../db/src/repositories/account-documents.ts | 6 +- .../repositories/hubspot.integration.test.ts | 453 +++++++ packages/db/src/repositories/hubspot.test.ts | 134 ++ packages/db/src/repositories/hubspot.ts | 1052 ++++++++++++++ packages/db/src/repositories/partners.ts | 168 ++- packages/db/src/schema/hubspot.ts | 184 +++ packages/db/src/schema/index.ts | 6 +- packages/integrations/package.json | 3 +- .../integrations/src/hubspot/client.test.ts | 323 +++++ packages/integrations/src/hubspot/client.ts | 861 ++++++++++++ packages/integrations/src/hubspot/fake.ts | 883 ++++++++++++ packages/integrations/src/hubspot/index.ts | 2 + .../migrations/001483_hubspot_handoff.sql | 272 ++++ supabase/tests/1483_hubspot_handoff.test.sql | 96 ++ 20 files changed, 6088 insertions(+), 56 deletions(-) create mode 100644 packages/contracts/src/hubspot.test.ts create mode 100644 packages/contracts/src/hubspot.ts create mode 100644 packages/db/src/repositories/hubspot.integration.test.ts create mode 100644 packages/db/src/repositories/hubspot.test.ts create mode 100644 packages/db/src/repositories/hubspot.ts create mode 100644 packages/db/src/schema/hubspot.ts create mode 100644 packages/integrations/src/hubspot/client.test.ts create mode 100644 packages/integrations/src/hubspot/client.ts create mode 100644 packages/integrations/src/hubspot/fake.ts create mode 100644 packages/integrations/src/hubspot/index.ts create mode 100644 supabase/migrations/001483_hubspot_handoff.sql create mode 100644 supabase/tests/1483_hubspot_handoff.test.sql diff --git a/packages/contracts/src/hubspot.test.ts b/packages/contracts/src/hubspot.test.ts new file mode 100644 index 00000000..adc7c568 --- /dev/null +++ b/packages/contracts/src/hubspot.test.ts @@ -0,0 +1,450 @@ +import { describe, expect, it } from "vitest"; + +import { + HubSpotConnectionInputSchema, + HubSpotHandoffInputSchema, + HubSpotPipelineQuerySchema, + canonicalHubSpotJson, + classifyHubSpotDrift, + countHubSpotPlan, + defaultHubSpotDecision, + hubspotDealLinkState, + hubspotFieldAction, + hubspotImportStatus, + hubspotPipelineSearchParams, + hubspotPlanIsLinkOnly, + hubspotPropertyAllowed, + hubspotRecordUrl, + normalizeHubSpotWebsite, + parseHubSpotRecordUrl, + summarizeHubSpotPipeline, + type HubSpotHandoffPlan, + type HubSpotPipelineDeal, +} from "./hubspot"; +import { PartnerInputSchema } from "./partners"; + +const operationId = "019a44ae-0000-7000-8000-00000000b001"; +const partnerId = "019a44ae-0000-7000-8000-00000000b002"; +const digest = "a".repeat(64); + +describe("parseHubSpotRecordUrl", () => { + it("reads company and deal URLs in both HubSpot spellings", () => { + expect( + parseHubSpotRecordUrl( + "https://app.hubspot.com/contacts/4410/company/9001?tab=overview", + ), + ).toEqual({ portalId: "4410", objectType: "company", objectId: "9001" }); + expect( + parseHubSpotRecordUrl( + " https://app-eu1.hubspot.com/contacts/4410/record/0-3/77/ ", + ), + ).toEqual({ portalId: "4410", objectType: "deal", objectId: "77" }); + }); + + it("refuses anything that is not a HubSpot record page", () => { + for (const url of [ + "http://app.hubspot.com/contacts/1/company/2", + "https://app.hubspot.com.evil.example/contacts/1/company/2", + "https://evilapp.hubspot.com/contacts/1/company/2", + "https://app.hubspot.com/contacts/1/record/0-1/2", + "https://app.hubspot.com/contacts/abc/company/2", + "northstar", + ]) + expect(parseHubSpotRecordUrl(url)).toBeNull(); + }); + + it("builds record links on the portal's own host only", () => { + expect( + hubspotRecordUrl( + { portalId: "4410", uiDomain: "app-eu1.hubspot.com" }, + "deal", + "77", + ), + ).toBe("https://app-eu1.hubspot.com/contacts/4410/record/0-3/77"); + expect( + hubspotRecordUrl( + { portalId: "4410", uiDomain: "evil.example" }, + "company", + "9", + ), + ).toBe("https://app.hubspot.com/contacts/4410/record/0-2/9"); + }); +}); + +describe("normalizeHubSpotWebsite", () => { + it("prefers the website, falls back to the domain, and validates both", () => { + expect(normalizeHubSpotWebsite(null, "northstar.example")).toBe( + "https://northstar.example", + ); + expect( + normalizeHubSpotWebsite("http://www.northstar.example/about", null), + ).toBe("http://www.northstar.example/about"); + expect(normalizeHubSpotWebsite("not a url", "localhost")).toBe(""); + expect(normalizeHubSpotWebsite("javascript:alert(1)", null)).toBe(""); + expect( + normalizeHubSpotWebsite("https://user:pw@northstar.example", null), + ).toBe(""); + }); +}); + +describe("field decisions", () => { + it("classifies three-way drift since the last import", () => { + const base = { lastSource: "A", lastResult: "A" }; + expect(classifyHubSpotDrift({ ...base, commerce: "A", hubspot: "A" })).toBe( + "unchanged", + ); + expect(classifyHubSpotDrift({ ...base, commerce: "A", hubspot: "B" })).toBe( + "hubspot_changed", + ); + expect(classifyHubSpotDrift({ ...base, commerce: "C", hubspot: "A" })).toBe( + "commerce_changed", + ); + expect(classifyHubSpotDrift({ ...base, commerce: "C", hubspot: "B" })).toBe( + "both_changed", + ); + expect( + classifyHubSpotDrift({ + lastSource: undefined, + lastResult: undefined, + commerce: "", + hubspot: "B", + }), + ).toBe("never_imported"); + }); + + it("keeps Commerce values unless a change is clearly safe to propose", () => { + expect( + defaultHubSpotDecision({ + drift: "never_imported", + commerce: "", + hubspot: "B", + }), + ).toBe("use_hubspot"); + expect( + defaultHubSpotDecision({ + drift: "never_imported", + commerce: "A", + hubspot: "B", + }), + ).toBe("keep"); + expect( + defaultHubSpotDecision({ + drift: "hubspot_changed", + commerce: "A", + hubspot: "B", + }), + ).toBe("use_hubspot"); + expect( + defaultHubSpotDecision({ + drift: "commerce_changed", + commerce: "C", + hubspot: "A", + }), + ).toBe("keep"); + expect( + defaultHubSpotDecision({ + drift: "both_changed", + commerce: "C", + hubspot: "B", + }), + ).toBeNull(); + }); + + it("never lets an empty source value clear a Commerce value", () => { + expect(hubspotFieldAction("use_hubspot", "Acme", "")).toEqual({ + action: "keep", + result: "Acme", + }); + expect(hubspotFieldAction("use_hubspot", "", "Acme")).toEqual({ + action: "add", + result: "Acme", + }); + expect(hubspotFieldAction("use_hubspot", "A", "B")).toEqual({ + action: "replace", + result: "B", + }); + expect(hubspotFieldAction("clear", "A", "B")).toEqual({ + action: "clear", + result: "", + }); + expect(hubspotFieldAction("clear", "", "B")).toEqual({ + action: "keep", + result: "", + }); + }); +}); + +const handoff = (overrides: Record = {}) => ({ + operationId, + connection: { id: partnerId, version: 1 }, + source: { companyId: "9001", dealId: "77" }, + reviewed: { company: digest }, + destination: { kind: "new_partner", partnerId, name: "Northstar Systems" }, + ...overrides, +}); + +describe("HubSpotHandoffInputSchema", () => { + it("starts with nothing optional selected and Prospect as status", () => { + const parsed = HubSpotHandoffInputSchema.parse(handoff()); + expect(parsed).toMatchObject({ + contacts: [], + notes: [], + documents: [], + includeDeal: false, + endCustomerCompanyId: null, + primaryContact: null, + destination: { status: "prospect", ownerId: null, models: [] }, + }); + }); + + it("refuses a primary contact that was not selected", () => { + expect( + HubSpotHandoffInputSchema.safeParse( + handoff({ primaryContact: { kind: "hubspot", id: "5" } }), + ).success, + ).toBe(false); + }); + + it("needs the deal for an end customer, which cannot be the partner", () => { + expect( + HubSpotHandoffInputSchema.safeParse( + handoff({ endCustomerCompanyId: "9002" }), + ).success, + ).toBe(false); + expect( + HubSpotHandoffInputSchema.safeParse( + handoff({ includeDeal: true, endCustomerCompanyId: "9001" }), + ).success, + ).toBe(false); + expect( + HubSpotHandoffInputSchema.safeParse( + handoff({ includeDeal: true, endCustomerCompanyId: "9002" }), + ).success, + ).toBe(true); + }); + + it("refuses duplicate selections", () => { + expect( + HubSpotHandoffInputSchema.safeParse( + handoff({ contacts: [{ hubspotId: "5" }, { hubspotId: "5" }] }), + ).success, + ).toBe(false); + }); +}); + +describe("HubSpotConnectionInputSchema", () => { + it("requires the audience to be accepted explicitly", () => { + const base = { portalId: "4410", scope: {} }; + expect(HubSpotConnectionInputSchema.safeParse(base).success).toBe(false); + expect( + HubSpotConnectionInputSchema.parse({ ...base, audienceAccepted: true }) + .scope, + ).toEqual({ + company: [], + deal: [], + contact: [], + notes: false, + files: false, + }); + }); + + it("allows base properties and approved ones only", () => { + const scope = { + company: ["industry"], + deal: [], + contact: [], + notes: false, + files: false, + }; + expect(hubspotPropertyAllowed(scope, "company", "name")).toBe(true); + expect(hubspotPropertyAllowed(scope, "company", "industry")).toBe(true); + expect(hubspotPropertyAllowed(scope, "company", "annualrevenue")).toBe( + false, + ); + }); +}); + +const deal = ( + id: string, + stageId: string, + amount: string | null, + currency: string | null, +): HubSpotPipelineDeal => ({ + id, + name: `Deal ${id}`, + stageId, + amount, + currency, + closeDate: null, + ownerId: null, + ownerName: null, + updatedAt: null, + companies: [], +}); + +describe("summarizeHubSpotPipeline", () => { + const pipeline = { + stages: [ + { id: "s2", label: "Proposal", displayOrder: 2, closed: false }, + { id: "s1", label: "Discovery", displayOrder: 1, closed: false }, + ], + }; + + it("counts each deal once and keeps currencies apart", () => { + const summary = summarizeHubSpotPipeline(pipeline, [ + deal("1", "s1", "1000.10", "USD"), + deal("1", "s1", "1000.10", "USD"), + deal("2", "s1", "250", "EUR"), + deal("3", "s2", "0.2", "USD"), + deal("4", "s2", null, "USD"), + deal("5", "s2", "12", null), + ]); + expect(summary.deals).toBe(5); + expect(summary.missingAmounts).toBe(2); + expect(summary.totals).toEqual([ + { currency: "EUR", amount: "250", deals: 1 }, + { currency: "USD", amount: "1000.3", deals: 2 }, + ]); + expect(summary.stages.map((stage) => [stage.id, stage.deals])).toEqual([ + ["s1", 2], + ["s2", 3], + ]); + expect(summary.stages[1]?.missingAmounts).toBe(2); + }); + + it("reports how many of a deal's companies are linked", () => { + const company = (partner: boolean) => ({ + id: "1", + name: "A", + labels: [], + partner: partner ? { id: partnerId, name: "A" } : null, + }); + expect(hubspotDealLinkState({ companies: [] })).toBe("none"); + expect( + hubspotDealLinkState({ companies: [company(true), company(false)] }), + ).toBe("partly"); + expect(hubspotDealLinkState({ companies: [company(true)] })).toBe("linked"); + }); +}); + +describe("HubSpotPipelineQuerySchema", () => { + it("falls back to defaults for unknown values and round-trips", () => { + const query = HubSpotPipelineQuerySchema.parse({ + state: "weird", + linked: "partly", + owner: "x", + view: "board", + currency: "usd", + }); + expect(query).toMatchObject({ + state: "open", + linked: "partly", + owner: undefined, + view: "board", + currency: undefined, + }); + expect(hubspotPipelineSearchParams(query).toString()).toBe( + "linked=partly&view=board", + ); + }); +}); + +describe("plans and imports", () => { + const plan: HubSpotHandoffPlan = { + operationId, + destination: { + kind: "existing_partner", + partnerId, + name: "Northstar", + expectedVersion: 2, + }, + fields: [ + { + field: "website", + action: "keep", + current: "https://a.example", + hubspot: "https://a.example", + result: "https://a.example", + drift: "unchanged", + source: { property: "website", label: "Website", value: "a.example" }, + }, + ], + contacts: [], + primaryContactId: null, + deal: null, + notes: [], + references: [], + documents: [], + dismissedSuggestions: [], + }; + + it("knows when a handoff only records the link", () => { + expect(hubspotPlanIsLinkOnly(plan)).toBe(true); + expect(countHubSpotPlan(plan)).toMatchObject({ create: false, kept: 1 }); + }); + + it("is never complete while an item is open", () => { + expect(hubspotImportStatus([])).toBe("complete"); + expect( + hubspotImportStatus([{ status: "attached" }, { status: "failed" }]), + ).toBe("needs_attention"); + expect( + hubspotImportStatus([{ status: "attached" }, { status: "excluded" }]), + ).toBe("completed_with_exclusions"); + }); + + it("spells equal values once", () => { + expect(canonicalHubSpotJson({ b: 1, a: { d: 2, c: 3 } })).toBe( + '{"a":{"c":3,"d":2},"b":1}', + ); + }); +}); + +describe("partner contact identity", () => { + const contact = { + id: "019a44ae-0000-7000-8000-00000000c001", + name: "Ada", + source: { + provider: "hubspot", + portalId: "4410", + objectId: "5", + labels: ["Decision maker"], + }, + }; + + it("keeps a HubSpot identity through a partner save", () => { + const parsed = PartnerInputSchema.parse({ + id: partnerId, + name: "Northstar", + contacts: [contact], + }); + expect(parsed.contacts[0]).toMatchObject(contact); + }); + + it("allows one primary and no duplicate IDs", () => { + expect( + PartnerInputSchema.safeParse({ + id: partnerId, + name: "N", + contacts: [ + { ...contact, primary: true }, + { name: "B", primary: true }, + ], + }).success, + ).toBe(false); + expect( + PartnerInputSchema.safeParse({ + id: partnerId, + name: "N", + contacts: [contact, contact], + }).success, + ).toBe(false); + expect( + PartnerInputSchema.safeParse({ + id: partnerId, + name: "N", + contacts: [{ name: "B", source: contact.source }], + }).success, + ).toBe(false); + }); +}); diff --git a/packages/contracts/src/hubspot.ts b/packages/contracts/src/hubspot.ts new file mode 100644 index 00000000..d5aeed42 --- /dev/null +++ b/packages/contracts/src/hubspot.ts @@ -0,0 +1,1207 @@ +import { z } from "zod"; + +import { accountDocumentKinds } from "./account-documents"; +import { partnerModels, partnerStatuses } from "./partners"; + +/** + * Bringing a HubSpot company or deal into Commerce (docs/operations/ + * hubspot-handoff.md). HubSpot stays authoritative for CRM activity and + * pipeline; Commerce owns the partner record. Linking, viewing and importing + * are separate: a link records identity, a view reads current CRM data through + * the approved scope, and an import copies only what a person selected in a + * reviewed plan. + * + * Everything here is pure so the review, the commit and the receipt derive + * their wording from one plan. + */ + +/** HubSpot record IDs are decimal strings; nothing else reaches a request. */ +export const HubSpotIdSchema = z.string().regex(/^\d{1,20}$/, "hubspot_id"); +export type HubSpotId = z.infer; + +export const hubspotObjectTypes = ["company", "deal", "contact"] as const; +export type HubSpotObjectType = (typeof hubspotObjectTypes)[number]; + +/** + * Properties every connection reads, because the handoff and pipeline cannot + * work without them. The administrator sees this list at activation; any + * other property stays unreadable until approved. + */ +export const hubspotBaseProperties = { + company: [ + "name", + "domain", + "website", + "country", + "hubspot_owner_id", + "hs_lastmodifieddate", + ], + deal: [ + "dealname", + "amount", + "deal_currency_code", + "closedate", + "pipeline", + "dealstage", + "hubspot_owner_id", + "hs_lastmodifieddate", + ], + contact: ["firstname", "lastname", "email", "jobtitle", "lastmodifieddate"], +} as const satisfies Record; + +/** Upper bounds that keep every read and import bounded. */ +export const hubspotLimits = { + searchResults: 25, + associations: 100, + contactsPerHandoff: 20, + notesPerHandoff: 20, + documentsPerHandoff: 10, + referencePropertiesPerHandoff: 50, + approvedPropertiesPerObject: 100, + noteBodyCharacters: 4000, + /** Deals read to build pipeline totals; beyond this coverage is partial. */ + pipelineDeals: 2000, + pipelinePageSize: 50, + /** The existing attachment limit (`contractDocumentMaxBytes`). */ + documentBytes: 25 * 1024 * 1024, +} as const; + +/** Display freshness for live CRM reads, not an import schedule. */ +export const hubspotFreshness = { + targetMs: 5 * 60_000, + staleMs: 15 * 60_000, +} as const; + +// --------------------------------------------------------------------------- +// The live source, as the read adapter normalizes it. +// --------------------------------------------------------------------------- + +export interface HubSpotPortal { + portalId: string; + /** Host of the HubSpot app for this portal, such as `app-eu1.hubspot.com`. */ + uiDomain: string; + timeZone: string | null; + companyCurrency: string | null; +} + +export interface HubSpotPropertyDefinition { + objectType: HubSpotObjectType; + name: string; + label: string; + type: string; + fieldType: string; + options: readonly { label: string; value: string }[]; + /** HubSpot marks it sensitive or highly sensitive. Excluded by default. */ + sensitive: boolean; + /** Defined by HubSpot rather than by the portal. */ + hubspotDefined: boolean; + calculated: boolean; +} + +export interface HubSpotOwner { + id: string; + name: string; + email: string | null; + archived: boolean; +} + +/** An association with its labels resolved to names, never type IDs. */ +export interface HubSpotAssociation { + id: string; + labels: readonly string[]; +} + +export interface HubSpotCompany { + id: string; + name: string | null; + domain: string | null; + website: string | null; + country: string | null; + ownerId: string | null; + updatedAt: string | null; + archived: boolean; + /** Approved additional properties only; `null` is empty in HubSpot. */ + properties: Readonly>; +} + +export interface HubSpotDeal { + id: string; + name: string | null; + pipelineId: string | null; + stageId: string | null; + /** The amount exactly as HubSpot stores it; `null` is not set, never 0. */ + amount: string | null; + currency: string | null; + /** HubSpot stores close dates as instants. */ + closeDate: string | null; + ownerId: string | null; + updatedAt: string | null; + archived: boolean; + properties: Readonly>; +} + +export interface HubSpotContact { + id: string; + firstName: string | null; + lastName: string | null; + email: string | null; + jobTitle: string | null; + updatedAt: string | null; +} + +export interface HubSpotNote { + id: string; + /** Plain text: markup removed, line breaks kept, bounded. */ + body: string; + ownerId: string | null; + timestamp: string | null; + attachmentIds: readonly string[]; +} + +export type HubSpotFileAvailability = + "available" | "unsupported_type" | "too_large" | "unreadable"; + +export interface HubSpotFile { + id: string; + name: string; + extension: string | null; + size: number | null; + updatedAt: string | null; + availability: HubSpotFileAvailability; + /** The note it came from, so the review can say where it was attached. */ + noteId: string; +} + +export interface HubSpotPipelineStage { + id: string; + label: string; + displayOrder: number; + closed: boolean; +} + +export interface HubSpotPipeline { + id: string; + label: string; + displayOrder: number; + stages: readonly HubSpotPipelineStage[]; +} + +/** One search result for the picker. */ +export interface HubSpotSearchResult { + objectType: "company" | "deal"; + id: string; + name: string; + /** A company's domain, or a deal's stage label. */ + detail: string | null; + ownerName: string | null; + updatedAt: string | null; +} + +// --------------------------------------------------------------------------- +// Record URLs. A URL is a hint that names a record, never authority to read it. +// --------------------------------------------------------------------------- + +const hubspotHost = /^app(?:-[a-z0-9]{2,10})?\.hubspot\.com$/; +const recordPath = + /^\/contacts\/(\d{1,20})\/(?:(company|deal)|record\/(0-2|0-3))\/(\d{1,20})(?:\/|$)/; + +export interface HubSpotRecordReference { + portalId: string; + objectType: "company" | "deal"; + objectId: string; +} + +/** The record a pasted HubSpot URL names, or `null` when it names none. */ +export function parseHubSpotRecordUrl( + raw: string, +): HubSpotRecordReference | null { + let url: URL; + try { + url = new URL(raw.trim()); + } catch { + return null; + } + if (url.protocol !== "https:" || !hubspotHost.test(url.hostname)) return null; + const match = recordPath.exec(url.pathname); + if (!match) return null; + const [, portalId, named, typeId, objectId] = match; + if (!portalId || !objectId) return null; + const objectType = + named === "company" || typeId === "0-2" ? "company" : "deal"; + return { portalId, objectType, objectId }; +} + +/** The HubSpot page for a record, on the portal's own app host. */ +export function hubspotRecordUrl( + portal: Pick, + objectType: HubSpotObjectType, + objectId: string, +): string { + const host = hubspotHost.test(portal.uiDomain) + ? portal.uiDomain + : "app.hubspot.com"; + const typeId = + objectType === "company" ? "0-2" : objectType === "deal" ? "0-3" : "0-1"; + return `https://${host}/contacts/${encodeURIComponent(portal.portalId)}/record/${typeId}/${encodeURIComponent(objectId)}`; +} + +// --------------------------------------------------------------------------- +// Bounded transformations: trimming and validated URL normalization only. +// --------------------------------------------------------------------------- + +/** Trimmed, control characters removed, or `""` for empty. */ +export function cleanHubSpotText(value: string | null, max: number): string { + if (value === null) return ""; + const clean = [...value] + .filter((c) => { + const code = c.charCodeAt(0); + return code >= 32 && code !== 127; + }) + .join("") + .replace(/\s+/g, " ") + .trim(); + return clean.length > max ? clean.slice(0, max).trimEnd() : clean; +} + +/** + * A website Commerce can store: the HubSpot website, or the domain as an + * https URL. Anything that does not parse as an http(s) URL with a dotted + * host is no proposal at all. + */ +export function normalizeHubSpotWebsite( + website: string | null, + domain: string | null, +): string { + for (const candidate of [website, domain]) { + const text = cleanHubSpotText(candidate, 500); + if (!text) continue; + const withScheme = /^https?:\/\//i.test(text) ? text : `https://${text}`; + try { + const url = new URL(withScheme); + if (!["http:", "https:"].includes(url.protocol)) continue; + if (!url.hostname.includes(".") || url.username || url.password) continue; + const normalized = url.toString(); + // A bare host keeps no trailing slash: "https://northstar.example". + const result = + url.pathname === "/" && !url.search && !url.hash + ? normalized.replace(/\/$/, "") + : normalized; + if (result.length <= 500) return result; + } catch { + continue; + } + } + return ""; +} + +export function hubspotContactName(contact: HubSpotContact): string { + const name = cleanHubSpotText( + [contact.firstName, contact.lastName].filter(Boolean).join(" "), + 200, + ); + return name || cleanHubSpotText(contact.email, 200); +} + +/** An email Commerce accepts, lower-cased, or `""`. */ +export function hubspotContactEmail(contact: HubSpotContact): string { + const email = cleanHubSpotText(contact.email, 320).toLowerCase(); + return z.email().safeParse(email).success ? email : ""; +} + +// --------------------------------------------------------------------------- +// The administrator's connection: one portal, an approved audience and scope. +// --------------------------------------------------------------------------- + +export const hubspotConnectionStatuses = ["active", "disconnected"] as const; +export type HubSpotConnectionStatus = + (typeof hubspotConnectionStatuses)[number]; + +export interface HubSpotScope { + /** Approved additional properties per object, beyond the base set. */ + company: readonly string[]; + deal: readonly string[]; + contact: readonly string[]; + notes: boolean; + files: boolean; +} + +export interface HubSpotOwnerMapping { + hubspotOwnerId: string; + staffId: string; + staffName: string; +} + +export interface HubSpotConnectionRecord { + id: string; + portalId: string; + uiDomain: string; + status: HubSpotConnectionStatus; + audience: string; + approvedById: string; + approvedByName: string; + approvedAt: string; + scope: HubSpotScope; + defaultPipelineId: string | null; + ownerMappings: readonly HubSpotOwnerMapping[]; + updatedAt: string; + version: number; +} + +/** The audience every connection names: who may read approved CRM data. */ +export const hubspotAudience = "staff_sales_read" as const; + +const propertyName = z.string().regex(/^[a-z0-9_]{1,100}$/, "hubspot_property"); +const propertyList = z + .array(propertyName) + .max(hubspotLimits.approvedPropertiesPerObject) + .default([]) + .transform((names) => [...new Set(names)].sort()); + +/** + * Activation, or a change to an active connection. Activating requires the + * administrator to accept that the approved audience may differ from each + * person's HubSpot visibility; the shared connection does not reproduce + * HubSpot user permissions. + */ +export const HubSpotConnectionInputSchema = z + .object({ + expectedVersion: z.int().min(1).optional(), + portalId: z.string().regex(/^\d{1,20}$/), + audienceAccepted: z.literal(true), + scope: z + .object({ + company: propertyList, + deal: propertyList, + contact: propertyList, + notes: z.boolean().default(false), + files: z.boolean().default(false), + }) + .strict(), + defaultPipelineId: z + .union([ + z.string().regex(/^[A-Za-z0-9_-]{1,100}$/), + z.literal(""), + z.null(), + ]) + .transform((value) => value || null) + .default(null), + ownerMappings: z + .array( + z + .object({ hubspotOwnerId: HubSpotIdSchema, staffId: z.guid() }) + .strict(), + ) + .max(500) + .default([]) + .refine( + (rows) => + new Set(rows.map((row) => row.hubspotOwnerId)).size === rows.length, + "duplicate_owner", + ), + }) + .strict(); +export type HubSpotConnectionInput = z.infer< + typeof HubSpotConnectionInputSchema +>; + +/** Whether a property may be read: base, or approved and not sensitive. */ +export function hubspotPropertyAllowed( + scope: HubSpotScope, + objectType: HubSpotObjectType, + name: string, +): boolean { + return ( + (hubspotBaseProperties[objectType] as readonly string[]).includes(name) || + scope[objectType].includes(name) + ); +} + +// --------------------------------------------------------------------------- +// The handoff: what a person reviewed and chose. +// --------------------------------------------------------------------------- + +/** Partner fields a handoff may set. Commission, margin, exclusivity, terms, + * contract and approval state are never imported, even with More control. */ +export const hubspotPartnerFields = ["name", "website", "region"] as const; +export type HubSpotPartnerField = (typeof hubspotPartnerFields)[number]; + +/** Where each partner field's HubSpot value comes from. */ +export const hubspotPartnerFieldSources: Readonly< + Record +> = { + name: { property: "name", label: "Company name" }, + website: { property: "website", label: "Website URL" }, + region: { property: "country", label: "Country/Region" }, +}; + +export const hubspotFieldDecisions = ["keep", "use_hubspot", "clear"] as const; +export type HubSpotFieldDecision = (typeof hubspotFieldDecisions)[number]; + +const line = (max: number) => + z + .string() + .trim() + .min(1) + .max(max) + .refine( + (value) => + [...value].every((c) => { + const code = c.charCodeAt(0); + return code >= 32 && code !== 127; + }), + "control_character", + ); +const optionalLine = (max: number) => + z + .string() + .trim() + .max(max) + .refine( + (value) => + [...value].every((c) => { + const code = c.charCodeAt(0); + return code >= 32 && code !== 127; + }), + "control_character", + ) + .default(""); +const optionalId = z + .union([z.guid(), z.literal(""), z.null()]) + .transform((value) => (value ? value : null)) + .default(null); + +/** A digest of one reviewed source item, so a commit can tell it changed. */ +const DigestSchema = z.string().regex(/^[a-f0-9]{64}$/); + +export const HubSpotHandoffInputSchema = z + .object({ + /** One handoff, however many times it is sent. */ + operationId: z.uuid(), + /** The connection the review was read through; any change invalidates it. */ + connection: z.object({ id: z.guid(), version: z.int().min(1) }).strict(), + source: z + .object({ + companyId: HubSpotIdSchema, + dealId: HubSpotIdSchema.nullable().default(null), + }) + .strict(), + /** Digests of the source as reviewed, by item key ("company", "deal", + * "contact:", "note:", "file:"). */ + reviewed: z.record(z.string().max(40), DigestSchema), + destination: z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("new_partner"), + partnerId: z.uuid(), + /** Proposed from HubSpot and editable; the source stays recorded. */ + name: line(200), + website: optionalLine(500), + region: optionalLine(500), + status: z.enum(partnerStatuses).default("prospect"), + ownerId: optionalId, + models: z + .array(z.enum(partnerModels)) + .max(partnerModels.length) + .default([]) + .transform((models) => + partnerModels.filter((model) => models.includes(model)), + ), + /** Similar partners the person inspected and set aside, with why. */ + dismissedSuggestions: z + .array( + z.object({ partnerId: z.guid(), reason: line(300) }).strict(), + ) + .max(10) + .default([]), + }) + .strict(), + z + .object({ + kind: z.literal("existing_partner"), + partnerId: z.guid(), + expectedVersion: z.int().min(1), + fields: z + .object({ + name: z.enum(hubspotFieldDecisions).default("keep"), + website: z.enum(hubspotFieldDecisions).default("keep"), + region: z.enum(hubspotFieldDecisions).default("keep"), + }) + .strict() + .default({ name: "keep", website: "keep", region: "keep" }), + }) + .strict(), + ]), + contacts: z + .array( + z + .object({ + hubspotId: HubSpotIdSchema, + /** An existing Commerce contact the person confirmed is this + * person; the HubSpot identity is added and its values kept. */ + matchContactId: optionalId, + }) + .strict(), + ) + .max(hubspotLimits.contactsPerHandoff) + .default([]), + /** A HubSpot contact ID, a Commerce contact ID, or none. Never inferred. */ + primaryContact: z + .union([ + z.object({ kind: z.literal("hubspot"), id: HubSpotIdSchema }).strict(), + z.object({ kind: z.literal("commerce"), id: z.guid() }).strict(), + z.null(), + ]) + .default(null), + includeDeal: z.boolean().default(false), + /** The deal's other company the person chose as end customer. */ + endCustomerCompanyId: HubSpotIdSchema.nullable().default(null), + notes: z + .array(HubSpotIdSchema) + .max(hubspotLimits.notesPerHandoff) + .default([]), + referenceProperties: z + .array( + z + .object({ + objectType: z.enum(["company", "deal"]), + name: propertyName, + }) + .strict(), + ) + .max(hubspotLimits.referencePropertiesPerHandoff) + .default([]), + documents: z + .array( + z + .object({ + fileId: HubSpotIdSchema, + title: line(200), + kind: z.enum(accountDocumentKinds), + }) + .strict(), + ) + .max(hubspotLimits.documentsPerHandoff) + .default([]), + }) + .strict() + .superRefine((input, ctx) => { + const unique = (values: readonly string[], path: string) => { + if (new Set(values).size !== values.length) + ctx.addIssue({ code: "custom", path: [path], message: "duplicate" }); + }; + unique( + input.contacts.map((contact) => contact.hubspotId), + "contacts", + ); + unique(input.notes, "notes"); + unique( + input.documents.map((document) => document.fileId), + "documents", + ); + unique( + input.referenceProperties.map((p) => `${p.objectType}:${p.name}`), + "referenceProperties", + ); + if ( + input.primaryContact?.kind === "hubspot" && + !input.contacts.some((c) => c.hubspotId === input.primaryContact?.id) + ) + ctx.addIssue({ + code: "custom", + path: ["primaryContact"], + message: "primary_not_selected", + }); + if (input.endCustomerCompanyId !== null) { + if (!input.includeDeal || input.source.dealId === null) + ctx.addIssue({ + code: "custom", + path: ["endCustomerCompanyId"], + message: "end_customer_needs_deal", + }); + if (input.endCustomerCompanyId === input.source.companyId) + ctx.addIssue({ + code: "custom", + path: ["endCustomerCompanyId"], + message: "end_customer_is_partner", + }); + } + }); +export type HubSpotHandoffInput = z.infer; + +// --------------------------------------------------------------------------- +// What the handoff copies and keeps for reference: the source snapshot. +// --------------------------------------------------------------------------- + +/** A source value as it was at import, with the property it came from. */ +export interface HubSpotSourceValue { + objectType: "company" | "deal"; + property: string; + label: string; + type: string; + value: string | null; +} + +export interface HubSpotCompanyReference { + id: string; + name: string; + labels: readonly string[]; +} + +/** The originating deal, kept as context. It is an opportunity, not a + * Commerce registration, and nothing derives protection or revenue from it. */ +export interface HubSpotDealReference { + id: string; + url: string; + name: string; + pipeline: { id: string | null; label: string | null }; + stage: { id: string | null; label: string | null; closed: boolean | null }; + amount: string | null; + currency: string | null; + closeDate: string | null; + owner: { id: string; name: string | null } | null; + associatedCompanies: readonly HubSpotCompanyReference[]; + /** The company that became this Commerce partner. */ + chosenCompanyId: string; + /** The company the person chose as end customer, if any. */ + endCustomer: HubSpotCompanyReference | null; + updatedAt: string | null; +} + +export interface HubSpotNoteReference { + id: string; + body: string; + /** `null` when HubSpot did not say who wrote it. */ + authorName: string | null; + timestamp: string | null; +} + +export interface HubSpotSourceSnapshot { + portalId: string; + uiDomain: string; + retrievedAt: string; + company: { + id: string; + url: string; + name: string; + updatedAt: string | null; + owner: { id: string; name: string | null } | null; + }; + /** Values imported into fields, and values kept only for reference. */ + values: readonly HubSpotSourceValue[]; + deal: HubSpotDealReference | null; + notes: readonly HubSpotNoteReference[]; +} + +// --------------------------------------------------------------------------- +// The plan: every addition, replacement, clear, reference and omission. +// --------------------------------------------------------------------------- + +export type HubSpotFieldDrift = + | "never_imported" + | "unchanged" + | "hubspot_changed" + | "commerce_changed" + | "both_changed"; + +/** + * How a field moved since the last import. `lastSource` is HubSpot's value + * then and `lastResult` what Commerce stored; `undefined` means the field was + * never imported. + */ +export function classifyHubSpotDrift(input: { + lastSource: string | undefined; + lastResult: string | undefined; + commerce: string; + hubspot: string; +}): HubSpotFieldDrift { + if (input.lastSource === undefined || input.lastResult === undefined) + return "never_imported"; + const hubspotChanged = input.hubspot !== input.lastSource; + const commerceChanged = input.commerce !== input.lastResult; + if (hubspotChanged && commerceChanged) return "both_changed"; + if (hubspotChanged) return "hubspot_changed"; + if (commerceChanged) return "commerce_changed"; + return "unchanged"; +} + +/** + * The decision the review starts with. Commerce values survive unless a + * person selects a change: an empty Commerce field is filled, a change made + * only in HubSpot is proposed, and a field both sides changed has no default + * (`null`), so it cannot be committed until someone chooses. + */ +export function defaultHubSpotDecision(input: { + drift: HubSpotFieldDrift; + commerce: string; + hubspot: string; +}): HubSpotFieldDecision | null { + if (input.hubspot === "" || input.hubspot === input.commerce) return "keep"; + if (input.commerce === "") return "use_hubspot"; + if (input.drift === "hubspot_changed") return "use_hubspot"; + if (input.drift === "both_changed") return null; + return "keep"; +} + +export type HubSpotFieldAction = "add" | "replace" | "clear" | "keep"; + +export interface HubSpotFieldChange { + field: HubSpotPartnerField; + action: HubSpotFieldAction; + /** The Commerce value before. */ + current: string; + /** HubSpot's value (after bounded normalization), `""` when empty. */ + hubspot: string; + /** What Commerce stores after the import. */ + result: string; + drift: HubSpotFieldDrift; + source: { property: string; label: string; value: string | null }; +} + +/** A partner contact with its HubSpot identity. */ +export interface HubSpotPlannedContact { + id: string; + hubspotId: string; + name: string; + email: string; + role: string; + labels: readonly string[]; + /** Linked to an existing Commerce contact whose values are kept. */ + matchedExisting: boolean; +} + +export interface HubSpotPlannedDocument { + fileId: string; + noteId: string; + fileName: string; + title: string; + kind: (typeof accountDocumentKinds)[number]; + size: number | null; + updatedAt: string | null; +} + +export interface HubSpotHandoffPlan { + operationId: string; + destination: + | { + kind: "new_partner"; + partnerId: string; + name: string; + status: (typeof partnerStatuses)[number]; + ownerId: string | null; + ownerName: string | null; + models: readonly (typeof partnerModels)[number][]; + } + | { + kind: "existing_partner"; + partnerId: string; + name: string; + expectedVersion: number; + }; + fields: readonly HubSpotFieldChange[]; + contacts: readonly HubSpotPlannedContact[]; + /** The contact made primary, by local contact ID; `null` changes nothing. */ + primaryContactId: string | null; + deal: HubSpotDealReference | null; + notes: readonly HubSpotNoteReference[]; + /** Approved values retained as labeled context, not copied into fields. */ + references: readonly HubSpotSourceValue[]; + documents: readonly HubSpotPlannedDocument[]; + dismissedSuggestions: readonly { partnerId: string; reason: string }[]; +} + +export interface HubSpotPlanCounts { + create: boolean; + added: number; + replaced: number; + cleared: number; + kept: number; + contactsAdded: number; + contactsLinked: number; + deals: number; + notes: number; + references: number; + documents: number; +} + +/** The counts the footer and the receipt both state, from one plan. */ +export function countHubSpotPlan(plan: HubSpotHandoffPlan): HubSpotPlanCounts { + const fields = (action: HubSpotFieldAction) => + plan.fields.filter((field) => field.action === action).length; + return { + create: plan.destination.kind === "new_partner", + added: fields("add"), + replaced: fields("replace"), + cleared: fields("clear"), + kept: fields("keep"), + contactsAdded: plan.contacts.filter((c) => !c.matchedExisting).length, + contactsLinked: plan.contacts.filter((c) => c.matchedExisting).length, + deals: plan.deal ? 1 : 0, + notes: plan.notes.length, + references: plan.references.length, + documents: plan.documents.length, + }; +} + +/** Whether the plan changes anything beyond recording the link. */ +export function hubspotPlanIsLinkOnly(plan: HubSpotHandoffPlan): boolean { + const counts = countHubSpotPlan(plan); + return ( + !counts.create && + counts.added + counts.replaced + counts.cleared === 0 && + counts.contactsAdded + counts.contactsLinked === 0 && + counts.deals + counts.notes + counts.references + counts.documents === 0 && + plan.primaryContactId === null + ); +} + +/** The field action a decision produces against the current value. */ +export function hubspotFieldAction( + decision: HubSpotFieldDecision, + current: string, + hubspot: string, +): { action: HubSpotFieldAction; result: string } { + if (decision === "clear") + return current === "" + ? { action: "keep", result: current } + : { action: "clear", result: "" }; + // An empty or inaccessible source value never clears a Commerce value. + if (decision === "keep" || hubspot === "" || hubspot === current) + return { action: "keep", result: current }; + return { action: current === "" ? "add" : "replace", result: hubspot }; +} + +// --------------------------------------------------------------------------- +// Import records, items and links, as pages read them. +// --------------------------------------------------------------------------- + +export const hubspotImportStatuses = [ + "complete", + "needs_attention", + "completed_with_exclusions", +] as const; +export type HubSpotImportStatus = (typeof hubspotImportStatuses)[number]; + +export const hubspotItemStatuses = [ + "pending", + "attached", + "failed", + "duplicate", + "reused", + "excluded", +] as const; +export type HubSpotItemStatus = (typeof hubspotItemStatuses)[number]; +/** Items that leave the handoff unfinished until someone acts. */ +export const unresolvedHubSpotItemStatuses = [ + "pending", + "failed", + "duplicate", +] as const satisfies readonly HubSpotItemStatus[]; + +export interface HubSpotImportItem { + id: string; + importId: string; + fileId: string; + noteId: string; + fileName: string; + title: string; + documentKind: (typeof accountDocumentKinds)[number]; + status: HubSpotItemStatus; + /** Why the last attempt did not attach it. */ + errorCode: string | null; + attachmentId: string | null; + /** The identical PDF already on the record, for a duplicate. */ + existingAttachment: { id: string; title: string } | null; + excludedReason: string | null; + attempts: number; + updatedAt: string; +} + +export interface HubSpotImportRecord { + id: string; + partnerId: string; + portalId: string; + companyId: string; + dealId: string | null; + status: HubSpotImportStatus; + /** The partner was created by this import rather than linked. */ + createdPartner: boolean; + plan: HubSpotHandoffPlan; + snapshot: HubSpotSourceSnapshot; + items: readonly HubSpotImportItem[]; + createdById: string; + createdByName: string; + createdAt: string; + completedAt: string | null; +} + +export interface HubSpotLinkRecord { + id: string; + portalId: string; + companyId: string; + partnerId: string; + partnerName: string; + companyName: string; + createdByName: string; + createdAt: string; +} + +/** The import's state from its items: never complete while one is open. */ +export function hubspotImportStatus( + items: readonly Pick[], +): HubSpotImportStatus { + if ( + items.some((item) => + (unresolvedHubSpotItemStatuses as readonly string[]).includes( + item.status, + ), + ) + ) + return "needs_attention"; + return items.some((item) => item.status === "excluded") + ? "completed_with_exclusions" + : "complete"; +} + +export const hubspotErrorCodes = [ + "HUBSPOT_NOT_CONFIGURED", + "HUBSPOT_NOT_CONNECTED", + "HUBSPOT_PORTAL_MISMATCH", + "HUBSPOT_CONNECTION_CHANGED", + "HUBSPOT_UNAVAILABLE", + "HUBSPOT_RATE_LIMITED", + "HUBSPOT_RECORD_NOT_FOUND", + "HUBSPOT_RECORD_ARCHIVED", + "HUBSPOT_SOURCE_CHANGED", + "HUBSPOT_NOT_ASSOCIATED", + "HUBSPOT_SCOPE_DENIED", + "HUBSPOT_ALREADY_LINKED", + "HUBSPOT_PARTNER_LINKED_ELSEWHERE", + "HUBSPOT_OPERATION_CONFLICT", + "HUBSPOT_CONTACT_LIMIT", + "HUBSPOT_CONTACT_NOT_FOUND", + "HUBSPOT_DOCUMENT_UNAVAILABLE", + "HUBSPOT_DOCUMENT_CHANGED", + "HUBSPOT_DOCUMENT_INVALID", + "HUBSPOT_ITEM_NOT_FOUND", + "HUBSPOT_ITEM_RESOLVED", + "HUBSPOT_IMPORT_NOT_FOUND", + "HUBSPOT_DECISION_REQUIRED", + "HUBSPOT_CONNECTION_VERSION_CONFLICT", +] as const; +export type HubSpotErrorCode = (typeof hubspotErrorCodes)[number]; + +// --------------------------------------------------------------------------- +// Pipeline: totals from the full authorized filtered result. +// --------------------------------------------------------------------------- + +export const hubspotLinkFilters = ["any", "linked", "partly", "none"] as const; +export type HubSpotLinkFilter = (typeof hubspotLinkFilters)[number]; +export const hubspotDealStates = ["open", "closed", "all"] as const; +export type HubSpotDealState = (typeof hubspotDealStates)[number]; + +const firstValue = (value: unknown): unknown => + Array.isArray(value) + ? (value as unknown[])[0] + : value === "" + ? undefined + : value; +const optionalParam = (schema: T) => + z.preprocess(firstValue, schema.optional().catch(undefined)); + +/** Pipeline filters from the URL. Unknown values fall back to no filter. */ +export const HubSpotPipelineQuerySchema = z.object({ + pipeline: optionalParam(z.string().regex(/^[A-Za-z0-9_-]{1,100}$/)), + stage: optionalParam(z.string().regex(/^[A-Za-z0-9_-]{1,100}$/)), + owner: optionalParam(HubSpotIdSchema), + state: z.preprocess( + firstValue, + z.enum(hubspotDealStates).catch("open").default("open"), + ), + linked: z.preprocess( + firstValue, + z.enum(hubspotLinkFilters).catch("any").default("any"), + ), + closeFrom: optionalParam(z.iso.date()), + closeTo: optionalParam(z.iso.date()), + currency: optionalParam(z.string().regex(/^[A-Z]{3}$/)), + q: z.preprocess(firstValue, z.string().trim().max(100).catch("")).default(""), + view: z.preprocess( + firstValue, + z.enum(["table", "board"]).catch("table").default("table"), + ), + deal: optionalParam(HubSpotIdSchema), +}); +export type HubSpotPipelineQuery = z.infer; + +export function hubspotPipelineSearchParams( + query: Partial, +): URLSearchParams { + const params = new URLSearchParams(); + const set = (key: string, value: string | undefined) => { + if (value) params.set(key, value); + }; + set("pipeline", query.pipeline); + set("stage", query.stage); + set("owner", query.owner); + if (query.state && query.state !== "open") params.set("state", query.state); + if (query.linked && query.linked !== "any") + params.set("linked", query.linked); + set("closeFrom", query.closeFrom); + set("closeTo", query.closeTo); + set("currency", query.currency); + set("q", query.q); + if (query.view === "board") params.set("view", "board"); + set("deal", query.deal); + return params; +} + +/** A pipeline deal with its companies and their Commerce links. */ +export interface HubSpotPipelineDeal { + id: string; + name: string; + stageId: string | null; + amount: string | null; + currency: string | null; + closeDate: string | null; + ownerId: string | null; + ownerName: string | null; + updatedAt: string | null; + companies: readonly { + id: string; + name: string; + labels: readonly string[]; + partner: { id: string; name: string } | null; + }[]; +} + +export interface HubSpotCurrencyTotal { + currency: string; + /** Decimal string, summed exactly. */ + amount: string; + deals: number; +} + +export interface HubSpotStageSummary { + id: string; + label: string; + closed: boolean; + deals: number; + totals: readonly HubSpotCurrencyTotal[]; + missingAmounts: number; +} + +export interface HubSpotPipelineSummary { + deals: number; + totals: readonly HubSpotCurrencyTotal[]; + missingAmounts: number; + stages: readonly HubSpotStageSummary[]; +} + +const decimalPattern = /^-?\d{1,15}(?:\.\d{1,6})?$/; +const scale = 1_000_000n; + +function toScaled(value: string): bigint | null { + const text = value.trim(); + if (!decimalPattern.test(text)) return null; + const negative = text.startsWith("-"); + const [whole = "0", fraction = ""] = text.replace("-", "").split("."); + const scaled = + BigInt(whole) * scale + BigInt(fraction.padEnd(6, "0").slice(0, 6)); + return negative ? -scaled : scaled; +} + +function fromScaled(value: bigint): string { + const negative = value < 0n; + const absolute = negative ? -value : value; + const whole = absolute / scale; + const fraction = (absolute % scale).toString().padStart(6, "0"); + const trimmed = fraction.replace(/0+$/, ""); + return `${negative ? "-" : ""}${whole}${trimmed ? `.${trimmed}` : ""}`; +} + +/** Whether a deal's amount can be summed: a plain decimal with a currency. */ +export function hubspotAmountKnown( + deal: Pick, +): boolean { + return ( + deal.amount !== null && + deal.currency !== null && + toScaled(deal.amount) !== null + ); +} + +function sumByCurrency( + deals: readonly Pick[], +): HubSpotCurrencyTotal[] { + const totals = new Map(); + for (const deal of deals) { + if (!hubspotAmountKnown(deal)) continue; + const currency = deal.currency ?? ""; + const entry = totals.get(currency) ?? { amount: 0n, deals: 0 }; + entry.amount += toScaled(deal.amount ?? "0") ?? 0n; + entry.deals += 1; + totals.set(currency, entry); + } + return [...totals.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([currency, entry]) => ({ + currency, + amount: fromScaled(entry.amount), + deals: entry.deals, + })); +} + +/** + * Stage counts and totals over every deal given, counting each deal once by + * ID. Amounts are grouped by currency and never combined across currencies; + * a deal without a usable amount or currency is counted as missing, not 0. + */ +export function summarizeHubSpotPipeline( + pipeline: Pick, + deals: readonly HubSpotPipelineDeal[], +): HubSpotPipelineSummary { + const unique = [...new Map(deals.map((deal) => [deal.id, deal])).values()]; + const missing = (rows: readonly HubSpotPipelineDeal[]) => + rows.filter((deal) => !hubspotAmountKnown(deal)).length; + const stages = [...pipeline.stages] + .sort((a, b) => a.displayOrder - b.displayOrder) + .map((stage) => { + const rows = unique.filter((deal) => deal.stageId === stage.id); + return { + id: stage.id, + label: stage.label, + closed: stage.closed, + deals: rows.length, + totals: sumByCurrency(rows), + missingAmounts: missing(rows), + }; + }); + return { + deals: unique.length, + totals: sumByCurrency(unique), + missingAmounts: missing(unique), + stages, + }; +} + +/** How many of a deal's companies are linked in Commerce. */ +export function hubspotDealLinkState( + deal: Pick, +): Exclude { + const linked = deal.companies.filter((company) => company.partner).length; + if (linked === 0) return "none"; + return linked === deal.companies.length ? "linked" : "partly"; +} + +/** JSON with object keys sorted, so equal values have one spelling. */ +export function canonicalHubSpotJson(value: unknown): string { + return JSON.stringify(value, (_key, item: unknown) => + item && typeof item === "object" && !Array.isArray(item) + ? Object.fromEntries( + Object.entries(item as Record).sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0, + ), + ) + : item, + ); +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index fe3940aa..0dffb75c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -18,3 +18,4 @@ export * from "./staff-notifications"; export * from "./access-tokens"; export * from "./account-documents"; +export * from "./hubspot"; diff --git a/packages/contracts/src/partners.ts b/packages/contracts/src/partners.ts index 5c3f8012..9545a97d 100644 --- a/packages/contracts/src/partners.ts +++ b/packages/contracts/src/partners.ts @@ -161,16 +161,40 @@ const optionalId = z .transform((value) => (value ? value : null)) .default(null); +/** + * Where a contact came from. A contact brought in from HubSpot keeps its + * portal and HubSpot ID and the association labels it had, so a later import + * matches it by identity rather than by name or email. + */ +export const PartnerContactSourceSchema = z + .object({ + provider: z.literal("hubspot"), + portalId: z.string().regex(/^\d{1,20}$/), + objectId: z.string().regex(/^\d{1,20}$/), + labels: z.array(z.string().trim().max(120)).max(20).default([]), + }) + .strict(); +export type PartnerContactSource = z.infer; + export const PartnerContactSchema = z .object({ + /** Stable across edits; absent on contacts typed before it existed. */ + id: z.guid().optional(), name: line(200), email: z .union([z.email().max(320), z.literal("")]) .transform((value) => value.trim().toLowerCase()) .default(""), role: optionalLine(200), + /** Chosen by a person, never inferred from order or a CRM label. */ + primary: z.boolean().optional(), + source: PartnerContactSourceSchema.optional(), }) - .strict(); + .strict() + .refine( + (contact) => !contact.source || contact.id !== undefined, + "source_without_id", + ); export type PartnerContact = z.infer; /** One free-form term: what was agreed, in the words of the deal. */ @@ -255,7 +279,17 @@ export const PartnerInputSchema = z contacts: z .array(PartnerContactSchema) .max(partnerContactLimit) - .default([]), + .default([]) + .refine( + (contacts) => contacts.filter((contact) => contact.primary).length <= 1, + "one_primary", + ) + .refine((contacts) => { + const ids = contacts.flatMap((contact) => + contact.id ? [contact.id] : [], + ); + return new Set(ids).size === ids.length; + }, "duplicate_contact"), nextStep: optionalLine(500), nextStepDue: optionalDate, notes: text(8000), @@ -442,6 +476,8 @@ export interface PartnerActivity { /** Field names changed, with the values before and after. */ changes: Record; occurredAt: string; + /** The change came from a reviewed HubSpot import. */ + source?: "hubspot"; } /** A partner whose next step is due on or before a day, for the home page diff --git a/packages/contracts/src/primitives.ts b/packages/contracts/src/primitives.ts index 4be8e9ba..13ebb46a 100644 --- a/packages/contracts/src/primitives.ts +++ b/packages/contracts/src/primitives.ts @@ -65,6 +65,8 @@ export const entityNames = [ "partner_deal", "staff_notification_settings", "access_token", + "hubspot_connection", + "hubspot_import", ] as const; export type EntityName = (typeof entityNames)[number]; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 144f7430..154cc815 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -24,3 +24,4 @@ export * from "./repositories/staff-notifications"; export * from "./repositories/access-tokens"; export * from "./repositories/account-documents"; +export * from "./repositories/hubspot"; diff --git a/packages/db/src/repositories/account-documents.ts b/packages/db/src/repositories/account-documents.ts index 9eb7d759..0d81ec6c 100644 --- a/packages/db/src/repositories/account-documents.ts +++ b/packages/db/src/repositories/account-documents.ts @@ -79,6 +79,10 @@ export class AccountDocumentRepository { rawInput: unknown, upload: { fileName: string; bytes: Uint8Array }, actor: Actor & { kind: "user" }, + /** Where the PDF came from when not uploaded by hand, such as a HubSpot + * file brought in by a reviewed import. */ + provenance: + Record | "external_upload" = "external_upload", ) { const target = AccountDocumentTargetSchema.parse(rawTarget); const input = AccountDocumentInputSchema.parse(rawInput); @@ -154,7 +158,7 @@ export class AccountDocumentRepository { signedOn: row.signedOn, fileName, sha256: row.sha256, - provenance: "external_upload", + provenance, }, }); return { row, reused: false }; diff --git a/packages/db/src/repositories/hubspot.integration.test.ts b/packages/db/src/repositories/hubspot.integration.test.ts new file mode 100644 index 00000000..eeb04b90 --- /dev/null +++ b/packages/db/src/repositories/hubspot.integration.test.ts @@ -0,0 +1,453 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import type { + HubSpotHandoffPlan, + HubSpotSourceSnapshot, +} from "@clockwork/contracts"; + +import { createRuntimeDatabase } from "../client"; +import { AccountDocumentRepository } from "./account-documents"; +import { + ContractDocumentStores, + PostgresContractDocumentStore, +} from "./contract-documents"; +import { HubSpotRepository } from "./hubspot"; +import { PartnerRepository } from "./partners"; + +const { client, db } = createRuntimeDatabase({ + url: + process.env.DIRECT_DATABASE_URL ?? + "postgresql://postgres:postgres@127.0.0.1:54322/postgres?sslmode=disable", + role: "clockwork_service", + ssl: false, +}); + +const repo = new HubSpotRepository(db); +const partners = new PartnerRepository(db); +const documents = new AccountDocumentRepository( + db, + new ContractDocumentStores(new PostgresContractDocumentStore(db)), +); +// Seeded staff with the sales workspace. +const staffId = "20000000-0000-4000-8000-000000000001"; +const seller = { kind: "user" as const, id: staffId, display: "Iris Operator" }; +const otherSeller = { + kind: "user" as const, + id: randomUUID(), + display: "Second Seller", +}; +// A portal of our own so reruns and other suites never share links. +const portalId = String(Date.now()).slice(-9); +const tag = randomUUID().slice(0, 8); +const digest = (n: number) => String(n).repeat(64).slice(0, 64); +let connection = { id: "", version: 0 }; +const createdPartners: string[] = []; + +beforeAll(async () => { + // Only one connection may be active; this suite brings its own. + await client`update commerce_hubspot_connections + set status = 'disconnected', disconnected_at = now(), version = version + 1 + where status = 'active'`; + const saved = await repo.saveConnection( + { + portalId, + audienceAccepted: true, + scope: { company: ["industry"], notes: true, files: true }, + ownerMappings: [{ hubspotOwnerId: "101", staffId }], + }, + { portalId, uiDomain: "app.hubspot.com" }, + seller, + ); + connection = { id: saved.id, version: saved.version }; +}); + +afterAll(async () => { + await client`delete from commerce_hubspot_import_items where import_id in ( + select id from commerce_hubspot_imports where portal_id = ${portalId})`; + await client`delete from commerce_hubspot_imports where portal_id = ${portalId}`; + await client`delete from commerce_external_links where portal_id = ${portalId}`; + await client`delete from commerce_hubspot_connections where portal_id = ${portalId}`; + await client.end(); +}); + +const snapshot = (companyId: string, name: string): HubSpotSourceSnapshot => ({ + portalId, + uiDomain: "app.hubspot.com", + retrievedAt: new Date().toISOString(), + company: { + id: companyId, + url: `https://app.hubspot.com/contacts/${portalId}/record/0-2/${companyId}`, + name, + updatedAt: null, + owner: { id: "101", name: "Priya Raman" }, + }, + values: [ + { + objectType: "company", + property: "name", + label: "Company name", + type: "string", + value: name, + }, + ], + deal: null, + notes: [], +}); + +const newPlan = ( + companyName: string, + overrides: Partial = {}, +): HubSpotHandoffPlan => { + const partnerId = randomUUID(); + createdPartners.push(partnerId); + return { + operationId: randomUUID(), + destination: { + kind: "new_partner", + partnerId, + name: companyName, + status: "prospect", + ownerId: staffId, + ownerName: "Iris Operator", + models: ["resale"], + }, + fields: [ + { + field: "name", + action: "add", + current: "", + hubspot: companyName, + result: companyName, + drift: "never_imported", + source: { property: "name", label: "Company name", value: companyName }, + }, + { + field: "website", + action: "add", + current: "", + hubspot: "https://northstar.example", + result: "https://northstar.example", + drift: "never_imported", + source: { + property: "domain", + label: "Domain", + value: "northstar.example", + }, + }, + ], + contacts: [ + { + id: randomUUID(), + hubspotId: "5001", + name: "Ada Okafor", + email: "ada@northstar.example", + role: "VP Partnerships", + labels: ["Decision maker"], + matchedExisting: false, + }, + ], + primaryContactId: null, + deal: null, + notes: [], + references: [], + documents: [], + dismissedSuggestions: [], + ...overrides, + }; +}; + +describe("HubSpot handoff", () => { + it("creates a partner, its link and the import in one commit", async () => { + const companyId = "910001"; + const name = `Northstar ${tag}`; + const plan = newPlan(name, { + documents: [ + { + fileId: "8001", + noteId: "6001", + fileName: "Order form.pdf", + title: "Order form", + kind: "agreement", + size: 1000, + updatedAt: null, + }, + ], + }); + const result = await repo.commit( + { + plan, + snapshot: snapshot(companyId, name), + requestDigest: digest(1), + connection, + companyId, + dealId: null, + }, + seller, + ); + expect(result).toMatchObject({ kind: "committed", createdPartner: true }); + const { partner, activity } = await partners.get( + plan.destination.partnerId, + "2026-10-22", + ); + expect(partner).toMatchObject({ + name, + status: "prospect", + ownerId: staffId, + models: ["resale"], + }); + expect(partner.contacts[0]?.source).toMatchObject({ objectId: "5001" }); + expect(activity.map((entry) => entry.eventType)).toEqual( + expect.arrayContaining(["partner.created", "hubspot.imported"]), + ); + expect( + activity.find((e) => e.eventType === "partner.created")?.source, + ).toBe("hubspot"); + const record = await repo.import(partner.id, plan.operationId); + expect(record.status).toBe("needs_attention"); + expect(record.items).toHaveLength(1); + await expect(repo.linkForPartner(partner.id)).resolves.toMatchObject({ + companyId, + portalId, + }); + }); + + it("returns the first result for a retry and refuses a changed one", async () => { + const companyId = "910002"; + const name = `Retry ${tag}`; + const plan = newPlan(name); + const input = { + plan, + snapshot: snapshot(companyId, name), + requestDigest: digest(2), + connection, + companyId, + dealId: null, + }; + const first = await repo.commit(input, seller); + const again = await repo.commit(input, seller); + expect(again).toMatchObject({ + kind: "replayed", + importId: plan.operationId, + partnerId: first.partnerId, + }); + await expect( + repo.commit({ ...input, requestDigest: digest(3) }, seller), + ).rejects.toThrow("HUBSPOT_OPERATION_CONFLICT"); + }); + + it("creates exactly one partner when two people bring in one company at once", async () => { + const companyId = "910003"; + const name = `Concurrent ${tag}`; + const results = await Promise.all( + [seller, otherSeller].map((actor, index) => + repo.commit( + { + plan: newPlan(name), + snapshot: snapshot(companyId, name), + requestDigest: digest(4 + index), + connection, + companyId, + dealId: null, + }, + actor, + ), + ), + ); + const committed = results.filter((r) => r.kind === "committed"); + const lost = results.filter((r) => r.kind === "already_linked"); + expect(committed).toHaveLength(1); + expect(lost).toHaveLength(1); + expect(lost[0]?.partnerId).toBe(committed[0]?.partnerId); + const [row] = await client<{ count: number }[]>` + select count(*)::int as count from commerce_partners where name = ${name}`; + expect(row?.count).toBe(1); + }); + + it("updates an existing partner only against the version reviewed", async () => { + const partner = await partners.save( + { + id: randomUUID(), + name: `Existing ${tag}`, + website: "https://kept.example", + status: "signed", + contacts: [{ name: "Manual Person" }], + }, + seller, + ); + createdPartners.push(partner.id); + const companyId = "910004"; + const plan: HubSpotHandoffPlan = { + ...newPlan(partner.name), + destination: { + kind: "existing_partner", + partnerId: partner.id, + name: partner.name, + expectedVersion: partner.version, + }, + fields: [ + { + field: "region", + action: "add", + current: "", + hubspot: "United States", + result: "United States", + drift: "never_imported", + source: { + property: "country", + label: "Country", + value: "United States", + }, + }, + { + field: "website", + action: "keep", + current: "https://kept.example", + hubspot: "https://northstar.example", + result: "https://kept.example", + drift: "never_imported", + source: { property: "website", label: "Website", value: null }, + }, + ], + }; + const base = { + snapshot: snapshot(companyId, partner.name), + connection, + companyId, + dealId: null, + }; + await expect( + repo.commit( + { + ...base, + plan: { + ...plan, + operationId: randomUUID(), + destination: { ...plan.destination, expectedVersion: 99 }, + } as HubSpotHandoffPlan, + requestDigest: digest(6), + }, + seller, + ), + ).rejects.toThrow("PARTNER_VERSION_CONFLICT"); + await repo.commit({ ...base, plan, requestDigest: digest(7) }, seller); + const { partner: after } = await partners.get(partner.id, "2026-10-22"); + expect(after).toMatchObject({ + region: "United States", + website: "https://kept.example", + status: "signed", + }); + expect(after.contacts.map((c) => c.name)).toEqual([ + "Manual Person", + "Ada Okafor", + ]); + await expect(repo.fieldHistory(partner.id)).resolves.toMatchObject({ + region: { lastSource: "United States", lastResult: "United States" }, + website: { lastResult: "https://kept.example" }, + }); + }); + + it("resolves documents one at a time and never reports open work complete", async () => { + const companyId = "910005"; + const name = `Documents ${tag}`; + const plan = newPlan(name, { + documents: ["8101", "8102"].map((fileId) => ({ + fileId, + noteId: "6001", + fileName: `${fileId}.pdf`, + title: `File ${fileId}`, + kind: "other" as const, + size: 100, + updatedAt: null, + })), + }); + await repo.commit( + { + plan, + snapshot: snapshot(companyId, name), + requestDigest: digest(8), + connection, + companyId, + dealId: null, + }, + seller, + ); + const partnerId = plan.destination.partnerId; + const record = await repo.import(partnerId, plan.operationId); + const [first, second] = record.items; + if (!first || !second) throw new Error("items missing"); + const attachment = await documents.add( + { type: "partner", id: partnerId }, + { id: first.id, title: first.title, kind: "other" }, + { + fileName: first.fileName, + bytes: Buffer.from(`%PDF-1.7\n${tag}\n%%EOF`), + }, + seller, + { provider: "hubspot", fileId: first.fileId, importId: plan.operationId }, + ); + const failed = await repo.recordItem( + partnerId, + plan.operationId, + second.id, + { status: "failed", errorCode: "HUBSPOT_DOCUMENT_UNAVAILABLE" }, + seller, + ); + expect(failed.status).toBe("needs_attention"); + const attached = await repo.recordItem( + partnerId, + plan.operationId, + first.id, + { status: "attached", attachmentId: attachment.id }, + seller, + ); + expect(attached.status).toBe("needs_attention"); + await expect( + repo.recordItem( + partnerId, + plan.operationId, + first.id, + { status: "failed", errorCode: "HUBSPOT_UNAVAILABLE" }, + seller, + ), + ).rejects.toThrow("HUBSPOT_ITEM_RESOLVED"); + const amended = await repo.excludeItem( + partnerId, + plan.operationId, + second.id, + "Not the signed copy", + seller, + ); + expect(amended.status).toBe("completed_with_exclusions"); + expect(amended.completedAt).not.toBeNull(); + await expect( + repo.attachmentByHash(partnerId, "0".repeat(64)), + ).resolves.toBeNull(); + }); + + it("stops committing reviews read through a connection that changed", async () => { + const companyId = "910006"; + const name = `Stale ${tag}`; + await expect( + repo.commit( + { + plan: newPlan(name), + snapshot: snapshot(companyId, name), + requestDigest: digest(9), + connection: { ...connection, version: connection.version + 5 }, + companyId, + dealId: null, + }, + seller, + ), + ).rejects.toThrow("HUBSPOT_CONNECTION_CHANGED"); + }); + + it("finds similar partners by name or website without merging", async () => { + const similar = await repo.similarPartners({ + name: `Northstar ${tag}`, + host: "www.northstar.example", + }); + expect(similar.some((p) => p.reason === "name")).toBe(true); + }); +}); diff --git a/packages/db/src/repositories/hubspot.test.ts b/packages/db/src/repositories/hubspot.test.ts new file mode 100644 index 00000000..eaeb20c0 --- /dev/null +++ b/packages/db/src/repositories/hubspot.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; + +import { mergeContacts } from "./hubspot"; + +const portalId = "4410"; +const planned = (overrides: Record = {}) => ({ + id: "019a44ae-0000-7000-8000-00000000d001", + hubspotId: "5001", + name: "Ada Okafor", + email: "ada@northstar.example", + role: "VP Partnerships", + labels: ["Decision maker"], + matchedExisting: false, + ...overrides, +}); + +describe("mergeContacts", () => { + it("appends new people with their HubSpot identity and keeps the rest", () => { + const merged = mergeContacts( + [{ name: "Manual", email: "", role: "" }], + { contacts: [planned()], primaryContactId: null }, + portalId, + ); + expect(merged).toEqual([ + { name: "Manual", email: "", role: "" }, + { + id: "019a44ae-0000-7000-8000-00000000d001", + name: "Ada Okafor", + email: "ada@northstar.example", + role: "VP Partnerships", + source: { + provider: "hubspot", + portalId, + objectId: "5001", + labels: ["Decision maker"], + }, + }, + ]); + }); + + it("links a confirmed match and keeps the Commerce values", () => { + const existing = { + id: "019a44ae-0000-7000-8000-00000000d002", + name: "Ada O.", + email: "ada@northstar.example", + role: "Partnerships", + }; + const merged = mergeContacts( + [existing], + { + contacts: [planned({ id: existing.id, matchedExisting: true })], + primaryContactId: existing.id, + }, + portalId, + ); + expect(merged).toHaveLength(1); + expect(merged[0]).toMatchObject({ + ...existing, + primary: true, + source: { objectId: "5001" }, + }); + }); + + it("never adds the same HubSpot person twice, even after a manual edit", () => { + const edited = { + id: "019a44ae-0000-7000-8000-00000000d003", + name: "Ada (edited by hand)", + email: "", + role: "", + source: { + provider: "hubspot" as const, + portalId, + objectId: "5001", + labels: [], + }, + }; + const merged = mergeContacts( + [edited], + { contacts: [planned()], primaryContactId: null }, + portalId, + ); + expect(merged).toHaveLength(1); + expect(merged[0]?.name).toBe("Ada (edited by hand)"); + }); + + it("moves primary only when a person chose one", () => { + const first = { + id: "019a44ae-0000-7000-8000-00000000d004", + name: "A", + email: "", + role: "", + primary: true, + }; + expect( + mergeContacts( + [first], + { contacts: [planned()], primaryContactId: null }, + portalId, + )[0]?.primary, + ).toBe(true); + const moved = mergeContacts( + [first], + { contacts: [planned()], primaryContactId: planned().id }, + portalId, + ); + expect(moved[0]?.primary).toBeUndefined(); + expect(moved[1]?.primary).toBe(true); + }); + + it("refuses a match that is gone and a list over the limit", () => { + expect(() => + mergeContacts( + [], + { + contacts: [planned({ matchedExisting: true })], + primaryContactId: null, + }, + portalId, + ), + ).toThrow("HUBSPOT_CONTACT_NOT_FOUND"); + const many = Array.from({ length: 20 }, (_, index) => ({ + name: `P${index}`, + email: "", + role: "", + })); + expect(() => + mergeContacts( + many, + { contacts: [planned()], primaryContactId: null }, + portalId, + ), + ).toThrow("HUBSPOT_CONTACT_LIMIT"); + }); +}); diff --git a/packages/db/src/repositories/hubspot.ts b/packages/db/src/repositories/hubspot.ts new file mode 100644 index 00000000..44cbe2ff --- /dev/null +++ b/packages/db/src/repositories/hubspot.ts @@ -0,0 +1,1052 @@ +import { randomUUID } from "node:crypto"; +import { and, desc, eq, inArray, sql } from "drizzle-orm"; + +import { + HubSpotConnectionInputSchema, + PartnerInputSchema, + hubspotAudience, + hubspotImportStatus, + partnerContactLimit, + type Actor, + type HubSpotConnectionRecord, + type HubSpotHandoffPlan, + type HubSpotImportItem, + type HubSpotImportRecord, + type HubSpotLinkRecord, + type HubSpotPartnerField, + type HubSpotSourceSnapshot, + type PartnerContact, + type PartnerInput, + type PartnerRecord, +} from "@clockwork/contracts"; + +import type { RuntimeDatabase, RuntimeTransaction } from "../client"; +import { accountDocuments } from "../schema/account-documents"; +import { + commerceExternalLinks, + commerceHubSpotConnections, + commerceHubSpotImportItems, + commerceHubSpotImports, +} from "../schema/hubspot"; +import { commercePartners } from "../schema/partners"; +import { withInternalTransaction } from "../transaction"; +import { appendAuditAndOutbox } from "./audit-outbox"; +import { + insertPartnerRow, + partnerInputOf, + partnerRecordOf, + resolveOwner, + staffOwners, + updatePartnerRow, +} from "./partners"; + +type UserActor = Actor & { kind: "user" }; +type ConnectionRow = typeof commerceHubSpotConnections.$inferSelect; +type ImportRow = typeof commerceHubSpotImports.$inferSelect; +type ItemRow = typeof commerceHubSpotImportItems.$inferSelect; + +const connectionView = (row: ConnectionRow): HubSpotConnectionRecord => ({ + id: row.id, + portalId: row.portalId, + uiDomain: row.uiDomain, + status: row.status, + audience: row.audience, + approvedById: row.approvedById, + approvedByName: row.approvedByName, + approvedAt: row.approvedAt.toISOString(), + scope: row.scope, + defaultPipelineId: row.defaultPipelineId, + ownerMappings: row.ownerMappings, + updatedAt: row.updatedAt.toISOString(), + version: row.version, +}); + +const itemView = ( + row: ItemRow, + existing: ReadonlyMap, +): HubSpotImportItem => ({ + id: row.id, + importId: row.importId, + fileId: row.fileId, + noteId: row.noteId, + fileName: row.fileName, + title: row.title, + documentKind: row.documentKind, + status: row.status, + errorCode: row.errorCode, + attachmentId: row.attachmentId, + existingAttachment: row.existingAttachmentId + ? { + id: row.existingAttachmentId, + title: existing.get(row.existingAttachmentId) ?? "", + } + : null, + excludedReason: row.excludedReason, + attempts: row.attempts, + updatedAt: row.updatedAt.toISOString(), +}); + +const importView = ( + row: ImportRow, + items: readonly HubSpotImportItem[], +): HubSpotImportRecord => ({ + id: row.id, + partnerId: row.partnerId, + portalId: row.portalId, + companyId: row.companyId, + dealId: row.dealId, + status: row.status, + createdPartner: row.createdPartner, + plan: row.plan, + snapshot: row.snapshot, + items, + createdById: row.createdById, + createdByName: row.createdByName, + createdAt: row.createdAt.toISOString(), + completedAt: row.completedAt?.toISOString() ?? null, +}); + +/** + * Codes the database or this repository refuses with; Drizzle wraps driver + * errors, so the code is looked for on the error and its causes. + */ +function refusal(error: unknown): string | undefined { + for ( + let current: unknown = error, depth = 0; + current instanceof Error && depth < 4; + current = current.cause, depth += 1 + ) { + const match = /\b((?:HUBSPOT|PARTNER)_[A-Z_]+)\b/.exec(current.message); + if (match) return match[1]; + // A unique violation on the link is a concurrent handoff that lost. + if (/commerce_external_links_(source|partner)/.test(current.message)) + return "HUBSPOT_ALREADY_LINKED"; + } + return undefined; +} + +export interface HubSpotCommitInput { + plan: HubSpotHandoffPlan; + snapshot: HubSpotSourceSnapshot; + /** sha256 of the request as sent, so a retry is recognised. */ + requestDigest: string; + connection: { id: string; version: number }; + companyId: string; + dealId: string | null; +} + +export type HubSpotCommitResult = + | { + kind: "committed" | "replayed"; + importId: string; + partnerId: string; + createdPartner: boolean; + } + /** Someone else's handoff already linked this company: open theirs. */ + | { kind: "already_linked"; partnerId: string; partnerName: string }; + +export type HubSpotItemOutcome = + | { status: "attached"; attachmentId: string } + | { status: "failed"; errorCode: string } + | { status: "duplicate"; existingAttachmentId: string }; + +/** What each partner field was at the last import that included it. */ +export type HubSpotFieldHistory = Partial< + Record +>; + +/** + * The HubSpot connection, company links and reviewed imports (001483). The + * web layer checks the reader's permission first: `staff:manage` for the + * connection, `sales:read` to read links and imports, `contract:write` to + * import. Every change writes an audit event in the same transaction. + */ +export class HubSpotRepository { + constructor(private readonly db: RuntimeDatabase) {} + + private async tx(fn: (tx: RuntimeTransaction) => Promise) { + try { + return await withInternalTransaction(this.db, randomUUID(), fn); + } catch (error) { + const code = refusal(error); + if (code) throw new Error(code, { cause: error }); + throw error; + } + } + + /** The active connection, and the most recent one for history. */ + connection(): Promise<{ + active: HubSpotConnectionRecord | null; + latest: HubSpotConnectionRecord | null; + }> { + return this.tx(async (tx) => { + const [latest] = await tx + .select() + .from(commerceHubSpotConnections) + .orderBy(desc(commerceHubSpotConnections.createdAt)) + .limit(1); + const [active] = await tx + .select() + .from(commerceHubSpotConnections) + .where(eq(commerceHubSpotConnections.status, "active")); + return { + active: active ? connectionView(active) : null, + latest: latest ? connectionView(latest) : null, + }; + }); + } + + /** + * Activates the connection for the portal the token reaches, or changes + * the scope of the active one. Activation names the audience; changing the + * portal requires disconnecting first, so IDs from two portals never mix. + */ + saveConnection( + raw: unknown, + portal: { portalId: string; uiDomain: string }, + actor: UserActor, + ): Promise { + const input = HubSpotConnectionInputSchema.parse(raw); + if (input.portalId !== portal.portalId) + throw new Error("HUBSPOT_PORTAL_MISMATCH"); + return this.tx(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended('hubspot:connection', 0))`, + ); + const staffIds = [ + ...new Set(input.ownerMappings.map((row) => row.staffId)), + ]; + const staff = staffIds.length ? await staffOwners(tx, staffIds) : []; + const names = new Map(staff.map((member) => [member.id, member.name])); + if (staff.length !== staffIds.length) + throw new Error("HUBSPOT_OWNER_NOT_STAFF"); + const ownerMappings = input.ownerMappings.map((row) => ({ + ...row, + staffName: names.get(row.staffId) ?? "", + })); + const [active] = await tx + .select() + .from(commerceHubSpotConnections) + .where(eq(commerceHubSpotConnections.status, "active")) + .for("update"); + const values = { + uiDomain: portal.uiDomain, + audience: hubspotAudience, + approvedById: actor.id, + approvedByName: actor.display ?? actor.id, + approvedAt: new Date(), + scope: input.scope, + defaultPipelineId: input.defaultPipelineId, + ownerMappings, + }; + if (active) { + if (input.expectedVersion !== active.version) + throw new Error("HUBSPOT_CONNECTION_VERSION_CONFLICT"); + if (active.portalId !== input.portalId) + throw new Error("HUBSPOT_PORTAL_MISMATCH"); + const [updated] = await tx + .update(commerceHubSpotConnections) + .set({ + ...values, + updatedAt: sql`now()`, + version: active.version + 1, + }) + .where(eq(commerceHubSpotConnections.id, active.id)) + .returning(); + if (!updated) throw new Error("HUBSPOT_CONNECTION_VERSION_CONFLICT"); + await appendAuditAndOutbox(tx, { + aggregateType: "hubspot_connection", + aggregateId: updated.id, + aggregateVersion: updated.version, + eventType: "hubspot.connection_updated", + actor, + requestId: randomUUID(), + before: { + scope: active.scope, + defaultPipelineId: active.defaultPipelineId, + ownerMappings: active.ownerMappings.length, + }, + after: { + scope: updated.scope, + defaultPipelineId: updated.defaultPipelineId, + ownerMappings: updated.ownerMappings.length, + }, + }); + return connectionView(updated); + } + if (input.expectedVersion !== undefined) + throw new Error("HUBSPOT_CONNECTION_VERSION_CONFLICT"); + const [inserted] = await tx + .insert(commerceHubSpotConnections) + .values({ + id: randomUUID(), + portalId: input.portalId, + status: "active", + ...values, + }) + .returning(); + if (!inserted) throw new Error("HUBSPOT_CONNECTION_VERSION_CONFLICT"); + await appendAuditAndOutbox(tx, { + aggregateType: "hubspot_connection", + aggregateId: inserted.id, + aggregateVersion: inserted.version, + eventType: "hubspot.connection_activated", + actor, + requestId: randomUUID(), + after: { + portalId: inserted.portalId, + audience: inserted.audience, + scope: inserted.scope, + defaultPipelineId: inserted.defaultPipelineId, + ownerMappings: inserted.ownerMappings.length, + }, + }); + return connectionView(inserted); + }); + } + + /** + * Stops all live reads. Imported Commerce facts, retained documents, + * source identifiers and history stay; pending reviews no longer commit + * because their connection is no longer active. + */ + disconnect(expectedVersion: number, actor: UserActor) { + return this.tx(async (tx) => { + const [active] = await tx + .select() + .from(commerceHubSpotConnections) + .where(eq(commerceHubSpotConnections.status, "active")) + .for("update"); + if (!active) throw new Error("HUBSPOT_NOT_CONNECTED"); + if (active.version !== expectedVersion) + throw new Error("HUBSPOT_CONNECTION_VERSION_CONFLICT"); + const [updated] = await tx + .update(commerceHubSpotConnections) + .set({ + status: "disconnected", + disconnectedAt: new Date(), + disconnectedByName: actor.display ?? actor.id, + updatedAt: sql`now()`, + version: active.version + 1, + }) + .where(eq(commerceHubSpotConnections.id, active.id)) + .returning(); + if (!updated) throw new Error("HUBSPOT_CONNECTION_VERSION_CONFLICT"); + await appendAuditAndOutbox(tx, { + aggregateType: "hubspot_connection", + aggregateId: updated.id, + aggregateVersion: updated.version, + eventType: "hubspot.connection_disconnected", + actor, + requestId: randomUUID(), + after: { portalId: updated.portalId }, + }); + return connectionView(updated); + }); + } + + /** The partner each company became, for companies that have one. */ + partnersForCompanies( + portalId: string, + companyIds: readonly string[], + ): Promise> { + const ids = [...new Set(companyIds)]; + if (!ids.length) return Promise.resolve(new Map()); + return this.tx(async (tx) => { + const rows = await tx + .select({ + companyId: commerceExternalLinks.objectId, + partnerId: commercePartners.id, + partnerName: commercePartners.name, + }) + .from(commerceExternalLinks) + .innerJoin( + commercePartners, + eq(commercePartners.id, commerceExternalLinks.partnerId), + ) + .where( + and( + eq(commerceExternalLinks.provider, "hubspot"), + eq(commerceExternalLinks.portalId, portalId), + eq(commerceExternalLinks.objectType, "company"), + eq(commerceExternalLinks.destinationType, "partner"), + inArray(commerceExternalLinks.objectId, ids), + ), + ); + return new Map( + rows.map((row) => [ + row.companyId, + { id: row.partnerId, name: row.partnerName }, + ]), + ); + }); + } + + /** The HubSpot company a partner is linked to in a portal, if any. */ + linkForPartner( + partnerId: string, + portalId?: string, + ): Promise { + return this.tx(async (tx) => { + const [row] = await tx + .select({ + link: commerceExternalLinks, + partnerName: commercePartners.name, + }) + .from(commerceExternalLinks) + .innerJoin( + commercePartners, + eq(commercePartners.id, commerceExternalLinks.partnerId), + ) + .where( + and( + eq(commerceExternalLinks.partnerId, partnerId), + portalId ? eq(commerceExternalLinks.portalId, portalId) : undefined, + ), + ) + .orderBy(desc(commerceExternalLinks.createdAt)) + .limit(1); + if (!row) return null; + return { + id: row.link.id, + portalId: row.link.portalId, + companyId: row.link.objectId, + partnerId, + partnerName: row.partnerName, + companyName: row.link.companyName, + createdByName: row.link.createdByName, + createdAt: row.link.createdAt.toISOString(), + }; + }); + } + + /** + * Partners that look like a company, by the MNDA company normalizer or the + * website's host. Suggestions only: a person inspects them, nothing merges. + */ + similarPartners(input: { name: string; host: string | null }): Promise< + { + id: string; + name: string; + website: string; + reason: "name" | "website"; + linkedCompanyId: string | null; + }[] + > { + // The host as a literal pattern: "northstar.example" also matches + // "https://www.northstar.example/anything". + const host = input.host + ?.toLowerCase() + .replace(/^www\./, "") + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return this.tx(async (tx) => { + const rows = await tx.execute<{ + id: string; + name: string; + website: string; + same_name: boolean; + linked_company_id: string | null; + }>(sql` + select p.id, p.name, p.website, + p.normalized_name = public.commerce_mnda_normalize_company(${input.name}) as same_name, + (select l.object_id from public.commerce_external_links l + where l.partner_id = p.id order by l.created_at desc limit 1) as linked_company_id + from public.commerce_partners p + where p.normalized_name = public.commerce_mnda_normalize_company(${input.name}) + ${host ? sql`or lower(p.website) ~ ${`^https?://(www\\.)?${host}(/|$)`}` : sql``} + order by p.updated_at desc + limit 5`); + return [...rows].map((row) => ({ + id: row.id, + name: row.name, + website: row.website, + reason: row.same_name ? ("name" as const) : ("website" as const), + linkedCompanyId: row.linked_company_id, + })); + }); + } + + /** Each field's HubSpot value and stored result at the latest import. */ + fieldHistory(partnerId: string): Promise { + return this.tx(async (tx) => { + const [latest] = await tx + .select({ plan: commerceHubSpotImports.plan }) + .from(commerceHubSpotImports) + .where(eq(commerceHubSpotImports.partnerId, partnerId)) + .orderBy( + desc(commerceHubSpotImports.createdAt), + desc(commerceHubSpotImports.id), + ) + .limit(1); + const history: HubSpotFieldHistory = {}; + for (const field of latest?.plan.fields ?? []) + history[field.field] = { + lastSource: field.hubspot, + lastResult: field.result, + }; + return history; + }); + } + + /** + * Commits a reviewed handoff in one transaction: the partner created or + * changed, the company link, the import with its plan and snapshot, a + * pending row per selected document, and the audit trail. A retry of the + * same operation returns the first result; a concurrent handoff of the same + * company resolves to whichever partner won. + */ + commit( + input: HubSpotCommitInput, + actor: UserActor, + ): Promise { + const { plan, snapshot } = input; + return this.tx(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtextextended(${`hubspot:company:${snapshot.portalId}:${input.companyId}`}, 0))`, + ); + const [prior] = await tx + .select() + .from(commerceHubSpotImports) + .where(eq(commerceHubSpotImports.id, plan.operationId)); + if (prior) { + if ( + prior.requestDigest !== input.requestDigest || + prior.createdById !== actor.id + ) + throw new Error("HUBSPOT_OPERATION_CONFLICT"); + return { + kind: "replayed", + importId: prior.id, + partnerId: prior.partnerId, + createdPartner: prior.createdPartner, + }; + } + const [connection] = await tx + .select() + .from(commerceHubSpotConnections) + .where(eq(commerceHubSpotConnections.id, input.connection.id)) + .for("share"); + if ( + !connection || + connection.status !== "active" || + connection.version !== input.connection.version || + connection.portalId !== snapshot.portalId + ) + throw new Error("HUBSPOT_CONNECTION_CHANGED"); + const [existingLink] = await tx + .select() + .from(commerceExternalLinks) + .where( + and( + eq(commerceExternalLinks.provider, "hubspot"), + eq(commerceExternalLinks.portalId, snapshot.portalId), + eq(commerceExternalLinks.objectType, "company"), + eq(commerceExternalLinks.objectId, input.companyId), + eq(commerceExternalLinks.destinationType, "partner"), + ), + ); + const destination = plan.destination; + if ( + existingLink?.partnerId && + (destination.kind === "new_partner" || + existingLink.partnerId !== destination.partnerId) + ) { + const [winner] = await tx + .select({ name: commercePartners.name }) + .from(commercePartners) + .where(eq(commercePartners.id, existingLink.partnerId)); + return { + kind: "already_linked", + partnerId: existingLink.partnerId, + partnerName: winner?.name ?? "", + }; + } + const source = { + provider: "hubspot", + importId: plan.operationId, + portalId: snapshot.portalId, + companyId: input.companyId, + dealId: input.dealId, + }; + let partner: PartnerRecord; + if (destination.kind === "new_partner") { + const field = (name: HubSpotPartnerField) => + plan.fields.find((change) => change.field === name)?.result ?? ""; + const partnerInput: PartnerInput = PartnerInputSchema.parse({ + id: destination.partnerId, + name: field("name"), + website: field("website"), + region: field("region"), + status: destination.status, + ownerId: destination.ownerId, + models: destination.models, + contacts: mergeContacts([], plan, snapshot.portalId), + }); + const [taken] = await tx + .select({ id: commercePartners.id }) + .from(commercePartners) + .where(eq(commercePartners.id, destination.partnerId)); + if (taken) throw new Error("HUBSPOT_OPERATION_CONFLICT"); + partner = await insertPartnerRow( + tx, + partnerInput, + await resolveOwner(tx, partnerInput.ownerId), + actor, + source, + ); + } else { + const [current] = await tx + .select() + .from(commercePartners) + .where(eq(commercePartners.id, destination.partnerId)) + .for("update"); + if (!current) throw new Error("PARTNER_NOT_FOUND"); + if (current.version !== destination.expectedVersion) + throw new Error("PARTNER_VERSION_CONFLICT"); + const before = partnerRecordOf(current, null); + const result = (name: HubSpotPartnerField, fallback: string) => + plan.fields.find((change) => change.field === name)?.result ?? + fallback; + const partnerInput: PartnerInput = PartnerInputSchema.parse({ + ...partnerInputOf(before), + name: result("name", before.name), + website: result("website", before.website), + region: result("region", before.region), + contacts: mergeContacts(before.contacts, plan, snapshot.portalId), + }); + // Owner, status and models are never changed by an update import. + partner = await updatePartnerRow( + tx, + current, + partnerInput, + current.ownerName, + actor, + source, + ); + } + if (!existingLink) { + // A partner links to one company per portal. + const [elsewhere] = await tx + .select({ objectId: commerceExternalLinks.objectId }) + .from(commerceExternalLinks) + .where( + and( + eq(commerceExternalLinks.provider, "hubspot"), + eq(commerceExternalLinks.portalId, snapshot.portalId), + eq(commerceExternalLinks.partnerId, partner.id), + ), + ); + if (elsewhere) throw new Error("HUBSPOT_PARTNER_LINKED_ELSEWHERE"); + } + const linkId = existingLink?.id ?? randomUUID(); + if (!existingLink) + await tx.insert(commerceExternalLinks).values({ + id: linkId, + provider: "hubspot", + portalId: snapshot.portalId, + objectType: "company", + objectId: input.companyId, + destinationType: "partner", + partnerId: partner.id, + companyName: snapshot.company.name.slice(0, 200) || input.companyId, + createdById: actor.id, + createdByName: actor.display ?? actor.id, + }); + const items = plan.documents.map((document) => ({ + id: randomUUID(), + importId: plan.operationId, + fileId: document.fileId, + noteId: document.noteId, + fileName: document.fileName.slice(0, 255), + title: document.title, + documentKind: document.kind, + fileUpdatedAt: document.updatedAt, + fileSize: document.size, + status: "pending" as const, + })); + const status = hubspotImportStatus(items); + await tx.insert(commerceHubSpotImports).values({ + id: plan.operationId, + linkId, + partnerId: partner.id, + connectionId: connection.id, + portalId: snapshot.portalId, + companyId: input.companyId, + dealId: input.dealId, + createdPartner: destination.kind === "new_partner", + status, + plan, + snapshot, + requestDigest: input.requestDigest, + createdById: actor.id, + createdByName: actor.display ?? actor.id, + completedAt: status === "needs_attention" ? null : new Date(), + }); + if (items.length) + await tx.insert(commerceHubSpotImportItems).values(items); + await appendAuditAndOutbox(tx, { + aggregateType: "hubspot_import", + aggregateId: plan.operationId, + aggregateVersion: 1, + eventType: "hubspot.imported", + actor, + requestId: randomUUID(), + after: { + partnerId: partner.id, + portalId: snapshot.portalId, + companyId: input.companyId, + dealId: input.dealId, + createdPartner: destination.kind === "new_partner", + linked: !existingLink, + fields: plan.fields + .filter((field) => field.action !== "keep") + .map((field) => ({ field: field.field, action: field.action })), + contacts: plan.contacts.length, + notes: plan.notes.length, + references: plan.references.map( + (value) => `${value.objectType}:${value.property}`, + ), + documents: items.map((item) => item.fileId), + dismissedSuggestions: plan.dismissedSuggestions, + status, + }, + }); + return { + kind: "committed", + importId: plan.operationId, + partnerId: partner.id, + createdPartner: destination.kind === "new_partner", + }; + }); + } + + /** A partner's imports, newest first, with their document items. */ + imports(partnerId: string, limit = 20): Promise { + return this.tx(async (tx) => { + const rows = await tx + .select() + .from(commerceHubSpotImports) + .where(eq(commerceHubSpotImports.partnerId, partnerId)) + .orderBy( + desc(commerceHubSpotImports.createdAt), + desc(commerceHubSpotImports.id), + ) + .limit(limit); + return withItems(tx, rows); + }); + } + + /** One import, scoped to its partner so an ID from another record fails. */ + import(partnerId: string, importId: string): Promise { + return this.tx(async (tx) => { + const rows = await tx + .select() + .from(commerceHubSpotImports) + .where( + and( + eq(commerceHubSpotImports.id, importId), + eq(commerceHubSpotImports.partnerId, partnerId), + ), + ); + const [record] = await withItems(tx, rows); + if (!record) throw new Error("HUBSPOT_IMPORT_NOT_FOUND"); + return record; + }); + } + + /** An attachment by ID on a partner, so a retry after a lost answer + * records the document it already attached. */ + attachmentOnPartner(partnerId: string, attachmentId: string) { + return this.tx(async (tx) => { + const [row] = await tx + .select({ id: accountDocuments.id, title: accountDocuments.title }) + .from(accountDocuments) + .where( + and( + eq(accountDocuments.id, attachmentId), + eq(accountDocuments.partnerId, partnerId), + ), + ); + return row ?? null; + }); + } + + /** The partner's attachment with the same bytes, if any. */ + attachmentByHash(partnerId: string, sha256: string) { + return this.tx(async (tx) => { + const [row] = await tx + .select({ id: accountDocuments.id, title: accountDocuments.title }) + .from(accountDocuments) + .where( + and( + eq(accountDocuments.partnerId, partnerId), + eq(accountDocuments.sha256, sha256), + ), + ); + return row ?? null; + }); + } + + /** Records one attempt at a document. Resolved items stay resolved. */ + recordItem( + partnerId: string, + importId: string, + itemId: string, + outcome: HubSpotItemOutcome, + actor: UserActor, + ): Promise { + return this.changeItem(partnerId, importId, itemId, actor, (item) => { + if (outcome.status === "attached") + return { + values: { + status: "attached", + attachmentId: outcome.attachmentId, + errorCode: null, + attempts: item.attempts + 1, + }, + event: "hubspot.document_attached", + detail: { attachmentId: outcome.attachmentId }, + }; + if (outcome.status === "duplicate") + return { + values: { + status: "duplicate", + existingAttachmentId: outcome.existingAttachmentId, + errorCode: null, + attempts: item.attempts + 1, + }, + event: "hubspot.document_duplicate", + detail: { existingAttachmentId: outcome.existingAttachmentId }, + }; + return { + values: { + status: "failed", + errorCode: outcome.errorCode, + attempts: item.attempts + 1, + }, + event: "hubspot.document_failed", + detail: { errorCode: outcome.errorCode }, + }; + }); + } + + /** + * Uses the identical PDF already on the record instead of attaching a + * second copy: a visible choice for a duplicate, never automatic. + */ + reuseExisting( + partnerId: string, + importId: string, + itemId: string, + actor: UserActor, + ) { + return this.changeItem(partnerId, importId, itemId, actor, (item) => { + if (item.status !== "duplicate" || !item.existingAttachmentId) + throw new Error("HUBSPOT_ITEM_RESOLVED"); + return { + values: { + status: "reused", + attachmentId: item.existingAttachmentId, + resolvedByName: actor.display ?? actor.id, + }, + event: "hubspot.document_reused", + detail: { attachmentId: item.existingAttachmentId }, + }; + }); + } + + /** + * Excludes a document from the handoff: an explicit, audited amendment to + * the reviewed plan, never a silent drop. The receipt says so. + */ + excludeItem( + partnerId: string, + importId: string, + itemId: string, + reason: string, + actor: UserActor, + ) { + const text = reason.trim().slice(0, 500); + if (!text) throw new Error("HUBSPOT_DECISION_REQUIRED"); + return this.changeItem(partnerId, importId, itemId, actor, () => ({ + values: { + status: "excluded", + excludedReason: text, + resolvedByName: actor.display ?? actor.id, + }, + event: "hubspot.document_excluded", + detail: { reason: text }, + })); + } + + private changeItem( + partnerId: string, + importId: string, + itemId: string, + actor: UserActor, + change: (item: ItemRow) => { + values: Partial; + event: string; + detail: Record; + }, + ): Promise { + return this.tx(async (tx) => { + const [record] = await tx + .select() + .from(commerceHubSpotImports) + .where( + and( + eq(commerceHubSpotImports.id, importId), + eq(commerceHubSpotImports.partnerId, partnerId), + ), + ) + .for("update"); + if (!record) throw new Error("HUBSPOT_IMPORT_NOT_FOUND"); + const [item] = await tx + .select() + .from(commerceHubSpotImportItems) + .where( + and( + eq(commerceHubSpotImportItems.id, itemId), + eq(commerceHubSpotImportItems.importId, importId), + ), + ) + .for("update"); + if (!item) throw new Error("HUBSPOT_ITEM_NOT_FOUND"); + if (["attached", "reused", "excluded"].includes(item.status)) + throw new Error("HUBSPOT_ITEM_RESOLVED"); + const { values, event, detail } = change(item); + await tx + .update(commerceHubSpotImportItems) + .set({ ...values, updatedAt: sql`now()`, version: item.version + 1 }) + .where(eq(commerceHubSpotImportItems.id, item.id)); + const items = await tx + .select({ status: commerceHubSpotImportItems.status }) + .from(commerceHubSpotImportItems) + .where(eq(commerceHubSpotImportItems.importId, importId)); + const status = hubspotImportStatus(items); + const [updated] = await tx + .update(commerceHubSpotImports) + .set({ + status, + completedAt: + status === "needs_attention" + ? null + : (record.completedAt ?? new Date()), + updatedAt: sql`now()`, + version: record.version + 1, + }) + .where(eq(commerceHubSpotImports.id, importId)) + .returning(); + if (!updated) throw new Error("HUBSPOT_IMPORT_NOT_FOUND"); + await appendAuditAndOutbox(tx, { + aggregateType: "hubspot_import", + aggregateId: importId, + aggregateVersion: updated.version, + eventType: event, + actor, + requestId: randomUUID(), + after: { + partnerId, + itemId, + fileId: item.fileId, + importStatus: status, + ...detail, + }, + }); + const [view] = await withItems(tx, [updated]); + if (!view) throw new Error("HUBSPOT_IMPORT_NOT_FOUND"); + return view; + }); + } +} + +async function withItems( + tx: RuntimeTransaction, + rows: readonly ImportRow[], +): Promise { + if (!rows.length) return []; + const items = await tx + .select() + .from(commerceHubSpotImportItems) + .where( + inArray( + commerceHubSpotImportItems.importId, + rows.map((row) => row.id), + ), + ) + .orderBy( + commerceHubSpotImportItems.fileName, + commerceHubSpotImportItems.id, + ); + const existingIds = items.flatMap((item) => + item.existingAttachmentId ? [item.existingAttachmentId] : [], + ); + const titles = existingIds.length + ? await tx + .select({ id: accountDocuments.id, title: accountDocuments.title }) + .from(accountDocuments) + .where(inArray(accountDocuments.id, existingIds)) + : []; + const existing = new Map(titles.map((row) => [row.id, row.title])); + return rows.map((row) => + importView( + row, + items + .filter((item) => item.importId === row.id) + .map((item) => itemView(item, existing)), + ), + ); +} + +/** + * The partner's contacts after a plan: unrelated contacts untouched, a + * matched contact keeps its values and gains its HubSpot identity, new + * people are appended with theirs, and a contact already carrying the same + * HubSpot identity is never added twice. + */ +export function mergeContacts( + current: readonly PartnerContact[], + plan: Pick, + portalId: string, +): PartnerContact[] { + const contacts = current.map((contact) => ({ ...contact })); + for (const planned of plan.contacts) { + const source = { + provider: "hubspot" as const, + portalId, + objectId: planned.hubspotId, + labels: [...planned.labels].slice(0, 20), + }; + const existing = contacts.find( + (contact) => + contact.id === planned.id || + (contact.source?.portalId === portalId && + contact.source.objectId === planned.hubspotId), + ); + if (existing) { + existing.id ??= planned.id; + existing.source = source; + continue; + } + if (planned.matchedExisting) throw new Error("HUBSPOT_CONTACT_NOT_FOUND"); + contacts.push({ + id: planned.id, + name: planned.name, + email: planned.email, + role: planned.role, + source, + }); + } + if (contacts.length > partnerContactLimit) + throw new Error("HUBSPOT_CONTACT_LIMIT"); + if (plan.primaryContactId !== null) { + if (!contacts.some((contact) => contact.id === plan.primaryContactId)) + throw new Error("HUBSPOT_CONTACT_NOT_FOUND"); + for (const contact of contacts) { + if (contact.id === plan.primaryContactId) contact.primary = true; + else delete contact.primary; + } + } + return contacts; +} diff --git a/packages/db/src/repositories/partners.ts b/packages/db/src/repositories/partners.ts index 96d2f631..8c980eeb 100644 --- a/packages/db/src/repositories/partners.ts +++ b/packages/db/src/repositories/partners.ts @@ -488,27 +488,7 @@ export class PartnerRepository { if (!current) { if (input.expectedVersion !== undefined) throw new Error("PARTNER_NOT_FOUND"); - const [inserted] = await tx - .insert(commercePartners) - .values({ - id: input.id, - ...partnerColumns(input, ownerName), - createdById: actor.id, - createdByName: actor.display ?? actor.id, - }) - .returning(); - if (!inserted) throw new Error("PARTNER_INSERT_FAILED"); - const record = partnerView(inserted, null); - await appendAuditAndOutbox(tx, { - aggregateType: "partner", - aggregateId: record.id, - aggregateVersion: record.version, - eventType: "partner.created", - actor, - requestId: randomUUID(), - after: { changes: partnerChanges({}, comparable(record)) }, - }); - return record; + return insertPartnerRow(tx, input, ownerName, actor); } if (input.expectedVersion === undefined) { // A retried first save returns the stored record only when it is the @@ -533,34 +513,7 @@ export class PartnerRepository { } if (current.version !== input.expectedVersion) throw new Error("PARTNER_VERSION_CONFLICT"); - const before = partnerView(current, null); - const changes = partnerChanges( - comparable(before), - inputComparable(input, ownerName), - ); - // Saving an unchanged form changes nothing and records nothing. - if (Object.keys(changes).length === 0) return before; - const [updated] = await tx - .update(commercePartners) - .set({ - ...partnerColumns(input, ownerName), - updatedAt: sql`now()`, - version: current.version + 1, - }) - .where(eq(commercePartners.id, input.id)) - .returning(); - if (!updated) throw new Error("PARTNER_UPDATE_FAILED"); - const record = partnerView(updated, null); - await appendAuditAndOutbox(tx, { - aggregateType: "partner", - aggregateId: record.id, - aggregateVersion: record.version, - eventType: "partner.updated", - actor, - requestId: randomUUID(), - after: { changes }, - }); - return record; + return updatePartnerRow(tx, current, input, ownerName, actor); }); } @@ -1076,6 +1029,11 @@ async function activity( inArray(auditEvents.aggregateId, dealIds), ) : undefined, + // HubSpot imports into this partner and what became of their files. + and( + eq(auditEvents.aggregateType, "hubspot_import"), + sql`${auditEvents.after}->>'partnerId' = ${partnerId}`, + ), ), ) .orderBy(desc(auditEvents.occurredAt), desc(auditEvents.id)) @@ -1084,6 +1042,7 @@ async function activity( const actor = row.actor as Partial; const after = (row.after ?? {}) as { changes?: Record; + source?: { provider?: unknown }; }; return { id: row.id, @@ -1092,6 +1051,9 @@ async function activity( dealEndClient: dealClients.get(row.aggregateId) ?? null, changes: after.changes ?? {}, occurredAt: row.occurredAt.toISOString(), + ...(after.source?.provider === "hubspot" + ? { source: "hubspot" as const } + : {}), }; }); } @@ -1114,7 +1076,111 @@ function auditDeal( }); } -async function staffOwners( +/** + * Inserts a partner and its `partner.created` event inside the caller's + * transaction. `source` records where the values came from, such as a + * HubSpot import, beside the fields written. + */ +export async function insertPartnerRow( + tx: RuntimeTransaction, + input: PartnerInput, + ownerName: string | null, + actor: UserActor, + source?: Record, +): Promise { + const [inserted] = await tx + .insert(commercePartners) + .values({ + id: input.id, + ...partnerColumns(input, ownerName), + createdById: actor.id, + createdByName: actor.display ?? actor.id, + }) + .returning(); + if (!inserted) throw new Error("PARTNER_INSERT_FAILED"); + const record = partnerView(inserted, null); + await appendAuditAndOutbox(tx, { + aggregateType: "partner", + aggregateId: record.id, + aggregateVersion: record.version, + eventType: "partner.created", + actor, + requestId: randomUUID(), + after: { + changes: partnerChanges({}, comparable(record)), + ...(source ? { source } : {}), + }, + }); + return record; +} + +/** + * Overwrites a locked partner row with `input` and records the fields that + * changed. An unchanged input writes and records nothing. + */ +export async function updatePartnerRow( + tx: RuntimeTransaction, + current: typeof commercePartners.$inferSelect, + input: PartnerInput, + ownerName: string | null, + actor: UserActor, + source?: Record, +): Promise { + const before = partnerView(current, null); + const changes = partnerChanges( + comparable(before), + inputComparable(input, ownerName), + ); + // Saving an unchanged form changes nothing and records nothing. + if (Object.keys(changes).length === 0) return before; + const [updated] = await tx + .update(commercePartners) + .set({ + ...partnerColumns(input, ownerName), + updatedAt: sql`now()`, + version: current.version + 1, + }) + .where(eq(commercePartners.id, input.id)) + .returning(); + if (!updated) throw new Error("PARTNER_UPDATE_FAILED"); + const record = partnerView(updated, null); + await appendAuditAndOutbox(tx, { + aggregateType: "partner", + aggregateId: record.id, + aggregateVersion: record.version, + eventType: "partner.updated", + actor, + requestId: randomUUID(), + after: { changes, ...(source ? { source } : {}) }, + }); + return record; +} + +/** A stored partner in the shape a save takes, so a caller can change part + * of it and write the rest back unchanged. */ +export function partnerInputOf( + record: PartnerRecord, +): Omit { + return { + id: record.id, + name: record.name, + website: record.website, + region: record.region, + models: record.models, + status: record.status, + ownerId: record.ownerId, + organizationId: record.organizationId, + contacts: record.contacts, + nextStep: record.nextStep, + nextStepDue: record.nextStepDue, + notes: record.notes, + terms: record.terms, + }; +} + +export { partnerView as partnerRecordOf }; + +export async function staffOwners( tx: RuntimeTransaction, ids?: readonly string[], ): Promise { @@ -1141,7 +1207,7 @@ async function staffOwners( } /** The owner's name, checked to be staff with the sales workspace. */ -async function resolveOwner( +export async function resolveOwner( tx: RuntimeTransaction, ownerId: string | null, ): Promise { diff --git a/packages/db/src/schema/hubspot.ts b/packages/db/src/schema/hubspot.ts new file mode 100644 index 00000000..b1a0b275 --- /dev/null +++ b/packages/db/src/schema/hubspot.ts @@ -0,0 +1,184 @@ +import { sql } from "drizzle-orm"; +import { + bigint, + boolean, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + unique, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; + +import type { + AccountDocumentKind, + HubSpotConnectionStatus, + HubSpotHandoffPlan, + HubSpotImportStatus, + HubSpotItemStatus, + HubSpotScope, + HubSpotSourceSnapshot, +} from "@clockwork/contracts"; + +import { accounts } from "../schema"; +import { accountDocuments } from "./account-documents"; +import { commercePartners } from "./partners"; + +/** Mirrors 001483. */ +export const commerceHubSpotConnections = pgTable( + "commerce_hubspot_connections", + { + id: uuid("id").primaryKey(), + portalId: text("portal_id").notNull(), + uiDomain: text("ui_domain").notNull(), + status: text("status").$type().notNull(), + audience: text("audience").$type<"staff_sales_read">().notNull(), + approvedById: uuid("approved_by_id").notNull(), + approvedByName: text("approved_by_name").notNull(), + approvedAt: timestamp("approved_at", { withTimezone: true }).notNull(), + scope: jsonb("scope").$type().notNull(), + defaultPipelineId: text("default_pipeline_id"), + ownerMappings: jsonb("owner_mappings") + .$type<{ hubspotOwnerId: string; staffId: string; staffName: string }[]>() + .notNull() + .default([]), + disconnectedByName: text("disconnected_by_name"), + disconnectedAt: timestamp("disconnected_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + version: integer("version").notNull().default(1), + }, + () => [ + uniqueIndex("commerce_hubspot_connections_one_active") + .on(sql`(true)`) + .where(sql`status = 'active'`), + ], +); + +export const commerceExternalLinks = pgTable( + "commerce_external_links", + { + id: uuid("id").primaryKey(), + provider: text("provider").$type<"hubspot">().notNull(), + portalId: text("portal_id").notNull(), + objectType: text("object_type").$type<"company">().notNull(), + objectId: text("object_id").notNull(), + destinationType: text("destination_type") + .$type<"partner" | "account">() + .notNull(), + partnerId: uuid("partner_id").references(() => commercePartners.id), + accountId: uuid("account_id").references(() => accounts.id), + companyName: text("company_name").notNull(), + createdById: uuid("created_by_id").notNull(), + createdByName: text("created_by_name").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + uniqueIndex("commerce_external_links_source").on( + t.provider, + t.portalId, + t.objectType, + t.objectId, + t.destinationType, + ), + uniqueIndex("commerce_external_links_partner") + .on(t.provider, t.portalId, t.partnerId) + .where(sql`${t.partnerId} is not null`), + index("commerce_external_links_partner_lookup") + .on(t.partnerId) + .where(sql`${t.partnerId} is not null`), + ], +); + +export const commerceHubSpotImports = pgTable( + "commerce_hubspot_imports", + { + id: uuid("id").primaryKey(), + linkId: uuid("link_id") + .notNull() + .references(() => commerceExternalLinks.id), + partnerId: uuid("partner_id") + .notNull() + .references(() => commercePartners.id), + connectionId: uuid("connection_id") + .notNull() + .references(() => commerceHubSpotConnections.id), + portalId: text("portal_id").notNull(), + companyId: text("company_id").notNull(), + dealId: text("deal_id"), + createdPartner: boolean("created_partner").notNull(), + status: text("status").$type().notNull(), + plan: jsonb("plan").$type().notNull(), + snapshot: jsonb("snapshot").$type().notNull(), + requestDigest: text("request_digest").notNull(), + createdById: uuid("created_by_id").notNull(), + createdByName: text("created_by_name").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + completedAt: timestamp("completed_at", { withTimezone: true }), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + version: integer("version").notNull().default(1), + }, + (t) => [ + index("commerce_hubspot_imports_partner").on( + t.partnerId, + t.createdAt.desc(), + t.id.desc(), + ), + ], +); + +export const commerceHubSpotImportItems = pgTable( + "commerce_hubspot_import_items", + { + id: uuid("id").primaryKey(), + importId: uuid("import_id") + .notNull() + .references(() => commerceHubSpotImports.id), + fileId: text("file_id").notNull(), + noteId: text("note_id").notNull(), + fileName: text("file_name").notNull(), + title: text("title").notNull(), + documentKind: text("document_kind").$type().notNull(), + fileUpdatedAt: text("file_updated_at"), + fileSize: bigint("file_size", { mode: "number" }), + status: text("status") + .$type() + .notNull() + .default("pending"), + errorCode: text("error_code"), + attachmentId: uuid("attachment_id").references(() => accountDocuments.id), + existingAttachmentId: uuid("existing_attachment_id").references( + () => accountDocuments.id, + ), + excludedReason: text("excluded_reason"), + resolvedByName: text("resolved_by_name"), + attempts: integer("attempts").notNull().default(0), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + version: integer("version").notNull().default(1), + }, + (t) => [ + unique("commerce_hubspot_import_items_file").on(t.importId, t.fileId), + ], +); + +export const hubspotSchema = { + commerceHubSpotConnections, + commerceExternalLinks, + commerceHubSpotImports, + commerceHubSpotImportItems, +}; diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 961bc036..82747ad1 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -13,6 +13,7 @@ import { partnersSchema } from "./partners"; import { staffNotificationsSchema } from "./staff-notifications"; import { contractTemplatesSchema } from "./contract-templates"; import { accessTokensSchema } from "./access-tokens"; +import { hubspotSchema } from "./hubspot"; export * from "../schema"; export * from "./account-documents"; @@ -29,6 +30,7 @@ export * from "./partners"; export * from "./staff-notifications"; export * from "./contract-templates"; export * from "./access-tokens"; +export * from "./hubspot"; /** Drizzle consumes this stable composition; lanes only edit their own object. */ export const runtimeSchema: typeof foundationSchema & @@ -45,7 +47,8 @@ export const runtimeSchema: typeof foundationSchema & typeof staffNotificationsSchema & typeof contractTemplatesSchema & typeof accessTokensSchema & - typeof accountDocumentsSchema = { + typeof accountDocumentsSchema & + typeof hubspotSchema = { ...accountDocumentsSchema, ...foundationSchema, ...coreSchema, @@ -61,4 +64,5 @@ export const runtimeSchema: typeof foundationSchema & ...staffNotificationsSchema, ...contractTemplatesSchema, ...accessTokensSchema, + ...hubspotSchema, }; diff --git a/packages/integrations/package.json b/packages/integrations/package.json index b6e348a5..6beed605 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -10,7 +10,8 @@ "./provider-transport": "./src/provider-transport.ts", "./telemetry": "./src/telemetry/index.ts", "./system": "./src/system/index.ts", - "./fakes": "./src/fakes/index.ts" + "./fakes": "./src/fakes/index.ts", + "./hubspot": "./src/hubspot/index.ts" }, "scripts": { "build": "tsc -p tsconfig.json --noEmit", diff --git a/packages/integrations/src/hubspot/client.test.ts b/packages/integrations/src/hubspot/client.test.ts new file mode 100644 index 00000000..3dfb0e3a --- /dev/null +++ b/packages/integrations/src/hubspot/client.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it } from "vitest"; + +import { HttpHubSpotReader, HubSpotError, hubspotNoteText } from "./client"; +import { FakeHubSpotReader, fakeHubSpotPdf } from "./fake"; + +interface Call { + url: string; + method: string; + headers: Record; + body: unknown; + redirect: string | undefined; +} + +function stub(routes: (call: Call) => Response | Promise): { + fetch: typeof fetch; + calls: Call[]; +} { + const calls: Call[] = []; + const fetcher = (input: string | URL, init?: RequestInit) => { + const call: Call = { + url: input.toString(), + method: init?.method ?? "GET", + headers: { ...(init?.headers as Record | undefined) }, + body: + typeof init?.body === "string" + ? (JSON.parse(init.body) as unknown) + : undefined, + redirect: init?.redirect, + }; + calls.push(call); + return Promise.resolve(routes(call)); + }; + return { fetch: fetcher as typeof fetch, calls }; +} + +const json = (value: unknown, status = 200, headers: HeadersInit = {}) => + new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json", ...headers }, + }); + +const reader = (fetcher: typeof fetch) => + new HttpHubSpotReader({ + accessToken: "pat-na1-test", + fetch: fetcher, + sleep: () => Promise.resolve(), + }); + +describe("HttpHubSpotReader", () => { + it("refuses to construct without a usable token", () => { + expect(() => new HttpHubSpotReader({ accessToken: " " })).toThrow( + HubSpotError, + ); + expect(() => new HttpHubSpotReader({ accessToken: "a\nb" })).toThrow( + HubSpotError, + ); + }); + + it("reads the portal with the bearer token and no redirects", async () => { + const { fetch, calls } = stub(() => + json({ portalId: 4410, uiDomain: "app-eu1.hubspot.com" }), + ); + await expect(reader(fetch).portal()).resolves.toEqual({ + portalId: "4410", + uiDomain: "app-eu1.hubspot.com", + timeZone: null, + companyCurrency: null, + }); + expect(calls[0]).toMatchObject({ + url: "https://api.hubapi.com/account-info/v3/details", + method: "GET", + redirect: "error", + headers: { authorization: "Bearer pat-na1-test" }, + }); + }); + + it("waits once for a short Retry-After, then reports throttling", async () => { + let attempts = 0; + const { fetch } = stub(() => { + attempts += 1; + return json({}, 429, { "retry-after": "1" }); + }); + await expect(reader(fetch).pipelines()).rejects.toMatchObject({ + code: "HUBSPOT_RATE_LIMITED", + }); + expect(attempts).toBe(2); + }); + + it("maps missing records and refused scopes to their own codes", async () => { + await expect( + reader(stub(() => json({}, 404)).fetch).associations( + "company", + "9", + "contact", + ), + ).rejects.toMatchObject({ code: "HUBSPOT_RECORD_NOT_FOUND" }); + await expect( + reader(stub(() => json({}, 403)).fetch).owners(), + ).rejects.toMatchObject({ code: "HUBSPOT_SCOPE_DENIED" }); + }); + + it("resolves association labels to names and drops type IDs", async () => { + const { fetch, calls } = stub(() => + json({ + results: [ + { + toObjectId: 9001, + associationTypes: [ + { category: "USER_DEFINED", typeId: 7, label: "Reseller" }, + { category: "HUBSPOT_DEFINED", typeId: 341, label: null }, + ], + }, + ], + }), + ); + await expect( + reader(fetch).associations("deal", "77", "company"), + ).resolves.toEqual([{ id: "9001", labels: ["Reseller"] }]); + expect(calls[0]?.url).toBe( + "https://api.hubapi.com/crm/v4/objects/deals/77/associations/companies?limit=100", + ); + }); + + it("refuses an ID that is not a HubSpot ID before any request", async () => { + const { fetch, calls } = stub(() => json({})); + await expect( + reader(fetch).associations("deal", "../owners", "company"), + ).rejects.toThrow(); + expect(calls).toHaveLength(0); + }); + + it("reads only the properties asked for and keeps empty as null", async () => { + const { fetch, calls } = stub(() => + json({ + results: [ + { + id: "9001", + properties: { + name: "Northstar", + domain: "", + industry: "IT", + hs_lastmodifieddate: "2026-10-20T00:00:00Z", + }, + }, + ], + }), + ); + const [company] = await reader(fetch).companies( + ["9001", "bad"], + ["name", "domain", "industry"], + ); + expect(company).toMatchObject({ + id: "9001", + name: "Northstar", + domain: null, + properties: { industry: "IT" }, + }); + expect(calls[0]?.body).toEqual({ + properties: ["name", "domain", "industry"], + inputs: [{ id: "9001" }], + }); + }); + + it("marks closed stages and hides archived pipelines", async () => { + const { fetch } = stub(() => + json({ + results: [ + { + id: "p", + label: "Sales", + displayOrder: 0, + stages: [ + { + id: "b", + label: "Won", + displayOrder: 1, + metadata: { isClosed: "true" }, + }, + { + id: "a", + label: "New", + displayOrder: 0, + metadata: { isClosed: "false" }, + }, + ], + }, + { + id: "old", + label: "Old", + displayOrder: 1, + archived: true, + stages: [], + }, + ], + }), + ); + const pipelines = await reader(fetch).pipelines(); + expect(pipelines).toHaveLength(1); + expect(pipelines[0]?.stages.map((s) => [s.id, s.closed])).toEqual([ + ["a", false], + ["b", true], + ]); + }); + + it("downloads only PDFs from HubSpot hosts and within the size limit", async () => { + const pdf = fakeHubSpotPdf("Order form"); + const signed = (url: string) => + stub((call) => + call.url.includes("/signed-url") + ? json({ url }) + : new Response(pdf, { status: 200 }), + ).fetch; + await expect( + reader( + signed("https://f.hubspotusercontent-na1.net/hubfs/1/a.pdf?sig=1"), + ).download("8001", 1_000_000), + ).resolves.toHaveLength(pdf.byteLength); + await expect( + reader(signed("https://attacker.example/a.pdf")).download( + "8001", + 1_000_000, + ), + ).rejects.toMatchObject({ code: "HUBSPOT_DOCUMENT_UNAVAILABLE" }); + await expect( + reader( + signed("https://f.hubspotusercontent-na1.net/hubfs/1/a.pdf"), + ).download("8001", 10), + ).rejects.toMatchObject({ code: "HUBSPOT_DOCUMENT_INVALID" }); + const notPdf = stub((call) => + call.url.includes("/signed-url") + ? json({ url: "https://f.hubspotusercontent-na1.net/a" }) + : new Response("", { status: 200 }), + ).fetch; + await expect(reader(notPdf).download("8001", 1000)).rejects.toMatchObject({ + code: "HUBSPOT_DOCUMENT_INVALID", + }); + }); + + it("never sends the access token to the file host", async () => { + const { fetch, calls } = stub((call) => + call.url.includes("/signed-url") + ? json({ url: "https://f.hubspotusercontent-na1.net/a.pdf" }) + : new Response(fakeHubSpotPdf("x"), { status: 200 }), + ); + await reader(fetch).download("8001", 1_000_000); + expect(calls[1]?.headers.authorization).toBeUndefined(); + }); + + it("describes files it cannot retain instead of hiding them", async () => { + const { fetch } = stub(() => + json({ id: "8002", name: "logo", extension: "png", size: 10 }), + ); + await expect(reader(fetch).file("8002", "6002")).resolves.toMatchObject({ + name: "logo.png", + availability: "unsupported_type", + noteId: "6002", + }); + const large = stub(() => + json({ id: "8003", name: "big", extension: "pdf", size: 30_000_000 }), + ).fetch; + await expect(reader(large).file("8003", "6001")).resolves.toMatchObject({ + availability: "too_large", + }); + await expect( + reader(stub(() => json({}, 404)).fetch).file("8004", "6001"), + ).resolves.toBeNull(); + }); +}); + +describe("hubspotNoteText", () => { + it("keeps plain text and line breaks and drops markup", () => { + expect( + hubspotNoteText( + '

Kickoff & pricing

Next:
send form

', + ), + ).toBe("Kickoff & pricing\nNext:\nsend form"); + expect(hubspotNoteText(null)).toBe(""); + }); +}); + +describe("FakeHubSpotReader", () => { + it("returns only requested extra properties", async () => { + const fake = new FakeHubSpotReader(); + const [company] = await fake.companies(["9001"], ["name", "industry"]); + expect(company?.properties).toEqual({ + industry: "Information Technology and Services", + }); + expect(company?.properties).not.toHaveProperty("tax_id"); + }); + + it("fails a download on request and then recovers", async () => { + const fake = new FakeHubSpotReader(); + fake.failDownload("8001"); + await expect(fake.download("8001", 1_000_000)).rejects.toBeInstanceOf( + HubSpotError, + ); + await expect(fake.download("8001", 1_000_000)).resolves.toBeInstanceOf( + Uint8Array, + ); + }); + + it("pages pipeline deals", async () => { + const fake = new FakeHubSpotReader(); + const first = await fake.pipelineDeals({ + pipelineId: "default", + stageIds: [], + properties: ["dealname"], + after: null, + limit: 4, + }); + expect(first.total).toBe(7); + expect(first.deals).toHaveLength(4); + const second = await fake.pipelineDeals({ + pipelineId: "default", + stageIds: [], + properties: ["dealname"], + after: first.next, + limit: 4, + }); + expect(second.deals).toHaveLength(3); + expect(second.next).toBeNull(); + }); +}); diff --git a/packages/integrations/src/hubspot/client.ts b/packages/integrations/src/hubspot/client.ts new file mode 100644 index 00000000..95f0f907 --- /dev/null +++ b/packages/integrations/src/hubspot/client.ts @@ -0,0 +1,861 @@ +import { + HubSpotIdSchema, + hubspotLimits, + type HubSpotAssociation, + type HubSpotCompany, + type HubSpotContact, + type HubSpotDeal, + type HubSpotFile, + type HubSpotFileAvailability, + type HubSpotNote, + type HubSpotObjectType, + type HubSpotOwner, + type HubSpotPipeline, + type HubSpotPortal, + type HubSpotPropertyDefinition, +} from "@clockwork/contracts"; +import { z } from "zod"; + +/** + * The HubSpot read boundary. It only reads: no CRM write scope is requested + * or used, and nothing here changes a HubSpot record. Callers decide which + * properties to ask for from the administrator-approved scope; the adapter + * never widens a request. + */ +export interface HubSpotReader { + portal(): Promise; + properties( + objectType: HubSpotObjectType, + ): Promise; + owners(): Promise; + pipelines(): Promise; + searchCompanies(input: { + query: string; + properties: readonly string[]; + limit: number; + }): Promise; + searchDeals(input: { + query: string; + properties: readonly string[]; + limit: number; + }): Promise; + companies( + ids: readonly string[], + properties: readonly string[], + ): Promise; + deals( + ids: readonly string[], + properties: readonly string[], + ): Promise; + contacts( + ids: readonly string[], + properties: readonly string[], + ): Promise; + /** Associated records with their labels, up to `hubspotLimits.associations`. */ + associations( + from: "company" | "deal", + id: string, + to: "company" | "deal" | "contact" | "note", + ): Promise; + /** Deal → company associations for many deals at once. */ + dealCompanies( + dealIds: readonly string[], + ): Promise>; + notes(ids: readonly string[]): Promise; + /** File metadata, or `null` when the file is missing or unreadable. */ + file(id: string, noteId: string): Promise; + /** A file's bytes, refused above `maxBytes`. */ + download(id: string, maxBytes: number): Promise; + /** One page of a pipeline's deals in the given stages. */ + pipelineDeals(input: { + pipelineId: string; + stageIds: readonly string[]; + properties: readonly string[]; + after: string | null; + limit: number; + }): Promise<{ deals: HubSpotDeal[]; next: string | null; total: number }>; +} + +/** + * `code` is a HubSpot error code from `hubspotErrorCodes`; pages word it. The + * provider's own message never reaches a reader. + */ +export class HubSpotError extends Error { + public constructor( + public readonly code: + | "HUBSPOT_NOT_CONFIGURED" + | "HUBSPOT_UNAVAILABLE" + | "HUBSPOT_RATE_LIMITED" + | "HUBSPOT_RECORD_NOT_FOUND" + | "HUBSPOT_SCOPE_DENIED" + | "HUBSPOT_DOCUMENT_UNAVAILABLE" + | "HUBSPOT_DOCUMENT_INVALID", + public readonly retryAfterMs?: number, + options?: ErrorOptions, + ) { + super(code, options); + this.name = "HubSpotError"; + } +} + +const API_ORIGIN = "https://api.hubapi.com"; +const TIMEOUT_MS = 15_000; +const MAX_JSON_BYTES = 4 * 1_048_576; +const BATCH = 100; +const PDF = new TextEncoder().encode("%PDF-"); + +/** Hosts HubSpot serves signed file downloads from. */ +const downloadHost = + /(?:^|\.)(?:hubspot\.com|hubspot\.net|hubapi\.com|hubspotusercontent(?:-[a-z0-9]+)?\.(?:com|net))$/; + +const nullableText = z + .union([z.string(), z.number(), z.boolean(), z.null()]) + .optional() + .transform((value) => + value === undefined || value === null || value === "" + ? null + : String(value), + ); +const ObjectSchema = z.object({ + id: z.union([z.string(), z.number()]).transform(String), + properties: z.record(z.string(), nullableText).default({}), + updatedAt: z.string().optional(), + archived: z.boolean().optional(), +}); +type ObjectRow = z.infer; +const PagingSchema = z + .object({ next: z.object({ after: z.string() }).optional() }) + .optional(); + +const prop = (row: ObjectRow, name: string) => row.properties[name] ?? null; + +function extra( + row: ObjectRow, + properties: readonly string[], + base: readonly string[], +): Record { + return Object.fromEntries( + properties + .filter((name) => !base.includes(name)) + .map((name) => [name, prop(row, name)]), + ); +} + +const companyBase = [ + "name", + "domain", + "website", + "country", + "hubspot_owner_id", + "hs_lastmodifieddate", +]; +const dealBase = [ + "dealname", + "amount", + "deal_currency_code", + "closedate", + "pipeline", + "dealstage", + "hubspot_owner_id", + "hs_lastmodifieddate", +]; + +function toCompany( + row: ObjectRow, + properties: readonly string[], +): HubSpotCompany { + return { + id: row.id, + name: prop(row, "name"), + domain: prop(row, "domain"), + website: prop(row, "website"), + country: prop(row, "country"), + ownerId: prop(row, "hubspot_owner_id"), + updatedAt: prop(row, "hs_lastmodifieddate") ?? row.updatedAt ?? null, + archived: row.archived ?? false, + properties: extra(row, properties, companyBase), + }; +} + +function toDeal(row: ObjectRow, properties: readonly string[]): HubSpotDeal { + return { + id: row.id, + name: prop(row, "dealname"), + pipelineId: prop(row, "pipeline"), + stageId: prop(row, "dealstage"), + amount: prop(row, "amount"), + currency: prop(row, "deal_currency_code"), + closeDate: prop(row, "closedate"), + ownerId: prop(row, "hubspot_owner_id"), + updatedAt: prop(row, "hs_lastmodifieddate") ?? row.updatedAt ?? null, + archived: row.archived ?? false, + properties: extra(row, properties, dealBase), + }; +} + +function toContact(row: ObjectRow): HubSpotContact { + return { + id: row.id, + firstName: prop(row, "firstname"), + lastName: prop(row, "lastname"), + email: prop(row, "email"), + jobTitle: prop(row, "jobtitle"), + updatedAt: prop(row, "lastmodifieddate") ?? row.updatedAt ?? null, + }; +} + +const entities: Readonly> = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + "#39": "'", + apos: "'", + nbsp: " ", +}; + +/** + * Note bodies are HTML. Commerce keeps plain text only: block ends become + * line breaks, every tag is dropped, a few entities are decoded, and the + * result is bounded. Nothing is rewritten beyond that. + */ +export function hubspotNoteText(html: string | null): string { + if (!html) return ""; + const text = html + .replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, "") + .replace(/<\s*br\s*\/?>/gi, "\n") + .replace(/<\s*\/\s*(p|div|li|h[1-6])\s*>/gi, "\n") + .replace(/<[^>]*>/g, "") + .replace(/&(#39|[a-z]+);/gi, (match, name: string) => + Object.hasOwn(entities, name.toLowerCase()) + ? (entities[name.toLowerCase()] ?? match) + : match, + ) + .split("\n") + .map((line) => + [...line] + .filter((c) => { + const code = c.charCodeAt(0); + return code >= 32 && code !== 127; + }) + .join("") + .replace(/\s+/g, " ") + .trim(), + ) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + return text.length > hubspotLimits.noteBodyCharacters + ? `${text.slice(0, hubspotLimits.noteBodyCharacters).trimEnd()}…` + : text; +} + +const AssociationRowSchema = z.object({ + toObjectId: z.union([z.string(), z.number()]).transform(String), + associationTypes: z + .array(z.object({ label: z.string().nullable().optional() })) + .default([]), +}); + +function association(row: z.infer) { + return { + id: row.toObjectId, + labels: [ + ...new Set( + row.associationTypes + .map((type) => type.label?.trim() ?? "") + .filter(Boolean), + ), + ], + } satisfies HubSpotAssociation; +} + +const objectPath: Readonly< + Record<"company" | "deal" | "contact" | "note", string> +> = { + company: "companies", + deal: "deals", + contact: "contacts", + note: "notes", +}; + +function fileAvailability(file: { + extension: string | null; + size: number | null; + archived: boolean; +}): HubSpotFileAvailability { + if (file.archived) return "unreadable"; + if ((file.extension ?? "").toLowerCase() !== "pdf") return "unsupported_type"; + if (file.size !== null && file.size > hubspotLimits.documentBytes) + return "too_large"; + return "available"; +} + +export interface HttpHubSpotReaderOptions { + /** A private-app access token with read scopes only. */ + accessToken: string; + fetch?: typeof fetch; + /** Tests only; production always talks to `https://api.hubapi.com`. */ + origin?: string; + timeoutMs?: number; + sleep?: (ms: number) => Promise; +} + +/** HubSpot's public API over HTTPS with a server-side private-app token. */ +export class HttpHubSpotReader implements HubSpotReader { + private readonly fetcher: typeof fetch; + private readonly origin: string; + private readonly timeoutMs: number; + private readonly sleep: (ms: number) => Promise; + + public constructor(private readonly options: HttpHubSpotReaderOptions) { + const token = options.accessToken.trim(); + if (!token || /[\r\n\s]/.test(token)) + throw new HubSpotError("HUBSPOT_NOT_CONFIGURED"); + this.fetcher = (options.fetch ?? fetch).bind(globalThis); + this.origin = options.origin ?? API_ORIGIN; + if (!/^https:\/\/[a-z0-9.-]+(?::\d+)?$/.test(this.origin)) + throw new HubSpotError("HUBSPOT_NOT_CONFIGURED"); + this.timeoutMs = options.timeoutMs ?? TIMEOUT_MS; + this.sleep = + options.sleep ?? + ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + } + + /** + * One request with a bounded retry: a throttled or failing call waits for + * HubSpot's Retry-After (at most two seconds) and tries once more. + */ + private async json( + method: "GET" | "POST", + path: string, + schema: z.ZodType, + body?: unknown, + ): Promise { + for (let attempt = 0; ; attempt += 1) { + const response = await this.send(method, path, body); + if (response.ok) return schema.parse(await this.readJson(response)); + void response.body?.cancel().catch(() => {}); + const retryAfterMs = Math.min( + Number(response.headers.get("retry-after") ?? "1") * 1000 || 1000, + 30_000, + ); + const transient = response.status === 429 || response.status >= 500; + if (transient && attempt === 0 && retryAfterMs <= 2000) { + await this.sleep(retryAfterMs); + continue; + } + if (response.status === 429) + throw new HubSpotError("HUBSPOT_RATE_LIMITED", retryAfterMs); + if (response.status === 404) + throw new HubSpotError("HUBSPOT_RECORD_NOT_FOUND"); + if (response.status === 401 || response.status === 403) + throw new HubSpotError("HUBSPOT_SCOPE_DENIED"); + throw new HubSpotError("HUBSPOT_UNAVAILABLE"); + } + } + + private async send( + method: "GET" | "POST", + path: string, + body: unknown, + ): Promise { + if (!path.startsWith("/") || path.includes("..")) + throw new HubSpotError("HUBSPOT_UNAVAILABLE"); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + return await this.fetcher(`${this.origin}${path}`, { + method, + redirect: "error", + signal: controller.signal, + headers: { + authorization: `Bearer ${this.options.accessToken.trim()}`, + accept: "application/json", + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + } catch (error) { + throw new HubSpotError("HUBSPOT_UNAVAILABLE", undefined, { + cause: error, + }); + } finally { + clearTimeout(timer); + } + } + + private async readJson(response: Response): Promise { + const bytes = await readBounded(response, MAX_JSON_BYTES); + try { + return JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch (error) { + throw new HubSpotError("HUBSPOT_UNAVAILABLE", undefined, { + cause: error, + }); + } + } + + async portal(): Promise { + const details = await this.json( + "GET", + "/account-info/v3/details", + z.object({ + portalId: z.union([z.string(), z.number()]).transform(String), + uiDomain: z.string().default("app.hubspot.com"), + timeZone: z.string().nullable().optional(), + companyCurrency: z.string().nullable().optional(), + }), + ); + return { + portalId: details.portalId, + uiDomain: details.uiDomain, + timeZone: details.timeZone ?? null, + companyCurrency: details.companyCurrency ?? null, + }; + } + + async properties( + objectType: HubSpotObjectType, + ): Promise { + const result = await this.json( + "GET", + `/crm/v3/properties/${objectPath[objectType]}`, + z.object({ + results: z.array( + z.object({ + name: z.string(), + label: z.string().default(""), + type: z.string().default("string"), + fieldType: z.string().default("text"), + hidden: z.boolean().optional(), + calculated: z.boolean().optional(), + hubspotDefined: z.boolean().optional(), + dataSensitivity: z.string().optional(), + sensitiveDataCategories: z.array(z.string()).optional(), + options: z + .array(z.object({ label: z.string(), value: z.string() })) + .default([]), + }), + ), + }), + ); + return result.results + .filter((property) => !property.hidden) + .map((property) => ({ + objectType, + name: property.name, + label: property.label || property.name, + type: property.type, + fieldType: property.fieldType, + options: property.options.slice(0, 200), + sensitive: + (property.dataSensitivity !== undefined && + property.dataSensitivity !== "non_sensitive") || + (property.sensitiveDataCategories?.length ?? 0) > 0, + hubspotDefined: property.hubspotDefined ?? false, + calculated: property.calculated ?? false, + })); + } + + async owners(): Promise { + const owners: HubSpotOwner[] = []; + // Departed owners stay readable as source context, so both lists load. + for (const archived of [false, true]) { + let after: string | null = null; + for (let page = 0; page < 10; page += 1) { + const query: string = `limit=100&archived=${archived}${after ? `&after=${encodeURIComponent(after)}` : ""}`; + const result = await this.json( + "GET", + `/crm/v3/owners?${query}`, + z.object({ + results: z.array( + z.object({ + id: z.union([z.string(), z.number()]).transform(String), + email: z.string().nullable().optional(), + firstName: z.string().nullable().optional(), + lastName: z.string().nullable().optional(), + }), + ), + paging: PagingSchema, + }), + ); + for (const owner of result.results) + owners.push({ + id: owner.id, + name: + [owner.firstName, owner.lastName] + .filter(Boolean) + .join(" ") + .trim() || + owner.email || + owner.id, + email: owner.email ?? null, + archived, + }); + after = result.paging?.next?.after ?? null; + if (!after) break; + } + } + return owners; + } + + async pipelines(): Promise { + const result = await this.json( + "GET", + "/crm/v3/pipelines/deals", + z.object({ + results: z.array( + z.object({ + id: z.string(), + label: z.string(), + displayOrder: z.number().default(0), + archived: z.boolean().optional(), + stages: z.array( + z.object({ + id: z.string(), + label: z.string(), + displayOrder: z.number().default(0), + archived: z.boolean().optional(), + metadata: z + .object({ + isClosed: z.union([z.string(), z.boolean()]).optional(), + }) + .passthrough() + .default({}), + }), + ), + }), + ), + }), + ); + return result.results + .filter((pipeline) => !pipeline.archived) + .sort((a, b) => a.displayOrder - b.displayOrder) + .map((pipeline) => ({ + id: pipeline.id, + label: pipeline.label, + displayOrder: pipeline.displayOrder, + stages: pipeline.stages + .filter((stage) => !stage.archived) + .sort((a, b) => a.displayOrder - b.displayOrder) + .map((stage) => ({ + id: stage.id, + label: stage.label, + displayOrder: stage.displayOrder, + closed: String(stage.metadata.isClosed) === "true", + })), + })); + } + + private async search( + objectType: "company" | "deal", + body: Record, + ) { + return this.json( + "POST", + `/crm/v3/objects/${objectPath[objectType]}/search`, + z.object({ + total: z.number().default(0), + results: z.array(ObjectSchema), + paging: PagingSchema, + }), + body, + ); + } + + async searchCompanies(input: { + query: string; + properties: readonly string[]; + limit: number; + }): Promise { + const result = await this.search("company", { + query: input.query.slice(0, 100), + limit: Math.min(input.limit, hubspotLimits.searchResults), + properties: input.properties, + }); + return result.results.map((row) => toCompany(row, input.properties)); + } + + async searchDeals(input: { + query: string; + properties: readonly string[]; + limit: number; + }): Promise { + const result = await this.search("deal", { + query: input.query.slice(0, 100), + limit: Math.min(input.limit, hubspotLimits.searchResults), + properties: input.properties, + }); + return result.results.map((row) => toDeal(row, input.properties)); + } + + private async batchRead( + objectType: "company" | "deal" | "contact" | "note", + ids: readonly string[], + properties: readonly string[], + ): Promise { + const valid = [...new Set(ids)].filter( + (id) => HubSpotIdSchema.safeParse(id).success, + ); + const rows: ObjectRow[] = []; + for (let index = 0; index < valid.length; index += BATCH) { + const result = await this.json( + "POST", + `/crm/v3/objects/${objectPath[objectType]}/batch/read`, + z.object({ results: z.array(ObjectSchema) }), + { + properties, + inputs: valid.slice(index, index + BATCH).map((id) => ({ id })), + }, + ); + rows.push(...result.results); + } + return rows; + } + + async companies(ids: readonly string[], properties: readonly string[]) { + return (await this.batchRead("company", ids, properties)).map((row) => + toCompany(row, properties), + ); + } + + async deals(ids: readonly string[], properties: readonly string[]) { + return (await this.batchRead("deal", ids, properties)).map((row) => + toDeal(row, properties), + ); + } + + async contacts(ids: readonly string[], properties: readonly string[]) { + return (await this.batchRead("contact", ids, properties)).map(toContact); + } + + async associations( + from: "company" | "deal", + id: string, + to: "company" | "deal" | "contact" | "note", + ): Promise { + HubSpotIdSchema.parse(id); + const result = await this.json( + "GET", + `/crm/v4/objects/${objectPath[from]}/${id}/associations/${objectPath[to]}?limit=${hubspotLimits.associations}`, + z.object({ results: z.array(AssociationRowSchema) }), + ); + return result.results.slice(0, hubspotLimits.associations).map(association); + } + + async dealCompanies( + dealIds: readonly string[], + ): Promise> { + const map = new Map(); + const ids = [...new Set(dealIds)]; + for (let index = 0; index < ids.length; index += BATCH) { + const result = await this.json( + "POST", + "/crm/v4/associations/deals/companies/batch/read", + z.object({ + results: z.array( + z.object({ + from: z.object({ + id: z.union([z.string(), z.number()]).transform(String), + }), + to: z.array(AssociationRowSchema).default([]), + }), + ), + }), + { inputs: ids.slice(index, index + BATCH).map((id) => ({ id })) }, + ); + for (const row of result.results) + map.set( + row.from.id, + row.to.slice(0, hubspotLimits.associations).map(association), + ); + } + return map; + } + + async notes(ids: readonly string[]): Promise { + const rows = await this.batchRead("note", ids, [ + "hs_note_body", + "hs_timestamp", + "hubspot_owner_id", + "hs_attachment_ids", + ]); + return rows.map((row) => ({ + id: row.id, + body: hubspotNoteText(prop(row, "hs_note_body")), + ownerId: prop(row, "hubspot_owner_id"), + timestamp: prop(row, "hs_timestamp"), + attachmentIds: (prop(row, "hs_attachment_ids") ?? "") + .split(";") + .map((value) => value.trim()) + .filter((value) => HubSpotIdSchema.safeParse(value).success) + .slice(0, 20), + })); + } + + async file(id: string, noteId: string): Promise { + HubSpotIdSchema.parse(id); + try { + const file = await this.json( + "GET", + `/files/v3/files/${id}`, + z.object({ + id: z.union([z.string(), z.number()]).transform(String), + name: z.string().default(""), + extension: z.string().nullable().optional(), + size: z.number().nullable().optional(), + updatedAt: z.string().nullable().optional(), + archived: z.boolean().optional(), + }), + ); + const extension = file.extension ?? null; + const size = file.size ?? null; + return { + id: file.id, + name: + [file.name, extension].filter(Boolean).join(".").slice(0, 255) || + `${file.id}.pdf`, + extension, + size, + updatedAt: file.updatedAt ?? null, + availability: fileAvailability({ + extension, + size, + archived: file.archived ?? false, + }), + noteId, + }; + } catch (error) { + if ( + error instanceof HubSpotError && + ["HUBSPOT_RECORD_NOT_FOUND", "HUBSPOT_SCOPE_DENIED"].includes( + error.code, + ) + ) + return null; + throw error; + } + } + + /** + * The bytes of a retained document. A short-lived signed URL is used only + * to fetch them now; it is never stored or shown in place of the document. + */ + async download(id: string, maxBytes: number): Promise { + HubSpotIdSchema.parse(id); + const signed = await this.json( + "GET", + `/files/v3/files/${id}/signed-url?expirationSeconds=300`, + z.object({ url: z.string() }), + ); + let url: URL; + try { + url = new URL(signed.url); + } catch { + throw new HubSpotError("HUBSPOT_DOCUMENT_UNAVAILABLE"); + } + if (url.protocol !== "https:" || !downloadHost.test(url.hostname)) + throw new HubSpotError("HUBSPOT_DOCUMENT_UNAVAILABLE"); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 60_000); + try { + // The signed URL carries its own authorization; the token stays here. + const response = await this.fetcher(url, { + redirect: "error", + signal: controller.signal, + }); + if (!response.ok) { + void response.body?.cancel().catch(() => {}); + throw new HubSpotError("HUBSPOT_DOCUMENT_UNAVAILABLE"); + } + const declared = Number(response.headers.get("content-length") ?? "0"); + if (declared > maxBytes) { + void response.body?.cancel().catch(() => {}); + throw new HubSpotError("HUBSPOT_DOCUMENT_INVALID"); + } + const bytes = await readBounded( + response, + maxBytes, + "HUBSPOT_DOCUMENT_INVALID", + ); + if (!startsWith(bytes, PDF)) + throw new HubSpotError("HUBSPOT_DOCUMENT_INVALID"); + return bytes; + } catch (error) { + if (error instanceof HubSpotError) throw error; + throw new HubSpotError("HUBSPOT_DOCUMENT_UNAVAILABLE", undefined, { + cause: error, + }); + } finally { + clearTimeout(timer); + } + } + + async pipelineDeals(input: { + pipelineId: string; + stageIds: readonly string[]; + properties: readonly string[]; + after: string | null; + limit: number; + }) { + const filters: Record[] = [ + { propertyName: "pipeline", operator: "EQ", value: input.pipelineId }, + ]; + if (input.stageIds.length) + filters.push({ + propertyName: "dealstage", + operator: "IN", + values: input.stageIds, + }); + const result = await this.search("deal", { + filterGroups: [{ filters }], + sorts: [{ propertyName: "hs_object_id", direction: "ASCENDING" }], + properties: input.properties, + limit: Math.min(input.limit, 100), + ...(input.after ? { after: input.after } : {}), + }); + return { + deals: result.results.map((row) => toDeal(row, input.properties)), + next: result.paging?.next?.after ?? null, + total: result.total, + }; + } +} + +function startsWith(bytes: Uint8Array, prefix: Uint8Array): boolean { + return prefix.every((byte, index) => bytes[index] === byte); +} + +async function readBounded( + response: Response, + maxBytes: number, + code: + "HUBSPOT_UNAVAILABLE" | "HUBSPOT_DOCUMENT_INVALID" = "HUBSPOT_UNAVAILABLE", +): Promise { + const reader = response.body?.getReader(); + if (!reader) return new Uint8Array(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > maxBytes) { + void reader.cancel().catch(() => {}); + throw new HubSpotError(code); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} diff --git a/packages/integrations/src/hubspot/fake.ts b/packages/integrations/src/hubspot/fake.ts new file mode 100644 index 00000000..c2a01ab8 --- /dev/null +++ b/packages/integrations/src/hubspot/fake.ts @@ -0,0 +1,883 @@ +import type { + HubSpotAssociation, + HubSpotCompany, + HubSpotContact, + HubSpotDeal, + HubSpotFile, + HubSpotNote, + HubSpotObjectType, + HubSpotOwner, + HubSpotPipeline, + HubSpotPortal, + HubSpotPropertyDefinition, +} from "@clockwork/contracts"; + +import { HubSpotError, type HubSpotReader } from "./client"; + +/** + * A fictional HubSpot portal for the guided demo and tests. Every company, + * person and deal is invented. It behaves like the HTTP reader: it returns + * only the properties asked for, resolves association labels, and can be told + * to fail a download or change a record so retries and stale reviews can be + * exercised. + */ +interface FakePortal { + portal: HubSpotPortal; + owners: HubSpotOwner[]; + pipelines: HubSpotPipeline[]; + properties: HubSpotPropertyDefinition[]; + companies: Map>; + deals: Map>; + contacts: Map; + notes: Map; + files: Map< + string, + Omit & { bytes: Uint8Array | null } + >; + /** `${from}:${id}:${to}` → associations. */ + associations: Map; +} + +/** A minimal, valid single-page PDF so retention checks pass on real bytes. */ +export function fakeHubSpotPdf(title: string): Uint8Array { + const text = title.replace(/[()\\]/g, ""); + const stream = `BT /F1 18 Tf 72 720 Td (${text}) Tj ET`; + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", + `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ]; + let body = "%PDF-1.4\n"; + const offsets: number[] = []; + objects.forEach((object, index) => { + offsets.push(body.length); + body += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + const xref = body.length; + body += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + for (const offset of offsets) + body += `${String(offset).padStart(10, "0")} 00000 n \n`; + body += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return new TextEncoder().encode(body); +} + +const property = ( + objectType: HubSpotObjectType, + name: string, + label: string, + extra: Partial = {}, +): HubSpotPropertyDefinition => ({ + objectType, + name, + label, + type: "string", + fieldType: "text", + options: [], + sensitive: false, + hubspotDefined: true, + calculated: false, + ...extra, +}); + +/** Instants relative to `now`, so the demo always reads as current. */ +function relative(now: Date) { + return (days: number) => + new Date(now.getTime() + days * 86_400_000).toISOString(); +} + +function fixture(now: Date): FakePortal { + const at = relative(now); + const day = (days: number) => at(days).slice(0, 10) + "T00:00:00.000Z"; + const companies = new Map>([ + [ + "9001", + { + name: "Northstar Systems", + domain: "northstar.example", + website: null, + country: "United States", + hubspot_owner_id: "101", + hs_lastmodifieddate: at(-2), + industry: "Information Technology and Services", + partner_tier: "gold", + tax_id: "00-0000000", + }, + ], + [ + "9002", + { + name: "Halcyon Research Cooperative", + domain: "halcyon.example", + website: "https://www.halcyon.example", + country: "Canada", + hubspot_owner_id: "102", + hs_lastmodifieddate: at(-6), + industry: "Research", + partner_tier: null, + tax_id: null, + }, + ], + [ + "9003", + { + name: "Larchmont Data Services", + domain: "larchmont.example", + website: "https://larchmont.example", + country: "Netherlands", + hubspot_owner_id: "101", + hs_lastmodifieddate: at(-1), + industry: "Information Technology and Services", + partner_tier: "silver", + tax_id: null, + }, + ], + [ + "9004", + { + name: "Brightwater Archives", + domain: "brightwater.example", + website: null, + country: "United Kingdom", + hubspot_owner_id: "103", + hs_lastmodifieddate: at(-12), + industry: "Libraries", + partner_tier: null, + tax_id: null, + }, + ], + [ + "9005", + { + name: "Meridian Cloud GmbH", + domain: "meridian.example", + website: "https://meridian.example", + country: "Germany", + hubspot_owner_id: "102", + hs_lastmodifieddate: at(-3), + industry: "Computer Software", + partner_tier: "platinum", + tax_id: null, + }, + ], + [ + "9006", + { + name: "Northstar Systems Europe", + domain: "northstar-eu.example", + website: null, + country: "Ireland", + hubspot_owner_id: "101", + hs_lastmodifieddate: at(-20), + industry: null, + partner_tier: null, + tax_id: null, + }, + ], + ]); + const deal = ( + name: string, + stage: string, + amount: string | null, + currency: string | null, + closeIn: number, + owner: string | null, + pipeline = "default", + ) => ({ + dealname: name, + pipeline, + dealstage: stage, + amount, + deal_currency_code: currency, + closedate: day(closeIn), + hubspot_owner_id: owner, + hs_lastmodifieddate: at(-1), + deal_source: "Partner referral", + }); + const deals = new Map>([ + [ + "7001", + deal( + "Halcyon archive tier via Northstar", + "closedwon", + "48000", + "USD", + -8, + "101", + ), + ], + [ + "7002", + deal( + "Meridian cold storage expansion", + "contractsent", + "125000", + "EUR", + 21, + "102", + ), + ], + [ + "7003", + deal( + "Brightwater digitization pilot", + "qualifiedtobuy", + null, + "GBP", + 45, + "103", + ), + ], + [ + "7004", + deal( + "Larchmont referral: municipal records", + "presentationscheduled", + "30000", + "USD", + 30, + "101", + ), + ], + [ + "7005", + deal( + "Inbound research data enquiry", + "appointmentscheduled", + "15000", + "USD", + 60, + null, + ), + ], + [ + "7006", + deal( + "Northstar EU distribution", + "appointmentscheduled", + "60000", + "EUR", + 75, + "101", + ), + ], + [ + "7007", + deal("Halcyon second site", "closedlost", "22000", "USD", -30, "102"), + ], + [ + "7008", + deal( + "Meridian renewal", + "renewal_open", + "90000", + "EUR", + 120, + "102", + "renewals", + ), + ], + ]); + const contacts = new Map( + ( + [ + [ + "5001", + "Ada", + "Okafor", + "ada.okafor@northstar.example", + "VP Partnerships", + ], + [ + "5002", + "Marcus", + "Hale", + "marcus.hale@northstar.example", + "Solutions Architect", + ], + [ + "5003", + "Ines", + "Albright", + "ines.albright@halcyon.example", + "Director of Research Computing", + ], + [ + "5004", + "Joris", + "Bakker", + "joris@larchmont.example", + "Managing Partner", + ], + ] as const + ).map(([id, firstName, lastName, email, jobTitle]) => [ + id, + { id, firstName, lastName, email, jobTitle, updatedAt: at(-4) }, + ]), + ); + const notes = new Map([ + [ + "6001", + { + id: "6001", + body: "Kickoff call: Northstar will resell the archive tier to Halcyon. Order form attached; Halcyon describes it as signed.", + ownerId: "101", + timestamp: at(-9), + attachmentIds: ["8001"], + }, + ], + [ + "6002", + { + id: "6002", + body: "Asked about a not-for-resale allowance for their lab.\nLogo for the co-marketing page attached.", + ownerId: null, + timestamp: at(-15), + attachmentIds: ["8002"], + }, + ], + ]); + const files = new Map< + string, + Omit & { bytes: Uint8Array | null } + >([ + [ + "8001", + { + id: "8001", + name: "Halcyon order form (signed).pdf", + extension: "pdf", + size: null, + updatedAt: at(-9), + availability: "available", + bytes: fakeHubSpotPdf("Halcyon order form"), + }, + ], + [ + "8002", + { + id: "8002", + name: "northstar-logo.png", + extension: "png", + size: 48_211, + updatedAt: at(-15), + availability: "unsupported_type", + bytes: null, + }, + ], + ]); + for (const file of files.values()) + if (file.bytes) file.size = file.bytes.byteLength; + const associations = new Map([ + [ + "deal:7001:company", + [ + { id: "9001", labels: ["Reseller"] }, + { id: "9002", labels: ["Primary", "End customer"] }, + ], + ], + ["deal:7002:company", [{ id: "9005", labels: ["Primary"] }]], + ["deal:7003:company", [{ id: "9004", labels: ["Primary"] }]], + [ + "deal:7004:company", + [{ id: "9003", labels: ["Primary", "Referral partner"] }], + ], + ["deal:7005:company", []], + [ + "deal:7006:company", + [ + { id: "9006", labels: ["Primary"] }, + { id: "9001", labels: ["Parent company"] }, + ], + ], + ["deal:7007:company", [{ id: "9002", labels: ["Primary"] }]], + ["deal:7008:company", [{ id: "9005", labels: ["Primary"] }]], + [ + "deal:7001:contact", + [ + { id: "5001", labels: ["Decision maker"] }, + { id: "5003", labels: ["Budget holder"] }, + ], + ], + ["deal:7001:note", [{ id: "6001", labels: [] }]], + [ + "company:9001:contact", + [ + { id: "5001", labels: ["Decision maker"] }, + { id: "5002", labels: [] }, + ], + ], + ["company:9002:contact", [{ id: "5003", labels: [] }]], + ["company:9003:contact", [{ id: "5004", labels: ["Primary"] }]], + [ + "company:9001:note", + [ + { id: "6001", labels: [] }, + { id: "6002", labels: [] }, + ], + ], + [ + "company:9001:deal", + [ + { id: "7001", labels: [] }, + { id: "7006", labels: [] }, + ], + ], + [ + "company:9002:deal", + [ + { id: "7001", labels: [] }, + { id: "7007", labels: [] }, + ], + ], + ["company:9003:deal", [{ id: "7004", labels: [] }]], + ["company:9004:deal", [{ id: "7003", labels: [] }]], + [ + "company:9005:deal", + [ + { id: "7002", labels: [] }, + { id: "7008", labels: [] }, + ], + ], + ["company:9006:deal", [{ id: "7006", labels: [] }]], + ]); + return { + portal: { + portalId: "4410", + uiDomain: "app.hubspot.com", + timeZone: "America/New_York", + companyCurrency: "USD", + }, + owners: [ + { + id: "101", + name: "Priya Raman", + email: "priya.raman@filone.example", + archived: false, + }, + { + id: "102", + name: "Tomás Ferreira", + email: "tomas.ferreira@filone.example", + archived: false, + }, + { + id: "103", + name: "Lena Vogt", + email: "lena.vogt@filone.example", + archived: true, + }, + ], + pipelines: [ + { + id: "default", + label: "Partner pipeline", + displayOrder: 0, + stages: [ + { + id: "appointmentscheduled", + label: "Discovery", + displayOrder: 0, + closed: false, + }, + { + id: "qualifiedtobuy", + label: "Qualified", + displayOrder: 1, + closed: false, + }, + { + id: "presentationscheduled", + label: "Proposal", + displayOrder: 2, + closed: false, + }, + { + id: "contractsent", + label: "Contract sent", + displayOrder: 3, + closed: false, + }, + { + id: "closedwon", + label: "Closed won", + displayOrder: 4, + closed: true, + }, + { + id: "closedlost", + label: "Closed lost", + displayOrder: 5, + closed: true, + }, + ], + }, + { + id: "renewals", + label: "Renewals", + displayOrder: 1, + stages: [ + { + id: "renewal_open", + label: "Renewal open", + displayOrder: 0, + closed: false, + }, + { + id: "renewal_done", + label: "Renewed", + displayOrder: 1, + closed: true, + }, + ], + }, + ], + properties: [ + property("company", "name", "Company name"), + property("company", "domain", "Company domain name"), + property("company", "website", "Website URL"), + property("company", "country", "Country/Region"), + property("company", "hubspot_owner_id", "Company owner"), + property("company", "hs_lastmodifieddate", "Last modified date", { + type: "datetime", + }), + property("company", "industry", "Industry", { type: "enumeration" }), + property("company", "partner_tier", "Partner tier", { + type: "enumeration", + fieldType: "select", + hubspotDefined: false, + options: [ + { label: "Silver", value: "silver" }, + { label: "Gold", value: "gold" }, + { label: "Platinum", value: "platinum" }, + ], + }), + property("company", "tax_id", "Tax ID", { + hubspotDefined: false, + sensitive: true, + }), + property("deal", "dealname", "Deal name"), + property("deal", "amount", "Amount", { type: "number" }), + property("deal", "deal_currency_code", "Currency"), + property("deal", "closedate", "Close date", { type: "datetime" }), + property("deal", "pipeline", "Pipeline"), + property("deal", "dealstage", "Deal stage"), + property("deal", "hubspot_owner_id", "Deal owner"), + property("deal", "hs_lastmodifieddate", "Last modified date", { + type: "datetime", + }), + property("deal", "deal_source", "Deal source", { hubspotDefined: false }), + property("contact", "firstname", "First name"), + property("contact", "lastname", "Last name"), + property("contact", "email", "Email"), + property("contact", "jobtitle", "Job title"), + property("contact", "lastmodifieddate", "Last modified date", { + type: "datetime", + }), + ], + companies, + deals, + contacts, + notes, + files, + associations, + }; +} + +const pick = ( + values: Record, + properties: readonly string[], +) => + Object.fromEntries( + properties + .filter((name) => Object.hasOwn(values, name)) + .map((name) => [name, values[name] ?? null]), + ); + +const companyBase = new Set([ + "name", + "domain", + "website", + "country", + "hubspot_owner_id", + "hs_lastmodifieddate", +]); +const dealBase = new Set([ + "dealname", + "amount", + "deal_currency_code", + "closedate", + "pipeline", + "dealstage", + "hubspot_owner_id", + "hs_lastmodifieddate", +]); + +export class FakeHubSpotReader implements HubSpotReader { + private readonly data: FakePortal; + private failures = new Map(); + /** Every request, by method, so tests can assert what was read. */ + public readonly calls: string[] = []; + + public constructor(now: Date = new Date("2026-10-22T15:00:00.000Z")) { + this.data = fixture(now); + } + + /** The next `times` downloads of a file fail as unavailable. */ + public failDownload(fileId: string, times = 1): void { + this.failures.set(fileId, times); + } + + /** Changes a company property, as a HubSpot user would. */ + public updateCompany(id: string, values: Record) { + const company = this.data.companies.get(id); + if (!company) throw new Error("FAKE_COMPANY_MISSING"); + Object.assign(company, values, { + hs_lastmodifieddate: new Date().toISOString(), + }); + } + + public archiveCompany(id: string) { + this.updateCompany(id, { __archived: "true" }); + } + + portal() { + this.calls.push("portal"); + return Promise.resolve({ ...this.data.portal }); + } + + properties(objectType: HubSpotObjectType) { + this.calls.push(`properties:${objectType}`); + return Promise.resolve( + this.data.properties.filter((p) => p.objectType === objectType), + ); + } + + owners() { + this.calls.push("owners"); + return Promise.resolve([...this.data.owners]); + } + + pipelines() { + this.calls.push("pipelines"); + return Promise.resolve(this.data.pipelines); + } + + private company( + id: string, + properties: readonly string[], + ): HubSpotCompany | null { + const values = this.data.companies.get(id); + if (!values) return null; + const picked = pick(values, properties); + return { + id, + name: values.name ?? null, + domain: values.domain ?? null, + website: values.website ?? null, + country: values.country ?? null, + ownerId: values.hubspot_owner_id ?? null, + updatedAt: values.hs_lastmodifieddate ?? null, + archived: values.__archived === "true", + properties: Object.fromEntries( + Object.entries(picked).filter(([name]) => !companyBase.has(name)), + ), + }; + } + + private deal(id: string, properties: readonly string[]): HubSpotDeal | null { + const values = this.data.deals.get(id); + if (!values) return null; + const picked = pick(values, properties); + return { + id, + name: values.dealname ?? null, + pipelineId: values.pipeline ?? null, + stageId: values.dealstage ?? null, + amount: values.amount ?? null, + currency: values.deal_currency_code ?? null, + closeDate: values.closedate ?? null, + ownerId: values.hubspot_owner_id ?? null, + updatedAt: values.hs_lastmodifieddate ?? null, + archived: false, + properties: Object.fromEntries( + Object.entries(picked).filter(([name]) => !dealBase.has(name)), + ), + }; + } + + searchCompanies(input: { + query: string; + properties: readonly string[]; + limit: number; + }) { + this.calls.push(`searchCompanies:${input.query}`); + const query = input.query.trim().toLowerCase(); + return Promise.resolve( + [...this.data.companies.entries()] + .filter( + ([, values]) => + values.__archived !== "true" && + `${values.name ?? ""} ${values.domain ?? ""}` + .toLowerCase() + .includes(query), + ) + .slice(0, input.limit) + .flatMap(([id]) => { + const company = this.company(id, input.properties); + return company ? [company] : []; + }), + ); + } + + searchDeals(input: { + query: string; + properties: readonly string[]; + limit: number; + }) { + this.calls.push(`searchDeals:${input.query}`); + const query = input.query.trim().toLowerCase(); + return Promise.resolve( + [...this.data.deals.entries()] + .filter(([, values]) => + (values.dealname ?? "").toLowerCase().includes(query), + ) + .slice(0, input.limit) + .flatMap(([id]) => { + const deal = this.deal(id, input.properties); + return deal ? [deal] : []; + }), + ); + } + + companies(ids: readonly string[], properties: readonly string[]) { + this.calls.push(`companies:${ids.join(",")}`); + return Promise.resolve( + ids.flatMap((id) => { + const company = this.company(id, properties); + return company ? [company] : []; + }), + ); + } + + deals(ids: readonly string[], properties: readonly string[]) { + this.calls.push(`deals:${ids.join(",")}`); + return Promise.resolve( + ids.flatMap((id) => { + const deal = this.deal(id, properties); + return deal ? [deal] : []; + }), + ); + } + + contacts(ids: readonly string[]) { + this.calls.push(`contacts:${ids.join(",")}`); + return Promise.resolve( + ids.flatMap((id) => { + const contact = this.data.contacts.get(id); + return contact ? [{ ...contact }] : []; + }), + ); + } + + associations( + from: "company" | "deal", + id: string, + to: "company" | "deal" | "contact" | "note", + ) { + this.calls.push(`associations:${from}:${id}:${to}`); + if (from === "company" && !this.data.companies.has(id)) + return Promise.reject(new HubSpotError("HUBSPOT_RECORD_NOT_FOUND")); + if (from === "deal" && !this.data.deals.has(id)) + return Promise.reject(new HubSpotError("HUBSPOT_RECORD_NOT_FOUND")); + return Promise.resolve([ + ...(this.data.associations.get(`${from}:${id}:${to}`) ?? []), + ]); + } + + dealCompanies(dealIds: readonly string[]) { + this.calls.push(`dealCompanies:${dealIds.length}`); + return Promise.resolve( + new Map( + dealIds.map((id) => [ + id, + [...(this.data.associations.get(`deal:${id}:company`) ?? [])], + ]), + ), + ); + } + + notes(ids: readonly string[]) { + this.calls.push(`notes:${ids.join(",")}`); + return Promise.resolve( + ids.flatMap((id) => { + const note = this.data.notes.get(id); + return note ? [{ ...note }] : []; + }), + ); + } + + file(id: string, noteId: string) { + this.calls.push(`file:${id}`); + const file = this.data.files.get(id); + if (!file) return Promise.resolve(null); + return Promise.resolve({ + id: file.id, + name: file.name, + extension: file.extension, + size: file.size, + updatedAt: file.updatedAt, + availability: file.availability, + noteId, + }); + } + + download(id: string, maxBytes: number) { + this.calls.push(`download:${id}`); + const remaining = this.failures.get(id) ?? 0; + if (remaining > 0) { + this.failures.set(id, remaining - 1); + return Promise.reject(new HubSpotError("HUBSPOT_DOCUMENT_UNAVAILABLE")); + } + const file = this.data.files.get(id); + if (!file?.bytes) + return Promise.reject(new HubSpotError("HUBSPOT_DOCUMENT_UNAVAILABLE")); + if (file.bytes.byteLength > maxBytes) + return Promise.reject(new HubSpotError("HUBSPOT_DOCUMENT_INVALID")); + return Promise.resolve(new Uint8Array(file.bytes)); + } + + pipelineDeals(input: { + pipelineId: string; + stageIds: readonly string[]; + properties: readonly string[]; + after: string | null; + limit: number; + }) { + this.calls.push(`pipelineDeals:${input.pipelineId}:${input.after ?? ""}`); + const matching = [...this.data.deals.entries()] + .filter( + ([, values]) => + values.pipeline === input.pipelineId && + (input.stageIds.length === 0 || + input.stageIds.includes(values.dealstage ?? "")), + ) + .map(([id]) => id) + .sort(); + const start = input.after ? Number(input.after) : 0; + const page = matching.slice(start, start + input.limit); + const next = + start + input.limit < matching.length + ? String(start + input.limit) + : null; + return Promise.resolve({ + deals: page.flatMap((id) => { + const deal = this.deal(id, input.properties); + return deal ? [deal] : []; + }), + next, + total: matching.length, + }); + } +} diff --git a/packages/integrations/src/hubspot/index.ts b/packages/integrations/src/hubspot/index.ts new file mode 100644 index 00000000..36cad389 --- /dev/null +++ b/packages/integrations/src/hubspot/index.ts @@ -0,0 +1,2 @@ +export * from "./client"; +export * from "./fake"; diff --git a/supabase/migrations/001483_hubspot_handoff.sql b/supabase/migrations/001483_hubspot_handoff.sql new file mode 100644 index 00000000..71f2befb --- /dev/null +++ b/supabase/migrations/001483_hubspot_handoff.sql @@ -0,0 +1,272 @@ +-- Bringing HubSpot companies and deals into Commerce +-- (docs/operations/hubspot-handoff.md). +-- +-- HubSpot stays authoritative for CRM activity and pipeline; nothing here is a +-- copy of the CRM. These tables record only what Commerce owns: +-- +-- * the administrator's connection: which portal, the approved audience and +-- data scope, owner mappings. The access token is a deployment secret and +-- never reaches the database; +-- * typed links from a HubSpot company to the Commerce record it became; +-- * each reviewed import: the plan a person approved, an immutable snapshot +-- of the selected source values, and per-document results. +-- +-- Live CRM reads are not stored. Pipeline and record views read HubSpot +-- through the approved scope each time (with a short in-process cache keyed by +-- the connection version), so disconnecting removes live access at once. +-- +-- Staff reach these tables through server code on the service role, which +-- checks `sales:read`, `contract:write` or `staff:manage` first. Every change +-- writes a `hubspot.*` or `partner.*` audit event in the same transaction. +-- Rows are never deleted. +set lock_timeout = '5s'; + +create table if not exists public.commerce_hubspot_connections ( + id uuid primary key, + portal_id text not null check (portal_id ~ '^[0-9]{1,20}$'), + ui_domain text not null check (ui_domain ~ '^app(-[a-z0-9]{2,10})?\.hubspot\.com$'), + status text not null check (status in ('active','disconnected')), + -- The audience the administrator approved. It is named, not inferred from + -- HubSpot user permissions, which a shared connection cannot reproduce. + audience text not null check (audience = 'staff_sales_read'), + approved_by_id uuid not null, + approved_by_name text not null check (length(btrim(approved_by_name)) between 1 and 200), + approved_at timestamptz not null, + scope jsonb not null + check (jsonb_typeof(scope) = 'object' + and jsonb_typeof(scope->'company') = 'array' + and jsonb_typeof(scope->'deal') = 'array' + and jsonb_typeof(scope->'contact') = 'array' + and jsonb_typeof(scope->'notes') = 'boolean' + and jsonb_typeof(scope->'files') = 'boolean'), + default_pipeline_id text check (default_pipeline_id ~ '^[A-Za-z0-9_-]{1,100}$'), + owner_mappings jsonb not null default '[]' + check (jsonb_typeof(owner_mappings) = 'array' and jsonb_array_length(owner_mappings) <= 500), + disconnected_by_name text, + disconnected_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + version integer not null default 1 check (version >= 1), + constraint commerce_hubspot_connections_disconnect_check + check ((status = 'disconnected') = (disconnected_at is not null)) +); + +comment on table public.commerce_hubspot_connections is + 'The approved HubSpot portal, audience and read scope. At most one is active; disconnecting keeps the row. No credential is stored here.'; + +create unique index if not exists commerce_hubspot_connections_one_active + on public.commerce_hubspot_connections ((true)) where status = 'active'; + +create table if not exists public.commerce_external_links ( + id uuid primary key, + provider text not null check (provider = 'hubspot'), + portal_id text not null check (portal_id ~ '^[0-9]{1,20}$'), + object_type text not null check (object_type = 'company'), + object_id text not null check (object_id ~ '^[0-9]{1,20}$'), + destination_type text not null check (destination_type in ('partner','account')), + partner_id uuid references public.commerce_partners(id), + account_id uuid references public.accounts(id), + -- The company's name when it was linked, so the link reads after disconnect. + company_name text not null check (length(btrim(company_name)) between 1 and 200), + created_by_id uuid not null, + created_by_name text not null check (length(btrim(created_by_name)) between 1 and 200), + created_at timestamptz not null default now(), + constraint commerce_external_links_target_check check ( + num_nonnulls(partner_id, account_id) = 1 + and ((destination_type = 'partner') = (partner_id is not null))) +); + +comment on table public.commerce_external_links is + 'Typed identity links from a HubSpot company to the Commerce record it became. A link copies nothing; imports do. Append-only.'; + +-- A HubSpot company becomes at most one partner and at most one account, so +-- two people bringing in the same company at once cannot create two partners: +-- the loser resolves to the winner's record. +create unique index if not exists commerce_external_links_source + on public.commerce_external_links (provider, portal_id, object_type, object_id, destination_type); + +-- A partner is linked to at most one company per portal. +create unique index if not exists commerce_external_links_partner + on public.commerce_external_links (provider, portal_id, partner_id) where partner_id is not null; + +create index if not exists commerce_external_links_partner_lookup + on public.commerce_external_links (partner_id) where partner_id is not null; + +create table if not exists public.commerce_hubspot_imports ( + -- The operation ID the review carried: one handoff however often it is sent. + id uuid primary key, + link_id uuid not null references public.commerce_external_links(id), + partner_id uuid not null references public.commerce_partners(id), + connection_id uuid not null references public.commerce_hubspot_connections(id), + portal_id text not null check (portal_id ~ '^[0-9]{1,20}$'), + company_id text not null check (company_id ~ '^[0-9]{1,20}$'), + deal_id text check (deal_id ~ '^[0-9]{1,20}$'), + created_partner boolean not null, + status text not null check (status in ('complete','needs_attention','completed_with_exclusions')), + -- The reviewed plan and the selected source values, both immutable. + plan jsonb not null check (jsonb_typeof(plan) = 'object'), + snapshot jsonb not null check (jsonb_typeof(snapshot) = 'object'), + -- What was sent, so a retry is recognised and a different request refused. + request_digest text not null check (request_digest ~ '^[a-f0-9]{64}$'), + created_by_id uuid not null, + created_by_name text not null check (length(btrim(created_by_name)) between 1 and 200), + created_at timestamptz not null default now(), + completed_at timestamptz, + updated_at timestamptz not null default now(), + version integer not null default 1 check (version >= 1), + constraint commerce_hubspot_imports_completed_check + check ((status = 'needs_attention') = (completed_at is null)) +); + +comment on table public.commerce_hubspot_imports is + 'Each reviewed HubSpot import: the plan approved, the selected source snapshot and its completion. Plans and snapshots never change.'; + +create index if not exists commerce_hubspot_imports_partner + on public.commerce_hubspot_imports (partner_id, created_at desc, id desc); + +create table if not exists public.commerce_hubspot_import_items ( + id uuid primary key, + import_id uuid not null references public.commerce_hubspot_imports(id), + file_id text not null check (file_id ~ '^[0-9]{1,20}$'), + note_id text not null check (note_id ~ '^[0-9]{1,20}$'), + file_name text not null check (length(file_name) between 1 and 255), + title text not null check (length(btrim(title)) between 1 and 200), + document_kind text not null check (document_kind in ('mnda','agreement','other')), + -- The file version reviewed; a different file later needs a fresh review. + file_updated_at text, + file_size bigint check (file_size >= 0), + status text not null default 'pending' + check (status in ('pending','attached','failed','duplicate','reused','excluded')), + error_code text check (error_code ~ '^[A-Z_]{1,80}$'), + attachment_id uuid references public.commerce_account_documents(id), + existing_attachment_id uuid references public.commerce_account_documents(id), + excluded_reason text check (length(excluded_reason) <= 500), + resolved_by_name text, + attempts integer not null default 0 check (attempts >= 0), + updated_at timestamptz not null default now(), + version integer not null default 1 check (version >= 1), + constraint commerce_hubspot_import_items_file unique (import_id, file_id), + constraint commerce_hubspot_import_items_attached_check + check ((status in ('attached','reused')) = (attachment_id is not null)), + constraint commerce_hubspot_import_items_excluded_check + check ((status = 'excluded') = (excluded_reason is not null)) +); + +comment on table public.commerce_hubspot_import_items is + 'One selected document per row, retried and resolved on its own. Attached, reused and excluded are final.'; + +-- Plans, snapshots and identities never change; the version advances on every +-- update; a resolved document stays resolved. +create or replace function public.protect_commerce_hubspot_row() returns trigger +language plpgsql set search_path = public as $$ +begin + if new.version <> old.version + 1 then + raise exception using errcode = 'P0001', message = 'HUBSPOT_VERSION_MUST_ADVANCE'; + end if; + if tg_table_name = 'commerce_hubspot_connections' then + if new.id <> old.id or new.portal_id <> old.portal_id + or new.created_at <> old.created_at then + raise exception using errcode = 'P0001', message = 'HUBSPOT_CONNECTION_IMMUTABLE'; + end if; + if old.status = 'disconnected' then + raise exception using errcode = 'P0001', message = 'HUBSPOT_CONNECTION_DISCONNECTED'; + end if; + elsif tg_table_name = 'commerce_hubspot_imports' then + if to_jsonb(new) - array['status','completed_at','updated_at','version'] + <> to_jsonb(old) - array['status','completed_at','updated_at','version'] then + raise exception using errcode = 'P0001', message = 'HUBSPOT_IMPORT_IMMUTABLE'; + end if; + elsif tg_table_name = 'commerce_hubspot_import_items' then + if to_jsonb(new) - array['status','error_code','attachment_id','existing_attachment_id', + 'excluded_reason','resolved_by_name','attempts','updated_at','version'] + <> to_jsonb(old) - array['status','error_code','attachment_id','existing_attachment_id', + 'excluded_reason','resolved_by_name','attempts','updated_at','version'] then + raise exception using errcode = 'P0001', message = 'HUBSPOT_ITEM_IMMUTABLE'; + end if; + if old.status in ('attached','reused','excluded') then + raise exception using errcode = 'P0001', message = 'HUBSPOT_ITEM_RESOLVED'; + end if; + end if; + return new; +end $$; + +drop trigger if exists protect_commerce_hubspot_connection on public.commerce_hubspot_connections; + +create trigger protect_commerce_hubspot_connection before update on public.commerce_hubspot_connections + for each row execute function public.protect_commerce_hubspot_row(); + +drop trigger if exists protect_commerce_hubspot_import on public.commerce_hubspot_imports; + +create trigger protect_commerce_hubspot_import before update on public.commerce_hubspot_imports + for each row execute function public.protect_commerce_hubspot_row(); + +drop trigger if exists protect_commerce_hubspot_import_item on public.commerce_hubspot_import_items; + +create trigger protect_commerce_hubspot_import_item before update on public.commerce_hubspot_import_items + for each row execute function public.protect_commerce_hubspot_row(); + +alter table public.commerce_hubspot_connections enable row level security; + +alter table public.commerce_hubspot_connections force row level security; + +alter table public.commerce_external_links enable row level security; + +alter table public.commerce_external_links force row level security; + +alter table public.commerce_hubspot_imports enable row level security; + +alter table public.commerce_hubspot_imports force row level security; + +alter table public.commerce_hubspot_import_items enable row level security; + +alter table public.commerce_hubspot_import_items force row level security; + +revoke all on public.commerce_hubspot_connections + from public, anon, authenticated, clockwork_runtime, clockwork_service; + +revoke all on public.commerce_external_links + from public, anon, authenticated, clockwork_runtime, clockwork_service; + +revoke all on public.commerce_hubspot_imports + from public, anon, authenticated, clockwork_runtime, clockwork_service; + +revoke all on public.commerce_hubspot_import_items + from public, anon, authenticated, clockwork_runtime, clockwork_service; + +revoke all on function public.protect_commerce_hubspot_row() from public; + +grant select, insert, update on public.commerce_hubspot_connections to clockwork_service; + +-- Links are append-only: relinking is a new reviewed decision, never an edit. +grant select, insert on public.commerce_external_links to clockwork_service; + +grant select, insert, update on public.commerce_hubspot_imports to clockwork_service; + +grant select, insert, update on public.commerce_hubspot_import_items to clockwork_service; + +drop policy if exists hubspot_connections_service on public.commerce_hubspot_connections; + +create policy hubspot_connections_service on public.commerce_hubspot_connections + for all to clockwork_service using (true) with check (true); + +drop policy if exists external_links_read on public.commerce_external_links; + +create policy external_links_read on public.commerce_external_links + for select to clockwork_service using (true); + +drop policy if exists external_links_insert on public.commerce_external_links; + +create policy external_links_insert on public.commerce_external_links + for insert to clockwork_service with check (true); + +drop policy if exists hubspot_imports_service on public.commerce_hubspot_imports; + +create policy hubspot_imports_service on public.commerce_hubspot_imports + for all to clockwork_service using (true) with check (true); + +drop policy if exists hubspot_import_items_service on public.commerce_hubspot_import_items; + +create policy hubspot_import_items_service on public.commerce_hubspot_import_items + for all to clockwork_service using (true) with check (true); + +reset lock_timeout; diff --git a/supabase/tests/1483_hubspot_handoff.test.sql b/supabase/tests/1483_hubspot_handoff.test.sql new file mode 100644 index 00000000..51b7debd --- /dev/null +++ b/supabase/tests/1483_hubspot_handoff.test.sql @@ -0,0 +1,96 @@ +begin; +select plan(16); + +select has_table('public', 'commerce_hubspot_connections', 'HubSpot connection table exists'); +select has_table('public', 'commerce_external_links', 'external link table exists'); +select has_table('public', 'commerce_hubspot_imports', 'HubSpot import table exists'); +select has_table('public', 'commerce_hubspot_import_items', 'HubSpot import item table exists'); + +select ok((select bool_and(relrowsecurity and relforcerowsecurity) from pg_class where oid in ( + 'public.commerce_hubspot_connections'::regclass, 'public.commerce_external_links'::regclass, + 'public.commerce_hubspot_imports'::regclass, 'public.commerce_hubspot_import_items'::regclass)), + 'forced row security on every HubSpot table'); + +select ok(not has_table_privilege('clockwork_runtime', 'public.commerce_hubspot_imports', 'SELECT') + and not has_table_privilege('authenticated', 'public.commerce_external_links', 'SELECT') + and not has_table_privilege('anon', 'public.commerce_hubspot_connections', 'SELECT'), + 'tenants and browsers cannot read HubSpot data'); + +select ok(has_table_privilege('clockwork_service', 'public.commerce_external_links', 'INSERT') + and not has_table_privilege('clockwork_service', 'public.commerce_external_links', 'UPDATE') + and not has_table_privilege('clockwork_service', 'public.commerce_external_links', 'DELETE'), + 'links are append-only for staff'); + +select ok(not has_table_privilege('clockwork_service', 'public.commerce_hubspot_imports', 'DELETE'), + 'imports are never deleted'); + +-- Fixtures: an approved connection and a partner to link. +update public.commerce_hubspot_connections set status = 'disconnected', disconnected_at = now(), version = version + 1 + where status = 'active'; +insert into public.commerce_hubspot_connections + (id, portal_id, ui_domain, status, audience, approved_by_id, approved_by_name, approved_at, scope) +values ('00000000-0000-4000-8000-0000000014a1', '1483', 'app.hubspot.com', 'active', 'staff_sales_read', + gen_random_uuid(), 'Admin', now(), + '{"company": [], "deal": [], "contact": [], "notes": false, "files": false}'); +insert into public.commerce_partners (id, name, created_by_id, created_by_name) +values ('00000000-0000-4000-8000-0000000014b1', 'Pgtap Partner One', gen_random_uuid(), 'Seller'), + ('00000000-0000-4000-8000-0000000014b2', 'Pgtap Partner Two', gen_random_uuid(), 'Seller'); +insert into public.commerce_external_links + (id, provider, portal_id, object_type, object_id, destination_type, partner_id, company_name, created_by_id, created_by_name) +values ('00000000-0000-4000-8000-0000000014c1', 'hubspot', '1483', 'company', '1', 'partner', + '00000000-0000-4000-8000-0000000014b1', 'Pgtap Co', gen_random_uuid(), 'Seller'); + +select throws_ok($$insert into public.commerce_hubspot_connections + (id, portal_id, ui_domain, status, audience, approved_by_id, approved_by_name, approved_at, scope) + values (gen_random_uuid(), '1484', 'app.hubspot.com', 'active', 'staff_sales_read', gen_random_uuid(), 'Admin', now(), + '{"company": [], "deal": [], "contact": [], "notes": false, "files": false}')$$, + '23505', null, 'only one connection is active at a time'); + +select throws_ok($$insert into public.commerce_external_links + (id, provider, portal_id, object_type, object_id, destination_type, partner_id, company_name, created_by_id, created_by_name) + values (gen_random_uuid(), 'hubspot', '1483', 'company', '1', 'partner', + '00000000-0000-4000-8000-0000000014b2', 'Pgtap Co', gen_random_uuid(), 'Seller')$$, + '23505', null, 'a company becomes at most one partner'); + +select throws_ok($$insert into public.commerce_external_links + (id, provider, portal_id, object_type, object_id, destination_type, partner_id, company_name, created_by_id, created_by_name) + values (gen_random_uuid(), 'hubspot', '1483', 'company', '2', 'partner', + '00000000-0000-4000-8000-0000000014b1', 'Other Co', gen_random_uuid(), 'Seller')$$, + '23505', null, 'a partner links to one company per portal'); + +select throws_ok($$insert into public.commerce_external_links + (id, provider, portal_id, object_type, object_id, destination_type, account_id, company_name, created_by_id, created_by_name) + values (gen_random_uuid(), 'hubspot', '1483', 'company', '3', 'partner', + '10000000-0000-4000-8000-000000000001', 'Typed Co', gen_random_uuid(), 'Seller')$$, + '23514', null, 'the destination type matches the linked record'); + +insert into public.commerce_hubspot_imports + (id, link_id, partner_id, connection_id, portal_id, company_id, created_partner, status, plan, snapshot, + request_digest, created_by_id, created_by_name) +values ('00000000-0000-4000-8000-0000000014d1', '00000000-0000-4000-8000-0000000014c1', + '00000000-0000-4000-8000-0000000014b1', '00000000-0000-4000-8000-0000000014a1', '1483', '1', true, + 'needs_attention', '{"fields": []}', '{"values": []}', repeat('a', 64), gen_random_uuid(), 'Seller'); +insert into public.commerce_hubspot_import_items + (id, import_id, file_id, note_id, file_name, title, document_kind, status, excluded_reason) +values ('00000000-0000-4000-8000-0000000014e1', '00000000-0000-4000-8000-0000000014d1', '8', '6', + 'a.pdf', 'A', 'other', 'excluded', 'Not needed'); + +select throws_ok($$update public.commerce_hubspot_imports set plan = '{"fields": [1]}', version = version + 1 + where id = '00000000-0000-4000-8000-0000000014d1'$$, + 'P0001', 'HUBSPOT_IMPORT_IMMUTABLE', 'a reviewed plan never changes'); + +select throws_ok($$update public.commerce_hubspot_import_items set status = 'pending', excluded_reason = null, + version = version + 1 where id = '00000000-0000-4000-8000-0000000014e1'$$, + 'P0001', 'HUBSPOT_ITEM_RESOLVED', 'a resolved document stays resolved'); + +select throws_ok($$update public.commerce_hubspot_imports set status = 'complete', completed_at = now() + where id = '00000000-0000-4000-8000-0000000014d1'$$, + 'P0001', 'HUBSPOT_VERSION_MUST_ADVANCE', 'every change advances the version'); + +select throws_ok($$insert into public.commerce_hubspot_import_items + (id, import_id, file_id, note_id, file_name, title, document_kind, status) + values (gen_random_uuid(), '00000000-0000-4000-8000-0000000014d1', '9', '6', 'b.pdf', 'B', 'other', 'attached')$$, + '23514', null, 'an attached document names its attachment'); + +select * from finish(); +rollback; From 5f6c23d285d8e86042ceb9bb7fb02ec7d66beb00 Mon Sep 17 00:00:00 2001 From: James Kurz Date: Thu, 22 Oct 2026 17:05:08 -0400 Subject: [PATCH 2/6] WIP: HubSpot handoff web experience, pipeline and settings --- .env.example | 4 + .github/workflows/terraform.yml | 1 + .../(internal)/internal/hubspot/page.tsx | 63 + .../internal/hubspot/review/page.tsx | 39 + .../(internal)/internal/mndas/page.tsx | 10 +- .../internal/owner/hubspot/page.tsx | 61 + .../internal/partners/[id]/page.tsx | 34 +- .../(internal)/internal/partners/page.tsx | 1 + .../(internal)/internal/pipeline/page.tsx | 48 + .../app/(experience)/channel-surfaces.test.ts | 8 + .../features/internal-ops/hubspot/actions.ts | 376 ++++ .../internal-ops/hubspot/hubspot.module.css | 534 ++++++ .../internal-ops/hubspot/hubspot.test.tsx | 701 +++++++ .../features/internal-ops/hubspot/model.ts | 203 +++ .../internal-ops/hubspot/page-data.ts | 408 +++++ .../internal-ops/hubspot/page-state.tsx | 138 ++ .../internal-ops/hubspot/partner-section.tsx | 262 +++ .../features/internal-ops/hubspot/picker.tsx | 252 +++ .../internal-ops/hubspot/pipeline-actions.tsx | 71 + .../internal-ops/hubspot/pipeline.tsx | 564 ++++++ .../src/features/internal-ops/hubspot/plan.ts | 299 +++ .../features/internal-ops/hubspot/receipt.tsx | 574 ++++++ .../internal-ops/hubspot/review-page.tsx | 208 +++ .../features/internal-ops/hubspot/review.tsx | 1603 +++++++++++++++++ .../features/internal-ops/hubspot/server.ts | 1065 +++++++++++ .../internal-ops/hubspot/settings.tsx | 454 +++++ .../features/internal-ops/mnda/workspace.tsx | 30 +- .../owner-console/owner-console.tsx | 14 + .../internal-ops/partners/deal-form.tsx | 22 +- .../internal-ops/partners/partner-detail.tsx | 69 +- .../internal-ops/partners/partner-list.tsx | 11 + .../features/internal-ops/partners/server.ts | 2 + apps/web/src/features/shell/app-shell.tsx | 2 + apps/web/src/features/shell/navigation.ts | 18 + .../src/features/shell/staff-access.test.tsx | 4 + .../features/shell/staff-navigation.test.ts | 2 + .../src/features/shell/staff-rail.test.tsx | 3 +- apps/web/src/i18n/catalogs.ts | 2 + .../src/i18n/messages/operations-hubspot.ts | 929 ++++++++++ .../src/i18n/messages/operations-partners.ts | 27 + apps/web/src/i18n/messages/platform-access.ts | 20 + apps/web/src/i18n/ownership.ts | 1 + deploy/app/main.tf | 2 + deploy/app/variables.tf | 6 + docs/operations/hubspot-handoff.md | 184 ++ docs/operations/partner-records.md | 8 + docs/security/access-matrix.md | 78 +- packages/contracts/src/hubspot.test.ts | 1 + packages/contracts/src/hubspot.ts | 12 + .../repositories/hubspot.integration.test.ts | 1 + packages/db/src/repositories/hubspot.ts | 25 +- .../integrations/src/hubspot/client.test.ts | 4 +- packages/ui/src/components/icons.ts | 1 + scripts/release-artifacts.mjs | 1 + 54 files changed, 9398 insertions(+), 62 deletions(-) create mode 100644 apps/web/app/(experience)/(internal)/internal/hubspot/page.tsx create mode 100644 apps/web/app/(experience)/(internal)/internal/hubspot/review/page.tsx create mode 100644 apps/web/app/(experience)/(internal)/internal/owner/hubspot/page.tsx create mode 100644 apps/web/app/(experience)/(internal)/internal/pipeline/page.tsx create mode 100644 apps/web/src/features/internal-ops/hubspot/actions.ts create mode 100644 apps/web/src/features/internal-ops/hubspot/hubspot.module.css create mode 100644 apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx create mode 100644 apps/web/src/features/internal-ops/hubspot/model.ts create mode 100644 apps/web/src/features/internal-ops/hubspot/page-data.ts create mode 100644 apps/web/src/features/internal-ops/hubspot/page-state.tsx create mode 100644 apps/web/src/features/internal-ops/hubspot/partner-section.tsx create mode 100644 apps/web/src/features/internal-ops/hubspot/picker.tsx create mode 100644 apps/web/src/features/internal-ops/hubspot/pipeline-actions.tsx create mode 100644 apps/web/src/features/internal-ops/hubspot/pipeline.tsx create mode 100644 apps/web/src/features/internal-ops/hubspot/plan.ts create mode 100644 apps/web/src/features/internal-ops/hubspot/receipt.tsx create mode 100644 apps/web/src/features/internal-ops/hubspot/review-page.tsx create mode 100644 apps/web/src/features/internal-ops/hubspot/review.tsx create mode 100644 apps/web/src/features/internal-ops/hubspot/server.ts create mode 100644 apps/web/src/features/internal-ops/hubspot/settings.tsx create mode 100644 apps/web/src/i18n/messages/operations-hubspot.ts create mode 100644 docs/operations/hubspot-handoff.md diff --git a/.env.example b/.env.example index 825b563d..a019c077 100644 --- a/.env.example +++ b/.env.example @@ -96,6 +96,10 @@ WORKFLOW_PROVIDER_CONTROL_TOKEN= CLOCKWORK_CRM_ENABLED=false CRM_PROVIDER_BASE_URL= CRM_PROVIDER_TOKEN= +# HubSpot handoff and pipeline (docs/operations/hubspot-handoff.md): a private +# app token with read-only CRM scopes. Unset leaves HubSpot unavailable; even +# when set, nothing is read until an administrator approves the connection. +HUBSPOT_ACCESS_TOKEN= ACCOUNTING_PROVIDER_BASE_URL= ACCOUNTING_PROVIDER_TOKEN= NOTIFICATION_PROVIDER_BASE_URL= diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index a1a7e3d7..ec714d47 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -77,6 +77,7 @@ jobs: TF_VAR_staff_provisioning: ${{ secrets.CLOCKWORK_STAFF_PROVISIONING }} TF_VAR_signwell_api_key: ${{ secrets.SIGNWELL_API_KEY }} TF_VAR_signwell_webhook_id: ${{ secrets.SIGNWELL_WEBHOOK_ID }} + TF_VAR_hubspot_access_token: ${{ secrets.HUBSPOT_ACCESS_TOKEN }} TF_VAR_commerce_mnda_enabled: ${{ vars.COMMERCE_MNDA_ENABLED || 'false' }} TF_VAR_commerce_contracts_signing_enabled: ${{ vars.COMMERCE_CONTRACTS_SIGNING_ENABLED || 'true' }} diff --git a/apps/web/app/(experience)/(internal)/internal/hubspot/page.tsx b/apps/web/app/(experience)/(internal)/internal/hubspot/page.tsx new file mode 100644 index 00000000..a516361e --- /dev/null +++ b/apps/web/app/(experience)/(internal)/internal/hubspot/page.tsx @@ -0,0 +1,63 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { handoffHref } from "@/src/features/internal-ops/hubspot/model"; +import { loadPicker } from "@/src/features/internal-ops/hubspot/page-data"; +import { HubSpotPageState } from "@/src/features/internal-ops/hubspot/page-state"; +import { HubSpotPicker } from "@/src/features/internal-ops/hubspot/picker"; +import { withStaffPermission } from "@/src/features/shell/staff-access"; +import { getTranslations } from "@/src/i18n/server"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { title: t("operations.hubspot.picker.title") }; +} + +/** Choose a HubSpot company or deal to bring in: `sales:read`. */ +async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + const [t, loaded] = await Promise.all([ + getTranslations(), + searchParams.then(loadPicker), + ]); + if (loaded.kind !== "ready") + return ( + + ); + // A pasted record link opens its review directly. + if (loaded.result.kind === "record") + redirect( + loaded.result.objectType === "company" + ? handoffHref({ + company: loaded.result.objectId, + deal: loaded.deal, + partner: loaded.partner, + }) + : handoffHref({ + deal: loaded.result.objectId, + partner: loaded.partner, + }), + ); + return ( + + ); +} + +export default withStaffPermission("sales:read", Page); diff --git a/apps/web/app/(experience)/(internal)/internal/hubspot/review/page.tsx b/apps/web/app/(experience)/(internal)/internal/hubspot/review/page.tsx new file mode 100644 index 00000000..bbfd8335 --- /dev/null +++ b/apps/web/app/(experience)/(internal)/internal/hubspot/review/page.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from "next"; + +import { loadReview } from "@/src/features/internal-ops/hubspot/page-data"; +import { HubSpotPageState } from "@/src/features/internal-ops/hubspot/page-state"; +import { ReviewPage } from "@/src/features/internal-ops/hubspot/review-page"; +import { withStaffPermission } from "@/src/features/shell/staff-access"; +import { getTranslations } from "@/src/i18n/server"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { title: t("operations.hubspot.review.title") }; +} + +/** + * The handoff review. Reading it needs `sales:read`; committing it is the + * server action's own `contract:write` check. + */ +async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + const [t, loaded] = await Promise.all([ + getTranslations(), + searchParams.then(loadReview), + ]); + if (loaded.kind !== "ready") + return ( + + ); + return ; +} + +export default withStaffPermission("sales:read", Page); diff --git a/apps/web/app/(experience)/(internal)/internal/mndas/page.tsx b/apps/web/app/(experience)/(internal)/internal/mndas/page.tsx index 4a51b659..15a46b80 100644 --- a/apps/web/app/(experience)/(internal)/internal/mndas/page.tsx +++ b/apps/web/app/(experience)/(internal)/internal/mndas/page.tsx @@ -4,6 +4,7 @@ import { getTranslations } from "@/src/i18n/server"; import { loadMndaPage } from "@/src/features/internal-ops/mnda/page-data"; import { MndaAccessState } from "@/src/features/internal-ops/mnda/access-state"; import { MndaWorkspace } from "@/src/features/internal-ops/mnda/workspace"; +import { loadMndaPrefill } from "@/src/features/internal-ops/hubspot/page-data"; export const dynamic = "force-dynamic"; export async function generateMetadata(): Promise { @@ -21,11 +22,18 @@ export default async function Page({ const query = parseMndaRegisterParams(params); const result = await loadMndaPage(query); if (!result.ok) return ; + const compose = params.compose === "1"; + // `handoff` names a HubSpot import whose company and chosen contact are + // offered as suggestions; the page has already checked MNDA access. + const prefill = compose + ? await loadMndaPrefill(params.partner, params.handoff) + : null; return ( ); } diff --git a/apps/web/app/(experience)/(internal)/internal/owner/hubspot/page.tsx b/apps/web/app/(experience)/(internal)/internal/owner/hubspot/page.tsx new file mode 100644 index 00000000..67187c7e --- /dev/null +++ b/apps/web/app/(experience)/(internal)/internal/owner/hubspot/page.tsx @@ -0,0 +1,61 @@ +import type { Metadata, Route } from "next"; +import Link from "next/link"; + +import { Breadcrumbs, PageHeader } from "@clockwork/ui"; + +import { loadSettings } from "@/src/features/internal-ops/hubspot/page-data"; +import { HubSpotPageState } from "@/src/features/internal-ops/hubspot/page-state"; +import { HubSpotSettings } from "@/src/features/internal-ops/hubspot/settings"; +import styles from "@/src/features/internal-ops/hubspot/hubspot.module.css"; +import { breadcrumbsLabel } from "@/src/features/shared/ui-kit-labels"; +import { withStaffPermission } from "@/src/features/shell/staff-access"; +import { getTranslations } from "@/src/i18n/server"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { title: t("operations.hubspot.settings.title") }; +} + +/** The HubSpot connection, its audience and its scope: `staff:manage`. */ +async function Page() { + const [t, loaded] = await Promise.all([getTranslations(), loadSettings()]); + if (loaded.kind !== "ready") + return ( + + ); + return ( +
+ {label}} + /> + + +
+ ); +} + +export default withStaffPermission("staff:manage", Page); diff --git a/apps/web/app/(experience)/(internal)/internal/partners/[id]/page.tsx b/apps/web/app/(experience)/(internal)/internal/partners/[id]/page.tsx index d26325db..f0aaea43 100644 --- a/apps/web/app/(experience)/(internal)/internal/partners/[id]/page.tsx +++ b/apps/web/app/(experience)/(internal)/internal/partners/[id]/page.tsx @@ -2,6 +2,9 @@ import { AccountDocumentsSection } from "@/src/features/internal-ops/account-doc import type { Metadata } from "next"; import { cache } from "react"; +import { loadPartnerHubSpotParts } from "@/src/features/internal-ops/hubspot/page-data"; +import { PartnerHubSpotSection } from "@/src/features/internal-ops/hubspot/partner-section"; +import { HubSpotReceipt } from "@/src/features/internal-ops/hubspot/receipt"; import { PartnerDetail } from "@/src/features/internal-ops/partners/partner-detail"; import { PartnerPageState } from "@/src/features/internal-ops/partners/page-state"; import { loadPartner } from "@/src/features/internal-ops/partners/server"; @@ -28,8 +31,14 @@ export async function generateMetadata({ }; } -async function Page({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; +async function Page({ + params, + searchParams, +}: { + params: Promise<{ id: string }>; + searchParams: Promise>; +}) { + const [{ id }, query] = await Promise.all([params, searchParams]); const [t, locale, loaded] = await Promise.all([ getTranslations(), getFormattingLocale(), @@ -42,6 +51,7 @@ async function Page({ params }: { params: Promise<{ id: string }> }) { title={t("operations.partners.detail.title")} /> ); + const hubspot = await loadPartnerHubSpotParts(loaded.partner, query); return ( }) { target={{ type: "partner", id: loaded.partner.id }} /> } + hubspot={ + + } + receipt={ + hubspot.receipt ? ( + + ) : null + } + dealSuggestion={loaded.canEdit ? hubspot.dealSuggestion : null} partner={loaded.partner} deals={loaded.deals} activity={loaded.activity} diff --git a/apps/web/app/(experience)/(internal)/internal/partners/page.tsx b/apps/web/app/(experience)/(internal)/internal/partners/page.tsx index ff8fbc9d..24c00033 100644 --- a/apps/web/app/(experience)/(internal)/internal/partners/page.tsx +++ b/apps/web/app/(experience)/(internal)/internal/partners/page.tsx @@ -38,6 +38,7 @@ async function Page({ today={loaded.today} canEdit={loaded.canEdit} demo={loaded.demo} + hubspot={loaded.hubspot} /> ); } diff --git a/apps/web/app/(experience)/(internal)/internal/pipeline/page.tsx b/apps/web/app/(experience)/(internal)/internal/pipeline/page.tsx new file mode 100644 index 00000000..195ed031 --- /dev/null +++ b/apps/web/app/(experience)/(internal)/internal/pipeline/page.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from "next"; + +import { loadPipelinePage } from "@/src/features/internal-ops/hubspot/page-data"; +import { HubSpotPageState } from "@/src/features/internal-ops/hubspot/page-state"; +import { PipelinePage } from "@/src/features/internal-ops/hubspot/pipeline"; +import { withStaffPermission } from "@/src/features/shell/staff-access"; +import { getFormattingLocale, getTranslations } from "@/src/i18n/server"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { title: t("operations.hubspot.pipeline.title") }; +} + +/** HubSpot's pipeline, read-only, for the sales workspace: `sales:read`. */ +async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + const [t, locale, loaded] = await Promise.all([ + getTranslations(), + getFormattingLocale(), + searchParams.then(loadPipelinePage), + ]); + if (loaded.kind !== "ready") + return ( + + ); + return ( + + ); +} + +export default withStaffPermission("sales:read", Page); diff --git a/apps/web/app/(experience)/channel-surfaces.test.ts b/apps/web/app/(experience)/channel-surfaces.test.ts index 8e58c7be..cc6c8513 100644 --- a/apps/web/app/(experience)/channel-surfaces.test.ts +++ b/apps/web/app/(experience)/channel-surfaces.test.ts @@ -306,6 +306,10 @@ const expected: Readonly> = { "/internal/gates", // Sales-to-operations handoffs read their own register. "/internal/handoffs", + // HubSpot picker and handoff review read HubSpot through the approved + // connection, never a projection channel. + "/internal/hubspot", + "/internal/hubspot/review", "/internal/migrations", // Standalone pre-sales MNDA repository, not an account projection channel. "/internal/mndas", @@ -321,12 +325,16 @@ const expected: Readonly> = { "/internal/organizations/new", // The owner console reads notices, controls and audit events directly. "/internal/owner", + // The HubSpot connection, read from its own table. + "/internal/owner/hubspot", // Staff partner records and registered deals, read from their own tables. "/internal/partners", "/internal/partners/new", "/internal/payg-offers", // Finance reads verified customer handoff requests directly. "/internal/payg-requests", + // HubSpot's pipeline, read live through the approved connection. + "/internal/pipeline", "/internal/price-books", // Indicative pricing reads the price book registry, as price-books does. "/internal/pricing", diff --git a/apps/web/src/features/internal-ops/hubspot/actions.ts b/apps/web/src/features/internal-ops/hubspot/actions.ts new file mode 100644 index 00000000..02d67ac5 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/actions.ts @@ -0,0 +1,376 @@ +"use server"; + +import { createHash, randomUUID } from "node:crypto"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { + HubSpotHandoffInputSchema, + hubspotLimits, + type HubSpotImportRecord, +} from "@clockwork/contracts"; +import { HubSpotError } from "@clockwork/integrations/hubspot"; + +import { SessionExpiredError } from "@/src/auth/session"; + +import { accountDocumentRepository } from "../account-documents/server"; +import { attempt, type ActionResult } from "../contracts/action-result"; +import { contractActor, contractStaff } from "../contracts/server"; +import { partnerRepository, partnerToday } from "../partners/server"; +import { buildHubSpotPlan, changedReviewItems, HandoffPlanError } from "./plan"; +import { + clearHubSpotCache, + configuredPortal, + hubspotContext, + hubspotRepository, + HubSpotUnavailableError, + loadHandoffSource, + requestDigest, +} from "./server"; + +async function run(operation: () => Promise): Promise> { + return attempt(async () => { + try { + return await operation(); + } catch (error) { + if (error instanceof SessionExpiredError) + throw new Error("SESSION_EXPIRED"); + if ( + error instanceof HubSpotUnavailableError || + error instanceof HandoffPlanError + ) + throw new Error(error.code); + throw error; + } + }); +} + +export type HandoffCommitResult = + | { + kind: "committed"; + partnerId: string; + importId: string; + pendingDocuments: number; + } + | { kind: "already_linked"; partnerId: string; partnerName: string } + | { kind: "source_changed"; items: string[] }; + +/** + * Commits a reviewed handoff. The source is read again now: anything + * selected that changed since the review sends the person back to it, and + * nothing the browser sent is trusted as a source value. A retry of the same + * operation, even after its answer was lost, resolves to the first result. + */ +export async function commitHubSpotHandoff(raw: unknown) { + return run(async (): Promise => { + const session = await contractStaff("contract:write"); + const actor = contractActor(session); + const handoff = HubSpotHandoffInputSchema.parse(raw); + const digest = requestDigest(raw); + const repository = hubspotRepository(); + const prior = await repository.operation(handoff.operationId); + if (prior) { + if (prior.requestDigest !== digest || prior.createdById !== actor.id) + throw new Error("HUBSPOT_OPERATION_CONFLICT"); + const record = await repository.import( + prior.partnerId, + handoff.operationId, + ); + return { + kind: "committed", + partnerId: prior.partnerId, + importId: handoff.operationId, + pendingDocuments: record.items.filter((i) => i.status === "pending") + .length, + }; + } + const context = await hubspotContext(); + if ( + context.connection.id !== handoff.connection.id || + context.connection.version !== handoff.connection.version + ) + throw new Error("HUBSPOT_CONNECTION_CHANGED"); + const source = await loadHandoffSource(context, handoff.source); + const changed = changedReviewItems( + handoff.reviewed, + source.digests, + handoff, + ); + if (changed.length) return { kind: "source_changed", items: changed }; + const partners = partnerRepository(); + const destination = + handoff.destination.kind === "existing_partner" + ? (await partners.get(handoff.destination.partnerId, partnerToday())) + .partner + : null; + const [history, owners] = await Promise.all([ + destination + ? repository.fieldHistory(destination.id) + : Promise.resolve({}), + handoff.destination.kind === "new_partner" && handoff.destination.ownerId + ? partners.owners() + : Promise.resolve([]), + ]); + const ownerId = + handoff.destination.kind === "new_partner" + ? handoff.destination.ownerId + : null; + const { plan, snapshot } = buildHubSpotPlan({ + source, + handoff, + destination, + history, + ownerName: owners.find((owner) => owner.id === ownerId)?.name ?? null, + newId: randomUUID, + }); + const result = await repository.commit( + { + plan, + snapshot, + requestDigest: digest, + connection: handoff.connection, + companyId: handoff.source.companyId, + dealId: handoff.source.dealId, + }, + actor, + ); + revalidatePath("/internal/partners"); + revalidatePath("/internal/pipeline"); + if (result.kind === "already_linked") return result; + revalidatePath(`/internal/partners/${result.partnerId}`); + return { + kind: "committed", + partnerId: result.partnerId, + importId: result.importId, + pendingDocuments: plan.documents.length, + }; + }); +} + +const ItemSchema = z + .object({ partnerId: z.guid(), importId: z.guid(), itemId: z.guid() }) + .strict(); + +const sha256 = (bytes: Uint8Array) => + createHash("sha256").update(bytes).digest("hex"); + +/** + * Brings one selected document across: re-reads the file's current version, + * refuses one that changed since the review, keeps the original bytes as a + * private attachment with HubSpot provenance, and stops at an identical PDF + * already on the partner so a person chooses what happens. Each attempt is + * recorded; a retry never repeats anything already done. + */ +export async function processHubSpotDocument(raw: unknown) { + return run(async (): Promise => { + const session = await contractStaff("contract:write"); + const actor = contractActor(session); + const { partnerId, importId, itemId } = ItemSchema.parse(raw); + const repository = hubspotRepository(); + const record = await repository.import(partnerId, importId); + const item = record.items.find((candidate) => candidate.id === itemId); + if (!item) throw new Error("HUBSPOT_ITEM_NOT_FOUND"); + if (!["pending", "failed"].includes(item.status)) + throw new Error("HUBSPOT_ITEM_RESOLVED"); + // A previous attempt attached it but its answer was lost. + const already = await repository.attachmentOnPartner(partnerId, item.id); + if (already) + return repository.recordItem( + partnerId, + importId, + item.id, + { status: "attached", attachmentId: already.id }, + actor, + ); + const fail = (errorCode: string) => + repository.recordItem( + partnerId, + importId, + item.id, + { status: "failed", errorCode }, + actor, + ); + let bytes: Uint8Array; + try { + const context = await hubspotContext(); + if (context.connection.portalId !== record.portalId) + return await fail("HUBSPOT_PORTAL_MISMATCH"); + if (!context.connection.scope.files) + return await fail("HUBSPOT_SCOPE_DENIED"); + const file = await context.reader.file(item.fileId, item.noteId); + if (!file || file.availability !== "available") + return await fail("HUBSPOT_DOCUMENT_UNAVAILABLE"); + const reviewed = record.plan.documents.find( + (document) => document.fileId === item.fileId, + ); + // A file replaced in HubSpot since the review needs a fresh review. + if ( + reviewed && + ((reviewed.updatedAt && file.updatedAt !== reviewed.updatedAt) || + (reviewed.size !== null && file.size !== reviewed.size)) + ) + return await fail("HUBSPOT_DOCUMENT_CHANGED"); + bytes = await context.reader.download( + item.fileId, + hubspotLimits.documentBytes, + ); + } catch (error) { + if (error instanceof HubSpotUnavailableError) return fail(error.code); + if (error instanceof HubSpotError) return fail(error.code); + throw error; + } + const hash = sha256(bytes); + const duplicate = await repository.attachmentByHash(partnerId, hash); + if (duplicate) + return repository.recordItem( + partnerId, + importId, + item.id, + { status: "duplicate", existingAttachmentId: duplicate.id }, + actor, + ); + try { + const attached = await accountDocumentRepository().add( + { type: "partner", id: partnerId }, + { id: item.id, title: item.title, kind: item.documentKind }, + { fileName: item.fileName, bytes }, + actor, + { + provider: "hubspot", + portalId: record.portalId, + fileId: item.fileId, + noteId: item.noteId, + importId, + }, + ); + revalidatePath(`/internal/partners/${partnerId}`); + return repository.recordItem( + partnerId, + importId, + item.id, + { status: "attached", attachmentId: attached.id }, + actor, + ); + } catch (error) { + const code = error instanceof Error ? error.message : ""; + if (code === "ATTACHMENT_ALREADY_EXISTS") { + const existing = await repository.attachmentByHash(partnerId, hash); + if (existing) + return repository.recordItem( + partnerId, + importId, + item.id, + { status: "duplicate", existingAttachmentId: existing.id }, + actor, + ); + } + if (code === "ATTACHMENT_RETRY_CONFLICT") { + const mine = await repository.attachmentOnPartner(partnerId, item.id); + if (mine) + return repository.recordItem( + partnerId, + importId, + item.id, + { status: "attached", attachmentId: mine.id }, + actor, + ); + } + return fail( + /^(DOCUMENT_|ATTACHMENT_)/.test(code) + ? "HUBSPOT_DOCUMENT_INVALID" + : "HUBSPOT_DOCUMENT_UNAVAILABLE", + ); + } + }); +} + +/** Uses the identical PDF already on the partner for a duplicate document. */ +export async function reuseHubSpotDocument(raw: unknown) { + return run(async () => { + const session = await contractStaff("contract:write"); + const { partnerId, importId, itemId } = ItemSchema.parse(raw); + const record = await hubspotRepository().reuseExisting( + partnerId, + importId, + itemId, + contractActor(session), + ); + revalidatePath(`/internal/partners/${partnerId}`); + return record; + }); +} + +/** + * Leaves a document out and finishes the handoff without it. The receipt + * then says "Completed with 1 item excluded", never a clean success. + */ +export async function excludeHubSpotDocument(raw: unknown) { + return run(async () => { + const session = await contractStaff("contract:write"); + const input = ItemSchema.extend({ + reason: z.string().trim().min(1).max(500), + }) + .strict() + .parse(raw); + const record = await hubspotRepository().excludeItem( + input.partnerId, + input.importId, + input.itemId, + input.reason, + contractActor(session), + ); + revalidatePath(`/internal/partners/${input.partnerId}`); + return record; + }); +} + +/** Reads HubSpot again for every page, without importing anything. */ +export async function refreshHubSpotData() { + return run(async () => { + await contractStaff("sales:read"); + clearHubSpotCache(); + revalidatePath("/internal/pipeline"); + return null; + }); +} + +/** + * Activates the connection, or changes its scope. The token's portal is + * read now and must be the one the administrator approved on the page. + */ +export async function saveHubSpotConnection(raw: unknown) { + return run(async () => { + const session = await contractStaff("staff:manage"); + const portal = await configuredPortal(); + if (!portal) throw new Error("HUBSPOT_NOT_CONFIGURED"); + const connection = await hubspotRepository().saveConnection( + raw, + portal, + contractActor(session), + ); + clearHubSpotCache(); + revalidatePath("/internal/owner/hubspot"); + return { id: connection.id, version: connection.version }; + }); +} + +/** + * Stops live HubSpot reads. Imported records, documents and history stay; + * pending reviews can no longer be committed. + */ +export async function disconnectHubSpot(raw: unknown) { + return run(async () => { + const session = await contractStaff("staff:manage"); + const { expectedVersion } = z + .object({ expectedVersion: z.int().min(1) }) + .strict() + .parse(raw); + await hubspotRepository().disconnect( + expectedVersion, + contractActor(session), + ); + clearHubSpotCache(); + revalidatePath("/internal/owner/hubspot"); + return null; + }); +} diff --git a/apps/web/src/features/internal-ops/hubspot/hubspot.module.css b/apps/web/src/features/internal-ops/hubspot/hubspot.module.css new file mode 100644 index 00000000..4a9b0b19 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/hubspot.module.css @@ -0,0 +1,534 @@ +/* HubSpot handoff, pipeline and settings. Orange only marks where a value + came from; it never signals validity or state. */ +.main { + display: grid; + width: min(100%, 1240px); + min-width: 0; + gap: var(--cw-space-5); + margin: 0 auto; + padding: clamp(1.25rem, 3vw, 2.5rem); + color: var(--cw-ink); +} + +.main > * { + min-width: 0; +} + +.main :global(.cw-page-header) { + margin-bottom: 0; +} + +.muted { + max-width: 72ch; + margin: 0; + color: var(--cw-muted); + font-size: var(--cw-text-md); + line-height: 1.5; +} + +.secondary { + color: var(--cw-muted); + font-size: var(--cw-text-sm); + overflow-wrap: anywhere; +} + +.prose { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.card { + display: grid; + min-width: 0; + gap: var(--cw-space-4); + border: 1px solid var(--cw-border); + border-radius: var(--cw-radius-lg); + background: var(--cw-surface); + box-shadow: var(--cw-shadow-xs); + padding: var(--cw-space-5); +} + +.card > h2, +.card > h3, +.cardHeading h2 { + margin: 0; + font-size: var(--cw-text-lg); + font-weight: 600; +} + +.cardHeading { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--cw-space-3); +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--cw-space-2); +} + +.source { + display: inline-flex; + align-items: center; + gap: 0.4em; + color: var(--cw-muted); + font-size: var(--cw-text-sm); +} + +.source::before { + content: ""; + width: 0.5em; + height: 0.5em; + flex: none; + border-radius: 50%; + background: #e8613c; +} + +.facts { + display: grid; + grid-template-columns: minmax(9rem, max-content) minmax(0, 1fr); + gap: var(--cw-space-2) var(--cw-space-4); + margin: 0; +} + +.facts dt { + color: var(--cw-muted); + font-weight: 600; +} + +.facts dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +.list { + display: grid; + gap: var(--cw-space-3); + margin: 0; + padding: 0; + list-style: none; +} + +.list > li { + display: grid; + gap: var(--cw-space-1); + padding-bottom: var(--cw-space-3); + border-bottom: 1px solid var(--cw-border); +} + +.list > li:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.rowHeading { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--cw-space-2); +} + +.rowHeading h3 { + margin: 0; + font-size: var(--cw-text-md); + font-weight: 600; + overflow-wrap: anywhere; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: var(--cw-space-1); +} + +.rowLink { + color: var(--cw-ink); + font-weight: 500; + text-decoration: none; +} + +.rowLink:hover, +.rowLink:focus-visible { + color: var(--cw-action-strong); + text-decoration: underline; +} + +/* Search */ +.search { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: var(--cw-space-3); +} + +.results { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); + gap: var(--cw-space-5); + align-items: start; +} + +/* Review: source and destination stay in view while choosing. */ +.handoff { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: stretch; + gap: var(--cw-space-3); +} + +.endpoint { + display: grid; + align-content: start; + gap: var(--cw-space-1); + border: 1px solid var(--cw-border); + border-radius: var(--cw-radius-lg); + background: var(--cw-surface); + padding: var(--cw-space-4); +} + +.endpoint strong { + font-size: var(--cw-text-lg); + overflow-wrap: anywhere; +} + +.arrow { + align-self: center; + color: var(--cw-muted); + font-size: var(--cw-text-lg); +} + +.form { + display: grid; + gap: var(--cw-space-5); + /* Room for the sticky footer so it never covers the last field. */ + padding-bottom: var(--cw-space-4); +} + +.fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); + gap: var(--cw-space-3) var(--cw-space-4); +} + +.wide { + grid-column: 1 / -1; +} + +.choices { + display: grid; + gap: var(--cw-space-3); + margin: 0; + padding: 0; + list-style: none; +} + +.choice { + display: grid; + gap: var(--cw-space-2); + padding: var(--cw-space-3); + border: 1px solid var(--cw-border); + border-radius: var(--cw-radius-sm); +} + +.choice[data-selected] { + border-color: var(--cw-action); + background: var(--cw-action-soft); +} + +.choiceDetail { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); + gap: var(--cw-space-2) var(--cw-space-3); + padding-left: 1.75rem; +} + +.unavailable { + color: var(--cw-muted); +} + +.compare { + width: 100%; + border-collapse: collapse; +} + +.compare th, +.compare td { + padding: var(--cw-space-2) var(--cw-space-3); + border-bottom: 1px solid var(--cw-border); + text-align: left; + vertical-align: top; + overflow-wrap: anywhere; +} + +.compare thead th { + color: var(--cw-muted); + font-size: var(--cw-text-sm); + font-weight: 600; +} + +.decision { + display: flex; + flex-wrap: wrap; + gap: var(--cw-space-2) var(--cw-space-3); + margin: 0; + padding: 0; + border: 0; +} + +.decision label { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.more { + border: 1px solid var(--cw-border); + border-radius: var(--cw-radius-lg); + background: var(--cw-surface); +} + +.more > summary { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--cw-space-2) var(--cw-space-3); + padding: var(--cw-space-4) var(--cw-space-5); + cursor: pointer; + font-weight: 600; +} + +.more > summary span { + color: var(--cw-muted); + font-size: var(--cw-text-sm); + font-weight: 400; +} + +.moreBody { + display: grid; + gap: var(--cw-space-5); + padding: 0 var(--cw-space-5) var(--cw-space-5); +} + +.footer { + position: sticky; + bottom: 0; + z-index: 2; + display: grid; + gap: var(--cw-space-3); + border: 1px solid var(--cw-border-strong); + border-radius: var(--cw-radius-lg); + background: var(--cw-surface-raised, var(--cw-surface)); + box-shadow: var(--cw-shadow-popover); + padding: var(--cw-space-4) var(--cw-space-5); +} + +.footerRow { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--cw-space-3); +} + +.summary { + margin: 0; + font-weight: 600; +} + +/* Pipeline */ +.filters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr)); + align-items: end; + gap: var(--cw-space-3); +} + +.stages { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr)); + gap: var(--cw-space-3); + margin: 0; + padding: 0; + list-style: none; +} + +.stage { + display: grid; + gap: var(--cw-space-1); + border: 1px solid var(--cw-border); + border-radius: var(--cw-radius-sm); + background: var(--cw-surface); + padding: var(--cw-space-3); +} + +.stage strong { + font-size: var(--cw-text-lg); +} + +.stageBar { + height: 0.35rem; + border-radius: 999px; + background: var(--cw-action-soft); + overflow: hidden; +} + +.stageBar span { + display: block; + height: 100%; + background: var(--cw-action); +} + +.workspace { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--cw-space-5); + align-items: start; +} + +.workspace[data-panel] { + grid-template-columns: minmax(0, 1fr) minmax(20rem, 26rem); +} + +.panel { + position: sticky; + top: calc(var(--cw-header-height, 4rem) + var(--cw-space-4)); +} + +.board { + display: grid; + grid-auto-columns: minmax(15rem, 1fr); + grid-auto-flow: column; + gap: var(--cw-space-3); + overflow-x: auto; + padding-bottom: var(--cw-space-2); +} + +.column { + display: grid; + align-content: start; + gap: var(--cw-space-2); + margin: 0; + padding: var(--cw-space-3); + border-radius: var(--cw-radius-lg); + background: var(--cw-canvas-deep, var(--cw-canvas)); + list-style: none; +} + +.column h3 { + margin: 0 0 var(--cw-space-1); + font-size: var(--cw-text-md); +} + +.dealCard { + display: grid; + gap: var(--cw-space-1); + border: 1px solid var(--cw-border); + border-radius: var(--cw-radius-sm); + background: var(--cw-surface); + padding: var(--cw-space-3); +} + +.scopeGroup { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); + gap: var(--cw-space-2) var(--cw-space-4); +} + +.receipt:focus { + outline: none; +} + +.receipt:focus-visible { + outline: 2px solid var(--cw-focus); + outline-offset: 4px; +} + +@media (max-width: 64rem) { + .workspace[data-panel] { + grid-template-columns: minmax(0, 1fr); + } + + .panel { + position: static; + order: -1; + } +} + +@media (max-width: 40rem) { + .handoff { + grid-template-columns: minmax(0, 1fr); + } + + .arrow { + justify-self: center; + transform: rotate(90deg); + } + + .search { + grid-template-columns: minmax(0, 1fr); + } + + .card { + padding: var(--cw-space-4); + } + + .facts { + grid-template-columns: minmax(0, 1fr); + } + + .facts dd { + margin-bottom: var(--cw-space-2); + } + + .choiceDetail { + padding-left: 0; + } + + /* Comparisons become labeled Commerce / HubSpot / Result rows. */ + .compare thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + } + + .compare tr, + .compare td, + .compare th { + display: block; + } + + .compare tr { + padding: var(--cw-space-2) 0; + border-bottom: 1px solid var(--cw-border); + } + + .compare td, + .compare th { + border: 0; + padding: var(--cw-space-1) 0; + } + + .compare td[data-label]::before { + content: attr(data-label) ": "; + color: var(--cw-muted); + font-weight: 600; + } + + .footer { + padding: var(--cw-space-3); + } +} + +@media (prefers-reduced-motion: reduce) { + .stageBar span { + transition: none; + } +} diff --git a/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx b/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx new file mode 100644 index 00000000..1fe6b5b7 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx @@ -0,0 +1,701 @@ +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + HubSpotHandoffInputSchema, + HubSpotPipelineQuerySchema, + PartnerTermsSchema, + type HubSpotConnectionRecord, + type HubSpotImportRecord, + type PartnerRecord, +} from "@clockwork/contracts"; +import { FakeHubSpotReader } from "@clockwork/integrations/hubspot"; + +const mocks = vi.hoisted(() => ({ + commit: vi.fn(), + process: vi.fn(), + exclude: vi.fn(), + reuse: vi.fn(), + push: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), +})); +vi.mock("./actions", () => ({ + commitHubSpotHandoff: mocks.commit, + processHubSpotDocument: mocks.process, + excludeHubSpotDocument: mocks.exclude, + reuseHubSpotDocument: mocks.reuse, +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: mocks.push, + refresh: mocks.refresh, + back: mocks.back, + }), +})); +vi.mock("@/src/auth/session", () => ({ + explicitDemoIdentityEnabled: () => false, +})); +vi.mock("@/src/db/service", () => ({ + getOptionalServiceDatabase: () => undefined, +})); + +import { buildHubSpotPlan, changedReviewItems } from "./plan"; +import { HubSpotReceipt } from "./receipt"; +import { HandoffReview } from "./review"; +import { + loadHandoffSource, + loadPipeline, + searchHubSpot, + type HubSpotContext, + type HubSpotStore, +} from "./server"; + +const now = new Date("2026-10-22T15:00:00.000Z"); +const partnerId = "019a44ae-0000-7000-8000-00000000e001"; +const operationId = "019a44ae-0000-7000-8000-00000000e002"; + +const connection = ( + scope: Partial = {}, +): HubSpotConnectionRecord => ({ + id: "019a44ae-0000-7000-8000-00000000e003", + portalId: "4410", + uiDomain: "app.hubspot.com", + status: "active", + audience: "staff_sales_read", + approvedById: partnerId, + approvedByName: "Admin", + approvedAt: now.toISOString(), + scope: { + company: ["industry", "partner_tier", "tax_id"], + deal: ["deal_source"], + contact: [], + notes: true, + files: true, + ...scope, + }, + defaultPipelineId: "default", + ownerMappings: [], + updatedAt: now.toISOString(), + version: 3, +}); + +const store = (linked: Record = {}) => + ({ + connection: () => Promise.resolve({ active: null, latest: null }), + partnersForCompanies: (_portal: string, ids: readonly string[]) => + Promise.resolve( + new Map( + ids.flatMap((id) => (linked[id] ? [[id, linked[id]] as const] : [])), + ), + ), + linkForPartner: () => Promise.resolve(null), + imports: () => Promise.resolve([]), + import: () => Promise.reject(new Error("HUBSPOT_IMPORT_NOT_FOUND")), + fieldHistory: () => Promise.resolve({}), + similarPartners: () => Promise.resolve([]), + }) as unknown as HubSpotStore; + +async function context( + options: { + scope?: Partial; + linked?: Record; + reader?: FakeHubSpotReader; + } = {}, +): Promise { + const reader = options.reader ?? new FakeHubSpotReader(now); + return { + // A fresh connection ID per context keeps the display cache apart. + connection: { ...connection(options.scope), id: crypto.randomUUID() }, + reader, + portal: await reader.portal(), + store: store(options.linked), + demo: false, + }; +} + +const partner = (patch: Partial = {}): PartnerRecord => ({ + id: partnerId, + name: "Northstar Systems", + website: "https://northstar.example", + region: "", + models: ["resale"], + status: "signed", + ownerId: null, + ownerName: null, + organizationId: null, + organizationName: null, + contacts: [], + nextStep: "", + nextStepDue: null, + notes: "", + terms: PartnerTermsSchema.parse({}), + createdById: partnerId, + createdByName: "Seller", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + version: 4, + ...patch, +}); + +describe("reading a handoff source through the approved scope", () => { + it("resolves names and labels and keeps the deal's other companies", async () => { + const source = await loadHandoffSource(await context(), { + companyId: "9001", + dealId: "7001", + }); + expect(source.proposed).toEqual({ + name: "Northstar Systems", + website: "https://northstar.example", + region: "United States", + }); + expect(source.deal?.reference.associatedCompanies).toEqual([ + { id: "9001", name: "Northstar Systems", labels: ["Reseller"] }, + { + id: "9002", + name: "Halcyon Research Cooperative", + labels: ["Primary", "End customer"], + }, + ]); + expect(source.deal?.reference.stage).toEqual({ + id: "closedwon", + label: "Closed won", + closed: true, + }); + expect(source.contacts.map((c) => [c.name, c.labels])).toEqual([ + ["Ada Okafor", ["Decision maker"]], + ["Marcus Hale", []], + ["Ines Albright", ["Budget holder"]], + ]); + expect(source.notes?.find((n) => n.id === "6002")?.authorName).toBeNull(); + expect(source.files?.map((f) => [f.name, f.availability])).toEqual([ + ["Halcyon order form (signed).pdf", "available"], + ["northstar-logo.png", "unsupported_type"], + ]); + }); + + it("never reads a property HubSpot marks sensitive, even if approved", async () => { + const source = await loadHandoffSource(await context(), { + companyId: "9001", + dealId: null, + }); + expect(source.companyValues.map((v) => v.property)).toEqual([ + "industry", + "partner_tier", + ]); + }); + + it("leaves notes and files out entirely when they are not approved", async () => { + const reader = new FakeHubSpotReader(now); + const source = await loadHandoffSource( + await context({ scope: { notes: false, files: false }, reader }), + { companyId: "9001", dealId: null }, + ); + expect(source.notes).toBeNull(); + expect(source.files).toBeNull(); + expect(reader.calls.some((call) => call.startsWith("notes:"))).toBe(false); + }); + + it("refuses a company that is not on the deal", async () => { + await expect( + loadHandoffSource(await context(), { companyId: "9004", dealId: "7001" }), + ).rejects.toThrow("HUBSPOT_NOT_ASSOCIATED"); + }); + + it("opens a pasted link from its own portal and explains another portal's", async () => { + const ctx = await context(); + await expect( + searchHubSpot(ctx, "https://app.hubspot.com/contacts/4410/deal/7001"), + ).resolves.toEqual({ + kind: "record", + objectType: "deal", + objectId: "7001", + }); + await expect( + searchHubSpot(ctx, "https://app.hubspot.com/contacts/999/company/1"), + ).resolves.toEqual({ kind: "foreign_portal", portalId: "999" }); + }); +}); + +describe("the handoff plan", () => { + const input = (overrides: Record = {}) => + HubSpotHandoffInputSchema.parse({ + operationId, + connection: { id: partnerId, version: 3 }, + source: { companyId: "9001", dealId: "7001" }, + reviewed: {}, + destination: { + kind: "new_partner", + partnerId, + name: "Northstar Systems", + website: "https://northstar.example", + }, + ...overrides, + }); + + it("creates exactly what was selected and records what was left out", async () => { + const source = await loadHandoffSource(await context(), { + companyId: "9001", + dealId: "7001", + }); + const { plan, snapshot } = buildHubSpotPlan({ + source, + handoff: input({ + contacts: [{ hubspotId: "5001" }], + primaryContact: { kind: "hubspot", id: "5001" }, + includeDeal: true, + endCustomerCompanyId: "9002", + documents: [{ fileId: "8001", title: "Order form", kind: "agreement" }], + referenceProperties: [{ objectType: "company", name: "partner_tier" }], + }), + destination: null, + history: {}, + ownerName: null, + newId: () => "019a44ae-0000-7000-8000-00000000e009", + }); + expect(plan.fields.map((f) => [f.field, f.action, f.result])).toEqual([ + ["name", "add", "Northstar Systems"], + ["website", "add", "https://northstar.example"], + ["region", "keep", ""], + ]); + expect(plan.primaryContactId).toBe("019a44ae-0000-7000-8000-00000000e009"); + expect(plan.deal?.endCustomer?.name).toBe("Halcyon Research Cooperative"); + expect(plan.deal?.amount).toBe("48000"); + expect(plan.omitted).toEqual({ + contacts: 2, + notes: 2, + documents: 0, + properties: 2, + }); + expect(snapshot.values.map((v) => v.property)).toContain("partner_tier"); + expect(snapshot.company.owner).toEqual({ id: "101", name: "Priya Raman" }); + }); + + it("keeps Commerce values unless a change is chosen and never clears a name", async () => { + const source = await loadHandoffSource(await context(), { + companyId: "9001", + dealId: null, + }); + const existing = (fields: Record) => + input({ + source: { companyId: "9001", dealId: null }, + destination: { + kind: "existing_partner", + partnerId, + expectedVersion: 4, + fields, + }, + }); + const { plan } = buildHubSpotPlan({ + source, + handoff: existing({ region: "use_hubspot", website: "clear" }), + destination: partner(), + history: {}, + ownerName: null, + newId: () => operationId, + }); + expect(plan.fields.map((f) => [f.field, f.action, f.result])).toEqual([ + ["name", "keep", "Northstar Systems"], + ["website", "clear", ""], + ["region", "add", "United States"], + ]); + expect(() => + buildHubSpotPlan({ + source, + handoff: existing({ name: "clear" }), + destination: partner(), + history: {}, + ownerName: null, + newId: () => operationId, + }), + ).toThrow("HUBSPOT_DECISION_REQUIRED"); + }); + + it("refuses a document HubSpot cannot provide instead of dropping it", async () => { + const source = await loadHandoffSource(await context(), { + companyId: "9001", + dealId: null, + }); + expect(() => + buildHubSpotPlan({ + source, + handoff: input({ + source: { companyId: "9001", dealId: null }, + documents: [{ fileId: "8002", title: "Logo", kind: "other" }], + }), + destination: null, + history: {}, + ownerName: null, + newId: () => operationId, + }), + ).toThrow("HUBSPOT_DOCUMENT_UNAVAILABLE"); + }); + + it("notices changes to what was selected, and only to that", async () => { + const reader = new FakeHubSpotReader(now); + const ctx = await context({ reader }); + const before = await loadHandoffSource(ctx, { + companyId: "9001", + dealId: null, + }); + reader.updateCompany("9001", { name: "Northstar Systems Inc." }); + const after = await loadHandoffSource(ctx, { + companyId: "9001", + dealId: null, + }); + const selection = { + source: { dealId: null }, + includeDeal: false, + endCustomerCompanyId: null, + contacts: [{ hubspotId: "5001", matchContactId: null }], + notes: [], + documents: [], + }; + expect( + changedReviewItems(before.digests, after.digests, selection), + ).toEqual(["company"]); + expect(changedReviewItems(after.digests, after.digests, selection)).toEqual( + [], + ); + }); +}); + +describe("the pipeline read model", () => { + it("totals every matching deal by currency and counts missing amounts", async () => { + const view = await loadPipeline( + await context({ + linked: { "9003": { id: partnerId, name: "Larchmont" } }, + }), + HubSpotPipelineQuerySchema.parse({}), + ); + expect(view.deals.map((d) => d.id).sort()).toEqual([ + "7002", + "7003", + "7004", + "7005", + "7006", + ]); + expect(view.summary.totals).toEqual([ + { currency: "EUR", amount: "185000", deals: 2 }, + { currency: "USD", amount: "45000", deals: 2 }, + ]); + expect(view.summary.missingAmounts).toBe(1); + expect(view.complete).toBe(true); + }); + + it("filters by link state with the same list the totals use", async () => { + const ctx = await context({ + linked: { "9001": { id: partnerId, name: "Northstar" } }, + }); + const partly = await loadPipeline( + ctx, + HubSpotPipelineQuerySchema.parse({ linked: "partly" }), + ); + expect(partly.deals.map((d) => d.id)).toEqual(["7006"]); + expect(partly.summary.deals).toBe(1); + const closed = await loadPipeline( + ctx, + HubSpotPipelineQuerySchema.parse({ state: "closed", linked: "partly" }), + ); + expect(closed.deals.map((d) => d.id)).toEqual(["7001"]); + }); +}); + +describe("HandoffReview", () => { + beforeEach(() => { + mocks.commit.mockReset(); + mocks.push.mockReset(); + }); + + async function renderReview( + props: Partial[0]> = {}, + ) { + const source = await loadHandoffSource(await context(), { + companyId: "9001", + dealId: "7001", + }); + render( + , + ); + return source; + } + + it("starts with nothing optional selected and says what it will create", async () => { + await renderReview(); + for (const name of ["Ada Okafor", "Marcus Hale", "Ines Albright"]) + expect(screen.getByRole("checkbox", { name })).not.toBeChecked(); + expect( + screen.getByRole("checkbox", { name: /Halcyon order form/u }), + ).not.toBeChecked(); + expect(screen.getByLabelText("Commerce relationship status")).toHaveValue( + "prospect", + ); + expect( + screen.getByText(/independent of the deal’s sales stage \(Closed won\)/u), + ).toBeInTheDocument(); + expect( + screen.getByText( + "Create Northstar Systems as a partner with 1 deal reference.", + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + /the HubSpot owner, Priya Raman, has no Commerce mapping/u, + ), + ).toBeInTheDocument(); + }); + + it("commits the selection as one reviewed plan and opens the receipt", async () => { + mocks.commit.mockResolvedValue({ + ok: true, + value: { + kind: "committed", + partnerId, + importId: operationId, + pendingDocuments: 1, + }, + }); + await renderReview(); + fireEvent.click(screen.getByRole("checkbox", { name: "Ada Okafor" })); + fireEvent.click( + screen.getByRole("checkbox", { name: /Halcyon order form/u }), + ); + expect( + screen.getByText( + "Create Northstar Systems as a partner with 1 contact, 1 deal reference, and 1 document.", + ), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Create partner" })); + await waitFor(() => expect(mocks.commit).toHaveBeenCalledTimes(1)); + const sent = HubSpotHandoffInputSchema.parse( + mocks.commit.mock.calls[0]?.[0], + ); + expect(sent).toMatchObject({ + contacts: [{ hubspotId: "5001", matchContactId: null }], + includeDeal: true, + endCustomerCompanyId: null, + documents: [{ fileId: "8001", kind: "other" }], + destination: { + kind: "new_partner", + status: "prospect", + ownerId: partnerId, + }, + }); + await waitFor(() => + expect(mocks.push).toHaveBeenCalledWith( + `/internal/partners/${partnerId}?handoff=${operationId}&start=1#hubspot-receipt`, + ), + ); + }); + + it("asks for a choice on a field both sides changed before committing", async () => { + await renderReview({ + destination: partner({ region: "Canada" }), + history: { region: { lastSource: "Mexico", lastResult: "Mexico" } }, + }); + expect(screen.getByText("Changed on both sides")).toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: "Import selected changes" }), + ); + expect( + await screen.findByText(/Choose what happens to Region/u), + ).toBeInTheDocument(); + expect(mocks.commit).not.toHaveBeenCalled(); + }); + + it("resolves to the winning partner when another handoff linked the company first", async () => { + mocks.commit.mockResolvedValue({ + ok: true, + value: { + kind: "already_linked", + partnerId, + partnerName: "Northstar (theirs)", + }, + }); + await renderReview(); + fireEvent.click(screen.getByRole("button", { name: "Create partner" })); + expect( + await screen.findByText( + "This company was just linked to Northstar (theirs)", + ), + ).toBeInTheDocument(); + expect(mocks.push).not.toHaveBeenCalled(); + }); +}); + +describe("HubSpotReceipt", () => { + const record = (status: "pending" | "failed"): HubSpotImportRecord => ({ + id: operationId, + partnerId, + portalId: "4410", + companyId: "9001", + dealId: "7001", + status: "needs_attention", + createdPartner: true, + plan: { + operationId, + destination: { + kind: "new_partner", + partnerId, + name: "Northstar Systems", + status: "prospect", + ownerId: null, + ownerName: null, + models: [], + }, + fields: [], + contacts: [], + primaryContactId: null, + deal: null, + notes: [], + references: [], + documents: [], + dismissedSuggestions: [], + omitted: { contacts: 1, notes: 0, documents: 0, properties: 0 }, + }, + snapshot: { + portalId: "4410", + uiDomain: "app.hubspot.com", + retrievedAt: now.toISOString(), + company: { + id: "9001", + url: "https://app.hubspot.com/contacts/4410/record/0-2/9001", + name: "Northstar Systems", + updatedAt: null, + owner: null, + }, + values: [], + deal: null, + notes: [], + }, + items: [ + { + id: "019a44ae-0000-7000-8000-00000000e00a", + importId: operationId, + fileId: "8001", + noteId: "6001", + fileName: "Order form.pdf", + title: "Order form", + documentKind: "agreement", + status, + errorCode: status === "failed" ? "HUBSPOT_DOCUMENT_UNAVAILABLE" : null, + attachmentId: null, + existingAttachment: null, + excludedReason: null, + attempts: status === "failed" ? 1 : 0, + updatedAt: now.toISOString(), + }, + ], + createdById: partnerId, + createdByName: "Pat Seller", + createdAt: now.toISOString(), + completedAt: null, + }); + + it("separates the saved record from the unfinished handoff", () => { + render( + , + ); + expect( + screen.getByText("Partner created · 1 document still needs attention"), + ).toBeInTheDocument(); + expect( + screen.getByText( + "HubSpot would not provide this file. Retry, or exclude it.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Retry document" }), + ).toBeInTheDocument(); + expect(screen.getByText("1 contact")).toBeInTheDocument(); + }); + + it("excludes an item only with a reason, as an explicit amendment", async () => { + const [item] = record("failed").items; + if (!item) throw new Error("fixture item missing"); + const excluded = { + ...record("failed"), + status: "completed_with_exclusions" as const, + items: [ + { + ...item, + status: "excluded" as const, + excludedReason: "Not the signed copy", + }, + ], + }; + mocks.exclude.mockResolvedValue({ ok: true, value: excluded }); + render( + , + ); + fireEvent.click( + screen.getByRole("button", { name: "Exclude this item and finish" }), + ); + const confirm = screen.getByRole("button", { name: "Exclude" }); + expect(confirm).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Why leave it out?"), { + target: { value: "Not the signed copy" }, + }); + fireEvent.click(confirm); + await waitFor(() => + expect( + screen.getByText("Partner created · Completed with 1 item excluded"), + ).toBeInTheDocument(), + ); + expect(mocks.exclude).toHaveBeenCalledWith( + expect.objectContaining({ reason: "Not the signed copy" }), + ); + }); + + it("brings pending documents across one at a time right after the commit", async () => { + mocks.process.mockResolvedValue({ + ok: false, + code: "HUBSPOT_DOCUMENT_UNAVAILABLE", + }); + render( + , + ); + await waitFor(() => expect(mocks.process).toHaveBeenCalledTimes(1)); + const heading = screen.getByRole("heading", { + name: "HubSpot import receipt", + }); + expect(heading).toHaveFocus(); + const section = heading.closest("section"); + if (!section) throw new Error("receipt section missing"); + expect( + within(section).getByText("Continue in Commerce"), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/features/internal-ops/hubspot/model.ts b/apps/web/src/features/internal-ops/hubspot/model.ts new file mode 100644 index 00000000..3160a810 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/model.ts @@ -0,0 +1,203 @@ +import type { Route } from "next"; + +import { + hubspotErrorCodes, + hubspotPipelineSearchParams, + type HubSpotCompanyReference, + type HubSpotDealReference, + type HubSpotFile, + type HubSpotFieldDrift, + type HubSpotImportStatus, + type HubSpotItemStatus, + type HubSpotNoteReference, + type HubSpotPipelineQuery, + type HubSpotSourceValue, +} from "@clockwork/contracts"; + +import type { MessageId } from "@/src/i18n"; + +/** + * Everything a handoff review shows about the HubSpot side, read fresh + * through the approved scope. `digests` fingerprint each item as shown, so + * the commit can tell whether anything selected changed in the meantime. + */ +export interface HandoffSource { + portal: { portalId: string; uiDomain: string }; + retrievedAt: string; + company: { + id: string; + url: string; + name: string; + domain: string; + /** HubSpot's own website value, before normalization. */ + website: string | null; + country: string | null; + updatedAt: string | null; + owner: { id: string; name: string | null } | null; + }; + /** Proposed Commerce values after the bounded normalization. */ + proposed: { name: string; website: string; region: string }; + /** Approved additional properties, with their labels and types. */ + companyValues: readonly HubSpotSourceValue[]; + deal: { + reference: Omit; + values: readonly HubSpotSourceValue[]; + } | null; + contacts: readonly { + id: string; + name: string; + email: string; + role: string; + labels: readonly string[]; + }[]; + /** `null` when notes are outside the approved scope. */ + notes: readonly HubSpotNoteReference[] | null; + /** `null` when files are outside the approved scope. */ + files: readonly HubSpotFile[] | null; + digests: Readonly>; +} + +/** A deal's companies, for choosing which one becomes the partner. */ +export interface DealCompanyChoice { + deal: { + id: string; + name: string; + url: string; + stageLabel: string | null; + }; + companies: readonly (HubSpotCompanyReference & { + partner: { id: string; name: string } | null; + })[]; +} + +type Tone = "neutral" | "info" | "success" | "warning" | "danger"; + +export const importStatusLabels: Readonly< + Record +> = { + complete: "operations.hubspot.import.status.complete", + needs_attention: "operations.hubspot.import.status.needs_attention", + completed_with_exclusions: + "operations.hubspot.import.status.completed_with_exclusions", +}; + +export const importStatusTone: Readonly> = { + complete: "success", + needs_attention: "warning", + completed_with_exclusions: "info", +}; + +export const itemStatusLabels: Readonly> = + { + pending: "operations.hubspot.item.status.pending", + attached: "operations.hubspot.item.status.attached", + failed: "operations.hubspot.item.status.failed", + duplicate: "operations.hubspot.item.status.duplicate", + reused: "operations.hubspot.item.status.reused", + excluded: "operations.hubspot.item.status.excluded", + }; + +export const itemStatusTone: Readonly> = { + pending: "neutral", + attached: "success", + failed: "danger", + duplicate: "warning", + reused: "success", + excluded: "neutral", +}; + +export const driftLabels: Readonly> = { + never_imported: "operations.hubspot.drift.never_imported", + unchanged: "operations.hubspot.drift.unchanged", + hubspot_changed: "operations.hubspot.drift.hubspot_changed", + commerce_changed: "operations.hubspot.drift.commerce_changed", + both_changed: "operations.hubspot.drift.both_changed", +}; + +const otherErrors = [ + "INVALID_INPUT", + "CONTRACT_FORBIDDEN", + "CONTRACT_MFA_REQUIRED", + "CONTRACT_DEMO_UNAVAILABLE", + "SESSION_EXPIRED", + "PARTNER_VERSION_CONFLICT", + "PARTNER_NOT_FOUND", + "PARTNER_OWNER_NOT_STAFF", + "HUBSPOT_OWNER_NOT_STAFF", + "UNEXPECTED", +] as const; +type HubSpotMessageCode = + (typeof hubspotErrorCodes)[number] | (typeof otherErrors)[number]; + +const errorMessages = Object.fromEntries( + [...hubspotErrorCodes, ...otherErrors].map((code) => [ + code, + `operations.hubspot.error.${code}`, + ]), +) as Record; + +/** The message for a refusal; unknown codes read as unexpected. */ +export function hubspotErrorMessage(code: string): MessageId { + return errorMessages[code as HubSpotMessageCode] ?? errorMessages.UNEXPECTED; +} + +export const hubspotPickerPath = "/internal/hubspot" as Route; +export const pipelinePath = "/internal/pipeline" as Route; +export const hubspotSettingsPath = "/internal/owner/hubspot" as Route; + +/** The review for a company, optionally from a deal or into a partner. */ +export function handoffHref(input: { + company?: string; + deal?: string | null; + partner?: string | null; +}): Route { + const params = new URLSearchParams(); + if (input.company) params.set("company", input.company); + if (input.deal) params.set("deal", input.deal); + if (input.partner) params.set("partner", input.partner); + return `/internal/hubspot/review?${params.toString()}` as Route; +} + +export function pipelineHref(query: Partial): Route { + const search = hubspotPipelineSearchParams(query).toString(); + return search ? `${pipelinePath}?${search}` : pipelinePath; +} + +/** A partner page with this import's receipt open. */ +export function receiptHref( + partnerId: string, + importId: string, + options: { start?: boolean } = {}, +): Route { + return `/internal/partners/${partnerId}?handoff=${importId}${options.start ? "&start=1" : ""}#hubspot-receipt` as Route; +} + +/** Whole-number and decimal amounts as money in their own currency. */ +export function formatHubSpotAmount( + amount: string, + currency: string, + locale: string, +): string { + const value = Number(amount); + if (!Number.isFinite(value)) return `${amount} ${currency}`; + try { + return new Intl.NumberFormat(locale, { + style: "currency", + currency, + maximumFractionDigits: 2, + }).format(value); + } catch { + return `${amount} ${currency}`; + } +} + +/** A HubSpot close date is an instant; show the calendar day it names. */ +export function formatHubSpotDate(value: string | null, locale: string) { + if (!value) return null; + const time = Date.parse(value); + if (Number.isNaN(time)) return null; + return new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeZone: "UTC", + }).format(time); +} diff --git a/apps/web/src/features/internal-ops/hubspot/page-data.ts b/apps/web/src/features/internal-ops/hubspot/page-data.ts new file mode 100644 index 00000000..aa9f3fab --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/page-data.ts @@ -0,0 +1,408 @@ +import "server-only"; + +import { z } from "zod"; + +import { + HubSpotIdSchema, + HubSpotPipelineQuerySchema, + type HubSpotFieldHistory, + type HubSpotImportRecord, + type PartnerOwnerOption, + type PartnerRecord, +} from "@clockwork/contracts"; +import { HubSpotError } from "@clockwork/integrations/hubspot"; + +import { + explicitDemoIdentityEnabled, + getRequestCommerceSession, +} from "@/src/auth/session"; + +import { contractReader } from "../contracts/demo-access"; +import { + ContractAccessError, + sessionHas, + type ContractAccessFailure, + type ContractStaffSession, +} from "../contracts/server"; +import { + partnerReader, + partnerRepository, + partnerToday, +} from "../partners/server"; +import { + configuredPortal, + hubspotContext, + hubspotRepository, + HubSpotUnavailableError, + loadDealChoice, + loadDealPeople, + loadHandoffSource, + loadPartnerHubSpot, + loadPipeline, + searchHubSpot, + settingsCatalog, + type HubSpotContext, +} from "./server"; + +export type HubSpotLoaded = + | ({ kind: "ready" } & T) + | { kind: "denied"; code: ContractAccessFailure } + /** HubSpot itself: not configured, not connected, another portal, or a + * provider failure the page can explain. */ + | { kind: "unavailable"; code: string; canManage: boolean } + | { kind: "missing" } + | { kind: "error" }; + +async function loadWith( + permission: "sales:read" | "contract:write" | "staff:manage", + load: (session: ContractStaffSession) => Promise, +): Promise> { + let session: ContractStaffSession; + try { + session = await contractReader( + permission === "staff:manage" && explicitDemoIdentityEnabled() + ? "sales:read" + : permission, + getRequestCommerceSession, + ); + } catch (error) { + if (error instanceof ContractAccessError) + return { kind: "denied", code: error.code }; + console.error("hubspot: session failed", error); + return { kind: "error" }; + } + try { + return { kind: "ready", ...(await load(session)) }; + } catch (error) { + const canManage = sessionHas(session, "staff:manage"); + if (error instanceof ContractAccessError) + return { kind: "denied", code: error.code }; + if ( + error instanceof HubSpotUnavailableError || + error instanceof HubSpotError + ) + return { kind: "unavailable", code: error.code, canManage }; + if (error instanceof Error) { + if ( + [ + "PARTNER_NOT_FOUND", + "HUBSPOT_IMPORT_NOT_FOUND", + "HUBSPOT_RECORD_NOT_FOUND", + ].includes(error.message) + ) + return { kind: "missing" }; + if (/^HUBSPOT_[A-Z_]+$/.test(error.message)) + return { kind: "unavailable", code: error.message, canManage }; + } + console.error("hubspot: page load failed", error); + return { kind: "error" }; + } +} + +const mayImport = (session: ContractStaffSession) => + !explicitDemoIdentityEnabled() && sessionHas(session, "contract:write"); + +const optionalId = (value: unknown) => { + const first = Array.isArray(value) ? (value as unknown[])[0] : value; + return HubSpotIdSchema.safeParse(first).success ? (first as string) : null; +}; +const optionalGuid = (value: unknown) => { + const first = Array.isArray(value) ? (value as unknown[])[0] : value; + return z.guid().safeParse(first).success ? (first as string) : null; +}; +const text = (value: unknown) => { + const first = Array.isArray(value) ? (value as unknown[])[0] : value; + return typeof first === "string" ? first.slice(0, 200) : ""; +}; + +type Params = Record; + +/** The search-first picker: companies and deals, or a pasted record link. */ +export function loadPicker(params: Params) { + return loadWith("sales:read", async (session) => { + const q = text(params.q).trim(); + const context = await hubspotContext(); + return { + q, + /** A deal whose company is being chosen, kept as context. */ + deal: optionalId(params.deal), + /** A partner the chosen company will be linked to. */ + partner: optionalGuid(params.partner), + result: await searchHubSpot(context, q), + portalId: context.connection.portalId, + canImport: mayImport(session), + demo: context.demo, + }; + }); +} + +export type OwnerProposal = + | { kind: "mapped"; ownerId: string; hubspotOwner: string } + | { kind: "viewer"; ownerId: string; hubspotOwner: string | null } + | { kind: "none"; hubspotOwner: string | null }; + +/** + * The owner a new partner starts with: the staff member an administrator + * mapped the HubSpot owner to, otherwise the person reviewing, otherwise + * nobody. The reason is shown beside it. + */ +function proposeOwner( + context: HubSpotContext, + hubspotOwner: { id: string; name: string | null } | null, + owners: readonly PartnerOwnerOption[], + viewerId: string, +): OwnerProposal { + const mapped = hubspotOwner + ? context.connection.ownerMappings.find( + (row) => row.hubspotOwnerId === hubspotOwner.id, + ) + : undefined; + if (mapped && owners.some((owner) => owner.id === mapped.staffId)) + return { + kind: "mapped", + ownerId: mapped.staffId, + hubspotOwner: hubspotOwner?.name ?? hubspotOwner?.id ?? "", + }; + const hubspotName = hubspotOwner + ? (hubspotOwner.name ?? hubspotOwner.id) + : null; + if (owners.some((owner) => owner.id === viewerId)) + return { kind: "viewer", ownerId: viewerId, hubspotOwner: hubspotName }; + return { kind: "none", hubspotOwner: hubspotName }; +} + +/** + * The handoff review. A deal without a chosen company asks for one first; + * a company already linked opens as an update of its partner; `partner` + * links an unlinked company to an existing partner. + */ +export function loadReview(params: Params) { + return loadWith("sales:read", async (session) => { + const companyId = optionalId(params.company); + const dealId = optionalId(params.deal); + const requestedPartner = optionalGuid(params.partner); + const context = await hubspotContext(); + if (!companyId) { + if (!dealId) throw new Error("HUBSPOT_RECORD_NOT_FOUND"); + return { + step: "choose_company" as const, + choice: await loadDealChoice(context, dealId), + partner: requestedPartner, + demo: context.demo, + }; + } + const source = await loadHandoffSource(context, { companyId, dealId }); + const linked = ( + await context.store.partnersForCompanies(context.connection.portalId, [ + companyId, + ]) + ).get(companyId); + const partnerId = linked?.id ?? requestedPartner; + const reader = partnerReader(session); + let destination: PartnerRecord | null = null; + let history: HubSpotFieldHistory = {}; + let linkedElsewhere: string | null = null; + if (partnerId) { + destination = (await reader.get(partnerId, partnerToday())).partner; + if (!linked) { + const link = await context.store.linkForPartner( + partnerId, + context.connection.portalId, + ); + linkedElsewhere = link ? link.companyName : null; + } + history = await context.store.fieldHistory(partnerId); + } + const owners = await reader.owners(); + const host = (() => { + try { + return source.proposed.website + ? new URL(source.proposed.website).hostname + : source.company.domain || null; + } catch { + return null; + } + })(); + const suggestions = destination + ? [] + : ( + await context.store.similarPartners({ + name: source.proposed.name || source.company.name, + host, + }) + ).filter((partner) => partner.linkedCompanyId !== companyId); + return { + step: "review" as const, + source, + connection: { + id: context.connection.id, + version: context.connection.version, + }, + destination, + /** A different company this partner is already linked to. */ + linkedElsewhere, + /** The partner page this company is already linked to, if any. */ + alreadyLinked: Boolean(linked), + /** The person asked for another partner than the linked one. */ + requestedOther: Boolean( + linked && requestedPartner && requestedPartner !== linked.id, + ), + history, + suggestions, + owners, + ownerProposal: proposeOwner( + context, + source.company.owner, + owners, + session.userId, + ), + canImport: mayImport(session), + demo: context.demo, + }; + }); +} + +/** The pipeline, filtered from the URL, with an open deal's people. */ +export function loadPipelinePage(params: Params) { + return loadWith("sales:read", async () => { + const query = HubSpotPipelineQuerySchema.parse(params); + const context = await hubspotContext(); + const view = await loadPipeline(context, query); + const selected = query.deal + ? (view.deals.find((deal) => deal.id === query.deal) ?? null) + : null; + return { + query, + view, + selected, + people: selected ? await loadDealPeople(context, selected.id) : [], + portal: context.portal, + demo: context.demo, + }; + }); +} + +/** The administrator's connection page. */ +export function loadSettings() { + return loadWith("staff:manage", async (session) => { + const demo = explicitDemoIdentityEnabled(); + const store = demo ? null : hubspotRepository(); + const [connection, portalResult] = await Promise.all([ + store + ? store.connection() + : Promise.resolve({ active: null, latest: null }), + configuredPortal().then( + (portal) => ({ ok: true as const, portal }), + (error: unknown) => ({ + ok: false as const, + code: + error instanceof HubSpotError ? error.code : "HUBSPOT_UNAVAILABLE", + }), + ), + ]); + const portal = portalResult.ok ? portalResult.portal : null; + const catalog = portal && !demo ? await settingsCatalog() : null; + return { + demo, + portal, + portalError: portalResult.ok ? null : portalResult.code, + active: connection.active, + latest: connection.latest, + catalog, + staff: store ? await partnerRepository().owners() : [], + canManage: !demo && sessionHas(session, "staff:manage"), + }; + }); +} + +/** A partner's import, for the receipt on the partner page. */ +export async function loadReceipt( + partnerId: string, + importId: string, +): Promise { + if (explicitDemoIdentityEnabled()) return null; + if (!z.guid().safeParse(importId).success) return null; + try { + return await hubspotRepository().import(partnerId, importId); + } catch (error) { + if (error instanceof Error && error.message === "HUBSPOT_IMPORT_NOT_FOUND") + return null; + throw error; + } +} + +/** + * The HubSpot parts of a partner page: the section, the receipt named in + * `?handoff=`, and a registration prepared from `?register=`. A failure here + * never takes the partner page down with it. + */ +export async function loadPartnerHubSpotParts( + partner: PartnerRecord, + params: Params, +) { + const handoffId = optionalGuid(params.handoff); + const registerId = optionalGuid(params.register); + const [view, receipt, register] = await Promise.all([ + loadPartnerHubSpot(partner, null).catch((error: unknown) => { + console.error("hubspot: partner section failed", error); + return null; + }), + handoffId ? loadReceipt(partner.id, handoffId).catch(() => null) : null, + registerId && registerId !== handoffId + ? loadReceipt(partner.id, registerId).catch(() => null) + : null, + ]); + const source = (register ?? receipt)?.plan.deal; + const dealSuggestion = + registerId && source + ? { + endClient: source.endCustomer?.name ?? "", + // i18n-exempt: a stored note recording where a registration came from + notes: `HubSpot deal: ${source.name} (${source.url})`, + } + : null; + return { + view, + receipt, + dealSuggestion, + autoStart: firstValue(params.start) === "1", + }; +} + +const firstValue = (value: unknown) => + Array.isArray(value) ? (value as unknown[])[0] : value; + +/** + * MNDA suggestions from a HubSpot import: the partner's name as the + * proposed company, and the person chosen as primary contact (or the only + * person brought in) as a proposed signer. A contact is never treated as + * an authorized signer; the MNDA form asks the seller to confirm. + */ +export async function loadMndaPrefill(rawPartner: unknown, rawImport: unknown) { + const partnerId = optionalGuid(rawPartner); + const importId = optionalGuid(rawImport); + if (!partnerId || !importId) return null; + try { + await contractReader("sales:read", getRequestCommerceSession); + const record = await loadReceipt(partnerId, importId); + if (!record) return null; + const { plan } = record; + const contact = + plan.contacts.find((c) => c.id === plan.primaryContactId) ?? + (plan.contacts.length === 1 ? plan.contacts[0] : undefined); + const partner = await partnerRepository().get(partnerId, partnerToday()); + return { + company: partner.partner.name, + ...(contact + ? { + signerName: contact.name, + signerEmail: contact.email, + signerTitle: contact.role, + } + : {}), + }; + } catch (error) { + console.error("hubspot: MNDA suggestions failed", error); + return null; + } +} diff --git a/apps/web/src/features/internal-ops/hubspot/page-state.tsx b/apps/web/src/features/internal-ops/hubspot/page-state.tsx new file mode 100644 index 00000000..184d64ce --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/page-state.tsx @@ -0,0 +1,138 @@ +import Link from "next/link"; + +import { ApplicationStatePanel, buttonClassName } from "@clockwork/ui"; + +import type { MessageId } from "@/src/i18n"; +import { getTranslations } from "@/src/i18n/server"; + +import { partnersPath } from "../partners/model"; +import { hubspotSettingsPath } from "./model"; +import type { HubSpotLoaded } from "./page-data"; +import styles from "./hubspot.module.css"; + +type Unready = Exclude, { kind: "ready" }>; + +const unavailableCopy: Readonly< + Record +> = { + HUBSPOT_NOT_CONFIGURED: { + title: "operations.hubspot.state.notConfiguredTitle", + body: "operations.hubspot.state.notConfiguredBody", + }, + HUBSPOT_NOT_CONNECTED: { + title: "operations.hubspot.state.notConnectedTitle", + body: "operations.hubspot.state.notConnectedBody", + }, + HUBSPOT_PORTAL_MISMATCH: { + title: "operations.hubspot.state.portalTitle", + body: "operations.hubspot.state.portalBody", + }, + HUBSPOT_RATE_LIMITED: { + title: "operations.hubspot.state.busyTitle", + body: "operations.hubspot.state.busyBody", + }, + HUBSPOT_SCOPE_DENIED: { + title: "operations.hubspot.state.scopeTitle", + body: "operations.hubspot.state.scopeBody", + }, + HUBSPOT_RECORD_ARCHIVED: { + title: "operations.hubspot.state.archivedTitle", + body: "operations.hubspot.state.archivedBody", + }, + HUBSPOT_NOT_ASSOCIATED: { + title: "operations.hubspot.state.notAssociatedTitle", + body: "operations.hubspot.state.notAssociatedBody", + }, +}; + +const fallback = { + title: "operations.hubspot.state.unavailableTitle", + body: "operations.hubspot.state.unavailableBody", +} as const satisfies { title: MessageId; body: MessageId }; + +/** + * What a HubSpot page shows when it cannot show HubSpot data, and where to + * go instead. Manual partner creation always stays one click away. + */ +export async function HubSpotPageState({ + state, + title, +}: { + state: Unready; + title: string; +}) { + const t = await getTranslations(); + const copy = + state.kind === "unavailable" + ? (unavailableCopy[state.code] ?? fallback) + : state.kind === "denied" + ? state.code === "CONTRACT_MFA_REQUIRED" + ? { + title: "operations.partners.access.mfaTitle" as const, + body: "operations.partners.access.mfaBody" as const, + } + : state.code === "CONTRACT_DEMO_UNAVAILABLE" + ? { + title: "operations.hubspot.state.demoTitle" as const, + body: "operations.hubspot.state.demoBody" as const, + } + : { + title: "operations.partners.access.forbiddenTitle" as const, + body: "operations.partners.access.forbiddenBody" as const, + } + : state.kind === "missing" + ? { + title: "operations.hubspot.state.missingTitle" as const, + body: "operations.hubspot.state.missingBody" as const, + } + : fallback; + return ( +
+

{title}

+ + {t("operations.hubspot.state.settings")} + + ) : ( + + {t("operations.partners.backToList")} + + ) + } + secondaryAction={ + state.kind === "unavailable" || state.kind === "missing" ? ( + + {t("operations.hubspot.picker.manual")} + + ) : undefined + } + /> +
+ ); +} diff --git a/apps/web/src/features/internal-ops/hubspot/partner-section.tsx b/apps/web/src/features/internal-ops/hubspot/partner-section.tsx new file mode 100644 index 00000000..7506cf91 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/partner-section.tsx @@ -0,0 +1,262 @@ +import type { Route } from "next"; +import Link from "next/link"; +import { Fragment } from "react"; + +import { + InlineNotice, + StatusBadge, + Table, + buttonClassName, +} from "@clockwork/ui"; + +import type { Translator } from "@/src/i18n"; + +import { LocalTimestamp } from "../local-timestamp"; +import { + formatHubSpotAmount, + formatHubSpotDate, + handoffHref, + hubspotErrorMessage, + hubspotPickerPath, + importStatusLabels, + importStatusTone, +} from "./model"; +import type { PartnerHubSpotView } from "./server"; +import styles from "./hubspot.module.css"; + +/** + * The HubSpot part of a partner page, beside the Commerce sections it never + * replaces: what the partner is linked to, what HubSpot says now (read, never + * written into Commerce fields), the deals associated with the company, and + * every import. Association with a deal is not sourcing or credit. + */ +export function PartnerHubSpotSection({ + t, + locale, + partnerId, + view, + canEdit, +}: { + t: Translator; + locale: string; + partnerId: string; + view: PartnerHubSpotView | null; + canEdit: boolean; +}) { + if (!view || (!view.link && !view.imports.length)) + return canEdit && view ? ( +
+
+

{t("operations.hubspot.section.title")}

+ + {t("operations.hubspot.section.link")} + +
+

+ {t("operations.hubspot.section.notLinked")} +

+
+ ) : null; + const { link, live, imports } = view; + return ( +
+
+

{t("operations.hubspot.section.title")}

+
+ {live.kind === "ready" ? ( + + {t("operations.hubspot.openInHubSpot")} + + ) : null} + {link && canEdit && live.kind === "ready" ? ( + + {t("operations.hubspot.reviewUpdates")} + + ) : null} +
+
+ {link ? ( +
+
{t("operations.hubspot.section.company")}
+
+ + {live.kind === "ready" ? live.companyName : link.companyName} + +
+
{t("operations.hubspot.section.linkedBy")}
+
+ {link.createdByName} ·{" "} + +
+ {live.kind === "ready" ? ( + <> +
{t("operations.hubspot.section.checked")}
+
+ + {live.stale ? ( + <> + {" "} + + {t("operations.hubspot.stale")} + + + ) : null} +
+
{t("operations.hubspot.section.updated")}
+
+ {live.updatedAt ? ( + + ) : ( + t("operations.hubspot.notSet") + )} +
+ + ) : null} +
+ ) : null} + {live.kind === "ready" && live.changedFields > 0 ? ( + + {t("operations.hubspot.reviewUpdates")} + + ) : undefined + } + /> + ) : null} + {live.kind === "archived" ? ( + + ) : null} + {live.kind === "unavailable" ? ( + + ) : null} + {live.kind === "ready" && live.values.length ? ( +
+ {t("operations.hubspot.section.current")} +
+ {live.values.map((value) => ( + +
{value.label}
+
{value.value ?? t("operations.hubspot.notSet")}
+
+ ))} +
+
+ ) : null} + {live.kind === "ready" ? ( +
+

{t("operations.hubspot.section.dealsTitle")}

+

+ {t("operations.hubspot.section.dealsDisclaimer")} +

+ {live.deals.length ? ( + deal.id)} + numericColumns={[2]} + rows={live.deals.map((deal) => [ + + {deal.name} + , + deal.stageLabel ?? t("operations.hubspot.notSet"), + deal.amount && deal.currency + ? formatHubSpotAmount(deal.amount, deal.currency, locale) + : t("operations.hubspot.notSet"), + formatHubSpotDate(deal.closeDate, locale) ?? + t("operations.hubspot.notSet"), + ])} + /> + ) : ( +

+ {t("operations.hubspot.section.noDeals")} +

+ )} + + ) : null} +
+

{t("operations.hubspot.section.history")}

+ {imports.length ? ( +
    + {imports.map((record) => ( +
  • +
    + + {t( + record.createdPartner + ? "operations.hubspot.section.importCreated" + : "operations.hubspot.section.importUpdated", + { name: record.snapshot.company.name }, + )} + + + {t(importStatusLabels[record.status])} + +
    + + {record.createdByName} ·{" "} + + +
  • + ))} +
+ ) : ( +

+ {t("operations.hubspot.section.noImports")} +

+ )} +
+ + ); +} diff --git a/apps/web/src/features/internal-ops/hubspot/picker.tsx b/apps/web/src/features/internal-ops/hubspot/picker.tsx new file mode 100644 index 00000000..e8cbbe1a --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/picker.tsx @@ -0,0 +1,252 @@ +import type { Route } from "next"; +import Link from "next/link"; + +import { + Breadcrumbs, + Button, + EmptyState, + InlineNotice, + Input, + PageHeader, + StatusBadge, + buttonClassName, +} from "@clockwork/ui"; + +import { breadcrumbsLabel } from "@/src/features/shared/ui-kit-labels"; +import type { Translator } from "@/src/i18n"; + +import { partnerHref, partnersPath } from "../partners/model"; +import { handoffHref, hubspotPickerPath } from "./model"; +import type { HubSpotSearch } from "./server"; +import styles from "./hubspot.module.css"; + +/** + * Search-first: nothing loads until someone searches, so the CRM directory + * is never listed wholesale. Results name the company's domain and owner and + * say when it is already linked in Commerce. + */ +export function HubSpotPicker({ + t, + q, + deal, + partner, + result, + portalId, + canImport, + demo, +}: { + t: Translator; + q: string; + deal: string | null; + partner: string | null; + result: HubSpotSearch; + portalId: string; + canImport: boolean; + demo: boolean; +}) { + const count = + result.kind === "results" + ? result.companies.length + (deal ? 0 : result.deals.length) + : 0; + return ( +
+ {label}} + /> + + {demo ? ( + + ) : !canImport ? ( + + ) : null} +
+ {deal ? : null} + {partner ? ( + + ) : null} + + + +

+ {result.kind === "results" + ? t("operations.hubspot.picker.announce", { count, query: q }) + : ""} +

+ {result.kind === "foreign_portal" ? ( + + ) : null} + {result.kind === "results" && count === 0 ? ( + + {t("operations.hubspot.picker.manual")} + + ) : undefined + } + /> + ) : null} + {result.kind === "results" && count > 0 ? ( +
+
+

+ {t("operations.hubspot.picker.companies")} +

+ {result.companies.length ? ( +
    + {result.companies.map((company) => ( +
  • +
    +

    {company.name}

    + {company.partner ? ( + + {t("operations.hubspot.picker.linked")} + + ) : null} +
    + + {[ + company.detail, + company.ownerName + ? t("operations.hubspot.ownerIs", { + name: company.ownerName, + }) + : t("operations.hubspot.noOwner"), + ] + .filter(Boolean) + .join(" · ")} + + {company.partner ? ( + + {t("operations.hubspot.picker.linkedTo", { + name: company.partner.name, + })} + + ) : null} +
    + {company.partner ? ( + <> + + {t("operations.hubspot.openPartner")} + + + {t("operations.hubspot.reviewUpdates")} + + + ) : ( + + {t("operations.hubspot.picker.review")} + + )} +
    +
  • + ))} +
+ ) : ( +

+ {t("operations.hubspot.picker.noCompanies")} +

+ )} +
+ {deal ? null : ( +
+

{t("operations.hubspot.picker.deals")}

+ {result.deals.length ? ( +
    + {result.deals.map((item) => ( +
  • +
    +

    {item.name}

    +
    + + {[ + item.detail, + item.ownerName + ? t("operations.hubspot.ownerIs", { + name: item.ownerName, + }) + : null, + ] + .filter(Boolean) + .join(" · ")} + +
    + + {t("operations.hubspot.picker.review")} + +
    +
  • + ))} +
+ ) : ( +

+ {t("operations.hubspot.picker.noDeals")} +

+ )} +
+ )} +
+ ) : null} + {result.kind === "empty" ? ( +

{t("operations.hubspot.picker.start")}

+ ) : null} +

+ + {t("operations.hubspot.picker.clear")} + +

+
+ ); +} diff --git a/apps/web/src/features/internal-ops/hubspot/pipeline-actions.tsx b/apps/web/src/features/internal-ops/hubspot/pipeline-actions.tsx new file mode 100644 index 00000000..5e35b013 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/pipeline-actions.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; + +import { Button } from "@clockwork/ui"; + +import { useTranslations } from "@/src/i18n/client"; + +import { refreshHubSpotData } from "./actions"; + +/** Reads HubSpot again now. It imports nothing and changes no record. */ +export function RefreshHubSpotButton() { + const t = useTranslations(); + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [failed, setFailed] = useState(false); + return ( + <> + + + {failed ? t("operations.hubspot.error.HUBSPOT_UNAVAILABLE") : ""} + + + ); +} + +/** Copies this filtered view's address, which carries no extra access. */ +export function CopyViewLink() { + const t = useTranslations(); + const [copied, setCopied] = useState(false); + return ( + <> + + + {copied ? t("operations.hubspot.pipeline.copied") : ""} + + + ); +} diff --git a/apps/web/src/features/internal-ops/hubspot/pipeline.tsx b/apps/web/src/features/internal-ops/hubspot/pipeline.tsx new file mode 100644 index 00000000..69318c83 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/pipeline.tsx @@ -0,0 +1,564 @@ +import Link from "next/link"; + +import { + hubspotDealStates, + hubspotLinkFilters, + hubspotRecordUrl, + type HubSpotPipelineDeal, + type HubSpotPipelineQuery, + type HubSpotPortal, +} from "@clockwork/contracts"; +import { + Button, + EmptyState, + InlineNotice, + Input, + PageHeader, + Select, + StatusBadge, + Table, + Tag, + buttonClassName, +} from "@clockwork/ui"; + +import type { MessageId, Translator } from "@/src/i18n"; + +import { LocalTimestamp } from "../local-timestamp"; +import { partnerHref } from "../partners/model"; +import { + formatHubSpotAmount, + formatHubSpotDate, + handoffHref, + pipelineHref, + pipelinePath, +} from "./model"; +import { CopyViewLink, RefreshHubSpotButton } from "./pipeline-actions"; +import type { PipelineView } from "./server"; +import styles from "./hubspot.module.css"; + +const stateLabels: Readonly> = + { + open: "operations.hubspot.pipeline.state.open", + closed: "operations.hubspot.pipeline.state.closed", + all: "operations.hubspot.pipeline.state.all", + }; + +const linkLabels: Readonly> = + { + any: "operations.hubspot.pipeline.linked.any", + linked: "operations.hubspot.pipeline.linked.linked", + partly: "operations.hubspot.pipeline.linked.partly", + none: "operations.hubspot.pipeline.linked.none", + }; + +function amountText( + deal: Pick, + t: Translator, + locale: string, +) { + if (deal.amount && deal.currency) + return formatHubSpotAmount(deal.amount, deal.currency, locale); + return t("operations.hubspot.notSet"); +} + +function LinkedCompanies({ + deal, + t, +}: { + deal: HubSpotPipelineDeal; + t: Translator; +}) { + const linked = deal.companies.filter((company) => company.partner).length; + if (!deal.companies.length) + return ( + + {t("operations.hubspot.pipeline.noCompanies")} + + ); + return ( + + {t("operations.hubspot.pipeline.linkedCount", { + linked, + total: deal.companies.length, + })} + + ); +} + +/** + * HubSpot's pipeline inside Commerce, read-only. Counts and totals come from + * every deal that matches the filters, never from the visible page; amounts + * stay in their own currencies, and missing amounts are counted, not zeroed. + */ +export function PipelinePage({ + t, + locale, + query, + view, + selected, + people, + portal, + demo, +}: { + t: Translator; + locale: string; + query: HubSpotPipelineQuery; + view: PipelineView; + selected: HubSpotPipelineDeal | null; + people: readonly { + id: string; + name: string; + role: string; + labels: readonly string[]; + }[]; + portal: Pick; + demo: boolean; +}) { + const stageLabel = new Map(view.pipeline.stages.map((s) => [s.id, s.label])); + const totals = (list: PipelineView["summary"]["totals"]) => + list + .map((total) => formatHubSpotAmount(total.amount, total.currency, locale)) + .join(" · "); + const largest = Math.max( + 1, + ...view.summary.stages.map((stage) => stage.deals), + ); + const dealHref = (id: string) => pipelineHref({ ...query, deal: id }); + const close = pipelineHref({ ...query, deal: undefined }); + return ( +
+ + + + + } + /> + {demo ? ( + + ) : null} +

+ {t("operations.hubspot.pipeline.checked")}{" "} + + {view.stale ? ( + <> + {" "} + + {t("operations.hubspot.stale")} + + + ) : null} +

+ {view.stale ? ( + + ) : null} + {!view.complete ? ( + + ) : null} + +
+ ({ + value, + label: t(stateLabels[value]), + }))} + /> + ({ + value: owner.id, + label: owner.name, + })), + ]} + /> + + + ({ + value, + label: t(linkLabels[value]), + }))} + /> + + {query.view === "board" ? ( + + ) : null} +
+ + + {t("operations.hubspot.pipeline.clear")} + +
+ + +
+
+

+ {t("operations.hubspot.pipeline.summary", { + count: view.summary.deals, + })} +

+ +
+

+ {[ + view.summary.totals.length ? totals(view.summary.totals) : null, + view.summary.missingAmounts + ? t("operations.hubspot.pipeline.missing", { + count: view.summary.missingAmounts, + }) + : null, + view.summary.totals.length > 1 + ? t("operations.hubspot.pipeline.noCombined") + : null, + ] + .filter(Boolean) + .join(" · ") || t("operations.hubspot.pipeline.noAmounts")} +

+
    + {view.summary.stages.map((stage) => ( +
  1. + {stage.label} + {stage.deals} +
  2. + ))} +
+
+ +
+ {view.deals.length === 0 ? ( + + ) : query.view === "board" ? ( +
+ {view.pipeline.stages.map((stage) => { + const deals = view.deals.filter( + (deal) => deal.stageId === stage.id, + ); + return ( +
+

+ {stage.label} · {deals.length} +

+
    + {deals.map((deal) => ( +
  • + + {deal.name} + + + {deal.companies.map((c) => c.name).join(", ") || + t("operations.hubspot.pipeline.noCompanies")} + + {amountText(deal, t, locale)} + +
  • + ))} +
+
+ ); + })} +
+ ) : ( +
deal.id)} + rows={view.deals.map((deal) => [ + + {deal.name} + , + deal.companies.map((c) => c.name).join(", ") || ( + + {t("operations.hubspot.pipeline.noCompanies")} + + ), + (deal.stageId && stageLabel.get(deal.stageId)) ?? + t("operations.hubspot.notSet"), + amountText(deal, t, locale), + deal.ownerName ?? t("operations.hubspot.noOwner"), + formatHubSpotDate(deal.closeDate, locale) ?? + t("operations.hubspot.notSet"), + , + ])} + /> + )} + {selected ? ( + + ) : null} + +

+ + {t("operations.partners.backToList")} + +

+ + ); +} diff --git a/apps/web/src/features/internal-ops/hubspot/plan.ts b/apps/web/src/features/internal-ops/hubspot/plan.ts new file mode 100644 index 00000000..16b24778 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/plan.ts @@ -0,0 +1,299 @@ +import { + classifyHubSpotDrift, + hubspotFieldAction, + hubspotPartnerFieldSources, + hubspotPartnerFields, + type HubSpotFieldChange, + type HubSpotFieldHistory, + type HubSpotHandoffInput, + type HubSpotHandoffPlan, + type HubSpotPartnerField, + type HubSpotPlannedContact, + type HubSpotSourceSnapshot, + type HubSpotSourceValue, + type PartnerRecord, +} from "@clockwork/contracts"; + +import type { HandoffSource } from "./model"; + +export class HandoffPlanError extends Error { + constructor(readonly code: string) { + super(code); + } +} + +const refuse = (code: string): never => { + throw new HandoffPlanError(code); +}; + +/** The HubSpot value each partner field would take, already normalized. */ +export function proposedValue( + source: Pick, + field: HubSpotPartnerField, +): string { + return source.proposed[field]; +} + +function fieldSource(source: HandoffSource, field: HubSpotPartnerField) { + const { property, label } = hubspotPartnerFieldSources[field]; + const raw = + field === "name" + ? source.company.name + : field === "website" + ? (source.company.website ?? (source.company.domain || null)) + : source.company.country; + return { + property: + field === "website" && !source.company.website ? "domain" : property, + label, + value: raw || null, + }; +} + +/** + * Which reviewed items changed since the review was read: the company, the + * deal when it is used, and every selected person, note and file. An item + * the review never saw counts as changed. + */ +export function changedReviewItems( + reviewed: Readonly>, + current: Readonly>, + input: Pick< + HubSpotHandoffInput, + "includeDeal" | "endCustomerCompanyId" | "contacts" | "notes" | "documents" + > & { source: { dealId: string | null } }, +): string[] { + const keys = [ + "company", + ...(input.source.dealId && (input.includeDeal || input.endCustomerCompanyId) + ? ["deal"] + : []), + ...input.contacts.map((contact) => `contact:${contact.hubspotId}`), + ...input.notes.map((id) => `note:${id}`), + ...input.documents.map((document) => `file:${document.fileId}`), + ]; + return keys.filter( + (key) => !reviewed[key] || !current[key] || reviewed[key] !== current[key], + ); +} + +/** + * The plan a reviewed handoff commits, and the snapshot of what it read. It + * refuses anything the source does not offer rather than dropping it: a + * person, note, file or property that is not there, or outside the approved + * scope, stops the commit with a code the review can explain. + */ +export function buildHubSpotPlan(input: { + source: HandoffSource; + handoff: HubSpotHandoffInput; + /** The partner an update writes to; `null` for a new partner. */ + destination: PartnerRecord | null; + history: HubSpotFieldHistory; + ownerName: string | null; + newId: () => string; +}): { plan: HubSpotHandoffPlan; snapshot: HubSpotSourceSnapshot } { + const { source, handoff, destination, history } = input; + if (handoff.source.companyId !== source.company.id) + refuse("HUBSPOT_SOURCE_CHANGED"); + if (handoff.source.dealId !== (source.deal?.reference.id ?? null)) + refuse("HUBSPOT_NOT_ASSOCIATED"); + const target = handoff.destination; + if (target.kind === "existing_partner") { + if (!destination || destination.id !== target.partnerId) + refuse("PARTNER_NOT_FOUND"); + } else if (destination) refuse("HUBSPOT_OPERATION_CONFLICT"); + + const fields: HubSpotFieldChange[] = hubspotPartnerFields.map((field) => { + const hubspot = proposedValue(source, field); + const sourceRef = fieldSource(source, field); + if (target.kind === "new_partner") { + // A new partner takes what the person confirmed, which may correct + // HubSpot's spelling; the original stays in the snapshot. + const result = target[field]; + return { + field, + action: result ? "add" : "keep", + current: "", + hubspot, + result, + drift: "never_imported", + source: sourceRef, + }; + } + const current = destination?.[field] ?? ""; + const decision = target.fields[field]; + if (decision === "clear" && field === "name") + refuse("HUBSPOT_DECISION_REQUIRED"); + const { action, result } = hubspotFieldAction(decision, current, hubspot); + return { + field, + action, + current, + hubspot, + result, + drift: classifyHubSpotDrift({ + lastSource: history[field]?.lastSource, + lastResult: history[field]?.lastResult, + commerce: current, + hubspot, + }), + source: sourceRef, + }; + }); + + const existingContacts = destination?.contacts ?? []; + const contacts: HubSpotPlannedContact[] = handoff.contacts.map((choice) => { + const person = + source.contacts.find((contact) => contact.id === choice.hubspotId) ?? + refuse("HUBSPOT_CONTACT_NOT_FOUND"); + const linked = existingContacts.find( + (contact) => + contact.source?.portalId === source.portal.portalId && + contact.source.objectId === person.id, + ); + if (choice.matchContactId) { + const match = existingContacts.find( + (contact) => contact.id === choice.matchContactId, + ); + if (!match) refuse("HUBSPOT_CONTACT_NOT_FOUND"); + } + const id = choice.matchContactId ?? linked?.id ?? input.newId(); + return { + id, + hubspotId: person.id, + name: person.name, + email: person.email, + role: person.role, + labels: person.labels, + matchedExisting: Boolean(choice.matchContactId ?? linked), + }; + }); + const primary = handoff.primaryContact; + const primaryContactId = + primary === null + ? null + : primary.kind === "hubspot" + ? (contacts.find((contact) => contact.hubspotId === primary.id)?.id ?? + refuse("HUBSPOT_CONTACT_NOT_FOUND")) + : existingContacts.some((contact) => contact.id === primary.id) + ? primary.id + : refuse("HUBSPOT_CONTACT_NOT_FOUND"); + + let deal: HubSpotHandoffPlan["deal"] = null; + if (handoff.includeDeal) { + const reference = + source.deal?.reference ?? refuse("HUBSPOT_NOT_ASSOCIATED"); + const endCustomer = + handoff.endCustomerCompanyId === null + ? null + : (reference.associatedCompanies.find( + (company) => company.id === handoff.endCustomerCompanyId, + ) ?? refuse("HUBSPOT_NOT_ASSOCIATED")); + deal = { + ...reference, + chosenCompanyId: source.company.id, + endCustomer, + }; + } + + if (handoff.notes.length && source.notes === null) + refuse("HUBSPOT_SCOPE_DENIED"); + const notes = handoff.notes.map( + (id) => + source.notes?.find((note) => note.id === id) ?? + refuse("HUBSPOT_SCOPE_DENIED"), + ); + + const references: HubSpotSourceValue[] = handoff.referenceProperties.map( + (choice) => + (choice.objectType === "company" + ? source.companyValues + : (source.deal?.values ?? []) + ).find((value) => value.property === choice.name) ?? + refuse("HUBSPOT_SCOPE_DENIED"), + ); + + if (handoff.documents.length && source.files === null) + refuse("HUBSPOT_SCOPE_DENIED"); + const documents = handoff.documents.map((choice) => { + const file = + source.files?.find((candidate) => candidate.id === choice.fileId) ?? + refuse("HUBSPOT_DOCUMENT_UNAVAILABLE"); + if (file.availability !== "available") + refuse("HUBSPOT_DOCUMENT_UNAVAILABLE"); + return { + fileId: file.id, + noteId: file.noteId, + fileName: file.name, + title: choice.title, + kind: choice.kind, + size: file.size, + updatedAt: file.updatedAt, + }; + }); + + const plan: HubSpotHandoffPlan = { + operationId: handoff.operationId, + destination: + target.kind === "new_partner" + ? { + kind: "new_partner", + partnerId: target.partnerId, + name: target.name, + status: target.status, + ownerId: target.ownerId, + ownerName: input.ownerName, + models: target.models, + } + : { + kind: "existing_partner", + partnerId: target.partnerId, + name: destination?.name ?? "", + expectedVersion: target.expectedVersion, + }, + fields, + contacts, + primaryContactId, + deal, + notes, + references, + documents, + dismissedSuggestions: + target.kind === "new_partner" ? target.dismissedSuggestions : [], + omitted: { + contacts: source.contacts.length - contacts.length, + notes: (source.notes?.length ?? 0) - notes.length, + documents: + (source.files?.filter((file) => file.availability === "available") + .length ?? 0) - documents.length, + properties: + source.companyValues.length + + (source.deal?.values.length ?? 0) - + references.length, + }, + }; + + const fieldValues: HubSpotSourceValue[] = fields.map((field) => ({ + objectType: "company", + property: field.source.property, + label: field.source.label, + type: "string", + value: field.source.value, + })); + const snapshot: HubSpotSourceSnapshot = { + portalId: source.portal.portalId, + uiDomain: source.portal.uiDomain, + retrievedAt: source.retrievedAt, + company: { + id: source.company.id, + url: source.company.url, + name: source.company.name, + updatedAt: source.company.updatedAt, + owner: source.company.owner, + }, + values: [...fieldValues, ...references], + deal, + notes, + }; + return { plan, snapshot }; +} diff --git a/apps/web/src/features/internal-ops/hubspot/receipt.tsx b/apps/web/src/features/internal-ops/hubspot/receipt.tsx new file mode 100644 index 00000000..4218634b --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/receipt.tsx @@ -0,0 +1,574 @@ +"use client"; + +import type { Route } from "next"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState, useTransition } from "react"; + +import { + countHubSpotPlan, + hubspotImportStatus, + type HubSpotFieldAction, + type HubSpotImportItem, + type HubSpotImportRecord, +} from "@clockwork/contracts"; +import { + Button, + InlineNotice, + Input, + StatusBadge, + Tag, + buttonClassName, +} from "@clockwork/ui"; + +import { useFormattingLocale, useTranslations } from "@/src/i18n/client"; +import type { MessageId } from "@/src/i18n"; + +import { LocalTimestamp } from "../local-timestamp"; +import { partnerEditHref } from "../partners/model"; +import { + excludeHubSpotDocument, + processHubSpotDocument, + reuseHubSpotDocument, +} from "./actions"; +import { + formatHubSpotAmount, + formatHubSpotDate, + hubspotErrorMessage, + importStatusLabels, + importStatusTone, + itemStatusLabels, + itemStatusTone, +} from "./model"; +import styles from "./hubspot.module.css"; + +const actionLabels: Readonly> = { + add: "operations.hubspot.receipt.added", + replace: "operations.hubspot.receipt.replaced", + clear: "operations.hubspot.receipt.cleared", + keep: "operations.hubspot.receipt.kept", +}; + +const fieldLabels: Readonly> = { + name: "operations.partners.field.name", + website: "operations.partners.field.website", + region: "operations.partners.field.region", +}; + +/** + * What a handoff did, item by item. "Record saved" and "Handoff complete" + * are separate facts: the partner is usable as soon as it is saved, but the + * handoff is complete only when every selected document is attached, reused + * or explicitly excluded, and an exclusion is said out loud. + */ +export function HubSpotReceipt({ + record: initial, + canEdit, + autoStart, + focus, +}: { + record: HubSpotImportRecord; + canEdit: boolean; + /** Bring pending documents across now, right after the commit. */ + autoStart: boolean; + /** Move focus here: the person just finished the review. */ + focus: boolean; +}) { + const t = useTranslations(); + const locale = useFormattingLocale(); + const router = useRouter(); + const heading = useRef(null); + const [record, setRecord] = useState(initial); + const [busy, setBusy] = useState(null); + const [failure, setFailure] = useState(null); + const [excluding, setExcluding] = useState<{ + id: string; + reason: string; + } | null>(null); + const [, startTransition] = useTransition(); + const started = useRef(false); + const [announcement, setAnnouncement] = useState(""); + + useEffect(() => setRecord(initial), [initial]); + useEffect(() => { + if (focus) heading.current?.focus(); + }, [focus]); + + async function act( + item: HubSpotImportItem, + run: () => Promise< + { ok: true; value: HubSpotImportRecord } | { ok: false; code: string } + >, + ) { + setBusy(item.id); + setFailure(null); + const result = await run().catch(() => ({ + ok: false as const, + code: "UNEXPECTED", + })); + setBusy(null); + if (!result.ok) { + setFailure(result.code); + return; + } + setRecord(result.value); + const updated = result.value.items.find((i) => i.id === item.id); + if (updated) + setAnnouncement( + t("operations.hubspot.receipt.itemAnnounce", { + title: updated.title, + status: t(itemStatusLabels[updated.status]), + }), + ); + startTransition(() => router.refresh()); + } + + const process = (item: HubSpotImportItem) => + act(item, () => + processHubSpotDocument({ + partnerId: record.partnerId, + importId: record.id, + itemId: item.id, + }), + ); + + // Right after the commit, documents come across one at a time so each has + // its own result; anything that fails waits here for a person. + useEffect(() => { + if (!autoStart || !canEdit || started.current) return; + started.current = true; + void (async () => { + for (const item of initial.items) + if (item.status === "pending" && item.attempts === 0) + await process(item); + })(); + // Once, on arrival. + }, []); + + const status = hubspotImportStatus(record.items); + const counts = countHubSpotPlan(record.plan); + const open = record.items.filter((item) => + ["pending", "failed", "duplicate"].includes(item.status), + ).length; + const excluded = record.items.filter( + (item) => item.status === "excluded", + ).length; + const saved = t( + record.createdPartner + ? "operations.hubspot.receipt.partnerCreated" + : "operations.hubspot.receipt.recordSaved", + ); + const state = + status === "needs_attention" + ? t("operations.hubspot.receipt.needsAttention", { count: open }) + : status === "completed_with_exclusions" + ? t("operations.hubspot.receipt.withExclusions", { count: excluded }) + : t("operations.hubspot.receipt.complete"); + const deal = record.plan.deal; + const changedFields = record.plan.fields.filter( + (field) => field.action !== "keep", + ); + + return ( +
+
+

+ {t("operations.hubspot.receipt.title")} +

+ + {t(importStatusLabels[status])} + +
+

+ {saved} · {state} +

+

+ {announcement} +

+ {status === "needs_attention" ? ( +

{t("operations.hubspot.receipt.usable")}

+ ) : null} + {failure ? ( + + ) : null} + +
+
{t("operations.hubspot.receipt.source")}
+
+ + {record.snapshot.company.name} + + {deal ? ( + <> + {" · "} + + {deal.name} + + + ) : null} +
+
{t("operations.hubspot.receipt.by")}
+
+ {record.createdByName} ·{" "} + +
+
{t("operations.hubspot.receipt.copied")}
+
+ {changedFields.length || + counts.contactsAdded || + counts.contactsLinked ? ( +
    + {changedFields.map((field) => ( +
  • + + {t(actionLabels[field.action], { + field: t( + fieldLabels[field.field] ?? + "operations.partners.field.name", + ), + value: field.result, + })} + + {field.result !== field.hubspot && field.hubspot ? ( + + {t("operations.hubspot.inHubSpot", { + value: field.hubspot, + })} + + ) : null} +
  • + ))} + {record.plan.contacts.map((contact) => ( +
  • + + {t( + contact.matchedExisting + ? "operations.hubspot.receipt.contactLinked" + : "operations.hubspot.receipt.contactAdded", + { name: contact.name }, + )} + {record.plan.primaryContactId === contact.id + ? ` · ${t("operations.hubspot.receipt.primary")}` + : ""} + + {contact.labels.length ? ( + + {contact.labels.map((label) => ( + {label} + ))} + + ) : null} +
  • + ))} +
+ ) : ( + t("operations.hubspot.receipt.nothingCopied") + )} +
+
{t("operations.hubspot.receipt.reference")}
+
+ {deal || record.plan.notes.length || record.plan.references.length ? ( +
    + {deal ? ( +
  • + + {t("operations.hubspot.receipt.deal", { + name: deal.name, + stage: deal.stage.label ?? t("operations.hubspot.notSet"), + })} + + + {[ + deal.amount && deal.currency + ? formatHubSpotAmount( + deal.amount, + deal.currency, + locale, + ) + : t("operations.hubspot.receipt.amountNotSet"), + formatHubSpotDate(deal.closeDate, locale) + ? t("operations.hubspot.receipt.closes", { + date: + formatHubSpotDate(deal.closeDate, locale) ?? "", + }) + : null, + deal.endCustomer + ? t("operations.hubspot.receipt.endCustomer", { + name: deal.endCustomer.name, + }) + : null, + ] + .filter(Boolean) + .join(" · ")} + +
  • + ) : null} + {record.plan.notes.map((note) => ( +
  • +
    + + {t("operations.hubspot.notes.label", { + author: + note.authorName ?? + t("operations.hubspot.notes.unknownAuthor"), + date: + formatHubSpotDate(note.timestamp, locale) ?? + t("operations.hubspot.notSet"), + })} + +

    + {note.body} +

    +
    +
  • + ))} + {record.plan.references.map((value) => ( +
  • + + {value.label}:{" "} + {value.value ?? t("operations.hubspot.notSet")} + +
  • + ))} +
+ ) : ( + t("operations.hubspot.receipt.nothingKept") + )} +
+
{t("operations.hubspot.receipt.leftOut")}
+
+ {[ + record.plan.omitted.contacts + ? t("operations.hubspot.summary.contacts", { + count: record.plan.omitted.contacts, + }) + : "", + record.plan.omitted.notes + ? t("operations.hubspot.summary.notes", { + count: record.plan.omitted.notes, + }) + : "", + record.plan.omitted.documents + ? t("operations.hubspot.summary.documents", { + count: record.plan.omitted.documents, + }) + : "", + record.plan.omitted.properties + ? t("operations.hubspot.summary.references", { + count: record.plan.omitted.properties, + }) + : "", + ] + .filter(Boolean) + .join(", ") || t("operations.hubspot.receipt.nothingLeftOut")} +
+
+ + {record.items.length ? ( +
+

{t("operations.hubspot.group.documents")}

+
    + {record.items.map((item) => ( +
  • +
    + {item.title} + + {t(itemStatusLabels[item.status])} + +
    + {item.fileName} + {item.status === "failed" && item.errorCode ? ( + + {t(hubspotErrorMessage(item.errorCode))} + + ) : null} + {item.status === "duplicate" && item.existingAttachment ? ( + + {t("operations.hubspot.receipt.duplicate", { + title: item.existingAttachment.title, + })} + + ) : null} + {item.status === "excluded" && item.excludedReason ? ( + + {t("operations.hubspot.receipt.excludedBecause", { + reason: item.excludedReason, + })} + + ) : null} + {canEdit && + ["pending", "failed", "duplicate"].includes(item.status) ? ( + excluding?.id === item.id ? ( +
    + + setExcluding({ + id: item.id, + reason: event.currentTarget.value, + }) + } + /> +
    + + +
    +
    + ) : ( +
    + {item.status === "duplicate" ? ( + + ) : ( + + )} + +
    + ) + ) : null} +
  • + ))} +
+

+ {t("operations.hubspot.receipt.appendOnly")} +

+
+ ) : null} + +
+

{t("operations.hubspot.continue.title")}

+

{t("operations.hubspot.continue.body")}

+
+ {canEdit ? ( + + {t("operations.hubspot.continue.relationship")} + + ) : null} + {canEdit && deal ? ( + + {t("operations.hubspot.continue.register")} + + ) : null} + + {t("operations.hubspot.continue.mnda")} + + {record.items.some( + (item) => item.status === "attached" || item.status === "reused", + ) ? ( + + {t("operations.hubspot.continue.documents")} + + ) : null} + + {t("operations.hubspot.continue.source")} + +
+ {deal ? ( +

+ {t("operations.hubspot.continue.registrationNotCreated")} +

+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/features/internal-ops/hubspot/review-page.tsx b/apps/web/src/features/internal-ops/hubspot/review-page.tsx new file mode 100644 index 00000000..5fee5b50 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/review-page.tsx @@ -0,0 +1,208 @@ +import type { Route } from "next"; +import Link from "next/link"; + +import { + Breadcrumbs, + Button, + InlineNotice, + PageHeader, + StatusBadge, + Tag, + buttonClassName, +} from "@clockwork/ui"; + +import { breadcrumbsLabel } from "@/src/features/shared/ui-kit-labels"; +import type { Translator } from "@/src/i18n"; + +import { partnerHref, partnersPath } from "../partners/model"; +import { hubspotPickerPath, type DealCompanyChoice } from "./model"; +import type { loadReview } from "./page-data"; +import { HandoffReview } from "./review"; +import styles from "./hubspot.module.css"; + +type Ready = Extract>, { kind: "ready" }>; + +/** + * A deal names its companies; which one becomes the Commerce partner is a + * person's choice. Every option starts unselected, and a HubSpot "Primary" + * label is shown as context, never as a default. + */ +function DealChoice({ + t, + choice, + partner, +}: { + t: Translator; + choice: DealCompanyChoice; + partner: string | null; +}) { + return ( +
+ + {partner ? : null} + {choice.companies.length ? ( +
+ + {t("operations.hubspot.choose.legend")} + +

{t("operations.hubspot.choose.body")}

+ {choice.companies.map((company) => ( + + ))} +
+ +
+
+ ) : ( + <> + +
+ + {t("operations.hubspot.choose.pick")} + +
+ + )} + + ); +} + +/** The handoff review page: a deal's company choice, or the review itself. */ +export function ReviewPage({ t, loaded }: { t: Translator; loaded: Ready }) { + const crumbs = [ + { label: t("operations.partners.title"), href: partnersPath }, + { label: t("operations.hubspot.picker.title"), href: hubspotPickerPath }, + { label: t("operations.hubspot.review.title") }, + ]; + if (loaded.step === "choose_company") + return ( +
+ ( + {label} + )} + /> + + {t("operations.hubspot.openInHubSpot")} + + } + /> + +
+ ); + const destination = loaded.destination; + return ( +
+ {label}} + /> + + {loaded.requestedOther && destination ? ( + + {t("operations.hubspot.openPartner")} + + } + /> + ) : null} + +
+ ); +} diff --git a/apps/web/src/features/internal-ops/hubspot/review.tsx b/apps/web/src/features/internal-ops/hubspot/review.tsx new file mode 100644 index 00000000..26d6758b --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/review.tsx @@ -0,0 +1,1603 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + useMemo, + useRef, + useState, + useTransition, + type ReactNode, +} from "react"; + +import { + HubSpotHandoffInputSchema, + accountDocumentKinds, + countHubSpotPlan, + defaultHubSpotDecision, + classifyHubSpotDrift, + hubspotPartnerFields, + hubspotPlanIsLinkOnly, + partnerModels, + partnerStatuses, + type AccountDocumentKind, + type HubSpotFieldDecision, + type HubSpotFieldHistory, + type HubSpotHandoffPlan, + type HubSpotPartnerField, + type PartnerModel, + type PartnerOwnerOption, + type PartnerRecord, + type PartnerStatus, +} from "@clockwork/contracts"; +import { + Button, + Checkbox, + Fieldset, + InlineNotice, + Input, + Select, + StatusBadge, + Tag, + buttonClassName, +} from "@clockwork/ui"; + +import { useUnsavedChangesWarning } from "@/src/features/customer-partner/unsaved-changes"; +import { useFormattingLocale, useTranslations } from "@/src/i18n/client"; +import type { MessageId, Translator } from "@/src/i18n"; + +import { LocalTimestamp } from "../local-timestamp"; +import { + partnerHref, + partnerModelLabels, + partnerStatusLabels, +} from "../partners/model"; +import { commitHubSpotHandoff } from "./actions"; +import { + driftLabels, + formatHubSpotAmount, + formatHubSpotDate, + handoffHref, + hubspotErrorMessage, + hubspotPickerPath, + receiptHref, + type HandoffSource, +} from "./model"; +import type { OwnerProposal } from "./page-data"; +import { buildHubSpotPlan } from "./plan"; +import styles from "./hubspot.module.css"; + +const fieldLabels: Readonly> = { + name: "operations.partners.field.name", + website: "operations.partners.field.website", + region: "operations.partners.field.region", +}; + +const documentKindLabels: Readonly> = { + mnda: "operations.accountDocuments.kind.mnda", + agreement: "operations.accountDocuments.kind.agreement", + other: "operations.accountDocuments.kind.other", +}; + +interface Suggestion { + id: string; + name: string; + website: string; + reason: "name" | "website"; + linkedCompanyId: string | null; +} + +interface ContactChoice { + selected: boolean; + /** "new", an existing contact ID, or "" while the person has not chosen. */ + match: string; +} + +interface DocumentChoice { + selected: boolean; + title: string; + kind: AccountDocumentKind; +} + +function bytes(size: number | null, locale: string): string | null { + if (size === null) return null; + const units = ["byte", "kilobyte", "megabyte"] as const; + let value = size; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return new Intl.NumberFormat(locale, { + style: "unit", + unit: units[unit], + unitDisplay: "short", + maximumFractionDigits: 1, + }).format(value); +} + +/** The footer sentence, from the same plan the commit uses. */ +function planSummary( + plan: HubSpotHandoffPlan, + t: Translator, + locale: string, + companyName: string, +): string { + const counts = countHubSpotPlan(plan); + const list = (parts: string[]) => + new Intl.ListFormat(locale, { type: "conjunction" }).format(parts); + const context = [ + counts.contactsAdded + ? t("operations.hubspot.summary.contacts", { + count: counts.contactsAdded, + }) + : "", + counts.contactsLinked + ? t("operations.hubspot.summary.contactsLinked", { + count: counts.contactsLinked, + }) + : "", + counts.deals + ? t("operations.hubspot.summary.deals", { count: counts.deals }) + : "", + counts.notes + ? t("operations.hubspot.summary.notes", { count: counts.notes }) + : "", + counts.references + ? t("operations.hubspot.summary.references", { count: counts.references }) + : "", + counts.documents + ? t("operations.hubspot.summary.documents", { count: counts.documents }) + : "", + ].filter(Boolean); + if (counts.create) + return context.length + ? t("operations.hubspot.summary.create", { + name: plan.destination.name, + parts: list(context), + }) + : t("operations.hubspot.summary.createOnly", { + name: plan.destination.name, + }); + if (hubspotPlanIsLinkOnly(plan)) + return t("operations.hubspot.summary.linkOnly", { + company: companyName, + partner: plan.destination.name, + }); + const changes = [ + counts.added + ? t("operations.hubspot.summary.added", { count: counts.added }) + : "", + counts.replaced + ? t("operations.hubspot.summary.replaced", { count: counts.replaced }) + : "", + counts.cleared + ? t("operations.hubspot.summary.cleared", { count: counts.cleared }) + : "", + ...context, + ].filter(Boolean); + const kept = plan.fields.filter( + (field) => field.action === "keep" && field.current !== "", + ).length; + return t("operations.hubspot.summary.update", { + changes: changes.length + ? list(changes) + : t("operations.hubspot.summary.noChanges"), + kept, + }); +} + +function Endpoint({ + label, + name, + detail, + link, +}: { + label: string; + name: string; + detail?: ReactNode; + link?: ReactNode; +}) { + return ( +
+ {label} + {name} + {detail} + {link} +
+ ); +} + +/** + * One coherent handoff: the HubSpot source on the left, the Commerce partner + * it becomes or updates on the right, and every group of information a + * person may bring across. Nothing optional starts selected except the + * originating deal as context. The footer states the plan's result in plain + * words and the one action that commits it. + */ +export function HandoffReview({ + source, + connection, + destination, + history, + suggestions, + owners, + ownerProposal, + linkedElsewhere, + canImport, + demo, +}: { + source: HandoffSource; + connection: { id: string; version: number }; + destination: PartnerRecord | null; + history: HubSpotFieldHistory; + suggestions: readonly Suggestion[]; + owners: readonly PartnerOwnerOption[]; + ownerProposal: OwnerProposal; + linkedElsewhere: string | null; + canImport: boolean; + demo: boolean; +}) { + const t = useTranslations(); + const locale = useFormattingLocale(); + const router = useRouter(); + const operationId = useRef(null); + const newPartnerId = useRef(null); + const summaryRef = useRef(null); + const [pending, startTransition] = useTransition(); + const [dirty, setDirty] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [failure, setFailure] = useState(null); + const [changedItems, setChangedItems] = useState(null); + const [alreadyLinked, setAlreadyLinked] = useState<{ + partnerId: string; + partnerName: string; + } | null>(null); + const [showErrors, setShowErrors] = useState(false); + const [confirmingCancel, setConfirmingCancel] = useState(false); + + // Destination: a new partner's proposed details, or an update's decisions. + const [name, setName] = useState(source.proposed.name); + const [website, setWebsite] = useState(source.proposed.website); + const [region, setRegion] = useState(source.proposed.region); + const [status, setStatus] = useState("prospect"); + const [ownerId, setOwnerId] = useState( + ownerProposal.kind === "none" ? "" : ownerProposal.ownerId, + ); + const [models, setModels] = useState([]); + const [dismissed, setDismissed] = useState< + Record + >({}); + const drift = (field: HubSpotPartnerField) => + classifyHubSpotDrift({ + lastSource: history[field]?.lastSource, + lastResult: history[field]?.lastResult, + commerce: destination?.[field] ?? "", + hubspot: source.proposed[field], + }); + const [decisions, setDecisions] = useState< + Record + >( + () => + Object.fromEntries( + hubspotPartnerFields.map((field) => [ + field, + destination + ? defaultHubSpotDecision({ + drift: drift(field), + commerce: destination[field], + hubspot: source.proposed[field], + }) + : "keep", + ]), + ) as Record, + ); + + // Groups: nothing optional starts selected; the deal is kept as context. + const [contacts, setContacts] = useState>({}); + const [primary, setPrimary] = useState(""); + const [includeDeal, setIncludeDeal] = useState(source.deal !== null); + const [endCustomer, setEndCustomer] = useState(""); + const [notes, setNotes] = useState([]); + const [references, setReferences] = useState([]); + const [documents, setDocuments] = useState>( + {}, + ); + + useUnsavedChangesWarning(dirty && !submitted); + const touch = () => setDirty(true); + + const existingContacts = destination?.contacts ?? []; + const linkedContact = (hubspotId: string) => + existingContacts.find( + (contact) => + contact.source?.portalId === source.portal.portalId && + contact.source.objectId === hubspotId, + ); + const emailMatch = (email: string) => + email + ? existingContacts.find( + (contact) => contact.id && !contact.source && contact.email === email, + ) + : undefined; + + const rawInput = () => { + operationId.current ??= crypto.randomUUID(); + newPartnerId.current ??= crypto.randomUUID(); + const selectedContacts = Object.entries(contacts).filter( + ([, choice]) => choice.selected, + ); + return { + operationId: operationId.current, + connection, + source: { + companyId: source.company.id, + dealId: source.deal?.reference.id ?? null, + }, + reviewed: source.digests, + destination: destination + ? { + kind: "existing_partner", + partnerId: destination.id, + expectedVersion: destination.version, + fields: Object.fromEntries( + hubspotPartnerFields.map((field) => [ + field, + decisions[field] ?? "keep", + ]), + ), + } + : { + kind: "new_partner", + partnerId: newPartnerId.current, + name, + website, + region, + status, + ownerId, + models, + dismissedSuggestions: suggestions + .filter((suggestion) => dismissed[suggestion.id]?.done) + .map((suggestion) => ({ + partnerId: suggestion.id, + reason: dismissed[suggestion.id]?.reason ?? "", + })), + }, + contacts: selectedContacts.map(([hubspotId, choice]) => ({ + hubspotId, + matchContactId: + choice.match && choice.match !== "new" ? choice.match : null, + })), + primaryContact: primary.startsWith("hubspot:") + ? { kind: "hubspot", id: primary.slice(8) } + : primary.startsWith("commerce:") + ? { kind: "commerce", id: primary.slice(9) } + : null, + includeDeal, + endCustomerCompanyId: includeDeal && endCustomer ? endCustomer : null, + notes, + referenceProperties: references.map((key) => { + const [objectType, property] = key.split(":"); + return { objectType, name: property }; + }), + documents: Object.entries(documents) + .filter(([, choice]) => choice.selected) + .map(([fileId, choice]) => ({ + fileId, + title: choice.title, + kind: choice.kind, + })), + }; + }; + + // The plan the footer describes is the plan the server will build. + const preview = useMemo(() => { + const parsed = HubSpotHandoffInputSchema.safeParse(rawInput()); + if (!parsed.success) return null; + try { + return buildHubSpotPlan({ + source, + handoff: parsed.data, + destination, + history, + ownerName: owners.find((owner) => owner.id === ownerId)?.name ?? null, + newId: () => "00000000-0000-4000-8000-000000000000", + }).plan; + } catch { + return null; + } + // rawInput reads the state listed here. + }, [ + name, + website, + region, + status, + ownerId, + models, + dismissed, + decisions, + contacts, + primary, + includeDeal, + endCustomer, + notes, + references, + documents, + source, + destination, + ]); + + // Everything that must be decided before the commit, each linked to its field. + const issues: { id: string; label: string }[] = []; + if (!destination && !name.trim()) + issues.push({ + id: "handoff-name", + label: t("operations.hubspot.issue.name"), + }); + if (destination) + for (const field of hubspotPartnerFields) + if (decisions[field] === null) + issues.push({ + id: `decision-${field}`, + label: t("operations.hubspot.issue.decision", { + field: t(fieldLabels[field]), + }), + }); + if (!destination) + for (const suggestion of suggestions) + if (!dismissed[suggestion.id]?.done) + issues.push({ + id: `suggestion-${suggestion.id}`, + label: t("operations.hubspot.issue.suggestion", { + name: suggestion.name, + }), + }); + for (const [hubspotId, choice] of Object.entries(contacts)) { + const person = source.contacts.find((c) => c.id === hubspotId); + if (choice.selected && person && emailMatch(person.email) && !choice.match) + issues.push({ + id: `contact-${hubspotId}`, + label: t("operations.hubspot.issue.contactMatch", { + name: person.name, + }), + }); + } + for (const [fileId, choice] of Object.entries(documents)) + if (choice.selected && !choice.title.trim()) + issues.push({ + id: `document-${fileId}`, + label: t("operations.hubspot.issue.documentTitle"), + }); + + const linkOnly = preview ? hubspotPlanIsLinkOnly(preview) : false; + const primaryLabel = !destination + ? t("operations.hubspot.action.create") + : linkOnly + ? t("operations.hubspot.action.link") + : t("operations.hubspot.action.import"); + const blocked = Boolean(linkedElsewhere) || !canImport; + const advancedInEffect = [ + references.length + ? t("operations.hubspot.more.references", { count: references.length }) + : "", + hubspotPartnerFields.some((field) => decisions[field] === "clear") + ? t("operations.hubspot.more.clears") + : "", + ].filter(Boolean); + + function submit() { + setShowErrors(true); + setFailure(null); + setAlreadyLinked(null); + if (issues.length) { + requestAnimationFrame(() => summaryRef.current?.focus()); + return; + } + startTransition(async () => { + const result = await commitHubSpotHandoff(rawInput()).catch(() => ({ + ok: false as const, + code: "UNEXPECTED", + })); + if (!result.ok) { + setFailure(result.code); + requestAnimationFrame(() => summaryRef.current?.focus()); + return; + } + const value = result.value; + if (value.kind === "source_changed") { + // Fresh source, same selections: the page reloads its data only. + setChangedItems(value.items); + router.refresh(); + requestAnimationFrame(() => summaryRef.current?.focus()); + return; + } + if (value.kind === "already_linked") { + setAlreadyLinked(value); + requestAnimationFrame(() => summaryRef.current?.focus()); + return; + } + setSubmitted(true); + router.push( + receiptHref(value.partnerId, value.importId, { + start: value.pendingDocuments > 0, + }), + ); + }); + } + + const changedLabel = (key: string) => { + if (key === "company") return t("operations.hubspot.group.company"); + if (key === "deal") return t("operations.hubspot.group.context"); + const [kind, id] = key.split(":"); + if (kind === "contact") + return source.contacts.find((c) => c.id === id)?.name ?? key; + if (kind === "note") return t("operations.hubspot.group.notes"); + return source.files?.find((file) => file.id === id)?.name ?? key; + }; + + const deal = source.deal?.reference ?? null; + const ownerReason = (() => { + const proposedId = + ownerProposal.kind === "none" ? "" : ownerProposal.ownerId; + if (ownerId !== proposedId) return t("operations.hubspot.owner.chosen"); + if (ownerProposal.kind === "mapped") + return t("operations.hubspot.owner.mapped", { + name: ownerProposal.hubspotOwner, + }); + if (ownerProposal.kind === "viewer") + return ownerProposal.hubspotOwner + ? t("operations.hubspot.owner.viewerUnmapped", { + name: ownerProposal.hubspotOwner, + }) + : t("operations.hubspot.owner.viewerNoOwner"); + return ownerProposal.hubspotOwner + ? t("operations.hubspot.owner.noneUnmapped", { + name: ownerProposal.hubspotOwner, + }) + : t("operations.hubspot.owner.none"); + })(); + + const referenceOptions = [ + ...source.companyValues, + ...(source.deal?.values ?? []), + ]; + + return ( +
+
+ + {deal + ? t("operations.hubspot.review.fromDeal", { deal: deal.name }) + : t("operations.hubspot.review.fromCompany")} + + } + link={ + + {t("operations.hubspot.openInHubSpot")} + + } + /> + + + {t("operations.hubspot.review.storedOn")} + + } + link={ + destination ? ( + + {t("operations.hubspot.openPartner")} + + ) : undefined + } + /> +
+ + + {t("operations.hubspot.review.read")}{" "} + + + + {demo ? ( + + ) : !canImport ? ( + + ) : null} + {linkedElsewhere ? ( + + ) : null} + +
+ {changedItems ? ( + + ) : null} + {alreadyLinked ? ( + + + {t("operations.hubspot.openPartner")} + + setSubmitted(true)} + > + {t("operations.hubspot.reviewUpdates")} + + + } + /> + ) : null} + {failure ? ( + + ) : null} + {showErrors && issues.length ? ( +
+

+ {t("operations.hubspot.issue.title", { count: issues.length })} +

+ +
+ ) : null} +
+ + {destination ? ( +
+

{t("operations.hubspot.compare.title")}

+

{t("operations.hubspot.compare.body")}

+
+ + + + + + + + + + + {hubspotPartnerFields.map((field) => { + const current = destination[field]; + const hubspot = source.proposed[field]; + const fieldDrift = drift(field); + const decision = decisions[field]; + const options: HubSpotFieldDecision[] = [ + "keep", + "use_hubspot", + ...(field !== "name" && current ? (["clear"] as const) : []), + ]; + return ( + + + + + + + ); + })} + +
+ {t("operations.hubspot.compare.title")} +
{t("operations.hubspot.compare.field")}{t("operations.hubspot.compare.commerce")}{t("operations.hubspot.compare.hubspot")}{t("operations.hubspot.compare.decision")}
+ + {t(fieldLabels[field])} + {fieldDrift !== "never_imported" && + fieldDrift !== "unchanged" ? ( + + {t(driftLabels[fieldDrift])} + + ) : null} + + + {current || ( + + {t("operations.hubspot.notSet")} + + )} + + {hubspot || ( + + {t("operations.hubspot.notSet")} + + )} + +
+ + {t("operations.hubspot.compare.decisionFor", { + field: t(fieldLabels[field]), + })} + + {options.map((option) => ( + + ))} +
+
+

+ {t("operations.hubspot.compare.unchanged")} +

+
+ ) : ( +
+

{t("operations.hubspot.group.company")}

+ {suggestions.length ? ( + + ) : null} + {suggestions.length ? ( +
    + {suggestions.map((suggestion) => { + const state = dismissed[suggestion.id] ?? { + open: false, + reason: "", + done: false, + }; + const set = (patch: Partial) => { + touch(); + setDismissed((all) => ({ + ...all, + [suggestion.id]: { ...state, ...patch }, + })); + }; + return ( +
  • +
    +

    {suggestion.name}

    + + {t( + suggestion.reason === "name" + ? "operations.hubspot.suggestion.sameName" + : "operations.hubspot.suggestion.sameWebsite", + )} + + {state.done ? ( + + {t("operations.hubspot.suggestion.dismissed")} + + ) : null} +
    + {suggestion.website ? ( + + {suggestion.website} + + ) : null} + {suggestion.linkedCompanyId ? ( + + {t("operations.hubspot.suggestion.linkedOther")} + + ) : null} +
    + + {t("operations.hubspot.suggestion.inspect")} + + {suggestion.linkedCompanyId ? null : ( + setSubmitted(true)} + > + {t("operations.hubspot.suggestion.useThis")} + + )} + {state.done ? ( + + ) : state.open ? null : ( + + )} +
    + {state.open && !state.done ? ( +
    + + set({ reason: event.currentTarget.value }) + } + /> +
    + +
    +
    + ) : null} +
  • + ); + })} +
+ ) : null} +
+ { + touch(); + setName(event.currentTarget.value); + }} + help={ + name.trim() !== source.proposed.name + ? t("operations.hubspot.inHubSpot", { + value: + source.proposed.name || t("operations.hubspot.notSet"), + }) + : undefined + } + error={ + showErrors && !name.trim() + ? t("operations.hubspot.issue.name") + : undefined + } + /> + { + touch(); + setWebsite(event.currentTarget.value); + }} + help={ + website.trim() !== source.proposed.website + ? t("operations.hubspot.inHubSpot", { + value: + source.proposed.website || + t("operations.hubspot.notSet"), + }) + : undefined + } + /> + { + touch(); + setRegion(event.currentTarget.value); + }} + help={ + region.trim() !== source.proposed.region + ? t("operations.hubspot.inHubSpot", { + value: + source.proposed.region || + t("operations.hubspot.notSet"), + }) + : undefined + } + /> + { + touch(); + setOwnerId(event.currentTarget.value); + }} + options={[ + { value: "", label: t("operations.partners.unowned") }, + ...owners.map((owner) => ({ + value: owner.id, + label: owner.name, + })), + ]} + /> +
+ + {t("operations.hubspot.models")} + + {partnerModels.map((model) => ( + + ))} +
+
+
+ )} + +
+ {source.contacts.length ? ( +
    + {source.contacts.map((person) => { + const choice = contacts[person.id] ?? { + selected: false, + match: "", + }; + const linked = linkedContact(person.id); + const match = linked ? undefined : emailMatch(person.email); + const set = (patch: Partial) => { + touch(); + setContacts((all) => ({ + ...all, + [person.id]: { ...choice, ...patch }, + })); + }; + return ( +
  • + { + const selected = event.currentTarget.checked; + set({ selected }); + if (!selected && primary === `hubspot:${person.id}`) + setPrimary(""); + }} + description={ + [ + person.role, + person.email, + person.labels.length + ? t("operations.hubspot.people.labels", { + labels: person.labels.join(", "), + }) + : "", + ] + .filter(Boolean) + .join(" · ") || undefined + } + /> + {linked ? ( + + {t("operations.hubspot.people.alreadyLinked", { + name: linked.name, + })} + + ) : null} + {match && choice.selected ? ( +
    + { + touch(); + setPrimary(event.currentTarget.value); + }} + options={[ + { + value: "", + label: t("operations.hubspot.people.primaryUnchanged"), + }, + ...source.contacts + .filter((person) => contacts[person.id]?.selected) + .map((person) => ({ + value: `hubspot:${person.id}`, + label: person.name, + })), + ...existingContacts + .filter( + (contact) => + contact.id && + !source.contacts.some( + (person) => + contacts[person.id]?.selected && + linkedContact(person.id)?.id === contact.id, + ), + ) + .map((contact) => ({ + value: `commerce:${contact.id ?? ""}`, + label: t("operations.hubspot.people.existing", { + name: contact.name, + }), + })), + ]} + /> + ) : null} +
+ +
+ {deal ? ( +
+ { + touch(); + setIncludeDeal(event.currentTarget.checked); + if (!event.currentTarget.checked) setEndCustomer(""); + }} + description={t("operations.hubspot.context.keepDealHelp")} + /> +
+
{t("operations.hubspot.deal.stage")}
+
+ {[deal.pipeline.label, deal.stage.label] + .filter(Boolean) + .join(" · ") || t("operations.hubspot.notSet")} +
+
{t("operations.hubspot.deal.amount")}
+
+ {deal.amount && deal.currency + ? formatHubSpotAmount(deal.amount, deal.currency, locale) + : deal.amount + ? t("operations.hubspot.deal.amountNoCurrency", { + amount: deal.amount, + }) + : t("operations.hubspot.notSet")} +
+
{t("operations.hubspot.deal.close")}
+
+ {formatHubSpotDate(deal.closeDate, locale) ?? + t("operations.hubspot.notSet")} +
+
{t("operations.hubspot.deal.owner")}
+
+ {deal.owner?.name ?? + deal.owner?.id ?? + t("operations.hubspot.noOwner")} +
+
{t("operations.hubspot.deal.companies")}
+
+
    + {deal.associatedCompanies.map((company) => ( +
  • + + {company.name} + {company.id === source.company.id + ? ` · ${t("operations.hubspot.deal.becomesPartner")}` + : ""} + + {company.labels.length ? ( + + {company.labels.map((label) => ( + {label} + ))} + + ) : null} +
  • + ))} +
+
+
+ {includeDeal && + deal.associatedCompanies.some((c) => c.id !== source.company.id) ? ( +
+ + set({ title: event.currentTarget.value }) + } + error={ + showErrors && !choice.title.trim() + ? t("operations.hubspot.issue.documentTitle") + : undefined + } + /> + setPipelineId(event.currentTarget.value)} + options={[ + { + value: "", + label: t("operations.hubspot.settings.pipelineFirst"), + }, + ...catalog.pipelines.map((pipeline) => ({ + value: pipeline.id, + label: pipeline.label, + })), + ]} + /> +
+
+ {catalog.owners.map((owner) => ( + diff --git a/apps/web/src/features/internal-ops/partners/partner-detail.tsx b/apps/web/src/features/internal-ops/partners/partner-detail.tsx index b795995c..bedd80b5 100644 --- a/apps/web/src/features/internal-ops/partners/partner-detail.tsx +++ b/apps/web/src/features/internal-ops/partners/partner-detail.tsx @@ -97,11 +97,33 @@ function historyLine(entry: PartnerActivity, t: Translator): string { const client = entry.dealEndClient ?? ""; switch (entry.eventType) { case "partner.created": - return t("operations.partners.history.created", { actor }); + return entry.source === "hubspot" + ? t("operations.partners.history.createdFromHubSpot", { actor }) + : t("operations.partners.history.created", { actor }); case "partner.updated": - return t("operations.partners.history.updated", { + return t( + entry.source === "hubspot" + ? "operations.partners.history.updatedFromHubSpot" + : "operations.partners.history.updated", + { actor, fields: changedFields(entry, t) }, + ); + case "hubspot.imported": + return t("operations.partners.history.hubspotImported", { actor }); + case "hubspot.document_attached": + return t("operations.partners.history.hubspotDocumentAttached", { + actor, + }); + case "hubspot.document_failed": + return t("operations.partners.history.hubspotDocumentFailed", { actor }); + case "hubspot.document_duplicate": + return t("operations.partners.history.hubspotDocumentDuplicate", { + actor, + }); + case "hubspot.document_reused": + return t("operations.partners.history.hubspotDocumentReused", { actor }); + case "hubspot.document_excluded": + return t("operations.partners.history.hubspotDocumentExcluded", { actor, - fields: changedFields(entry, t), }); case "partner.deal_registered": return t("operations.partners.history.dealRegistered", { actor, client }); @@ -205,6 +227,7 @@ function Deals({ canEdit, protectionDays, organizations, + dealSuggestion, }: { partner: PartnerRecord; deals: readonly PartnerDealWithConflicts[]; @@ -214,9 +237,14 @@ function Deals({ canEdit: boolean; protectionDays: number; organizations: readonly PartnerOrganizationOption[]; + dealSuggestion: { endClient: string; notes: string } | null; }) { return ( -
+

{t("operations.partners.deals.title")}

{t("operations.partners.deals.description")} @@ -228,6 +256,7 @@ function Deals({ today={today} protectionDays={protectionDays} organizations={organizations} + suggestion={dealSuggestion} /> ) : null} {deals.length === 0 ? ( @@ -424,6 +453,9 @@ export function PartnerDetail({ canEdit, demo, documents, + hubspot, + receipt, + dealSuggestion = null, }: { t: Translator; locale: string; @@ -437,6 +469,11 @@ export function PartnerDetail({ canEdit: boolean; demo: boolean; documents?: ReactNode; + /** The HubSpot section and a just-finished import's receipt. */ + hubspot?: ReactNode; + receipt?: ReactNode; + /** A registration prepared from a HubSpot handoff, still unsaved. */ + dealSuggestion?: { endClient: string; notes: string } | null; }) { const overdue = partner.nextStepDue !== null && partner.nextStepDue < today; const overview: [string, ReactNode][] = [ @@ -540,7 +577,9 @@ export function PartnerDetail({ {demo ? ( ) : null} - {documents} + {receipt} + {documents ?

{documents}
: null} + {hubspot}
@@ -559,6 +598,7 @@ export function PartnerDetail({ canEdit={canEdit} protectionDays={protectionDays} organizations={organizations} + dealSuggestion={dealSuggestion} />
@@ -569,8 +609,23 @@ export function PartnerDetail({ {partner.contacts.length ? (
+

+ {t("operations.hubspot.audience")}{" "} + {t("operations.hubspot.noSecondConfirmation")} +

- {preview - ? planSummary(preview, t, locale, source.company.name) - : t("operations.hubspot.summary.incomplete")} + {nothingToDo + ? t("operations.hubspot.summary.nothingSelected") + : preview + ? planSummary(preview, t, locale, source.company.name) + : t("operations.hubspot.summary.incomplete")}

-

{t("operations.hubspot.audience")}

- - {t("operations.hubspot.noSecondConfirmation")} -
diff --git a/apps/web/src/features/internal-ops/hubspot/server.ts b/apps/web/src/features/internal-ops/hubspot/server.ts index ed7ce68d..bebabfcb 100644 --- a/apps/web/src/features/internal-ops/hubspot/server.ts +++ b/apps/web/src/features/internal-ops/hubspot/server.ts @@ -88,8 +88,20 @@ function configuredReader(): HubSpotReader | null { return token ? new HttpHubSpotReader({ accessToken: token }) : null; } -export const hubspotConfigured = () => - explicitDemoIdentityEnabled() || Boolean(configuredReader()); +/** + * Whether sellers can bring HubSpot records in here: the demo always can; a + * deployment needs a token and an approved connection. Never throws, so a + * page that only offers the entry point is not taken down by it. + */ +export async function hubspotReady(): Promise { + if (explicitDemoIdentityEnabled()) return true; + if (!process.env.HUBSPOT_ACCESS_TOKEN?.trim()) return false; + try { + return (await hubspotRepository().connection()).active !== null; + } catch { + return false; + } +} export function hubspotRepository(): HubSpotRepository { const database = getOptionalServiceDatabase(); diff --git a/apps/web/src/features/internal-ops/hubspot/setup-guide.tsx b/apps/web/src/features/internal-ops/hubspot/setup-guide.tsx new file mode 100644 index 00000000..0a838374 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/setup-guide.tsx @@ -0,0 +1,172 @@ +import Link from "next/link"; + +import { StatusBadge } from "@clockwork/ui"; + +import type { MessageId, Translator } from "@/src/i18n"; + +import { hubspotPickerPath, pipelinePath } from "./model"; +import styles from "./hubspot.module.css"; + +/** The read-only scopes the HubSpot private app needs, as HubSpot names them. */ +export const hubspotPrivateAppScopes = [ + "crm.objects.companies.read", + "crm.objects.deals.read", + "crm.objects.contacts.read", + "crm.objects.owners.read", + "crm.schemas.companies.read", + "crm.schemas.deals.read", + "crm.schemas.contacts.read", +] as const; + +export type HubSpotSetupStep = "token" | "deploy" | "approve" | "use"; + +/** + * Where an environment stands: no token, a token the deployment cannot use, + * a working token waiting for approval, or connected. + */ +export function hubspotSetupStage(input: { + tokenConfigured: boolean; + portalReachable: boolean; + active: boolean; +}): HubSpotSetupStep { + if (input.active && input.portalReachable) return "use"; + if (input.portalReachable) return "approve"; + if (input.tokenConfigured) return "deploy"; + return "token"; +} + +const order: readonly HubSpotSetupStep[] = [ + "token", + "deploy", + "approve", + "use", +]; + +const titles: Readonly> = { + token: "operations.hubspot.setup.tokenTitle", + deploy: "operations.hubspot.setup.deployTitle", + approve: "operations.hubspot.setup.approveTitle", + use: "operations.hubspot.setup.useTitle", +}; + +/** + * How to connect HubSpot, step by step, with where this environment stands. + * Administrators see it on the connection page until HubSpot is in use; the + * token itself is never shown or typed into Commerce. + */ +export function HubSpotSetupGuide({ + t, + stage, +}: { + t: Translator; + stage: HubSpotSetupStep; +}) { + const current = order.indexOf(stage); + const state = (step: HubSpotSetupStep) => { + const index = order.indexOf(step); + return index < current || (step === "use" && stage === "use") + ? "done" + : index === current + ? "current" + : "upcoming"; + }; + const badge = (step: HubSpotSetupStep) => { + const value = state(step); + return ( + + {t( + value === "done" + ? "operations.hubspot.setup.done" + : value === "current" + ? "operations.hubspot.setup.current" + : "operations.hubspot.setup.upcoming", + )} + + ); + }; + return ( +
+
+

{t("operations.hubspot.setup.title")}

+
+

{t("operations.hubspot.setup.body")}

+
    +
  1. +
    +

    1. {t(titles.token)}

    + {badge("token")} +
    +

    + {t("operations.hubspot.setup.tokenBody")} +

    +

    + {t("operations.hubspot.setup.scopes")} +

    +
      + {hubspotPrivateAppScopes.map((scope) => ( +
    • + {scope} +
    • + ))} +
    +

    + {t("operations.hubspot.setup.filesScope")} +

    +
  2. +
  3. +
    +

    2. {t(titles.deploy)}

    + {badge("deploy")} +
    +

    + {t("operations.hubspot.setup.deployBody")} +

    + {stage === "deploy" ? ( +

    + {t("operations.hubspot.setup.deployCheck")} +

    + ) : null} +
  4. +
  5. +
    +

    3. {t(titles.approve)}

    + {badge("approve")} +
    +

    + {t("operations.hubspot.setup.approveBody")} +

    +
  6. +
  7. +
    +

    4. {t(titles.use)}

    + {badge("use")} +
    +

    + {t("operations.hubspot.setup.useBody")} +

    + {stage === "use" ? ( +
    + + {t("operations.hubspot.setup.openPicker")} + + + {t("operations.hubspot.setup.openPipeline")} + + + {t("operations.hubspot.setup.openPartners")} + +
    + ) : null} +
  8. +
+
+ ); +} diff --git a/apps/web/src/features/internal-ops/partners/partner-list.tsx b/apps/web/src/features/internal-ops/partners/partner-list.tsx index 9ec80f5d..1f96887b 100644 --- a/apps/web/src/features/internal-ops/partners/partner-list.tsx +++ b/apps/web/src/features/internal-ops/partners/partner-list.tsx @@ -103,7 +103,7 @@ export function PartnerList({ today, canEdit, demo, - hubspot = false, + hubspot = "hidden", }: { t: Translator; locale: string; @@ -113,8 +113,9 @@ export function PartnerList({ today: string; canEdit: boolean; demo: boolean; - /** HubSpot is set up for this deployment, so a handoff can start here. */ - hubspot?: boolean; + /** Whether a HubSpot handoff can start here, or an administrator should + * be shown how to connect HubSpot. */ + hubspot?: "ready" | "setup" | "hidden"; }) { const filtered = Boolean( query.q || @@ -146,7 +147,7 @@ export function PartnerList({ {t("operations.partners.new")} ) : null} - {hubspot && (canEdit || demo) ? ( + {hubspot === "ready" && (canEdit || demo) ? ( ) : null} + {hubspot === "setup" ? ( + + {t("operations.hubspot.setup.noticeAction")} + + } + /> + ) : null} {result.truncated ? ( `apps/web/app/(experience)/(internal)/internal/hubspot/review/page.tsx`
`apps/web/app/(experience)/(internal)/internal/page.tsx`
`apps/web/app/(experience)/(internal)/internal/partners/[id]/page.tsx`
`apps/web/app/(experience)/(internal)/internal/partners/export/route.ts`
`apps/web/app/(experience)/(internal)/internal/partners/page.tsx`
`apps/web/app/(experience)/(internal)/internal/pipeline/page.tsx`
`apps/web/app/(experience)/(internal)/internal/pricing/page.tsx`
`apps/web/app/(experience)/(internal)/internal/pricing/scenarios/[id]/summary/route.ts`
`apps/web/app/(experience)/(internal)/internal/sales-library/items/[id]/route.ts`
`apps/web/app/(experience)/(internal)/internal/sales-library/page.tsx`
`apps/web/src/features/experience-server/authorization.ts`
`apps/web/src/features/internal-ops/account-documents/server.ts`
`apps/web/src/features/internal-ops/contracts/demo-access.ts`
`apps/web/src/features/internal-ops/contracts/scenario-import.ts`
`apps/web/src/features/internal-ops/hubspot/actions.ts`
`apps/web/src/features/internal-ops/hubspot/page-data.ts`
`apps/web/src/features/internal-ops/partners/actions.ts`
`apps/web/src/features/internal-ops/partners/server.ts`
`apps/web/src/features/internal-ops/sales-home/partner-source.ts`
`apps/web/src/features/internal-ops/sales-pricing/scenario-actions.ts`
`apps/web/src/features/internal-ops/sales-pricing/scenario-server.ts`
`apps/web/src/features/internal-ops/team/model.ts`
`apps/web/src/features/mcp/tools/handoffs.ts`
`apps/web/src/features/mcp/tools/partners.ts`
`apps/web/src/features/mcp/tools/pricing.ts`
`apps/web/src/features/shell/navigation.ts` | | `collateral:manage` | `apps/web/app/(experience)/(internal)/internal/sales-library/items/[id]/route.ts`
`apps/web/app/(experience)/(internal)/internal/sales-library/items/route.ts`
`apps/web/app/(experience)/(internal)/internal/sales-library/page.tsx`
`apps/web/src/features/internal-ops/sales-library/actions.ts`
`apps/web/src/features/internal-ops/team/model.ts`
`apps/web/src/features/mcp/tools/handoffs.ts` | | `operations:read` | `apps/web/app/(experience)/(internal)/internal/accounts/[id]/page.tsx`
`apps/web/app/(experience)/(internal)/internal/agreements/page.tsx`
`apps/web/app/(experience)/(internal)/internal/approvals/page.tsx`
`apps/web/app/(experience)/(internal)/internal/assisted/page.tsx`
`apps/web/app/(experience)/(internal)/internal/billing-reconciliation/page.tsx`
`apps/web/app/(experience)/(internal)/internal/capabilities/actions.ts`
`apps/web/app/(experience)/(internal)/internal/capabilities/page.tsx`
`apps/web/app/(experience)/(internal)/internal/catalog/page.tsx`
`apps/web/app/(experience)/(internal)/internal/channel-policy/page.tsx`
`apps/web/app/(experience)/(internal)/internal/collections/page.tsx`
`apps/web/app/(experience)/(internal)/internal/gates/page.tsx`
`apps/web/app/(experience)/(internal)/internal/handoffs/[id]/page.tsx`
`apps/web/app/(experience)/(internal)/internal/handoffs/page.tsx`
`apps/web/app/(experience)/(internal)/internal/migrations/page.tsx`
`apps/web/app/(experience)/(internal)/internal/operations/page.tsx`
`apps/web/app/(experience)/(internal)/internal/organizations/[id]/page.tsx`
`apps/web/app/(experience)/(internal)/internal/organizations/page.tsx`
`apps/web/app/(experience)/(internal)/internal/page.tsx`
`apps/web/app/(experience)/(internal)/internal/payg-offers/page.tsx`
`apps/web/app/(experience)/(internal)/internal/payg-requests/page.tsx`
`apps/web/app/(experience)/(internal)/internal/price-books/page.tsx`
`apps/web/app/(experience)/(internal)/internal/providers/page.tsx`
`apps/web/app/(experience)/(internal)/internal/provisioning/page.tsx`
`apps/web/app/(experience)/(internal)/internal/queues/[id]/page.tsx`
`apps/web/app/(experience)/(internal)/internal/queues/page.tsx`
`apps/web/app/(experience)/(internal)/internal/recovery/page.tsx`
`apps/web/app/(experience)/(internal)/internal/renewals/page.tsx`
`apps/web/app/(experience)/(internal)/internal/reports/page.tsx`
`apps/web/app/(experience)/(internal)/internal/revenue/page.tsx`
`apps/web/app/(experience)/(internal)/internal/search/page.tsx`
`apps/web/app/(experience)/(internal)/internal/status/page.tsx`
`apps/web/app/(experience)/(internal)/internal/unhandled-errors/page.tsx`
`apps/web/app/(experience)/(internal)/internal/webhook-replay/page.tsx`
`apps/web/src/features/experience-server/authorization.ts`
`apps/web/src/features/internal-ops/account-documents/server.ts`
`apps/web/src/features/internal-ops/handoff/server.ts`
`apps/web/src/features/internal-ops/organizations/server.ts`
`apps/web/src/features/internal-ops/team/model.ts`
`apps/web/src/features/mcp/tools/handoffs.ts`
`apps/web/src/features/shell/app-shell.tsx`
`apps/web/src/features/shell/command-items.ts`
`apps/web/src/features/shell/navigation.ts` | -| `staff:manage` | `apps/web/app/(experience)/(internal)/internal/notifications/settings/page.tsx`
`apps/web/app/(experience)/(internal)/internal/owner/hubspot/page.tsx`
`apps/web/app/(experience)/(internal)/internal/owner/page.tsx`
`apps/web/app/(experience)/(internal)/internal/team/page.tsx`
`apps/web/src/features/internal-ops/access-tokens/actions.ts`
`apps/web/src/features/internal-ops/access-tokens/server.ts`
`apps/web/src/features/internal-ops/hubspot/actions.ts`
`apps/web/src/features/internal-ops/hubspot/page-data.ts`
`apps/web/src/features/internal-ops/notifications/server.ts`
`apps/web/src/features/internal-ops/owner-console/actions.ts`
`apps/web/src/features/internal-ops/team/actions.ts`
`apps/web/src/features/internal-ops/team/model.ts`
`apps/web/src/features/mcp/tools/staff.ts`
`apps/web/src/features/shell/navigation.ts` | +| `staff:manage` | `apps/web/app/(experience)/(internal)/internal/notifications/settings/page.tsx`
`apps/web/app/(experience)/(internal)/internal/owner/hubspot/page.tsx`
`apps/web/app/(experience)/(internal)/internal/owner/page.tsx`
`apps/web/app/(experience)/(internal)/internal/team/page.tsx`
`apps/web/src/features/internal-ops/access-tokens/actions.ts`
`apps/web/src/features/internal-ops/access-tokens/server.ts`
`apps/web/src/features/internal-ops/hubspot/actions.ts`
`apps/web/src/features/internal-ops/hubspot/page-data.ts`
`apps/web/src/features/internal-ops/notifications/server.ts`
`apps/web/src/features/internal-ops/owner-console/actions.ts`
`apps/web/src/features/internal-ops/partners/server.ts`
`apps/web/src/features/internal-ops/team/actions.ts`
`apps/web/src/features/internal-ops/team/model.ts`
`apps/web/src/features/mcp/tools/staff.ts`
`apps/web/src/features/shell/navigation.ts` | | `operations:write` | `apps/web/app/(experience)/(internal)/internal/capabilities/page.tsx`
`apps/web/app/(experience)/(internal)/internal/catalog/actions.ts`
`apps/web/app/(experience)/(internal)/internal/catalog/page.tsx`
`apps/web/app/(experience)/(internal)/internal/organizations/new/page.tsx`
`apps/web/app/(experience)/(internal)/internal/organizations/page.tsx`
`apps/web/app/(experience)/(internal)/internal/price-books/page.tsx`
`apps/web/app/(experience)/(internal)/internal/providers/actions.ts`
`apps/web/app/(experience)/(internal)/internal/providers/page.tsx`
`apps/web/src/features/internal-ops/administration-safety/policy.ts`
`apps/web/src/features/internal-ops/handoff/actions.ts`
`apps/web/src/features/internal-ops/handoff/server.ts`
`apps/web/src/features/internal-ops/organizations/actions.ts`
`apps/web/src/features/internal-ops/organizations/server.ts`
`apps/web/src/features/internal-ops/team/model.ts`
`apps/web/src/features/shell/navigation.ts` | | `audit:read` | `apps/web/src/features/internal-ops/team/model.ts` | | `audit:append` | `apps/web/src/features/internal-ops/team/model.ts` | From f2b56fde016549ddc049e62263be5503f155351e Mon Sep 17 00:00:00 2001 From: James Kurz Date: Thu, 22 Oct 2026 17:22:39 -0400 Subject: [PATCH 4/6] Address adversarial review: decisions on refresh, contact identity, pipeline scope, admin setup --- .../internal/owner/hubspot/page.tsx | 2 +- .../features/internal-ops/hubspot/actions.ts | 23 ++- .../internal-ops/hubspot/hubspot.module.css | 12 ++ .../internal-ops/hubspot/hubspot.test.tsx | 102 ++++++++++ .../internal-ops/hubspot/page-data.ts | 46 +++-- .../internal-ops/hubspot/page-state.tsx | 4 + .../internal-ops/hubspot/pipeline.tsx | 12 +- .../src/features/internal-ops/hubspot/plan.ts | 44 +++- .../features/internal-ops/hubspot/receipt.tsx | 3 +- .../features/internal-ops/hubspot/review.tsx | 188 +++++++++++++----- .../features/internal-ops/hubspot/server.ts | 64 +++++- .../internal-ops/hubspot/settings.tsx | 41 +++- .../features/internal-ops/mnda/workspace.tsx | 16 +- .../internal-ops/partners/deal-form.tsx | 30 ++- .../internal-ops/partners/partner-detail.tsx | 12 +- .../internal-ops/partners/partner-form.tsx | 6 +- .../src/i18n/messages/operations-hubspot.ts | 85 +++++++- docs/operations/hubspot-handoff.md | 54 +++-- packages/contracts/src/hubspot.test.ts | 2 + packages/contracts/src/hubspot.ts | 18 +- packages/contracts/src/partners.ts | 19 ++ .../repositories/hubspot.integration.test.ts | 7 +- packages/db/src/repositories/hubspot.ts | 106 ++++++---- packages/integrations/src/hubspot/client.ts | 31 ++- .../migrations/001483_hubspot_handoff.sql | 1 + supabase/tests/1483_hubspot_handoff.test.sql | 4 +- 26 files changed, 758 insertions(+), 174 deletions(-) diff --git a/apps/web/app/(experience)/(internal)/internal/owner/hubspot/page.tsx b/apps/web/app/(experience)/(internal)/internal/owner/hubspot/page.tsx index bcefab51..6dd547fe 100644 --- a/apps/web/app/(experience)/(internal)/internal/owner/hubspot/page.tsx +++ b/apps/web/app/(experience)/(internal)/internal/owner/hubspot/page.tsx @@ -65,7 +65,7 @@ async function Page() { )} (operation: () => Promise): Promise> { @@ -97,12 +99,24 @@ export async function commitHubSpotHandoff(raw: unknown) { handoff, ); if (changed.length) return { kind: "source_changed", items: changed }; + if (handoff.destination.kind === "new_partner") { + const dismissed = new Set( + handoff.destination.dismissedSuggestions.map((item) => item.partnerId), + ); + const similar = await similarPartnersFor(repository, source); + if (similar.some((partner) => !dismissed.has(partner.id))) + throw new Error("HUBSPOT_DECISION_REQUIRED"); + } const partners = partnerRepository(); - const destination = + const found = handoff.destination.kind === "existing_partner" ? (await partners.get(handoff.destination.partnerId, partnerToday())) .partner : null; + // The same contact IDs the review used, including derived ones. + const destination = found + ? { ...found, contacts: withPartnerContactIds(found.id, found.contacts) } + : null; const [history, owners] = await Promise.all([ destination ? repository.fieldHistory(destination.id) @@ -252,7 +266,12 @@ export async function processHubSpotDocument(raw: unknown) { actor, ); } catch (error) { - const code = error instanceof Error ? error.message : ""; + const code = + error instanceof z.ZodError + ? "DOCUMENT_INVALID" + : error instanceof Error + ? error.message + : ""; if (code === "ATTACHMENT_ALREADY_EXISTS") { const existing = await repository.attachmentByHash(partnerId, hash); if (existing) diff --git a/apps/web/src/features/internal-ops/hubspot/hubspot.module.css b/apps/web/src/features/internal-ops/hubspot/hubspot.module.css index 8073ab05..95cd9a95 100644 --- a/apps/web/src/features/internal-ops/hubspot/hubspot.module.css +++ b/apps/web/src/features/internal-ops/hubspot/hubspot.module.css @@ -331,6 +331,18 @@ padding: var(--cw-space-4) var(--cw-space-5); } +/* Focused fields scroll clear of the sticky footer, and a short or zoomed + window gets the footer back in the flow instead of covering the form. */ +:global(html):has(.footer) { + scroll-padding-bottom: 12rem; +} + +@media (max-height: 34rem) { + .footer { + position: static; + } +} + .footerRow { display: flex; flex-wrap: wrap; diff --git a/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx b/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx index c62e2dbd..1be743ee 100644 --- a/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx +++ b/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx @@ -11,6 +11,7 @@ import { HubSpotHandoffInputSchema, HubSpotPipelineQuerySchema, PartnerTermsSchema, + withPartnerContactIds, type HubSpotConnectionRecord, type HubSpotImportRecord, type PartnerRecord, @@ -79,6 +80,7 @@ const connection = ( company: ["industry", "partner_tier", "tax_id"], deal: ["deal_source"], contact: [], + pipelines: ["default", "renewals"], notes: true, files: true, ...scope, @@ -745,3 +747,103 @@ describe("HubSpotSetupGuide", () => { expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); }); }); + +describe("review findings stay fixed", () => { + const existing = (fields: Record, extra = {}) => + HubSpotHandoffInputSchema.parse({ + operationId, + connection: { id: partnerId, version: 3 }, + source: { companyId: "9001", dealId: null }, + reviewed: {}, + destination: { + kind: "existing_partner", + partnerId, + expectedVersion: 4, + fields, + }, + ...extra, + }); + + it("refuses a field changed on both sides when no choice was sent", async () => { + const source = await loadHandoffSource(await context(), { + companyId: "9001", + dealId: null, + }); + const build = (fields: Record) => + buildHubSpotPlan({ + source, + handoff: existing(fields), + destination: partner({ region: "Canada" }), + history: { region: { lastSource: "Mexico", lastResult: "Mexico" } }, + ownerName: null, + newId: () => operationId, + }); + expect(() => build({})).toThrow("HUBSPOT_DECISION_REQUIRED"); + expect(build({ region: "keep" }).plan.fields[2]?.result).toBe("Canada"); + }); + + it("matches a hand-typed contact by its derived ID and never re-links another person", async () => { + const source = await loadHandoffSource(await context(), { + companyId: "9001", + dealId: null, + }); + const typed = withPartnerContactIds(partnerId, [ + { name: "Ada", email: "ada.okafor@northstar.example", role: "" }, + ]); + const { plan } = buildHubSpotPlan({ + source, + handoff: existing( + {}, + { contacts: [{ hubspotId: "5001", matchContactId: typed[0]?.id }] }, + ), + destination: partner({ contacts: typed }), + history: {}, + ownerName: null, + newId: () => operationId, + }); + expect(plan.contacts[0]).toMatchObject({ + id: typed[0]?.id, + matchedExisting: true, + }); + const linked = [ + { + ...(typed[0] ?? { name: "" }), + source: { + provider: "hubspot" as const, + portalId: "4410", + objectId: "5002", + labels: [], + }, + }, + ]; + expect(() => + buildHubSpotPlan({ + source, + handoff: existing( + {}, + { contacts: [{ hubspotId: "5001", matchContactId: typed[0]?.id }] }, + ), + destination: partner({ contacts: linked }), + history: {}, + ownerName: null, + newId: () => operationId, + }), + ).toThrow("HUBSPOT_CONTACT_NOT_FOUND"); + }); + + it("reads no deal outside the approved pipelines", async () => { + const ctx = await context({ scope: { pipelines: ["renewals"] } }); + await expect( + loadHandoffSource(ctx, { companyId: "9001", dealId: "7001" }), + ).rejects.toThrow("HUBSPOT_SCOPE_DENIED"); + const view = await loadPipeline(ctx, HubSpotPipelineQuerySchema.parse({})); + expect(view.pipelines.map((p) => p.id)).toEqual(["renewals"]); + expect(view.deals.map((d) => d.id)).toEqual(["7008"]); + await expect( + loadPipeline( + await context({ scope: { pipelines: [] } }), + HubSpotPipelineQuerySchema.parse({}), + ), + ).rejects.toThrow("HUBSPOT_NO_PIPELINES"); + }); +}); diff --git a/apps/web/src/features/internal-ops/hubspot/page-data.ts b/apps/web/src/features/internal-ops/hubspot/page-data.ts index aa9f3fab..4c40dec6 100644 --- a/apps/web/src/features/internal-ops/hubspot/page-data.ts +++ b/apps/web/src/features/internal-ops/hubspot/page-data.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { HubSpotIdSchema, HubSpotPipelineQuerySchema, + withPartnerContactIds, type HubSpotFieldHistory, type HubSpotImportRecord, type PartnerOwnerOption, @@ -41,6 +42,7 @@ import { loadPipeline, searchHubSpot, settingsCatalog, + similarPartnersFor, type HubSpotContext, } from "./server"; @@ -203,7 +205,11 @@ export function loadReview(params: Params) { let history: HubSpotFieldHistory = {}; let linkedElsewhere: string | null = null; if (partnerId) { - destination = (await reader.get(partnerId, partnerToday())).partner; + const found = (await reader.get(partnerId, partnerToday())).partner; + destination = { + ...found, + contacts: withPartnerContactIds(found.id, found.contacts), + }; if (!linked) { const link = await context.store.linkForPartner( partnerId, @@ -214,23 +220,9 @@ export function loadReview(params: Params) { history = await context.store.fieldHistory(partnerId); } const owners = await reader.owners(); - const host = (() => { - try { - return source.proposed.website - ? new URL(source.proposed.website).hostname - : source.company.domain || null; - } catch { - return null; - } - })(); const suggestions = destination ? [] - : ( - await context.store.similarPartners({ - name: source.proposed.name || source.company.name, - host, - }) - ).filter((partner) => partner.linkedCompanyId !== companyId); + : await similarPartnersFor(context.store, source); return { step: "review" as const, source, @@ -301,11 +293,24 @@ export function loadSettings() { ), ]); const portal = portalResult.ok ? portalResult.portal : null; - const catalog = portal && !demo ? await settingsCatalog() : null; + // A failing catalog read leaves the page usable: the connection and its + // disconnect control still show, with the reason the catalog is missing. + let catalogError: string | null = null; + const catalog = + portal && !demo + ? await settingsCatalog().catch((error: unknown) => { + catalogError = + error instanceof HubSpotError + ? error.code + : "HUBSPOT_UNAVAILABLE"; + return null; + }) + : null; return { demo, portal, portalError: portalResult.ok ? null : portalResult.code, + catalogError, active: connection.active, latest: connection.latest, catalog, @@ -357,6 +362,13 @@ export async function loadPartnerHubSpotParts( registerId && source ? { endClient: source.endCustomer?.name ?? "", + // The deal's other companies, none chosen, when the review left + // the end customer open. + choices: source.endCustomer + ? [] + : source.associatedCompanies + .filter((company) => company.id !== source.chosenCompanyId) + .map((company) => company.name), // i18n-exempt: a stored note recording where a registration came from notes: `HubSpot deal: ${source.name} (${source.url})`, } diff --git a/apps/web/src/features/internal-ops/hubspot/page-state.tsx b/apps/web/src/features/internal-ops/hubspot/page-state.tsx index 184d64ce..a0372cb3 100644 --- a/apps/web/src/features/internal-ops/hubspot/page-state.tsx +++ b/apps/web/src/features/internal-ops/hubspot/page-state.tsx @@ -35,6 +35,10 @@ const unavailableCopy: Readonly< title: "operations.hubspot.state.scopeTitle", body: "operations.hubspot.state.scopeBody", }, + HUBSPOT_NO_PIPELINES: { + title: "operations.hubspot.state.noPipelinesTitle", + body: "operations.hubspot.state.noPipelinesBody", + }, HUBSPOT_RECORD_ARCHIVED: { title: "operations.hubspot.state.archivedTitle", body: "operations.hubspot.state.archivedBody", diff --git a/apps/web/src/features/internal-ops/hubspot/pipeline.tsx b/apps/web/src/features/internal-ops/hubspot/pipeline.tsx index 69318c83..d38f64c1 100644 --- a/apps/web/src/features/internal-ops/hubspot/pipeline.tsx +++ b/apps/web/src/features/internal-ops/hubspot/pipeline.tsx @@ -1,3 +1,4 @@ +import type { Route } from "next"; import Link from "next/link"; import { @@ -123,7 +124,10 @@ export function PipelinePage({ 1, ...view.summary.stages.map((stage) => stage.deals), ); - const dealHref = (id: string) => pipelineHref({ ...query, deal: id }); + // The panel's heading is the anchor, so opening a deal lower down the + // list brings its details into view, above the table on a phone. + const dealHref = (id: string) => + `${pipelineHref({ ...query, deal: id })}#deal-panel` as Route; const close = pipelineHref({ ...query, deal: undefined }); return (
@@ -164,7 +168,7 @@ export function PipelinePage({ tone="warning" title={t("operations.hubspot.pipeline.partialTitle")} description={t("operations.hubspot.pipeline.partialBody", { - read: view.deals.length, + read: view.read, total: view.total, })} /> @@ -437,7 +441,9 @@ export function PipelinePage({ aria-labelledby="deal-panel" >
-

{selected.name}

+

+ {selected.name} +

contact.id === choice.matchContactId, ); - if (!match) refuse("HUBSPOT_CONTACT_NOT_FOUND"); + // A match is to a contact with no HubSpot identity, or the same one; + // it never moves a contact from one HubSpot person to another. + if ( + !match || + (match.source && + !( + match.source.portalId === source.portal.portalId && + match.source.objectId === person.id + )) + ) + refuse("HUBSPOT_CONTACT_NOT_FOUND"); } const id = choice.matchContactId ?? linked?.id ?? input.newId(); return { @@ -168,6 +191,8 @@ export function buildHubSpotPlan(input: { matchedExisting: Boolean(choice.matchContactId ?? linked), }; }); + if (new Set(contacts.map((contact) => contact.id)).size !== contacts.length) + refuse("HUBSPOT_CONTACT_NOT_FOUND"); const primary = handoff.primaryContact; const primaryContactId = primary === null @@ -224,7 +249,8 @@ export function buildHubSpotPlan(input: { return { fileId: file.id, noteId: file.noteId, - fileName: file.name, + // The attachment store keeps names up to 200 characters. + fileName: contractPdfFileName(file.name.replace(/\.pdf$/i, "")), title: choice.title, kind: choice.kind, size: file.size, diff --git a/apps/web/src/features/internal-ops/hubspot/receipt.tsx b/apps/web/src/features/internal-ops/hubspot/receipt.tsx index 4218634b..38776e33 100644 --- a/apps/web/src/features/internal-ops/hubspot/receipt.tsx +++ b/apps/web/src/features/internal-ops/hubspot/receipt.tsx @@ -479,7 +479,8 @@ export function HubSpotReceipt({ title: item.existingAttachment?.title ?? "", })} - ) : ( + ) : item.errorCode === + "HUBSPOT_DOCUMENT_CHANGED" ? null : (
); })} +
+
+ {catalog.pipelines.map((pipeline) => ( + { + const on = event.currentTarget.checked; + setScope((current) => ({ + ...current, + pipelines: on + ? [...current.pipelines, pipeline.id] + : current.pipelines.filter( + (id) => id !== pipeline.id, + ), + })); + if (!on && pipelineId === pipeline.id) setPipelineId(""); + }} + /> + ))} +
+
({ - value: pipeline.id, - label: pipeline.label, - })), + ...catalog.pipelines + .filter((pipeline) => scope.pipelines.includes(pipeline.id)) + .map((pipeline) => ({ + value: pipeline.id, + label: pipeline.label, + })), ]} />
clearInterval(timer); }, [reload]); + // Whether the open composer is the one a HubSpot handoff prepared. + const [fromHandoff, setFromHandoff] = useState(false); const open = (start: ComposerStart) => { + setFromHandoff(false); setMessage(null); setComposer((current) => ({ key: (current?.key ?? 0) + 1, start })); requestAnimationFrame(() => @@ -288,12 +291,16 @@ export function MndaWorkspace({ if (!compose) return; const url = new URL(window.location.href); url.searchParams.delete("compose"); + url.searchParams.delete("handoff"); + url.searchParams.delete("partner"); window.history.replaceState(null, "", `${url.pathname}${url.search}`); - if (!initial.demo) + if (!initial.demo) { open({ kind: "form", values: { ...emptyValues(initial.signers), ...prefill }, }); + setFromHandoff(Boolean(prefill)); + } // Once, on arrival. }, []); const refresh = () => void reload(queryRef.current, true); @@ -410,7 +417,7 @@ export function MndaWorkspace({ /> ) : null}
- {composer && prefill ? ( + {composer && fromHandoff ? ( data.canManage || record.ownerId === data.viewerId } - onClose={() => setComposer(null)} + onClose={() => { + setComposer(null); + setFromHandoff(false); + }} onChanged={refresh} onSent={(record) => { setComposer(null); diff --git a/apps/web/src/features/internal-ops/partners/deal-form.tsx b/apps/web/src/features/internal-ops/partners/deal-form.tsx index 74e8ad30..5afd374a 100644 --- a/apps/web/src/features/internal-ops/partners/deal-form.tsx +++ b/apps/web/src/features/internal-ops/partners/deal-form.tsx @@ -95,7 +95,12 @@ export function DealForm({ * registration as editable suggestions; the dates, protection, size and * status stay the seller's own decisions, and nothing saves until they do. */ - suggestion?: { endClient: string; notes: string } | null; + suggestion?: { + endClient: string; + notes: string; + /** Companies on the HubSpot deal to choose the end client from. */ + choices?: readonly string[]; + } | null; }) { const t = useTranslations(); const router = useRouter(); @@ -180,6 +185,13 @@ export function DealForm({ code: "UNEXPECTED", })); if (result.ok) { + // A registration prepared from HubSpot is used once: reloading the + // page must not offer to register the same opportunity again. + if (suggestion && !deal) { + const url = new URL(window.location.href); + url.searchParams.delete("register"); + window.history.replaceState(null, "", `${url.pathname}${url.search}`); + } if (!deal) { id.current = null; setEndClient(""); @@ -264,6 +276,22 @@ export function DealForm({ /> ) : null}
+ {suggestion?.choices?.length && !deal ? ( + setContacts((all) => [ ...all, - ...rowsOf([{ name: "", email: "", role: "" }]), + // A stable ID, so a later HubSpot import can match this + // person rather than add them again. + ...rowsOf([ + { id: crypto.randomUUID(), name: "", email: "", role: "" }, + ]), ]) } > diff --git a/apps/web/src/i18n/messages/operations-hubspot.ts b/apps/web/src/i18n/messages/operations-hubspot.ts index 444c9dd5..abbb131a 100644 --- a/apps/web/src/i18n/messages/operations-hubspot.ts +++ b/apps/web/src/i18n/messages/operations-hubspot.ts @@ -128,7 +128,7 @@ export const hubspotMessages = defineStaffMessages({ en: "This company was just linked to {name}", }, "operations.hubspot.review.alreadyLinkedBody": { - en: "Someone else finished a handoff for it first, so no second partner was created. Open that partner, or review what differs.", + en: "Someone else finished a handoff for this company first, so this one was not applied. Open that partner, or review what differs.", }, "operations.hubspot.review.exactLinkTitle": { en: "This company is already linked", @@ -224,7 +224,7 @@ export const hubspotMessages = defineStaffMessages({ en: "HubSpot has no contacts associated with this company or deal.", }, "operations.hubspot.people.alreadyLinked": { - en: "Already on this partner as {name}. Selecting it again changes nothing.", + en: "Already on this partner as {name}. Selecting it refreshes its HubSpot labels; the Commerce values stay.", }, "operations.hubspot.people.matchLabel": { en: "{name} in Commerce has the same email", @@ -308,7 +308,7 @@ export const hubspotMessages = defineStaffMessages({ "operations.hubspot.more.title": { en: "More control" }, "operations.hubspot.more.hint": { - en: "Keep approved HubSpot properties for reference, or clear a value.", + en: "Keep approved HubSpot properties for reference.", }, "operations.hubspot.more.inEffect": { en: "In effect: {items}" }, "operations.hubspot.more.references": { @@ -334,7 +334,7 @@ export const hubspotMessages = defineStaffMessages({ en: "Commission, margin, exclusivity, contract and approval state are set in Commerce only and never come from HubSpot.", }, "operations.hubspot.more.clearHelp": { - en: "Clear value removes the Commerce value for website or region. It is only offered where you choose it.", + en: "Clear value, in the comparison above, removes the Commerce website or region. Nothing is cleared unless you choose it.", }, "operations.hubspot.issue.title": { @@ -364,13 +364,52 @@ export const hubspotMessages = defineStaffMessages({ "operations.hubspot.summary.createOnly": { en: "Create {name} as a partner.", }, - "operations.hubspot.summary.update": { - en: "{changes}; keep {kept} existing values.", + "operations.hubspot.summary.update": { en: "{changes}; {kept}." }, + "operations.hubspot.summary.updateOnly": { en: "{changes}." }, + "operations.hubspot.summary.kept": { + count: "count", + en: { + one: "keep {count} existing value", + other: "keep {count} existing values", + }, + }, + "operations.hubspot.summary.addContacts": { + count: "count", + en: { one: "add {count} contact", other: "add {count} contacts" }, + }, + "operations.hubspot.summary.linkContacts": { + count: "count", + en: { one: "link {count} contact", other: "link {count} contacts" }, + }, + "operations.hubspot.summary.keepDeals": { + count: "count", + en: { + one: "keep {count} deal reference", + other: "keep {count} deal references", + }, + }, + "operations.hubspot.summary.keepNotes": { + count: "count", + en: { one: "keep {count} note", other: "keep {count} notes" }, + }, + "operations.hubspot.summary.keepReferences": { + count: "count", + en: { + one: "keep {count} reference value", + other: "keep {count} reference values", + }, + }, + "operations.hubspot.summary.attachDocuments": { + count: "count", + en: { + one: "attach {count} document", + other: "attach {count} documents", + }, }, "operations.hubspot.summary.linkOnly": { en: "Link {company} to {partner}. Nothing is copied.", }, - "operations.hubspot.summary.noChanges": { en: "Change no values" }, + "operations.hubspot.summary.noChanges": { en: "change no values" }, "operations.hubspot.summary.nothingSelected": { en: "Already linked. Choose a change, a person, context or a document to import.", }, @@ -379,7 +418,7 @@ export const hubspotMessages = defineStaffMessages({ }, "operations.hubspot.summary.added": { count: "count", - en: { one: "Add {count} value", other: "Add {count} values" }, + en: { one: "add {count} value", other: "add {count} values" }, }, "operations.hubspot.summary.replaced": { count: "count", @@ -510,7 +549,7 @@ export const hubspotMessages = defineStaffMessages({ "operations.hubspot.continue.documents": { en: "View documents" }, "operations.hubspot.continue.source": { en: "Open source in HubSpot" }, "operations.hubspot.continue.registrationNotCreated": { - en: "Commerce registration: Not created", + en: "Commerce registration: not created by this import. Registering is its own step.", }, "operations.hubspot.register.title": { @@ -519,6 +558,13 @@ export const hubspotMessages = defineStaffMessages({ "operations.hubspot.register.body": { en: "The end client and source link are suggestions. The registration date, protection, size, model and status are yours to set; the deal’s amount and close date are not used.", }, + "operations.hubspot.register.choose": { + en: "End client from the HubSpot deal", + }, + "operations.hubspot.register.chooseHelp": { + en: "The deal’s other companies. Pick one, or type the end client below.", + }, + "operations.hubspot.register.chooseNone": { en: "Choose…" }, "operations.hubspot.mnda.title": { en: "Suggested from a HubSpot import", }, @@ -711,6 +757,15 @@ export const hubspotMessages = defineStaffMessages({ en: "Commerce could not read HubSpot just now. Try again shortly; nothing was imported.", }, "operations.hubspot.state.settings": { en: "HubSpot connection" }, + "operations.hubspot.state.noPipelinesTitle": { + en: "No HubSpot pipelines are approved", + }, + "operations.hubspot.state.noPipelinesBody": { + en: "An administrator chooses which deal pipelines staff may see on the HubSpot connection page.", + }, + "operations.hubspot.error.HUBSPOT_NO_PIPELINES": { + en: "No HubSpot pipeline is approved for staff to see.", + }, "operations.hubspot.ownerPanel.title": { en: "Integrations" }, "operations.hubspot.ownerPanel.description": { @@ -751,7 +806,7 @@ export const hubspotMessages = defineStaffMessages({ en: "Approve the connection here", }, "operations.hubspot.setup.approveBody": { - en: "Below, confirm the portal the token reaches, choose the extra properties staff may read, decide on notes and files, pick the pipeline shown first, map HubSpot owners to Commerce staff, accept the audience and choose Activate connection.", + en: "Below, confirm the portal the token reaches, choose the pipelines and extra properties staff may read, decide on notes and files, pick the pipeline shown first, map HubSpot owners to Commerce staff, accept the audience and choose Activate connection.", }, "operations.hubspot.setup.useTitle": { en: "Start using it" }, "operations.hubspot.setup.useBody": { @@ -813,6 +868,14 @@ export const hubspotMessages = defineStaffMessages({ "operations.hubspot.settings.sensitive": { en: "Sensitive in HubSpot: excluded", }, + "operations.hubspot.settings.pipelines": { en: "Pipelines" }, + "operations.hubspot.settings.pipelinesHelp": { + en: "Staff see deals only in the pipelines chosen here, in the pipeline view, search and handoffs alike.", + }, + "operations.hubspot.settings.stages": { + count: "count", + en: { one: "{count} stage", other: "{count} stages" }, + }, "operations.hubspot.settings.activity": { en: "Notes and files" }, "operations.hubspot.settings.notes": { en: "Notes" }, "operations.hubspot.settings.notesHelp": { @@ -965,7 +1028,7 @@ export const hubspotMessages = defineStaffMessages({ en: "Your session expired. Sign in again; your choices stay on this page.", }, "operations.hubspot.error.PARTNER_VERSION_CONFLICT": { - en: "Someone changed this partner while you were reviewing. Reload and review again.", + en: "Someone changed this partner while you were reviewing. The comparison now shows their change and your choices are kept; check it and submit again.", }, "operations.hubspot.error.PARTNER_NOT_FOUND": { en: "That partner no longer exists.", diff --git a/docs/operations/hubspot-handoff.md b/docs/operations/hubspot-handoff.md index 927882ba..7380d371 100644 --- a/docs/operations/hubspot-handoff.md +++ b/docs/operations/hubspot-handoff.md @@ -92,7 +92,9 @@ Audit: `hubspot.connection_activated`, `hubspot.connection_updated`, `hubspot.document_attached`, `hubspot.document_failed`, `hubspot.document_duplicate`, `hubspot.document_reused` and `hubspot.document_excluded`, plus `partner.created` and `partner.updated` -carrying the HubSpot source. Events carry IDs and counts, not contact details. +carrying the HubSpot source. `hubspot.*` events carry IDs, counts and reasons, +not contact details; `partner.*` events record changed fields, contacts +included, exactly as any partner edit does. ## Documents @@ -135,23 +137,42 @@ details, **HubSpot deals associated with this company** (with the note that association is not sourcing, ownership or revenue credit), and the import history. Contacts brought in show **From HubSpot** and their labels. +## Connecting HubSpot + +The connection page (**Owner console → Integrations → HubSpot connection**, +`/internal/owner/hubspot`) shows this checklist with where the environment +stands, and administrators see **Set up HubSpot** on Partners until it is done. + +1. **Create a read-only private app in HubSpot.** A HubSpot super admin opens + Settings → Integrations → Private apps → Create a private app, names it Fil + One Commerce and grants only: `crm.objects.companies.read`, + `crm.objects.deals.read`, `crm.objects.contacts.read`, + `crm.objects.owners.read`, `crm.schemas.companies.read`, + `crm.schemas.deals.read`, `crm.schemas.contacts.read`, plus `files` to bring + PDFs across from notes. No write scopes. Copy the access token. +2. **Add the token to the deployment.** In GitHub, fil-one/clockwork → Settings + → Environments, add the secret `HUBSPOT_ACCESS_TOKEN` to `production` (and + `staging` to try it there first). Then run **Actions → Deploy → Run + workflow** on `main`; the deploy passes it to the app as + `TF_VAR_hubspot_access_token`. It never reaches the database or a browser. +3. **Approve the connection** on the connection page (below). +4. **Use it:** Partners → From HubSpot, Sales → Pipeline, or **Link HubSpot + record** on a partner. + ## Connection and access -- **Token.** `HUBSPOT_ACCESS_TOKEN` is a HubSpot private-app token with - read-only scopes for companies, deals, contacts, owners, pipelines, schemas, - notes and files as needed. It is a deployment secret - (`TF_VAR_hubspot_access_token` from the `HUBSPOT_ACCESS_TOKEN` repository - secret) and never reaches the database or a browser. Without it, HubSpot pages - explain that HubSpot is not set up and offer manual partner creation. +- **Token.** Without `HUBSPOT_ACCESS_TOKEN`, HubSpot pages explain that HubSpot + is not set up and offer manual partner creation. - **Activation.** **Owner console → Integrations → HubSpot connection** (`/internal/owner/hubspot`, `staff:manage`) shows the portal the token reaches - and runs a read-only check. The administrator chooses the additional - properties staff may read (properties HubSpot marks sensitive cannot be - chosen; unknown or new properties stay unreadable), whether notes and files - are readable, the default pipeline and owner mappings, and must accept the - audience: **staff with Sales access**, which can differ from each person's - HubSpot visibility. Acceptance is required each time the scope is saved and is - recorded with the administrator's name. + and runs a read-only check. The administrator chooses the deal pipelines staff + may see (deals in other pipelines are unreadable in the pipeline view, search + and handoffs alike), the additional properties staff may read (properties + HubSpot marks sensitive cannot be chosen; unknown or new properties stay + unreadable), whether notes and files are readable, the default pipeline and + owner mappings, and must accept the audience: **staff with Sales access**, + which can differ from each person's HubSpot visibility. Acceptance is required + each time the scope is saved and is recorded with the administrator's name. - **Permissions.** Viewing HubSpot data needs `sales:read`; importing needs `contract:write`, the existing partner write permission; the connection needs `staff:manage`. Every check also requires a real staff session with MFA, @@ -173,6 +194,11 @@ and plan model leave room for them. reversal**. - **Saved and team pipeline views** (the URL is the shareable view today) and filters on custom properties. +- **Identical PDFs in the review itself.** An identical attachment is found when + the document is brought across; the receipt then stops for **Use existing + attachment** or exclusion, so nothing is attached twice. +- **Company-level scope.** Every company in the portal is readable by the + approved audience; scope is set by pipeline and property. - **A HubSpot-side record card.** Pasting a HubSpot record link into the picker gives the same workflow without the UI extension. diff --git a/packages/contracts/src/hubspot.test.ts b/packages/contracts/src/hubspot.test.ts index 62476b5f..1482a3b1 100644 --- a/packages/contracts/src/hubspot.test.ts +++ b/packages/contracts/src/hubspot.test.ts @@ -243,6 +243,7 @@ describe("HubSpotConnectionInputSchema", () => { company: [], deal: [], contact: [], + pipelines: [], notes: false, files: false, }); @@ -253,6 +254,7 @@ describe("HubSpotConnectionInputSchema", () => { company: ["industry"], deal: [], contact: [], + pipelines: [], notes: false, files: false, }; diff --git a/packages/contracts/src/hubspot.ts b/packages/contracts/src/hubspot.ts index 67761279..8c4a7d87 100644 --- a/packages/contracts/src/hubspot.ts +++ b/packages/contracts/src/hubspot.ts @@ -321,6 +321,8 @@ export interface HubSpotScope { company: readonly string[]; deal: readonly string[]; contact: readonly string[]; + /** Deal pipelines staff may see; deals in any other pipeline stay unread. */ + pipelines: readonly string[]; notes: boolean; files: boolean; } @@ -373,6 +375,11 @@ export const HubSpotConnectionInputSchema = z company: propertyList, deal: propertyList, contact: propertyList, + pipelines: z + .array(z.string().regex(/^[A-Za-z0-9_-]{1,100}$/)) + .max(50) + .default([]) + .transform((ids) => [...new Set(ids)]), notes: z.boolean().default(false), files: z.boolean().default(false), }) @@ -520,14 +527,16 @@ export const HubSpotHandoffInputSchema = z kind: z.literal("existing_partner"), partnerId: z.guid(), expectedVersion: z.int().min(1), + /** A field left out takes its default; one changed on both sides + * since the last import has none and must be decided. */ fields: z .object({ - name: z.enum(hubspotFieldDecisions).default("keep"), - website: z.enum(hubspotFieldDecisions).default("keep"), - region: z.enum(hubspotFieldDecisions).default("keep"), + name: z.enum(hubspotFieldDecisions).optional(), + website: z.enum(hubspotFieldDecisions).optional(), + region: z.enum(hubspotFieldDecisions).optional(), }) .strict() - .default({ name: "keep", website: "keep", region: "keep" }), + .default({}), }) .strict(), ]), @@ -999,6 +1008,7 @@ export const hubspotErrorCodes = [ "HUBSPOT_IMPORT_NOT_FOUND", "HUBSPOT_DECISION_REQUIRED", "HUBSPOT_CONNECTION_VERSION_CONFLICT", + "HUBSPOT_NO_PIPELINES", ] as const; export type HubSpotErrorCode = (typeof hubspotErrorCodes)[number]; diff --git a/packages/contracts/src/partners.ts b/packages/contracts/src/partners.ts index 9545a97d..78187e51 100644 --- a/packages/contracts/src/partners.ts +++ b/packages/contracts/src/partners.ts @@ -197,6 +197,25 @@ export const PartnerContactSchema = z ); export type PartnerContact = z.infer; +/** + * The contacts with an ID each. Contacts typed before contacts had IDs get + * one derived from the partner and their position, so a review and the + * commit that follows it name them the same way; the commit then stores it. + */ +export function withPartnerContactIds( + partnerId: string, + contacts: readonly PartnerContact[], +): PartnerContact[] { + return contacts.map((contact, index) => + contact.id + ? contact + : { + ...contact, + id: `${partnerId.slice(0, 24)}${index.toString(16).padStart(12, "0")}`, + }, + ); +} + /** One free-form term: what was agreed, in the words of the deal. */ export const PartnerTermRowSchema = z .object({ diff --git a/packages/db/src/repositories/hubspot.integration.test.ts b/packages/db/src/repositories/hubspot.integration.test.ts index b1de8c0f..35d8b0b3 100644 --- a/packages/db/src/repositories/hubspot.integration.test.ts +++ b/packages/db/src/repositories/hubspot.integration.test.ts @@ -53,7 +53,12 @@ beforeAll(async () => { { portalId, audienceAccepted: true, - scope: { company: ["industry"], notes: true, files: true }, + scope: { + company: ["industry"], + pipelines: ["default"], + notes: true, + files: true, + }, ownerMappings: [{ hubspotOwnerId: "101", staffId }], }, { portalId, uiDomain: "app.hubspot.com" }, diff --git a/packages/db/src/repositories/hubspot.ts b/packages/db/src/repositories/hubspot.ts index 4d48d2f2..ffa1d85a 100644 --- a/packages/db/src/repositories/hubspot.ts +++ b/packages/db/src/repositories/hubspot.ts @@ -7,6 +7,7 @@ import { hubspotAudience, hubspotImportStatus, partnerContactLimit, + withPartnerContactIds, type Actor, type HubSpotConnectionRecord, type HubSpotFieldHistory, @@ -114,13 +115,27 @@ const importView = ( function refusal(error: unknown): string | undefined { for ( let current: unknown = error, depth = 0; - current instanceof Error && depth < 4; + current instanceof Error && depth < 5; current = current.cause, depth += 1 ) { - const match = /\b((?:HUBSPOT|PARTNER)_[A-Z_]+)\b/.exec(current.message); - if (match) return match[1]; - // A unique violation on the link is a concurrent handoff that lost. - if (/commerce_external_links_(source|partner)/.test(current.message)) + // Codes this module and the partner helpers throw are the whole message. + if (/^(?:HUBSPOT|PARTNER)_[A-Z_]+$/.test(current.message)) + return current.message; + const driver = current as Error & { + code?: unknown; + constraint_name?: unknown; + }; + // Trigger refusals (001466, 001483) raise P0001 with the code as message. + if (driver.code === "P0001") { + const match = /^((?:HUBSPOT|PARTNER)_[A-Z_]+)$/.exec(current.message); + if (match) return match[1]; + } + // A unique violation on a link is a concurrent handoff that lost. + if ( + driver.code === "23505" && + typeof driver.constraint_name === "string" && + driver.constraint_name.startsWith("commerce_external_links_") + ) return "HUBSPOT_ALREADY_LINKED"; } return undefined; @@ -608,7 +623,11 @@ export class HubSpotRepository { name: result("name", before.name), website: result("website", before.website), region: result("region", before.region), - contacts: mergeContacts(before.contacts, plan, snapshot.portalId), + contacts: mergeContacts( + withPartnerContactIds(before.id, before.contacts), + plan, + snapshot.portalId, + ), }); // Owner, status and models are never changed by an update import. partner = await updatePartnerRow( @@ -810,39 +829,48 @@ export class HubSpotRepository { outcome: HubSpotItemOutcome, actor: UserActor, ): Promise { - return this.changeItem(partnerId, importId, itemId, actor, (item) => { - if (outcome.status === "attached") - return { - values: { - status: "attached", - attachmentId: outcome.attachmentId, - errorCode: null, - attempts: item.attempts + 1, - }, - event: "hubspot.document_attached", - detail: { attachmentId: outcome.attachmentId }, - }; - if (outcome.status === "duplicate") + return this.changeItem( + partnerId, + importId, + itemId, + actor, + (item) => { + if (outcome.status === "attached") + return { + values: { + status: "attached", + attachmentId: outcome.attachmentId, + errorCode: null, + attempts: item.attempts + 1, + }, + event: "hubspot.document_attached", + detail: { attachmentId: outcome.attachmentId }, + }; + if (outcome.status === "duplicate") + return { + values: { + status: "duplicate", + existingAttachmentId: outcome.existingAttachmentId, + errorCode: null, + attempts: item.attempts + 1, + }, + event: "hubspot.document_duplicate", + detail: { existingAttachmentId: outcome.existingAttachmentId }, + }; return { values: { - status: "duplicate", - existingAttachmentId: outcome.existingAttachmentId, - errorCode: null, + status: "failed", + errorCode: outcome.errorCode, attempts: item.attempts + 1, }, - event: "hubspot.document_duplicate", - detail: { existingAttachmentId: outcome.existingAttachmentId }, + event: "hubspot.document_failed", + detail: { errorCode: outcome.errorCode }, }; - return { - values: { - status: "failed", - errorCode: outcome.errorCode, - attempts: item.attempts + 1, - }, - event: "hubspot.document_failed", - detail: { errorCode: outcome.errorCode }, - }; - }); + }, + (item) => + outcome.status === "attached" && + item.attachmentId === outcome.attachmentId, + ); } /** @@ -904,6 +932,7 @@ export class HubSpotRepository { event: string; detail: Record; }, + attachedAgain?: (item: ItemRow) => boolean, ): Promise { return this.tx(async (tx) => { const [record] = await tx @@ -928,8 +957,15 @@ export class HubSpotRepository { ) .for("update"); if (!item) throw new Error("HUBSPOT_ITEM_NOT_FOUND"); - if (["attached", "reused", "excluded"].includes(item.status)) + if (["attached", "reused", "excluded"].includes(item.status)) { + // A second attempt that attached the same file (two tabs, or the + // automatic start racing a click) is the same result, not an error. + if (item.status === "attached" && attachedAgain?.(item)) { + const [view] = await withItems(tx, [record]); + if (view) return view; + } throw new Error("HUBSPOT_ITEM_RESOLVED"); + } const { values, event, detail } = change(item); await tx .update(commerceHubSpotImportItems) diff --git a/packages/integrations/src/hubspot/client.ts b/packages/integrations/src/hubspot/client.ts index 95f0f907..89947b49 100644 --- a/packages/integrations/src/hubspot/client.ts +++ b/packages/integrations/src/hubspot/client.ts @@ -106,7 +106,7 @@ const PDF = new TextEncoder().encode("%PDF-"); /** Hosts HubSpot serves signed file downloads from. */ const downloadHost = - /(?:^|\.)(?:hubspot\.com|hubspot\.net|hubapi\.com|hubspotusercontent(?:-[a-z0-9]+)?\.(?:com|net))$/; + /(?:^|\.)(?:hubspot\.com|hubspot\.net|hubapi\.com|hubspotusercontent(?:-[a-z0-9]+|\d+)?\.(?:com|net))$/; const nullableText = z .union([z.string(), z.number(), z.boolean(), z.null()]) @@ -333,8 +333,15 @@ export class HttpHubSpotReader implements HubSpotReader { body?: unknown, ): Promise { for (let attempt = 0; ; attempt += 1) { - const response = await this.send(method, path, body); - if (response.ok) return schema.parse(await this.readJson(response)); + const { response, release } = await this.send(method, path, body); + if (response.ok) + try { + // The timeout covers the body too, not only the headers. + return schema.parse(await this.readJson(response)); + } finally { + release(); + } + release(); void response.body?.cancel().catch(() => {}); const retryAfterMs = Math.min( Number(response.headers.get("retry-after") ?? "1") * 1000 || 1000, @@ -359,13 +366,13 @@ export class HttpHubSpotReader implements HubSpotReader { method: "GET" | "POST", path: string, body: unknown, - ): Promise { + ): Promise<{ response: Response; release: () => void }> { if (!path.startsWith("/") || path.includes("..")) throw new HubSpotError("HUBSPOT_UNAVAILABLE"); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); try { - return await this.fetcher(`${this.origin}${path}`, { + const response = await this.fetcher(`${this.origin}${path}`, { method, redirect: "error", signal: controller.signal, @@ -376,17 +383,25 @@ export class HttpHubSpotReader implements HubSpotReader { }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); + return { response, release: () => clearTimeout(timer) }; } catch (error) { + clearTimeout(timer); throw new HubSpotError("HUBSPOT_UNAVAILABLE", undefined, { cause: error, }); - } finally { - clearTimeout(timer); } } private async readJson(response: Response): Promise { - const bytes = await readBounded(response, MAX_JSON_BYTES); + let bytes: Uint8Array; + try { + bytes = await readBounded(response, MAX_JSON_BYTES); + } catch (error) { + if (error instanceof HubSpotError) throw error; + throw new HubSpotError("HUBSPOT_UNAVAILABLE", undefined, { + cause: error, + }); + } try { return JSON.parse(new TextDecoder().decode(bytes)) as unknown; } catch (error) { diff --git a/supabase/migrations/001483_hubspot_handoff.sql b/supabase/migrations/001483_hubspot_handoff.sql index 71f2befb..7c4a3544 100644 --- a/supabase/migrations/001483_hubspot_handoff.sql +++ b/supabase/migrations/001483_hubspot_handoff.sql @@ -37,6 +37,7 @@ create table if not exists public.commerce_hubspot_connections ( and jsonb_typeof(scope->'company') = 'array' and jsonb_typeof(scope->'deal') = 'array' and jsonb_typeof(scope->'contact') = 'array' + and jsonb_typeof(scope->'pipelines') = 'array' and jsonb_typeof(scope->'notes') = 'boolean' and jsonb_typeof(scope->'files') = 'boolean'), default_pipeline_id text check (default_pipeline_id ~ '^[A-Za-z0-9_-]{1,100}$'), diff --git a/supabase/tests/1483_hubspot_handoff.test.sql b/supabase/tests/1483_hubspot_handoff.test.sql index 51b7debd..8fec4394 100644 --- a/supabase/tests/1483_hubspot_handoff.test.sql +++ b/supabase/tests/1483_hubspot_handoff.test.sql @@ -31,7 +31,7 @@ insert into public.commerce_hubspot_connections (id, portal_id, ui_domain, status, audience, approved_by_id, approved_by_name, approved_at, scope) values ('00000000-0000-4000-8000-0000000014a1', '1483', 'app.hubspot.com', 'active', 'staff_sales_read', gen_random_uuid(), 'Admin', now(), - '{"company": [], "deal": [], "contact": [], "notes": false, "files": false}'); + '{"company": [], "deal": [], "contact": [], "pipelines": [], "notes": false, "files": false}'); insert into public.commerce_partners (id, name, created_by_id, created_by_name) values ('00000000-0000-4000-8000-0000000014b1', 'Pgtap Partner One', gen_random_uuid(), 'Seller'), ('00000000-0000-4000-8000-0000000014b2', 'Pgtap Partner Two', gen_random_uuid(), 'Seller'); @@ -43,7 +43,7 @@ values ('00000000-0000-4000-8000-0000000014c1', 'hubspot', '1483', 'company', '1 select throws_ok($$insert into public.commerce_hubspot_connections (id, portal_id, ui_domain, status, audience, approved_by_id, approved_by_name, approved_at, scope) values (gen_random_uuid(), '1484', 'app.hubspot.com', 'active', 'staff_sales_read', gen_random_uuid(), 'Admin', now(), - '{"company": [], "deal": [], "contact": [], "notes": false, "files": false}')$$, + '{"company": [], "deal": [], "contact": [], "pipelines": [], "notes": false, "files": false}')$$, '23505', null, 'only one connection is active at a time'); select throws_ok($$insert into public.commerce_external_links From 9f51cc38847133c3e33aa450d04d5d1e1ceadf5a Mon Sep 17 00:00:00 2001 From: James Kurz Date: Thu, 22 Oct 2026 17:26:57 -0400 Subject: [PATCH 5/6] Type the contact identity regression fixture --- apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx b/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx index 1be743ee..23020e38 100644 --- a/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx +++ b/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx @@ -805,9 +805,11 @@ describe("review findings stay fixed", () => { id: typed[0]?.id, matchedExisting: true, }); + const [first] = typed; + if (!first) throw new Error("fixture contact missing"); const linked = [ { - ...(typed[0] ?? { name: "" }), + ...first, source: { provider: "hubspot" as const, portalId: "4410", From 28637df63fcb9969da73b47728f23f7f3ab7d6ef Mon Sep 17 00:00:00 2001 From: James Kurz Date: Thu, 22 Oct 2026 17:36:25 -0400 Subject: [PATCH 6/6] Close third review pass: email-bound contact matches, version-first conflicts, MCP contact identity --- .../features/internal-ops/hubspot/actions.ts | 19 ++++- .../internal-ops/hubspot/hubspot.test.tsx | 17 +++++ .../src/features/internal-ops/hubspot/plan.ts | 4 + .../features/internal-ops/hubspot/review.tsx | 75 +++++++++++++++---- .../internal-ops/hubspot/settings.tsx | 3 +- apps/web/src/features/mcp/tools/partners.ts | 35 ++++++++- .../src/i18n/messages/operations-hubspot.ts | 3 + packages/contracts/src/hubspot.ts | 1 + packages/db/src/repositories/hubspot.ts | 3 +- 9 files changed, 141 insertions(+), 19 deletions(-) diff --git a/apps/web/src/features/internal-ops/hubspot/actions.ts b/apps/web/src/features/internal-ops/hubspot/actions.ts index 70d0f06b..b4c7ae48 100644 --- a/apps/web/src/features/internal-ops/hubspot/actions.ts +++ b/apps/web/src/features/internal-ops/hubspot/actions.ts @@ -105,7 +105,7 @@ export async function commitHubSpotHandoff(raw: unknown) { ); const similar = await similarPartnersFor(repository, source); if (similar.some((partner) => !dismissed.has(partner.id))) - throw new Error("HUBSPOT_DECISION_REQUIRED"); + throw new Error("HUBSPOT_SIMILAR_PARTNER"); } const partners = partnerRepository(); const found = @@ -113,6 +113,14 @@ export async function commitHubSpotHandoff(raw: unknown) { ? (await partners.get(handoff.destination.partnerId, partnerToday())) .partner : null; + // A partner edited since the review is a version conflict before + // anything else, so the page refreshes the comparison. + if ( + found && + handoff.destination.kind === "existing_partner" && + found.version !== handoff.destination.expectedVersion + ) + throw new Error("PARTNER_VERSION_CONFLICT"); // The same contact IDs the review used, including derived ones. const destination = found ? { ...found, contacts: withPartnerContactIds(found.id, found.contacts) } @@ -235,6 +243,15 @@ export async function processHubSpotDocument(raw: unknown) { } const hash = sha256(bytes); const duplicate = await repository.attachmentByHash(partnerId, hash); + // Another attempt (a second tab) attached this very item meanwhile. + if (duplicate?.id === item.id) + return repository.recordItem( + partnerId, + importId, + item.id, + { status: "attached", attachmentId: item.id }, + actor, + ); if (duplicate) return repository.recordItem( partnerId, diff --git a/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx b/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx index 23020e38..4ecf6ed7 100644 --- a/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx +++ b/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx @@ -805,6 +805,23 @@ describe("review findings stay fixed", () => { id: typed[0]?.id, matchedExisting: true, }); + // A list that shifted under the review cannot pair Ada with someone else. + const shifted = withPartnerContactIds(partnerId, [ + { name: "Bob", email: "bob@northstar.example", role: "" }, + ]); + expect(() => + buildHubSpotPlan({ + source, + handoff: existing( + {}, + { contacts: [{ hubspotId: "5001", matchContactId: shifted[0]?.id }] }, + ), + destination: partner({ contacts: shifted }), + history: {}, + ownerName: null, + newId: () => operationId, + }), + ).toThrow("HUBSPOT_CONTACT_NOT_FOUND"); const [first] = typed; if (!first) throw new Error("fixture contact missing"); const linked = [ diff --git a/apps/web/src/features/internal-ops/hubspot/plan.ts b/apps/web/src/features/internal-ops/hubspot/plan.ts index b1e2b497..dbe17c72 100644 --- a/apps/web/src/features/internal-ops/hubspot/plan.ts +++ b/apps/web/src/features/internal-ops/hubspot/plan.ts @@ -172,6 +172,10 @@ export function buildHubSpotPlan(input: { // it never moves a contact from one HubSpot person to another. if ( !match || + // Matching is offered by email only; a list that shifted underneath + // the review can never pair a person with someone else. + !person.email || + match.email !== person.email || (match.source && !( match.source.portalId === source.portal.portalId && diff --git a/apps/web/src/features/internal-ops/hubspot/review.tsx b/apps/web/src/features/internal-ops/hubspot/review.tsx index 40c42a53..a5c8cbed 100644 --- a/apps/web/src/features/internal-ops/hubspot/review.tsx +++ b/apps/web/src/features/internal-ops/hubspot/review.tsx @@ -340,20 +340,22 @@ export function HandoffReview({ const destinationChanged = seenDestination.current !== (destination?.id ?? null); seenDestination.current = destination?.id ?? null; - setDecisions((current) => { - const next = { ...current }; - for (const field of hubspotPartnerFields) { - const key = [ - drift(field), - destination?.[field] ?? "", - source.proposed[field], - ].join("\u0000"); - if (destinationChanged || seenFields.current[field] !== key) - next[field] = startingDecision(field); - seenFields.current[field] = key; - } - return next; - }); + // Worked out here, outside the state update, so the update stays pure. + const resets: Partial< + Record + > = {}; + for (const field of hubspotPartnerFields) { + const key = [ + drift(field), + destination?.[field] ?? "", + source.proposed[field], + ].join("\u0000"); + if (destinationChanged || seenFields.current[field] !== key) + resets[field] = startingDecision(field); + seenFields.current[field] = key; + } + if (Object.keys(resets).length) + setDecisions((current) => ({ ...current, ...resets })); if (destinationChanged) { setSubmitted(false); setLinkedFirst(null); @@ -389,6 +391,45 @@ export function HandoffReview({ ) : undefined; + // When the partner's contacts change under the review, a confirmed match + // or a primary that no longer points at the same person is cleared, so + // the person decides again instead of linking someone else. + const contactsKey = existingContacts + .map((contact) => `${contact.id ?? ""}:${contact.email}`) + .join("|"); + const seenContacts = useRef(contactsKey); + useEffect(() => { + if (seenContacts.current === contactsKey) return; + seenContacts.current = contactsKey; + const stale: string[] = []; + for (const [hubspotId, choice] of Object.entries(contacts)) { + const person = source.contacts.find((c) => c.id === hubspotId); + if ( + choice.match && + choice.match !== "new" && + choice.match !== (person ? emailMatch(person.email)?.id : undefined) + ) + stale.push(hubspotId); + } + if (stale.length) + setContacts((current) => { + const next = { ...current }; + for (const id of stale) { + const choice = next[id]; + if (choice) next[id] = { ...choice, match: "" }; + } + return next; + }); + if ( + primary.startsWith("commerce:") && + !existingContacts.some( + (contact) => `commerce:${contact.id ?? ""}` === primary, + ) + ) + setPrimary(""); + // `contactsKey` captures the contacts the choices refer to. + }, [contactsKey]); + const rawInput = () => { operationId.current ??= crypto.randomUUID(); newPartnerId.current ??= crypto.randomUUID(); @@ -579,7 +620,11 @@ export function HandoffReview({ setFailure(result.code); // Someone changed the partner: load their change and keep every // choice, so the comparison shows it and asks again where needed. - if (result.code === "PARTNER_VERSION_CONFLICT") router.refresh(); + if ( + result.code === "PARTNER_VERSION_CONFLICT" || + result.code === "HUBSPOT_SIMILAR_PARTNER" + ) + router.refresh(); requestAnimationFrame(() => summaryRef.current?.focus()); return; } diff --git a/apps/web/src/features/internal-ops/hubspot/settings.tsx b/apps/web/src/features/internal-ops/hubspot/settings.tsx index 040c547a..376d7e74 100644 --- a/apps/web/src/features/internal-ops/hubspot/settings.tsx +++ b/apps/web/src/features/internal-ops/hubspot/settings.tsx @@ -243,7 +243,8 @@ export function HubSpotSettings({ description={t("operations.hubspot.settings.scopeBody")} className={styles.card} > - {(["company", "deal", "contact"] as const).map((objectType) => { + {/* People are read by name, email and job title only. */} + {(["company", "deal"] as const).map((objectType) => { const properties = catalog.properties.filter( (property) => property.objectType === objectType, ); diff --git a/apps/web/src/features/mcp/tools/partners.ts b/apps/web/src/features/mcp/tools/partners.ts index 93e190a5..e29dbcac 100644 --- a/apps/web/src/features/mcp/tools/partners.ts +++ b/apps/web/src/features/mcp/tools/partners.ts @@ -1,6 +1,7 @@ // i18n-exempt-file: MCP tool names, descriptions and results are read by MCP clients and the model, not rendered in the interface import "server-only"; +import { randomUUID } from "node:crypto"; import { z } from "zod"; import { @@ -14,6 +15,7 @@ import { partnerSizeUnits, partnerStatuses, PartnerListQuerySchema, + type PartnerContact, type PartnerDealWithConflicts, type PartnerRecord, } from "@clockwork/contracts"; @@ -162,6 +164,35 @@ export const getPartner = defineTool({ }, }); +/** + * A replaced contact list keeps each person's stable ID, HubSpot identity + * and primary flag, matched by email (or by name without one); a new person + * gets a new ID. Without this, an edit through MCP would sever HubSpot links. + */ +function keepContactIdentity( + next: readonly { + name: string; + email?: string | undefined; + role?: string | undefined; + }[], + current: readonly PartnerContact[], +): PartnerContact[] { + return next.map((person) => { + const email = person.email?.trim().toLowerCase() ?? ""; + const prior = current.find((existing) => + email ? existing.email === email : existing.name === person.name, + ); + return { + name: person.name, + email, + role: person.role ?? "", + id: prior?.id ?? randomUUID(), + ...(prior?.primary ? { primary: true } : {}), + ...(prior?.source ? { source: prior.source } : {}), + }; + }); +} + const contact = z .object({ name: z.string().min(1).max(200), @@ -280,7 +311,9 @@ export const updatePartner = defineTool({ changes.organizationId !== undefined ? changes.organizationId : partner.organizationId, - contacts: changes.contacts ?? partner.contacts, + contacts: changes.contacts + ? keepContactIdentity(changes.contacts, partner.contacts) + : partner.contacts, nextStep: changes.nextStep ?? partner.nextStep, nextStepDue: changes.nextStepDue !== undefined diff --git a/apps/web/src/i18n/messages/operations-hubspot.ts b/apps/web/src/i18n/messages/operations-hubspot.ts index abbb131a..45fd7bc9 100644 --- a/apps/web/src/i18n/messages/operations-hubspot.ts +++ b/apps/web/src/i18n/messages/operations-hubspot.ts @@ -763,6 +763,9 @@ export const hubspotMessages = defineStaffMessages({ "operations.hubspot.state.noPipelinesBody": { en: "An administrator chooses which deal pipelines staff may see on the HubSpot connection page.", }, + "operations.hubspot.error.HUBSPOT_SIMILAR_PARTNER": { + en: "A partner that looks like this company was added meanwhile. It is now listed above; inspect it, then create the partner.", + }, "operations.hubspot.error.HUBSPOT_NO_PIPELINES": { en: "No HubSpot pipeline is approved for staff to see.", }, diff --git a/packages/contracts/src/hubspot.ts b/packages/contracts/src/hubspot.ts index 8c4a7d87..ed9e1d06 100644 --- a/packages/contracts/src/hubspot.ts +++ b/packages/contracts/src/hubspot.ts @@ -1009,6 +1009,7 @@ export const hubspotErrorCodes = [ "HUBSPOT_DECISION_REQUIRED", "HUBSPOT_CONNECTION_VERSION_CONFLICT", "HUBSPOT_NO_PIPELINES", + "HUBSPOT_SIMILAR_PARTNER", ] as const; export type HubSpotErrorCode = (typeof hubspotErrorCodes)[number]; diff --git a/packages/db/src/repositories/hubspot.ts b/packages/db/src/repositories/hubspot.ts index ffa1d85a..3edb84b6 100644 --- a/packages/db/src/repositories/hubspot.ts +++ b/packages/db/src/repositories/hubspot.ts @@ -56,7 +56,8 @@ const connectionView = (row: ConnectionRow): HubSpotConnectionRecord => ({ approvedById: row.approvedById, approvedByName: row.approvedByName, approvedAt: row.approvedAt.toISOString(), - scope: row.scope, + // Rows written before pipelines were part of the scope read as none. + scope: { ...row.scope, pipelines: row.scope.pipelines ?? [] }, defaultPipelineId: row.defaultPipelineId, ownerMappings: row.ownerMappings, updatedAt: row.updatedAt.toISOString(),