@memberjunction/connector-hubspot 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/HubSpotConnector.d.ts +522 -0
- package/dist/HubSpotConnector.js +3140 -0
- package/dist/HubSpotConnector.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
|
@@ -0,0 +1,3140 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var HubSpotConnector_1;
|
|
8
|
+
import { RegisterClass } from '@memberjunction/global';
|
|
9
|
+
import { Metadata, RunView } from '@memberjunction/core';
|
|
10
|
+
import { BaseIntegrationConnector, BaseRESTIntegrationConnector, } from '@memberjunction/integration-engine';
|
|
11
|
+
import { mergeDeclaredWithSampledFields } from '@memberjunction/connector-schema-merge';
|
|
12
|
+
// ─── Constants ────────────────────────────────────────────────────────
|
|
13
|
+
const HUBSPOT_API_BASE = 'https://api.hubapi.com';
|
|
14
|
+
const DEFAULT_API_VERSION = 'v3';
|
|
15
|
+
/** Maximum retries for rate-limited or failed requests */
|
|
16
|
+
const MAX_RETRIES = 5;
|
|
17
|
+
/** HTTP request timeout in milliseconds */
|
|
18
|
+
const REQUEST_TIMEOUT_MS = 30000;
|
|
19
|
+
/** Minimum milliseconds between API requests (HubSpot: 100 req/10s for private apps) */
|
|
20
|
+
const MIN_REQUEST_INTERVAL_MS = 100;
|
|
21
|
+
/**
|
|
22
|
+
* HubSpot CRM search API hard cap: the opaque `after` offset cannot page beyond 10,000 results
|
|
23
|
+
* within a single query window. Incremental windows larger than this (and same-`hs_lastmodifieddate`
|
|
24
|
+
* clusters bigger than 10k from bulk imports) must re-anchor by keyset to be fetched completely.
|
|
25
|
+
*/
|
|
26
|
+
const HUBSPOT_SEARCH_WINDOW_CAP = 10_000;
|
|
27
|
+
/**
|
|
28
|
+
* Comprehensive HubSpot object metadata — single source of truth for both
|
|
29
|
+
* action generation and API property requests.
|
|
30
|
+
*
|
|
31
|
+
* CRM objects (contacts, companies, deals, tasks, tickets, products) generate
|
|
32
|
+
* CRUD actions. Activity/ancillary objects (calls, emails, notes, meetings,
|
|
33
|
+
* line_items, quotes, feedback_submissions) are included for property lookups
|
|
34
|
+
* but excluded from action generation via IncludeInActionGeneration: false.
|
|
35
|
+
*/
|
|
36
|
+
const HUBSPOT_OBJECTS = [
|
|
37
|
+
// ── CRM Objects (generate actions) ──────────────────────────────────
|
|
38
|
+
{
|
|
39
|
+
Name: 'contacts', DisplayName: 'Contact',
|
|
40
|
+
Description: 'A person or lead in HubSpot CRM', SupportsWrite: true,
|
|
41
|
+
// Contacts upsert by email — the natural unique key. Drives the idempotent Upsert verb,
|
|
42
|
+
// which defines the contact-create collision race (409 Contact already exists) out of existence.
|
|
43
|
+
UpsertKey: 'email',
|
|
44
|
+
Fields: [
|
|
45
|
+
{ Name: 'email', DisplayName: 'Email', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contact email address' },
|
|
46
|
+
{ Name: 'firstname', DisplayName: 'First Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contact first name' },
|
|
47
|
+
{ Name: 'lastname', DisplayName: 'Last Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contact last name' },
|
|
48
|
+
{ Name: 'phone', DisplayName: 'Phone', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contact phone number' },
|
|
49
|
+
{ Name: 'mobilephone', DisplayName: 'Mobile Phone', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Mobile phone number' },
|
|
50
|
+
{ Name: 'company', DisplayName: 'Company', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Associated company name' },
|
|
51
|
+
{ Name: 'jobtitle', DisplayName: 'Job Title', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contact job title' },
|
|
52
|
+
{ Name: 'lifecyclestage', DisplayName: 'Lifecycle Stage', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Lifecycle stage (subscriber, lead, opportunity, customer, etc.)' },
|
|
53
|
+
{ Name: 'hs_lead_status', DisplayName: 'Lead Status', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Lead qualification status' },
|
|
54
|
+
{ Name: 'address', DisplayName: 'Address', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Street address' },
|
|
55
|
+
{ Name: 'city', DisplayName: 'City', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'City' },
|
|
56
|
+
{ Name: 'state', DisplayName: 'State', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'State or province' },
|
|
57
|
+
{ Name: 'zip', DisplayName: 'Zip', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Postal/zip code' },
|
|
58
|
+
{ Name: 'country', DisplayName: 'Country', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Country' },
|
|
59
|
+
{ Name: 'website', DisplayName: 'Website', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contact website URL' },
|
|
60
|
+
{ Name: 'industry', DisplayName: 'Industry', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Industry' },
|
|
61
|
+
{ Name: 'annualrevenue', DisplayName: 'Annual Revenue', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Annual revenue' },
|
|
62
|
+
{ Name: 'numberofemployees', DisplayName: 'Number of Employees', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Number of employees' },
|
|
63
|
+
{ Name: 'associatedcompanyid', DisplayName: 'Associated Company ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'ID of associated company' },
|
|
64
|
+
{ Name: 'notes_last_contacted', DisplayName: 'Last Contacted', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last contacted' },
|
|
65
|
+
{ Name: 'notes_last_updated', DisplayName: 'Notes Last Updated', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When notes were last updated' },
|
|
66
|
+
{ Name: 'hs_email_optout', DisplayName: 'Email Opt-out', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Whether contact has opted out of email' },
|
|
67
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
68
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When the contact was created' },
|
|
69
|
+
{ Name: 'lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
Name: 'companies', DisplayName: 'Company',
|
|
74
|
+
Description: 'A business organization in HubSpot CRM', SupportsWrite: true,
|
|
75
|
+
Fields: [
|
|
76
|
+
{ Name: 'name', DisplayName: 'Company Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Company name' },
|
|
77
|
+
{ Name: 'domain', DisplayName: 'Domain', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Company website domain' },
|
|
78
|
+
{ Name: 'industry', DisplayName: 'Industry', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Company industry' },
|
|
79
|
+
{ Name: 'phone', DisplayName: 'Phone', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Company phone number' },
|
|
80
|
+
{ Name: 'address', DisplayName: 'Address', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Street address' },
|
|
81
|
+
{ Name: 'address2', DisplayName: 'Address Line 2', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Address line 2' },
|
|
82
|
+
{ Name: 'city', DisplayName: 'City', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'City' },
|
|
83
|
+
{ Name: 'state', DisplayName: 'State', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'State or province' },
|
|
84
|
+
{ Name: 'zip', DisplayName: 'Zip', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Postal/zip code' },
|
|
85
|
+
{ Name: 'country', DisplayName: 'Country', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Country' },
|
|
86
|
+
{ Name: 'website', DisplayName: 'Website', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Company website URL' },
|
|
87
|
+
{ Name: 'description', DisplayName: 'Description', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Company description' },
|
|
88
|
+
{ Name: 'numberofemployees', DisplayName: 'Employees', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Number of employees' },
|
|
89
|
+
{ Name: 'annualrevenue', DisplayName: 'Annual Revenue', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Annual revenue' },
|
|
90
|
+
{ Name: 'lifecyclestage', DisplayName: 'Lifecycle Stage', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Company lifecycle stage' },
|
|
91
|
+
{ Name: 'type', DisplayName: 'Type', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Company type' },
|
|
92
|
+
{ Name: 'founded_year', DisplayName: 'Founded Year', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Year the company was founded' },
|
|
93
|
+
{ Name: 'is_public', DisplayName: 'Is Public', Type: 'boolean', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Whether the company is publicly traded' },
|
|
94
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
95
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When the company was created' },
|
|
96
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
Name: 'deals', DisplayName: 'Deal',
|
|
101
|
+
Description: 'A sales deal/opportunity in HubSpot CRM', SupportsWrite: true,
|
|
102
|
+
Fields: [
|
|
103
|
+
{ Name: 'dealname', DisplayName: 'Deal Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Name of the deal' },
|
|
104
|
+
{ Name: 'amount', DisplayName: 'Amount', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Deal value/amount' },
|
|
105
|
+
{ Name: 'dealstage', DisplayName: 'Deal Stage', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Current stage in the sales pipeline' },
|
|
106
|
+
{ Name: 'pipeline', DisplayName: 'Pipeline', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Sales pipeline' },
|
|
107
|
+
{ Name: 'closedate', DisplayName: 'Close Date', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Expected close date' },
|
|
108
|
+
{ Name: 'dealtype', DisplayName: 'Deal Type', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Type of deal' },
|
|
109
|
+
{ Name: 'description', DisplayName: 'Description', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Deal description' },
|
|
110
|
+
{ Name: 'hs_deal_stage_probability', DisplayName: 'Stage Probability', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Probability based on deal stage' },
|
|
111
|
+
{ Name: 'hs_projected_amount', DisplayName: 'Projected Amount', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Projected deal amount' },
|
|
112
|
+
{ Name: 'hs_priority', DisplayName: 'Priority', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Deal priority level' },
|
|
113
|
+
{ Name: 'hubspot_owner_id', DisplayName: 'Owner', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'HubSpot owner user ID' },
|
|
114
|
+
{ Name: 'notes_last_contacted', DisplayName: 'Last Contacted', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last contacted' },
|
|
115
|
+
{ Name: 'num_associated_contacts', DisplayName: 'Associated Contacts', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Number of associated contacts' },
|
|
116
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
117
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When the deal was created' },
|
|
118
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
Name: 'tasks', DisplayName: 'Task',
|
|
123
|
+
Description: 'A task/to-do item in HubSpot CRM', SupportsWrite: true,
|
|
124
|
+
Fields: [
|
|
125
|
+
{ Name: 'hs_task_subject', DisplayName: 'Subject', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Task subject line' },
|
|
126
|
+
{ Name: 'hs_task_body', DisplayName: 'Body', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Task body/description' },
|
|
127
|
+
{ Name: 'hs_task_status', DisplayName: 'Status', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Task status (NOT_STARTED, IN_PROGRESS, COMPLETED, etc.)' },
|
|
128
|
+
{ Name: 'hs_task_priority', DisplayName: 'Priority', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Task priority level' },
|
|
129
|
+
{ Name: 'hs_task_type', DisplayName: 'Type', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Task type (TODO, CALL, EMAIL)' },
|
|
130
|
+
{ Name: 'hs_timestamp', DisplayName: 'Due Date', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Task due date/timestamp' },
|
|
131
|
+
{ Name: 'hs_task_completion_date', DisplayName: 'Completion Date', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'When the task was completed' },
|
|
132
|
+
{ Name: 'hs_queue_membership_ids', DisplayName: 'Queue IDs', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Task queue membership IDs' },
|
|
133
|
+
{ Name: 'hubspot_owner_id', DisplayName: 'Owner', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'HubSpot owner user ID' },
|
|
134
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
135
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When the task was created' },
|
|
136
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
Name: 'tickets', DisplayName: 'Ticket',
|
|
141
|
+
Description: 'A support ticket in HubSpot CRM', SupportsWrite: true,
|
|
142
|
+
Fields: [
|
|
143
|
+
{ Name: 'subject', DisplayName: 'Subject', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Ticket subject line' },
|
|
144
|
+
{ Name: 'content', DisplayName: 'Content', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Ticket body/content' },
|
|
145
|
+
{ Name: 'hs_pipeline', DisplayName: 'Pipeline', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Support pipeline' },
|
|
146
|
+
{ Name: 'hs_pipeline_stage', DisplayName: 'Pipeline Stage', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Current pipeline stage' },
|
|
147
|
+
{ Name: 'hs_ticket_priority', DisplayName: 'Priority', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Ticket priority level' },
|
|
148
|
+
{ Name: 'hs_ticket_category', DisplayName: 'Category', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Ticket category' },
|
|
149
|
+
{ Name: 'source_type', DisplayName: 'Source', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'How the ticket was created' },
|
|
150
|
+
{ Name: 'hubspot_owner_id', DisplayName: 'Owner', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'HubSpot owner user ID' },
|
|
151
|
+
{ Name: 'closed_date', DisplayName: 'Closed Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When the ticket was closed' },
|
|
152
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
153
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When the ticket was created' },
|
|
154
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
Name: 'products', DisplayName: 'Product',
|
|
159
|
+
Description: 'A product in the HubSpot product catalog', SupportsWrite: true,
|
|
160
|
+
Fields: [
|
|
161
|
+
{ Name: 'name', DisplayName: 'Product Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Product name' },
|
|
162
|
+
{ Name: 'description', DisplayName: 'Description', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Product description' },
|
|
163
|
+
{ Name: 'price', DisplayName: 'Price', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Product unit price' },
|
|
164
|
+
{ Name: 'hs_cost_of_goods_sold', DisplayName: 'Cost of Goods Sold', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Cost of goods sold' },
|
|
165
|
+
{ Name: 'hs_recurring_billing_period', DisplayName: 'Recurring Billing Period', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Recurring billing period' },
|
|
166
|
+
{ Name: 'hs_sku', DisplayName: 'SKU', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Stock keeping unit identifier' },
|
|
167
|
+
{ Name: 'tax', DisplayName: 'Tax', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Tax amount' },
|
|
168
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
169
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When the product was created' },
|
|
170
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
// ── Ancillary Objects (property lookups only, no action generation) ──
|
|
174
|
+
{
|
|
175
|
+
Name: 'line_items', DisplayName: 'Line Item',
|
|
176
|
+
Description: 'A line item on a deal or quote', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
177
|
+
Fields: [
|
|
178
|
+
{ Name: 'name', DisplayName: 'Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Line item name' },
|
|
179
|
+
{ Name: 'description', DisplayName: 'Description', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Line item description' },
|
|
180
|
+
{ Name: 'quantity', DisplayName: 'Quantity', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Quantity' },
|
|
181
|
+
{ Name: 'price', DisplayName: 'Price', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Unit price' },
|
|
182
|
+
{ Name: 'amount', DisplayName: 'Amount', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Total amount' },
|
|
183
|
+
{ Name: 'discount', DisplayName: 'Discount', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Discount amount' },
|
|
184
|
+
{ Name: 'tax', DisplayName: 'Tax', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Tax amount' },
|
|
185
|
+
{ Name: 'hs_product_id', DisplayName: 'Product ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Associated product ID' },
|
|
186
|
+
{ Name: 'hs_line_item_currency_code', DisplayName: 'Currency', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Currency code' },
|
|
187
|
+
{ Name: 'hs_sku', DisplayName: 'SKU', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Stock keeping unit' },
|
|
188
|
+
{ Name: 'hs_cost_of_goods_sold', DisplayName: 'Cost of Goods Sold', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Cost of goods sold' },
|
|
189
|
+
{ Name: 'hs_recurring_billing_period', DisplayName: 'Recurring Billing Period', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Recurring billing period' },
|
|
190
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
191
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
192
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
Name: 'quotes', DisplayName: 'Quote',
|
|
197
|
+
Description: 'A sales quote in HubSpot', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
198
|
+
Fields: [
|
|
199
|
+
{ Name: 'hs_title', DisplayName: 'Title', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Quote title' },
|
|
200
|
+
{ Name: 'hs_expiration_date', DisplayName: 'Expiration Date', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Quote expiration date' },
|
|
201
|
+
{ Name: 'hs_status', DisplayName: 'Status', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Quote status' },
|
|
202
|
+
{ Name: 'hs_quote_amount', DisplayName: 'Amount', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Quote total amount' },
|
|
203
|
+
{ Name: 'hs_currency', DisplayName: 'Currency', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Currency code' },
|
|
204
|
+
{ Name: 'hs_sender_firstname', DisplayName: 'Sender First Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Sender first name' },
|
|
205
|
+
{ Name: 'hs_sender_lastname', DisplayName: 'Sender Last Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Sender last name' },
|
|
206
|
+
{ Name: 'hs_sender_email', DisplayName: 'Sender Email', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Sender email' },
|
|
207
|
+
{ Name: 'hs_sender_company_name', DisplayName: 'Sender Company', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Sender company name' },
|
|
208
|
+
{ Name: 'hs_language', DisplayName: 'Language', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Quote language' },
|
|
209
|
+
{ Name: 'hs_locale', DisplayName: 'Locale', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Quote locale' },
|
|
210
|
+
{ Name: 'hs_slug', DisplayName: 'Slug', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'URL slug' },
|
|
211
|
+
{ Name: 'hs_public_url_key', DisplayName: 'Public URL Key', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Public URL access key' },
|
|
212
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
213
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
214
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
// ── Activity Objects (property lookups only, no action generation) ───
|
|
218
|
+
{
|
|
219
|
+
Name: 'calls', DisplayName: 'Call',
|
|
220
|
+
Description: 'A call activity in HubSpot', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
221
|
+
Fields: [
|
|
222
|
+
{ Name: 'hs_call_title', DisplayName: 'Title', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Call title' },
|
|
223
|
+
{ Name: 'hs_call_body', DisplayName: 'Body', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Call notes/body' },
|
|
224
|
+
{ Name: 'hs_call_status', DisplayName: 'Status', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Call status' },
|
|
225
|
+
{ Name: 'hs_call_direction', DisplayName: 'Direction', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Inbound or outbound' },
|
|
226
|
+
{ Name: 'hs_call_duration', DisplayName: 'Duration', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Call duration in ms' },
|
|
227
|
+
{ Name: 'hs_call_from_number', DisplayName: 'From Number', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Caller phone number' },
|
|
228
|
+
{ Name: 'hs_call_to_number', DisplayName: 'To Number', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Recipient phone number' },
|
|
229
|
+
{ Name: 'hs_call_disposition', DisplayName: 'Disposition', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Call outcome disposition' },
|
|
230
|
+
{ Name: 'hs_call_recording_url', DisplayName: 'Recording URL', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Call recording URL' },
|
|
231
|
+
{ Name: 'hubspot_owner_id', DisplayName: 'Owner', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'HubSpot owner user ID' },
|
|
232
|
+
{ Name: 'hs_timestamp', DisplayName: 'Timestamp', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Call timestamp' },
|
|
233
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
234
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
235
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
Name: 'emails', DisplayName: 'Email',
|
|
240
|
+
Description: 'An email activity in HubSpot', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
241
|
+
Fields: [
|
|
242
|
+
{ Name: 'hs_email_subject', DisplayName: 'Subject', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Email subject' },
|
|
243
|
+
{ Name: 'hs_email_text', DisplayName: 'Text Body', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Plain text body' },
|
|
244
|
+
{ Name: 'hs_email_html', DisplayName: 'HTML Body', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'HTML body' },
|
|
245
|
+
{ Name: 'hs_email_status', DisplayName: 'Status', Type: 'enum', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Email status' },
|
|
246
|
+
{ Name: 'hs_email_direction', DisplayName: 'Direction', Type: 'enum', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Inbound or outbound' },
|
|
247
|
+
{ Name: 'hs_email_sender_email', DisplayName: 'Sender Email', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Sender email address' },
|
|
248
|
+
{ Name: 'hs_email_sender_firstname', DisplayName: 'Sender First Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Sender first name' },
|
|
249
|
+
{ Name: 'hs_email_sender_lastname', DisplayName: 'Sender Last Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Sender last name' },
|
|
250
|
+
{ Name: 'hs_email_to_email', DisplayName: 'To Email', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Recipient email address' },
|
|
251
|
+
{ Name: 'hubspot_owner_id', DisplayName: 'Owner', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'HubSpot owner user ID' },
|
|
252
|
+
{ Name: 'hs_timestamp', DisplayName: 'Timestamp', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Email timestamp' },
|
|
253
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
254
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
255
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
256
|
+
],
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
Name: 'notes', DisplayName: 'Note',
|
|
260
|
+
Description: 'A note activity in HubSpot', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
261
|
+
Fields: [
|
|
262
|
+
{ Name: 'hs_note_body', DisplayName: 'Body', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Note body content' },
|
|
263
|
+
{ Name: 'hs_timestamp', DisplayName: 'Timestamp', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Note timestamp' },
|
|
264
|
+
{ Name: 'hubspot_owner_id', DisplayName: 'Owner', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'HubSpot owner user ID' },
|
|
265
|
+
{ Name: 'hs_attachment_ids', DisplayName: 'Attachment IDs', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Attached file IDs' },
|
|
266
|
+
{ Name: 'hs_body_preview', DisplayName: 'Body Preview', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Truncated body preview' },
|
|
267
|
+
{ Name: 'hs_body_preview_is_truncated', DisplayName: 'Preview Truncated', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Whether preview is truncated' },
|
|
268
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
269
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
270
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
271
|
+
],
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
Name: 'meetings', DisplayName: 'Meeting',
|
|
275
|
+
Description: 'A meeting activity in HubSpot', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
276
|
+
Fields: [
|
|
277
|
+
{ Name: 'hs_meeting_title', DisplayName: 'Title', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Meeting title' },
|
|
278
|
+
{ Name: 'hs_meeting_body', DisplayName: 'Body', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Meeting description' },
|
|
279
|
+
{ Name: 'hs_meeting_start_time', DisplayName: 'Start Time', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Meeting start time' },
|
|
280
|
+
{ Name: 'hs_meeting_end_time', DisplayName: 'End Time', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Meeting end time' },
|
|
281
|
+
{ Name: 'hs_meeting_outcome', DisplayName: 'Outcome', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Meeting outcome' },
|
|
282
|
+
{ Name: 'hs_meeting_location', DisplayName: 'Location', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Meeting location' },
|
|
283
|
+
{ Name: 'hs_meeting_external_url', DisplayName: 'External URL', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'External meeting URL' },
|
|
284
|
+
{ Name: 'hs_internal_meeting_notes', DisplayName: 'Internal Notes', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Internal meeting notes' },
|
|
285
|
+
{ Name: 'hs_activity_type', DisplayName: 'Activity Type', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Activity type' },
|
|
286
|
+
{ Name: 'hubspot_owner_id', DisplayName: 'Owner', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'HubSpot owner user ID' },
|
|
287
|
+
{ Name: 'hs_timestamp', DisplayName: 'Timestamp', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Meeting timestamp' },
|
|
288
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
289
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
290
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
Name: 'feedback_submissions', DisplayName: 'Feedback Submission',
|
|
295
|
+
Description: 'A feedback survey submission in HubSpot', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
296
|
+
Fields: [
|
|
297
|
+
{ Name: 'hs_survey_id', DisplayName: 'Survey ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Survey ID' },
|
|
298
|
+
{ Name: 'hs_survey_name', DisplayName: 'Survey Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Survey name' },
|
|
299
|
+
{ Name: 'hs_survey_type', DisplayName: 'Survey Type', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Type of survey' },
|
|
300
|
+
{ Name: 'hs_submission_name', DisplayName: 'Submission Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Submission name' },
|
|
301
|
+
{ Name: 'hs_content', DisplayName: 'Content', Type: 'text', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Submission content' },
|
|
302
|
+
{ Name: 'hs_response_group', DisplayName: 'Response Group', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Response group' },
|
|
303
|
+
{ Name: 'hs_sentiment', DisplayName: 'Sentiment', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Response sentiment' },
|
|
304
|
+
{ Name: 'hs_survey_channel', DisplayName: 'Channel', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Survey channel' },
|
|
305
|
+
{ Name: 'hs_timestamp', DisplayName: 'Timestamp', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Submission timestamp' },
|
|
306
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
307
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
308
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
312
|
+
// CRM: Goal Targets (discovered live via Properties API)
|
|
313
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
314
|
+
{
|
|
315
|
+
Name: 'goal_targets', DisplayName: 'Goal Target',
|
|
316
|
+
Description: 'Target milestone or metric threshold for a goal', SupportsWrite: true, IncludeInActionGeneration: false,
|
|
317
|
+
Fields: [
|
|
318
|
+
{ Name: 'hs_goal_name', DisplayName: 'Goal Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Associated goal name' },
|
|
319
|
+
{ Name: 'hs_target_amount', DisplayName: 'Target Amount', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Target value to achieve' },
|
|
320
|
+
{ Name: 'hs_goal_target_kpi_type', DisplayName: 'KPI Type', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'KPI metric type' },
|
|
321
|
+
{ Name: 'hs_start_date', DisplayName: 'Start Date', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Goal target start date' },
|
|
322
|
+
{ Name: 'hs_end_date', DisplayName: 'End Date', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Goal target end date' },
|
|
323
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
324
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
325
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
326
|
+
],
|
|
327
|
+
},
|
|
328
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
329
|
+
// Non-CRM Objects — field definitions for objects without Properties API
|
|
330
|
+
// ══════════════════════════════════════════════════════════════════════
|
|
331
|
+
// ── Pipelines & Stages ───────────────────────────────────────────────
|
|
332
|
+
{
|
|
333
|
+
Name: 'deal_pipelines', DisplayName: 'Deal Pipeline',
|
|
334
|
+
Description: 'Deal pipeline definitions', SupportsWrite: true, IncludeInActionGeneration: false,
|
|
335
|
+
Fields: [
|
|
336
|
+
{ Name: 'id', DisplayName: 'Pipeline ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Pipeline unique identifier' },
|
|
337
|
+
{ Name: 'label', DisplayName: 'Label', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Pipeline display label' },
|
|
338
|
+
{ Name: 'displayOrder', DisplayName: 'Display Order', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Sort order' },
|
|
339
|
+
{ Name: 'archived', DisplayName: 'Archived', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Whether pipeline is archived' },
|
|
340
|
+
{ Name: 'createdAt', DisplayName: 'Created At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
341
|
+
{ Name: 'updatedAt', DisplayName: 'Updated At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
342
|
+
],
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
Name: 'ticket_pipelines', DisplayName: 'Ticket Pipeline',
|
|
346
|
+
Description: 'Ticket pipeline definitions', SupportsWrite: true, IncludeInActionGeneration: false,
|
|
347
|
+
Fields: [
|
|
348
|
+
{ Name: 'id', DisplayName: 'Pipeline ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Pipeline unique identifier' },
|
|
349
|
+
{ Name: 'label', DisplayName: 'Label', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Pipeline display label' },
|
|
350
|
+
{ Name: 'displayOrder', DisplayName: 'Display Order', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Sort order' },
|
|
351
|
+
{ Name: 'archived', DisplayName: 'Archived', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Whether pipeline is archived' },
|
|
352
|
+
{ Name: 'createdAt', DisplayName: 'Created At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
353
|
+
{ Name: 'updatedAt', DisplayName: 'Updated At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
354
|
+
],
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
Name: 'deal_pipeline_stages', DisplayName: 'Deal Pipeline Stage',
|
|
358
|
+
Description: 'Stages within deal pipelines', SupportsWrite: true, IncludeInActionGeneration: false,
|
|
359
|
+
Fields: [
|
|
360
|
+
{ Name: 'id', DisplayName: 'Stage ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Stage unique identifier' },
|
|
361
|
+
{ Name: 'label', DisplayName: 'Label', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Stage display name' },
|
|
362
|
+
{ Name: 'displayOrder', DisplayName: 'Display Order', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Sort order within pipeline' },
|
|
363
|
+
{ Name: 'metadata', DisplayName: 'Metadata', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Stage metadata JSON (probability, isClosed)' },
|
|
364
|
+
{ Name: 'writePermissions', DisplayName: 'Write Permissions', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Write permission setting' },
|
|
365
|
+
{ Name: 'archived', DisplayName: 'Archived', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Whether stage is archived' },
|
|
366
|
+
{ Name: 'createdAt', DisplayName: 'Created At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
367
|
+
{ Name: 'updatedAt', DisplayName: 'Updated At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
368
|
+
],
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
Name: 'ticket_pipeline_stages', DisplayName: 'Ticket Pipeline Stage',
|
|
372
|
+
Description: 'Stages within ticket pipelines', SupportsWrite: true, IncludeInActionGeneration: false,
|
|
373
|
+
Fields: [
|
|
374
|
+
{ Name: 'id', DisplayName: 'Stage ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Stage unique identifier' },
|
|
375
|
+
{ Name: 'label', DisplayName: 'Label', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Stage display name' },
|
|
376
|
+
{ Name: 'displayOrder', DisplayName: 'Display Order', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Sort order within pipeline' },
|
|
377
|
+
{ Name: 'metadata', DisplayName: 'Metadata', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Stage metadata JSON (ticketState)' },
|
|
378
|
+
{ Name: 'writePermissions', DisplayName: 'Write Permissions', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Write permission setting' },
|
|
379
|
+
{ Name: 'archived', DisplayName: 'Archived', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Whether stage is archived' },
|
|
380
|
+
{ Name: 'createdAt', DisplayName: 'Created At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
381
|
+
{ Name: 'updatedAt', DisplayName: 'Updated At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
382
|
+
],
|
|
383
|
+
},
|
|
384
|
+
// ── Lists Sub-Resources ──────────────────────────────────────────────
|
|
385
|
+
{
|
|
386
|
+
Name: 'list_memberships', DisplayName: 'List Membership',
|
|
387
|
+
Description: 'Records belonging to a contact or company list', SupportsWrite: true, IncludeInActionGeneration: false,
|
|
388
|
+
Fields: [
|
|
389
|
+
{ Name: 'recordId', DisplayName: 'Record ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'CRM record ID that is a member' },
|
|
390
|
+
{ Name: 'listId', DisplayName: 'List ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: false, Description: 'ID of the parent list' },
|
|
391
|
+
{ Name: 'membershipTimestamp', DisplayName: 'Membership Timestamp', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When the record joined the list' },
|
|
392
|
+
{ Name: 'listVersion', DisplayName: 'List Version', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'List version when record was added' },
|
|
393
|
+
],
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
Name: 'list_folders', DisplayName: 'List Folder',
|
|
397
|
+
Description: 'Organizational folders for grouping lists', SupportsWrite: true, IncludeInActionGeneration: false,
|
|
398
|
+
Fields: [
|
|
399
|
+
{ Name: 'folderId', DisplayName: 'Folder ID', Type: 'number', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Folder unique identifier' },
|
|
400
|
+
{ Name: 'name', DisplayName: 'Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Folder name' },
|
|
401
|
+
{ Name: 'parentFolderId', DisplayName: 'Parent Folder ID', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Parent folder ID (0 for root)' },
|
|
402
|
+
],
|
|
403
|
+
},
|
|
404
|
+
// ── CRM Imports & Exports ────────────────────────────────────────────
|
|
405
|
+
{
|
|
406
|
+
Name: 'crm_imports', DisplayName: 'CRM Import',
|
|
407
|
+
Description: 'Import job history and status', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
408
|
+
Fields: [
|
|
409
|
+
{ Name: 'id', DisplayName: 'Import ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Import job identifier' },
|
|
410
|
+
{ Name: 'state', DisplayName: 'State', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Import state (STARTED, PROCESSING, DONE, FAILED, CANCELED)' },
|
|
411
|
+
{ Name: 'optOutImport', DisplayName: 'Opt Out Import', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Whether import was opt-out type' },
|
|
412
|
+
{ Name: 'createdAt', DisplayName: 'Created At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When import was initiated' },
|
|
413
|
+
{ Name: 'updatedAt', DisplayName: 'Updated At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When import status last changed' },
|
|
414
|
+
],
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
Name: 'crm_exports', DisplayName: 'CRM Export',
|
|
418
|
+
Description: 'Export job history and status', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
419
|
+
Fields: [
|
|
420
|
+
{ Name: 'id', DisplayName: 'Export ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Export task identifier' },
|
|
421
|
+
{ Name: 'status', DisplayName: 'Status', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Export status (PENDING, PROCESSING, COMPLETE)' },
|
|
422
|
+
{ Name: 'createdAt', DisplayName: 'Created At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When export started' },
|
|
423
|
+
],
|
|
424
|
+
},
|
|
425
|
+
// ── Transactional Email ──────────────────────────────────────────────
|
|
426
|
+
{
|
|
427
|
+
Name: 'transactional_smtp_tokens', DisplayName: 'Transactional SMTP Token',
|
|
428
|
+
Description: 'SMTP API tokens for transactional email sending', SupportsWrite: true, IncludeInActionGeneration: false,
|
|
429
|
+
Fields: [
|
|
430
|
+
{ Name: 'id', DisplayName: 'Token ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Token unique identifier' },
|
|
431
|
+
{ Name: 'emailAddress', DisplayName: 'Email Address', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Sender email address' },
|
|
432
|
+
{ Name: 'createdBy', DisplayName: 'Created By', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'User who created the token' },
|
|
433
|
+
{ Name: 'emailCampaignId', DisplayName: 'Email Campaign ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Associated email campaign' },
|
|
434
|
+
{ Name: 'campaignName', DisplayName: 'Campaign Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Campaign label for grouping' },
|
|
435
|
+
{ Name: 'createdAt', DisplayName: 'Created At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
436
|
+
],
|
|
437
|
+
},
|
|
438
|
+
// ── HubDB Rows ───────────────────────────────────────────────────────
|
|
439
|
+
{
|
|
440
|
+
Name: 'hubdb_rows', DisplayName: 'HubDB Row',
|
|
441
|
+
Description: 'Row data within a HubDB structured table', SupportsWrite: true, IncludeInActionGeneration: false,
|
|
442
|
+
Fields: [
|
|
443
|
+
{ Name: 'id', DisplayName: 'Row ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Row unique identifier' },
|
|
444
|
+
{ Name: 'path', DisplayName: 'Path', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'URL path for the row' },
|
|
445
|
+
{ Name: 'name', DisplayName: 'Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Row display name' },
|
|
446
|
+
{ Name: 'childTableId', DisplayName: 'Child Table ID', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Nested child table reference' },
|
|
447
|
+
{ Name: 'createdAt', DisplayName: 'Created At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
448
|
+
{ Name: 'updatedAt', DisplayName: 'Updated At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
449
|
+
],
|
|
450
|
+
},
|
|
451
|
+
// ── Automation — Custom Coded Actions ─────────────────────────────────
|
|
452
|
+
{
|
|
453
|
+
Name: 'custom_coded_actions', DisplayName: 'Custom Coded Action',
|
|
454
|
+
Description: 'Developer-created workflow extension action definitions', SupportsWrite: true, IncludeInActionGeneration: false,
|
|
455
|
+
Fields: [
|
|
456
|
+
{ Name: 'id', DisplayName: 'Action ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Action definition identifier' },
|
|
457
|
+
{ Name: 'actionUrl', DisplayName: 'Action URL', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Webhook URL for action execution' },
|
|
458
|
+
{ Name: 'published', DisplayName: 'Published', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Whether action is published' },
|
|
459
|
+
{ Name: 'revisionId', DisplayName: 'Revision ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Current revision identifier' },
|
|
460
|
+
{ Name: 'archivedAt', DisplayName: 'Archived At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When archived (null if active)' },
|
|
461
|
+
],
|
|
462
|
+
},
|
|
463
|
+
// ── Files — Folders ──────────────────────────────────────────────────
|
|
464
|
+
{
|
|
465
|
+
Name: 'file_folders', DisplayName: 'File Folder',
|
|
466
|
+
Description: 'File manager folder structure', SupportsWrite: true, IncludeInActionGeneration: false,
|
|
467
|
+
Fields: [
|
|
468
|
+
{ Name: 'id', DisplayName: 'Folder ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Folder unique identifier' },
|
|
469
|
+
{ Name: 'name', DisplayName: 'Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Folder name' },
|
|
470
|
+
{ Name: 'path', DisplayName: 'Path', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Full path to the folder' },
|
|
471
|
+
{ Name: 'parentFolderId', DisplayName: 'Parent Folder ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Parent folder for nesting' },
|
|
472
|
+
{ Name: 'archived', DisplayName: 'Archived', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Whether folder is archived' },
|
|
473
|
+
{ Name: 'createdAt', DisplayName: 'Created At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
474
|
+
{ Name: 'updatedAt', DisplayName: 'Updated At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
475
|
+
],
|
|
476
|
+
},
|
|
477
|
+
// ── Account & Settings ───────────────────────────────────────────────
|
|
478
|
+
{
|
|
479
|
+
Name: 'account_info', DisplayName: 'Account Info',
|
|
480
|
+
Description: 'HubSpot portal account details and configuration', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
481
|
+
Fields: [
|
|
482
|
+
{ Name: 'portalId', DisplayName: 'Portal ID', Type: 'number', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot portal (account) ID' },
|
|
483
|
+
{ Name: 'accountType', DisplayName: 'Account Type', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Account/subscription type' },
|
|
484
|
+
{ Name: 'timeZone', DisplayName: 'Time Zone', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Portal default time zone' },
|
|
485
|
+
{ Name: 'companyCurrency', DisplayName: 'Company Currency', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Default currency code' },
|
|
486
|
+
{ Name: 'additionalCurrencies', DisplayName: 'Additional Currencies', Type: 'text', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Enabled additional currencies' },
|
|
487
|
+
{ Name: 'utcOffset', DisplayName: 'UTC Offset', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'UTC offset string' },
|
|
488
|
+
{ Name: 'utcOffsetMilliseconds', DisplayName: 'UTC Offset (ms)', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'UTC offset in milliseconds' },
|
|
489
|
+
{ Name: 'uiDomain', DisplayName: 'UI Domain', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Portal UI domain' },
|
|
490
|
+
{ Name: 'dataHostingLocation', DisplayName: 'Data Hosting Location', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Data residency region' },
|
|
491
|
+
],
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
Name: 'api_usage', DisplayName: 'API Usage',
|
|
495
|
+
Description: 'Daily API call usage statistics', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
496
|
+
Fields: [
|
|
497
|
+
{ Name: 'name', DisplayName: 'App Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Private app name' },
|
|
498
|
+
{ Name: 'usageCount', DisplayName: 'Usage Count', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Number of API calls' },
|
|
499
|
+
{ Name: 'currentUsage', DisplayName: 'Current Usage', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Current usage for the period' },
|
|
500
|
+
{ Name: 'collectedAt', DisplayName: 'Collected At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When data was collected' },
|
|
501
|
+
{ Name: 'fetchStatus', DisplayName: 'Fetch Status', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Status of the data fetch' },
|
|
502
|
+
{ Name: 'resetsAt', DisplayName: 'Resets At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When usage counter resets' },
|
|
503
|
+
],
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
Name: 'portal_users', DisplayName: 'Portal User',
|
|
507
|
+
Description: 'HubSpot portal users with role and team assignments', SupportsWrite: true, IncludeInActionGeneration: false,
|
|
508
|
+
Fields: [
|
|
509
|
+
{ Name: 'id', DisplayName: 'User ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'User unique identifier' },
|
|
510
|
+
{ Name: 'email', DisplayName: 'Email', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'User email address' },
|
|
511
|
+
{ Name: 'roleId', DisplayName: 'Role ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Assigned role identifier' },
|
|
512
|
+
{ Name: 'primaryTeamId', DisplayName: 'Primary Team ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Primary team assignment' },
|
|
513
|
+
{ Name: 'superAdmin', DisplayName: 'Super Admin', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Whether user is a super admin' },
|
|
514
|
+
],
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
Name: 'user_roles', DisplayName: 'User Role',
|
|
518
|
+
Description: 'Portal user role definitions', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
519
|
+
Fields: [
|
|
520
|
+
{ Name: 'id', DisplayName: 'Role ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Role unique identifier' },
|
|
521
|
+
{ Name: 'name', DisplayName: 'Name', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: false, Description: 'Role name' },
|
|
522
|
+
{ Name: 'requiresBillingWrite', DisplayName: 'Requires Billing Write', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Whether role requires billing write' },
|
|
523
|
+
],
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
Name: 'business_units', DisplayName: 'Business Unit',
|
|
527
|
+
Description: 'Business unit partitions within a portal', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
528
|
+
Fields: [
|
|
529
|
+
{ Name: 'id', DisplayName: 'Business Unit ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Business unit identifier' },
|
|
530
|
+
{ Name: 'name', DisplayName: 'Name', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: false, Description: 'Business unit name' },
|
|
531
|
+
{ Name: 'logoMetadata', DisplayName: 'Logo Metadata', Type: 'text', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Logo configuration JSON' },
|
|
532
|
+
],
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
Name: 'currencies', DisplayName: 'Currency',
|
|
536
|
+
Description: 'Exchange rate and currency settings', SupportsWrite: true, IncludeInActionGeneration: false,
|
|
537
|
+
Fields: [
|
|
538
|
+
{ Name: 'id', DisplayName: 'Exchange Rate ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Exchange rate record identifier' },
|
|
539
|
+
{ Name: 'fromCurrencyCode', DisplayName: 'From Currency', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Source currency code (ISO 4217)' },
|
|
540
|
+
{ Name: 'toCurrencyCode', DisplayName: 'To Currency', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Target currency code (ISO 4217)' },
|
|
541
|
+
{ Name: 'conversionRate', DisplayName: 'Conversion Rate', Type: 'number', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Exchange rate value' },
|
|
542
|
+
{ Name: 'effectiveTimestamp', DisplayName: 'Effective At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When rate became effective' },
|
|
543
|
+
{ Name: 'visible', DisplayName: 'Visible', Type: 'boolean', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Whether currency pair is visible' },
|
|
544
|
+
{ Name: 'createdAt', DisplayName: 'Created At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
545
|
+
{ Name: 'updatedAt', DisplayName: 'Updated At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
546
|
+
],
|
|
547
|
+
},
|
|
548
|
+
// ── Conversations ────────────────────────────────────────────────────
|
|
549
|
+
{
|
|
550
|
+
Name: 'conversation_inboxes', DisplayName: 'Conversation Inbox',
|
|
551
|
+
Description: 'Conversations inbox definitions', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
552
|
+
Fields: [
|
|
553
|
+
{ Name: 'id', DisplayName: 'Inbox ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Inbox unique identifier' },
|
|
554
|
+
{ Name: 'name', DisplayName: 'Name', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: false, Description: 'Inbox name' },
|
|
555
|
+
{ Name: 'archived', DisplayName: 'Archived', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Whether inbox is archived' },
|
|
556
|
+
{ Name: 'createdAt', DisplayName: 'Created At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
557
|
+
{ Name: 'updatedAt', DisplayName: 'Updated At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
558
|
+
],
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
Name: 'conversation_inbox_channels', DisplayName: 'Conversation Inbox Channel',
|
|
562
|
+
Description: 'Communication channels attached to a conversations inbox', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
563
|
+
Fields: [
|
|
564
|
+
{ Name: 'channelId', DisplayName: 'Channel ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Channel unique identifier' },
|
|
565
|
+
{ Name: 'channelAccountId', DisplayName: 'Channel Account ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Account ID for the channel' },
|
|
566
|
+
{ Name: 'name', DisplayName: 'Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Channel display name' },
|
|
567
|
+
{ Name: 'type', DisplayName: 'Type', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Channel type (EMAIL, CHAT, FORM)' },
|
|
568
|
+
{ Name: 'createdAt', DisplayName: 'Created At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
569
|
+
{ Name: 'updatedAt', DisplayName: 'Updated At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
570
|
+
],
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
Name: 'conversation_custom_channels', DisplayName: 'Conversation Custom Channel',
|
|
574
|
+
Description: 'Developer-registered custom communication channels', SupportsWrite: true, IncludeInActionGeneration: false,
|
|
575
|
+
Fields: [
|
|
576
|
+
{ Name: 'id', DisplayName: 'Channel ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Custom channel identifier' },
|
|
577
|
+
{ Name: 'name', DisplayName: 'Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Channel display name' },
|
|
578
|
+
{ Name: 'webhookUrl', DisplayName: 'Webhook URL', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Webhook URL for receiving messages' },
|
|
579
|
+
{ Name: 'channelAccountConnectUrl', DisplayName: 'Connect URL', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'URL for connecting channel accounts' },
|
|
580
|
+
{ Name: 'createdAt', DisplayName: 'Created At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
581
|
+
{ Name: 'updatedAt', DisplayName: 'Updated At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
582
|
+
],
|
|
583
|
+
},
|
|
584
|
+
// ── Timeline Events ──────────────────────────────────────────────────
|
|
585
|
+
{
|
|
586
|
+
Name: 'timeline_event_templates', DisplayName: 'Timeline Event Template',
|
|
587
|
+
Description: 'Custom timeline event type definitions', SupportsWrite: true, IncludeInActionGeneration: false,
|
|
588
|
+
Fields: [
|
|
589
|
+
{ Name: 'id', DisplayName: 'Template ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Event template identifier' },
|
|
590
|
+
{ Name: 'name', DisplayName: 'Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Template name' },
|
|
591
|
+
{ Name: 'objectType', DisplayName: 'Object Type', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'CRM object type this event applies to' },
|
|
592
|
+
{ Name: 'headerTemplate', DisplayName: 'Header Template', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Handlebars template for event header' },
|
|
593
|
+
{ Name: 'detailTemplate', DisplayName: 'Detail Template', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Handlebars template for event detail' },
|
|
594
|
+
{ Name: 'createdAt', DisplayName: 'Created At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
595
|
+
{ Name: 'updatedAt', DisplayName: 'Updated At', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
596
|
+
],
|
|
597
|
+
},
|
|
598
|
+
// ── Analytics / Legacy ───────────────────────────────────────────────
|
|
599
|
+
{
|
|
600
|
+
Name: 'email_campaigns_legacy', DisplayName: 'Email Campaign (Legacy)',
|
|
601
|
+
Description: 'Email campaign tracking and analytics', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
602
|
+
Fields: [
|
|
603
|
+
{ Name: 'id', DisplayName: 'Campaign ID', Type: 'string', IsRequired: true, IsReadOnly: true, IsPrimaryKey: true, Description: 'Campaign unique identifier' },
|
|
604
|
+
{ Name: 'appId', DisplayName: 'App ID', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'App that created the campaign' },
|
|
605
|
+
{ Name: 'appName', DisplayName: 'App Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'App display name' },
|
|
606
|
+
{ Name: 'contentId', DisplayName: 'Content ID', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Associated content identifier' },
|
|
607
|
+
{ Name: 'subject', DisplayName: 'Subject', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Email subject line' },
|
|
608
|
+
{ Name: 'name', DisplayName: 'Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Campaign name' },
|
|
609
|
+
{ Name: 'type', DisplayName: 'Type', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Campaign type' },
|
|
610
|
+
{ Name: 'numIncluded', DisplayName: 'Num Included', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Number of recipients included' },
|
|
611
|
+
{ Name: 'numQueued', DisplayName: 'Num Queued', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Number of emails queued' },
|
|
612
|
+
{ Name: 'subType', DisplayName: 'Sub Type', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Campaign sub-type' },
|
|
613
|
+
{ Name: 'lastUpdatedTime', DisplayName: 'Last Updated', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last updated' },
|
|
614
|
+
],
|
|
615
|
+
},
|
|
616
|
+
// ── Missing T1 CRM objects (in STANDARD_OBJECTS but not previously listed here) ─
|
|
617
|
+
{
|
|
618
|
+
Name: 'leads', DisplayName: 'Lead',
|
|
619
|
+
Description: 'A prospective buyer in the lead pipeline (separate from contact lifecycle stage)', SupportsWrite: true,
|
|
620
|
+
Fields: [
|
|
621
|
+
{ Name: 'hs_lead_label', DisplayName: 'Lead Label', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Display label for the lead' },
|
|
622
|
+
{ Name: 'hs_is_enrolled_in_sequence', DisplayName: 'Enrolled in Sequence', Type: 'boolean', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Whether enrolled in a sequence' },
|
|
623
|
+
{ Name: 'hs_lead_status', DisplayName: 'Lead Status', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Lead qualification status' },
|
|
624
|
+
{ Name: 'hubspot_owner_id', DisplayName: 'Owner', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Assigned owner user ID' },
|
|
625
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
626
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
627
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
628
|
+
],
|
|
629
|
+
},
|
|
630
|
+
// ── Engagement / Activity objects ────────────────────────────────────
|
|
631
|
+
{
|
|
632
|
+
Name: 'communications', DisplayName: 'Communication',
|
|
633
|
+
Description: 'SMS, WhatsApp, and LinkedIn messages logged as CRM engagements', SupportsWrite: true,
|
|
634
|
+
Fields: [
|
|
635
|
+
{ Name: 'hs_communication_channel_type', DisplayName: 'Channel Type', Type: 'enum', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Channel (SMS, WHATS_APP, LINKEDIN_MESSAGE)' },
|
|
636
|
+
{ Name: 'hs_communication_body', DisplayName: 'Body', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Message body' },
|
|
637
|
+
{ Name: 'hs_communication_logged_from', DisplayName: 'Logged From', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Source of the logged communication' },
|
|
638
|
+
{ Name: 'hs_timestamp', DisplayName: 'Timestamp', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'When the communication occurred' },
|
|
639
|
+
{ Name: 'hubspot_owner_id', DisplayName: 'Owner', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Assigned owner user ID' },
|
|
640
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
641
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
642
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
643
|
+
],
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
Name: 'postal_mail', DisplayName: 'Postal Mail',
|
|
647
|
+
Description: 'Physical mail logged as a CRM engagement', SupportsWrite: true,
|
|
648
|
+
Fields: [
|
|
649
|
+
{ Name: 'hs_body_preview', DisplayName: 'Body Preview', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Preview of the mail content' },
|
|
650
|
+
{ Name: 'hs_timestamp', DisplayName: 'Timestamp', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'When the mail was sent' },
|
|
651
|
+
{ Name: 'hs_postal_mail_status', DisplayName: 'Status', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Delivery status' },
|
|
652
|
+
{ Name: 'hubspot_owner_id', DisplayName: 'Owner', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Assigned owner user ID' },
|
|
653
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
654
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
655
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
656
|
+
],
|
|
657
|
+
},
|
|
658
|
+
// ── Commerce objects ─────────────────────────────────────────────────
|
|
659
|
+
{
|
|
660
|
+
Name: 'invoices', DisplayName: 'Invoice',
|
|
661
|
+
Description: 'Billing invoice records in HubSpot Commerce', SupportsWrite: true,
|
|
662
|
+
Fields: [
|
|
663
|
+
{ Name: 'hs_invoice_status', DisplayName: 'Status', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Invoice status (DRAFT, OUTSTANDING, PAID, VOIDED)' },
|
|
664
|
+
{ Name: 'hs_number', DisplayName: 'Invoice Number', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Invoice number' },
|
|
665
|
+
{ Name: 'hs_due_date', DisplayName: 'Due Date', Type: 'date', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Payment due date' },
|
|
666
|
+
{ Name: 'hs_currency_code', DisplayName: 'Currency', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'ISO currency code' },
|
|
667
|
+
{ Name: 'hs_invoice_total_amount', DisplayName: 'Total Amount', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Total invoice amount' },
|
|
668
|
+
{ Name: 'hubspot_owner_id', DisplayName: 'Owner', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Assigned owner user ID' },
|
|
669
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
670
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
671
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
672
|
+
],
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
Name: 'subscriptions', DisplayName: 'Commerce Subscription',
|
|
676
|
+
Description: 'Recurring commerce subscriptions', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
677
|
+
Fields: [
|
|
678
|
+
{ Name: 'hs_status', DisplayName: 'Status', Type: 'enum', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Subscription status (ACTIVE, CANCELLED, PAST_DUE, etc.)' },
|
|
679
|
+
{ Name: 'hs_billing_start_date', DisplayName: 'Billing Start', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When billing started' },
|
|
680
|
+
{ Name: 'hs_recurring_billing_period', DisplayName: 'Billing Period', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Billing frequency period' },
|
|
681
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
682
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
683
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
684
|
+
],
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
Name: 'discounts', DisplayName: 'Discount',
|
|
688
|
+
Description: 'Discount line items applied to commerce transactions', SupportsWrite: true,
|
|
689
|
+
Fields: [
|
|
690
|
+
{ Name: 'hs_label', DisplayName: 'Label', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Discount label' },
|
|
691
|
+
{ Name: 'hs_discount_percentage', DisplayName: 'Discount %', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Percentage discount amount' },
|
|
692
|
+
{ Name: 'hs_value', DisplayName: 'Value', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Fixed discount value' },
|
|
693
|
+
{ Name: 'hs_type', DisplayName: 'Type', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Discount type (PERCENT or FIXED_AMOUNT)' },
|
|
694
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
695
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
696
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
697
|
+
],
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
Name: 'fees', DisplayName: 'Fee',
|
|
701
|
+
Description: 'Fee line items applied to commerce transactions', SupportsWrite: true,
|
|
702
|
+
Fields: [
|
|
703
|
+
{ Name: 'hs_label', DisplayName: 'Label', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Fee label' },
|
|
704
|
+
{ Name: 'hs_value', DisplayName: 'Value', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Fee amount' },
|
|
705
|
+
{ Name: 'hs_type', DisplayName: 'Type', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Fee type (PERCENT or FIXED_AMOUNT)' },
|
|
706
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
707
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
708
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
709
|
+
],
|
|
710
|
+
},
|
|
711
|
+
{
|
|
712
|
+
Name: 'taxes', DisplayName: 'Tax',
|
|
713
|
+
Description: 'Tax line items applied to commerce transactions', SupportsWrite: true,
|
|
714
|
+
Fields: [
|
|
715
|
+
{ Name: 'hs_label', DisplayName: 'Label', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Tax label' },
|
|
716
|
+
{ Name: 'hs_rate', DisplayName: 'Rate', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Tax rate percentage' },
|
|
717
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
718
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
719
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
720
|
+
],
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
Name: 'commerce_payments', DisplayName: 'Commerce Payment',
|
|
724
|
+
Description: 'Payment records for commerce transactions', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
725
|
+
Fields: [
|
|
726
|
+
{ Name: 'hs_payment_amount', DisplayName: 'Amount', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Payment amount' },
|
|
727
|
+
{ Name: 'hs_payment_status', DisplayName: 'Status', Type: 'enum', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Payment status' },
|
|
728
|
+
{ Name: 'hs_payment_date', DisplayName: 'Payment Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When payment was made' },
|
|
729
|
+
{ Name: 'hs_currency_code', DisplayName: 'Currency', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'ISO currency code' },
|
|
730
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
731
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
732
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
733
|
+
],
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
Name: 'users', DisplayName: 'CRM User',
|
|
737
|
+
Description: 'HubSpot user records exposed via CRM API (distinct from portal settings users)', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
738
|
+
Fields: [
|
|
739
|
+
{ Name: 'hs_email', DisplayName: 'Email', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'User email address' },
|
|
740
|
+
{ Name: 'hs_given_name', DisplayName: 'First Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'First name' },
|
|
741
|
+
{ Name: 'hs_family_name', DisplayName: 'Last Name', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Last name' },
|
|
742
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
743
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
744
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
745
|
+
],
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
Name: 'orders', DisplayName: 'Order',
|
|
749
|
+
Description: 'Commerce order records', SupportsWrite: true,
|
|
750
|
+
Fields: [
|
|
751
|
+
{ Name: 'hs_order_name', DisplayName: 'Order Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Name of the order' },
|
|
752
|
+
{ Name: 'hs_status', DisplayName: 'Status', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Order status' },
|
|
753
|
+
{ Name: 'hs_fulfillment_status', DisplayName: 'Fulfillment Status', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Fulfillment status' },
|
|
754
|
+
{ Name: 'hs_currency_code', DisplayName: 'Currency', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'ISO currency code' },
|
|
755
|
+
{ Name: 'hs_total_price', DisplayName: 'Total Price', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Total order price' },
|
|
756
|
+
{ Name: 'hs_source_store', DisplayName: 'Source Store', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Source store/channel' },
|
|
757
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
758
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
759
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
760
|
+
],
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
Name: 'carts', DisplayName: 'Cart',
|
|
764
|
+
Description: 'Shopping cart records for commerce', SupportsWrite: true,
|
|
765
|
+
Fields: [
|
|
766
|
+
{ Name: 'hs_cart_name', DisplayName: 'Cart Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Cart name or identifier' },
|
|
767
|
+
{ Name: 'hs_status', DisplayName: 'Status', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Cart status (OPEN, CONVERTED, ABANDONED)' },
|
|
768
|
+
{ Name: 'hs_total_price', DisplayName: 'Total Price', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Total cart value' },
|
|
769
|
+
{ Name: 'hs_currency_code', DisplayName: 'Currency', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'ISO currency code' },
|
|
770
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
771
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
772
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
773
|
+
],
|
|
774
|
+
},
|
|
775
|
+
// ── Marketing ────────────────────────────────────────────────────────
|
|
776
|
+
{
|
|
777
|
+
Name: 'marketing_events', DisplayName: 'Marketing Event',
|
|
778
|
+
Description: 'Virtual or in-person events tracked in HubSpot', SupportsWrite: true,
|
|
779
|
+
Fields: [
|
|
780
|
+
{ Name: 'eventName', DisplayName: 'Event Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Name of the marketing event' },
|
|
781
|
+
{ Name: 'eventType', DisplayName: 'Event Type', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Type of event (WEBINAR, CONFERENCE, etc.)' },
|
|
782
|
+
{ Name: 'eventOrganizer', DisplayName: 'Organizer', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Event organizer name' },
|
|
783
|
+
{ Name: 'eventUrl', DisplayName: 'Event URL', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Link to the event' },
|
|
784
|
+
{ Name: 'startDateTime', DisplayName: 'Start Date/Time', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Event start date and time' },
|
|
785
|
+
{ Name: 'endDateTime', DisplayName: 'End Date/Time', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Event end date and time' },
|
|
786
|
+
{ Name: 'registrants', DisplayName: 'Registrants', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Number of registered attendees' },
|
|
787
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
788
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
789
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
790
|
+
],
|
|
791
|
+
},
|
|
792
|
+
// ── Activatable Objects (Object Library) ─────────────────────────────
|
|
793
|
+
{
|
|
794
|
+
Name: 'services', DisplayName: 'Service',
|
|
795
|
+
Description: 'Service offerings tracked in HubSpot (activatable object)', SupportsWrite: true,
|
|
796
|
+
Fields: [
|
|
797
|
+
{ Name: 'name', DisplayName: 'Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Service name' },
|
|
798
|
+
{ Name: 'description', DisplayName: 'Description', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Service description' },
|
|
799
|
+
{ Name: 'price', DisplayName: 'Price', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Service price' },
|
|
800
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
801
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
802
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
803
|
+
],
|
|
804
|
+
},
|
|
805
|
+
{
|
|
806
|
+
Name: 'courses', DisplayName: 'Course',
|
|
807
|
+
Description: 'Educational courses tracked in HubSpot (activatable object)', SupportsWrite: true,
|
|
808
|
+
Fields: [
|
|
809
|
+
{ Name: 'name', DisplayName: 'Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Course name' },
|
|
810
|
+
{ Name: 'description', DisplayName: 'Description', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Course description' },
|
|
811
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
812
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
813
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
814
|
+
],
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
Name: 'listings', DisplayName: 'Listing',
|
|
818
|
+
Description: 'Property or product listings (activatable object)', SupportsWrite: true,
|
|
819
|
+
Fields: [
|
|
820
|
+
{ Name: 'name', DisplayName: 'Name', Type: 'string', IsRequired: true, IsReadOnly: false, IsPrimaryKey: false, Description: 'Listing name' },
|
|
821
|
+
{ Name: 'description', DisplayName: 'Description', Type: 'text', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Listing description' },
|
|
822
|
+
{ Name: 'price', DisplayName: 'Price', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Listing price' },
|
|
823
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
824
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
825
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
826
|
+
],
|
|
827
|
+
},
|
|
828
|
+
{
|
|
829
|
+
Name: 'appointments', DisplayName: 'Appointment',
|
|
830
|
+
Description: 'Scheduled appointments (activatable object)', SupportsWrite: true,
|
|
831
|
+
Fields: [
|
|
832
|
+
{ Name: 'hs_appointment_name', DisplayName: 'Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Appointment name' },
|
|
833
|
+
{ Name: 'hs_appointment_start', DisplayName: 'Start', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Appointment start time' },
|
|
834
|
+
{ Name: 'hs_appointment_end', DisplayName: 'End', Type: 'datetime', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Appointment end time' },
|
|
835
|
+
{ Name: 'hs_appointment_status', DisplayName: 'Status', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Appointment status' },
|
|
836
|
+
{ Name: 'hubspot_owner_id', DisplayName: 'Owner', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Assigned owner user ID' },
|
|
837
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
838
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
839
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
840
|
+
],
|
|
841
|
+
},
|
|
842
|
+
// ── System / Other CRM ───────────────────────────────────────────────
|
|
843
|
+
{
|
|
844
|
+
Name: 'projects', DisplayName: 'Project',
|
|
845
|
+
Description: 'Project records in HubSpot CRM', SupportsWrite: true,
|
|
846
|
+
Fields: [
|
|
847
|
+
{ Name: 'hs_project_name', DisplayName: 'Project Name', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Name of the project' },
|
|
848
|
+
{ Name: 'hs_status', DisplayName: 'Status', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Project status' },
|
|
849
|
+
{ Name: 'hs_start_date', DisplayName: 'Start Date', Type: 'date', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Project start date' },
|
|
850
|
+
{ Name: 'hs_due_date', DisplayName: 'Due Date', Type: 'date', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Project due date' },
|
|
851
|
+
{ Name: 'hubspot_owner_id', DisplayName: 'Owner', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Assigned owner user ID' },
|
|
852
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
853
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
854
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
855
|
+
],
|
|
856
|
+
},
|
|
857
|
+
{
|
|
858
|
+
Name: 'deal_splits', DisplayName: 'Deal Split',
|
|
859
|
+
Description: 'Revenue split allocations across deal owners', SupportsWrite: true,
|
|
860
|
+
Fields: [
|
|
861
|
+
{ Name: 'hs_split_percentage', DisplayName: 'Split %', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Percentage of deal revenue attributed to this split' },
|
|
862
|
+
{ Name: 'hs_split_amount', DisplayName: 'Split Amount', Type: 'number', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Calculated split amount' },
|
|
863
|
+
{ Name: 'hs_deal_id', DisplayName: 'Deal ID', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Associated deal ID' },
|
|
864
|
+
{ Name: 'hubspot_owner_id', DisplayName: 'Owner', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Owner receiving the split credit' },
|
|
865
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
866
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
867
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
868
|
+
],
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
Name: 'transcriptions', DisplayName: 'Transcription',
|
|
872
|
+
Description: 'Call transcription records (auto-generated by HubSpot AI, read-only)', SupportsWrite: false, IncludeInActionGeneration: false,
|
|
873
|
+
Fields: [
|
|
874
|
+
{ Name: 'hs_call_id', DisplayName: 'Call ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'ID of the associated call' },
|
|
875
|
+
{ Name: 'hs_transcript_text', DisplayName: 'Transcript', Type: 'text', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Full call transcript text' },
|
|
876
|
+
{ Name: 'hs_language', DisplayName: 'Language', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'Detected transcript language' },
|
|
877
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
878
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
879
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
880
|
+
],
|
|
881
|
+
},
|
|
882
|
+
{
|
|
883
|
+
Name: 'contracts', DisplayName: 'Contract',
|
|
884
|
+
Description: 'Contract records in HubSpot CRM', SupportsWrite: true,
|
|
885
|
+
Fields: [
|
|
886
|
+
{ Name: 'hs_title', DisplayName: 'Title', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contract title' },
|
|
887
|
+
{ Name: 'hs_contract_status', DisplayName: 'Status', Type: 'enum', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contract status (DRAFT, SENT, SIGNED, etc.)' },
|
|
888
|
+
{ Name: 'hs_effective_date', DisplayName: 'Effective Date', Type: 'date', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Date the contract takes effect' },
|
|
889
|
+
{ Name: 'hs_expiration_date', DisplayName: 'Expiration Date', Type: 'date', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Contract expiration date' },
|
|
890
|
+
{ Name: 'hs_total_contract_value', DisplayName: 'Total Value', Type: 'number', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Total contract value' },
|
|
891
|
+
{ Name: 'hubspot_owner_id', DisplayName: 'Owner', Type: 'string', IsRequired: false, IsReadOnly: false, IsPrimaryKey: false, Description: 'Assigned owner user ID' },
|
|
892
|
+
{ Name: 'hs_object_id', DisplayName: 'Object ID', Type: 'string', IsRequired: false, IsReadOnly: true, IsPrimaryKey: true, Description: 'HubSpot internal object ID' },
|
|
893
|
+
{ Name: 'createdate', DisplayName: 'Created Date', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When created' },
|
|
894
|
+
{ Name: 'hs_lastmodifieddate', DisplayName: 'Last Modified', Type: 'datetime', IsRequired: false, IsReadOnly: true, IsPrimaryKey: false, Description: 'When last modified' },
|
|
895
|
+
],
|
|
896
|
+
},
|
|
897
|
+
];
|
|
898
|
+
// ─── Connector ────────────────────────────────────────────────────────
|
|
899
|
+
/**
|
|
900
|
+
* Connector for HubSpot CRM via the HubSpot REST API v3.
|
|
901
|
+
*
|
|
902
|
+
* Extends BaseRESTIntegrationConnector to leverage metadata-driven object/field
|
|
903
|
+
* discovery from IntegrationEngineBase cache and generic pagination handling.
|
|
904
|
+
*
|
|
905
|
+
* Uses Bearer token authentication with a HubSpot Private App access token (API Key auth).
|
|
906
|
+
* Supports cursor-based pagination and automatic response flattening.
|
|
907
|
+
*
|
|
908
|
+
* Configuration JSON (on CompanyIntegration) supports optional rate limit overrides:
|
|
909
|
+
* {
|
|
910
|
+
* "accessToken": "...",
|
|
911
|
+
* "MaxRetries": 5, // optional, default: 5
|
|
912
|
+
* "RequestTimeoutMs": 30000, // optional, default: 30000
|
|
913
|
+
* "MinRequestIntervalMs": 100 // optional, default: 100
|
|
914
|
+
* }
|
|
915
|
+
*
|
|
916
|
+
* Supports full CRUD: Get, Create, Update, Delete, Search, and List operations
|
|
917
|
+
* on all HubSpot CRM object types.
|
|
918
|
+
*/
|
|
919
|
+
let HubSpotConnector = class HubSpotConnector extends BaseRESTIntegrationConnector {
|
|
920
|
+
constructor() {
|
|
921
|
+
super(...arguments);
|
|
922
|
+
/** Timestamp of the last API request, used for throttling */
|
|
923
|
+
this.lastRequestTime = 0;
|
|
924
|
+
/** Resolved config (populated after first Authenticate call) */
|
|
925
|
+
this._config = null;
|
|
926
|
+
/** Cached auth context — reused within a session to avoid redundant credential loads */
|
|
927
|
+
this._cachedAuth = null;
|
|
928
|
+
/** Cache of resolved default association typeIds, keyed by `${fromType}/${toType}`. */
|
|
929
|
+
this._assocTypeIdCache = new Map();
|
|
930
|
+
}
|
|
931
|
+
static { HubSpotConnector_1 = this; }
|
|
932
|
+
// ── Per-instance config accessors (fall back to module-level defaults) ──
|
|
933
|
+
get effectiveMaxRetries() { return this._config?.MaxRetries ?? MAX_RETRIES; }
|
|
934
|
+
get effectiveRequestTimeoutMs() { return this._config?.RequestTimeoutMs ?? REQUEST_TIMEOUT_MS; }
|
|
935
|
+
get effectiveMinRequestIntervalMs() { return this._config?.MinRequestIntervalMs ?? MIN_REQUEST_INTERVAL_MS; }
|
|
936
|
+
// ─── Capability Getters ──────────────────────────────────────────────
|
|
937
|
+
get SupportsCreate() { return true; }
|
|
938
|
+
get SupportsUpdate() { return true; }
|
|
939
|
+
get SupportsUpsert() { return true; }
|
|
940
|
+
get SupportsDelete() { return true; }
|
|
941
|
+
get SupportsSearch() { return true; }
|
|
942
|
+
get SupportsListing() { return true; }
|
|
943
|
+
get IntegrationName() { return 'HubSpot'; }
|
|
944
|
+
// ─── §7 sync-efficiency contract (HubSpot = the full reference implementation) ──────────
|
|
945
|
+
// The engine consumes these for adaptive rate limiting, peak parallelization, and precise 429
|
|
946
|
+
// back-off. HubSpot's public limit is ~100-110 requests / 10s per Private App; the connector's
|
|
947
|
+
// MinRequestIntervalMs (default 100ms) is the sustained pace.
|
|
948
|
+
/** ~10 req/s sustained (honors MinRequestIntervalMs config) with a ~100-request burst window. */
|
|
949
|
+
get RateLimitPolicy() {
|
|
950
|
+
const interval = this.effectiveMinRequestIntervalMs;
|
|
951
|
+
return {
|
|
952
|
+
TokensPerSec: Math.max(1, Math.round(1000 / (interval > 0 ? interval : 100))),
|
|
953
|
+
Burst: 100,
|
|
954
|
+
ThrottleBackoffFactor: 0.5,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
/** HubSpot rate-limits on a rolling 10-second window; on a 429 that escaped internal retries, back off ~10s. */
|
|
958
|
+
ExtractRetryAfterMs(error) {
|
|
959
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
960
|
+
return /\b429\b|rate.?limit|too many requests/i.test(msg) ? 10_000 : undefined;
|
|
961
|
+
}
|
|
962
|
+
/** HubSpot tolerates modest object-level parallelism; the engine's AIMD controller ramps toward this, with the token-bucket as the real backstop. */
|
|
963
|
+
get MaxConcurrencyHint() { return 4; }
|
|
964
|
+
// ─── Action Metadata ─────────────────────────────────────────────────
|
|
965
|
+
GetIntegrationObjects() {
|
|
966
|
+
return HUBSPOT_OBJECTS;
|
|
967
|
+
}
|
|
968
|
+
GetActionGeneratorConfig() {
|
|
969
|
+
const config = super.GetActionGeneratorConfig();
|
|
970
|
+
if (!config)
|
|
971
|
+
return null;
|
|
972
|
+
config.IconClass = 'fa-brands fa-hubspot';
|
|
973
|
+
config.CreateCategory = false; // HubSpot category already exists in metadata/action-categories
|
|
974
|
+
return config;
|
|
975
|
+
}
|
|
976
|
+
// ─── Live API Discovery ─────────────────────────────────────────────
|
|
977
|
+
/** Known standard HubSpot CRM object type IDs → API names. */
|
|
978
|
+
/**
|
|
979
|
+
* All standard CRM object type IDs → names.
|
|
980
|
+
* Fields discovered live via /crm/v3/properties/{objectType}.
|
|
981
|
+
* All support: GET, POST, PATCH, DELETE, search (incremental via hs_lastmodifieddate).
|
|
982
|
+
*/
|
|
983
|
+
static { this.STANDARD_OBJECTS = {
|
|
984
|
+
// Core CRM
|
|
985
|
+
'0-1': 'contacts', '0-2': 'companies', '0-3': 'deals', '0-4': 'leads',
|
|
986
|
+
'0-5': 'tickets', '0-7': 'products', '0-8': 'line_items',
|
|
987
|
+
// Activities
|
|
988
|
+
'0-18': 'communications', '0-19': 'feedback_submissions',
|
|
989
|
+
'0-27': 'tasks', '0-46': 'notes', '0-47': 'meetings',
|
|
990
|
+
'0-48': 'calls', '0-49': 'emails', '0-116': 'postal_mail',
|
|
991
|
+
// Commerce
|
|
992
|
+
'0-14': 'quotes', '0-53': 'invoices', '0-69': 'subscriptions',
|
|
993
|
+
'0-74': 'goal_targets', '0-84': 'discounts', '0-85': 'fees',
|
|
994
|
+
'0-86': 'taxes', '0-101': 'commerce_payments', '0-115': 'users',
|
|
995
|
+
'0-123': 'orders', '0-142': 'carts',
|
|
996
|
+
// Marketing (CRM-backed)
|
|
997
|
+
'0-54': 'marketing_events',
|
|
998
|
+
// Activatable (Object Library)
|
|
999
|
+
'0-162': 'services', '0-410': 'courses', '0-420': 'listings',
|
|
1000
|
+
'0-421': 'appointments',
|
|
1001
|
+
// System / Other CRM
|
|
1002
|
+
// NOTE: deal_splits (0-72), transcriptions (0-150), contracts (0-155) objectTypeIds
|
|
1003
|
+
// are confirmed from the HubSpot API catalog as of April 2026 but have not been
|
|
1004
|
+
// independently verified against a live account. partner_clients, partner_services,
|
|
1005
|
+
// and subscription_lifecycle have TBD objectTypeIds — discovered dynamically via schemas API.
|
|
1006
|
+
'0-970': 'projects', '0-72': 'deal_splits', '0-150': 'transcriptions',
|
|
1007
|
+
'0-155': 'contracts', '0-136': 'goals',
|
|
1008
|
+
}; }
|
|
1009
|
+
/**
|
|
1010
|
+
* Non-CRM API endpoints that don't follow the /crm/v3/objects pattern.
|
|
1011
|
+
* Fields cannot be discovered via /crm/v3/properties — discovered dynamically
|
|
1012
|
+
* by fetching one record and inferring fields from the response.
|
|
1013
|
+
*/
|
|
1014
|
+
static { this.NON_CRM_OBJECTS = [
|
|
1015
|
+
// ── CRM Config ───────────────────────────────────────────────────
|
|
1016
|
+
{ name: 'owners', label: 'Owners', description: 'HubSpot users who own records', apiPath: '/crm/v3/owners', write: false, incremental: true, pkField: 'id', incrementalParam: 'after' },
|
|
1017
|
+
{ name: 'deal_pipelines', label: 'Deal Pipelines', description: 'Deal pipeline definitions and stages', apiPath: '/crm/v3/pipelines/deals', write: true, incremental: false, pkField: 'id' },
|
|
1018
|
+
{ name: 'ticket_pipelines', label: 'Ticket Pipelines', description: 'Ticket pipeline definitions and stages', apiPath: '/crm/v3/pipelines/tickets', write: true, incremental: false, pkField: 'id' },
|
|
1019
|
+
{ name: 'forecasts', label: 'Forecasts', description: 'CRM sales forecasts', apiPath: '/crm/v3/forecasts', write: false, incremental: false, pkField: 'id' },
|
|
1020
|
+
// Pipeline stages — parameterized; falls back to DB fields
|
|
1021
|
+
{ name: 'deal_pipeline_stages', label: 'Deal Pipeline Stages', description: 'Stages within deal pipelines', apiPath: '/crm/v3/pipelines/deals/{pipelineId}/stages', write: true, incremental: false, pkField: 'id', parentObject: 'deal_pipelines' },
|
|
1022
|
+
{ name: 'ticket_pipeline_stages', label: 'Ticket Pipeline Stages', description: 'Stages within ticket pipelines', apiPath: '/crm/v3/pipelines/tickets/{pipelineId}/stages', write: true, incremental: false, pkField: 'id', parentObject: 'ticket_pipelines' },
|
|
1023
|
+
// Lists v3 — The legacy v1 Contact Lists API sunsets April 30, 2026.
|
|
1024
|
+
// This connector correctly uses the v3 endpoint which is the migration target.
|
|
1025
|
+
{ name: 'lists', label: 'Lists', description: 'Contact and company lists', apiPath: '/crm/v3/lists', write: true, incremental: true, pkField: 'listId' },
|
|
1026
|
+
// List memberships — parameterized; fan-out across all lists
|
|
1027
|
+
{ name: 'list_memberships', label: 'List Memberships', description: 'Records belonging to a list', apiPath: '/crm/v3/lists/{listId}/memberships', write: true, incremental: false, pkField: 'recordId', parentObject: 'lists' },
|
|
1028
|
+
{ name: 'list_folders', label: 'List Folders', description: 'Organizational folders for lists', apiPath: '/crm/v3/lists/folders', write: true, incremental: false, pkField: 'id' },
|
|
1029
|
+
// CRM Imports & Exports
|
|
1030
|
+
{ name: 'crm_imports', label: 'CRM Imports', description: 'Import job history and status', apiPath: '/crm/v3/imports', write: false, incremental: true, pkField: 'id' },
|
|
1031
|
+
{ name: 'crm_exports', label: 'CRM Exports', description: 'Export job history and status', apiPath: '/crm/v3/exports/export/async', write: false, incremental: false, pkField: 'id' },
|
|
1032
|
+
// ── Marketing ────────────────────────────────────────────────────
|
|
1033
|
+
{ name: 'marketing_emails', label: 'Marketing Emails', description: 'Marketing email campaigns', apiPath: '/marketing/v3/emails', write: true, incremental: true, pkField: 'id', serverIncrementalParam: 'updatedAfter' },
|
|
1034
|
+
{ name: 'campaigns', label: 'Campaigns', description: 'Marketing campaign tracking', apiPath: '/marketing/v3/campaigns', write: false, incremental: true, pkField: 'id', serverIncrementalParam: 'updatedAfter' },
|
|
1035
|
+
// Forms v3 — DEVELOPER_PREVIEW status in HubSpot API catalog. Use for listing/reading;
|
|
1036
|
+
// creating forms programmatically may not be supported in all plans.
|
|
1037
|
+
{ name: 'forms', label: 'Forms', description: 'HubSpot forms for lead capture', apiPath: '/marketing/v3/forms', write: true, incremental: true, pkField: 'id', serverIncrementalParam: 'updatedAfter' },
|
|
1038
|
+
// Form submissions — legacy v1 endpoint; parameterized (requires formGuid); falls back to DB fields
|
|
1039
|
+
{ name: 'form_submissions', label: 'Form Submissions', description: 'Submitted form data across all forms', apiPath: '/form-integrations/v1/submissions/forms', write: false, incremental: true, pkField: 'submittedAt' },
|
|
1040
|
+
// Transactional email — v3 SMTP tokens for single-send (legacy Transactional Single Send)
|
|
1041
|
+
{ name: 'transactional_smtp_tokens', label: 'Transactional SMTP Tokens', description: 'SMTP API tokens for transactional email', apiPath: '/marketing/v3/transactional/smtp-tokens', write: true, incremental: false, pkField: 'id' },
|
|
1042
|
+
// Single-send v4 — separate from the v3 SMTP token endpoint; DEVELOPER_PREVIEW in catalog
|
|
1043
|
+
{ name: 'single_send_v4', label: 'Single Send (v4)', description: 'Single-send transactional email via Marketing API v4', apiPath: '/marketing/v4/email/single-send', write: true, incremental: false, pkField: 'id' },
|
|
1044
|
+
// Ads
|
|
1045
|
+
{ name: 'ad_campaigns', label: 'Ad Campaigns', description: 'Advertising campaigns across ad networks', apiPath: '/marketing/v3/ads/campaigns', write: false, incremental: true, pkField: 'id', serverIncrementalParam: 'updatedAfter' },
|
|
1046
|
+
{ name: 'ad_accounts', label: 'Ad Accounts', description: 'Connected advertising accounts', apiPath: '/marketing/v3/ads/accounts', write: false, incremental: false, pkField: 'id' },
|
|
1047
|
+
// ── CMS ──────────────────────────────────────────────────────────
|
|
1048
|
+
{ name: 'site_pages', label: 'Site Pages', description: 'CMS website pages', apiPath: '/cms/v3/pages/site-pages', write: true, incremental: true, pkField: 'id', serverIncrementalParam: 'updatedAfter' },
|
|
1049
|
+
{ name: 'landing_pages', label: 'Landing Pages', description: 'CMS landing pages', apiPath: '/cms/v3/pages/landing-pages', write: true, incremental: true, pkField: 'id', serverIncrementalParam: 'updatedAfter' },
|
|
1050
|
+
{ name: 'blog_posts', label: 'Blog Posts', description: 'CMS blog posts', apiPath: '/cms/v3/blogs/posts', write: true, incremental: true, pkField: 'id', serverIncrementalParam: 'updatedAfter' },
|
|
1051
|
+
{ name: 'blog_authors', label: 'Blog Authors', description: 'CMS blog author profiles', apiPath: '/cms/v3/blogs/authors', write: true, incremental: true, pkField: 'id', serverIncrementalParam: 'updatedAfter' },
|
|
1052
|
+
{ name: 'blog_tags', label: 'Blog Tags', description: 'CMS blog tag taxonomy', apiPath: '/cms/v3/blogs/tags', write: true, incremental: true, pkField: 'id', serverIncrementalParam: 'updatedAfter' },
|
|
1053
|
+
{ name: 'blog_settings', label: 'Blog Settings', description: 'CMS blog configuration', apiPath: '/cms/v3/blogs/settings', write: false, incremental: false, pkField: 'id' },
|
|
1054
|
+
{ name: 'domains', label: 'Domains', description: 'Connected domains', apiPath: '/cms/v3/domains', write: false, incremental: false, pkField: 'id' },
|
|
1055
|
+
{ name: 'url_mappings', label: 'URL Mappings', description: 'CMS URL mapping rules', apiPath: '/cms/v3/url-redirects/mapping', write: true, incremental: false, pkField: 'id' },
|
|
1056
|
+
{ name: 'url_redirects', label: 'URL Redirects', description: 'URL redirect rules', apiPath: '/cms/v3/url-redirects', write: true, incremental: false, pkField: 'id' },
|
|
1057
|
+
{ name: 'site_search', label: 'Site Search', description: 'CMS site search index results', apiPath: '/cms/v3/site-search/search', write: false, incremental: false, pkField: 'id' },
|
|
1058
|
+
{ name: 'source_code', label: 'Source Code', description: 'CMS theme and template source files', apiPath: '/cms/v3/source-code/environment/published', write: true, incremental: false, pkField: 'path' },
|
|
1059
|
+
{ name: 'media_bridge', label: 'Media Bridge', description: 'External media provider bridge objects', apiPath: '/cms/v3/media-bridge/objects', write: true, incremental: false, pkField: 'id' },
|
|
1060
|
+
{ name: 'hubdb_tables', label: 'HubDB Tables', description: 'HubDB structured data tables', apiPath: '/cms/v3/hubdb/tables', write: true, incremental: true, pkField: 'id', serverIncrementalParam: 'updatedAfter' },
|
|
1061
|
+
// HubDB rows — parameterized; fan-out across all HubDB tables
|
|
1062
|
+
{ name: 'hubdb_rows', label: 'HubDB Rows', description: 'Row data within HubDB tables', apiPath: '/cms/v3/hubdb/tables/{tableIdOrName}/rows', write: true, incremental: false, pkField: 'id', parentObject: 'hubdb_tables' },
|
|
1063
|
+
// ── Automation ───────────────────────────────────────────────────
|
|
1064
|
+
// v3/workflows is the standard listing endpoint
|
|
1065
|
+
{ name: 'workflows', label: 'Workflows', description: 'Automation workflows', apiPath: '/automation/v3/workflows', write: false, incremental: false, pkField: 'id' },
|
|
1066
|
+
// v4/actions — custom coded actions (requires appId); falls back to DB fields
|
|
1067
|
+
{ name: 'custom_coded_actions', label: 'Custom Coded Actions', description: 'Developer-created workflow extension actions', apiPath: '/automation/v4/actions/{appId}', write: true, incremental: false, pkField: 'id' },
|
|
1068
|
+
// ── Events ───────────────────────────────────────────────────────
|
|
1069
|
+
{ name: 'behavioral_events', label: 'Behavioral Events', description: 'Custom behavioral event completions', apiPath: '/events/v3/events', write: true, incremental: true, pkField: 'id', serverIncrementalParam: 'occurredAfter' },
|
|
1070
|
+
{ name: 'event_definitions', label: 'Event Definitions', description: 'Custom event type definitions', apiPath: '/events/v3/event-definitions', write: true, incremental: false, pkField: 'name' },
|
|
1071
|
+
// Event completions — parameterized; fan-out across all event definitions
|
|
1072
|
+
{ name: 'event_completions', label: 'Event Completions', description: 'Completion records for a specific custom behavioral event type', apiPath: '/events/v3/event-definitions/{eventDefinitionName}/completions', write: false, incremental: true, pkField: 'id', parentObject: 'event_definitions' },
|
|
1073
|
+
// ── Files ────────────────────────────────────────────────────────
|
|
1074
|
+
{ name: 'files', label: 'Files', description: 'File manager files and documents', apiPath: '/files/v3/files', write: true, incremental: true, pkField: 'id', serverIncrementalParam: 'updatedAfter' },
|
|
1075
|
+
{ name: 'file_folders', label: 'File Folders', description: 'File manager folder structure', apiPath: '/files/v3/folders', write: true, incremental: false, pkField: 'id' },
|
|
1076
|
+
// ── Account & Settings ───────────────────────────────────────────
|
|
1077
|
+
{ name: 'account_info', label: 'Account Info', description: 'HubSpot portal account details', apiPath: '/account-info/v3/details', write: false, incremental: false, pkField: 'portalId' },
|
|
1078
|
+
{ name: 'api_usage', label: 'API Usage', description: 'Daily API usage statistics', apiPath: '/account-info/v3/api-usage/daily', write: false, incremental: true, pkField: 'date' },
|
|
1079
|
+
{ name: 'audit_logs', label: 'Audit Logs', description: 'Account activity audit trail', apiPath: '/account-info/v3/audit-logs/activity', write: false, incremental: true, pkField: 'id' },
|
|
1080
|
+
{ name: 'portal_users', label: 'Portal Users', description: 'HubSpot portal users and permissions', apiPath: '/settings/v3/users', write: true, incremental: false, pkField: 'id' },
|
|
1081
|
+
{ name: 'user_roles', label: 'User Roles', description: 'Portal user role definitions', apiPath: '/settings/v3/users/roles', write: false, incremental: false, pkField: 'id' },
|
|
1082
|
+
{ name: 'business_units', label: 'Business Units', description: 'Business unit partitions within a portal', apiPath: '/business-units/v3/business-units', write: false, incremental: false, pkField: 'id' },
|
|
1083
|
+
{ name: 'currencies', label: 'Currencies', description: 'Exchange rate and currency settings', apiPath: '/settings/v3/currencies', write: true, incremental: false, pkField: 'currencyCode' },
|
|
1084
|
+
{ name: 'tax_rates', label: 'Tax Rates', description: 'Tax rate definitions', apiPath: '/tax-rates/v3/tax-rates', write: true, incremental: false, pkField: 'id' },
|
|
1085
|
+
// ── Communication Preferences ────────────────────────────────────
|
|
1086
|
+
{ name: 'subscription_definitions', label: 'Subscription Definitions', description: 'Email subscription types', apiPath: '/communication-preferences/v4/definitions', write: true, incremental: false, pkField: 'id' },
|
|
1087
|
+
// ── User Provisioning (SCIM) ─────────────────────────────────────
|
|
1088
|
+
// Requires Enterprise + SCIM scope. Standard SCIM 2.0 schema.
|
|
1089
|
+
{ name: 'scim_users', label: 'SCIM Users', description: 'User provisioning via SCIM 2.0', apiPath: '/scim/v2/Users', write: true, incremental: true, pkField: 'id', incrementalParam: 'startIndex' },
|
|
1090
|
+
{ name: 'scim_groups', label: 'SCIM Groups', description: 'Group provisioning via SCIM 2.0', apiPath: '/scim/v2/Groups', write: true, incremental: false, pkField: 'id' },
|
|
1091
|
+
// ── Conversations ────────────────────────────────────────────────
|
|
1092
|
+
{ name: 'conversation_inboxes', label: 'Conversation Inboxes', description: 'Conversations inbox definitions', apiPath: '/conversations/v3/conversations/inboxes', write: false, incremental: false, pkField: 'id' },
|
|
1093
|
+
{ name: 'conversation_threads', label: 'Conversation Threads', description: 'Conversations inbox threads', apiPath: '/conversations/v3/conversations/threads', write: false, incremental: true, pkField: 'id', serverIncrementalParam: 'updatedAfter' },
|
|
1094
|
+
// Conversation messages — parameterized; fan-out across threads. NOTE: can be very large
|
|
1095
|
+
// at scale (one fetch per thread). Consider disabling for high-volume portals.
|
|
1096
|
+
{ name: 'conversation_messages', label: 'Conversation Messages', description: 'Messages within conversation threads', apiPath: '/conversations/v3/conversations/threads/{threadId}/messages', write: false, incremental: false, pkField: 'id', parentObject: 'conversation_threads' },
|
|
1097
|
+
// Inbox channels — parameterized; fan-out across inboxes
|
|
1098
|
+
{ name: 'conversation_inbox_channels', label: 'Conversation Inbox Channels', description: 'Communication channels attached to an inbox', apiPath: '/conversations/v3/conversations/inboxes/{inboxId}/channels', write: false, incremental: false, pkField: 'channelId', parentObject: 'conversation_inboxes' },
|
|
1099
|
+
// Custom channels (developer-registered channels)
|
|
1100
|
+
{ name: 'conversation_custom_channels', label: 'Conversation Custom Channels', description: 'Developer-registered custom communication channels', apiPath: '/conversations/custom-channels/v3', write: true, incremental: false, pkField: 'id' },
|
|
1101
|
+
// Conversation channels — generic channel listing
|
|
1102
|
+
{ name: 'conversation_channels', label: 'Conversation Channels', description: 'Communication channels for conversations', apiPath: '/conversations/v3/conversations/channels', write: false, incremental: false, pkField: 'id' },
|
|
1103
|
+
// Visitor identification
|
|
1104
|
+
{ name: 'visitor_identification', label: 'Visitor Identification', description: 'Visitor identification tokens for conversations', apiPath: '/conversations/v3/visitor-identification/tokens/create', write: false, incremental: false, pkField: 'token' },
|
|
1105
|
+
// ── Timeline Events ──────────────────────────────────────────────
|
|
1106
|
+
// Timeline event templates — parameterized (requires appId); falls back to DB fields
|
|
1107
|
+
{ name: 'timeline_event_templates', label: 'Timeline Event Templates', description: 'Custom timeline event type definitions', apiPath: '/integrators/timeline/v3/{appId}/event-templates', write: true, incremental: false, pkField: 'id' },
|
|
1108
|
+
// ── Automation ───────────────────────────────────────────────────
|
|
1109
|
+
{ name: 'sequences', label: 'Sequences', description: 'Sales sequences and enrollment rules', apiPath: '/automation/v3/sequences', write: false, incremental: false, pkField: 'id' },
|
|
1110
|
+
// ── Scheduler ───────────────────────────────────────────────────
|
|
1111
|
+
{ name: 'meeting_scheduler', label: 'Meeting Scheduler', description: 'Meeting booking page configurations', apiPath: '/scheduler/v3/meetings/meeting-links', write: false, incremental: false, pkField: 'id' },
|
|
1112
|
+
// ── Analytics / Reporting (Legacy) ───────────────────────────────
|
|
1113
|
+
{ name: 'email_campaigns_legacy', label: 'Email Campaigns (Legacy)', description: 'Email campaign tracking and analytics', apiPath: '/email/public/v1/campaigns', write: false, incremental: true, pkField: 'id' },
|
|
1114
|
+
// ── Data Studio ─────────────────────────────────────────────────
|
|
1115
|
+
{ name: 'datasource_ingestion', label: 'Datasource Ingestion', description: 'External data source ingestion (beta)', apiPath: '/data-studio/v3/datasource-ingestion', write: true, incremental: false, pkField: 'id' },
|
|
1116
|
+
]; }
|
|
1117
|
+
/**
|
|
1118
|
+
* Association objects use the v4 per-object associations endpoint.
|
|
1119
|
+
* Fields are fixed (two FK columns + association_type) — no live discovery API exists.
|
|
1120
|
+
*/
|
|
1121
|
+
static { this.ASSOCIATION_OBJECTS = [
|
|
1122
|
+
{ name: 'assoc_contacts_companies', label: 'Contact ↔ Company', description: 'Associations between contacts and companies', apiPath: '/crm/v4/associations/contacts/companies', pkFields: ['contact_id', 'company_id'] },
|
|
1123
|
+
// deal->contact = 3 (HUBSPOT_DEFINED, verified via /labels). Wire from=deal even though stored key is contact|deal.
|
|
1124
|
+
{ name: 'assoc_contacts_deals', label: 'Contact ↔ Deal', description: 'Associations between contacts and deals', apiPath: '/crm/v4/associations/contacts/deals', pkFields: ['contact_id', 'deal_id'], fromType: 'deals', toType: 'contacts', fromPkField: 'deal_id', toPkField: 'contact_id', associationCategory: 'HUBSPOT_DEFINED', associationTypeId: 3 },
|
|
1125
|
+
{ name: 'assoc_contacts_tickets', label: 'Contact ↔ Ticket', description: 'Associations between contacts and tickets', apiPath: '/crm/v4/associations/contacts/tickets', pkFields: ['contact_id', 'ticket_id'] },
|
|
1126
|
+
{ name: 'assoc_contacts_calls', label: 'Contact ↔ Call', description: 'Associations between contacts and calls', apiPath: '/crm/v4/associations/contacts/calls', pkFields: ['contact_id', 'call_id'] },
|
|
1127
|
+
{ name: 'assoc_contacts_emails', label: 'Contact ↔ Email', description: 'Associations between contacts and emails', apiPath: '/crm/v4/associations/contacts/emails', pkFields: ['contact_id', 'email_id'] },
|
|
1128
|
+
{ name: 'assoc_contacts_meetings', label: 'Contact ↔ Meeting', description: 'Associations between contacts and meetings', apiPath: '/crm/v4/associations/contacts/meetings', pkFields: ['contact_id', 'meeting_id'] },
|
|
1129
|
+
{ name: 'assoc_contacts_notes', label: 'Contact ↔ Note', description: 'Associations between contacts and notes', apiPath: '/crm/v4/associations/contacts/notes', pkFields: ['contact_id', 'note_id'] },
|
|
1130
|
+
{ name: 'assoc_contacts_tasks', label: 'Contact ↔ Task', description: 'Associations between contacts and tasks', apiPath: '/crm/v4/associations/contacts/tasks', pkFields: ['contact_id', 'task_id'] },
|
|
1131
|
+
{ name: 'assoc_contacts_feedback_submissions', label: 'Contact ↔ Feedback Submission', description: 'Associations between contacts and feedback submissions', apiPath: '/crm/v4/associations/contacts/feedback_submissions', pkFields: ['contact_id', 'feedback_submission_id'] },
|
|
1132
|
+
// No hardcoded typeId — resolved at runtime via /labels (deals/companies exposes multiple HUBSPOT_DEFINED defaults).
|
|
1133
|
+
{ name: 'assoc_companies_deals', label: 'Company ↔ Deal', description: 'Associations between companies and deals', apiPath: '/crm/v4/associations/companies/deals', pkFields: ['company_id', 'deal_id'], fromType: 'companies', toType: 'deals', fromPkField: 'company_id', toPkField: 'deal_id', associationCategory: 'HUBSPOT_DEFINED' },
|
|
1134
|
+
{ name: 'assoc_companies_tickets', label: 'Company ↔ Ticket', description: 'Associations between companies and tickets', apiPath: '/crm/v4/associations/companies/tickets', pkFields: ['company_id', 'ticket_id'] },
|
|
1135
|
+
{ name: 'assoc_companies_calls', label: 'Company ↔ Call', description: 'Associations between companies and calls', apiPath: '/crm/v4/associations/companies/calls', pkFields: ['company_id', 'call_id'] },
|
|
1136
|
+
{ name: 'assoc_companies_emails', label: 'Company ↔ Email', description: 'Associations between companies and emails', apiPath: '/crm/v4/associations/companies/emails', pkFields: ['company_id', 'email_id'] },
|
|
1137
|
+
{ name: 'assoc_companies_meetings', label: 'Company ↔ Meeting', description: 'Associations between companies and meetings', apiPath: '/crm/v4/associations/companies/meetings', pkFields: ['company_id', 'meeting_id'] },
|
|
1138
|
+
{ name: 'assoc_companies_notes', label: 'Company ↔ Note', description: 'Associations between companies and notes', apiPath: '/crm/v4/associations/companies/notes', pkFields: ['company_id', 'note_id'] },
|
|
1139
|
+
{ name: 'assoc_companies_tasks', label: 'Company ↔ Task', description: 'Associations between companies and tasks', apiPath: '/crm/v4/associations/companies/tasks', pkFields: ['company_id', 'task_id'] },
|
|
1140
|
+
{ name: 'assoc_deals_calls', label: 'Deal ↔ Call', description: 'Associations between deals and calls', apiPath: '/crm/v4/associations/deals/calls', pkFields: ['deal_id', 'call_id'] },
|
|
1141
|
+
{ name: 'assoc_deals_emails', label: 'Deal ↔ Email', description: 'Associations between deals and emails', apiPath: '/crm/v4/associations/deals/emails', pkFields: ['deal_id', 'email_id'] },
|
|
1142
|
+
{ name: 'assoc_deals_meetings', label: 'Deal ↔ Meeting', description: 'Associations between deals and meetings', apiPath: '/crm/v4/associations/deals/meetings', pkFields: ['deal_id', 'meeting_id'] },
|
|
1143
|
+
{ name: 'assoc_deals_notes', label: 'Deal ↔ Note', description: 'Associations between deals and notes', apiPath: '/crm/v4/associations/deals/notes', pkFields: ['deal_id', 'note_id'] },
|
|
1144
|
+
{ name: 'assoc_deals_tasks', label: 'Deal ↔ Task', description: 'Associations between deals and tasks', apiPath: '/crm/v4/associations/deals/tasks', pkFields: ['deal_id', 'task_id'] },
|
|
1145
|
+
{ name: 'assoc_deals_quotes', label: 'Deal ↔ Quote', description: 'Associations between deals and quotes', apiPath: '/crm/v4/associations/deals/quotes', pkFields: ['deal_id', 'quote_id'] },
|
|
1146
|
+
{ name: 'assoc_deals_line_items', label: 'Deal ↔ Line Item', description: 'Associations between deals and line items', apiPath: '/crm/v4/associations/deals/line_items', pkFields: ['deal_id', 'line_item_id'] },
|
|
1147
|
+
{ name: 'assoc_tickets_calls', label: 'Ticket ↔ Call', description: 'Associations between tickets and calls', apiPath: '/crm/v4/associations/tickets/calls', pkFields: ['ticket_id', 'call_id'] },
|
|
1148
|
+
{ name: 'assoc_tickets_emails', label: 'Ticket ↔ Email', description: 'Associations between tickets and emails', apiPath: '/crm/v4/associations/tickets/emails', pkFields: ['ticket_id', 'email_id'] },
|
|
1149
|
+
{ name: 'assoc_tickets_meetings', label: 'Ticket ↔ Meeting', description: 'Associations between tickets and meetings', apiPath: '/crm/v4/associations/tickets/meetings', pkFields: ['ticket_id', 'meeting_id'] },
|
|
1150
|
+
{ name: 'assoc_tickets_notes', label: 'Ticket ↔ Note', description: 'Associations between tickets and notes', apiPath: '/crm/v4/associations/tickets/notes', pkFields: ['ticket_id', 'note_id'] },
|
|
1151
|
+
{ name: 'assoc_tickets_tasks', label: 'Ticket ↔ Task', description: 'Associations between tickets and tasks', apiPath: '/crm/v4/associations/tickets/tasks', pkFields: ['ticket_id', 'task_id'] },
|
|
1152
|
+
{ name: 'assoc_tickets_feedback_submissions', label: 'Ticket ↔ Feedback Submission', description: 'Associations between tickets and feedback submissions', apiPath: '/crm/v4/associations/tickets/feedback_submissions', pkFields: ['ticket_id', 'feedback_submission_id'] },
|
|
1153
|
+
{ name: 'assoc_quotes_contacts', label: 'Quote ↔ Contact', description: 'Associations between quotes and contacts', apiPath: '/crm/v4/associations/quotes/contacts', pkFields: ['quote_id', 'contact_id'] },
|
|
1154
|
+
{ name: 'assoc_quotes_line_items', label: 'Quote ↔ Line Item', description: 'Associations between quotes and line items', apiPath: '/crm/v4/associations/quotes/line_items', pkFields: ['quote_id', 'line_item_id'] },
|
|
1155
|
+
]; }
|
|
1156
|
+
/**
|
|
1157
|
+
* Discovers all HubSpot objects (standard + custom + non-CRM + associations) via live API and static lists.
|
|
1158
|
+
*/
|
|
1159
|
+
async DiscoverObjects(companyIntegration, contextUser) {
|
|
1160
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
1161
|
+
const headers = this.BuildHeaders(auth);
|
|
1162
|
+
const results = [];
|
|
1163
|
+
// Add all known standard objects
|
|
1164
|
+
for (const [typeId, name] of Object.entries(HubSpotConnector_1.STANDARD_OBJECTS)) {
|
|
1165
|
+
results.push({
|
|
1166
|
+
Name: name,
|
|
1167
|
+
Label: name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
|
|
1168
|
+
Description: `HubSpot ${name} (${typeId})`,
|
|
1169
|
+
SupportsIncrementalSync: true,
|
|
1170
|
+
SupportsWrite: true,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
// Discover custom CRM objects via /crm/v3/schemas
|
|
1174
|
+
try {
|
|
1175
|
+
const schemasUrl = `${HUBSPOT_API_BASE}/crm/v3/schemas`;
|
|
1176
|
+
const schemasResp = await this.MakeHTTPRequest(auth, schemasUrl, 'GET', headers);
|
|
1177
|
+
if (schemasResp.Status === 200) {
|
|
1178
|
+
const body = schemasResp.Body;
|
|
1179
|
+
for (const s of body.results ?? []) {
|
|
1180
|
+
const name = s.name ?? s.labels?.singular ?? s.objectTypeId;
|
|
1181
|
+
if (!results.some(r => r.Name === name)) {
|
|
1182
|
+
results.push({
|
|
1183
|
+
Name: name,
|
|
1184
|
+
Label: s.labels?.singular ?? name,
|
|
1185
|
+
Description: `HubSpot custom object: ${name}`,
|
|
1186
|
+
SupportsIncrementalSync: true,
|
|
1187
|
+
SupportsWrite: true,
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
catch (err) {
|
|
1194
|
+
console.warn(`[HubSpot] Custom object discovery failed (non-fatal): ${err instanceof Error ? err.message : err}`);
|
|
1195
|
+
}
|
|
1196
|
+
// Add non-CRM objects (Marketing, CMS, Automation, etc.)
|
|
1197
|
+
for (const obj of HubSpotConnector_1.NON_CRM_OBJECTS) {
|
|
1198
|
+
if (!results.some(r => r.Name === obj.name)) {
|
|
1199
|
+
results.push({
|
|
1200
|
+
Name: obj.name,
|
|
1201
|
+
Label: obj.label,
|
|
1202
|
+
Description: obj.description,
|
|
1203
|
+
SupportsIncrementalSync: obj.incremental,
|
|
1204
|
+
SupportsWrite: obj.write,
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
// Add association objects (v4 API — fixed schema, no live field discovery)
|
|
1209
|
+
for (const assoc of HubSpotConnector_1.ASSOCIATION_OBJECTS) {
|
|
1210
|
+
if (!results.some(r => r.Name === assoc.name)) {
|
|
1211
|
+
results.push({
|
|
1212
|
+
Name: assoc.name,
|
|
1213
|
+
Label: assoc.label,
|
|
1214
|
+
Description: assoc.description,
|
|
1215
|
+
SupportsIncrementalSync: false,
|
|
1216
|
+
SupportsWrite: false,
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
return results;
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Helper to check if an object name is a CRM object (has /crm/v3/properties endpoint).
|
|
1224
|
+
*/
|
|
1225
|
+
IsCRMObject(objectName) {
|
|
1226
|
+
const crmNames = new Set(Object.values(HubSpotConnector_1.STANDARD_OBJECTS));
|
|
1227
|
+
// Custom objects also use CRM properties API
|
|
1228
|
+
return crmNames.has(objectName) || objectName.startsWith('p_') || objectName.startsWith('2-');
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Finds the non-CRM object config by name.
|
|
1232
|
+
*/
|
|
1233
|
+
GetNonCRMObject(objectName) {
|
|
1234
|
+
return HubSpotConnector_1.NON_CRM_OBJECTS.find(o => o.name === objectName);
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Returns association object config if objectName is an association table, null otherwise.
|
|
1238
|
+
*/
|
|
1239
|
+
GetAssociationObject(objectName) {
|
|
1240
|
+
return HubSpotConnector_1.ASSOCIATION_OBJECTS.find(a => a.name === objectName);
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Discovers all fields on a HubSpot object via the Properties API.
|
|
1244
|
+
* Returns field types, constraints, PKs, and read-only flags from live metadata.
|
|
1245
|
+
*/
|
|
1246
|
+
async DiscoverFields(companyIntegration, objectName, contextUser) {
|
|
1247
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
1248
|
+
const headers = this.BuildHeaders(auth);
|
|
1249
|
+
// Non-CRM objects have fixed schemas — return static PK field; IntrospectSchema supplements from DB
|
|
1250
|
+
if (!this.IsCRMObject(objectName)) {
|
|
1251
|
+
return this.DiscoverNonCRMFields(objectName);
|
|
1252
|
+
}
|
|
1253
|
+
// CRM objects: live field discovery via Properties API
|
|
1254
|
+
const url = `${HUBSPOT_API_BASE}/crm/v3/properties/${objectName}`;
|
|
1255
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
1256
|
+
if (response.Status !== 200)
|
|
1257
|
+
return [];
|
|
1258
|
+
const body = response.Body;
|
|
1259
|
+
const props = body.results ?? [];
|
|
1260
|
+
const fields = props
|
|
1261
|
+
.filter(p => !p.hidden)
|
|
1262
|
+
.map(p => ({
|
|
1263
|
+
Name: p.name,
|
|
1264
|
+
Label: p.label || p.name,
|
|
1265
|
+
Description: p.description || undefined,
|
|
1266
|
+
DataType: this.MapHubSpotType(p.type, p.fieldType),
|
|
1267
|
+
IsRequired: false,
|
|
1268
|
+
IsUniqueKey: false, // hasUniqueValue is HubSpot field-level uniqueness, not the record PK; only hs_object_id is the true unique key
|
|
1269
|
+
IsReadOnly: p.modificationMetadata?.readOnlyValue === true || p.calculated,
|
|
1270
|
+
}));
|
|
1271
|
+
// Ensure hs_object_id is the record PK — HubSpot's Properties API sets
|
|
1272
|
+
// hasUniqueValue=false for hs_object_id even though it IS the record identifier,
|
|
1273
|
+
// AND the API never returns an IsPrimaryKey signal at all (PK lives in the
|
|
1274
|
+
// response envelope, not in property metadata). We must therefore stamp
|
|
1275
|
+
// IsPrimaryKey + IsUniqueKey + IsReadOnly on this field explicitly. Without
|
|
1276
|
+
// the IsPrimaryKey stamp, UpsertField's new-field path persists it without a
|
|
1277
|
+
// PK flag and the downstream SoftPKClassifier becomes our only safety net.
|
|
1278
|
+
const pkField = fields.find(f => f.Name === 'hs_object_id');
|
|
1279
|
+
if (pkField) {
|
|
1280
|
+
pkField.IsPrimaryKey = true;
|
|
1281
|
+
pkField.IsUniqueKey = true;
|
|
1282
|
+
pkField.IsReadOnly = true;
|
|
1283
|
+
}
|
|
1284
|
+
else {
|
|
1285
|
+
fields.push({
|
|
1286
|
+
Name: 'hs_object_id',
|
|
1287
|
+
Label: 'Object ID',
|
|
1288
|
+
Description: 'HubSpot internal object ID',
|
|
1289
|
+
DataType: 'string',
|
|
1290
|
+
IsRequired: true,
|
|
1291
|
+
IsPrimaryKey: true,
|
|
1292
|
+
IsUniqueKey: true,
|
|
1293
|
+
IsReadOnly: true,
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
return fields;
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Discovers fields for non-CRM objects by fetching the first page of results
|
|
1300
|
+
* and inferring field names/types from the response.
|
|
1301
|
+
*/
|
|
1302
|
+
/**
|
|
1303
|
+
* Non-CRM and association objects have fixed, documented schemas.
|
|
1304
|
+
* - Association objects: return both composite PK fields from ASSOCIATION_OBJECTS config.
|
|
1305
|
+
* - Non-CRM objects: return the PK field from NON_CRM_OBJECTS config.
|
|
1306
|
+
* IntrospectSchema's DB-fallback supplements with the full field list from metadata.
|
|
1307
|
+
* No live API sampling needed.
|
|
1308
|
+
*/
|
|
1309
|
+
DiscoverNonCRMFields(objectName) {
|
|
1310
|
+
const assocConfig = this.GetAssociationObject(objectName);
|
|
1311
|
+
if (assocConfig) {
|
|
1312
|
+
return assocConfig.pkFields.map(pk => ({
|
|
1313
|
+
Name: pk,
|
|
1314
|
+
Label: pk,
|
|
1315
|
+
Description: `Key field for ${assocConfig.label}`,
|
|
1316
|
+
DataType: 'string',
|
|
1317
|
+
IsRequired: true,
|
|
1318
|
+
IsUniqueKey: true,
|
|
1319
|
+
IsReadOnly: true,
|
|
1320
|
+
}));
|
|
1321
|
+
}
|
|
1322
|
+
const objConfig = this.GetNonCRMObject(objectName);
|
|
1323
|
+
if (!objConfig)
|
|
1324
|
+
return [];
|
|
1325
|
+
return [{
|
|
1326
|
+
Name: objConfig.pkField,
|
|
1327
|
+
Label: objConfig.pkField,
|
|
1328
|
+
Description: `Primary key for ${objConfig.label}`,
|
|
1329
|
+
DataType: 'string',
|
|
1330
|
+
IsRequired: true,
|
|
1331
|
+
IsUniqueKey: true,
|
|
1332
|
+
IsReadOnly: true,
|
|
1333
|
+
}];
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Priority-ordered list of HubSpot "last changed" timestamp field names, used to
|
|
1337
|
+
* populate SourceObjectInfo.IncrementalWatermarkField. Every name here is a field
|
|
1338
|
+
* the connector ALREADY declares on its objects — CRM objects expose
|
|
1339
|
+
* `hs_lastmodifieddate` (contacts use the legacy `lastmodifieddate`), while non-CRM
|
|
1340
|
+
* REST objects expose `updatedAt`. Provable-only: the watermark is set on an object
|
|
1341
|
+
* solely from that object's own declared field list, never invented.
|
|
1342
|
+
*/
|
|
1343
|
+
static { this.WATERMARK_FIELD_CANDIDATES = [
|
|
1344
|
+
'hs_lastmodifieddate',
|
|
1345
|
+
'lastmodifieddate',
|
|
1346
|
+
'updatedAt',
|
|
1347
|
+
]; }
|
|
1348
|
+
/**
|
|
1349
|
+
* Promotes an object's own declared "last changed" timestamp field into the
|
|
1350
|
+
* IncrementalWatermarkField slot. Returns the first candidate present in the
|
|
1351
|
+
* supplied field-name set, or undefined when the object declares none — an honest
|
|
1352
|
+
* gap rather than a fabricated watermark. Matching is case-insensitive so a
|
|
1353
|
+
* DB-cached field list (which may differ in casing) still resolves.
|
|
1354
|
+
*/
|
|
1355
|
+
PickIncrementalWatermarkField(fieldNames) {
|
|
1356
|
+
const present = new Set(fieldNames.map(n => n.toLowerCase()));
|
|
1357
|
+
for (const candidate of HubSpotConnector_1.WATERMARK_FIELD_CANDIDATES) {
|
|
1358
|
+
if (present.has(candidate.toLowerCase())) {
|
|
1359
|
+
// Return the actually-declared name (preserve its real casing).
|
|
1360
|
+
return fieldNames.find(n => n.toLowerCase() === candidate.toLowerCase());
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
return undefined;
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Full schema introspection — discovers all objects and their fields from the live API.
|
|
1367
|
+
*/
|
|
1368
|
+
async IntrospectSchema(companyIntegration, contextUser) {
|
|
1369
|
+
const objects = await this.DiscoverObjects(companyIntegration, contextUser);
|
|
1370
|
+
const result = { Objects: [] };
|
|
1371
|
+
for (const obj of objects) {
|
|
1372
|
+
try {
|
|
1373
|
+
const nonCrmConfig = this.GetNonCRMObject(obj.Name);
|
|
1374
|
+
const assocConfig = this.GetAssociationObject(obj.Name);
|
|
1375
|
+
const pkFieldNames = assocConfig
|
|
1376
|
+
? assocConfig.pkFields
|
|
1377
|
+
: [nonCrmConfig ? nonCrmConfig.pkField : 'hs_object_id'];
|
|
1378
|
+
let liveFields = await this.DiscoverFields(companyIntegration, obj.Name, contextUser);
|
|
1379
|
+
// Fall back to DB-cached Layer 1 static metadata when live discovery is insufficient:
|
|
1380
|
+
// - CRM objects: Properties API returned 0 fields (403/404/non-JSON)
|
|
1381
|
+
// - Non-CRM objects: endpoint returned only the pkField (parameterized or scope-restricted)
|
|
1382
|
+
const liveIsMinimal = liveFields.length === 0 ||
|
|
1383
|
+
(!this.IsCRMObject(obj.Name) && liveFields.length <= 1);
|
|
1384
|
+
if (liveIsMinimal) {
|
|
1385
|
+
try {
|
|
1386
|
+
const integrationObj = this.GetCachedObject(companyIntegration.IntegrationID, obj.Name);
|
|
1387
|
+
const dbFields = this.GetCachedFields(integrationObj.ID);
|
|
1388
|
+
if (dbFields.length > liveFields.length) {
|
|
1389
|
+
console.log(`[HubSpot] Live discovery returned ${liveFields.length} field(s) for "${obj.Name}" — using ${dbFields.length} DB-cached fields instead`);
|
|
1390
|
+
liveFields = dbFields.map(f => ({
|
|
1391
|
+
Name: f.Name,
|
|
1392
|
+
Label: f.DisplayName ?? f.Name,
|
|
1393
|
+
Description: f.Description ?? undefined,
|
|
1394
|
+
DataType: f.Type ?? 'string',
|
|
1395
|
+
IsRequired: f.IsRequired ?? false,
|
|
1396
|
+
IsUniqueKey: f.IsPrimaryKey || pkFieldNames.includes(f.Name),
|
|
1397
|
+
IsReadOnly: false,
|
|
1398
|
+
}));
|
|
1399
|
+
// Guarantee PK fields are present — DB records may not include them
|
|
1400
|
+
for (const pkName of pkFieldNames) {
|
|
1401
|
+
if (!liveFields.some(f => f.Name === pkName)) {
|
|
1402
|
+
liveFields.unshift({
|
|
1403
|
+
Name: pkName,
|
|
1404
|
+
Label: pkName,
|
|
1405
|
+
Description: `Primary key for ${obj.Name}`,
|
|
1406
|
+
DataType: 'string',
|
|
1407
|
+
IsRequired: true,
|
|
1408
|
+
IsUniqueKey: true,
|
|
1409
|
+
IsReadOnly: true,
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
catch {
|
|
1416
|
+
// No DB record yet — proceed with whatever live returned
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
const sourceFields = liveFields.map(f => ({
|
|
1420
|
+
Name: f.Name,
|
|
1421
|
+
Label: f.Label,
|
|
1422
|
+
Description: f.Description,
|
|
1423
|
+
SourceType: f.DataType,
|
|
1424
|
+
IsRequired: f.IsRequired,
|
|
1425
|
+
IsPrimaryKey: f.IsUniqueKey || pkFieldNames.includes(f.Name),
|
|
1426
|
+
IsForeignKey: false,
|
|
1427
|
+
ForeignKeyTarget: null,
|
|
1428
|
+
MaxLength: null,
|
|
1429
|
+
Precision: null,
|
|
1430
|
+
Scale: null,
|
|
1431
|
+
DefaultValue: null,
|
|
1432
|
+
}));
|
|
1433
|
+
const pkFields = sourceFields.filter(f => f.IsPrimaryKey);
|
|
1434
|
+
result.Objects.push({
|
|
1435
|
+
ExternalName: obj.Name,
|
|
1436
|
+
ExternalLabel: obj.Label ?? obj.Name,
|
|
1437
|
+
Description: obj.Description,
|
|
1438
|
+
Fields: sourceFields,
|
|
1439
|
+
PrimaryKeyFields: pkFields.map(f => f.Name),
|
|
1440
|
+
Relationships: [],
|
|
1441
|
+
IncrementalWatermarkField: this.PickIncrementalWatermarkField(sourceFields.map(f => f.Name)),
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
catch (err) {
|
|
1445
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1446
|
+
console.warn(`[HubSpot] Live discovery threw for "${obj.Name}": ${msg} — trying DB-cached fields`);
|
|
1447
|
+
// Unexpected exception path — try DB as last resort
|
|
1448
|
+
try {
|
|
1449
|
+
const nonCrmConfig2 = this.GetNonCRMObject(obj.Name);
|
|
1450
|
+
const assocConfig2 = this.GetAssociationObject(obj.Name);
|
|
1451
|
+
const pkFieldNames2 = assocConfig2
|
|
1452
|
+
? assocConfig2.pkFields
|
|
1453
|
+
: [nonCrmConfig2 ? nonCrmConfig2.pkField : 'hs_object_id'];
|
|
1454
|
+
const integrationObj = this.GetCachedObject(companyIntegration.IntegrationID, obj.Name);
|
|
1455
|
+
const dbFields = this.GetCachedFields(integrationObj.ID);
|
|
1456
|
+
if (dbFields.length > 0) {
|
|
1457
|
+
const sourceFields = dbFields.map(f => ({
|
|
1458
|
+
Name: f.Name,
|
|
1459
|
+
Label: f.DisplayName ?? f.Name,
|
|
1460
|
+
Description: f.Description ?? undefined,
|
|
1461
|
+
SourceType: f.Type ?? 'string',
|
|
1462
|
+
IsRequired: f.IsRequired ?? false,
|
|
1463
|
+
IsPrimaryKey: f.IsPrimaryKey || pkFieldNames2.includes(f.Name),
|
|
1464
|
+
IsForeignKey: false,
|
|
1465
|
+
ForeignKeyTarget: null,
|
|
1466
|
+
MaxLength: null,
|
|
1467
|
+
Precision: null,
|
|
1468
|
+
Scale: null,
|
|
1469
|
+
DefaultValue: null,
|
|
1470
|
+
}));
|
|
1471
|
+
const pkFields = sourceFields.filter(f => f.IsPrimaryKey);
|
|
1472
|
+
result.Objects.push({
|
|
1473
|
+
ExternalName: obj.Name,
|
|
1474
|
+
ExternalLabel: obj.Label ?? obj.Name,
|
|
1475
|
+
Description: obj.Description,
|
|
1476
|
+
Fields: sourceFields,
|
|
1477
|
+
PrimaryKeyFields: pkFields.map(f => f.Name),
|
|
1478
|
+
Relationships: [],
|
|
1479
|
+
IncrementalWatermarkField: this.PickIncrementalWatermarkField(sourceFields.map(f => f.Name)),
|
|
1480
|
+
});
|
|
1481
|
+
console.log(`[HubSpot] Used ${dbFields.length} DB-cached fields for "${obj.Name}" after exception`);
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
catch {
|
|
1486
|
+
// DB fallback also failed — truly skip this object
|
|
1487
|
+
}
|
|
1488
|
+
console.warn(`[HubSpot] Skipping "${obj.Name}" — no live or DB fields available`);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
// Sample-union augmentation (connector sample-union standard; see CONNECTOR_DISCOVERY_STANDARD.md):
|
|
1492
|
+
// this connector's own IntrospectSchema above builds the Declared/DB catalog with NO measured widths
|
|
1493
|
+
// (MaxLength: null). Wire MJ's EXISTING read-path sampler (`DiscoverFieldsViaFetch`) into that result —
|
|
1494
|
+
// per object, in parallel, best-effort — and let the shared PURE `mergeDeclaredWithSampledFields` union
|
|
1495
|
+
// the two by field name (never-shrink `max()` width; append MJ-discovered custom columns). No merge/PK/
|
|
1496
|
+
// type logic here — MJ owns all of it. Per-object failures keep that object's existing fields.
|
|
1497
|
+
await runBounded(result.Objects, 8, async (obj) => {
|
|
1498
|
+
try {
|
|
1499
|
+
const sampled = await this.DiscoverFieldsViaFetch(companyIntegration, obj.ExternalName, contextUser);
|
|
1500
|
+
obj.Fields = mergeDeclaredWithSampledFields(obj.Fields, sampled);
|
|
1501
|
+
}
|
|
1502
|
+
catch {
|
|
1503
|
+
// Keep this object's declared fields — sampling is best-effort and never breaks introspection.
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
return result;
|
|
1507
|
+
}
|
|
1508
|
+
// ─── CRUD Operations ─────────────────────────────────────────────────
|
|
1509
|
+
/**
|
|
1510
|
+
* Retrieves a single record by ExternalID (HubSpot object ID).
|
|
1511
|
+
*/
|
|
1512
|
+
async GetRecord(ctx) {
|
|
1513
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
1514
|
+
const contextUser = ctx.ContextUser;
|
|
1515
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
1516
|
+
const headers = this.BuildHeaders(auth);
|
|
1517
|
+
const propertiesParam = this.BuildPropertiesParam(ctx.ObjectName);
|
|
1518
|
+
const url = `${HUBSPOT_API_BASE}/crm/v3/objects/${ctx.ObjectName}/${ctx.ExternalID}?${propertiesParam.replace(/^&/, '')}`;
|
|
1519
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
1520
|
+
if (response.Status === 404)
|
|
1521
|
+
return null;
|
|
1522
|
+
this.ValidateCRUDResponse(response, 'GetRecord', ctx.ObjectName);
|
|
1523
|
+
const raw = response.Body;
|
|
1524
|
+
return this.RawToExternalRecord(raw, ctx.ObjectName);
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* Creates a new record in HubSpot.
|
|
1528
|
+
* Routes association objects to the v4 batch/create endpoint instead of v3 objects.
|
|
1529
|
+
*/
|
|
1530
|
+
async CreateRecord(ctx) {
|
|
1531
|
+
const assocConfig = this.GetAssociationObject(ctx.ObjectName);
|
|
1532
|
+
if (assocConfig) {
|
|
1533
|
+
return this.CreateAssociation(ctx.CompanyIntegration, ctx.ContextUser, ctx.ObjectName, ctx.Attributes, assocConfig);
|
|
1534
|
+
}
|
|
1535
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
1536
|
+
const contextUser = ctx.ContextUser;
|
|
1537
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
1538
|
+
const headers = this.BuildHeaders(auth);
|
|
1539
|
+
const url = `${HUBSPOT_API_BASE}/crm/v3/objects/${ctx.ObjectName}`;
|
|
1540
|
+
const body = { properties: ctx.Attributes };
|
|
1541
|
+
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
|
|
1542
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
1543
|
+
const created = response.Body;
|
|
1544
|
+
return {
|
|
1545
|
+
Success: true,
|
|
1546
|
+
ExternalID: String(created['id'] ?? ''),
|
|
1547
|
+
StatusCode: response.Status,
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
return this.BuildCRUDErrorResult(response, 'CreateRecord', ctx.ObjectName);
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Updates an existing record in HubSpot by ExternalID.
|
|
1554
|
+
* For association objects, re-creates the association (idempotent in HubSpot).
|
|
1555
|
+
*/
|
|
1556
|
+
async UpdateRecord(ctx) {
|
|
1557
|
+
const assocConfig = this.GetAssociationObject(ctx.ObjectName);
|
|
1558
|
+
if (assocConfig) {
|
|
1559
|
+
// Associations have no updatable properties — the IDs ARE the relationship.
|
|
1560
|
+
// Re-creating is idempotent: HubSpot ignores duplicates.
|
|
1561
|
+
return this.CreateAssociation(ctx.CompanyIntegration, ctx.ContextUser, ctx.ObjectName, ctx.Attributes, assocConfig);
|
|
1562
|
+
}
|
|
1563
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
1564
|
+
const contextUser = ctx.ContextUser;
|
|
1565
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
1566
|
+
const headers = this.BuildHeaders(auth);
|
|
1567
|
+
const url = `${HUBSPOT_API_BASE}/crm/v3/objects/${ctx.ObjectName}/${ctx.ExternalID}`;
|
|
1568
|
+
const body = { properties: ctx.Attributes };
|
|
1569
|
+
const response = await this.MakeHTTPRequest(auth, url, 'PATCH', headers, body);
|
|
1570
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
1571
|
+
const updated = response.Body;
|
|
1572
|
+
return {
|
|
1573
|
+
Success: true,
|
|
1574
|
+
ExternalID: String(updated['id'] ?? ctx.ExternalID),
|
|
1575
|
+
StatusCode: response.Status,
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
return this.BuildCRUDErrorResult(response, 'UpdateRecord', ctx.ObjectName);
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Idempotently creates-or-updates a record keyed by a unique business property
|
|
1582
|
+
* (default: the object's `UpsertKey` metadata, e.g. 'email' for contacts).
|
|
1583
|
+
*
|
|
1584
|
+
* Uses HubSpot's batch/upsert endpoint with a batch of one. This is the ONLY HubSpot
|
|
1585
|
+
* single-call idempotent path verified against the live API: the single-record
|
|
1586
|
+
* PATCH .../{id}?idProperty=email does NOT create-on-missing (returns 404), while
|
|
1587
|
+
* POST .../batch/upsert creates-on-missing and updates-on-existing with a 2xx (no 409).
|
|
1588
|
+
* A batch of one sidesteps the documented batch caveats (whole-batch-409 on concurrent
|
|
1589
|
+
* batches, no partial upserts) that only bite multi-input batches.
|
|
1590
|
+
*
|
|
1591
|
+
* This *defines the error out of existence*: a search-then-create sequence has a window in
|
|
1592
|
+
* which a concurrent writer can create the same email-keyed contact, yielding
|
|
1593
|
+
* `409 Contact already exists`. Rather than catch and special-case that 409, the single keyed
|
|
1594
|
+
* upsert removes the window entirely — the collision is no longer a condition the caller (or
|
|
1595
|
+
* this code) ever has to handle.
|
|
1596
|
+
*/
|
|
1597
|
+
async Upsert(ctx) {
|
|
1598
|
+
const idProperty = ctx.IDProperty ?? this.GetUpsertKey(ctx.ObjectName);
|
|
1599
|
+
if (!idProperty) {
|
|
1600
|
+
return {
|
|
1601
|
+
Success: false,
|
|
1602
|
+
StatusCode: 400,
|
|
1603
|
+
ErrorMessage: `[HubSpot] Upsert on '${ctx.ObjectName}' has no upsert key — set ctx.IDProperty or declare UpsertKey in object metadata`,
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
const idValue = ctx.Attributes[idProperty];
|
|
1607
|
+
if (idValue == null || String(idValue).length === 0) {
|
|
1608
|
+
return {
|
|
1609
|
+
Success: false,
|
|
1610
|
+
StatusCode: 400,
|
|
1611
|
+
ErrorMessage: `[HubSpot] Upsert on '${ctx.ObjectName}' is missing a value for the upsert key '${idProperty}' in Attributes`,
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
1615
|
+
const contextUser = ctx.ContextUser;
|
|
1616
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
1617
|
+
const headers = this.BuildHeaders(auth);
|
|
1618
|
+
const url = `${HUBSPOT_API_BASE}/crm/v3/objects/${ctx.ObjectName}/batch/upsert`;
|
|
1619
|
+
// Batch of one: id is the upsert-key value; properties carry the full record.
|
|
1620
|
+
const body = { inputs: [{ idProperty, id: String(idValue), properties: ctx.Attributes }] };
|
|
1621
|
+
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
|
|
1622
|
+
// Never trust a bare 2xx — the batch envelope can report per-input errors with a 2xx.
|
|
1623
|
+
const batchError = this.GetBatchUpsertError(response);
|
|
1624
|
+
if (batchError) {
|
|
1625
|
+
return {
|
|
1626
|
+
Success: false,
|
|
1627
|
+
StatusCode: response.Status,
|
|
1628
|
+
ErrorMessage: `[HubSpot] Upsert on ${ctx.ObjectName}: ${batchError}`,
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
const upserted = response.Body.results?.[0];
|
|
1632
|
+
return {
|
|
1633
|
+
Success: true,
|
|
1634
|
+
ExternalID: String(upserted?.id ?? ''),
|
|
1635
|
+
StatusCode: response.Status,
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* Deletes (archives) a record in HubSpot by ExternalID.
|
|
1640
|
+
* Routes association objects to the v4 batch/archive endpoint instead of v3 objects.
|
|
1641
|
+
*/
|
|
1642
|
+
async DeleteRecord(ctx) {
|
|
1643
|
+
const assocConfig = this.GetAssociationObject(ctx.ObjectName);
|
|
1644
|
+
if (assocConfig) {
|
|
1645
|
+
return this.DeleteAssociation(ctx.CompanyIntegration, ctx.ContextUser, ctx.ObjectName, ctx.ExternalID, assocConfig);
|
|
1646
|
+
}
|
|
1647
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
1648
|
+
const contextUser = ctx.ContextUser;
|
|
1649
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
1650
|
+
const headers = this.BuildHeaders(auth);
|
|
1651
|
+
const url = `${HUBSPOT_API_BASE}/crm/v3/objects/${ctx.ObjectName}/${ctx.ExternalID}`;
|
|
1652
|
+
const response = await this.MakeHTTPRequest(auth, url, 'DELETE', headers);
|
|
1653
|
+
if (response.Status === 204 || (response.Status >= 200 && response.Status < 300)) {
|
|
1654
|
+
return {
|
|
1655
|
+
Success: true,
|
|
1656
|
+
ExternalID: ctx.ExternalID,
|
|
1657
|
+
StatusCode: response.Status,
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
return this.BuildCRUDErrorResult(response, 'DeleteRecord', ctx.ObjectName);
|
|
1661
|
+
}
|
|
1662
|
+
/**
|
|
1663
|
+
* Creates an association in HubSpot using the v4 batch/create endpoint.
|
|
1664
|
+
* ExternalID returned is "{leftID}|{rightID}" matching the pull ExternalID format.
|
|
1665
|
+
*/
|
|
1666
|
+
async CreateAssociation(rawCompanyIntegration, rawContextUser, objectName, attributes, assocConfig) {
|
|
1667
|
+
const companyIntegration = rawCompanyIntegration;
|
|
1668
|
+
const contextUser = rawContextUser;
|
|
1669
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
1670
|
+
const headers = this.BuildHeaders(auth);
|
|
1671
|
+
const [leftField, rightField] = assocConfig.pkFields;
|
|
1672
|
+
const leftID = String(attributes[leftField] ?? '');
|
|
1673
|
+
const rightID = String(attributes[rightField] ?? '');
|
|
1674
|
+
if (!leftID || !rightID) {
|
|
1675
|
+
return {
|
|
1676
|
+
Success: false,
|
|
1677
|
+
ExternalID: '',
|
|
1678
|
+
StatusCode: 400,
|
|
1679
|
+
ErrorMessage: `CreateAssociation ${objectName}: missing PK fields '${leftField}' or '${rightField}' in attributes`,
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
// Wire direction is explicit config, decoupled from pkFields/apiPath order.
|
|
1683
|
+
// attributes are keyed by pkField name, so map from/to via fromPkField/toPkField.
|
|
1684
|
+
const { fromType, toType } = this.GetAssociationWireTypes(assocConfig);
|
|
1685
|
+
const fromID = assocConfig.fromPkField ? String(attributes[assocConfig.fromPkField] ?? '') : leftID;
|
|
1686
|
+
const toID = assocConfig.toPkField ? String(attributes[assocConfig.toPkField] ?? '') : rightID;
|
|
1687
|
+
// typeId: hardcoded verified-only fast path; otherwise resolve from /labels.
|
|
1688
|
+
const typeId = assocConfig.associationTypeId
|
|
1689
|
+
?? await this.ResolveAssociationTypeId(auth, headers, fromType, toType);
|
|
1690
|
+
if (typeId == null) {
|
|
1691
|
+
return { Success: false, ExternalID: '', StatusCode: 400, ErrorMessage: `CreateAssociation ${objectName}: could not resolve a HUBSPOT_DEFINED association typeId for ${fromType}->${toType}` };
|
|
1692
|
+
}
|
|
1693
|
+
const types = [{ associationCategory: assocConfig.associationCategory ?? 'HUBSPOT_DEFINED', associationTypeId: typeId }];
|
|
1694
|
+
const url = `${HUBSPOT_API_BASE}/crm/v4/associations/${fromType}/${toType}/batch/create`;
|
|
1695
|
+
const body = { inputs: [{ from: { id: fromID }, to: { id: toID }, types }] };
|
|
1696
|
+
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
|
|
1697
|
+
// Never trust a bare 2xx — HubSpot returns 2xx with numErrors/empty results on
|
|
1698
|
+
// validation failures and on the old empty-types no-op.
|
|
1699
|
+
const batchError = this.GetAssociationBatchError(response);
|
|
1700
|
+
if (!batchError) {
|
|
1701
|
+
// Stored ExternalID stays in pkFields order (left|right), NOT wire order.
|
|
1702
|
+
return { Success: true, ExternalID: `${leftID}|${rightID}`, StatusCode: response.Status };
|
|
1703
|
+
}
|
|
1704
|
+
return { Success: false, ExternalID: '', StatusCode: response.Status, ErrorMessage: `CreateAssociation ${objectName}: ${batchError}` };
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* Removes an association in HubSpot using the v4 batch/archive endpoint.
|
|
1708
|
+
* ExternalID must be "{leftID}|{rightID}" — the same format stored by pull sync.
|
|
1709
|
+
*/
|
|
1710
|
+
async DeleteAssociation(rawCompanyIntegration, rawContextUser, objectName, externalID, assocConfig) {
|
|
1711
|
+
const companyIntegration = rawCompanyIntegration;
|
|
1712
|
+
const contextUser = rawContextUser;
|
|
1713
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
1714
|
+
const headers = this.BuildHeaders(auth);
|
|
1715
|
+
const pipeIndex = externalID.indexOf('|');
|
|
1716
|
+
if (pipeIndex < 0) {
|
|
1717
|
+
return {
|
|
1718
|
+
Success: false,
|
|
1719
|
+
ExternalID: externalID,
|
|
1720
|
+
StatusCode: 400,
|
|
1721
|
+
ErrorMessage: `DeleteAssociation ${objectName}: cannot parse composite ExternalID '${externalID}' — expected 'leftID|rightID'`,
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
const leftID = externalID.substring(0, pipeIndex);
|
|
1725
|
+
const rightID = externalID.substring(pipeIndex + 1);
|
|
1726
|
+
// Stored key is in pkFields order (left=pkFields[0], right=pkFields[1]). Map to the
|
|
1727
|
+
// explicit wire direction so archive matches the create direction.
|
|
1728
|
+
const { fromType, toType } = this.GetAssociationWireTypes(assocConfig);
|
|
1729
|
+
const [leftPkField] = assocConfig.pkFields;
|
|
1730
|
+
const idByPkField = { [leftPkField]: leftID, [assocConfig.pkFields[1]]: rightID };
|
|
1731
|
+
const fromID = assocConfig.fromPkField ? idByPkField[assocConfig.fromPkField] : leftID;
|
|
1732
|
+
const toID = assocConfig.toPkField ? idByPkField[assocConfig.toPkField] : rightID;
|
|
1733
|
+
const url = `${HUBSPOT_API_BASE}/crm/v4/associations/${fromType}/${toType}/batch/archive`;
|
|
1734
|
+
const body = { inputs: [{ from: { id: fromID }, to: { id: toID } }] };
|
|
1735
|
+
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
|
|
1736
|
+
// batch/archive returns 204 No Content on success
|
|
1737
|
+
if (response.Status === 204 || (response.Status >= 200 && response.Status < 300)) {
|
|
1738
|
+
return { Success: true, ExternalID: externalID, StatusCode: response.Status };
|
|
1739
|
+
}
|
|
1740
|
+
return this.BuildCRUDErrorResult(response, 'DeleteAssociation', objectName);
|
|
1741
|
+
}
|
|
1742
|
+
/**
|
|
1743
|
+
* Searches HubSpot objects using the CRM search API.
|
|
1744
|
+
*/
|
|
1745
|
+
async SearchRecords(ctx) {
|
|
1746
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
1747
|
+
const contextUser = ctx.ContextUser;
|
|
1748
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
1749
|
+
const headers = this.BuildHeaders(auth);
|
|
1750
|
+
const url = `${HUBSPOT_API_BASE}/crm/v3/objects/${ctx.ObjectName}/search`;
|
|
1751
|
+
const filters = Object.entries(ctx.Filters).map(([propertyName, value]) => ({
|
|
1752
|
+
propertyName,
|
|
1753
|
+
operator: 'EQ',
|
|
1754
|
+
value,
|
|
1755
|
+
}));
|
|
1756
|
+
const properties = this.GetObjectFieldNames(ctx.ObjectName);
|
|
1757
|
+
const body = {
|
|
1758
|
+
filterGroups: [{ filters }],
|
|
1759
|
+
properties,
|
|
1760
|
+
limit: ctx.PageSize ?? 100,
|
|
1761
|
+
after: ctx.Page != null && ctx.Page > 1 ? String((ctx.Page - 1) * (ctx.PageSize ?? 100)) : undefined,
|
|
1762
|
+
};
|
|
1763
|
+
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
|
|
1764
|
+
this.ValidateCRUDResponse(response, 'SearchRecords', ctx.ObjectName);
|
|
1765
|
+
const responseBody = response.Body;
|
|
1766
|
+
const results = responseBody.results ?? [];
|
|
1767
|
+
const records = results.map(r => this.RawToExternalRecord(r, ctx.ObjectName));
|
|
1768
|
+
return {
|
|
1769
|
+
Records: records,
|
|
1770
|
+
TotalCount: responseBody.total ?? records.length,
|
|
1771
|
+
HasMore: responseBody.paging?.next?.after != null,
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Lists records from a HubSpot object with cursor-based pagination.
|
|
1776
|
+
*/
|
|
1777
|
+
async ListRecords(ctx) {
|
|
1778
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
1779
|
+
const contextUser = ctx.ContextUser;
|
|
1780
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
1781
|
+
const headers = this.BuildHeaders(auth);
|
|
1782
|
+
const pageSize = ctx.PageSize ?? 100;
|
|
1783
|
+
const propertiesParam = this.BuildPropertiesParam(ctx.ObjectName);
|
|
1784
|
+
let url = `${HUBSPOT_API_BASE}/crm/v3/objects/${ctx.ObjectName}?limit=${pageSize}${propertiesParam}`;
|
|
1785
|
+
if (ctx.Cursor) {
|
|
1786
|
+
url += `&after=${encodeURIComponent(ctx.Cursor)}`;
|
|
1787
|
+
}
|
|
1788
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
1789
|
+
this.ValidateCRUDResponse(response, 'ListRecords', ctx.ObjectName);
|
|
1790
|
+
const responseBody = response.Body;
|
|
1791
|
+
const results = responseBody.results ?? [];
|
|
1792
|
+
const records = results.map(r => this.RawToExternalRecord(r, ctx.ObjectName));
|
|
1793
|
+
const nextCursor = responseBody.paging?.next?.after;
|
|
1794
|
+
return {
|
|
1795
|
+
Records: records,
|
|
1796
|
+
HasMore: nextCursor != null,
|
|
1797
|
+
NextCursor: nextCursor ?? undefined,
|
|
1798
|
+
TotalCount: responseBody.total,
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
// ─── CRUD Helpers ────────────────────────────────────────────────────
|
|
1802
|
+
/** Converts a raw HubSpot API object to an ExternalRecord. */
|
|
1803
|
+
RawToExternalRecord(raw, objectType) {
|
|
1804
|
+
const flat = this.FlattenHubSpotRecord(raw);
|
|
1805
|
+
return {
|
|
1806
|
+
ExternalID: String(raw['id'] ?? ''),
|
|
1807
|
+
ObjectType: objectType,
|
|
1808
|
+
Fields: flat,
|
|
1809
|
+
ModifiedAt: raw['updatedAt'] ? new Date(raw['updatedAt']) : undefined,
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
/** Validates a CRUD response and throws on non-2xx status. */
|
|
1813
|
+
ValidateCRUDResponse(response, operation, objectName) {
|
|
1814
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
1815
|
+
const bodyPreview = typeof response.Body === 'string'
|
|
1816
|
+
? response.Body.slice(0, 500)
|
|
1817
|
+
: JSON.stringify(response.Body).slice(0, 500);
|
|
1818
|
+
throw new Error(`[HubSpot] ${operation} on ${objectName} failed (HTTP ${response.Status}): ${bodyPreview}`);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* Resolves the v4 wire from/to object types for an association. Uses explicit fromType/toType
|
|
1823
|
+
* config when present; otherwise falls back to apiPath segment order. Both
|
|
1824
|
+
* CreateAssociation and DeleteAssociation share this so create and archive always agree.
|
|
1825
|
+
*/
|
|
1826
|
+
GetAssociationWireTypes(assocConfig) {
|
|
1827
|
+
const segments = assocConfig.apiPath.split('/').filter(Boolean);
|
|
1828
|
+
return {
|
|
1829
|
+
fromType: assocConfig.fromType ?? segments.at(-2),
|
|
1830
|
+
toType: assocConfig.toType ?? segments.at(-1),
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Resolves the default HUBSPOT_DEFINED association typeId for a (fromType, toType) pair via
|
|
1835
|
+
* GET /crm/v4/associations/{fromType}/{toType}/labels, cached per pair for the connector's life.
|
|
1836
|
+
* Picks the unlabeled HUBSPOT_DEFINED entry (label === null) as the plain default; if none is
|
|
1837
|
+
* unlabeled, falls back to the sole/first HUBSPOT_DEFINED entry. Returns null on lookup failure
|
|
1838
|
+
* or when no HUBSPOT_DEFINED entry exists — callers MUST treat null as a hard error (never
|
|
1839
|
+
* silently send empty types).
|
|
1840
|
+
*/
|
|
1841
|
+
async ResolveAssociationTypeId(auth, headers, fromType, toType) {
|
|
1842
|
+
const cacheKey = `${fromType}/${toType}`;
|
|
1843
|
+
const cached = this._assocTypeIdCache.get(cacheKey);
|
|
1844
|
+
if (cached != null)
|
|
1845
|
+
return cached;
|
|
1846
|
+
const url = `${HUBSPOT_API_BASE}/crm/v4/associations/${fromType}/${toType}/labels`;
|
|
1847
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
1848
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
1849
|
+
console.warn(`[HubSpot] /labels lookup failed for ${cacheKey}: HTTP ${response.Status}`);
|
|
1850
|
+
return null;
|
|
1851
|
+
}
|
|
1852
|
+
const body = response.Body;
|
|
1853
|
+
const defined = (body?.results ?? []).filter(r => r.category === 'HUBSPOT_DEFINED' && r.typeId != null);
|
|
1854
|
+
if (defined.length === 0)
|
|
1855
|
+
return null;
|
|
1856
|
+
const unlabeled = defined.find(r => r.label == null);
|
|
1857
|
+
const chosen = (unlabeled ?? defined[0]).typeId;
|
|
1858
|
+
this._assocTypeIdCache.set(cacheKey, chosen);
|
|
1859
|
+
return chosen;
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* Validates a v4 association batch/create response BODY (not just the HTTP status).
|
|
1863
|
+
* HubSpot returns 2xx even when zero associations are created — on the legacy empty-`types`
|
|
1864
|
+
* no-op (empty results, no errors) and on validation failures (empty results + numErrors).
|
|
1865
|
+
* Returns null when the operation genuinely completed; otherwise a human-readable error.
|
|
1866
|
+
* Predicate verified against live HubSpot batch/create responses.
|
|
1867
|
+
*/
|
|
1868
|
+
GetAssociationBatchError(response) {
|
|
1869
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
1870
|
+
const b = response.Body;
|
|
1871
|
+
return b?.['message'] ? String(b['message']) : `HTTP ${response.Status}`;
|
|
1872
|
+
}
|
|
1873
|
+
const body = response.Body;
|
|
1874
|
+
if (body?.numErrors || (body?.errors && body.errors.length > 0)) {
|
|
1875
|
+
return body.errors?.[0]?.message ?? `HubSpot reported ${body?.numErrors ?? body?.errors?.length} association error(s)`;
|
|
1876
|
+
}
|
|
1877
|
+
if (body?.status && body.status !== 'COMPLETE') {
|
|
1878
|
+
return `association batch status was '${body.status}', expected 'COMPLETE'`;
|
|
1879
|
+
}
|
|
1880
|
+
if (!body?.results || body.results.length === 0) {
|
|
1881
|
+
return `HubSpot returned no association results (2xx but nothing linked)`;
|
|
1882
|
+
}
|
|
1883
|
+
return null;
|
|
1884
|
+
}
|
|
1885
|
+
/**
|
|
1886
|
+
* Validates a v3 batch/upsert response BODY (not just the HTTP status). HubSpot's batch
|
|
1887
|
+
* envelope can return a 2xx while reporting per-input failures via `numErrors`/`errors`,
|
|
1888
|
+
* an incomplete `status`, or an empty `results` array. Returns null when the upsert
|
|
1889
|
+
* genuinely produced a record; otherwise a human-readable error. Mirrors the
|
|
1890
|
+
* GetAssociationBatchError precedent — never trust a bare 2xx on a batch endpoint.
|
|
1891
|
+
*/
|
|
1892
|
+
GetBatchUpsertError(response) {
|
|
1893
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
1894
|
+
const b = response.Body;
|
|
1895
|
+
return b?.['message'] ? String(b['message']) : `HTTP ${response.Status}`;
|
|
1896
|
+
}
|
|
1897
|
+
const body = response.Body;
|
|
1898
|
+
if (body?.numErrors || (body?.errors && body.errors.length > 0)) {
|
|
1899
|
+
return body.errors?.[0]?.message ?? `HubSpot reported ${body?.numErrors ?? body?.errors?.length} upsert error(s)`;
|
|
1900
|
+
}
|
|
1901
|
+
if (body?.status && body.status !== 'COMPLETE') {
|
|
1902
|
+
return `upsert batch status was '${body.status}', expected 'COMPLETE'`;
|
|
1903
|
+
}
|
|
1904
|
+
if (!body?.results || body.results.length === 0) {
|
|
1905
|
+
return `HubSpot returned no upsert results (2xx but nothing written)`;
|
|
1906
|
+
}
|
|
1907
|
+
// A 2xx result with no usable id means the write didn't really land — reporting success here
|
|
1908
|
+
// would hand the caller an empty ExternalID and silently break any later lookup keyed on it.
|
|
1909
|
+
if (body.results[0]?.id == null || String(body.results[0].id).length === 0) {
|
|
1910
|
+
return `HubSpot upsert result is missing an object id (2xx but no id to link)`;
|
|
1911
|
+
}
|
|
1912
|
+
return null;
|
|
1913
|
+
}
|
|
1914
|
+
/** Builds a CRUDResult for error responses. */
|
|
1915
|
+
BuildCRUDErrorResult(response, operation, objectName) {
|
|
1916
|
+
const bodyObj = response.Body;
|
|
1917
|
+
let message;
|
|
1918
|
+
if (response.Status === 403) {
|
|
1919
|
+
message = `[HubSpot] 403 Forbidden — required scope missing for ${operation} on '${objectName}'. ` +
|
|
1920
|
+
`Add the required scope to your HubSpot app and reconnect.`;
|
|
1921
|
+
}
|
|
1922
|
+
else {
|
|
1923
|
+
message = bodyObj?.['message']
|
|
1924
|
+
? String(bodyObj['message'])
|
|
1925
|
+
: `[HubSpot] ${operation} on ${objectName} failed (HTTP ${response.Status})`;
|
|
1926
|
+
}
|
|
1927
|
+
return {
|
|
1928
|
+
Success: false,
|
|
1929
|
+
ErrorMessage: message,
|
|
1930
|
+
StatusCode: response.Status,
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
// ─── Abstract method implementations (BaseRESTIntegrationConnector) ──
|
|
1934
|
+
async Authenticate(companyIntegration, contextUser) {
|
|
1935
|
+
if (this._cachedAuth)
|
|
1936
|
+
return this._cachedAuth;
|
|
1937
|
+
console.log(`[HubSpot] Authenticating...`);
|
|
1938
|
+
const credentials = await this.LoadCredentials(companyIntegration, contextUser);
|
|
1939
|
+
const config = this.BuildConnectionConfig(credentials, companyIntegration);
|
|
1940
|
+
this._config = config;
|
|
1941
|
+
// Do NOT log credential-derived info (token length, prefix, etc.) — would leak secret-shape data into MJAPI logs.
|
|
1942
|
+
const auth = {
|
|
1943
|
+
Token: credentials.AccessToken,
|
|
1944
|
+
Credentials: credentials,
|
|
1945
|
+
Config: config,
|
|
1946
|
+
};
|
|
1947
|
+
this._cachedAuth = auth;
|
|
1948
|
+
return auth;
|
|
1949
|
+
}
|
|
1950
|
+
BuildHeaders(auth) {
|
|
1951
|
+
return {
|
|
1952
|
+
'Authorization': `Bearer ${auth.Token}`,
|
|
1953
|
+
'Accept': 'application/json',
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1956
|
+
async MakeHTTPRequest(_auth, url, method, headers, body) {
|
|
1957
|
+
// Throttle: ensure minimum interval between requests
|
|
1958
|
+
const minInterval = this.effectiveMinRequestIntervalMs;
|
|
1959
|
+
const elapsed = Date.now() - this.lastRequestTime;
|
|
1960
|
+
if (elapsed < minInterval) {
|
|
1961
|
+
await this.Sleep(minInterval - elapsed);
|
|
1962
|
+
}
|
|
1963
|
+
const maxRetries = this.effectiveMaxRetries;
|
|
1964
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1965
|
+
const response = await this.FetchWithTimeout(url, method, headers, body);
|
|
1966
|
+
this.lastRequestTime = Date.now();
|
|
1967
|
+
if (response.status === 429) {
|
|
1968
|
+
const delayMs = this.CalculateRetryDelay(response, attempt);
|
|
1969
|
+
console.warn(`[HubSpot] Rate limited (429), retrying in ${delayMs}ms ` +
|
|
1970
|
+
`(attempt ${attempt + 1}/${maxRetries})`);
|
|
1971
|
+
await this.Sleep(delayMs);
|
|
1972
|
+
continue;
|
|
1973
|
+
}
|
|
1974
|
+
// Handle empty responses (e.g., 204 No Content from DELETE)
|
|
1975
|
+
if (response.status === 204) {
|
|
1976
|
+
return this.BuildRESTResponse(response, {});
|
|
1977
|
+
}
|
|
1978
|
+
let responseBody;
|
|
1979
|
+
try {
|
|
1980
|
+
responseBody = await response.json();
|
|
1981
|
+
}
|
|
1982
|
+
catch {
|
|
1983
|
+
// Non-JSON body (e.g. HTML error page) — treat as empty object
|
|
1984
|
+
responseBody = {};
|
|
1985
|
+
}
|
|
1986
|
+
return this.BuildRESTResponse(response, responseBody);
|
|
1987
|
+
}
|
|
1988
|
+
// The loop only retries on 429 (every other status returns above), so exhausting retries here
|
|
1989
|
+
// means sustained 429 rate-limiting. Carry the "429 rate limit" marker so ClassifyError →
|
|
1990
|
+
// RATE_LIMIT_EXCEEDED and the engine's adaptive limiter / ExtractRetryAfterMs react correctly.
|
|
1991
|
+
throw new Error(`HubSpot 429 rate limit: request failed after ${maxRetries} retries (sustained throttling): ${url}`);
|
|
1992
|
+
}
|
|
1993
|
+
NormalizeResponse(rawBody, responseDataKey) {
|
|
1994
|
+
const body = rawBody;
|
|
1995
|
+
let records;
|
|
1996
|
+
if (responseDataKey != null) {
|
|
1997
|
+
const data = body[responseDataKey];
|
|
1998
|
+
if (!data || !Array.isArray(data))
|
|
1999
|
+
return [];
|
|
2000
|
+
records = data;
|
|
2001
|
+
}
|
|
2002
|
+
else if (Array.isArray(rawBody)) {
|
|
2003
|
+
records = rawBody;
|
|
2004
|
+
}
|
|
2005
|
+
else {
|
|
2006
|
+
return [];
|
|
2007
|
+
}
|
|
2008
|
+
const flattened = records.map(r => this.FlattenHubSpotRecord(r));
|
|
2009
|
+
console.log(`[HubSpot] NormalizeResponse: ${flattened.length} records flattened`);
|
|
2010
|
+
return flattened;
|
|
2011
|
+
}
|
|
2012
|
+
ExtractPaginationInfo(rawBody, _paginationType, _currentPage, _currentOffset, _pageSize) {
|
|
2013
|
+
const body = rawBody;
|
|
2014
|
+
const paging = body['paging'];
|
|
2015
|
+
const nextCursor = paging?.next?.after;
|
|
2016
|
+
if (nextCursor) {
|
|
2017
|
+
return { HasMore: true, NextCursor: nextCursor };
|
|
2018
|
+
}
|
|
2019
|
+
return { HasMore: false };
|
|
2020
|
+
}
|
|
2021
|
+
GetBaseURL(_companyIntegration, _auth) {
|
|
2022
|
+
return HUBSPOT_API_BASE;
|
|
2023
|
+
}
|
|
2024
|
+
// ─── HubSpot-specific pagination ─────────────────────────────────
|
|
2025
|
+
/**
|
|
2026
|
+
* Overrides base pagination URL building to use HubSpot's parameter names.
|
|
2027
|
+
* HubSpot uses `after` for cursor pagination (not `cursor`), and needs
|
|
2028
|
+
* `limit` instead of `pageSize`. Also appends `properties` query param.
|
|
2029
|
+
*/
|
|
2030
|
+
BuildPaginatedURL(basePath, obj, _page, _offset, cursor) {
|
|
2031
|
+
const separator = basePath.includes('?') ? '&' : '?';
|
|
2032
|
+
const objectName = this.ExtractObjectNameFromPath(basePath);
|
|
2033
|
+
const propertiesParam = this.BuildPropertiesParam(objectName);
|
|
2034
|
+
if (cursor) {
|
|
2035
|
+
return `${basePath}${separator}limit=${obj.DefaultPageSize}&after=${encodeURIComponent(cursor)}${propertiesParam}`;
|
|
2036
|
+
}
|
|
2037
|
+
return `${basePath}${separator}limit=${obj.DefaultPageSize}${propertiesParam}`;
|
|
2038
|
+
}
|
|
2039
|
+
// ─── TestConnection ─────────────────────────────────────────────
|
|
2040
|
+
/** Tests connectivity by authenticating and fetching 1 contact. */
|
|
2041
|
+
async TestConnection(companyIntegration, _contextUser) {
|
|
2042
|
+
try {
|
|
2043
|
+
const auth = await this.Authenticate(companyIntegration, _contextUser);
|
|
2044
|
+
const headers = this.BuildHeaders(auth);
|
|
2045
|
+
const response = await this.MakeHTTPRequest(auth, `${HUBSPOT_API_BASE}/crm/v3/objects/contacts?limit=1`, 'GET', headers);
|
|
2046
|
+
if (response.Status >= 200 && response.Status < 300) {
|
|
2047
|
+
const hubSpotAuth = auth;
|
|
2048
|
+
return {
|
|
2049
|
+
Success: true,
|
|
2050
|
+
Message: 'Successfully connected to HubSpot CRM API',
|
|
2051
|
+
ServerVersion: `HubSpot CRM API ${hubSpotAuth.Credentials.ApiVersion}`,
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
const bodyPreview = typeof response.Body === 'string'
|
|
2055
|
+
? response.Body.slice(0, 500)
|
|
2056
|
+
: JSON.stringify(response.Body).slice(0, 500);
|
|
2057
|
+
return {
|
|
2058
|
+
Success: false,
|
|
2059
|
+
Message: `HubSpot API returned ${response.Status}: ${bodyPreview}`,
|
|
2060
|
+
};
|
|
2061
|
+
}
|
|
2062
|
+
catch (err) {
|
|
2063
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2064
|
+
return { Success: false, Message: `Connection failed: ${message}` };
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
// ─── GetDefaultFieldMappings ──────────────────────────────────
|
|
2068
|
+
GetDefaultFieldMappings(objectName, _entityName) {
|
|
2069
|
+
switch (objectName) {
|
|
2070
|
+
case 'contacts':
|
|
2071
|
+
return [
|
|
2072
|
+
{ SourceFieldName: 'email', DestinationFieldName: 'Email', IsKeyField: true },
|
|
2073
|
+
{ SourceFieldName: 'firstname', DestinationFieldName: 'FirstName' },
|
|
2074
|
+
{ SourceFieldName: 'lastname', DestinationFieldName: 'LastName' },
|
|
2075
|
+
{ SourceFieldName: 'phone', DestinationFieldName: 'Phone' },
|
|
2076
|
+
{ SourceFieldName: 'company', DestinationFieldName: 'CompanyName' },
|
|
2077
|
+
{ SourceFieldName: 'lifecyclestage', DestinationFieldName: 'Status' },
|
|
2078
|
+
];
|
|
2079
|
+
case 'companies':
|
|
2080
|
+
return [
|
|
2081
|
+
{ SourceFieldName: 'name', DestinationFieldName: 'Name', IsKeyField: true },
|
|
2082
|
+
{ SourceFieldName: 'domain', DestinationFieldName: 'Website' },
|
|
2083
|
+
{ SourceFieldName: 'industry', DestinationFieldName: 'Industry' },
|
|
2084
|
+
{ SourceFieldName: 'city', DestinationFieldName: 'City' },
|
|
2085
|
+
{ SourceFieldName: 'state', DestinationFieldName: 'State' },
|
|
2086
|
+
];
|
|
2087
|
+
case 'deals':
|
|
2088
|
+
return [
|
|
2089
|
+
{ SourceFieldName: 'dealname', DestinationFieldName: 'Name', IsKeyField: true },
|
|
2090
|
+
{ SourceFieldName: 'amount', DestinationFieldName: 'Amount' },
|
|
2091
|
+
{ SourceFieldName: 'dealstage', DestinationFieldName: 'Stage' },
|
|
2092
|
+
{ SourceFieldName: 'closedate', DestinationFieldName: 'CloseDate' },
|
|
2093
|
+
{ SourceFieldName: 'pipeline', DestinationFieldName: 'Pipeline' },
|
|
2094
|
+
];
|
|
2095
|
+
default:
|
|
2096
|
+
return [];
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
// ─── Default Configuration ──────────────────────────────────────
|
|
2100
|
+
GetDefaultConfiguration() {
|
|
2101
|
+
return {
|
|
2102
|
+
DefaultSchemaName: 'HubSpot',
|
|
2103
|
+
DefaultObjects: [], // Objects are auto-discovered from metadata via DiscoverObjects
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
// ─── Schema Discovery Helpers ────────────────────────────────────
|
|
2107
|
+
/** Converts a HubSpot property definition to ExternalFieldSchema format */
|
|
2108
|
+
MapPropertyToField(prop) {
|
|
2109
|
+
return {
|
|
2110
|
+
Name: prop.name,
|
|
2111
|
+
Label: prop.label || prop.name,
|
|
2112
|
+
DataType: this.MapHubSpotType(prop.type, prop.fieldType),
|
|
2113
|
+
IsRequired: false, // HubSpot doesn't expose required via properties API
|
|
2114
|
+
IsUniqueKey: prop.hasUniqueValue,
|
|
2115
|
+
IsReadOnly: prop.calculated || (prop.modificationMetadata?.readOnlyValue ?? false),
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
/** Maps HubSpot type + fieldType to a simplified data type string */
|
|
2119
|
+
MapHubSpotType(type, fieldType) {
|
|
2120
|
+
switch (type) {
|
|
2121
|
+
case 'string':
|
|
2122
|
+
if (fieldType === 'textarea')
|
|
2123
|
+
return 'text';
|
|
2124
|
+
if (fieldType === 'html')
|
|
2125
|
+
return 'html';
|
|
2126
|
+
return 'string';
|
|
2127
|
+
case 'number':
|
|
2128
|
+
return 'number';
|
|
2129
|
+
case 'date':
|
|
2130
|
+
case 'datetime':
|
|
2131
|
+
return 'datetime';
|
|
2132
|
+
case 'bool':
|
|
2133
|
+
return 'boolean';
|
|
2134
|
+
case 'enumeration':
|
|
2135
|
+
return 'enum';
|
|
2136
|
+
case 'json':
|
|
2137
|
+
// HubSpot json-type properties can be arbitrarily large objects.
|
|
2138
|
+
// Map to 'text' so the schema builder creates nvarchar(MAX) columns.
|
|
2139
|
+
return 'text';
|
|
2140
|
+
case 'phone_number':
|
|
2141
|
+
return 'string';
|
|
2142
|
+
default:
|
|
2143
|
+
return type;
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
// ─── Configuration parsing ────────────────────────────────────
|
|
2147
|
+
/**
|
|
2148
|
+
* Builds a HubSpotConnectionConfig from credentials and optional overrides
|
|
2149
|
+
* from the CompanyIntegration Configuration JSON.
|
|
2150
|
+
*/
|
|
2151
|
+
BuildConnectionConfig(credentials, companyIntegration) {
|
|
2152
|
+
const config = {
|
|
2153
|
+
AccessToken: credentials.AccessToken,
|
|
2154
|
+
ApiVersion: credentials.ApiVersion,
|
|
2155
|
+
};
|
|
2156
|
+
const configJson = companyIntegration.Configuration;
|
|
2157
|
+
if (configJson) {
|
|
2158
|
+
this.ApplyConfigOverrides(config, configJson);
|
|
2159
|
+
}
|
|
2160
|
+
return config;
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2163
|
+
* Parses optional performance overrides from Configuration JSON and applies
|
|
2164
|
+
* them to the provided config object. Invalid/missing values are silently ignored.
|
|
2165
|
+
*/
|
|
2166
|
+
ApplyConfigOverrides(config, json) {
|
|
2167
|
+
try {
|
|
2168
|
+
const parsed = JSON.parse(json);
|
|
2169
|
+
const parseOptionalInt = (key) => {
|
|
2170
|
+
const v = parsed[key];
|
|
2171
|
+
if (v == null)
|
|
2172
|
+
return undefined;
|
|
2173
|
+
const n = Number(v);
|
|
2174
|
+
return isNaN(n) ? undefined : Math.floor(n);
|
|
2175
|
+
};
|
|
2176
|
+
config.MaxRetries = parseOptionalInt('MaxRetries');
|
|
2177
|
+
config.RequestTimeoutMs = parseOptionalInt('RequestTimeoutMs');
|
|
2178
|
+
config.MinRequestIntervalMs = parseOptionalInt('MinRequestIntervalMs');
|
|
2179
|
+
}
|
|
2180
|
+
catch {
|
|
2181
|
+
// Configuration JSON may not be valid JSON or may only contain credentials — ignore
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
// ─── Credential management ────────────────────────────────────
|
|
2185
|
+
/**
|
|
2186
|
+
* Reads credentials from CompanyIntegration.CredentialID -> Credential.Values JSON,
|
|
2187
|
+
* or falls back to CompanyIntegration Configuration JSON for backwards compat.
|
|
2188
|
+
*/
|
|
2189
|
+
async LoadCredentials(companyIntegration, contextUser) {
|
|
2190
|
+
// Try loading from linked Credential entity first
|
|
2191
|
+
const credentialID = companyIntegration.CredentialID;
|
|
2192
|
+
if (credentialID) {
|
|
2193
|
+
const creds = await this.LoadFromCredentialEntity(credentialID, contextUser);
|
|
2194
|
+
if (creds)
|
|
2195
|
+
return creds;
|
|
2196
|
+
}
|
|
2197
|
+
// Fallback: read from CompanyIntegration Configuration JSON
|
|
2198
|
+
const configJson = companyIntegration.Configuration;
|
|
2199
|
+
if (configJson) {
|
|
2200
|
+
const creds = this.ParseCredentialJson(configJson);
|
|
2201
|
+
if (creds)
|
|
2202
|
+
return creds;
|
|
2203
|
+
}
|
|
2204
|
+
throw new Error('No HubSpot credentials found. Attach a credential with an accessToken or apiKey, ' +
|
|
2205
|
+
'or set Configuration JSON on the CompanyIntegration.');
|
|
2206
|
+
}
|
|
2207
|
+
/** Loads credentials from a Credential entity by ID. */
|
|
2208
|
+
async LoadFromCredentialEntity(credentialID, contextUser, provider) {
|
|
2209
|
+
const md = provider ?? new Metadata();
|
|
2210
|
+
const credential = await md.GetEntityObject('MJ: Credentials', contextUser);
|
|
2211
|
+
const loaded = await credential.Load(credentialID);
|
|
2212
|
+
if (!loaded || !credential.Values)
|
|
2213
|
+
return null;
|
|
2214
|
+
return this.ParseCredentialJson(credential.Values);
|
|
2215
|
+
}
|
|
2216
|
+
/** Parses a JSON string to extract HubSpot credentials. Returns null if no token found. */
|
|
2217
|
+
ParseCredentialJson(json) {
|
|
2218
|
+
try {
|
|
2219
|
+
const parsed = JSON.parse(json);
|
|
2220
|
+
const token = parsed['accessToken'] ?? parsed['AccessToken'] ?? parsed['apiKey'] ?? parsed['ApiKey'];
|
|
2221
|
+
if (token) {
|
|
2222
|
+
return {
|
|
2223
|
+
AccessToken: token,
|
|
2224
|
+
ApiVersion: parsed['apiVersion'] ?? DEFAULT_API_VERSION,
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
return null;
|
|
2228
|
+
}
|
|
2229
|
+
catch {
|
|
2230
|
+
return null;
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
// ─── Response flattening ─────────────────────────────────────────
|
|
2234
|
+
/**
|
|
2235
|
+
* Flattens a HubSpot CRM record from the nested format:
|
|
2236
|
+
* { id, properties: { field1, field2 }, createdAt, updatedAt, archived }
|
|
2237
|
+
* into a flat record with all properties at the top level,
|
|
2238
|
+
* plus system fields (hs_object_id, createdAt, updatedAt, archived).
|
|
2239
|
+
*/
|
|
2240
|
+
FlattenHubSpotRecord(record) {
|
|
2241
|
+
const properties = record['properties'];
|
|
2242
|
+
const result = {};
|
|
2243
|
+
// Add flattened properties — HubSpot uses '' to mean "no value" for all
|
|
2244
|
+
// property types, which causes SQL errors on datetime/numeric columns.
|
|
2245
|
+
// Normalize empty strings to null so nullable DB columns receive NULL.
|
|
2246
|
+
if (properties) {
|
|
2247
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
2248
|
+
result[key] = value === '' ? null : value;
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
// Add system fields (these override any conflicting property names)
|
|
2252
|
+
result['hs_object_id'] = record['id'];
|
|
2253
|
+
result['createdAt'] = record['createdAt'];
|
|
2254
|
+
result['updatedAt'] = record['updatedAt'];
|
|
2255
|
+
result['archived'] = record['archived'];
|
|
2256
|
+
return result;
|
|
2257
|
+
}
|
|
2258
|
+
// ─── Association fetch (v4 API) ───────────────────────────────────
|
|
2259
|
+
/**
|
|
2260
|
+
* Overrides FetchChanges to support three fetch strategies:
|
|
2261
|
+
*
|
|
2262
|
+
* 1. **Association objects** → v4 per-object associations endpoint
|
|
2263
|
+
* 2. **Incremental sync** (watermark set) → HubSpot search API with server-side
|
|
2264
|
+
* `hs_lastmodifieddate >= watermark` filter
|
|
2265
|
+
* 3. **Full load** (no watermark / first sync) → standard list API via base class
|
|
2266
|
+
*/
|
|
2267
|
+
async FetchChanges(ctx) {
|
|
2268
|
+
const obj = this.GetCachedObject(ctx.CompanyIntegration.IntegrationID, ctx.ObjectName);
|
|
2269
|
+
if (obj.Category === 'Association') {
|
|
2270
|
+
return this.FetchAssociationChanges(ctx, obj);
|
|
2271
|
+
}
|
|
2272
|
+
// Non-CRM objects (Marketing, CMS, etc.) don't support the CRM search API.
|
|
2273
|
+
// Use the base class list endpoint (reads APIPath from IntegrationObject) with
|
|
2274
|
+
// client-side watermark filtering.
|
|
2275
|
+
const nonCrm = this.GetNonCRMObject(ctx.ObjectName);
|
|
2276
|
+
if (nonCrm) {
|
|
2277
|
+
return this.FetchNonCRMChanges(ctx, nonCrm);
|
|
2278
|
+
}
|
|
2279
|
+
// CRM objects: use search-based incremental sync when a watermark exists
|
|
2280
|
+
if (ctx.WatermarkValue) {
|
|
2281
|
+
return this.FetchChangesViaSearch(ctx);
|
|
2282
|
+
}
|
|
2283
|
+
// First sync (no watermark): use list API for full load.
|
|
2284
|
+
// Cannot use super.FetchChanges here — the base class reads raw HubSpot records
|
|
2285
|
+
// without flattening the nested {id, properties: {...}} envelope, so ExternalID
|
|
2286
|
+
// would resolve to '' (raw['hs_object_id'] is undefined at the top level).
|
|
2287
|
+
return this.FetchCRMFullLoad(ctx);
|
|
2288
|
+
}
|
|
2289
|
+
/**
|
|
2290
|
+
* Full-load path for CRM objects (first sync, no watermark).
|
|
2291
|
+
* Uses the CRM list endpoint with property expansion and FlattenHubSpotRecord so that
|
|
2292
|
+
* ExternalID is correctly built from hs_object_id (mirroring FetchChangesViaSearch).
|
|
2293
|
+
* The base class FetchChanges cannot be used here because it reads raw[field] without
|
|
2294
|
+
* flattening, causing ExternalID="" for every record.
|
|
2295
|
+
*/
|
|
2296
|
+
async FetchCRMFullLoad(ctx) {
|
|
2297
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
2298
|
+
const contextUser = ctx.ContextUser;
|
|
2299
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
2300
|
+
const headers = this.BuildHeaders(auth);
|
|
2301
|
+
const limit = Math.min(ctx.BatchSize ?? 100, 100); // HubSpot CRM list API max is 100
|
|
2302
|
+
const propertiesParam = this.BuildPropertiesParam(ctx.ObjectName, ctx.RequestedSourceFields);
|
|
2303
|
+
let url = `${HUBSPOT_API_BASE}/crm/v3/objects/${ctx.ObjectName}?limit=${limit}${propertiesParam}`;
|
|
2304
|
+
if (ctx.CurrentCursor) {
|
|
2305
|
+
url += `&after=${ctx.CurrentCursor}`;
|
|
2306
|
+
}
|
|
2307
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
2308
|
+
if (response.Status !== 200) {
|
|
2309
|
+
const body = response.Body;
|
|
2310
|
+
const msg = body?.message ?? body?.error ?? JSON.stringify(body);
|
|
2311
|
+
console.warn(`[HubSpot] CRM full-load failed for ${ctx.ObjectName}: HTTP ${response.Status} — ${msg}`);
|
|
2312
|
+
return { Records: [], HasMore: false };
|
|
2313
|
+
}
|
|
2314
|
+
const body = response.Body;
|
|
2315
|
+
const rawResults = body.results ?? [];
|
|
2316
|
+
const records = rawResults.map(r => {
|
|
2317
|
+
const raw = r;
|
|
2318
|
+
const flat = this.FlattenHubSpotRecord(raw);
|
|
2319
|
+
return {
|
|
2320
|
+
ExternalID: String(flat['hs_object_id'] ?? raw['id'] ?? ''),
|
|
2321
|
+
ObjectType: ctx.ObjectName,
|
|
2322
|
+
Fields: flat,
|
|
2323
|
+
};
|
|
2324
|
+
});
|
|
2325
|
+
const nextCursor = body.paging?.next?.after;
|
|
2326
|
+
const hasMore = nextCursor != null;
|
|
2327
|
+
let newWatermark;
|
|
2328
|
+
if (!hasMore) {
|
|
2329
|
+
const dateField = this.GetWatermarkField(ctx.ObjectName);
|
|
2330
|
+
const latest = this.FindLatestDate(records, dateField);
|
|
2331
|
+
if (latest)
|
|
2332
|
+
newWatermark = latest;
|
|
2333
|
+
}
|
|
2334
|
+
return {
|
|
2335
|
+
Records: records,
|
|
2336
|
+
HasMore: hasMore,
|
|
2337
|
+
NextCursor: nextCursor,
|
|
2338
|
+
NewWatermarkValue: newWatermark,
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
/**
|
|
2342
|
+
* Fetches all pages from a single non-CRM API endpoint URL, accumulating all records.
|
|
2343
|
+
* Used as a building block for both flat and parameterized endpoint fetches.
|
|
2344
|
+
*/
|
|
2345
|
+
async FetchAllPagesFromURL(auth, baseUrl, pkField, objectName) {
|
|
2346
|
+
const headers = this.BuildHeaders(auth);
|
|
2347
|
+
const allRecords = [];
|
|
2348
|
+
let cursor;
|
|
2349
|
+
do {
|
|
2350
|
+
const url = cursor ? `${baseUrl}&after=${encodeURIComponent(cursor)}` : baseUrl;
|
|
2351
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
2352
|
+
if (response.Status !== 200) {
|
|
2353
|
+
const body = response.Body;
|
|
2354
|
+
const msg = body?.message ?? body?.error ?? JSON.stringify(body);
|
|
2355
|
+
console.warn(`[HubSpot] FetchAllPages failed for ${objectName}: HTTP ${response.Status} — ${msg}`);
|
|
2356
|
+
break;
|
|
2357
|
+
}
|
|
2358
|
+
const body = response.Body;
|
|
2359
|
+
const results = (body['results'] ?? body['objects'] ?? body['messages'] ?? []);
|
|
2360
|
+
for (const raw of results) {
|
|
2361
|
+
const id = String(raw[pkField] ?? raw['id'] ?? '');
|
|
2362
|
+
const properties = raw['properties'];
|
|
2363
|
+
const fields = properties ? { ...raw, ...properties } : raw;
|
|
2364
|
+
allRecords.push({ ExternalID: id, ObjectType: objectName, Fields: fields });
|
|
2365
|
+
}
|
|
2366
|
+
// Only follow pagination cursor when results were returned (avoid infinite loop)
|
|
2367
|
+
const paging = body['paging'];
|
|
2368
|
+
cursor = results.length > 0 ? paging?.next?.after : undefined;
|
|
2369
|
+
} while (cursor);
|
|
2370
|
+
return allRecords;
|
|
2371
|
+
}
|
|
2372
|
+
/**
|
|
2373
|
+
* Handles parameterized endpoints (apiPath with {placeholder}) by fan-out:
|
|
2374
|
+
* fetches all parent records, then fetches children for each parent ID.
|
|
2375
|
+
* All child records are accumulated and returned as a single batch (HasMore: false).
|
|
2376
|
+
*
|
|
2377
|
+
* Skips objects where parentObject is not found in NON_CRM_OBJECTS (config error).
|
|
2378
|
+
* Skips objects with {appId} placeholder — these require Developer App configuration.
|
|
2379
|
+
*/
|
|
2380
|
+
async FetchParameterizedChanges(ctx, objConfig) {
|
|
2381
|
+
// {appId} placeholders require developer app config — not available at runtime
|
|
2382
|
+
if (objConfig.apiPath.includes('{appId}')) {
|
|
2383
|
+
console.warn(`[HubSpot] ${ctx.ObjectName}: Parameterized endpoint requires {appId} (Developer App ID). ` +
|
|
2384
|
+
`Configure AppID in connector settings to enable this object. Returning empty batch.`);
|
|
2385
|
+
return { Records: [], HasMore: false };
|
|
2386
|
+
}
|
|
2387
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
2388
|
+
const contextUser = ctx.ContextUser;
|
|
2389
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
2390
|
+
// Find and validate parent config
|
|
2391
|
+
const parentConfig = HubSpotConnector_1.NON_CRM_OBJECTS.find(o => o.name === objConfig.parentObject);
|
|
2392
|
+
if (!parentConfig) {
|
|
2393
|
+
console.warn(`[HubSpot] ${ctx.ObjectName}: parentObject '${objConfig.parentObject}' not found in NON_CRM_OBJECTS`);
|
|
2394
|
+
return { Records: [], HasMore: false };
|
|
2395
|
+
}
|
|
2396
|
+
// Fetch all parent records
|
|
2397
|
+
const parentUrl = `${HUBSPOT_API_BASE}${parentConfig.apiPath}?limit=100`;
|
|
2398
|
+
const parentRecords = await this.FetchAllPagesFromURL(auth, parentUrl, parentConfig.pkField, parentConfig.name);
|
|
2399
|
+
if (parentRecords.length === 0) {
|
|
2400
|
+
return { Records: [], HasMore: false };
|
|
2401
|
+
}
|
|
2402
|
+
// Extract the placeholder name from the apiPath (e.g. '{pipelineId}' → 'pipelineId')
|
|
2403
|
+
const placeholderMatch = objConfig.apiPath.match(/\{([^}]+)\}/);
|
|
2404
|
+
if (!placeholderMatch) {
|
|
2405
|
+
return { Records: [], HasMore: false };
|
|
2406
|
+
}
|
|
2407
|
+
// Fan-out: fetch children for each parent
|
|
2408
|
+
const allChildren = [];
|
|
2409
|
+
for (const parent of parentRecords) {
|
|
2410
|
+
const parentId = String(parent.Fields[parentConfig.pkField] ?? parent.ExternalID ?? '');
|
|
2411
|
+
if (!parentId)
|
|
2412
|
+
continue;
|
|
2413
|
+
// Substitute placeholder with actual parent ID
|
|
2414
|
+
const childPath = objConfig.apiPath.replace(`{${placeholderMatch[1]}}`, encodeURIComponent(parentId));
|
|
2415
|
+
const childUrl = `${HUBSPOT_API_BASE}${childPath}?limit=100`;
|
|
2416
|
+
const children = await this.FetchAllPagesFromURL(auth, childUrl, objConfig.pkField, ctx.ObjectName);
|
|
2417
|
+
// Tag each child with its parent ID so the record is traceable
|
|
2418
|
+
for (const child of children) {
|
|
2419
|
+
child.Fields[`_parent_${parentConfig.pkField}`] = parentId;
|
|
2420
|
+
allChildren.push(child);
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
// Apply watermark filtering client-side if watermark is set
|
|
2424
|
+
let filtered = allChildren;
|
|
2425
|
+
if (ctx.WatermarkValue) {
|
|
2426
|
+
const watermarkMs = new Date(ctx.WatermarkValue).getTime();
|
|
2427
|
+
filtered = allChildren.filter(r => {
|
|
2428
|
+
for (const key of ['updatedAt', 'updated', 'createdAt', 'hs_lastmodifieddate']) {
|
|
2429
|
+
const val = r.Fields[key];
|
|
2430
|
+
if (val && typeof val === 'string') {
|
|
2431
|
+
const ms = new Date(val).getTime();
|
|
2432
|
+
if (!isNaN(ms) && ms > watermarkMs)
|
|
2433
|
+
return true;
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
return false;
|
|
2437
|
+
});
|
|
2438
|
+
}
|
|
2439
|
+
const newWatermark = this.FindLatestDateInFields(filtered);
|
|
2440
|
+
return {
|
|
2441
|
+
Records: filtered,
|
|
2442
|
+
HasMore: false,
|
|
2443
|
+
NewWatermarkValue: newWatermark,
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
/**
|
|
2447
|
+
* Fetches records from non-CRM HubSpot APIs (Marketing, CMS, Files, etc.).
|
|
2448
|
+
* These endpoints use standard REST list pagination, not the CRM search API.
|
|
2449
|
+
* Watermark filtering is client-side based on date fields in the response.
|
|
2450
|
+
*
|
|
2451
|
+
* Dispatches to FetchParameterizedChanges when apiPath contains {placeholder}.
|
|
2452
|
+
*/
|
|
2453
|
+
async FetchNonCRMChanges(ctx, objConfig) {
|
|
2454
|
+
// Parameterized endpoints (apiPath with {placeholder}) require fan-out across parent IDs.
|
|
2455
|
+
// Dispatch to the specialized handler instead of building a URL with a literal placeholder.
|
|
2456
|
+
if (objConfig.parentObject) {
|
|
2457
|
+
return this.FetchParameterizedChanges(ctx, objConfig);
|
|
2458
|
+
}
|
|
2459
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
2460
|
+
const contextUser = ctx.ContextUser;
|
|
2461
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
2462
|
+
const headers = this.BuildHeaders(auth);
|
|
2463
|
+
// Build URL with pagination — varies by endpoint type
|
|
2464
|
+
const limit = ctx.BatchSize || 100;
|
|
2465
|
+
let url;
|
|
2466
|
+
let isScimPagination = false;
|
|
2467
|
+
if (objConfig.incrementalParam === 'startIndex') {
|
|
2468
|
+
// SCIM 2.0 endpoints (scim_users, scim_groups) use offset-based pagination:
|
|
2469
|
+
// startIndex is 1-based, count is page size. CurrentCursor stores next startIndex as string.
|
|
2470
|
+
isScimPagination = true;
|
|
2471
|
+
const startIndex = ctx.CurrentCursor ? parseInt(ctx.CurrentCursor, 10) : 1;
|
|
2472
|
+
url = `${HUBSPOT_API_BASE}${objConfig.apiPath}?startIndex=${startIndex}&count=${limit}`;
|
|
2473
|
+
}
|
|
2474
|
+
else {
|
|
2475
|
+
url = `${HUBSPOT_API_BASE}${objConfig.apiPath}?limit=${limit}`;
|
|
2476
|
+
if (ctx.CurrentCursor) {
|
|
2477
|
+
url += `&after=${ctx.CurrentCursor}`;
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
// Server-side incremental filtering (preferred over client-side)
|
|
2481
|
+
if (ctx.WatermarkValue && objConfig.serverIncrementalParam) {
|
|
2482
|
+
url += `&${objConfig.serverIncrementalParam}=${encodeURIComponent(ctx.WatermarkValue)}`;
|
|
2483
|
+
}
|
|
2484
|
+
else if (ctx.WatermarkValue && objConfig.incrementalParam === 'after') {
|
|
2485
|
+
// Legacy: owners endpoint uses incrementalParam: 'after' → maps to updatedAfter
|
|
2486
|
+
url += `&updatedAfter=${ctx.WatermarkValue}`;
|
|
2487
|
+
}
|
|
2488
|
+
const response = await this.MakeHTTPRequest(auth, url, 'GET', headers);
|
|
2489
|
+
if (response.Status !== 200) {
|
|
2490
|
+
const body = response.Body;
|
|
2491
|
+
const msg = body?.message ?? body?.error ?? JSON.stringify(body);
|
|
2492
|
+
console.warn(`[HubSpot] Non-CRM fetch failed for ${ctx.ObjectName}: HTTP ${response.Status} — ${msg}`);
|
|
2493
|
+
return { Records: [], HasMore: false };
|
|
2494
|
+
}
|
|
2495
|
+
const body = response.Body;
|
|
2496
|
+
// SCIM responses use 'Resources' array; standard HubSpot uses 'results' or 'objects'
|
|
2497
|
+
const results = (body['Resources'] ?? body['results'] ?? body['objects'] ?? []);
|
|
2498
|
+
// Build ExternalRecords
|
|
2499
|
+
const records = results.map(raw => {
|
|
2500
|
+
const id = String(raw[objConfig.pkField] ?? raw['id'] ?? '');
|
|
2501
|
+
// Flatten properties if they exist (some endpoints nest under 'properties')
|
|
2502
|
+
const properties = raw['properties'];
|
|
2503
|
+
const fields = properties ? { ...raw, ...properties } : raw;
|
|
2504
|
+
return {
|
|
2505
|
+
ExternalID: id,
|
|
2506
|
+
ObjectType: ctx.ObjectName,
|
|
2507
|
+
Fields: fields,
|
|
2508
|
+
};
|
|
2509
|
+
});
|
|
2510
|
+
// Client-side watermark filtering only when no server-side param is available
|
|
2511
|
+
const hasServerSideFilter = !!(ctx.WatermarkValue && (objConfig.serverIncrementalParam || objConfig.incrementalParam === 'after'));
|
|
2512
|
+
let filteredRecords = records;
|
|
2513
|
+
if (ctx.WatermarkValue && !hasServerSideFilter) {
|
|
2514
|
+
const watermarkMs = new Date(ctx.WatermarkValue).getTime();
|
|
2515
|
+
filteredRecords = records.filter(r => {
|
|
2516
|
+
// Check common date fields
|
|
2517
|
+
for (const key of ['updatedAt', 'updated', 'createdAt', 'hs_lastmodifieddate']) {
|
|
2518
|
+
const val = r.Fields[key];
|
|
2519
|
+
if (val && typeof val === 'string') {
|
|
2520
|
+
const recMs = new Date(val).getTime();
|
|
2521
|
+
if (!isNaN(recMs) && recMs > watermarkMs)
|
|
2522
|
+
return true;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
return false;
|
|
2526
|
+
});
|
|
2527
|
+
}
|
|
2528
|
+
// Pagination cursor — varies by endpoint type
|
|
2529
|
+
let nextCursor;
|
|
2530
|
+
if (isScimPagination) {
|
|
2531
|
+
// SCIM: totalResults lets us know if there are more pages.
|
|
2532
|
+
// Next startIndex = current startIndex + count returned.
|
|
2533
|
+
const totalResults = body['totalResults'] ?? 0;
|
|
2534
|
+
const currentStart = ctx.CurrentCursor ? parseInt(ctx.CurrentCursor, 10) : 1;
|
|
2535
|
+
const nextStart = currentStart + results.length;
|
|
2536
|
+
nextCursor = results.length > 0 && nextStart <= totalResults ? String(nextStart) : undefined;
|
|
2537
|
+
}
|
|
2538
|
+
else {
|
|
2539
|
+
// Standard HubSpot cursor pagination — only trust when results were returned.
|
|
2540
|
+
// Some endpoints (e.g. conversation_threads) return a next.after cursor even on
|
|
2541
|
+
// the last page, causing an infinite loop if followed blindly.
|
|
2542
|
+
const paging = body['paging'];
|
|
2543
|
+
nextCursor = results.length > 0 ? paging?.next?.after : undefined;
|
|
2544
|
+
}
|
|
2545
|
+
// Set watermark from latest record
|
|
2546
|
+
let newWatermark;
|
|
2547
|
+
if (!nextCursor && filteredRecords.length > 0) {
|
|
2548
|
+
newWatermark = this.FindLatestDateInFields(filteredRecords);
|
|
2549
|
+
}
|
|
2550
|
+
return {
|
|
2551
|
+
Records: filteredRecords,
|
|
2552
|
+
HasMore: !!nextCursor,
|
|
2553
|
+
NextCursor: nextCursor,
|
|
2554
|
+
NewWatermarkValue: newWatermark,
|
|
2555
|
+
};
|
|
2556
|
+
}
|
|
2557
|
+
/**
|
|
2558
|
+
* Find the latest date value across common date fields in a set of records.
|
|
2559
|
+
*/
|
|
2560
|
+
FindLatestDateInFields(records) {
|
|
2561
|
+
let latest = 0;
|
|
2562
|
+
let latestStr;
|
|
2563
|
+
const dateKeys = ['updatedAt', 'updated', 'createdAt', 'hs_lastmodifieddate', 'created', 'publishDate'];
|
|
2564
|
+
for (const r of records) {
|
|
2565
|
+
for (const key of dateKeys) {
|
|
2566
|
+
const val = r.Fields[key];
|
|
2567
|
+
if (val && typeof val === 'string') {
|
|
2568
|
+
const ms = new Date(val).getTime();
|
|
2569
|
+
if (!isNaN(ms) && ms > latest) {
|
|
2570
|
+
latest = ms;
|
|
2571
|
+
latestStr = val;
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
return latestStr;
|
|
2577
|
+
}
|
|
2578
|
+
/**
|
|
2579
|
+
* Fetches changed records using the HubSpot search API with server-side date filtering.
|
|
2580
|
+
* Much more efficient than fetching ALL records and filtering client-side.
|
|
2581
|
+
*
|
|
2582
|
+
* Handles the search API's 10,000-results-per-window hard cap by keyset re-anchoring: results
|
|
2583
|
+
* are sorted by (dateField, hs_object_id) ASCENDING, paginated within a window by the API's
|
|
2584
|
+
* opaque `after` offset, and once that offset hits the 10k cap the NEXT window re-anchors with a
|
|
2585
|
+
* compound filter `(dateField > anchor) OR (dateField == anchor AND hs_object_id > anchorId)`.
|
|
2586
|
+
* This makes an incremental window — or a bulk-import cluster of >10k records that all share one
|
|
2587
|
+
* `hs_lastmodifieddate` — page through completely in a single sync, instead of the watermark
|
|
2588
|
+
* stalling on a same-timestamp cluster it can never advance past (which silently lost records).
|
|
2589
|
+
* The date GTE watermark remains the primary filter throughout, so incremental sync is preserved.
|
|
2590
|
+
*
|
|
2591
|
+
* LIVE-VERIFY (confirm during the credentialed run against a real >10k same-timestamp cluster):
|
|
2592
|
+
* 1. hs_object_id GT/EQ comparison in v3 search is NUMERIC, not lexicographic. HIGHEST STAKES — if
|
|
2593
|
+
* lexicographic, '2' > '10000' and the keyset would skip records. (hs_object_id is a sequential
|
|
2594
|
+
* 64-bit integer / number-typed property, so numeric is expected, but prove it across an
|
|
2595
|
+
* id-magnitude boundary, e.g. ids 9, 10, 100, 1000 within one timestamp cluster.)
|
|
2596
|
+
* 2. Datetime filter values accept epoch-millis-as-string for EQ/GT/GTE (the pre-existing GTE
|
|
2597
|
+
* watermark filter already relies on this, so a regression here would also break prior behavior).
|
|
2598
|
+
* 3. The compound (dateField ASC, hs_object_id ASC) sort is honored deterministically across pages
|
|
2599
|
+
* and object types, so the last raw result is the true (date,id)-max keyset boundary.
|
|
2600
|
+
* 4. `total` reflects the CURRENT filterGroups per re-anchored query (not a cached original count).
|
|
2601
|
+
*/
|
|
2602
|
+
async FetchChangesViaSearch(ctx) {
|
|
2603
|
+
const companyIntegration = ctx.CompanyIntegration;
|
|
2604
|
+
const contextUser = ctx.ContextUser;
|
|
2605
|
+
const auth = await this.Authenticate(companyIntegration, contextUser);
|
|
2606
|
+
const headers = this.BuildHeaders(auth);
|
|
2607
|
+
const dateField = this.GetWatermarkField(ctx.ObjectName);
|
|
2608
|
+
const watermarkMs = new Date(ctx.WatermarkValue).getTime();
|
|
2609
|
+
const properties = this.BuildEffectiveProperties(ctx.ObjectName, ctx.RequestedSourceFields);
|
|
2610
|
+
const pageSize = Math.min(ctx.BatchSize ?? 100, 100); // HubSpot search API max is 100
|
|
2611
|
+
const cursor = this.parseSearchCursor(ctx.CurrentCursor);
|
|
2612
|
+
const isReanchored = cursor.anchorDateMs != null && cursor.anchorId != null;
|
|
2613
|
+
// When re-anchored past a 10k window, the keyset predicate replaces the plain GTE filter.
|
|
2614
|
+
// anchorDateMs is always >= watermarkMs (we only ever advance), so the keyset predicate
|
|
2615
|
+
// subsumes the original watermark filter — incremental scope is never widened.
|
|
2616
|
+
const filterGroups = isReanchored
|
|
2617
|
+
? [
|
|
2618
|
+
{ filters: [{ propertyName: dateField, operator: 'GT', value: cursor.anchorDateMs }] },
|
|
2619
|
+
{ filters: [
|
|
2620
|
+
{ propertyName: dateField, operator: 'EQ', value: cursor.anchorDateMs },
|
|
2621
|
+
{ propertyName: 'hs_object_id', operator: 'GT', value: cursor.anchorId },
|
|
2622
|
+
] },
|
|
2623
|
+
]
|
|
2624
|
+
: [{ filters: [{ propertyName: dateField, operator: 'GTE', value: String(watermarkMs) }] }];
|
|
2625
|
+
const searchBody = {
|
|
2626
|
+
filterGroups,
|
|
2627
|
+
// Secondary sort on hs_object_id is REQUIRED: it makes ordering deterministic within a
|
|
2628
|
+
// same-timestamp cluster so the keyset anchor (last record's id) is well-defined and the
|
|
2629
|
+
// next window can never skip or re-emit a record at the boundary.
|
|
2630
|
+
sorts: [
|
|
2631
|
+
{ propertyName: dateField, direction: 'ASCENDING' },
|
|
2632
|
+
{ propertyName: 'hs_object_id', direction: 'ASCENDING' },
|
|
2633
|
+
],
|
|
2634
|
+
properties,
|
|
2635
|
+
limit: pageSize,
|
|
2636
|
+
};
|
|
2637
|
+
if (cursor.after) {
|
|
2638
|
+
searchBody['after'] = cursor.after;
|
|
2639
|
+
}
|
|
2640
|
+
const url = `${HUBSPOT_API_BASE}/crm/v3/objects/${ctx.ObjectName}/search`;
|
|
2641
|
+
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, searchBody);
|
|
2642
|
+
this.ValidateCRUDResponse(response, 'FetchChangesViaSearch', ctx.ObjectName);
|
|
2643
|
+
const body = response.Body;
|
|
2644
|
+
const searchTotal = body.total ?? 0;
|
|
2645
|
+
const rawResults = body.results ?? [];
|
|
2646
|
+
const records = rawResults.map(r => {
|
|
2647
|
+
const raw = r;
|
|
2648
|
+
const flat = this.FlattenHubSpotRecord(raw);
|
|
2649
|
+
return {
|
|
2650
|
+
ExternalID: String(flat['hs_object_id'] ?? raw['id'] ?? ''),
|
|
2651
|
+
ObjectType: ctx.ObjectName,
|
|
2652
|
+
Fields: flat,
|
|
2653
|
+
};
|
|
2654
|
+
});
|
|
2655
|
+
// Keyset anchor for the NEXT window = the last record in (date, id) sort order.
|
|
2656
|
+
const anchor = this.extractSearchAnchor(rawResults, dateField);
|
|
2657
|
+
const { nextCursor, hasMore, stalled } = this.computeSearchResume({
|
|
2658
|
+
incoming: cursor,
|
|
2659
|
+
pagingNextAfter: body.paging?.next?.after,
|
|
2660
|
+
total: searchTotal,
|
|
2661
|
+
lastAnchorDateMs: anchor.dateMs,
|
|
2662
|
+
lastAnchorId: anchor.id,
|
|
2663
|
+
});
|
|
2664
|
+
if (stalled) {
|
|
2665
|
+
// total exceeds the window cap but we couldn't form a keyset anchor to page past it. Fail
|
|
2666
|
+
// LOUD rather than silently dropping the remainder: the engine catches this, marks the
|
|
2667
|
+
// fetch incomplete, and leaves the watermark un-advanced so the next sync retries the gap.
|
|
2668
|
+
throw new Error(`HubSpot ${ctx.ObjectName}: search reported total=${searchTotal} (beyond the ` +
|
|
2669
|
+
`${HUBSPOT_SEARCH_WINDOW_CAP}-record window cap) but the last page returned no keyset anchor, ` +
|
|
2670
|
+
`so the scan cannot advance without risking silent record loss. Aborting this fetch; the ` +
|
|
2671
|
+
`watermark is left un-advanced and the remainder is retried on the next sync.`);
|
|
2672
|
+
}
|
|
2673
|
+
if (searchTotal > HUBSPOT_SEARCH_WINDOW_CAP && !cursor.after && !isReanchored) {
|
|
2674
|
+
// Informational only — the keyset re-anchor below pages through the whole window in this
|
|
2675
|
+
// sync; this is no longer a multi-cycle stall, just a large object worth noting.
|
|
2676
|
+
console.log(`[HubSpot] ${ctx.ObjectName}: ${searchTotal} records since watermark exceed the 10k search window; ` +
|
|
2677
|
+
`keyset-paginating by (${dateField}, hs_object_id) to fetch them all in this sync.`);
|
|
2678
|
+
}
|
|
2679
|
+
// On the final page of the entire scan, also fetch archived (deleted) records since the
|
|
2680
|
+
// watermark so they flow through the engine's delete pipeline.
|
|
2681
|
+
if (!hasMore) {
|
|
2682
|
+
const archived = await this.FetchArchivedCRMChanges(auth, headers, ctx.ObjectName, dateField, watermarkMs, properties);
|
|
2683
|
+
if (archived.length > 0) {
|
|
2684
|
+
console.log(`[HubSpot] ${ctx.ObjectName}: found ${archived.length} archived (deleted) record(s) since watermark`);
|
|
2685
|
+
records.push(...archived);
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
let newWatermark;
|
|
2689
|
+
if (!hasMore) {
|
|
2690
|
+
const latest = this.FindLatestDate(records.filter(r => !r.IsDeleted), dateField);
|
|
2691
|
+
if (latest)
|
|
2692
|
+
newWatermark = latest;
|
|
2693
|
+
}
|
|
2694
|
+
return {
|
|
2695
|
+
Records: records,
|
|
2696
|
+
HasMore: hasMore,
|
|
2697
|
+
NextCursor: nextCursor ? JSON.stringify(nextCursor) : undefined,
|
|
2698
|
+
NewWatermarkValue: newWatermark,
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
/**
|
|
2702
|
+
* Parses the {@link HubSpotSearchCursor} threaded via FetchContext.CurrentCursor. Tolerates a
|
|
2703
|
+
* legacy raw `after` string (pre-keyset format) by treating it as a plain window offset, so an
|
|
2704
|
+
* in-flight sync mid-upgrade degrades gracefully rather than throwing.
|
|
2705
|
+
*/
|
|
2706
|
+
parseSearchCursor(raw) {
|
|
2707
|
+
if (!raw)
|
|
2708
|
+
return {};
|
|
2709
|
+
try {
|
|
2710
|
+
const parsed = JSON.parse(raw);
|
|
2711
|
+
if (parsed && typeof parsed === 'object')
|
|
2712
|
+
return parsed;
|
|
2713
|
+
// Parsed to a primitive (e.g. a bare numeric `after` like "9900" from the pre-keyset
|
|
2714
|
+
// format) — treat the raw value as a plain window offset.
|
|
2715
|
+
return { after: raw };
|
|
2716
|
+
}
|
|
2717
|
+
catch {
|
|
2718
|
+
return { after: raw };
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
/**
|
|
2722
|
+
* Extracts the keyset anchor (the last record's dateField-as-epoch-ms and hs_object_id) from a
|
|
2723
|
+
* batch of raw search results. Because results are sorted (dateField, hs_object_id) ASCENDING,
|
|
2724
|
+
* the last element is the maximum position and therefore the resume point for the next window.
|
|
2725
|
+
* Returns undefined fields when the batch is empty or the values can't be parsed.
|
|
2726
|
+
*/
|
|
2727
|
+
extractSearchAnchor(rawResults, dateField) {
|
|
2728
|
+
if (rawResults.length === 0)
|
|
2729
|
+
return {};
|
|
2730
|
+
const lastFlat = this.FlattenHubSpotRecord(rawResults[rawResults.length - 1]);
|
|
2731
|
+
const id = lastFlat['hs_object_id'];
|
|
2732
|
+
const result = {};
|
|
2733
|
+
if (id != null && String(id).length > 0)
|
|
2734
|
+
result.id = String(id);
|
|
2735
|
+
const dateMs = this.toEpochMs(lastFlat[dateField]);
|
|
2736
|
+
if (dateMs != null)
|
|
2737
|
+
result.dateMs = dateMs;
|
|
2738
|
+
return result;
|
|
2739
|
+
}
|
|
2740
|
+
/**
|
|
2741
|
+
* Normalizes a HubSpot datetime property value to an epoch-millis string for use in a search
|
|
2742
|
+
* filter. Accepts both an ISO-8601 string (the usual v3 shape) and a bare epoch-millis numeric
|
|
2743
|
+
* string (some endpoints/properties). Returns undefined when unparseable — callers then skip
|
|
2744
|
+
* re-anchoring rather than seeking from a NaN position.
|
|
2745
|
+
*/
|
|
2746
|
+
toEpochMs(dateVal) {
|
|
2747
|
+
if (dateVal == null)
|
|
2748
|
+
return undefined;
|
|
2749
|
+
const s = String(dateVal);
|
|
2750
|
+
const iso = new Date(s).getTime();
|
|
2751
|
+
if (!Number.isNaN(iso))
|
|
2752
|
+
return String(iso);
|
|
2753
|
+
const epoch = Number(s);
|
|
2754
|
+
if (!Number.isNaN(epoch) && epoch > 0)
|
|
2755
|
+
return String(epoch);
|
|
2756
|
+
return undefined;
|
|
2757
|
+
}
|
|
2758
|
+
/**
|
|
2759
|
+
* Pure decision for the next search window, given this page's pagination + total. No network.
|
|
2760
|
+
*
|
|
2761
|
+
* - If more pages remain inside the current ≤10k window, advance the API `after` offset and keep
|
|
2762
|
+
* the same anchor.
|
|
2763
|
+
* - Otherwise the window is exhausted (the API stopped returning `after`, or it reached the 10k
|
|
2764
|
+
* cap). If records still match the current filter (`total > cap`), re-anchor the next window on
|
|
2765
|
+
* the last record's (dateField, hs_object_id) keyset; else the scan is complete.
|
|
2766
|
+
*
|
|
2767
|
+
* `total` is the count matching the CURRENT filter, so after each re-anchor it shrinks by roughly
|
|
2768
|
+
* one window until it falls to/under the cap — guaranteeing termination with no skipped records
|
|
2769
|
+
* (the anchor's id strictly increases) and no duplicates (the keyset predicate excludes it).
|
|
2770
|
+
*/
|
|
2771
|
+
computeSearchResume(args) {
|
|
2772
|
+
const { incoming, pagingNextAfter, total, lastAnchorDateMs, lastAnchorId } = args;
|
|
2773
|
+
const cap = HUBSPOT_SEARCH_WINDOW_CAP;
|
|
2774
|
+
const withinWindow = pagingNextAfter != null && Number(pagingNextAfter) < cap;
|
|
2775
|
+
if (withinWindow) {
|
|
2776
|
+
return {
|
|
2777
|
+
nextCursor: { after: pagingNextAfter, anchorDateMs: incoming.anchorDateMs, anchorId: incoming.anchorId },
|
|
2778
|
+
hasMore: true,
|
|
2779
|
+
};
|
|
2780
|
+
}
|
|
2781
|
+
if (total > cap) {
|
|
2782
|
+
if (lastAnchorId != null && lastAnchorDateMs != null) {
|
|
2783
|
+
return { nextCursor: { anchorDateMs: lastAnchorDateMs, anchorId: lastAnchorId }, hasMore: true };
|
|
2784
|
+
}
|
|
2785
|
+
// total exceeds the 10k window cap but this page produced no usable keyset anchor — which
|
|
2786
|
+
// means HubSpot returned a total > 0 with an empty/anchorless page (a contract violation).
|
|
2787
|
+
// Re-anchoring is impossible; silently stopping here would DROP the remaining records — the
|
|
2788
|
+
// exact silent-loss this fix exists to prevent. Flag it so the caller aborts loudly and
|
|
2789
|
+
// leaves the watermark un-advanced (the next sync retries the gap) instead of skipping it.
|
|
2790
|
+
return { nextCursor: undefined, hasMore: false, stalled: true };
|
|
2791
|
+
}
|
|
2792
|
+
return { nextCursor: undefined, hasMore: false };
|
|
2793
|
+
}
|
|
2794
|
+
/**
|
|
2795
|
+
* Detects archived (deleted) CRM records since the given watermark.
|
|
2796
|
+
*
|
|
2797
|
+
* Uses the HubSpot search API with `archived: true` so the same server-side
|
|
2798
|
+
* GTE watermark filter applies — only records archived/modified since the last
|
|
2799
|
+
* sync are returned. Returns them with `IsDeleted: true` so the integration
|
|
2800
|
+
* engine routes them through the delete pipeline.
|
|
2801
|
+
*
|
|
2802
|
+
* Falls back to empty on any API error (e.g. object type doesn't support
|
|
2803
|
+
* archived search) so delete detection degrades gracefully rather than
|
|
2804
|
+
* blocking the active-record sync.
|
|
2805
|
+
*/
|
|
2806
|
+
async FetchArchivedCRMChanges(auth, headers, objectName, dateField, watermarkMs, properties) {
|
|
2807
|
+
const url = `${HUBSPOT_API_BASE}/crm/v3/objects/${objectName}/search`;
|
|
2808
|
+
const allDeleted = [];
|
|
2809
|
+
let cursor;
|
|
2810
|
+
do {
|
|
2811
|
+
const searchBody = {
|
|
2812
|
+
filterGroups: [{
|
|
2813
|
+
filters: [{
|
|
2814
|
+
propertyName: dateField,
|
|
2815
|
+
operator: 'GTE',
|
|
2816
|
+
value: String(watermarkMs),
|
|
2817
|
+
}],
|
|
2818
|
+
}],
|
|
2819
|
+
// Secondary sort on hs_object_id matches the active-record path: bulk deletions often
|
|
2820
|
+
// share one hs_lastmodifieddate, so without a tie-breaker the opaque `after` pages of
|
|
2821
|
+
// this archived scan have undefined intra-cluster order and could skip/duplicate.
|
|
2822
|
+
sorts: [
|
|
2823
|
+
{ propertyName: dateField, direction: 'ASCENDING' },
|
|
2824
|
+
{ propertyName: 'hs_object_id', direction: 'ASCENDING' },
|
|
2825
|
+
],
|
|
2826
|
+
properties,
|
|
2827
|
+
limit: 100,
|
|
2828
|
+
archived: true,
|
|
2829
|
+
};
|
|
2830
|
+
if (cursor)
|
|
2831
|
+
searchBody['after'] = cursor;
|
|
2832
|
+
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, searchBody);
|
|
2833
|
+
if (response.Status !== 200)
|
|
2834
|
+
break; // silently skip — object may not support archived search
|
|
2835
|
+
const body = response.Body;
|
|
2836
|
+
const rawResults = body.results ?? [];
|
|
2837
|
+
for (const r of rawResults) {
|
|
2838
|
+
const raw = r;
|
|
2839
|
+
const flat = this.FlattenHubSpotRecord(raw);
|
|
2840
|
+
allDeleted.push({
|
|
2841
|
+
ExternalID: String(flat['hs_object_id'] ?? raw['id'] ?? ''),
|
|
2842
|
+
ObjectType: objectName,
|
|
2843
|
+
Fields: flat,
|
|
2844
|
+
IsDeleted: true,
|
|
2845
|
+
});
|
|
2846
|
+
}
|
|
2847
|
+
cursor = rawResults.length > 0 ? body.paging?.next?.after : undefined;
|
|
2848
|
+
} while (cursor);
|
|
2849
|
+
return allDeleted;
|
|
2850
|
+
}
|
|
2851
|
+
/** Returns the watermark date field name for a given object type. */
|
|
2852
|
+
GetWatermarkField(objectName) {
|
|
2853
|
+
return objectName.toLowerCase() === 'contacts' ? 'lastmodifieddate' : 'hs_lastmodifieddate';
|
|
2854
|
+
}
|
|
2855
|
+
/** Finds the latest date value across records for a given field name. */
|
|
2856
|
+
FindLatestDate(records, fieldName) {
|
|
2857
|
+
let latest = null;
|
|
2858
|
+
for (const r of records) {
|
|
2859
|
+
const val = r.Fields[fieldName];
|
|
2860
|
+
if (val == null)
|
|
2861
|
+
continue;
|
|
2862
|
+
const d = new Date(String(val));
|
|
2863
|
+
if (!isNaN(d.getTime()) && (!latest || d > latest)) {
|
|
2864
|
+
latest = d;
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
return latest ? latest.toISOString() : undefined;
|
|
2868
|
+
}
|
|
2869
|
+
/**
|
|
2870
|
+
* Fetches association records in batches by iterating over synced parent (from-side)
|
|
2871
|
+
* objects and calling HubSpot's v4 per-object associations endpoint.
|
|
2872
|
+
*
|
|
2873
|
+
* Uses ctx.CurrentOffset to track parent position across batch calls, so the engine
|
|
2874
|
+
* can page through all parents without truncating records.
|
|
2875
|
+
*/
|
|
2876
|
+
/**
|
|
2877
|
+
* Fetches association records using the HubSpot v4 batch/read endpoint.
|
|
2878
|
+
* Batches up to 100 parent IDs per request instead of one GET per parent,
|
|
2879
|
+
* reducing API calls from O(n) to O(n/100).
|
|
2880
|
+
*/
|
|
2881
|
+
async FetchAssociationChanges(ctx, obj) {
|
|
2882
|
+
const parsed = this.ParseAssociationPath(obj.APIPath);
|
|
2883
|
+
if (!parsed) {
|
|
2884
|
+
console.warn(`[HubSpot] Cannot parse association path: ${obj.APIPath}`);
|
|
2885
|
+
return {
|
|
2886
|
+
Records: [], HasMore: false,
|
|
2887
|
+
Warnings: [{
|
|
2888
|
+
Code: 'ASSOCIATION_PATH_UNPARSEABLE',
|
|
2889
|
+
Message: `Association '${obj.Name}' has an unparseable APIPath '${obj.APIPath}' — cannot determine the from/to object types, so 0 associations were fetched (not a real "no data" result).`,
|
|
2890
|
+
Data: { object: obj.Name, apiPath: obj.APIPath },
|
|
2891
|
+
}],
|
|
2892
|
+
};
|
|
2893
|
+
}
|
|
2894
|
+
const { fromType, toType } = parsed;
|
|
2895
|
+
const auth = await this.Authenticate(ctx.CompanyIntegration, ctx.ContextUser);
|
|
2896
|
+
// Derive PK field names from the API path object type names.
|
|
2897
|
+
// "companies" → "company_id", "emails" → "email_id", "contacts" → "contact_id"
|
|
2898
|
+
const singularize = (t) => t.endsWith('ies') ? t.slice(0, -3) + 'y' : t.endsWith('s') ? t.slice(0, -1) : t;
|
|
2899
|
+
const leftFieldName = `${singularize(fromType)}_id`;
|
|
2900
|
+
const rightFieldName = `${singularize(toType)}_id`;
|
|
2901
|
+
const parentIDs = await this.LoadAssociationParentIDs(fromType, ctx);
|
|
2902
|
+
const parentOffset = ctx.CurrentOffset ?? 0;
|
|
2903
|
+
const BATCH_LIMIT = 100; // HubSpot v4 batch/read max inputs per request
|
|
2904
|
+
// Zero parents on the FIRST page = the silent-empty case for a second-layer object: there is
|
|
2905
|
+
// no parent `fromType` data in MJ to read associations from (parent not synced/mapped, or its
|
|
2906
|
+
// entity-map disabled, or DAG ordered this before its parent). Surface it as a structured
|
|
2907
|
+
// FetchWarning so the engine records it in the run artifact (visible over GraphQL) instead
|
|
2908
|
+
// of a quiet zero-record "success". Subsequent pages legitimately exhaust to 0 — not flagged.
|
|
2909
|
+
if (parentOffset === 0 && parentIDs.length === 0) {
|
|
2910
|
+
console.warn(`[HubSpot] Association '${obj.Name}': 0 parent ${fromType} records in MJ — nothing to associate.`);
|
|
2911
|
+
return {
|
|
2912
|
+
Records: [],
|
|
2913
|
+
HasMore: false,
|
|
2914
|
+
Warnings: [{
|
|
2915
|
+
Code: 'ZERO_PARENTS',
|
|
2916
|
+
Message: `Association '${obj.Name}' has no parent ${fromType} records in MJ to read associations from — '${fromType}' was not synced/mapped this run (or its entity-map is disabled). 0 associations fetched.`,
|
|
2917
|
+
Data: { object: obj.Name, parentType: fromType },
|
|
2918
|
+
}],
|
|
2919
|
+
};
|
|
2920
|
+
}
|
|
2921
|
+
if (parentOffset === 0) {
|
|
2922
|
+
console.log(`[HubSpot] Fetching ${obj.Name}: ${parentIDs.length} parent ${fromType} via batch API (100/request)`);
|
|
2923
|
+
}
|
|
2924
|
+
const batchParentIDs = parentIDs.slice(parentOffset, parentOffset + BATCH_LIMIT);
|
|
2925
|
+
if (batchParentIDs.length === 0) {
|
|
2926
|
+
return { Records: [], HasMore: false };
|
|
2927
|
+
}
|
|
2928
|
+
const records = await this.FetchAssociationBatch(auth, fromType, toType, batchParentIDs, leftFieldName, rightFieldName, ctx.ObjectName);
|
|
2929
|
+
const nextOffset = parentOffset + batchParentIDs.length;
|
|
2930
|
+
const hasMore = nextOffset < parentIDs.length;
|
|
2931
|
+
console.log(`[HubSpot] ${obj.Name}: fetched ${records.length} association records (parents ${parentOffset}–${parentOffset + batchParentIDs.length - 1} of ${parentIDs.length})`);
|
|
2932
|
+
return { Records: records, HasMore: hasMore, NextOffset: hasMore ? nextOffset : undefined };
|
|
2933
|
+
}
|
|
2934
|
+
/**
|
|
2935
|
+
* Calls POST /crm/v4/associations/{fromType}/{toType}/batch/read with up to 100 parent IDs.
|
|
2936
|
+
* Response format: { results: [{ from: { id }, to: [{ toObjectId, associationTypes }] }] }
|
|
2937
|
+
*/
|
|
2938
|
+
async FetchAssociationBatch(auth, fromType, toType, parentIDs, leftFieldName, rightFieldName, objectName) {
|
|
2939
|
+
const headers = this.BuildHeaders(auth);
|
|
2940
|
+
const url = `${HUBSPOT_API_BASE}/crm/v4/associations/${fromType}/${toType}/batch/read`;
|
|
2941
|
+
const body = { inputs: parentIDs.map(id => ({ id })) };
|
|
2942
|
+
const response = await this.MakeHTTPRequest(auth, url, 'POST', headers, body);
|
|
2943
|
+
if (response.Status < 200 || response.Status >= 300) {
|
|
2944
|
+
const respBody = response.Body;
|
|
2945
|
+
const msg = respBody?.message ?? respBody?.error ?? JSON.stringify(respBody);
|
|
2946
|
+
// Surface as a real error (the engine's fetch handler records it as sync.record.error in
|
|
2947
|
+
// the run artifact) instead of a swallowed console.warn + empty result that reads as a
|
|
2948
|
+
// successful "no associations" — a non-2xx batch/read is a failure, not absence of data.
|
|
2949
|
+
throw new Error(`[HubSpot] Association batch read failed for ${objectName}: HTTP ${response.Status} — ${msg}`);
|
|
2950
|
+
}
|
|
2951
|
+
const respBody = response.Body;
|
|
2952
|
+
const records = [];
|
|
2953
|
+
for (const item of respBody.results ?? []) {
|
|
2954
|
+
const parentID = item.from.id;
|
|
2955
|
+
for (const assoc of item.to ?? []) {
|
|
2956
|
+
const flat = this.FlattenAssociationRecord({ toObjectId: assoc.toObjectId, associationTypes: assoc.associationTypes }, leftFieldName, parentID, rightFieldName);
|
|
2957
|
+
records.push({
|
|
2958
|
+
ExternalID: `${flat[leftFieldName]}|${flat[rightFieldName]}`,
|
|
2959
|
+
ObjectType: objectName,
|
|
2960
|
+
Fields: flat,
|
|
2961
|
+
});
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
return records;
|
|
2965
|
+
}
|
|
2966
|
+
/**
|
|
2967
|
+
* Converts a HubSpot v4 association result item into a flat record suitable for storage.
|
|
2968
|
+
* v4 format: { toObjectId: number, associationTypes: [{ label, typeId, category }] }
|
|
2969
|
+
*/
|
|
2970
|
+
FlattenAssociationRecord(record, leftFieldName, leftValue, rightFieldName) {
|
|
2971
|
+
const assocTypes = record['associationTypes'];
|
|
2972
|
+
const rightValue = String(record['toObjectId']);
|
|
2973
|
+
return {
|
|
2974
|
+
[leftFieldName]: leftValue,
|
|
2975
|
+
[rightFieldName]: rightValue,
|
|
2976
|
+
association_type: assocTypes?.[0]?.label ?? null,
|
|
2977
|
+
};
|
|
2978
|
+
}
|
|
2979
|
+
/**
|
|
2980
|
+
* Parses a v4 associations APIPath to extract from/to object type names.
|
|
2981
|
+
* E.g., "/crm/v4/associations/contacts/companies" → { fromType: "contacts", toType: "companies" }
|
|
2982
|
+
*/
|
|
2983
|
+
ParseAssociationPath(apiPath) {
|
|
2984
|
+
const match = /\/crm\/v4\/associations\/([^/?]+)\/([^/?]+)/.exec(apiPath);
|
|
2985
|
+
if (!match)
|
|
2986
|
+
return null;
|
|
2987
|
+
return { fromType: match[1], toType: match[2] };
|
|
2988
|
+
}
|
|
2989
|
+
/**
|
|
2990
|
+
* Loads hs_object_id values for all synced records of a given HubSpot object type
|
|
2991
|
+
* by finding its entity map and querying the local MJ entity.
|
|
2992
|
+
*/
|
|
2993
|
+
async LoadAssociationParentIDs(fromType, ctx) {
|
|
2994
|
+
const rv = new RunView();
|
|
2995
|
+
const entityMapResult = await rv.RunView({
|
|
2996
|
+
EntityName: 'MJ: Company Integration Entity Maps',
|
|
2997
|
+
ExtraFilter: `ExternalObjectName='${fromType}' AND SyncEnabled=1 AND CompanyIntegrationID='${ctx.CompanyIntegration.ID}'`,
|
|
2998
|
+
Fields: ['Entity'],
|
|
2999
|
+
MaxRows: 1,
|
|
3000
|
+
ResultType: 'simple',
|
|
3001
|
+
}, ctx.ContextUser);
|
|
3002
|
+
if (!entityMapResult.Success || entityMapResult.Results.length === 0) {
|
|
3003
|
+
console.warn(`[HubSpot] No entity map found for ${fromType} — skipping association fetch`);
|
|
3004
|
+
return [];
|
|
3005
|
+
}
|
|
3006
|
+
const entityName = entityMapResult.Results[0].Entity;
|
|
3007
|
+
const idsResult = await rv.RunView({
|
|
3008
|
+
EntityName: entityName,
|
|
3009
|
+
Fields: ['hs_object_id'],
|
|
3010
|
+
ResultType: 'simple',
|
|
3011
|
+
}, ctx.ContextUser);
|
|
3012
|
+
if (!idsResult.Success)
|
|
3013
|
+
return [];
|
|
3014
|
+
return idsResult.Results
|
|
3015
|
+
.map(r => String(r['hs_object_id']))
|
|
3016
|
+
.filter(id => id && id !== 'undefined' && id !== 'null');
|
|
3017
|
+
}
|
|
3018
|
+
// ─── URL helpers ─────────────────────────────────────────────────
|
|
3019
|
+
/**
|
|
3020
|
+
* Extracts the HubSpot object name from an API path.
|
|
3021
|
+
* E.g., "/crm/v3/objects/contacts" -> "contacts"
|
|
3022
|
+
*/
|
|
3023
|
+
ExtractObjectNameFromPath(path) {
|
|
3024
|
+
// Remove query string
|
|
3025
|
+
const pathOnly = path.split('?')[0];
|
|
3026
|
+
// Get the last path segment
|
|
3027
|
+
const segments = pathOnly.split('/').filter(s => s.length > 0);
|
|
3028
|
+
return segments[segments.length - 1] ?? '';
|
|
3029
|
+
}
|
|
3030
|
+
/**
|
|
3031
|
+
* Returns the known field names for a HubSpot object type, derived from
|
|
3032
|
+
* the HUBSPOT_OBJECTS metadata (single source of truth).
|
|
3033
|
+
*/
|
|
3034
|
+
GetObjectFieldNames(objectName) {
|
|
3035
|
+
const obj = HUBSPOT_OBJECTS.find(o => o.Name === objectName);
|
|
3036
|
+
return obj ? obj.Fields.map(f => f.Name) : [];
|
|
3037
|
+
}
|
|
3038
|
+
/**
|
|
3039
|
+
* Returns the configured upsert key (unique business property to match on) for an
|
|
3040
|
+
* object from HUBSPOT_OBJECTS metadata, or undefined if the object declares none.
|
|
3041
|
+
* Used by Upsert to default the idProperty when the caller doesn't override it.
|
|
3042
|
+
*/
|
|
3043
|
+
GetUpsertKey(objectName) {
|
|
3044
|
+
return HUBSPOT_OBJECTS.find(o => o.Name === objectName)?.UpsertKey;
|
|
3045
|
+
}
|
|
3046
|
+
/**
|
|
3047
|
+
* Returns the effective property list for a HubSpot CRM request.
|
|
3048
|
+
*
|
|
3049
|
+
* When `requestedFields` (from FetchContext.RequestedSourceFields) is provided it
|
|
3050
|
+
* contains the source fields from active field maps, including any custom properties.
|
|
3051
|
+
* We merge those with the essential system properties so watermark tracking always works.
|
|
3052
|
+
*
|
|
3053
|
+
* Falls back to the static HUBSPOT_OBJECTS field list when no requestedFields are given.
|
|
3054
|
+
*
|
|
3055
|
+
* Note: hs_object_id is NOT included — it's the top-level `id` field on every HubSpot
|
|
3056
|
+
* response and is injected by FlattenHubSpotRecord regardless of `?properties=`.
|
|
3057
|
+
* Essential system properties (always included):
|
|
3058
|
+
* - GetWatermarkField(objectName) — the per-object modified-date property (object-specific;
|
|
3059
|
+
* contacts use 'lastmodifieddate', all others use 'hs_lastmodifieddate')
|
|
3060
|
+
* - createdate — creation timestamp
|
|
3061
|
+
*/
|
|
3062
|
+
BuildEffectiveProperties(objectName, requestedFields) {
|
|
3063
|
+
const essentialProperties = [this.GetWatermarkField(objectName), 'createdate'];
|
|
3064
|
+
if (requestedFields && requestedFields.length > 0) {
|
|
3065
|
+
const merged = new Set([...requestedFields, ...essentialProperties]);
|
|
3066
|
+
return [...merged];
|
|
3067
|
+
}
|
|
3068
|
+
return this.GetObjectFieldNames(objectName);
|
|
3069
|
+
}
|
|
3070
|
+
/**
|
|
3071
|
+
* Builds the `properties` query parameter for a HubSpot object type.
|
|
3072
|
+
* Accepts optional `requestedFields` from FetchContext to include custom-mapped properties.
|
|
3073
|
+
* Returns empty string if no properties are configured for the object.
|
|
3074
|
+
*/
|
|
3075
|
+
BuildPropertiesParam(objectName, requestedFields) {
|
|
3076
|
+
const properties = this.BuildEffectiveProperties(objectName, requestedFields);
|
|
3077
|
+
if (properties.length > 0) {
|
|
3078
|
+
return `&properties=${properties.join(',')}`;
|
|
3079
|
+
}
|
|
3080
|
+
return '';
|
|
3081
|
+
}
|
|
3082
|
+
// ─── HTTP helpers ────────────────────────────────────────────────
|
|
3083
|
+
/** Executes an HTTP request with a timeout and optional JSON body. */
|
|
3084
|
+
async FetchWithTimeout(url, method, headers, body) {
|
|
3085
|
+
const controller = new AbortController();
|
|
3086
|
+
const timeoutMs = this.effectiveRequestTimeoutMs;
|
|
3087
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
3088
|
+
try {
|
|
3089
|
+
const requestInit = { method, headers, signal: controller.signal };
|
|
3090
|
+
if (body !== undefined) {
|
|
3091
|
+
requestInit.body = JSON.stringify(body);
|
|
3092
|
+
requestInit.headers['Content-Type'] = 'application/json';
|
|
3093
|
+
}
|
|
3094
|
+
return await fetch(url, requestInit);
|
|
3095
|
+
}
|
|
3096
|
+
catch (err) {
|
|
3097
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
3098
|
+
throw new Error(`HubSpot API request timed out after ${timeoutMs / 1000}s: ${url}`);
|
|
3099
|
+
}
|
|
3100
|
+
throw err;
|
|
3101
|
+
}
|
|
3102
|
+
finally {
|
|
3103
|
+
clearTimeout(timeoutId);
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
/** Calculates retry delay from Retry-After header or exponential backoff. */
|
|
3107
|
+
CalculateRetryDelay(response, attempt) {
|
|
3108
|
+
const retryAfter = parseInt(response.headers.get('Retry-After') ?? '0', 10);
|
|
3109
|
+
return retryAfter > 0 ? retryAfter * 1000 : Math.min(1000 * Math.pow(2, attempt), 30000);
|
|
3110
|
+
}
|
|
3111
|
+
/** Converts a fetch Response + parsed body into a RESTResponse. */
|
|
3112
|
+
BuildRESTResponse(response, body) {
|
|
3113
|
+
const headers = {};
|
|
3114
|
+
response.headers.forEach((v, k) => { headers[k.toLowerCase()] = v; });
|
|
3115
|
+
return { Status: response.status, Body: body, Headers: headers };
|
|
3116
|
+
}
|
|
3117
|
+
/** Returns a promise that resolves after the specified number of milliseconds. */
|
|
3118
|
+
Sleep(ms) {
|
|
3119
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
3120
|
+
}
|
|
3121
|
+
};
|
|
3122
|
+
HubSpotConnector = HubSpotConnector_1 = __decorate([
|
|
3123
|
+
RegisterClass(BaseIntegrationConnector, 'HubSpotConnector')
|
|
3124
|
+
], HubSpotConnector);
|
|
3125
|
+
export { HubSpotConnector };
|
|
3126
|
+
/**
|
|
3127
|
+
* Minimal bounded promise-pool: runs `worker` over `items` with at most `limit` in flight.
|
|
3128
|
+
* (The sample-union augmentation brings its own tiny pool — local plumbing, NOT a shared framework artifact.)
|
|
3129
|
+
*/
|
|
3130
|
+
async function runBounded(items, limit, worker) {
|
|
3131
|
+
const queue = [...items];
|
|
3132
|
+
const size = Math.max(1, Math.min(limit, queue.length));
|
|
3133
|
+
const runners = Array.from({ length: size }, async () => {
|
|
3134
|
+
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
|
|
3135
|
+
await worker(next);
|
|
3136
|
+
}
|
|
3137
|
+
});
|
|
3138
|
+
await Promise.all(runners);
|
|
3139
|
+
}
|
|
3140
|
+
//# sourceMappingURL=HubSpotConnector.js.map
|