feat(hubspot): bidirectional HubSpot CRM integration#69
Conversation
Adds PostgreSQL as a first-class data source type alongside PostHog. Users can connect multiple Postgres databases per workspace, with credentials stored securely (AES-256-GCM encrypted passwords) and read-only agent tools for the ML/agentic backend. Key components: - Migration 025: data_sources table with RLS, indexes, and integration_definitions seed - Core library: connection string parser, SQL query validator, SSRF + DNS validation, postgres.js client with read-only enforcement and concurrency semaphore - API routes: CRUD at /api/data-sources, 6 agent tools at /api/agent/pg/* - Frontend: setup wizard step, settings section, API client + React Query hooks - MCP server: 6 new tools (pg_list_schemas, pg_list_tables, pg_list_columns, pg_query, pg_explain, pg_stats) - 86 unit tests for connection string parsing, SQL validation, and SSRF Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…rktrees The Postgres data source connector added a third parallel query to the definitions route. Update the test mock to handle the data_sources chain and exclude .claude/worktrees/** from vitest to prevent phantom failures from stale worktree node_modules. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add migration 026 with hubspot_connections, hubspot_sync_state, hubspot_records, and hubspot_associations tables with full RLS policies, indexes, and update triggers. Seed HubSpot into integration_definitions and add 'hubspot' to supported integrations list. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- types.ts: HubSpot API response types (contacts, companies, deals, tickets), search types, connection config, sync state, rate limit config, and error classes - auth.ts: OAuth URL builder, token exchange, token refresh, Private App token validation, and token expiry check - config.ts: Encrypted credential retrieval (user + admin variants), connection resolution, API constants, rate limit and sync config - index.ts: Module barrel exports Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- oauth/authorize: Initiates OAuth flow, creates pending connection with CSRF state, redirects to HubSpot - oauth/callback: Exchanges auth code for tokens, encrypts and stores them, fetches account info, updates connection status - validate: POST validates Private App token format, tests connection, encrypts and stores token. PATCH updates connection config. Rate limited to 10 requests/min per IP. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- GET /connections: List all workspace connections (safe columns only) - POST /connections: Create a pending connection manually - GET /connections/[id]: Fetch single connection details - DELETE /connections/[id]: Remove connection (cascades to sync state, records, and associations) All routes enforce workspace isolation via authenticated user membership. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- auth.test.ts: 18 tests covering getAuthorizeUrl, exchangeCodeForTokens, refreshAccessToken, validatePrivateAppToken, and isTokenExpired - callback/route.test.ts: 8 tests covering missing params, invalid state, HubSpot errors, auth failures, and token exchange failures - validate/route.test.ts: 12 tests covering valid tokens, format errors, connection failures, auth checks, and PATCH config updates All 38 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- client.ts: Full HubSpot CRM API client with OAuth and Private App auth, automatic token refresh, rate limiting, retry logic with exponential backoff. Methods for contacts, companies, deals, tickets, custom objects, engagements, search, associations, schemas, properties, and owners. - rate-limiter.ts: Token bucket algorithm per connection with priority queue (high/normal/low). Conservative 50% capacity, 429 pause/recovery, dynamic adjustment from response headers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- polling.ts: Sync engine with incremental sync (lastmodifieddate watermark), 90-day backfill for first sync, sync locks to prevent concurrent syncs, upserts to hubspot_records cache table, and sync state tracking. - cron/hubspot-sync/route.ts: POST endpoint authenticated via CRON_SECRET, iterates all active connections, respects 5-minute Vercel execution limit with time tracking. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- search/route.ts: GET endpoint proxying to HubSpot Search API with heuristic query detection (@ for email, else full-text search) - objects/route.ts: GET endpoint listing CRM object schemas - properties/route.ts: GET lists properties for an object type, POST creates new properties All routes verify connection ownership via workspace membership. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…search - client.test.ts: 18 tests covering constructor validation, connection testing, CRUD operations, search, retry logic on 429/500, auth errors, and not-found errors - rate-limiter.test.ts: 11 tests covering token bucket acquisition, per-connection buckets, 429 pause/recovery, priority allocation, header parsing, and reset functions - hubspot-sync/route.test.ts: 6 tests covering cron auth, empty/active connections, error handling, summary output, and DB failures - search/route.test.ts: 8 tests covering auth, validation, full-text search, email heuristic (CONTAINS_TOKEN), and object type selection All 81 tests passing across 7 test files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…PI routes Adds HubSpot destination capabilities (Phase 3): - Entity operations: upsertCompany, upsertContact, createDeal, batchCreateChain - Association management with v4 API and batch support (max 2000) - Auto-create beton_* custom properties (ensureBetonProperties) - API routes: entities, associations, lists, mappings, sample-data - Client extensions: createRecord, updateRecord, createAssociationV4, lists Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… wizard Adds HubSpot setup wizard step with: - Auth method selector (OAuth vs Private App Token) - Token input with validation (must start with pat-) - Object type checkboxes (contacts, companies, deals, tickets) - Connection preview with portal name, auth type, and object badges - Wired into SetupWizard with state management and step handlers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…config Adds: - HubSpotFieldMappingStep: maps Beton fields to HubSpot properties with auto-create beton_* properties button and auto-matching - HubSpotConnectionsSection: settings page section listing HubSpot connections with test/delete actions - Settings page: adds HubSpot field config and connections section Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds: - HubSpotEntityCreator: checkbox-based entity creation with company -> contact -> deal chain, existence checking, phase tracking, and deep links to HubSpot records - HubSpotEntityChip: pill badge with HubSpot icon linking to entity pages in HubSpot CRM, with linked/unlinked visual states Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ities route 36 new tests: - entities.test.ts (HS-U13 to HS-U22): upsert company/contact, create deal, batch chain, partial failure, custom properties, URL building - associations.test.ts (HS-U23 to HS-U28): single/batch associations, auto-resolve type IDs, empty array handling - entities/route.test.ts (HS-I01 to HS-I08): single creation, batch chain, auth errors, missing data validation, partial failure Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add 11 agent routes under /api/agent/hubspot/* for MCP server and agent access to HubSpot CRM. Each route follows existing agent patterns with validateAgentRequest, resolveSession, rate limiting, and admin credential resolution. Also adds resolveHubSpotConnectionAdmin helper that bypasses RLS for server-side use. Routes: search, records (GET/POST), records/batch, entities, associations, associations/batch, objects, properties (GET/POST), lists (GET/POST), sync (GET/POST), connections. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
29 tests covering all HubSpot agent endpoints: - Search: auth, filters, auto-connection, cross-workspace isolation - Batch records: create, upsert, limit enforcement, partial failure - Entities: chain creation, partial chain, admin credentials - Associations: batch create, limit, mixed types - Sync: trigger, conflict detection, status retrieval Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Register hubspot_search, hubspot_read_record, hubspot_create_record, hubspot_batch_create, hubspot_create_entity_chain, hubspot_associate, hubspot_batch_associate, hubspot_create_list, hubspot_list_objects, hubspot_list_properties, hubspot_create_property, hubspot_trigger_sync, hubspot_sync_status, and hubspot_list_connections as MCP tools. Each tool proxies to the agent API with x-agent-secret auth. Also adds extraHeaders support to the proxy callApi function. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add HubSpot CRM signal detectors: - hubspot_deal_stage_change (expansion, weight 18) - hubspot_new_deal (expansion, weight 22) - hubspot_lifecycle_change (expansion, weight 15) - hubspot_meeting_booked (expansion, weight 12) - hubspot_ticket_created (churn_risk, weight -14) All detectors query the hubspot_records sync table and follow the existing SignalDetectorDefinition pattern. Scoring config updated with weights. Total detectors: 25 (up from 20). Also re-exports client, polling, and rate-limiter from hubspot/index.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add HubSpot sync cron job (every 15 min) to vercel.json
- Add HubSpot connection status to /api/workspace/setup-status (optional,
does not block setup completion)
- Add auth_type and portal_id to trackIntegrationConnected call
- Add trackOnboardingStepSkipped('hubspot') to skip button
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
HubSpot CRM Integration — Product DocumentationBranch: OverviewBidirectional HubSpot CRM integration for Beton Inspector — pulls CRM records via polling sync (source) and pushes scored entities back as HubSpot records (destination). Supports OAuth 2.0 + Private App token authentication, multiple portal connections per workspace, and full programmatic access via Agent API and MCP tools. Key Capabilities
ArchitectureDatabase Schema
|
| Column | Type | Notes |
|---|---|---|
| id | uuid | PK |
| workspace_id | uuid | FK workspaces, RLS-enforced |
| name | text | User-given label, UNIQUE(workspace_id, name) |
| auth_type | text | 'oauth' or 'private_app' |
| access_token_encrypted | text | AES-256-GCM encrypted |
| refresh_token_encrypted | text | OAuth only |
| private_app_token_encrypted | text | Private App only |
| token_expires_at | timestamptz | OAuth expiry |
| hub_id | text | HubSpot portal ID, UNIQUE(workspace_id, hub_id) |
| hub_domain | text | Portal domain |
| status | text | 'connected', 'error', 'pending' |
| is_active | boolean | Enable/disable toggle |
| is_primary | boolean | Auto-select target |
| enabled_objects | jsonb | Array of object types to sync |
| config | jsonb | Extra settings |
hubspot_sync_state
Per-connection, per-object-type sync tracking.
| Column | Type | Notes |
|---|---|---|
| connection_id | uuid | FK hubspot_connections |
| object_type | text | 'contacts', 'companies', etc. |
| last_sync_at | timestamptz | Watermark for incremental sync |
| last_sync_cursor | text | Pagination cursor |
| sync_lock_id | uuid | Prevents concurrent syncs |
| sync_lock_expires_at | timestamptz | 10-min lock timeout |
| records_synced | integer | Running count |
| status | text | 'idle', 'syncing', 'error' |
hubspot_records
Synced CRM data cache with JSONB properties and GIN index.
| Column | Type | Notes |
|---|---|---|
| connection_id | uuid | FK hubspot_connections |
| workspace_id | uuid | Denormalized for RLS |
| hubspot_id | text | HubSpot record ID |
| object_type | text | CRM object type |
| properties | jsonb | Full record data, GIN indexed |
| hubspot_created_at | timestamptz | Original creation time |
| hubspot_updated_at | timestamptz | Last modified in HubSpot |
| synced_at | timestamptz | When Beton last synced this |
hubspot_associations
Cached relationship graph between CRM objects.
| Column | Type | Notes |
|---|---|---|
| connection_id | uuid | FK hubspot_connections |
| from_object_type / from_hubspot_id | text | Source object |
| to_object_type / to_hubspot_id | text | Target object |
| association_type | text | HubSpot type ID |
| association_label | text | Human-readable label |
Security: All tables have RLS policies via get_user_workspaces(). Service role bypasses for cron/agent operations.
API Reference
User-Facing Routes (/api/integrations/hubspot/*)
| Endpoint | Method | Purpose |
|---|---|---|
/oauth/authorize |
GET | Generate state, redirect to HubSpot OAuth |
/oauth/callback |
GET | Exchange code for tokens, encrypt, store |
/validate |
POST | Validate Private App token, test connection |
/validate |
PATCH | Update connection config |
/connections |
GET | List workspace connections |
/connections |
POST | Create connection |
/connections/[id] |
GET | Single connection detail |
/connections/[id] |
DELETE | Remove connection |
/search |
GET | Proxy to HubSpot Search API |
/objects |
GET | List CRM object types |
/properties |
GET | List properties for object type |
/properties |
POST | Create custom property |
/entities |
POST | Batch chain: company→contact→deal |
/associations |
POST | Create single association |
/lists |
POST | Create/manage static lists |
/mappings |
GET/PUT | Field mapping CRUD |
/sample-data |
GET | Sample Beton data for mapping preview |
Auth: Supabase session cookie (RLS-protected)
Agent API Routes (/api/agent/hubspot/*)
| Endpoint | Method | Purpose |
|---|---|---|
/search |
POST | Search CRM objects with filters |
/records |
GET | Get single record by ID |
/records |
POST | Create/upsert record |
/records/batch |
POST | Batch create (max 100) |
/entities |
POST | Chain create with associations |
/associations |
POST | Single association |
/associations/batch |
POST | Batch associations (max 2000) |
/objects |
GET | List object schemas |
/properties |
GET/POST | List/create properties |
/lists |
POST | Create static list |
/sync |
GET | Sync status per object type |
/sync |
POST | Trigger manual sync |
/connections |
GET | List workspace connections |
Auth: x-agent-secret header + session_id in body/query. All require connection_id (optional — auto-selects primary active connection if omitted).
Cron Route
| Endpoint | Method | Schedule |
|---|---|---|
/api/cron/hubspot-sync |
POST | */15 * * * * |
Auth: Authorization: Bearer <CRON_SECRET>. Max duration 300s. Iterates all active connections sequentially.
Signal Detectors
| Detector | Signal Name | Fires When | Metric |
|---|---|---|---|
hubspot-new-deal |
hubspot_new_deal |
Deal created within window | Deal amount |
hubspot-deal-stage-change |
hubspot_deal_stage_change |
Deal stage updated | 1 (fixed) |
hubspot-ticket-created |
hubspot_ticket_created |
Support ticket opened | 1 (fixed) |
hubspot-lifecycle-change |
hubspot_lifecycle_change |
Contact/company lifecycle stage changes | 1 (fixed) |
hubspot-meeting-booked |
hubspot_meeting_booked |
Meeting engagement created | 1 (fixed) |
All implement SignalDetectorDefinition, auto-registered via detectors/index.ts.
MCP Server Tools
14 tools registered in packages/mcp-server/src/tools/hubspot.ts:
hubspot_search · hubspot_read_record · hubspot_create_record · hubspot_batch_create · hubspot_create_entity_chain · hubspot_associate · hubspot_batch_associate · hubspot_create_list · hubspot_list_objects · hubspot_list_properties · hubspot_create_property · hubspot_trigger_sync · hubspot_sync_status · hubspot_list_connections
All proxy to Agent API routes with session_id + optional connection_id.
UI Components
| Component | Location | Purpose |
|---|---|---|
HubSpotStep |
components/setup/steps/ |
Setup wizard step — OAuth/Private App selector, token input, object type checkboxes |
HubSpotConnectionPreview |
components/setup/previews/ |
Portal name, auth badge, enabled objects summary |
HubSpotFieldMappingStep |
components/hubspot/ |
Beton→HubSpot field mapping with auto-create |
HubSpotEntityCreator |
components/hubspot/ |
Batch entity creation with phase tracking UI |
HubSpotEntityChip |
components/setup/previews/ |
Pill badge linking to HubSpot record |
HubSpotConnectionsSection |
components/settings/ |
Settings page connections list, test/delete actions |
Configuration
Required Environment Variables
| Variable | Purpose |
|---|---|
HUBSPOT_CLIENT_ID |
OAuth app client ID |
HUBSPOT_CLIENT_SECRET |
OAuth app client secret |
ENCRYPTION_KEY |
64-hex-char AES-256-GCM key for credential storage |
Existing Variables Used
| Variable | Purpose |
|---|---|
AGENT_SECRET |
Agent API authentication |
CRON_SECRET |
Cron job authentication |
NEXT_PUBLIC_SUPABASE_URL |
Database connection |
SUPABASE_SERVICE_ROLE_KEY |
Admin database operations |
Rate Limit Defaults
- OAuth: 55 req/10s (50% of 110 limit)
- Private App: 50 req/10s (50% of 100 limit)
- Daily OAuth: 200,000 (80% of 250,000)
- Retry: 3 attempts, 1000ms base exponential backoff
Known Limitations
- No webhook support — Uses polling (every 15 minutes), not real-time webhooks
- Custom objects read-only — Sync supported but no custom object creation via entity chain
- OAuth requires HubSpot Developer App — Must register app at developers.hubspot.com
- Search API limits — HubSpot caps search at 5 req/s and 10,000 results per query
- Batch size caps — Records: 100 per batch, Associations: 2,000 per batch
- Sequential sync — Object types sync one-at-a-time per connection (not parallel)
- No field mapping sync — Mapping config stored but not yet wired into polling engine
End-to-End Testing Protocol — HubSpot IntegrationThis protocol covers all testable paths once a live HubSpot portal is connected via Chrome extension or browser. Designed for dispatching Claude with browser automation. PrerequisitesEnvironment Setup# Required env vars in .env.local or Vercel
HUBSPOT_CLIENT_ID=<hubspot-app-client-id>
HUBSPOT_CLIENT_SECRET=<hubspot-app-client-secret>
ENCRYPTION_KEY=<64-hex-char-key> # already set
AGENT_SECRET=<agent-secret> # already set
CRON_SECRET=<cron-secret>
NEXT_PUBLIC_SUPABASE_URL=<supabase-url> # already set
SUPABASE_SERVICE_ROLE_KEY=<service-role-key>HubSpot Developer App Setup
Database# Apply migration 026 to staging Supabase
npx supabase db push --linkedPhase 1: Authentication Flow (Browser)T1.1 — OAuth Flow
T1.2 — Private App Token Flow
T1.3 — Connection Management
Phase 2: Sync & Data Pull (API)T2.1 — Manual Sync Trigger# Trigger sync via agent API
curl -X POST http://localhost:3000/api/agent/hubspot/sync \
-H "Content-Type: application/json" \
-H "x-agent-secret: $AGENT_SECRET" \
-d '{"session_id": "<session>", "object_types": ["contacts", "companies"]}'
T2.2 — Sync Status Checkcurl "http://localhost:3000/api/agent/hubspot/sync?session_id=<session>" \
-H "x-agent-secret: $AGENT_SECRET"
T2.3 — Cron Synccurl -X POST http://localhost:3000/api/cron/hubspot-sync \
-H "Authorization: Bearer $CRON_SECRET"
Phase 3: Search & Read (API)T3.1 — Search Contactscurl -X POST http://localhost:3000/api/agent/hubspot/search \
-H "Content-Type: application/json" \
-H "x-agent-secret: $AGENT_SECRET" \
-d '{
"session_id": "<session>",
"object_type": "contacts",
"filters": [{"propertyName": "email", "operator": "CONTAINS_TOKEN", "value": "@example.com"}],
"properties": ["firstname", "lastname", "email"],
"limit": 10
}'
T3.2 — Read Single Recordcurl "http://localhost:3000/api/agent/hubspot/records?session_id=<session>&object_type=contacts&record_id=<id>" \
-H "x-agent-secret: $AGENT_SECRET"
T3.3 — List Objects & Properties# List object types
curl "http://localhost:3000/api/agent/hubspot/objects?session_id=<session>" \
-H "x-agent-secret: $AGENT_SECRET"
# List properties for contacts
curl "http://localhost:3000/api/agent/hubspot/properties?session_id=<session>&object_type=contacts" \
-H "x-agent-secret: $AGENT_SECRET"
Phase 4: Write Operations (API)T4.1 — Create Single Recordcurl -X POST http://localhost:3000/api/agent/hubspot/records \
-H "Content-Type: application/json" \
-H "x-agent-secret: $AGENT_SECRET" \
-d '{
"session_id": "<session>",
"object_type": "companies",
"properties": {"name": "Beton Test Corp", "domain": "betontest.com"}
}'
T4.2 — Upsert Record (Dedup by Domain)curl -X POST http://localhost:3000/api/agent/hubspot/records \
-H "Content-Type: application/json" \
-H "x-agent-secret: $AGENT_SECRET" \
-d '{
"session_id": "<session>",
"object_type": "companies",
"properties": {"name": "Beton Test Corp Updated", "domain": "betontest.com"},
"matching_property": "domain"
}'
T4.3 — Batch Createcurl -X POST http://localhost:3000/api/agent/hubspot/records/batch \
-H "Content-Type: application/json" \
-H "x-agent-secret: $AGENT_SECRET" \
-d '{
"session_id": "<session>",
"object_type": "contacts",
"records": [
{"properties": {"firstname": "Alice", "lastname": "Test", "email": "alice@betontest.com"}},
{"properties": {"firstname": "Bob", "lastname": "Test", "email": "bob@betontest.com"}}
]
}'
T4.4 — Entity Chain (Company → Contact → Deal)curl -X POST http://localhost:3000/api/agent/hubspot/entities \
-H "Content-Type: application/json" \
-H "x-agent-secret: $AGENT_SECRET" \
-d '{
"session_id": "<session>",
"create_company": true,
"company_data": {"name": "Chain Test Corp", "domain": "chaintest.com"},
"create_contact": true,
"contact_data": {"firstname": "Chain", "lastname": "Contact", "email": "chain@chaintest.com"},
"create_deal": true,
"deal_data": {"dealname": "Chain Deal", "amount": "50000", "pipeline": "default"}
}'
Phase 5: Associations (API)T5.1 — Single Associationcurl -X POST http://localhost:3000/api/agent/hubspot/associations \
-H "Content-Type: application/json" \
-H "x-agent-secret: $AGENT_SECRET" \
-d '{
"session_id": "<session>",
"from_object_type": "contacts",
"from_object_id": "<contact_id>",
"to_object_type": "companies",
"to_object_id": "<company_id>"
}'
T5.2 — Batch Associationscurl -X POST http://localhost:3000/api/agent/hubspot/associations/batch \
-H "Content-Type: application/json" \
-H "x-agent-secret: $AGENT_SECRET" \
-d '{
"session_id": "<session>",
"associations": [
{"from_type": "deals", "from_id": "<deal1>", "to_type": "companies", "to_id": "<company1>"},
{"from_type": "deals", "from_id": "<deal1>", "to_type": "contacts", "to_id": "<contact1>"}
]
}'
Phase 6: Custom Properties (API)T6.1 — Create Beton Propertiescurl -X POST http://localhost:3000/api/agent/hubspot/properties \
-H "Content-Type: application/json" \
-H "x-agent-secret: $AGENT_SECRET" \
-d '{
"session_id": "<session>",
"object_type": "companies",
"name": "beton_test_score",
"label": "Beton Test Score",
"type": "number",
"field_type": "number",
"group_name": "beton_scores",
"description": "Test score from Beton Inspector"
}'
Phase 7: UI Verification (Browser)T7.1 — Setup Wizard Flow
T7.2 — Settings Page
T7.3 — Field Mapping (if wired)
Phase 8: Signal Detection (After Sync)T8.1 — Verify Signal Detectors FireAfter syncing data, run signal detection: curl -X POST http://localhost:3000/api/cron/signal-detection \
-H "Authorization: Bearer $CRON_SECRET"
Phase 9: Error HandlingT9.1 — Invalid Token
T9.2 — Rate Limiting
T9.3 — Network Errors
CleanupAfter testing, clean up test data: # Delete test records from HubSpot via API or HubSpot UI
# Remove test connections from Beton settings
# Clear hubspot_records and hubspot_sync_state for test connectionsMCP Tool Smoke Tests (via Claude/Agent)For each of the 14 MCP tools, verify basic functionality:
|
Preparing for isolated worktree-based development workflow. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
nadyyym
left a comment
There was a problem hiding this comment.
Code Review
Build and Tests
- Build: PASSES
- Tests: All 146 HubSpot tests pass
Critical
C1. Association arguments swapped in batchCreateChain() (src/lib/integrations/hubspot/entities.ts:441,450)
createAssociation(client, fromType, fromId, toType, toId) is called with swapped IDs:
// Line 441: intends deal->company, but passes deals/{companyId} -> companies/{dealId}
await createAssociation(client, 'deals', companyId, 'companies', dealResult.recordId)
// Line 450: same issue for deal->contact
await createAssociation(client, 'deals', contactId, 'contacts', dealResult.recordId)Fix:
await createAssociation(client, 'deals', dealResult.recordId, 'companies', companyId)
await createAssociation(client, 'deals', dealResult.recordId, 'contacts', contactId)This is a data integrity bug -- will create wrong associations in production HubSpot accounts.
Important
I2. PR description says "PKCE" but implementation uses standard code+secret.
Not a code bug -- HubSpot server-side OAuth does not support PKCE. But the PR description should be corrected to avoid confusion.
I3. Signal detectors do not filter by account (all 5 detectors in src/lib/heuristics/signals/detectors/hubspot-*.ts)
All detectors query hubspot_records filtered only by workspace_id and object_type, never joining against the accountId being evaluated. The domain is fetched but never used to filter deals. Result: Every account in a workspace gets identical signals for any deal change. This produces mass false positives.
Fix: Join HubSpot records to accounts via domain/email/company associations, or filter by an account-specific identifier.
I4. meeting_booked detector queries object_type = 'meetings' but meetings are not in STANDARD_OBJECT_TYPES (contacts, companies, deals, tickets). Unless manually added to enabled_objects, this detector will never fire.
I5. Sync lock race condition (polling.ts:66-128)
TOCTOU in acquireSyncLock(): separate select + update allows two processes to both read an expired lock and both take it. Fix: Single atomic UPDATE ... WHERE sync_lock_expires_at < NOW() RETURNING sync_lock_id.
I6. Agent API routes use manual validation instead of Zod
All 11 agent routes do if (!field) checks while MCP tools use Zod schemas. Inconsistent validation means direct agent API calls accept any shape.
I7. GET /api/agent/hubspot/lists is a stub -- returns { message: 'HubSpot lists endpoint active' } instead of actual data.
Minor
stripNullsutility duplicated insearch/route.tsandrecords/route.ts- ~25
eslint-disablecomments for@typescript-eslint/no-explicit-any(expected until types regenerated) is_primary: trueset unconditionally in OAuth callback without clearing previous primary- Rate limiter uses in-memory state -- not shared across Vercel serverless instances (mitigated by 50% capacity)
Positive
- Excellent migration 026 -- all 4 tables have RLS, GIN index on
hubspot_records.properties, proper CASCADE deletes, no collision with migration 025 - Strong security -- OAuth tokens encrypted at rest (AES-256-GCM), refresh tokens excluded from API responses, all 11 agent routes validate
x-agent-secret, CRON_SECRET verified, OAuth state parameter validated for CSRF prevention - Thoughtful rate limiter -- token bucket at 50% capacity, priority-based allocation (60/30/10), per-connection state, graceful 429 recovery, Retry-After parsing
- Correct token refresh --
isTokenExpired()uses 5-minute buffer, refreshes BEFORE expiry - Solid sync engine -- incremental via
lastmodifieddatewatermark,last_sync_atupdated only AFTER success, errors do not advance cursor - Excellent test coverage -- 14 test files with mock isolation, 146 test cases
- MCP tools are proper thin proxies -- all 14 delegate to agent API, no direct HubSpot calls
Security Checklist
- OAuth tokens encrypted at rest (AES-256-GCM)
- Refresh tokens not in logs or API responses
- All 11 agent routes require
x-agent-secret - No HubSpot API keys in client-side code
- Rate limiter prevents API abuse
- Cron authenticates via
CRON_SECRET - OAuth state parameter validated (CSRF protection)
Verdict
Fix two issues before merge:
- Swap association arguments in
entities.ts:441,450(2-line fix) - Add account-level filtering to all 5 signal detectors (functional correctness)
Then resolve merge conflicts with staging and rename 026_hubspot_integration.sql to 027_hubspot_integration.sql (since PR #66 migration will take the 026 slot).
After those fixes, this is a high-quality PR ready for staging.
Review by Claude Code
The createAssociation calls had fromId and toId swapped - passing companyId/contactId as the deal fromId instead of dealResult.recordId. This caused HubSpot associations to be created in the wrong direction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test Report: PR #69 -- HubSpot IntegrationTested by: Claude Code (automated E2E) Phase 1: Build & Unit Tests
Phase 2: Bug FixesC1: Association arguments swapped -- FIXED
I3: Signal detectors dont filter by account -- CONFIRMED, NOT FIXEDAll 5 I4:
|
| Area | Status |
|---|---|
| Build / Tests / Lint | All green |
| C1: Association swap | Fixed (158fe68) |
| I3: Detector account filtering | Confirmed -- needs fix |
| I4: Meetings object type | Confirmed -- silent no-op |
| I7: Lists GET stub | Acceptable for now |
| Route smoke tests (48 total) | 48/48 pass |
| UI rendering | No crashes |
| DB migration | Schema validated |
Test Report: PR #69 -- HubSpot Integration (Updated)Tested by: Claude Code (automated E2E) Phase 1: Build & Unit Tests PASS
Phase 2: Bug FixesC1: Association arguments swapped -- FIXED I3: Signal detectors dont filter by account -- CONFIRMED, NOT FIXED I4: I7: GET Phase 3: Smoke Tests PASSCurl -- 24/24 localhost, 24/24 Vercel preview (48 total):
Chrome UI -- PASS (with migration applied):
Phase 4: Database Migration PASSMigration
Phase 5: Vercel Preview PASSAll 24 routes return correct status codes on deployed preview. UI renders correctly with authenticated session after migration applied. HubSpot Developer AppCreated "Beton Inspector" app (ID: OAuth E2E Flow -- NOT TESTEDBlocked on: HubSpot app needs remaining 6 OAuth scopes added, and a new Vercel preview deploy is needed to pick up the env vars. The Private App Token flow is testable via the CONFIGURE button now visible in the UI. Summary
Recommendation: Fix I3 (signal detector account filtering) before merge -- it is a correctness issue that will generate false positive signals for all accounts in a workspace. |
All 5 HubSpot signal detectors were querying hubspot_records by workspace_id + object_type only, causing every account in a workspace to receive signals for ANY HubSpot activity. Now each detector: 1. Fetches the account domain from the accounts table 2. Finds matching HubSpot company records via JSONB domain containment 3. Resolves associated record IDs via hubspot_associations (both directions) 4. Filters hubspot_records by those IDs Added getHubSpotIdsForAccount() shared helper to signals/helpers.ts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
I3 Fix Applied: Signal Detector Account FilteringCommit What changed (6 files, +121/-11 lines):
How it works:
Build passes, 783/783 tests pass. |
I3 Fix Applied: Signal Detector Account FilteringCommit What changed (6 files, +121/-11 lines):
How it works:
Build passes, 783/783 tests pass. |
|
Closing as superseded by the E5 connector framework (#90) + HubSpot port (#91). This branch (feature/BETON-hubspot-foundation) predates the field-mapping/DestinationAdapter framework — it's 124 commits behind staging, currently has 12 merge conflicts, and ships the dropped architecture (polling.ts + hubspot_connections/sync_state/records tables). Its 026_hubspot_integration.sql also collides with #91's integration_configs registry migration. The capability here (incremental sync) will be rebuilt on the SourceAdapter framework rather than ported. See docs/prd-hubspot-pr3-usability.md (WS4). |
Summary
hubspot_connectionstableArchitecture
Database (Migration 026)
hubspot_connections- Multi-instance, workspace-scoped, OAuth + PAT fieldshubspot_sync_state- Per-connection per-object-type sync trackinghubspot_records- JSONB property cache with GIN indexhubspot_associations- Association cacheKey Design Decisions
hubspot_connectionstable over reusingdata_sources- HubSpot needs OAuth-specific fields (refresh_token, expires_at)X-HubSpot-RateLimit-*headersconnection_idis omittedlastmodifieddate > last_sync_atfilter, 90-day backfill on first syncFiles Changed
src/lib/integrations/hubspot/(8 modules)packages/mcp-server/src/lib/heuristics/signals/detectors/Test Plan
npm run build)make test)x-agent-secretheaderCRON_SECRET