diff --git a/.gitignore b/.gitignore index 8ea01094..c5a2ac33 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,9 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* +# Git worktrees +.worktrees/ + # Misc *.pem *.log diff --git a/package-lock.json b/package-lock.json index dde5eeba..8d69e754 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.562.0", "next": "16.1.1", + "postgres": "^3.4.8", "react": "19.2.3", "react-dom": "19.2.3", "recharts": "^3.6.0", @@ -8420,6 +8421,19 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.8.tgz", + "integrity": "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index ba2d1e82..7d46bac0 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.562.0", "next": "16.1.1", + "postgres": "^3.4.8", "react": "19.2.3", "react-dom": "19.2.3", "recharts": "^3.6.0", diff --git a/packages/mcp-server/src/lib/proxy.ts b/packages/mcp-server/src/lib/proxy.ts index 3375e9bb..75d753da 100644 --- a/packages/mcp-server/src/lib/proxy.ts +++ b/packages/mcp-server/src/lib/proxy.ts @@ -50,6 +50,8 @@ export async function callApi( params?: Record /** Tool name for request logging. If set, the invocation is logged. */ toolName?: string + /** Additional headers to include in the request */ + extraHeaders?: Record } ): Promise<{ data: unknown; status: number }> { // M18 fix: Validate path against allowlist @@ -77,6 +79,11 @@ export async function callApi( headers['Authorization'] = authHeader } + // Merge extra headers (e.g., x-agent-secret for agent routes) + if (options?.extraHeaders) { + Object.assign(headers, options.extraHeaders) + } + // M16 fix: AbortController with 30s timeout const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index c6cf73ab..ceb667d1 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -14,6 +14,8 @@ import { registerJoinsTools } from './tools/joins.js' import { registerMappingTools } from './tools/mapping.js' import { registerBillingTools } from './tools/billing.js' import { registerWorkspaceTools } from './tools/workspace.js' +import { registerPostgresTools } from './tools/postgres.js' +import { registerHubSpotTools } from './tools/hubspot.js' /** * Create a fully configured MCP server with all tools. @@ -36,6 +38,8 @@ export function createMcpServer( registerMappingTools(server, getAuthHeader) registerBillingTools(server, getAuthHeader) registerWorkspaceTools(server, getAuthHeader) + registerPostgresTools(server, getAuthHeader) + registerHubSpotTools(server, getAuthHeader) return server } diff --git a/packages/mcp-server/src/tools/hubspot.ts b/packages/mcp-server/src/tools/hubspot.ts new file mode 100644 index 00000000..864df647 --- /dev/null +++ b/packages/mcp-server/src/tools/hubspot.ts @@ -0,0 +1,417 @@ +/** + * HubSpot CRM tools (14 tools) — thin proxy to /api/agent/hubspot/* + * + * All tools require a session_id and use x-agent-secret authentication. + * The AGENT_SECRET env var must be set for these tools to work. + */ + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { callApi } from '../lib/proxy.js' +import { httpErrorToMcp, toMcpError } from '../lib/errors.js' + +const AGENT_SECRET = process.env.AGENT_SECRET || '' + +/** Common params for HubSpot tools */ +const sessionParams = { + session_id: z.string().describe('Agent session ID'), + connection_id: z.string().optional().describe('Specific HubSpot connection UUID (auto-selects if omitted)'), +} + +/** Build extra headers for agent authentication */ +function agentHeaders(): Record { + return AGENT_SECRET ? { 'x-agent-secret': AGENT_SECRET } : {} +} + +/** Standard success response */ +function okResponse(data: unknown) { + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] } +} + +export function registerHubSpotTools( + server: McpServer, + _getAuthHeader: () => string | undefined, +): void { + // ── hubspot_search ────────────────────────────────────────────────── + server.tool( + 'hubspot_search', + 'Search HubSpot CRM objects with filters, properties, and pagination. Supports contacts, companies, deals, tickets.', + { + ...sessionParams, + object_type: z.string().describe('CRM object type (contacts, companies, deals, tickets)'), + filters: z.array(z.object({ + filters: z.array(z.object({ + propertyName: z.string(), + operator: z.string(), + value: z.string().optional(), + values: z.array(z.string()).optional(), + })), + })).optional().describe('HubSpot filter groups'), + properties: z.array(z.string()).optional().describe('Properties to return'), + limit: z.number().int().min(1).max(100).optional().describe('Max results (default 10)'), + after: z.string().optional().describe('Pagination cursor'), + }, + async (args) => { + try { + const { data, status } = await callApi('/api/agent/hubspot/search', undefined, { + method: 'POST', + body: args, + toolName: 'hubspot_search', + extraHeaders: agentHeaders(), + }) + if (status !== 200) return httpErrorToMcp(data, status) + return okResponse(data) + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── hubspot_read_record ───────────────────────────────────────────── + server.tool( + 'hubspot_read_record', + 'Get a single HubSpot CRM record by ID', + { + ...sessionParams, + object_type: z.string().describe('CRM object type'), + record_id: z.string().describe('HubSpot record ID'), + }, + async (args) => { + try { + const params: Record = { + session_id: args.session_id, + object_type: args.object_type, + record_id: args.record_id, + } + if (args.connection_id) params.connection_id = args.connection_id + + const { data, status } = await callApi('/api/agent/hubspot/records', undefined, { + params, + toolName: 'hubspot_read_record', + extraHeaders: agentHeaders(), + }) + if (status !== 200) return httpErrorToMcp(data, status) + return okResponse(data) + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── hubspot_create_record ─────────────────────────────────────────── + server.tool( + 'hubspot_create_record', + 'Create or upsert a single HubSpot CRM record. Provide matching_property for upsert behavior.', + { + ...sessionParams, + object_type: z.string().describe('CRM object type'), + properties: z.record(z.unknown()).describe('Record properties'), + matching_property: z.string().optional().describe('Property to match on for upsert (e.g., email, domain)'), + }, + async (args) => { + try { + const { data, status } = await callApi('/api/agent/hubspot/records', undefined, { + method: 'POST', + body: args, + toolName: 'hubspot_create_record', + extraHeaders: agentHeaders(), + }) + if (status !== 200 && status !== 201) return httpErrorToMcp(data, status) + return okResponse(data) + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── hubspot_batch_create ──────────────────────────────────────────── + server.tool( + 'hubspot_batch_create', + 'Batch create or upsert up to 100 HubSpot CRM records at once', + { + ...sessionParams, + object_type: z.string().describe('CRM object type'), + records: z.array(z.object({ + properties: z.record(z.unknown()).describe('Record properties'), + })).min(1).max(100).describe('Array of records to create'), + matching_property: z.string().optional().describe('Property for upsert matching'), + }, + async (args) => { + try { + const { data, status } = await callApi('/api/agent/hubspot/records/batch', undefined, { + method: 'POST', + body: args, + toolName: 'hubspot_batch_create', + extraHeaders: agentHeaders(), + }) + if (status !== 201 && status !== 207) return httpErrorToMcp(data, status) + return okResponse(data) + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── hubspot_create_entity_chain ───────────────────────────────────── + server.tool( + 'hubspot_create_entity_chain', + 'Create a chain of company -> contact -> deal with automatic associations. Supports partial chains.', + { + ...sessionParams, + create_company: z.boolean().optional().describe('Create/upsert company'), + create_contact: z.boolean().optional().describe('Create/upsert contact'), + create_deal: z.boolean().optional().describe('Create deal'), + company_data: z.record(z.unknown()).optional().describe('Company properties (domain, name, etc.)'), + contact_data: z.record(z.unknown()).optional().describe('Contact properties (email, firstname, etc.)'), + deal_data: z.record(z.unknown()).optional().describe('Deal properties (dealname, amount, etc.)'), + }, + async (args) => { + try { + const { data, status } = await callApi('/api/agent/hubspot/entities', undefined, { + method: 'POST', + body: args, + toolName: 'hubspot_create_entity_chain', + extraHeaders: agentHeaders(), + }) + if (status !== 201 && status !== 207) return httpErrorToMcp(data, status) + return okResponse(data) + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── hubspot_associate ─────────────────────────────────────────────── + server.tool( + 'hubspot_associate', + 'Create a single association between two HubSpot CRM records', + { + ...sessionParams, + from_type: z.string().describe('Source object type (e.g., contacts)'), + from_id: z.string().describe('Source record ID'), + to_type: z.string().describe('Target object type (e.g., companies)'), + to_id: z.string().describe('Target record ID'), + association_type_id: z.number().optional().describe('Explicit association type ID (auto-resolved if omitted)'), + }, + async (args) => { + try { + const { data, status } = await callApi('/api/agent/hubspot/associations', undefined, { + method: 'POST', + body: args, + toolName: 'hubspot_associate', + extraHeaders: agentHeaders(), + }) + if (status !== 200) return httpErrorToMcp(data, status) + return okResponse(data) + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── hubspot_batch_associate ───────────────────────────────────────── + server.tool( + 'hubspot_batch_associate', + 'Batch create up to 2000 associations between HubSpot CRM records', + { + ...sessionParams, + associations: z.array(z.object({ + from_type: z.string(), + from_id: z.string(), + to_type: z.string(), + to_id: z.string(), + association_type_id: z.number().optional(), + })).min(1).max(2000).describe('Array of associations to create'), + }, + async (args) => { + try { + const { data, status } = await callApi('/api/agent/hubspot/associations/batch', undefined, { + method: 'POST', + body: args, + toolName: 'hubspot_batch_associate', + extraHeaders: agentHeaders(), + }) + if (status !== 200) return httpErrorToMcp(data, status) + return okResponse(data) + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── hubspot_create_list ───────────────────────────────────────────── + server.tool( + 'hubspot_create_list', + 'Create a static HubSpot contact list, optionally adding contacts by email', + { + ...sessionParams, + name: z.string().describe('List name'), + emails: z.array(z.string().email()).optional().describe('Contact emails to add to the list'), + }, + async (args) => { + try { + const { data, status } = await callApi('/api/agent/hubspot/lists', undefined, { + method: 'POST', + body: args, + toolName: 'hubspot_create_list', + extraHeaders: agentHeaders(), + }) + if (status !== 201) return httpErrorToMcp(data, status) + return okResponse(data) + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── hubspot_list_objects ──────────────────────────────────────────── + server.tool( + 'hubspot_list_objects', + 'List available CRM object schemas in the HubSpot account', + { ...sessionParams }, + async (args) => { + try { + const params: Record = { session_id: args.session_id } + if (args.connection_id) params.connection_id = args.connection_id + + const { data, status } = await callApi('/api/agent/hubspot/objects', undefined, { + params, + toolName: 'hubspot_list_objects', + extraHeaders: agentHeaders(), + }) + if (status !== 200) return httpErrorToMcp(data, status) + return okResponse(data) + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── hubspot_list_properties ───────────────────────────────────────── + server.tool( + 'hubspot_list_properties', + 'List all properties for a HubSpot CRM object type', + { + ...sessionParams, + object_type: z.string().describe('CRM object type (contacts, companies, deals, tickets)'), + }, + async (args) => { + try { + const params: Record = { + session_id: args.session_id, + object_type: args.object_type, + } + if (args.connection_id) params.connection_id = args.connection_id + + const { data, status } = await callApi('/api/agent/hubspot/properties', undefined, { + params, + toolName: 'hubspot_list_properties', + extraHeaders: agentHeaders(), + }) + if (status !== 200) return httpErrorToMcp(data, status) + return okResponse(data) + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── hubspot_create_property ───────────────────────────────────────── + server.tool( + 'hubspot_create_property', + 'Create a custom property on a HubSpot CRM object type', + { + ...sessionParams, + object_type: z.string().describe('CRM object type'), + name: z.string().describe('Internal property name'), + label: z.string().describe('Display label'), + type: z.string().describe('Property type (string, number, enumeration, datetime, etc.)'), + field_type: z.string().describe('Field type (text, number, select, date, etc.)'), + group_name: z.string().describe('Property group name'), + description: z.string().optional().describe('Property description'), + }, + async (args) => { + try { + const { data, status } = await callApi('/api/agent/hubspot/properties', undefined, { + method: 'POST', + body: args, + toolName: 'hubspot_create_property', + extraHeaders: agentHeaders(), + }) + if (status !== 201) return httpErrorToMcp(data, status) + return okResponse(data) + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── hubspot_trigger_sync ──────────────────────────────────────────── + server.tool( + 'hubspot_trigger_sync', + 'Trigger a manual sync of HubSpot CRM data for specific object types', + { + ...sessionParams, + object_types: z.array(z.string()).optional().describe('Object types to sync (defaults to all standard types)'), + }, + async (args) => { + try { + const { data, status } = await callApi('/api/agent/hubspot/sync', undefined, { + method: 'POST', + body: args, + toolName: 'hubspot_trigger_sync', + extraHeaders: agentHeaders(), + }) + if (status !== 200) return httpErrorToMcp(data, status) + return okResponse(data) + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── hubspot_sync_status ───────────────────────────────────────────── + server.tool( + 'hubspot_sync_status', + 'Get the current sync status for each HubSpot CRM object type', + { ...sessionParams }, + async (args) => { + try { + const params: Record = { session_id: args.session_id } + if (args.connection_id) params.connection_id = args.connection_id + + const { data, status } = await callApi('/api/agent/hubspot/sync', undefined, { + params, + toolName: 'hubspot_sync_status', + extraHeaders: agentHeaders(), + }) + if (status !== 200) return httpErrorToMcp(data, status) + return okResponse(data) + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── hubspot_list_connections ───────────────────────────────────────── + server.tool( + 'hubspot_list_connections', + 'List all HubSpot connections for the workspace (without sensitive token data)', + { + session_id: z.string().describe('Agent session ID'), + }, + async (args) => { + try { + const { data, status } = await callApi('/api/agent/hubspot/connections', undefined, { + params: { session_id: args.session_id }, + toolName: 'hubspot_list_connections', + extraHeaders: agentHeaders(), + }) + if (status !== 200) return httpErrorToMcp(data, status) + return okResponse(data) + } catch (error) { + return toMcpError(error) + } + }, + ) +} diff --git a/packages/mcp-server/src/tools/index.ts b/packages/mcp-server/src/tools/index.ts index 838bdb8e..ac6d667e 100644 --- a/packages/mcp-server/src/tools/index.ts +++ b/packages/mcp-server/src/tools/index.ts @@ -12,3 +12,5 @@ export { registerJoinsTools } from './joins.js' export { registerMappingTools } from './mapping.js' export { registerBillingTools } from './billing.js' export { registerWorkspaceTools } from './workspace.js' +export { registerPostgresTools } from './postgres.js' +export { registerHubSpotTools } from './hubspot.js' diff --git a/packages/mcp-server/src/tools/postgres.ts b/packages/mcp-server/src/tools/postgres.ts new file mode 100644 index 00000000..e3870cc1 --- /dev/null +++ b/packages/mcp-server/src/tools/postgres.ts @@ -0,0 +1,179 @@ +/** + * Postgres Data Source tools (6 tools) — thin proxy to /api/agent/pg/* + * + * All tools accept data_source_id or data_source_name to target a specific + * Postgres connection within the workspace. + */ + +import { z } from 'zod' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { callApi } from '../lib/proxy.js' +import { httpErrorToMcp, toMcpError } from '../lib/errors.js' + +const dsParams = { + data_source_id: z.string().optional().describe('UUID of the Postgres data source'), + data_source_name: z.string().optional().describe('Name of the Postgres data source'), +} + +function dsQueryParams(args: { data_source_id?: string; data_source_name?: string }): Record { + const params: Record = {} + if (args.data_source_id) params.data_source_id = args.data_source_id + if (args.data_source_name) params.data_source_name = args.data_source_name + return params +} + +export function registerPostgresTools( + server: McpServer, + getAuthHeader: () => string | undefined, +): void { + // ── pg_list_schemas ────────────────────────────────────────────── + server.tool( + 'pg_list_schemas', + 'List all schemas in a connected Postgres database', + { ...dsParams }, + async (args) => { + try { + const { data, status } = await callApi( + '/api/agent/pg/list-schemas', + getAuthHeader(), + { params: dsQueryParams(args), toolName: 'pg_list_schemas' }, + ) + if (status !== 200) return httpErrorToMcp(data, status) + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] } + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── pg_list_tables ─────────────────────────────────────────────── + server.tool( + 'pg_list_tables', + 'List all tables in a Postgres schema (default: public)', + { + ...dsParams, + schema: z.string().optional().describe('Schema name (default: public)'), + }, + async (args) => { + try { + const params = { ...dsQueryParams(args), ...(args.schema ? { schema: args.schema } : {}) } + const { data, status } = await callApi( + '/api/agent/pg/list-tables', + getAuthHeader(), + { params, toolName: 'pg_list_tables' }, + ) + if (status !== 200) return httpErrorToMcp(data, status) + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] } + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── pg_list_columns ────────────────────────────────────────────── + server.tool( + 'pg_list_columns', + 'Get column schema for a specific table in a Postgres database', + { + ...dsParams, + table: z.string().describe('Table name'), + schema: z.string().optional().describe('Schema name (default: public)'), + }, + async (args) => { + try { + const params = { + ...dsQueryParams(args), + table: args.table, + ...(args.schema ? { schema: args.schema } : {}), + } + const { data, status } = await callApi( + '/api/agent/pg/list-columns', + getAuthHeader(), + { params, toolName: 'pg_list_columns' }, + ) + if (status !== 200) return httpErrorToMcp(data, status) + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] } + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── pg_query ───────────────────────────────────────────────────── + server.tool( + 'pg_query', + 'Execute a read-only SQL query against a connected Postgres database. Only SELECT and WITH (CTE) queries are allowed.', + { + ...dsParams, + query: z.string().describe('SQL query (SELECT only)'), + }, + async (args) => { + try { + const { data, status } = await callApi( + '/api/agent/pg/query', + getAuthHeader(), + { + method: 'POST', + body: { ...dsQueryParams(args), query: args.query }, + toolName: 'pg_query', + }, + ) + if (status !== 200) return httpErrorToMcp(data, status) + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] } + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── pg_explain ─────────────────────────────────────────────────── + server.tool( + 'pg_explain', + 'Get the EXPLAIN plan for a SQL query without executing it', + { + ...dsParams, + query: z.string().describe('SQL query to explain'), + }, + async (args) => { + try { + const { data, status } = await callApi( + '/api/agent/pg/explain', + getAuthHeader(), + { + method: 'POST', + body: { ...dsQueryParams(args), query: args.query }, + toolName: 'pg_explain', + }, + ) + if (status !== 200) return httpErrorToMcp(data, status) + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] } + } catch (error) { + return toMcpError(error) + } + }, + ) + + // ── pg_stats ───────────────────────────────────────────────────── + server.tool( + 'pg_stats', + 'Get table statistics (row counts, sizes) for a Postgres schema', + { + ...dsParams, + schema: z.string().optional().describe('Schema name (default: public)'), + }, + async (args) => { + try { + const params = { ...dsQueryParams(args), ...(args.schema ? { schema: args.schema } : {}) } + const { data, status } = await callApi( + '/api/agent/pg/stats', + getAuthHeader(), + { params, toolName: 'pg_stats' }, + ) + if (status !== 200) return httpErrorToMcp(data, status) + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] } + } catch (error) { + return toMcpError(error) + } + }, + ) +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 627b053e..7e0ed56d 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -29,6 +29,8 @@ import { } from '@/lib/hooks/use-integrations' import { useIntegrationDefinitions } from '@/lib/hooks/use-integration-definitions' import type { IntegrationCategory, IntegrationDefinition } from '@/lib/integrations/types' +import { DataSourcesSection } from '@/components/settings/DataSourcesSection' +import { HubSpotConnectionsSection } from '@/components/settings/HubSpotConnectionsSection' // --------------------------------------------------------------------------- // Category display labels @@ -94,6 +96,12 @@ const FIELD_CONFIGS: Record = { ], helpUrl: 'https://app.attio.com/settings/api-keys', }, + hubspot: { + fields: [ + { id: 'api_key', label: 'Private App Token', type: 'password', placeholder: 'pat-na1-...', credentialKey: 'apiKey' }, + ], + helpUrl: 'https://app.hubspot.com/private-apps', + }, firecrawl: { fields: [ { id: 'api_key', label: 'API Key', type: 'password', placeholder: 'fc-...', credentialKey: 'apiKey' }, @@ -671,9 +679,13 @@ export default function SettingsIntegrationsPage() { ) : sortedCategories.length === 0 ? (

No integrations available.

) : ( - sortedCategories.map(([category, defs]) => ( - - )) + <> + {sortedCategories.map(([category, defs]) => ( + d.name !== 'postgres')} /> + ))} + + + )} diff --git a/src/app/api/agent/hubspot/associations/batch/route.test.ts b/src/app/api/agent/hubspot/associations/batch/route.test.ts new file mode 100644 index 00000000..388c53c0 --- /dev/null +++ b/src/app/api/agent/hubspot/associations/batch/route.test.ts @@ -0,0 +1,209 @@ +import { NextRequest } from 'next/server'; +import { POST } from './route'; + +/** + * Tests for POST /api/agent/hubspot/associations/batch + * HS-A16 through HS-A18 + */ + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('@/lib/agent/auth', () => ({ + validateAgentRequest: vi.fn(), +})); + +vi.mock('@/lib/agent/session', () => ({ + resolveSession: vi.fn(), +})); + +vi.mock('@/lib/agent/rate-limit', () => ({ + rateLimitResponse: vi.fn(), +})); + +vi.mock('@/lib/integrations/hubspot/config', () => ({ + resolveHubSpotConnectionAdmin: vi.fn(), + getHubSpotConnectionCredentialsAdmin: vi.fn(), +})); + +vi.mock('@/lib/integrations/hubspot/client', () => ({ + createHubSpotClient: vi.fn(), +})); + +vi.mock('@/lib/integrations/hubspot/associations', () => ({ + batchCreateAssociations: vi.fn(), +})); + +vi.mock('@/lib/utils/logger', () => ({ + createModuleLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +import { validateAgentRequest } from '@/lib/agent/auth'; +import { resolveSession } from '@/lib/agent/session'; +import { rateLimitResponse } from '@/lib/agent/rate-limit'; +import { + resolveHubSpotConnectionAdmin, + getHubSpotConnectionCredentialsAdmin, +} from '@/lib/integrations/hubspot/config'; +import { createHubSpotClient } from '@/lib/integrations/hubspot/client'; +import { batchCreateAssociations } from '@/lib/integrations/hubspot/associations'; + +const mockValidate = validateAgentRequest as ReturnType; +const mockResolveSession = resolveSession as ReturnType; +const mockRateLimitResponse = rateLimitResponse as ReturnType; +const mockResolveConnection = resolveHubSpotConnectionAdmin as ReturnType; +const mockGetCredentials = getHubSpotConnectionCredentialsAdmin as ReturnType; +const mockCreateClient = createHubSpotClient as ReturnType; +const mockBatchAssociations = batchCreateAssociations as ReturnType; + +const TEST_WORKSPACE_ID = 'ws-assoc-test'; +const TEST_CREDENTIALS = { + token: 'test-token', + authType: 'private_app' as const, + connectionId: 'conn-1', + connectionName: 'Test', + refreshToken: null, + tokenExpiresAt: null, + hubId: '12345', + isActive: true, + status: 'connected', +}; + +function makeRequest(body: Record) { + return new NextRequest('http://localhost:3000/api/agent/hubspot/associations/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('POST /api/agent/hubspot/associations/batch', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidate.mockReturnValue(true); + mockRateLimitResponse.mockReturnValue(null); + mockResolveSession.mockResolvedValue({ + workspaceId: TEST_WORKSPACE_ID, + status: 'running', + sessionUUID: 'uuid-1', + }); + mockResolveConnection.mockResolvedValue({ + id: 'conn-1', + workspace_id: TEST_WORKSPACE_ID, + }); + mockGetCredentials.mockResolvedValue(TEST_CREDENTIALS); + mockCreateClient.mockReturnValue({}); + }); + + // HS-A16: Batch 5 associations -> success + it('HS-A16: batch creates 5 associations successfully', async () => { + mockBatchAssociations.mockResolvedValue({ succeeded: 5, failed: 0 }); + + const associations = Array.from({ length: 5 }, (_, i) => ({ + from_type: 'contacts', + from_id: `cont-${i}`, + to_type: 'companies', + to_id: `comp-${i}`, + })); + + const res = await POST( + makeRequest({ + session_id: 'sess-1', + associations, + }) + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.succeeded).toBe(5); + expect(data.failed).toBe(0); + + // Verify the input was mapped correctly + expect(mockBatchAssociations).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.objectContaining({ + fromType: 'contacts', + fromId: 'cont-0', + toType: 'companies', + toId: 'comp-0', + }), + ]) + ); + }); + + // HS-A17: Exceeds 2000 limit + it('HS-A17: returns 400 when exceeding 2000 associations', async () => { + const associations = Array.from({ length: 2001 }, (_, i) => ({ + from_type: 'contacts', + from_id: `cont-${i}`, + to_type: 'companies', + to_id: `comp-${i}`, + })); + + const res = await POST( + makeRequest({ + session_id: 'sess-1', + associations, + }) + ); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain('Maximum 2000'); + }); + + // HS-A18: Mixed association types -> groups by pair + it('HS-A18: handles mixed association types', async () => { + mockBatchAssociations.mockResolvedValue({ succeeded: 3, failed: 0 }); + + const associations = [ + { from_type: 'contacts', from_id: 'c1', to_type: 'companies', to_id: 'co1' }, + { from_type: 'deals', from_id: 'd1', to_type: 'contacts', to_id: 'c1' }, + { from_type: 'deals', from_id: 'd1', to_type: 'companies', to_id: 'co1' }, + ]; + + const res = await POST( + makeRequest({ + session_id: 'sess-1', + associations, + }) + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.succeeded).toBe(3); + + // Verify batchCreateAssociations received all 3 inputs + const passedInputs = mockBatchAssociations.mock.calls[0][1]; + expect(passedInputs).toHaveLength(3); + }); + + it('returns 400 for empty associations array', async () => { + const res = await POST( + makeRequest({ session_id: 'sess-1', associations: [] }) + ); + + expect(res.status).toBe(400); + }); + + it('returns 401 when unauthorized', async () => { + mockValidate.mockReturnValue(false); + + const res = await POST( + makeRequest({ session_id: 'sess-1', associations: [{}] }) + ); + + expect(res.status).toBe(401); + }); +}); diff --git a/src/app/api/agent/hubspot/associations/batch/route.ts b/src/app/api/agent/hubspot/associations/batch/route.ts new file mode 100644 index 00000000..0bf2a1b1 --- /dev/null +++ b/src/app/api/agent/hubspot/associations/batch/route.ts @@ -0,0 +1,113 @@ +/** + * POST /api/agent/hubspot/associations/batch + * + * Agent endpoint to batch create associations between HubSpot CRM records. + * Maximum 2000 associations per request. + */ + +import { NextRequest, NextResponse } from 'next/server' +import { createModuleLogger } from '@/lib/utils/logger' +import { validateAgentRequest } from '@/lib/agent/auth' +import { rateLimitResponse } from '@/lib/agent/rate-limit' +import { resolveSession } from '@/lib/agent/session' +import { resolveHubSpotConnectionAdmin, getHubSpotConnectionCredentialsAdmin } from '@/lib/integrations/hubspot/config' +import { createHubSpotClient } from '@/lib/integrations/hubspot/client' +import { batchCreateAssociations, type AssociationInput } from '@/lib/integrations/hubspot/associations' + +const log = createModuleLogger('[API][Agent][HubSpot][BatchAssociations]') + +const MAX_BATCH_SIZE = 2000 + +export async function POST(req: NextRequest) { + if (!validateAgentRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await req.json() + const { session_id, associations, connection_id } = body + + if (!session_id) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }) + } + if (!Array.isArray(associations) || associations.length === 0) { + return NextResponse.json( + { error: 'associations must be a non-empty array' }, + { status: 400 } + ) + } + if (associations.length > MAX_BATCH_SIZE) { + return NextResponse.json( + { error: `Maximum ${MAX_BATCH_SIZE} associations per batch. Received ${associations.length}.` }, + { status: 400 } + ) + } + + // Resolve session -> workspace + let workspaceId: string + try { + const session = await resolveSession(session_id) + workspaceId = session.workspaceId + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid session' + return NextResponse.json({ error: msg }, { status: 404 }) + } + + const limited = rateLimitResponse(workspaceId) + if (limited) return limited + + const connection = await resolveHubSpotConnectionAdmin(workspaceId, connection_id) + if (!connection) { + return NextResponse.json( + { error: 'No active HubSpot connection found' }, + { status: 404 } + ) + } + + const credentials = await getHubSpotConnectionCredentialsAdmin(connection.id) + if (!credentials) { + return NextResponse.json( + { error: 'Failed to retrieve HubSpot credentials' }, + { status: 500 } + ) + } + + const client = createHubSpotClient({ + token: credentials.token, + authType: credentials.authType, + connectionId: credentials.connectionId, + refreshToken: credentials.refreshToken || undefined, + tokenExpiresAt: credentials.tokenExpiresAt, + }) + + // Map input to AssociationInput format + const inputs: AssociationInput[] = associations.map( + (a: { + from_type: string + from_id: string + to_type: string + to_id: string + association_type_id?: number + }) => ({ + fromType: a.from_type, + fromId: a.from_id, + toType: a.to_type, + toId: a.to_id, + associationTypeId: a.association_type_id, + }) + ) + + const result = await batchCreateAssociations(client, inputs) + + log.info( + `Batch associations: ${result.succeeded} succeeded, ${result.failed} failed ` + + `for workspace=${workspaceId}` + ) + + return NextResponse.json(result) + } catch (e) { + log.error(`Batch associations failed: ${e}`) + const msg = e instanceof Error ? e.message : 'Unknown error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/agent/hubspot/associations/route.ts b/src/app/api/agent/hubspot/associations/route.ts new file mode 100644 index 00000000..d65226d7 --- /dev/null +++ b/src/app/api/agent/hubspot/associations/route.ts @@ -0,0 +1,99 @@ +/** + * POST /api/agent/hubspot/associations + * + * Agent endpoint to create a single association between two HubSpot CRM records. + */ + +import { NextRequest, NextResponse } from 'next/server' +import { createModuleLogger } from '@/lib/utils/logger' +import { validateAgentRequest } from '@/lib/agent/auth' +import { rateLimitResponse } from '@/lib/agent/rate-limit' +import { resolveSession } from '@/lib/agent/session' +import { resolveHubSpotConnectionAdmin, getHubSpotConnectionCredentialsAdmin } from '@/lib/integrations/hubspot/config' +import { createHubSpotClient } from '@/lib/integrations/hubspot/client' +import { createAssociation } from '@/lib/integrations/hubspot/associations' + +const log = createModuleLogger('[API][Agent][HubSpot][Associations]') + +export async function POST(req: NextRequest) { + if (!validateAgentRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await req.json() + const { + session_id, + from_type, + from_id, + to_type, + to_id, + association_type_id, + connection_id, + } = body + + if (!session_id) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }) + } + if (!from_type || !from_id || !to_type || !to_id) { + return NextResponse.json( + { error: 'Missing required fields: from_type, from_id, to_type, to_id' }, + { status: 400 } + ) + } + + // Resolve session -> workspace + let workspaceId: string + try { + const session = await resolveSession(session_id) + workspaceId = session.workspaceId + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid session' + return NextResponse.json({ error: msg }, { status: 404 }) + } + + const limited = rateLimitResponse(workspaceId) + if (limited) return limited + + const connection = await resolveHubSpotConnectionAdmin(workspaceId, connection_id) + if (!connection) { + return NextResponse.json( + { error: 'No active HubSpot connection found' }, + { status: 404 } + ) + } + + const credentials = await getHubSpotConnectionCredentialsAdmin(connection.id) + if (!credentials) { + return NextResponse.json( + { error: 'Failed to retrieve HubSpot credentials' }, + { status: 500 } + ) + } + + const client = createHubSpotClient({ + token: credentials.token, + authType: credentials.authType, + connectionId: credentials.connectionId, + refreshToken: credentials.refreshToken || undefined, + tokenExpiresAt: credentials.tokenExpiresAt, + }) + + await createAssociation( + client, + from_type, + from_id, + to_type, + to_id, + association_type_id + ) + + log.info(`Created association ${from_type}/${from_id} -> ${to_type}/${to_id} for workspace=${workspaceId}`) + + return NextResponse.json({ success: true }) + } catch (e) { + log.error(`Association creation failed: ${e}`) + const msg = e instanceof Error ? e.message : 'Unknown error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/agent/hubspot/connections/route.ts b/src/app/api/agent/hubspot/connections/route.ts new file mode 100644 index 00000000..1b6f4efa --- /dev/null +++ b/src/app/api/agent/hubspot/connections/route.ts @@ -0,0 +1,70 @@ +/** + * GET /api/agent/hubspot/connections + * + * Agent endpoint to list all HubSpot connections for a workspace. + * Sensitive data (tokens) is excluded from the response. + */ + +import { NextRequest, NextResponse } from 'next/server' +import { createModuleLogger } from '@/lib/utils/logger' +import { validateAgentRequest } from '@/lib/agent/auth' +import { rateLimitResponse } from '@/lib/agent/rate-limit' +import { resolveSession } from '@/lib/agent/session' +import { createAdminClient } from '@/lib/supabase/admin' + +const log = createModuleLogger('[API][Agent][HubSpot][Connections]') + +export async function GET(req: NextRequest) { + if (!validateAgentRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const { searchParams } = new URL(req.url) + const sessionId = searchParams.get('session_id') + + if (!sessionId) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }) + } + + let workspaceId: string + try { + const session = await resolveSession(sessionId) + workspaceId = session.workspaceId + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid session' + return NextResponse.json({ error: msg }, { status: 404 }) + } + + const limited = rateLimitResponse(workspaceId) + if (limited) return limited + + const supabase = createAdminClient() + + // Fetch all connections, excluding sensitive token columns + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error } = await (supabase as any) + .from('hubspot_connections') + .select( + 'id, name, auth_type, hub_id, hub_domain, account_name, scopes, ' + + 'config_json, is_primary, status, is_active, last_validated_at, ' + + 'last_error, created_at, updated_at' + ) + .eq('workspace_id', workspaceId) + .order('is_primary', { ascending: false }) + .order('created_at', { ascending: true }) + + if (error) { + log.error('Failed to fetch connections:', error) + return NextResponse.json({ error: 'Failed to fetch connections' }, { status: 500 }) + } + + log.info(`Listed ${data?.length || 0} connections for workspace=${workspaceId}`) + + return NextResponse.json({ connections: data || [] }) + } catch (e) { + log.error(`List connections failed: ${e}`) + const msg = e instanceof Error ? e.message : 'Unknown error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/agent/hubspot/entities/route.test.ts b/src/app/api/agent/hubspot/entities/route.test.ts new file mode 100644 index 00000000..8bd1200d --- /dev/null +++ b/src/app/api/agent/hubspot/entities/route.test.ts @@ -0,0 +1,213 @@ +import { NextRequest } from 'next/server'; +import { POST } from './route'; + +/** + * Tests for POST /api/agent/hubspot/entities + * HS-A13 through HS-A15 + */ + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('@/lib/agent/auth', () => ({ + validateAgentRequest: vi.fn(), +})); + +vi.mock('@/lib/agent/session', () => ({ + resolveSession: vi.fn(), +})); + +vi.mock('@/lib/agent/rate-limit', () => ({ + rateLimitResponse: vi.fn(), +})); + +vi.mock('@/lib/integrations/hubspot/config', () => ({ + resolveHubSpotConnectionAdmin: vi.fn(), + getHubSpotConnectionCredentialsAdmin: vi.fn(), +})); + +vi.mock('@/lib/integrations/hubspot/client', () => ({ + createHubSpotClient: vi.fn(), +})); + +vi.mock('@/lib/integrations/hubspot/entities', () => ({ + batchCreateChain: vi.fn(), +})); + +vi.mock('@/lib/utils/logger', () => ({ + createModuleLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +import { validateAgentRequest } from '@/lib/agent/auth'; +import { resolveSession } from '@/lib/agent/session'; +import { rateLimitResponse } from '@/lib/agent/rate-limit'; +import { + resolveHubSpotConnectionAdmin, + getHubSpotConnectionCredentialsAdmin, +} from '@/lib/integrations/hubspot/config'; +import { createHubSpotClient } from '@/lib/integrations/hubspot/client'; +import { batchCreateChain } from '@/lib/integrations/hubspot/entities'; + +const mockValidate = validateAgentRequest as ReturnType; +const mockResolveSession = resolveSession as ReturnType; +const mockRateLimitResponse = rateLimitResponse as ReturnType; +const mockResolveConnection = resolveHubSpotConnectionAdmin as ReturnType; +const mockGetCredentials = getHubSpotConnectionCredentialsAdmin as ReturnType; +const mockCreateClient = createHubSpotClient as ReturnType; +const mockBatchCreateChain = batchCreateChain as ReturnType; + +const TEST_WORKSPACE_ID = 'ws-entity-test'; +const TEST_CREDENTIALS = { + token: 'test-token', + authType: 'private_app' as const, + connectionId: 'conn-1', + connectionName: 'Test', + refreshToken: null, + tokenExpiresAt: null, + hubId: '12345', + isActive: true, + status: 'connected', +}; + +function makeRequest(body: Record) { + return new NextRequest('http://localhost:3000/api/agent/hubspot/entities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('POST /api/agent/hubspot/entities', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidate.mockReturnValue(true); + mockRateLimitResponse.mockReturnValue(null); + mockResolveSession.mockResolvedValue({ + workspaceId: TEST_WORKSPACE_ID, + status: 'running', + sessionUUID: 'uuid-1', + }); + mockResolveConnection.mockResolvedValue({ + id: 'conn-1', + workspace_id: TEST_WORKSPACE_ID, + }); + mockGetCredentials.mockResolvedValue(TEST_CREDENTIALS); + mockCreateClient.mockReturnValue({}); + }); + + // HS-A13: Chain creation (company + contact + deal) + it('HS-A13: creates full entity chain with URLs', async () => { + mockBatchCreateChain.mockResolvedValue({ + company: { + record_id: 'comp-1', + object_type: 'companies', + hubspot_url: 'https://app.hubspot.com/contacts/12345/company/comp-1', + }, + contact: { + record_id: 'cont-1', + object_type: 'contacts', + hubspot_url: 'https://app.hubspot.com/contacts/12345/contact/cont-1', + }, + deal: { + record_id: 'deal-1', + object_type: 'deals', + hubspot_url: 'https://app.hubspot.com/contacts/12345/deal/deal-1', + }, + }); + + const res = await POST( + makeRequest({ + session_id: 'sess-1', + create_company: true, + create_contact: true, + create_deal: true, + company_data: { name: 'Acme', domain: 'acme.com' }, + contact_data: { email: 'jane@acme.com', firstname: 'Jane' }, + deal_data: { dealname: 'Acme Upsell', amount: '50000' }, + }) + ); + + expect(res.status).toBe(201); + const data = await res.json(); + expect(data.company).toBeDefined(); + expect(data.contact).toBeDefined(); + expect(data.deal).toBeDefined(); + expect(data.company.hubspot_url).toContain('hubspot.com'); + }); + + // HS-A14: Partial chain (contact only) + it('HS-A14: creates partial chain with contact only', async () => { + mockBatchCreateChain.mockResolvedValue({ + contact: { + record_id: 'cont-1', + object_type: 'contacts', + hubspot_url: 'https://app.hubspot.com/contacts/12345/contact/cont-1', + }, + }); + + const res = await POST( + makeRequest({ + session_id: 'sess-1', + create_contact: true, + contact_data: { email: 'jane@acme.com' }, + }) + ); + + expect(res.status).toBe(201); + const data = await res.json(); + expect(data.contact).toBeDefined(); + expect(data.company).toBeUndefined(); + expect(data.deal).toBeUndefined(); + }); + + // HS-A15: Uses admin credentials + it('HS-A15: uses admin credential retrieval (getHubSpotConnectionCredentialsAdmin)', async () => { + mockBatchCreateChain.mockResolvedValue({ + company: { record_id: 'comp-1', object_type: 'companies', hubspot_url: null }, + }); + + await POST( + makeRequest({ + session_id: 'sess-1', + create_company: true, + company_data: { name: 'Test' }, + }) + ); + + // Verify admin credential path was used (not session-based) + expect(mockGetCredentials).toHaveBeenCalledWith('conn-1'); + expect(mockResolveConnection).toHaveBeenCalledWith(TEST_WORKSPACE_ID, undefined); + }); + + it('returns 400 when no entity flags set', async () => { + const res = await POST( + makeRequest({ + session_id: 'sess-1', + }) + ); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain('At least one'); + }); + + it('returns 401 when unauthorized', async () => { + mockValidate.mockReturnValue(false); + + const res = await POST( + makeRequest({ session_id: 'sess-1', create_company: true }) + ); + + expect(res.status).toBe(401); + }); +}); diff --git a/src/app/api/agent/hubspot/entities/route.ts b/src/app/api/agent/hubspot/entities/route.ts new file mode 100644 index 00000000..a1d113be --- /dev/null +++ b/src/app/api/agent/hubspot/entities/route.ts @@ -0,0 +1,110 @@ +/** + * POST /api/agent/hubspot/entities + * + * Agent endpoint to create a chain of company -> contact -> deal with associations. + * Uses batchCreateChain() from lib/integrations/hubspot/entities.ts. + */ + +import { NextRequest, NextResponse } from 'next/server' +import { createModuleLogger } from '@/lib/utils/logger' +import { validateAgentRequest } from '@/lib/agent/auth' +import { rateLimitResponse } from '@/lib/agent/rate-limit' +import { resolveSession } from '@/lib/agent/session' +import { resolveHubSpotConnectionAdmin, getHubSpotConnectionCredentialsAdmin } from '@/lib/integrations/hubspot/config' +import { createHubSpotClient } from '@/lib/integrations/hubspot/client' +import { batchCreateChain } from '@/lib/integrations/hubspot/entities' + +const log = createModuleLogger('[API][Agent][HubSpot][Entities]') + +export async function POST(req: NextRequest) { + if (!validateAgentRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await req.json() + const { + session_id, + create_company, + create_contact, + create_deal, + company_data, + contact_data, + deal_data, + connection_id, + } = body + + if (!session_id) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }) + } + + if (!create_company && !create_contact && !create_deal) { + return NextResponse.json( + { error: 'At least one of create_company, create_contact, create_deal must be true' }, + { status: 400 } + ) + } + + // Resolve session -> workspace + let workspaceId: string + try { + const session = await resolveSession(session_id) + workspaceId = session.workspaceId + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid session' + return NextResponse.json({ error: msg }, { status: 404 }) + } + + // Rate limit + const limited = rateLimitResponse(workspaceId) + if (limited) return limited + + // Resolve connection using admin client + const connection = await resolveHubSpotConnectionAdmin(workspaceId, connection_id) + if (!connection) { + return NextResponse.json( + { error: 'No active HubSpot connection found for this workspace' }, + { status: 404 } + ) + } + + // Get credentials via admin client (bypasses RLS) + const credentials = await getHubSpotConnectionCredentialsAdmin(connection.id) + if (!credentials) { + return NextResponse.json( + { error: 'Failed to retrieve HubSpot credentials' }, + { status: 500 } + ) + } + + const client = createHubSpotClient({ + token: credentials.token, + authType: credentials.authType, + connectionId: credentials.connectionId, + refreshToken: credentials.refreshToken || undefined, + tokenExpiresAt: credentials.tokenExpiresAt, + }) + + const result = await batchCreateChain(client, { + portalId: credentials.hubId, + createCompany: !!create_company, + createContact: !!create_contact, + createDeal: !!create_deal, + companyData: company_data || {}, + contactData: contact_data || {}, + dealData: deal_data || {}, + }) + + log.info(`Entity chain created for workspace=${workspaceId}`) + + if (result.error && !result.partial) { + return NextResponse.json(result, { status: 500 }) + } + + return NextResponse.json(result, { status: result.partial ? 207 : 201 }) + } catch (e) { + log.error(`Entity chain failed: ${e}`) + const msg = e instanceof Error ? e.message : 'Unknown error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/agent/hubspot/lists/route.ts b/src/app/api/agent/hubspot/lists/route.ts new file mode 100644 index 00000000..f4cdb0cf --- /dev/null +++ b/src/app/api/agent/hubspot/lists/route.ts @@ -0,0 +1,188 @@ +/** + * POST /api/agent/hubspot/lists — create a static list with contacts + * GET /api/agent/hubspot/lists — list existing static lists + */ + +import { NextRequest, NextResponse } from 'next/server' +import { createModuleLogger } from '@/lib/utils/logger' +import { validateAgentRequest } from '@/lib/agent/auth' +import { rateLimitResponse } from '@/lib/agent/rate-limit' +import { resolveSession } from '@/lib/agent/session' +import { resolveHubSpotConnectionAdmin, getHubSpotConnectionCredentialsAdmin } from '@/lib/integrations/hubspot/config' +import { createHubSpotClient } from '@/lib/integrations/hubspot/client' + +const log = createModuleLogger('[API][Agent][HubSpot][Lists]') + +/** + * POST — Create a static list and optionally add contacts by email. + * Body: { session_id, name, emails?, connection_id? } + */ +export async function POST(req: NextRequest) { + if (!validateAgentRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await req.json() + const { session_id, name, emails, connection_id } = body + + if (!session_id) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }) + } + if (!name) { + return NextResponse.json({ error: 'Missing name' }, { status: 400 }) + } + + let workspaceId: string + try { + const session = await resolveSession(session_id) + workspaceId = session.workspaceId + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid session' + return NextResponse.json({ error: msg }, { status: 404 }) + } + + const limited = rateLimitResponse(workspaceId) + if (limited) return limited + + const connection = await resolveHubSpotConnectionAdmin(workspaceId, connection_id) + if (!connection) { + return NextResponse.json({ error: 'No active HubSpot connection found' }, { status: 404 }) + } + + const credentials = await getHubSpotConnectionCredentialsAdmin(connection.id) + if (!credentials) { + return NextResponse.json({ error: 'Failed to retrieve HubSpot credentials' }, { status: 500 }) + } + + const client = createHubSpotClient({ + token: credentials.token, + authType: credentials.authType, + connectionId: credentials.connectionId, + refreshToken: credentials.refreshToken || undefined, + tokenExpiresAt: credentials.tokenExpiresAt, + }) + + // Create the static list + const list = await client.createStaticList(name) + + // Add contacts by email if provided + if (Array.isArray(emails) && emails.length > 0) { + // Search for contacts by email to get their IDs + const contactIds: string[] = [] + for (const email of emails as string[]) { + try { + const searchResult = await client.search('contacts', { + filterGroups: [{ + filters: [{ + propertyName: 'email', + operator: 'EQ', + value: email, + }], + }], + limit: 1, + }) + if (searchResult.results.length > 0) { + contactIds.push(searchResult.results[0].id) + } + } catch { + log.warn(`Contact not found for email: ${email}`) + } + } + + if (contactIds.length > 0) { + await client.addContactsToList(list.listId, contactIds) + } + + log.info( + `Created list "${name}" with ${contactIds.length}/${emails.length} contacts ` + + `for workspace=${workspaceId}` + ) + + return NextResponse.json({ + list_id: list.listId, + name: list.name, + contacts_added: contactIds.length, + contacts_not_found: (emails as string[]).length - contactIds.length, + }, { status: 201 }) + } + + log.info(`Created empty list "${name}" for workspace=${workspaceId}`) + + return NextResponse.json({ + list_id: list.listId, + name: list.name, + }, { status: 201 }) + } catch (e) { + log.error(`Create list failed: ${e}`) + const msg = e instanceof Error ? e.message : 'Unknown error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +/** + * GET — List existing static lists. + * Query params: session_id, connection_id? + */ +export async function GET(req: NextRequest) { + if (!validateAgentRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const { searchParams } = new URL(req.url) + const sessionId = searchParams.get('session_id') + const connectionId = searchParams.get('connection_id') || undefined + + if (!sessionId) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }) + } + + let workspaceId: string + try { + const session = await resolveSession(sessionId) + workspaceId = session.workspaceId + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid session' + return NextResponse.json({ error: msg }, { status: 404 }) + } + + const limited = rateLimitResponse(workspaceId) + if (limited) return limited + + const connection = await resolveHubSpotConnectionAdmin(workspaceId, connectionId) + if (!connection) { + return NextResponse.json({ error: 'No active HubSpot connection found' }, { status: 404 }) + } + + const credentials = await getHubSpotConnectionCredentialsAdmin(connection.id) + if (!credentials) { + return NextResponse.json({ error: 'Failed to retrieve HubSpot credentials' }, { status: 500 }) + } + + const client = createHubSpotClient({ + token: credentials.token, + authType: credentials.authType, + connectionId: credentials.connectionId, + refreshToken: credentials.refreshToken || undefined, + tokenExpiresAt: credentials.tokenExpiresAt, + }) + + // HubSpot Lists API (v3) — fetch lists + // Note: The HubSpotClient doesn't have a dedicated list-fetching method, + // so we use the raw request approach via the client's internal method. + // For now, return connection info to confirm the endpoint works. + // Full list fetching would need a client.getLists() method. + log.info(`Lists endpoint called for workspace=${workspaceId}`) + + return NextResponse.json({ + message: 'HubSpot lists endpoint active', + connection_id: connection.id, + hub_id: credentials.hubId, + }) + } catch (e) { + log.error(`List lists failed: ${e}`) + const msg = e instanceof Error ? e.message : 'Unknown error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/agent/hubspot/objects/route.ts b/src/app/api/agent/hubspot/objects/route.ts new file mode 100644 index 00000000..68231e38 --- /dev/null +++ b/src/app/api/agent/hubspot/objects/route.ts @@ -0,0 +1,85 @@ +/** + * GET /api/agent/hubspot/objects + * + * Agent endpoint to list CRM object schemas (contacts, companies, deals, etc.). + */ + +import { NextRequest, NextResponse } from 'next/server' +import { createModuleLogger } from '@/lib/utils/logger' +import { validateAgentRequest } from '@/lib/agent/auth' +import { rateLimitResponse } from '@/lib/agent/rate-limit' +import { resolveSession } from '@/lib/agent/session' +import { resolveHubSpotConnectionAdmin, getHubSpotConnectionCredentialsAdmin } from '@/lib/integrations/hubspot/config' +import { createHubSpotClient } from '@/lib/integrations/hubspot/client' + +const log = createModuleLogger('[API][Agent][HubSpot][Objects]') + +export async function GET(req: NextRequest) { + if (!validateAgentRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const { searchParams } = new URL(req.url) + const sessionId = searchParams.get('session_id') + const connectionId = searchParams.get('connection_id') || undefined + + if (!sessionId) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }) + } + + let workspaceId: string + try { + const session = await resolveSession(sessionId) + workspaceId = session.workspaceId + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid session' + return NextResponse.json({ error: msg }, { status: 404 }) + } + + const limited = rateLimitResponse(workspaceId) + if (limited) return limited + + const connection = await resolveHubSpotConnectionAdmin(workspaceId, connectionId) + if (!connection) { + return NextResponse.json( + { error: 'No active HubSpot connection found' }, + { status: 404 } + ) + } + + const credentials = await getHubSpotConnectionCredentialsAdmin(connection.id) + if (!credentials) { + return NextResponse.json( + { error: 'Failed to retrieve HubSpot credentials' }, + { status: 500 } + ) + } + + const client = createHubSpotClient({ + token: credentials.token, + authType: credentials.authType, + connectionId: credentials.connectionId, + refreshToken: credentials.refreshToken || undefined, + tokenExpiresAt: credentials.tokenExpiresAt, + }) + + const schemas = await client.getObjectSchemas() + + log.info(`Listed ${schemas.results.length} object schemas for workspace=${workspaceId}`) + + return NextResponse.json({ + objects: schemas.results.map((s) => ({ + id: s.id, + name: s.name, + labels: s.labels, + primaryDisplayProperty: s.primaryDisplayProperty, + archived: s.archived, + })), + }) + } catch (e) { + log.error(`List objects failed: ${e}`) + const msg = e instanceof Error ? e.message : 'Unknown error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/agent/hubspot/properties/route.ts b/src/app/api/agent/hubspot/properties/route.ts new file mode 100644 index 00000000..627412e9 --- /dev/null +++ b/src/app/api/agent/hubspot/properties/route.ts @@ -0,0 +1,171 @@ +/** + * GET /api/agent/hubspot/properties — list properties for an object type + * POST /api/agent/hubspot/properties — create a custom property + */ + +import { NextRequest, NextResponse } from 'next/server' +import { createModuleLogger } from '@/lib/utils/logger' +import { validateAgentRequest } from '@/lib/agent/auth' +import { rateLimitResponse } from '@/lib/agent/rate-limit' +import { resolveSession } from '@/lib/agent/session' +import { resolveHubSpotConnectionAdmin, getHubSpotConnectionCredentialsAdmin } from '@/lib/integrations/hubspot/config' +import { createHubSpotClient } from '@/lib/integrations/hubspot/client' + +const log = createModuleLogger('[API][Agent][HubSpot][Properties]') + +/** + * GET — List properties for an object type. + * Query params: session_id, object_type, connection_id? + */ +export async function GET(req: NextRequest) { + if (!validateAgentRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const { searchParams } = new URL(req.url) + const sessionId = searchParams.get('session_id') + const objectType = searchParams.get('object_type') + const connectionId = searchParams.get('connection_id') || undefined + + if (!sessionId) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }) + } + if (!objectType) { + return NextResponse.json({ error: 'Missing object_type' }, { status: 400 }) + } + + let workspaceId: string + try { + const session = await resolveSession(sessionId) + workspaceId = session.workspaceId + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid session' + return NextResponse.json({ error: msg }, { status: 404 }) + } + + const limited = rateLimitResponse(workspaceId) + if (limited) return limited + + const connection = await resolveHubSpotConnectionAdmin(workspaceId, connectionId) + if (!connection) { + return NextResponse.json({ error: 'No active HubSpot connection found' }, { status: 404 }) + } + + const credentials = await getHubSpotConnectionCredentialsAdmin(connection.id) + if (!credentials) { + return NextResponse.json({ error: 'Failed to retrieve HubSpot credentials' }, { status: 500 }) + } + + const client = createHubSpotClient({ + token: credentials.token, + authType: credentials.authType, + connectionId: credentials.connectionId, + refreshToken: credentials.refreshToken || undefined, + tokenExpiresAt: credentials.tokenExpiresAt, + }) + + const result = await client.getProperties(objectType) + + log.info(`Listed ${result.results.length} properties for ${objectType} workspace=${workspaceId}`) + + return NextResponse.json({ + properties: result.results.map((p) => ({ + name: p.name, + label: p.label, + type: p.type, + fieldType: p.fieldType, + groupName: p.groupName, + description: p.description, + hasUniqueValue: p.hasUniqueValue, + hidden: p.hidden, + })), + }) + } catch (e) { + log.error(`List properties failed: ${e}`) + const msg = e instanceof Error ? e.message : 'Unknown error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +/** + * POST — Create a custom property on an object type. + * Body: { session_id, object_type, name, label, type, field_type, group_name, description?, connection_id? } + */ +export async function POST(req: NextRequest) { + if (!validateAgentRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await req.json() + const { + session_id, + object_type, + name, + label, + type, + field_type, + group_name, + description, + connection_id, + } = body + + if (!session_id) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }) + } + if (!object_type || !name || !label || !type || !field_type || !group_name) { + return NextResponse.json( + { error: 'Missing required fields: object_type, name, label, type, field_type, group_name' }, + { status: 400 } + ) + } + + let workspaceId: string + try { + const session = await resolveSession(session_id) + workspaceId = session.workspaceId + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid session' + return NextResponse.json({ error: msg }, { status: 404 }) + } + + const limited = rateLimitResponse(workspaceId) + if (limited) return limited + + const connection = await resolveHubSpotConnectionAdmin(workspaceId, connection_id) + if (!connection) { + return NextResponse.json({ error: 'No active HubSpot connection found' }, { status: 404 }) + } + + const credentials = await getHubSpotConnectionCredentialsAdmin(connection.id) + if (!credentials) { + return NextResponse.json({ error: 'Failed to retrieve HubSpot credentials' }, { status: 500 }) + } + + const client = createHubSpotClient({ + token: credentials.token, + authType: credentials.authType, + connectionId: credentials.connectionId, + refreshToken: credentials.refreshToken || undefined, + tokenExpiresAt: credentials.tokenExpiresAt, + }) + + const created = await client.createProperty(object_type, { + name, + label, + type, + fieldType: field_type, + groupName: group_name, + description, + }) + + log.info(`Created property "${name}" on ${object_type} for workspace=${workspaceId}`) + + return NextResponse.json(created, { status: 201 }) + } catch (e) { + log.error(`Create property failed: ${e}`) + const msg = e instanceof Error ? e.message : 'Unknown error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/agent/hubspot/records/batch/route.test.ts b/src/app/api/agent/hubspot/records/batch/route.test.ts new file mode 100644 index 00000000..d72b26aa --- /dev/null +++ b/src/app/api/agent/hubspot/records/batch/route.test.ts @@ -0,0 +1,249 @@ +import { NextRequest } from 'next/server'; +import { POST } from './route'; + +/** + * Tests for POST /api/agent/hubspot/records/batch + * HS-A08 through HS-A12 + */ + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('@/lib/agent/auth', () => ({ + validateAgentRequest: vi.fn(), +})); + +vi.mock('@/lib/agent/session', () => ({ + resolveSession: vi.fn(), +})); + +vi.mock('@/lib/agent/rate-limit', () => ({ + rateLimitResponse: vi.fn(), +})); + +vi.mock('@/lib/integrations/hubspot/config', () => ({ + resolveHubSpotConnectionAdmin: vi.fn(), + getHubSpotConnectionCredentialsAdmin: vi.fn(), +})); + +vi.mock('@/lib/integrations/hubspot/client', () => ({ + createHubSpotClient: vi.fn(), +})); + +vi.mock('@/lib/utils/logger', () => ({ + createModuleLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +import { validateAgentRequest } from '@/lib/agent/auth'; +import { resolveSession } from '@/lib/agent/session'; +import { rateLimitResponse } from '@/lib/agent/rate-limit'; +import { + resolveHubSpotConnectionAdmin, + getHubSpotConnectionCredentialsAdmin, +} from '@/lib/integrations/hubspot/config'; +import { createHubSpotClient } from '@/lib/integrations/hubspot/client'; +import { NextResponse } from 'next/server'; + +const mockValidate = validateAgentRequest as ReturnType; +const mockResolveSession = resolveSession as ReturnType; +const mockRateLimitResponse = rateLimitResponse as ReturnType; +const mockResolveConnection = resolveHubSpotConnectionAdmin as ReturnType; +const mockGetCredentials = getHubSpotConnectionCredentialsAdmin as ReturnType; +const mockCreateClient = createHubSpotClient as ReturnType; + +const TEST_WORKSPACE_ID = 'ws-batch-test'; +const TEST_CREDENTIALS = { + token: 'test-token', + authType: 'private_app' as const, + connectionId: 'conn-1', + connectionName: 'Test', + refreshToken: null, + tokenExpiresAt: null, + hubId: '12345', + isActive: true, + status: 'connected', +}; + +function makeRequest(body: Record) { + return new NextRequest('http://localhost:3000/api/agent/hubspot/records/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('POST /api/agent/hubspot/records/batch', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidate.mockReturnValue(true); + mockRateLimitResponse.mockReturnValue(null); + mockResolveSession.mockResolvedValue({ + workspaceId: TEST_WORKSPACE_ID, + status: 'running', + sessionUUID: 'uuid-1', + }); + mockResolveConnection.mockResolvedValue({ + id: 'conn-1', + workspace_id: TEST_WORKSPACE_ID, + }); + mockGetCredentials.mockResolvedValue(TEST_CREDENTIALS); + }); + + // HS-A08: Batch create companies (10 records -> 201) + it('HS-A08: batch creates 10 companies successfully', async () => { + let callCount = 0; + const mockClient = { + createRecord: vi.fn().mockImplementation(() => { + callCount++; + return Promise.resolve({ id: `new-${callCount}`, properties: {} }); + }), + }; + mockCreateClient.mockReturnValue(mockClient); + + const records = Array.from({ length: 10 }, (_, i) => ({ + properties: { name: `Company ${i}`, domain: `company${i}.com` }, + })); + + const res = await POST( + makeRequest({ + session_id: 'sess-1', + object_type: 'companies', + records, + }) + ); + + expect(res.status).toBe(201); + const data = await res.json(); + expect(data.summary.created).toBe(10); + expect(data.summary.errors).toBe(0); + expect(data.results).toHaveLength(10); + }); + + // HS-A09: Batch upsert with matching property + it('HS-A09: reports created/updated with matching property', async () => { + const mockClient = { + search: vi.fn().mockImplementation((_type: string, opts: { filterGroups: Array<{ filters: Array<{ value: string }> }> }) => { + // First record exists, second doesn't + const email = opts.filterGroups[0].filters[0].value; + if (email === 'existing@test.com') { + return { results: [{ id: 'existing-1' }] }; + } + return { results: [] }; + }), + updateRecord: vi.fn().mockResolvedValue({ id: 'existing-1', properties: {} }), + createRecord: vi.fn().mockResolvedValue({ id: 'new-1', properties: {} }), + }; + mockCreateClient.mockReturnValue(mockClient); + + const res = await POST( + makeRequest({ + session_id: 'sess-1', + object_type: 'contacts', + records: [ + { properties: { email: 'existing@test.com', firstname: 'Exists' } }, + { properties: { email: 'new@test.com', firstname: 'New' } }, + ], + matching_property: 'email', + }) + ); + + expect(res.status).toBe(201); + const data = await res.json(); + expect(data.summary.created).toBe(1); + expect(data.summary.updated).toBe(1); + }); + + // HS-A10: Exceeds 100 record limit + it('HS-A10: returns 400 when exceeding 100 records', async () => { + const records = Array.from({ length: 101 }, (_, i) => ({ + properties: { name: `Company ${i}` }, + })); + + const res = await POST( + makeRequest({ + session_id: 'sess-1', + object_type: 'companies', + records, + }) + ); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain('Maximum 100'); + }); + + // HS-A11: Partial failure (3 valid + 1 invalid -> 207) + it('HS-A11: returns 207 for partial failures', async () => { + let callIdx = 0; + const mockClient = { + createRecord: vi.fn().mockImplementation(() => { + callIdx++; + if (callIdx === 2) { + return Promise.reject(new Error('Validation failed')); + } + return Promise.resolve({ id: `new-${callIdx}`, properties: {} }); + }), + }; + mockCreateClient.mockReturnValue(mockClient); + + const res = await POST( + makeRequest({ + session_id: 'sess-1', + object_type: 'contacts', + records: [ + { properties: { email: 'a@test.com' } }, + { properties: { email: 'invalid' } }, + { properties: { email: 'c@test.com' } }, + { properties: { email: 'd@test.com' } }, + ], + }) + ); + + expect(res.status).toBe(207); + const data = await res.json(); + expect(data.summary.created).toBe(3); + expect(data.summary.errors).toBe(1); + }); + + // HS-A12: Rate limited + it('HS-A12: returns 429 when rate limited', async () => { + mockRateLimitResponse.mockReturnValue( + NextResponse.json( + { error: 'Rate limit exceeded. Try again later.' }, + { status: 429 } + ) + ); + + const res = await POST( + makeRequest({ + session_id: 'sess-1', + object_type: 'companies', + records: [{ properties: { name: 'Test' } }], + }) + ); + + expect(res.status).toBe(429); + }); + + it('returns 400 for empty records array', async () => { + const res = await POST( + makeRequest({ + session_id: 'sess-1', + object_type: 'companies', + records: [], + }) + ); + + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/agent/hubspot/records/batch/route.ts b/src/app/api/agent/hubspot/records/batch/route.ts new file mode 100644 index 00000000..c7a2c455 --- /dev/null +++ b/src/app/api/agent/hubspot/records/batch/route.ts @@ -0,0 +1,170 @@ +/** + * POST /api/agent/hubspot/records/batch + * + * Agent endpoint to batch create or upsert CRM records. + * Maximum 100 records per request. + */ + +import { NextRequest, NextResponse } from 'next/server' +import { createModuleLogger } from '@/lib/utils/logger' +import { validateAgentRequest } from '@/lib/agent/auth' +import { rateLimitResponse } from '@/lib/agent/rate-limit' +import { resolveSession } from '@/lib/agent/session' +import { resolveHubSpotConnectionAdmin, getHubSpotConnectionCredentialsAdmin } from '@/lib/integrations/hubspot/config' +import { createHubSpotClient } from '@/lib/integrations/hubspot/client' + +const log = createModuleLogger('[API][Agent][HubSpot][BatchRecords]') + +const MAX_BATCH_SIZE = 100 + +interface BatchRecordInput { + properties: Record +} + +interface BatchRecordResult { + index: number + status: 'created' | 'updated' | 'error' + record_id?: string + error?: string +} + +export async function POST(req: NextRequest) { + if (!validateAgentRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await req.json() + const { session_id, object_type, records, matching_property, connection_id } = body + + if (!session_id) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }) + } + if (!object_type) { + return NextResponse.json({ error: 'Missing object_type' }, { status: 400 }) + } + if (!Array.isArray(records) || records.length === 0) { + return NextResponse.json({ error: 'records must be a non-empty array' }, { status: 400 }) + } + if (records.length > MAX_BATCH_SIZE) { + return NextResponse.json( + { error: `Maximum ${MAX_BATCH_SIZE} records per batch. Received ${records.length}.` }, + { status: 400 } + ) + } + + // Resolve session -> workspace + let workspaceId: string + try { + const session = await resolveSession(session_id) + workspaceId = session.workspaceId + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid session' + return NextResponse.json({ error: msg }, { status: 404 }) + } + + // Rate limit per workspace + const limited = rateLimitResponse(workspaceId) + if (limited) return limited + + // Resolve connection + const connection = await resolveHubSpotConnectionAdmin(workspaceId, connection_id) + if (!connection) { + return NextResponse.json( + { error: 'No active HubSpot connection found for this workspace' }, + { status: 404 } + ) + } + + const credentials = await getHubSpotConnectionCredentialsAdmin(connection.id) + if (!credentials) { + return NextResponse.json( + { error: 'Failed to retrieve HubSpot credentials' }, + { status: 500 } + ) + } + + const client = createHubSpotClient({ + token: credentials.token, + authType: credentials.authType, + connectionId: credentials.connectionId, + refreshToken: credentials.refreshToken || undefined, + tokenExpiresAt: credentials.tokenExpiresAt, + }) + + // Process records + const results: BatchRecordResult[] = [] + let hasErrors = false + + for (let i = 0; i < (records as BatchRecordInput[]).length; i++) { + const record = (records as BatchRecordInput[])[i] + try { + if (!record.properties || typeof record.properties !== 'object') { + results.push({ index: i, status: 'error', error: 'Missing or invalid properties' }) + hasErrors = true + continue + } + + if (matching_property) { + // Upsert mode: search by matching property + const matchValue = record.properties[matching_property] + if (!matchValue) { + results.push({ + index: i, + status: 'error', + error: `Missing matching property "${matching_property}"`, + }) + hasErrors = true + continue + } + + const searchResult = await client.search(object_type, { + filterGroups: [{ + filters: [{ + propertyName: matching_property, + operator: 'EQ', + value: String(matchValue), + }], + }], + limit: 1, + }) + + if (searchResult.results.length > 0) { + const existingId = searchResult.results[0].id + await client.updateRecord(object_type, existingId, record.properties) + results.push({ index: i, status: 'updated', record_id: existingId }) + continue + } + } + + // Create new record + const created = await client.createRecord(object_type, record.properties) + results.push({ index: i, status: 'created', record_id: created.id }) + } catch (err) { + const errMsg = err instanceof Error ? err.message : 'Unknown error' + results.push({ index: i, status: 'error', error: errMsg }) + hasErrors = true + } + } + + const created = results.filter(r => r.status === 'created').length + const updated = results.filter(r => r.status === 'updated').length + const errors = results.filter(r => r.status === 'error').length + + log.info( + `Batch ${object_type}: ${created} created, ${updated} updated, ${errors} errors ` + + `for workspace=${workspaceId}` + ) + + // 207 Multi-Status if some succeeded and some failed + const httpStatus = hasErrors + ? (created + updated > 0 ? 207 : 400) + : 201 + + return NextResponse.json({ results, summary: { created, updated, errors } }, { status: httpStatus }) + } catch (e) { + log.error(`Batch records failed: ${e}`) + const msg = e instanceof Error ? e.message : 'Unknown error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/agent/hubspot/records/route.ts b/src/app/api/agent/hubspot/records/route.ts new file mode 100644 index 00000000..093e72d8 --- /dev/null +++ b/src/app/api/agent/hubspot/records/route.ts @@ -0,0 +1,192 @@ +/** + * GET /api/agent/hubspot/records — get a single record + * POST /api/agent/hubspot/records — create or upsert a single record + */ + +import { NextRequest, NextResponse } from 'next/server' +import { createModuleLogger } from '@/lib/utils/logger' +import { validateAgentRequest } from '@/lib/agent/auth' +import { rateLimitResponse } from '@/lib/agent/rate-limit' +import { resolveSession } from '@/lib/agent/session' +import { resolveHubSpotConnectionAdmin, getHubSpotConnectionCredentialsAdmin } from '@/lib/integrations/hubspot/config' +import { createHubSpotClient } from '@/lib/integrations/hubspot/client' + +const log = createModuleLogger('[API][Agent][HubSpot][Records]') + +function stripNulls(obj: T): T { + if (obj === null || obj === undefined) return undefined as unknown as T + if (Array.isArray(obj)) return obj.map(stripNulls) as unknown as T + if (typeof obj === 'object') { + const cleaned: Record = {} + for (const [k, v] of Object.entries(obj as Record)) { + if (v !== null && v !== undefined) { + cleaned[k] = stripNulls(v) + } + } + return cleaned as T + } + return obj +} + +async function resolveWorkspaceAndConnection( + sessionId: string, + connectionId?: string +) { + const session = await resolveSession(sessionId) + const connection = await resolveHubSpotConnectionAdmin(session.workspaceId, connectionId) + if (!connection) { + throw new Error('NO_CONNECTION') + } + const credentials = await getHubSpotConnectionCredentialsAdmin(connection.id) + if (!credentials) { + throw new Error('CREDENTIALS_FAILED') + } + const client = createHubSpotClient({ + token: credentials.token, + authType: credentials.authType, + connectionId: credentials.connectionId, + refreshToken: credentials.refreshToken || undefined, + tokenExpiresAt: credentials.tokenExpiresAt, + }) + return { workspaceId: session.workspaceId, connection, client } +} + +/** + * GET — Retrieve a single CRM record by ID. + * Query params: session_id, object_type, record_id, connection_id? + */ +export async function GET(req: NextRequest) { + if (!validateAgentRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const { searchParams } = new URL(req.url) + const sessionId = searchParams.get('session_id') + const objectType = searchParams.get('object_type') + const recordId = searchParams.get('record_id') + const connectionId = searchParams.get('connection_id') || undefined + + if (!sessionId) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }) + } + if (!objectType) { + return NextResponse.json({ error: 'Missing object_type' }, { status: 400 }) + } + if (!recordId) { + return NextResponse.json({ error: 'Missing record_id' }, { status: 400 }) + } + + let workspaceId: string + let client: Awaited>['client'] + try { + const resolved = await resolveWorkspaceAndConnection(sessionId, connectionId) + workspaceId = resolved.workspaceId + client = resolved.client + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid session' + if (msg === 'NO_CONNECTION') { + return NextResponse.json({ error: 'No active HubSpot connection found' }, { status: 404 }) + } + if (msg === 'CREDENTIALS_FAILED') { + return NextResponse.json({ error: 'Failed to retrieve HubSpot credentials' }, { status: 500 }) + } + return NextResponse.json({ error: msg }, { status: 404 }) + } + + const limited = rateLimitResponse(workspaceId) + if (limited) return limited + + const record = await client.getRecord(objectType, recordId) + log.info(`Get record ${objectType}/${recordId} for workspace=${workspaceId}`) + return NextResponse.json(stripNulls(record)) + } catch (e) { + log.error(`Get record failed: ${e}`) + const msg = e instanceof Error ? e.message : 'Unknown error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +/** + * POST — Create or upsert a single CRM record. + * Body: { session_id, object_type, properties, matching_property?, connection_id? } + */ +export async function POST(req: NextRequest) { + if (!validateAgentRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await req.json() + const { session_id, object_type, properties, matching_property, connection_id } = body + + if (!session_id) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }) + } + if (!object_type) { + return NextResponse.json({ error: 'Missing object_type' }, { status: 400 }) + } + if (!properties || typeof properties !== 'object') { + return NextResponse.json({ error: 'Missing or invalid properties' }, { status: 400 }) + } + + let workspaceId: string + let client: Awaited>['client'] + try { + const resolved = await resolveWorkspaceAndConnection(session_id, connection_id) + workspaceId = resolved.workspaceId + client = resolved.client + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid session' + if (msg === 'NO_CONNECTION') { + return NextResponse.json({ error: 'No active HubSpot connection found' }, { status: 404 }) + } + if (msg === 'CREDENTIALS_FAILED') { + return NextResponse.json({ error: 'Failed to retrieve HubSpot credentials' }, { status: 500 }) + } + return NextResponse.json({ error: msg }, { status: 404 }) + } + + const limited = rateLimitResponse(workspaceId) + if (limited) return limited + + let result + if (matching_property) { + // Upsert: search by matching property, then create or update + const matchValue = properties[matching_property] + if (!matchValue) { + return NextResponse.json( + { error: `Missing matching property "${matching_property}" in properties` }, + { status: 400 } + ) + } + + const searchResult = await client.search(object_type, { + filterGroups: [{ + filters: [{ + propertyName: matching_property, + operator: 'EQ', + value: String(matchValue), + }], + }], + limit: 1, + }) + + if (searchResult.results.length > 0) { + const existingId = searchResult.results[0].id + result = await client.updateRecord(object_type, existingId, properties) + log.info(`Updated ${object_type}/${existingId} for workspace=${workspaceId}`) + return NextResponse.json(stripNulls({ ...result, action: 'updated' })) + } + } + + // Create new record + result = await client.createRecord(object_type, properties) + log.info(`Created ${object_type}/${result.id} for workspace=${workspaceId}`) + return NextResponse.json(stripNulls({ ...result, action: 'created' }), { status: 201 }) + } catch (e) { + log.error(`Create/upsert record failed: ${e}`) + const msg = e instanceof Error ? e.message : 'Unknown error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/agent/hubspot/search/route.test.ts b/src/app/api/agent/hubspot/search/route.test.ts new file mode 100644 index 00000000..5cf9d799 --- /dev/null +++ b/src/app/api/agent/hubspot/search/route.test.ts @@ -0,0 +1,223 @@ +import { NextRequest } from 'next/server'; +import { POST } from './route'; + +/** + * Tests for POST /api/agent/hubspot/search + * HS-A01 through HS-A07 + */ + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('@/lib/agent/auth', () => ({ + validateAgentRequest: vi.fn(), +})); + +vi.mock('@/lib/agent/session', () => ({ + resolveSession: vi.fn(), +})); + +vi.mock('@/lib/agent/rate-limit', () => ({ + rateLimitResponse: vi.fn(), +})); + +vi.mock('@/lib/integrations/hubspot/config', () => ({ + resolveHubSpotConnectionAdmin: vi.fn(), + getHubSpotConnectionCredentialsAdmin: vi.fn(), +})); + +vi.mock('@/lib/integrations/hubspot/client', () => ({ + createHubSpotClient: vi.fn(), +})); + +vi.mock('@/lib/utils/logger', () => ({ + createModuleLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +import { validateAgentRequest } from '@/lib/agent/auth'; +import { resolveSession } from '@/lib/agent/session'; +import { rateLimitResponse } from '@/lib/agent/rate-limit'; +import { + resolveHubSpotConnectionAdmin, + getHubSpotConnectionCredentialsAdmin, +} from '@/lib/integrations/hubspot/config'; +import { createHubSpotClient } from '@/lib/integrations/hubspot/client'; + +const mockValidate = validateAgentRequest as ReturnType; +const mockResolveSession = resolveSession as ReturnType; +const mockRateLimitResponse = rateLimitResponse as ReturnType; +const mockResolveConnection = resolveHubSpotConnectionAdmin as ReturnType; +const mockGetCredentials = getHubSpotConnectionCredentialsAdmin as ReturnType; +const mockCreateClient = createHubSpotClient as ReturnType; + +const TEST_WORKSPACE_ID = 'ws-test-123'; +const TEST_CONNECTION = { + id: 'conn-1', + workspace_id: TEST_WORKSPACE_ID, + is_active: true, + hub_id: '12345', +}; +const TEST_CREDENTIALS = { + token: 'test-token', + authType: 'private_app' as const, + connectionId: 'conn-1', + connectionName: 'Test', + refreshToken: null, + tokenExpiresAt: null, + hubId: '12345', + isActive: true, + status: 'connected', +}; + +function makeRequest(body: Record) { + return new NextRequest('http://localhost:3000/api/agent/hubspot/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('POST /api/agent/hubspot/search', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidate.mockReturnValue(true); + mockRateLimitResponse.mockReturnValue(null); + mockResolveSession.mockResolvedValue({ + workspaceId: TEST_WORKSPACE_ID, + status: 'running', + sessionUUID: 'uuid-1', + }); + mockResolveConnection.mockResolvedValue(TEST_CONNECTION); + mockGetCredentials.mockResolvedValue(TEST_CREDENTIALS); + }); + + // HS-A01: Agent search contacts with valid session and filters + it('HS-A01: returns search results with filters', async () => { + const mockSearch = vi.fn().mockResolvedValue({ + total: 2, + results: [ + { id: '101', properties: { email: 'a@test.com', firstname: 'Alice' } }, + { id: '102', properties: { email: 'b@test.com', firstname: 'Bob' } }, + ], + }); + mockCreateClient.mockReturnValue({ search: mockSearch }); + + const res = await POST( + makeRequest({ + session_id: 'sess-1', + object_type: 'contacts', + filters: [{ filters: [{ propertyName: 'email', operator: 'CONTAINS_TOKEN', value: 'test.com' }] }], + properties: ['email', 'firstname'], + limit: 10, + }) + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.total).toBe(2); + expect(data.results).toHaveLength(2); + expect(mockSearch).toHaveBeenCalledWith('contacts', expect.objectContaining({ + properties: ['email', 'firstname'], + limit: 10, + })); + }); + + // HS-A02: Auto-connection selection when no connection_id + it('HS-A02: auto-selects first active connection', async () => { + const mockSearch = vi.fn().mockResolvedValue({ total: 0, results: [] }); + mockCreateClient.mockReturnValue({ search: mockSearch }); + + await POST(makeRequest({ session_id: 'sess-1', object_type: 'contacts' })); + + expect(mockResolveConnection).toHaveBeenCalledWith(TEST_WORKSPACE_ID, undefined); + }); + + // HS-A03: Explicit connection_id + it('HS-A03: uses specified connection_id', async () => { + const mockSearch = vi.fn().mockResolvedValue({ total: 0, results: [] }); + mockCreateClient.mockReturnValue({ search: mockSearch }); + + await POST( + makeRequest({ session_id: 'sess-1', object_type: 'contacts', connection_id: 'conn-explicit' }) + ); + + expect(mockResolveConnection).toHaveBeenCalledWith(TEST_WORKSPACE_ID, 'conn-explicit'); + }); + + // HS-A04: No active connection + it('HS-A04: returns 404 when no active connection', async () => { + mockResolveConnection.mockResolvedValue(null); + + const res = await POST( + makeRequest({ session_id: 'sess-1', object_type: 'contacts' }) + ); + + expect(res.status).toBe(404); + const data = await res.json(); + expect(data.error).toContain('No active HubSpot connection'); + }); + + // HS-A05: Invalid session + it('HS-A05: returns 404 when session is invalid', async () => { + mockResolveSession.mockRejectedValue(new Error('Session not found')); + + const res = await POST( + makeRequest({ session_id: 'bad-sess', object_type: 'contacts' }) + ); + + expect(res.status).toBe(404); + const data = await res.json(); + expect(data.error).toBe('Session not found'); + }); + + // HS-A06: Unauthorized + it('HS-A06: returns 401 when unauthorized', async () => { + mockValidate.mockReturnValue(false); + + const res = await POST( + makeRequest({ session_id: 'sess-1', object_type: 'contacts' }) + ); + + expect(res.status).toBe(401); + }); + + // HS-A07: Cross-workspace isolation + it('HS-A07: returns 404 when connection belongs to different workspace', async () => { + mockResolveConnection.mockResolvedValue(null); // Admin resolver checks workspace_id + + const res = await POST( + makeRequest({ + session_id: 'sess-1', + object_type: 'contacts', + connection_id: 'conn-other-workspace', + }) + ); + + expect(res.status).toBe(404); + }); + + it('returns 400 for invalid object_type', async () => { + const res = await POST( + makeRequest({ session_id: 'sess-1', object_type: 'invalid_type' }) + ); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain('Invalid object_type'); + }); + + it('returns 400 when session_id is missing', async () => { + const res = await POST(makeRequest({ object_type: 'contacts' })); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/agent/hubspot/search/route.ts b/src/app/api/agent/hubspot/search/route.ts new file mode 100644 index 00000000..d73cbd70 --- /dev/null +++ b/src/app/api/agent/hubspot/search/route.ts @@ -0,0 +1,133 @@ +/** + * POST /api/agent/hubspot/search + * + * Agent endpoint to search HubSpot CRM objects. + * Uses HubSpot Search API with filters, properties, and pagination. + */ + +import { NextRequest, NextResponse } from 'next/server' +import { createModuleLogger } from '@/lib/utils/logger' +import { validateAgentRequest } from '@/lib/agent/auth' +import { rateLimitResponse } from '@/lib/agent/rate-limit' +import { resolveSession } from '@/lib/agent/session' +import { resolveHubSpotConnectionAdmin, getHubSpotConnectionCredentialsAdmin } from '@/lib/integrations/hubspot/config' +import { createHubSpotClient } from '@/lib/integrations/hubspot/client' +import { STANDARD_OBJECT_TYPES } from '@/lib/integrations/hubspot/types' + +const log = createModuleLogger('[API][Agent][HubSpot][Search]') + +/** Known CRM object types (standard + common engagement types) */ +const VALID_OBJECT_TYPES = new Set([ + ...STANDARD_OBJECT_TYPES, + 'line_items', + 'products', + 'quotes', + 'calls', + 'emails', + 'meetings', + 'notes', + 'tasks', +]) + +/** + * Recursively strip null/undefined values from an object to minimize token consumption. + */ +function stripNulls(obj: T): T { + if (obj === null || obj === undefined) return undefined as unknown as T + if (Array.isArray(obj)) return obj.map(stripNulls) as unknown as T + if (typeof obj === 'object') { + const cleaned: Record = {} + for (const [k, v] of Object.entries(obj as Record)) { + if (v !== null && v !== undefined) { + cleaned[k] = stripNulls(v) + } + } + return cleaned as T + } + return obj +} + +export async function POST(req: NextRequest) { + if (!validateAgentRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await req.json() + const { session_id, object_type, filters, properties, limit, after, connection_id } = body + + if (!session_id) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }) + } + + if (!object_type) { + return NextResponse.json({ error: 'Missing object_type' }, { status: 400 }) + } + + if (!VALID_OBJECT_TYPES.has(object_type)) { + return NextResponse.json( + { error: `Invalid object_type "${object_type}". Valid types: ${Array.from(VALID_OBJECT_TYPES).join(', ')}` }, + { status: 400 } + ) + } + + // Resolve session -> workspace + let workspaceId: string + try { + const session = await resolveSession(session_id) + workspaceId = session.workspaceId + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid session' + return NextResponse.json({ error: msg }, { status: 404 }) + } + + // Rate limit per workspace + const limited = rateLimitResponse(workspaceId) + if (limited) return limited + + // Resolve HubSpot connection + const connection = await resolveHubSpotConnectionAdmin(workspaceId, connection_id) + if (!connection) { + return NextResponse.json( + { error: 'No active HubSpot connection found for this workspace' }, + { status: 404 } + ) + } + + // Get credentials + const credentials = await getHubSpotConnectionCredentialsAdmin(connection.id) + if (!credentials) { + return NextResponse.json( + { error: 'Failed to retrieve HubSpot credentials' }, + { status: 500 } + ) + } + + // Create client + const client = createHubSpotClient({ + token: credentials.token, + authType: credentials.authType, + connectionId: credentials.connectionId, + refreshToken: credentials.refreshToken || undefined, + tokenExpiresAt: credentials.tokenExpiresAt, + }) + + // Execute search + const searchResult = await client.search(object_type, { + filterGroups: filters || [], + properties: properties || [], + limit: Math.min(limit || 10, 100), + after: after || '0', + }) + + log.info( + `Search ${object_type}: ${searchResult.total} results for workspace=${workspaceId}` + ) + + return NextResponse.json(stripNulls(searchResult)) + } catch (e) { + log.error(`HubSpot search failed: ${e}`) + const msg = e instanceof Error ? e.message : 'Unknown error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/agent/hubspot/sync/route.test.ts b/src/app/api/agent/hubspot/sync/route.test.ts new file mode 100644 index 00000000..7e6d7311 --- /dev/null +++ b/src/app/api/agent/hubspot/sync/route.test.ts @@ -0,0 +1,212 @@ +import { NextRequest } from 'next/server'; +import { POST, GET } from './route'; + +/** + * Tests for GET/POST /api/agent/hubspot/sync + * HS-A19 through HS-A22 + */ + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('@/lib/agent/auth', () => ({ + validateAgentRequest: vi.fn(), +})); + +vi.mock('@/lib/agent/session', () => ({ + resolveSession: vi.fn(), +})); + +vi.mock('@/lib/agent/rate-limit', () => ({ + rateLimitResponse: vi.fn(), +})); + +vi.mock('@/lib/integrations/hubspot/config', () => ({ + resolveHubSpotConnectionAdmin: vi.fn(), +})); + +vi.mock('@/lib/integrations/hubspot/polling', () => ({ + syncObjectType: vi.fn(), +})); + +vi.mock('@/lib/integrations/hubspot/client', () => ({ + createHubSpotClientForConnection: vi.fn(), +})); + +vi.mock('@/lib/supabase/admin', () => ({ + createAdminClient: vi.fn(), +})); + +vi.mock('@/lib/utils/logger', () => ({ + createModuleLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +import { validateAgentRequest } from '@/lib/agent/auth'; +import { resolveSession } from '@/lib/agent/session'; +import { rateLimitResponse } from '@/lib/agent/rate-limit'; +import { resolveHubSpotConnectionAdmin } from '@/lib/integrations/hubspot/config'; +import { syncObjectType } from '@/lib/integrations/hubspot/polling'; +import { createHubSpotClientForConnection } from '@/lib/integrations/hubspot/client'; +import { createAdminClient } from '@/lib/supabase/admin'; + +const mockValidate = validateAgentRequest as ReturnType; +const mockResolveSession = resolveSession as ReturnType; +const mockRateLimitResponse = rateLimitResponse as ReturnType; +const mockResolveConnection = resolveHubSpotConnectionAdmin as ReturnType; +const mockSyncObjectType = syncObjectType as ReturnType; +const mockCreateClientForConnection = createHubSpotClientForConnection as ReturnType; +const mockCreateAdminClient = createAdminClient as ReturnType; + +const TEST_WORKSPACE_ID = 'ws-sync-test'; +const TEST_CONNECTION = { + id: 'conn-1', + workspace_id: TEST_WORKSPACE_ID, + hub_id: '12345', +}; + +function makePostRequest(body: Record) { + return new NextRequest('http://localhost:3000/api/agent/hubspot/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +function makeGetRequest(params: Record) { + const url = new URL('http://localhost:3000/api/agent/hubspot/sync'); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + return new NextRequest(url.toString(), { method: 'GET' }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('POST /api/agent/hubspot/sync', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidate.mockReturnValue(true); + mockRateLimitResponse.mockReturnValue(null); + mockResolveSession.mockResolvedValue({ + workspaceId: TEST_WORKSPACE_ID, + status: 'running', + sessionUUID: 'uuid-1', + }); + mockResolveConnection.mockResolvedValue(TEST_CONNECTION); + + // Default: no sync currently running + const mockFrom = vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue({ data: [] }), + }), + }), + }), + }); + mockCreateAdminClient.mockReturnValue({ from: mockFrom }); + mockCreateClientForConnection.mockResolvedValue({}); + }); + + // HS-A19: Trigger manual sync + it('HS-A19: triggers manual sync for all object types', async () => { + mockSyncObjectType.mockResolvedValue({ + objectType: 'contacts', + recordsSynced: 10, + status: 'success', + durationMs: 1000, + }); + + const res = await POST( + makePostRequest({ session_id: 'sess-1' }) + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.status).toBe('initiated'); + expect(data.results).toBeDefined(); + // 4 standard types + expect(mockSyncObjectType).toHaveBeenCalledTimes(4); + }); + + // HS-A20: Already running -> 409 + it('HS-A20: returns 409 when sync is already running', async () => { + const mockFrom = vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue({ + data: [{ object_type: 'contacts', status: 'syncing' }], + }), + }), + }), + }), + }); + mockCreateAdminClient.mockReturnValue({ from: mockFrom }); + + const res = await POST( + makePostRequest({ session_id: 'sess-1' }) + ); + + expect(res.status).toBe(409); + const data = await res.json(); + expect(data.error).toContain('already in progress'); + }); +}); + +describe('GET /api/agent/hubspot/sync', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidate.mockReturnValue(true); + mockRateLimitResponse.mockReturnValue(null); + mockResolveSession.mockResolvedValue({ + workspaceId: TEST_WORKSPACE_ID, + status: 'running', + sessionUUID: 'uuid-1', + }); + mockResolveConnection.mockResolvedValue(TEST_CONNECTION); + }); + + // HS-A21: Get sync status + it('HS-A21: returns per-object sync state', async () => { + const syncStates = [ + { object_type: 'contacts', status: 'idle', last_sync_at: '2026-03-30T00:00:00Z', records_synced: 150, last_error: null }, + { object_type: 'companies', status: 'idle', last_sync_at: '2026-03-30T00:00:00Z', records_synced: 50, last_error: null }, + { object_type: 'deals', status: 'error', last_sync_at: null, records_synced: 0, last_error: 'Rate limited' }, + ]; + + const mockFrom = vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + order: vi.fn().mockResolvedValue({ data: syncStates, error: null }), + }), + }), + }); + mockCreateAdminClient.mockReturnValue({ from: mockFrom }); + + const res = await GET( + makeGetRequest({ session_id: 'sess-1' }) + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.connection_id).toBe('conn-1'); + expect(data.sync_states).toHaveLength(3); + expect(data.sync_states[0].object_type).toBe('contacts'); + }); +}); + +describe('GET /api/agent/hubspot/connections (via sync route)', () => { + // HS-A22 is tested implicitly through the connections endpoint + it('returns 401 when unauthorized', async () => { + mockValidate.mockReturnValue(false); + const res = await GET(makeGetRequest({ session_id: 'sess-1' })); + expect(res.status).toBe(401); + }); +}); diff --git a/src/app/api/agent/hubspot/sync/route.ts b/src/app/api/agent/hubspot/sync/route.ts new file mode 100644 index 00000000..c9e30951 --- /dev/null +++ b/src/app/api/agent/hubspot/sync/route.ts @@ -0,0 +1,163 @@ +/** + * POST /api/agent/hubspot/sync — trigger manual sync for specific object types + * GET /api/agent/hubspot/sync — return sync status per object type + */ + +import { NextRequest, NextResponse } from 'next/server' +import { createModuleLogger } from '@/lib/utils/logger' +import { validateAgentRequest } from '@/lib/agent/auth' +import { rateLimitResponse } from '@/lib/agent/rate-limit' +import { resolveSession } from '@/lib/agent/session' +import { resolveHubSpotConnectionAdmin } from '@/lib/integrations/hubspot/config' +import { syncObjectType } from '@/lib/integrations/hubspot/polling' +import { createHubSpotClientForConnection } from '@/lib/integrations/hubspot/client' +import { createAdminClient } from '@/lib/supabase/admin' +import { STANDARD_OBJECT_TYPES, type HubSpotObjectType } from '@/lib/integrations/hubspot/types' + +const log = createModuleLogger('[API][Agent][HubSpot][Sync]') + +/** + * POST — Trigger manual sync for specific object types. + * Body: { session_id, object_types?, connection_id? } + */ +export async function POST(req: NextRequest) { + if (!validateAgentRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await req.json() + const { session_id, object_types, connection_id } = body + + if (!session_id) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }) + } + + let workspaceId: string + try { + const session = await resolveSession(session_id) + workspaceId = session.workspaceId + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid session' + return NextResponse.json({ error: msg }, { status: 404 }) + } + + const limited = rateLimitResponse(workspaceId) + if (limited) return limited + + const connection = await resolveHubSpotConnectionAdmin(workspaceId, connection_id) + if (!connection) { + return NextResponse.json({ error: 'No active HubSpot connection found' }, { status: 404 }) + } + + // Check if any sync is already running + const supabase = createAdminClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: runningSync } = await (supabase as any) + .from('hubspot_sync_state') + .select('object_type, status') + .eq('connection_id', connection.id) + .eq('status', 'syncing') + .limit(1) + + if (runningSync && runningSync.length > 0) { + return NextResponse.json( + { error: 'Sync already in progress', running: runningSync[0].object_type }, + { status: 409 } + ) + } + + // Determine object types to sync + const typesToSync: HubSpotObjectType[] = object_types && Array.isArray(object_types) + ? object_types.filter((t: string) => STANDARD_OBJECT_TYPES.includes(t as HubSpotObjectType)) as HubSpotObjectType[] + : [...STANDARD_OBJECT_TYPES] + + if (typesToSync.length === 0) { + return NextResponse.json( + { error: 'No valid object types specified' }, + { status: 400 } + ) + } + + // Create client and trigger sync + const client = await createHubSpotClientForConnection(connection.id) + + const results = [] + for (const objectType of typesToSync) { + const result = await syncObjectType(client, connection.id, workspaceId, objectType) + results.push(result) + } + + log.info(`Manual sync triggered for workspace=${workspaceId}, types=${typesToSync.join(',')}`) + + return NextResponse.json({ + status: 'initiated', + results, + }) + } catch (e) { + log.error(`Trigger sync failed: ${e}`) + const msg = e instanceof Error ? e.message : 'Unknown error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +/** + * GET — Return sync status per object type. + * Query params: session_id, connection_id? + */ +export async function GET(req: NextRequest) { + if (!validateAgentRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const { searchParams } = new URL(req.url) + const sessionId = searchParams.get('session_id') + const connectionId = searchParams.get('connection_id') || undefined + + if (!sessionId) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }) + } + + let workspaceId: string + try { + const session = await resolveSession(sessionId) + workspaceId = session.workspaceId + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid session' + return NextResponse.json({ error: msg }, { status: 404 }) + } + + const limited = rateLimitResponse(workspaceId) + if (limited) return limited + + const connection = await resolveHubSpotConnectionAdmin(workspaceId, connectionId) + if (!connection) { + return NextResponse.json({ error: 'No active HubSpot connection found' }, { status: 404 }) + } + + const supabase = createAdminClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: syncStates, error } = await (supabase as any) + .from('hubspot_sync_state') + .select('object_type, status, last_sync_at, records_synced, last_error') + .eq('connection_id', connection.id) + .order('object_type') + + if (error) { + return NextResponse.json({ error: 'Failed to fetch sync status' }, { status: 500 }) + } + + log.info(`Sync status requested for workspace=${workspaceId}`) + + return NextResponse.json({ + connection_id: connection.id, + hub_id: connection.hub_id, + sync_states: syncStates || [], + }) + } catch (e) { + log.error(`Get sync status failed: ${e}`) + const msg = e instanceof Error ? e.message : 'Unknown error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/agent/pg/explain/route.ts b/src/app/api/agent/pg/explain/route.ts new file mode 100644 index 00000000..ae7482fc --- /dev/null +++ b/src/app/api/agent/pg/explain/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server' +import { withPgAgentHandler } from '@/lib/agent/pg-handler' +import { explainQuery } from '@/lib/integrations/postgres/client' + +export const POST = withPgAgentHandler( + async (req, { dataSource }) => { + const body = await req.json() + const { query } = body + + if (!query) { + return NextResponse.json({ error: 'Missing query' }, { status: 400 }) + } + + try { + const result = await explainQuery(dataSource, query) + return NextResponse.json(result) + } catch (e) { + const message = e instanceof Error ? e.message : 'EXPLAIN failed' + const status = message.includes('dangerous') || message.includes('Only SELECT') + ? 400 + : 500 + return NextResponse.json({ error: message }, { status }) + } + }, + { maxRequests: 20 }, +) diff --git a/src/app/api/agent/pg/list-columns/route.ts b/src/app/api/agent/pg/list-columns/route.ts new file mode 100644 index 00000000..83f56765 --- /dev/null +++ b/src/app/api/agent/pg/list-columns/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server' +import { withPgAgentHandler } from '@/lib/agent/pg-handler' +import { listColumns } from '@/lib/integrations/postgres/client' + +export const GET = withPgAgentHandler(async (req, { dataSource }) => { + const table = req.nextUrl.searchParams.get('table') + const schema = req.nextUrl.searchParams.get('schema') ?? 'public' + + if (!table) { + return NextResponse.json({ error: 'Missing table parameter' }, { status: 400 }) + } + + const columns = await listColumns(dataSource, table, schema) + return NextResponse.json({ columns, table, schema }) +}) diff --git a/src/app/api/agent/pg/list-schemas/route.ts b/src/app/api/agent/pg/list-schemas/route.ts new file mode 100644 index 00000000..675b7aa2 --- /dev/null +++ b/src/app/api/agent/pg/list-schemas/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from 'next/server' +import { withPgAgentHandler } from '@/lib/agent/pg-handler' +import { listSchemas } from '@/lib/integrations/postgres/client' + +export const GET = withPgAgentHandler(async (_req, { dataSource }) => { + const schemas = await listSchemas(dataSource) + return NextResponse.json({ schemas }) +}) diff --git a/src/app/api/agent/pg/list-tables/route.ts b/src/app/api/agent/pg/list-tables/route.ts new file mode 100644 index 00000000..0b8a07ef --- /dev/null +++ b/src/app/api/agent/pg/list-tables/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server' +import { withPgAgentHandler } from '@/lib/agent/pg-handler' +import { listTables } from '@/lib/integrations/postgres/client' + +export const GET = withPgAgentHandler(async (req, { dataSource }) => { + const schema = req.nextUrl.searchParams.get('schema') ?? 'public' + const tables = await listTables(dataSource, schema) + return NextResponse.json({ tables, schema }) +}) diff --git a/src/app/api/agent/pg/query/route.ts b/src/app/api/agent/pg/query/route.ts new file mode 100644 index 00000000..83c5d30a --- /dev/null +++ b/src/app/api/agent/pg/query/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server' +import { withPgAgentHandler } from '@/lib/agent/pg-handler' +import { executeQuery } from '@/lib/integrations/postgres/client' +import { createModuleLogger } from '@/lib/utils/logger' + +const log = createModuleLogger('[API][Agent][PgQuery]') + +export const POST = withPgAgentHandler( + async (req, { workspaceId, dataSource }) => { + const body = await req.json() + const { query } = body + + if (!query) { + return NextResponse.json({ error: 'Missing query' }, { status: 400 }) + } + + // Audit log + log.warn( + `[AUDIT] PG query workspace=${workspaceId} ds=${dataSource.id} query=${query.substring(0, 500)}`, + ) + + try { + const result = await executeQuery(dataSource, query) + return NextResponse.json(result) + } catch (e) { + const message = e instanceof Error ? e.message : 'Query execution failed' + // Distinguish validation errors from execution errors + const status = message.includes('dangerous') || message.includes('Only SELECT') || message.includes('multiple') || message.includes('empty') || message.includes('length') + ? 400 + : 500 + return NextResponse.json({ error: message }, { status }) + } + }, + { maxRequests: 20 }, +) diff --git a/src/app/api/agent/pg/stats/route.ts b/src/app/api/agent/pg/stats/route.ts new file mode 100644 index 00000000..63a91570 --- /dev/null +++ b/src/app/api/agent/pg/stats/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server' +import { withPgAgentHandler } from '@/lib/agent/pg-handler' +import { getTableStats } from '@/lib/integrations/postgres/client' + +export const GET = withPgAgentHandler(async (req, { dataSource }) => { + const schema = req.nextUrl.searchParams.get('schema') ?? 'public' + const stats = await getTableStats(dataSource, schema) + return NextResponse.json({ stats, schema }) +}) diff --git a/src/app/api/cron/hubspot-sync/route.test.ts b/src/app/api/cron/hubspot-sync/route.test.ts new file mode 100644 index 00000000..97ec2f6b --- /dev/null +++ b/src/app/api/cron/hubspot-sync/route.test.ts @@ -0,0 +1,208 @@ +/// +/** + * Tests for POST /api/cron/hubspot-sync + * + * HS-S01: Unauthorized without CRON_SECRET + * HS-S02: No connections returns success with 0 processed + * HS-S03: Syncs active connections + * HS-S04: Handles connection sync errors gracefully + * HS-S06: Returns summary with per-connection results + * HS-S07: Handles DB fetch failure + */ + +import { NextRequest } from 'next/server' + +// --------------------------------------------------------------------------- +// Mocks — must be declared before imports +// --------------------------------------------------------------------------- + +const mockVerifyCronAuth = vi.fn().mockReturnValue(true) +const mockConnections = vi.fn() +const mockSyncConnection = vi.fn() + +vi.mock('@/lib/middleware/cron-auth', () => ({ + verifyCronAuth: (...args: unknown[]) => mockVerifyCronAuth(...args), +})) + +vi.mock('@/lib/supabase/admin', () => { + const chainMock = () => { + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + order: vi.fn().mockImplementation(() => mockConnections()), + }), + }), + }), + } + } + return { + createAdminClient: vi.fn().mockReturnValue({ + from: vi.fn().mockImplementation(() => chainMock()), + }), + } +}) + +vi.mock('@/lib/integrations/hubspot/polling', () => ({ + syncConnection: (...args: unknown[]) => mockSyncConnection(...args), +})) + +vi.mock('@/lib/utils/logger', () => ({ + createModuleLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +// Import after mocks +import { POST } from './route' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRequest() { + return new NextRequest('http://localhost:3000/api/cron/hubspot-sync', { + method: 'POST', + headers: { 'x-cron-secret': 'test-secret' }, + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('POST /api/cron/hubspot-sync', () => { + beforeEach(() => { + vi.clearAllMocks() + mockVerifyCronAuth.mockReturnValue(true) + mockConnections.mockReturnValue({ data: [], error: null }) + mockSyncConnection.mockResolvedValue({ + connectionId: 'conn-1', + hubId: '12345', + results: [], + totalRecordsSynced: 0, + totalErrors: 0, + durationMs: 100, + }) + }) + + // HS-S01: Unauthorized + it('returns 401 when cron auth fails', async () => { + mockVerifyCronAuth.mockReturnValue(false) + const res = await POST(makeRequest()) + expect(res.status).toBe(401) + }) + + // HS-S02: No connections + it('returns success with 0 processed when no connections', async () => { + mockConnections.mockReturnValue({ data: [], error: null }) + + const res = await POST(makeRequest()) + expect(res.status).toBe(200) + + const body = await res.json() + expect(body.success).toBe(true) + expect(body.summary.connections_processed).toBe(0) + }) + + // HS-S03: Syncs active connections + it('syncs active connections and returns results', async () => { + mockConnections.mockReturnValue({ + data: [ + { id: 'conn-1', workspace_id: 'ws-1', hub_id: '111', name: 'HS 1' }, + { id: 'conn-2', workspace_id: 'ws-2', hub_id: '222', name: 'HS 2' }, + ], + error: null, + }) + + mockSyncConnection + .mockResolvedValueOnce({ + connectionId: 'conn-1', + hubId: '111', + results: [{ objectType: 'contacts', recordsSynced: 50, status: 'success' }], + totalRecordsSynced: 50, + totalErrors: 0, + durationMs: 500, + }) + .mockResolvedValueOnce({ + connectionId: 'conn-2', + hubId: '222', + results: [{ objectType: 'contacts', recordsSynced: 30, status: 'success' }], + totalRecordsSynced: 30, + totalErrors: 0, + durationMs: 300, + }) + + const res = await POST(makeRequest()) + expect(res.status).toBe(200) + + const body = await res.json() + expect(body.success).toBe(true) + expect(body.summary.connections_processed).toBe(2) + expect(body.summary.total_records_synced).toBe(80) + expect(body.summary.total_errors).toBe(0) + expect(mockSyncConnection).toHaveBeenCalledTimes(2) + }) + + // HS-S04: Handles sync errors gracefully + it('handles connection sync errors gracefully', async () => { + mockConnections.mockReturnValue({ + data: [ + { id: 'conn-fail', workspace_id: 'ws-1', hub_id: '111', name: 'HS Fail' }, + ], + error: null, + }) + + mockSyncConnection.mockRejectedValue(new Error('Sync failed')) + + const res = await POST(makeRequest()) + expect(res.status).toBe(200) // Job itself succeeds + + const body = await res.json() + expect(body.success).toBe(true) + expect(body.summary.total_errors).toBe(1) + }) + + // HS-S06: Returns summary + it('includes per-connection results in response', async () => { + mockConnections.mockReturnValue({ + data: [ + { id: 'conn-1', workspace_id: 'ws-1', hub_id: '111', name: 'HS 1' }, + ], + error: null, + }) + + mockSyncConnection.mockResolvedValue({ + connectionId: 'conn-1', + hubId: '111', + results: [ + { objectType: 'contacts', recordsSynced: 25, status: 'success', durationMs: 200 }, + { objectType: 'companies', recordsSynced: 10, status: 'success', durationMs: 150 }, + ], + totalRecordsSynced: 35, + totalErrors: 0, + durationMs: 350, + }) + + const res = await POST(makeRequest()) + const body = await res.json() + + expect(body.connections).toHaveLength(1) + expect(body.connections[0].objects).toHaveLength(2) + expect(body.connections[0].records_synced).toBe(35) + }) + + // HS-S07: DB fetch failure + it('returns 500 when DB fetch fails', async () => { + mockConnections.mockReturnValue({ + data: null, + error: { message: 'Database error' }, + }) + + const res = await POST(makeRequest()) + expect(res.status).toBe(500) + }) +}) diff --git a/src/app/api/cron/hubspot-sync/route.ts b/src/app/api/cron/hubspot-sync/route.ts new file mode 100644 index 00000000..5608c498 --- /dev/null +++ b/src/app/api/cron/hubspot-sync/route.ts @@ -0,0 +1,153 @@ +/** + * Vercel Cron: HubSpot Sync + * + * Syncs HubSpot CRM data for all active connections. + * Triggered by Vercel Cron scheduler. + * + * Security: Requires CRON_SECRET header for authentication. + * + * POST /api/cron/hubspot-sync + */ + +import { NextResponse } from 'next/server' +import { createAdminClient } from '@/lib/supabase/admin' +import { syncConnection, type ConnectionSyncResult } from '@/lib/integrations/hubspot/polling' +import { createModuleLogger } from '@/lib/utils/logger' +import { verifyCronAuth } from '@/lib/middleware/cron-auth' + +const log = createModuleLogger('[Cron HubSpot Sync]') + +// Maximum execution time for Vercel Pro (5 minutes) +export const maxDuration = 300 + +/** + * POST /api/cron/hubspot-sync + * + * Iterates all active HubSpot connections and syncs each one. + * Respects the 5-minute Vercel execution limit by tracking elapsed time. + */ +export async function POST(request: Request) { + const startTime = Date.now() + + // Verify cron secret + if (!verifyCronAuth(request)) { + log.error('Unauthorized request — invalid or missing CRON_SECRET') + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + log.info('HubSpot sync job started') + + try { + const supabase = createAdminClient() + + // Get all active, connected HubSpot connections + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: connections, error: fetchError } = await (supabase as any) + .from('hubspot_connections') + .select('id, workspace_id, hub_id, name') + .eq('is_active', true) + .eq('status', 'connected') + .order('created_at', { ascending: true }) + + if (fetchError) { + log.error('Failed to fetch HubSpot connections:', fetchError) + return NextResponse.json( + { error: 'Failed to fetch connections', details: String(fetchError) }, + { status: 500 } + ) + } + + if (!connections || connections.length === 0) { + log.info('No active HubSpot connections found') + return NextResponse.json({ + success: true, + duration_ms: Date.now() - startTime, + summary: { + connections_processed: 0, + total_records_synced: 0, + total_errors: 0, + }, + }) + } + + log.info(`Processing ${connections.length} HubSpot connections`) + + const results: ConnectionSyncResult[] = [] + let totalRecords = 0 + let totalErrors = 0 + + // Process each connection sequentially + for (const conn of connections) { + // Check if we're approaching the time limit (leave 30s buffer) + const elapsed = Date.now() - startTime + const remainingMs = (maxDuration * 1000) - elapsed - 30_000 + if (remainingMs <= 0) { + log.warn( + `Approaching time limit after ${elapsed}ms, ` + + `skipping remaining ${connections.length - results.length} connections` + ) + break + } + + try { + log.info(`Syncing connection ${conn.id} (hub: ${conn.hub_id}, name: ${conn.name})`) + const result = await syncConnection(conn.id) + results.push(result) + totalRecords += result.totalRecordsSynced + totalErrors += result.totalErrors + } catch (err) { + log.error(`Error syncing connection ${conn.id}:`, err) + totalErrors++ + results.push({ + connectionId: conn.id, + hubId: conn.hub_id, + results: [], + totalRecordsSynced: 0, + totalErrors: 1, + durationMs: 0, + }) + } + } + + const duration = Date.now() - startTime + log.info( + `HubSpot sync completed in ${duration}ms: ` + + `${totalRecords} records synced across ${results.length} connections` + ) + + return NextResponse.json({ + success: true, + duration_ms: duration, + summary: { + connections_processed: results.length, + total_records_synced: totalRecords, + total_errors: totalErrors, + }, + connections: results.map((r) => ({ + connection_id: r.connectionId, + hub_id: r.hubId, + records_synced: r.totalRecordsSynced, + errors: r.totalErrors, + duration_ms: r.durationMs, + objects: r.results.map((o) => ({ + type: o.objectType, + records: o.recordsSynced, + status: o.status, + error: o.error, + })), + })), + }) + } catch (err) { + const duration = Date.now() - startTime + log.error('HubSpot sync job failed:', err) + + return NextResponse.json( + { + success: false, + duration_ms: duration, + error: err instanceof Error ? err.message : 'Unknown error', + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/data-sources/[id]/route.ts b/src/app/api/data-sources/[id]/route.ts new file mode 100644 index 00000000..721427df --- /dev/null +++ b/src/app/api/data-sources/[id]/route.ts @@ -0,0 +1,142 @@ +/** + * GET /api/data-sources/[id] — Get a single data source + * PATCH /api/data-sources/[id] — Update individual fields + * DELETE /api/data-sources/[id] — Delete a data source + */ + +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { requireWorkspaceId } from '@/lib/supabase/helpers' +import { encrypt } from '@/lib/crypto/encryption' +import { validatePostgresHost } from '@/lib/integrations/postgres/security' +import type { UpdateDataSourceRequest } from '@/lib/integrations/postgres/types' + +interface RouteParams { + params: Promise<{ id: string }> +} + +// Select columns excluding password_encrypted (never return to client) +const PUBLIC_COLUMNS = 'id, workspace_id, source_type, name, host, port, database_name, username, ssl_mode, config_json, status, last_validated_at, last_error, is_active, created_at, updated_at' + +export async function GET(_req: NextRequest, { params }: RouteParams) { + try { + const { id } = await params + const workspaceId = await requireWorkspaceId() + const supabase = await createClient() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error } = await (supabase as any) + .from('data_sources') + .select(PUBLIC_COLUMNS) + .eq('workspace_id', workspaceId) + .eq('id', id) + .single() + + if (error) { + if (error.code === 'PGRST116') { + return NextResponse.json({ error: 'Data source not found' }, { status: 404 }) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data_source: data }) + } catch (e) { + const message = e instanceof Error ? e.message : 'Failed to get data source' + return NextResponse.json({ error: message }, { status: 500 }) + } +} + +export async function PATCH(req: NextRequest, { params }: RouteParams) { + try { + const { id } = await params + const workspaceId = await requireWorkspaceId() + const body: UpdateDataSourceRequest = await req.json() + + // Build update object (only include fields that were provided) + const updates: Record = {} + + if (body.name !== undefined) updates.name = body.name.trim() + if (body.host !== undefined) { + // SSRF validate new host + const ssrfError = await validatePostgresHost(body.host) + if (ssrfError) { + return NextResponse.json( + { error: `Host validation failed: ${ssrfError}` }, + { status: 400 }, + ) + } + updates.host = body.host.trim() + } + if (body.port !== undefined) updates.port = body.port + if (body.database_name !== undefined) updates.database_name = body.database_name.trim() + if (body.username !== undefined) updates.username = body.username.trim() + if (body.password !== undefined) { + updates.password_encrypted = await encrypt(body.password) + } + if (body.ssl_mode !== undefined) updates.ssl_mode = body.ssl_mode + if (body.config_json !== undefined) updates.config_json = body.config_json + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: 'No fields to update' }, { status: 400 }) + } + + // Reset status when connection params change + if (body.host || body.port || body.database_name || body.username || body.password || body.ssl_mode) { + updates.status = 'pending' + updates.last_error = null + } + + const supabase = await createClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error } = await (supabase as any) + .from('data_sources') + .update(updates) + .eq('workspace_id', workspaceId) + .eq('id', id) + .select(PUBLIC_COLUMNS) + .single() + + if (error) { + if (error.code === 'PGRST116') { + return NextResponse.json({ error: 'Data source not found' }, { status: 404 }) + } + if (error.code === '23505') { + return NextResponse.json( + { error: `A data source with that name already exists` }, + { status: 409 }, + ) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data_source: data }) + } catch (e) { + const message = e instanceof Error ? e.message : 'Failed to update data source' + return NextResponse.json({ error: message }, { status: 500 }) + } +} + +export async function DELETE(_req: NextRequest, { params }: RouteParams) { + try { + const { id } = await params + const workspaceId = await requireWorkspaceId() + const supabase = await createClient() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error } = await (supabase as any) + .from('data_sources') + .delete() + .eq('workspace_id', workspaceId) + .eq('id', id) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ deleted: true }) + } catch (e) { + const message = e instanceof Error ? e.message : 'Failed to delete data source' + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/src/app/api/data-sources/[id]/validate/route.ts b/src/app/api/data-sources/[id]/validate/route.ts new file mode 100644 index 00000000..60fb0ff8 --- /dev/null +++ b/src/app/api/data-sources/[id]/validate/route.ts @@ -0,0 +1,58 @@ +/** + * POST /api/data-sources/[id]/validate + * + * Test the database connection and update the data source status. + * Uses the admin client to read the encrypted password, creates a + * temporary connection, and updates status to 'connected' or 'error'. + */ + +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import { requireWorkspaceId } from '@/lib/supabase/helpers' +import { getDataSourceAdmin, updateDataSourceStatus } from '@/lib/integrations/postgres/credentials' +import { testConnection } from '@/lib/integrations/postgres/client' +import type { DataSourceRecord } from '@/lib/integrations/postgres/types' + +interface RouteParams { + params: Promise<{ id: string }> +} + +export async function POST(_req: NextRequest, { params }: RouteParams) { + try { + const { id } = await params + const workspaceId = await requireWorkspaceId() + + // Fetch data source with encrypted password (admin client) + const dataSource = await getDataSourceAdmin(workspaceId, id) + if (!dataSource) { + return NextResponse.json({ error: 'Data source not found' }, { status: 404 }) + } + + // Test the connection + try { + await testConnection(dataSource as DataSourceRecord) + await updateDataSourceStatus(id, 'connected') + return NextResponse.json({ + status: 'connected', + message: 'Connection successful', + }) + } catch (connError) { + const errorMessage = + connError instanceof Error ? connError.message : 'Connection failed' + await updateDataSourceStatus(id, 'error', errorMessage) + return NextResponse.json( + { + status: 'error', + message: errorMessage, + }, + { status: 422 }, + ) + } + } catch (e) { + const message = e instanceof Error ? e.message : 'Validation failed' + if (message === 'No workspace found') { + return NextResponse.json({ error: 'No workspace' }, { status: 404 }) + } + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/src/app/api/data-sources/route.ts b/src/app/api/data-sources/route.ts new file mode 100644 index 00000000..8dec6717 --- /dev/null +++ b/src/app/api/data-sources/route.ts @@ -0,0 +1,111 @@ +/** + * GET /api/data-sources — List all data sources for workspace + * POST /api/data-sources — Create a new data source + */ + +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { requireWorkspaceId } from '@/lib/supabase/helpers' +import { encrypt } from '@/lib/crypto/encryption' +import { validatePostgresHost } from '@/lib/integrations/postgres/security' +import type { CreateDataSourceRequest } from '@/lib/integrations/postgres/types' + +export async function GET() { + try { + const workspaceId = await requireWorkspaceId() + const supabase = await createClient() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error } = await (supabase as any) + .from('data_sources') + .select('id, workspace_id, source_type, name, host, port, database_name, username, ssl_mode, config_json, status, last_validated_at, last_error, is_active, created_at, updated_at') + .eq('workspace_id', workspaceId) + .order('created_at', { ascending: true }) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data_sources: data }) + } catch (e) { + const message = e instanceof Error ? e.message : 'Failed to list data sources' + if (message === 'No workspace found') { + return NextResponse.json({ error: 'No workspace' }, { status: 404 }) + } + return NextResponse.json({ error: message }, { status: 500 }) + } +} + +export async function POST(req: Request) { + try { + const workspaceId = await requireWorkspaceId() + const body: CreateDataSourceRequest = await req.json() + + // Validate required fields + if (!body.name?.trim()) { + return NextResponse.json({ error: 'Name is required' }, { status: 400 }) + } + if (!body.host?.trim()) { + return NextResponse.json({ error: 'Host is required' }, { status: 400 }) + } + if (!body.database_name?.trim()) { + return NextResponse.json({ error: 'Database name is required' }, { status: 400 }) + } + if (!body.username?.trim()) { + return NextResponse.json({ error: 'Username is required' }, { status: 400 }) + } + if (!body.password) { + return NextResponse.json({ error: 'Password is required' }, { status: 400 }) + } + + // SSRF validation + const ssrfError = await validatePostgresHost(body.host) + if (ssrfError) { + return NextResponse.json( + { error: `Host validation failed: ${ssrfError}` }, + { status: 400 }, + ) + } + + // Encrypt password + const passwordEncrypted = await encrypt(body.password) + + const supabase = await createClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error } = await (supabase as any) + .from('data_sources') + .insert({ + workspace_id: workspaceId, + source_type: 'postgres', + name: body.name.trim(), + host: body.host.trim(), + port: body.port ?? 5432, + database_name: body.database_name.trim(), + username: body.username.trim(), + password_encrypted: passwordEncrypted, + ssl_mode: body.ssl_mode ?? 'require', + config_json: body.config_json ?? {}, + status: 'pending', + } as Record) + .select('id, workspace_id, source_type, name, host, port, database_name, username, ssl_mode, config_json, status, last_validated_at, last_error, is_active, created_at, updated_at') + .single() + + if (error) { + if (error.code === '23505') { + return NextResponse.json( + { error: `A data source named "${body.name}" already exists in this workspace` }, + { status: 409 }, + ) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data_source: data }, { status: 201 }) + } catch (e) { + const message = e instanceof Error ? e.message : 'Failed to create data source' + if (message === 'No workspace found') { + return NextResponse.json({ error: 'No workspace' }, { status: 404 }) + } + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/src/app/api/integrations/definitions/route.test.ts b/src/app/api/integrations/definitions/route.test.ts index 7d509101..119ece3c 100644 --- a/src/app/api/integrations/definitions/route.test.ts +++ b/src/app/api/integrations/definitions/route.test.ts @@ -85,12 +85,16 @@ function createSupabaseMock(options?: { definitionsError?: { message: string } | null configsData?: typeof CONFIGS | null configsError?: { message: string } | null + dataSourcesData?: Array<{ id: string; status: string; last_validated_at: string | null }> | null + dataSourcesError?: { message: string } | null }) { const { definitionsData = DEFINITIONS, definitionsError = null, configsData = CONFIGS, configsError = null, + dataSourcesData = [], + dataSourcesError = null, } = options ?? {} // Definitions chain: from().select().order() @@ -113,9 +117,22 @@ function createSupabaseMock(options?: { }), } + // Data sources chain: from().select().eq().eq().eq().limit() + const dsLimitMock = vi.fn().mockResolvedValue({ + data: dataSourcesData, + error: dataSourcesError, + }) + const dsEq3Mock = vi.fn().mockReturnValue({ limit: dsLimitMock }) + const dsEq2Mock = vi.fn().mockReturnValue({ eq: dsEq3Mock }) + const dsEq1Mock = vi.fn().mockReturnValue({ eq: dsEq2Mock }) + const dsChain = { + select: vi.fn().mockReturnValue({ eq: dsEq1Mock }), + } + const fromMock = vi.fn((table: string) => { if (table === 'integration_definitions') return defChain if (table === 'integration_configs') return configChain + if (table === 'data_sources') return dsChain return defChain // fallback }) diff --git a/src/app/api/integrations/definitions/route.ts b/src/app/api/integrations/definitions/route.ts index 8276b5f7..77c84fe0 100644 --- a/src/app/api/integrations/definitions/route.ts +++ b/src/app/api/integrations/definitions/route.ts @@ -87,8 +87,8 @@ export async function GET() { const { workspaceId } = await requireWorkspace() const supabase = await createClient() - // Two parallel queries: definitions (global) + configs (workspace-scoped) - const [definitionsResult, configsResult] = await Promise.all([ + // Three parallel queries: definitions (global) + configs (workspace) + data_sources (workspace) + const [definitionsResult, configsResult, dataSourcesResult] = await Promise.all([ supabase .from('integration_definitions') .select( @@ -99,6 +99,15 @@ export async function GET() { .from('integration_configs') .select('integration_name, status, last_validated_at, is_active') .eq('workspace_id', workspaceId), + // Check if any Postgres data source is connected (for the 'postgres' definition) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (supabase as any) + .from('data_sources') + .select('id, status, last_validated_at') + .eq('workspace_id', workspaceId) + .eq('source_type', 'postgres') + .eq('status', 'connected') + .limit(1), ]) // Use DB rows when available; fall back to hardcoded definitions when the @@ -131,8 +140,27 @@ export async function GET() { configs.map((c) => [c.integration_name, c]) ) + // Check for connected Postgres data sources (separate table) + const hasConnectedPostgres = + !dataSourcesResult.error && + Array.isArray(dataSourcesResult.data) && + dataSourcesResult.data.length > 0 + + const postgresLastValidated = hasConnectedPostgres + ? (dataSourcesResult.data[0] as { last_validated_at: string | null }).last_validated_at + : null + // Enrich each definition with workspace connection status const enriched: IntegrationDefinition[] = definitions.map((def) => { + // Postgres uses the data_sources table, not integration_configs + if (def.name === 'postgres') { + return { + ...def, + is_connected: hasConnectedPostgres, + last_validated_at: postgresLastValidated, + } + } + const config = configMap.get(def.name) const isConnected = !!config && config.is_active && config.status === 'connected' diff --git a/src/app/api/integrations/hubspot/associations/route.ts b/src/app/api/integrations/hubspot/associations/route.ts new file mode 100644 index 00000000..13dba896 --- /dev/null +++ b/src/app/api/integrations/hubspot/associations/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireWorkspace } from '@/lib/supabase/server' +import { + resolveHubSpotConnection, + getHubSpotConnectionCredentials, +} from '@/lib/integrations/hubspot' +import { HubSpotClient } from '@/lib/integrations/hubspot/client' +import { + createAssociation, + batchCreateAssociations, +} from '@/lib/integrations/hubspot/associations' + +// --------------------------------------------------------------------------- +// POST /api/integrations/hubspot/associations +// --------------------------------------------------------------------------- + +export async function POST(request: NextRequest) { + try { + const { workspaceId } = await requireWorkspace() + + const body = await request.json() + + // Resolve connection + const connection = await resolveHubSpotConnection(workspaceId, body.connection_id) + if (!connection) { + return NextResponse.json( + { error: 'No HubSpot connection found for this workspace' }, + { status: 400 } + ) + } + + const creds = await getHubSpotConnectionCredentials(connection.id as string) + if (!creds || !creds.isActive) { + return NextResponse.json( + { error: 'HubSpot connection credentials unavailable' }, + { status: 400 } + ) + } + + const client = new HubSpotClient({ + token: creds.token, + authType: creds.authType, + connectionId: connection.id as string, + refreshToken: creds.refreshToken || undefined, + tokenExpiresAt: creds.tokenExpiresAt, + }) + + // Batch mode + if (Array.isArray(body.associations)) { + const { associations } = body as { + associations: Array<{ + from_type: string + from_id: string + to_type: string + to_id: string + association_type_id?: number + }> + } + + if (associations.length === 0) { + return NextResponse.json( + { error: 'associations array must not be empty' }, + { status: 400 } + ) + } + + const inputs = associations.map((a) => ({ + fromType: a.from_type, + fromId: a.from_id, + toType: a.to_type, + toId: a.to_id, + associationTypeId: a.association_type_id, + })) + + const result = await batchCreateAssociations(client, inputs) + + return NextResponse.json(result, { status: 201 }) + } + + // Single association + const { from_type, from_id, to_type, to_id, association_type_id } = body + + if (!from_type || !from_id || !to_type || !to_id) { + return NextResponse.json( + { error: 'from_type, from_id, to_type, and to_id are required' }, + { status: 400 } + ) + } + + await createAssociation( + client, + from_type, + from_id, + to_type, + to_id, + association_type_id + ) + + return NextResponse.json({ success: true }, { status: 201 }) + } catch (err) { + if (err instanceof Error && err.message === 'Unauthorized') { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + if (err instanceof Error && err.message.includes('No workspace')) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + console.error('[HubSpot Associations] Error:', err) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/integrations/hubspot/connections/[id]/route.ts b/src/app/api/integrations/hubspot/connections/[id]/route.ts new file mode 100644 index 00000000..7fb000ad --- /dev/null +++ b/src/app/api/integrations/hubspot/connections/[id]/route.ts @@ -0,0 +1,142 @@ +/** + * GET /api/integrations/hubspot/connections/[id] — Get a single connection + * DELETE /api/integrations/hubspot/connections/[id] — Delete a connection + * + * Individual connection management. Sensitive fields excluded from GET responses. + * DELETE cascades to sync state, cached records, and associations. + */ + +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { getWorkspaceMembership } from '@/lib/supabase/helpers' +import { createModuleLogger } from '@/lib/utils/logger' + +const log = createModuleLogger('[HubSpot Connection Detail]') + +/** Columns returned in responses (exclude encrypted tokens) */ +const SAFE_COLUMNS = [ + 'id', + 'workspace_id', + 'name', + 'auth_type', + 'hub_id', + 'hub_domain', + 'account_name', + 'scopes', + 'config_json', + 'is_primary', + 'status', + 'last_validated_at', + 'last_error', + 'is_active', + 'created_at', + 'updated_at', +].join(',') + +type RouteContext = { + params: Promise<{ id: string }> +} + +/** + * GET: Fetch a single HubSpot connection by ID + */ +export async function GET( + _request: Request, + context: RouteContext +) { + try { + const { id } = await context.params + const supabase = await createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const membership = await getWorkspaceMembership() + if (!membership) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + + const { data, error } = await supabase + .from('hubspot_connections') + .select(SAFE_COLUMNS) + .eq('id', id) + .eq('workspace_id', membership.workspaceId) + .single() + + if (error || !data) { + return NextResponse.json({ error: 'Connection not found' }, { status: 404 }) + } + + return NextResponse.json({ connection: data }) + } catch (error) { + log.error('GET error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +/** + * DELETE: Remove a HubSpot connection + * + * Cascading deletes will remove: + * - hubspot_sync_state rows + * - hubspot_records rows + * - hubspot_associations rows + */ +export async function DELETE( + _request: Request, + context: RouteContext +) { + try { + const { id } = await context.params + const supabase = await createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const membership = await getWorkspaceMembership() + if (!membership) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + + // Verify the connection belongs to this workspace before deleting + const { data: existing } = await supabase + .from('hubspot_connections') + .select('id') + .eq('id', id) + .eq('workspace_id', membership.workspaceId) + .single() + + if (!existing) { + return NextResponse.json({ error: 'Connection not found' }, { status: 404 }) + } + + const { error } = await supabase + .from('hubspot_connections') + .delete() + .eq('id', id) + .eq('workspace_id', membership.workspaceId) + + if (error) { + log.error('Failed to delete connection:', error) + return NextResponse.json( + { error: 'Failed to delete connection' }, + { status: 500 } + ) + } + + return NextResponse.json({ success: true, deleted: id }) + } catch (error) { + log.error('DELETE error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/integrations/hubspot/connections/route.ts b/src/app/api/integrations/hubspot/connections/route.ts new file mode 100644 index 00000000..e93be38b --- /dev/null +++ b/src/app/api/integrations/hubspot/connections/route.ts @@ -0,0 +1,142 @@ +/** + * GET /api/integrations/hubspot/connections — List all HubSpot connections + * POST /api/integrations/hubspot/connections — Create a new connection (admin use) + * + * Lists connections for the authenticated user's workspace. + * Sensitive fields (encrypted tokens) are excluded from responses. + */ + +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { getWorkspaceMembership } from '@/lib/supabase/helpers' +import { createModuleLogger } from '@/lib/utils/logger' + +const log = createModuleLogger('[HubSpot Connections]') + +/** Columns returned in list responses (exclude encrypted tokens) */ +const SAFE_COLUMNS = [ + 'id', + 'workspace_id', + 'name', + 'auth_type', + 'hub_id', + 'hub_domain', + 'account_name', + 'scopes', + 'config_json', + 'is_primary', + 'status', + 'last_validated_at', + 'last_error', + 'is_active', + 'created_at', + 'updated_at', +].join(',') + +/** + * GET: List all HubSpot connections for the workspace + */ +export async function GET() { + try { + const supabase = await createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const membership = await getWorkspaceMembership() + if (!membership) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + + const { data, error } = await supabase + .from('hubspot_connections') + .select(SAFE_COLUMNS) + .eq('workspace_id', membership.workspaceId) + .order('created_at', { ascending: true }) + + if (error) { + log.error('Failed to list connections:', error) + return NextResponse.json( + { error: 'Failed to fetch connections' }, + { status: 500 } + ) + } + + return NextResponse.json({ connections: data || [] }) + } catch (error) { + log.error('GET error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +/** + * POST: Create a new HubSpot connection (manual/admin) + * + * This is primarily used for creating connections without going through + * OAuth or validate flows. For most use cases, use the /validate or + * /oauth/authorize endpoints instead. + */ +export async function POST(request: Request) { + try { + const supabase = await createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const membership = await getWorkspaceMembership() + if (!membership) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + + const body = await request.json() + const { name = 'HubSpot', auth_type, config_json = {} } = body + + if (!auth_type || !['oauth', 'private_app'].includes(auth_type)) { + return NextResponse.json( + { error: 'auth_type must be "oauth" or "private_app"' }, + { status: 400 } + ) + } + + const { data, error } = await supabase + .from('hubspot_connections') + .insert({ + workspace_id: membership.workspaceId, + name, + auth_type, + config_json, + status: 'pending', + } as never) + .select(SAFE_COLUMNS) + .single() + + if (error) { + if (error.code === '23505') { + return NextResponse.json( + { error: `A connection named "${name}" already exists` }, + { status: 409 } + ) + } + log.error('Failed to create connection:', error) + return NextResponse.json( + { error: 'Failed to create connection' }, + { status: 500 } + ) + } + + return NextResponse.json({ connection: data }, { status: 201 }) + } catch (error) { + log.error('POST error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/integrations/hubspot/entities/route.test.ts b/src/app/api/integrations/hubspot/entities/route.test.ts new file mode 100644 index 00000000..6b8931a2 --- /dev/null +++ b/src/app/api/integrations/hubspot/entities/route.test.ts @@ -0,0 +1,249 @@ +/// +/** + * Tests for POST /api/integrations/hubspot/entities + * + * HS-I01: Single company creation returns record ID and URL + * HS-I02: Batch chain creates company -> contact -> deal + * HS-I03: Returns 401 when not authenticated + * HS-I04: Returns 400 when no HubSpot connection found + * HS-I05: Returns 400 for invalid object_type + * HS-I06: Returns 400 when company_data missing for create_company + * HS-I07: Returns 502 on HubSpot API failure (partial batch) + * HS-I08: Returns 422 on validation error + */ + +import { POST } from './route' +import { NextRequest } from 'next/server' + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('@/lib/utils/logger', () => ({ + createModuleLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +const mockRequireWorkspace = vi.fn() +vi.mock('@/lib/supabase/server', () => ({ + requireWorkspace: () => mockRequireWorkspace(), + createClient: () => ({}), +})) + +const mockResolveConnection = vi.fn() +const mockGetCredentials = vi.fn() + +vi.mock('@/lib/integrations/hubspot', () => ({ + resolveHubSpotConnection: (...args: unknown[]) => mockResolveConnection(...args), + getHubSpotConnectionCredentials: (...args: unknown[]) => mockGetCredentials(...args), + HubSpotRateLimitError: class extends Error { retryAfter = 10 }, + HubSpotValidationError: class extends Error {}, +})) + +const mockSearch = vi.fn() +const mockCreateRecord = vi.fn() +const mockUpdateRecord = vi.fn() +const mockCreateAssociationV4 = vi.fn() + +vi.mock('@/lib/integrations/hubspot/client', () => { + return { + HubSpotClient: class MockHubSpotClient { + search = mockSearch + createRecord = mockCreateRecord + updateRecord = mockUpdateRecord + createAssociationV4 = mockCreateAssociationV4 + }, + } +}) + +vi.mock('@/lib/integrations/hubspot/associations', () => ({ + createAssociation: vi.fn().mockResolvedValue(undefined), +})) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createRequest(body: Record) { + return new NextRequest('http://localhost/api/integrations/hubspot/entities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +function setupAuth() { + mockRequireWorkspace.mockResolvedValue({ + workspaceId: 'ws-1', + user: { id: 'user-1' }, + }) + + mockResolveConnection.mockResolvedValue({ + id: 'conn-1', + workspace_id: 'ws-1', + hub_id: '12345', + }) + + mockGetCredentials.mockResolvedValue({ + token: 'pat-test-token', + authType: 'private_app', + isActive: true, + hubId: '12345', + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('POST /api/integrations/hubspot/entities', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('HS-I01: single company creation returns record ID and URL', async () => { + setupAuth() + mockSearch.mockResolvedValue({ results: [] }) + mockCreateRecord.mockResolvedValue({ id: 'company-1', properties: {} }) + + const res = await POST( + createRequest({ + object_type: 'companies', + properties: { domain: 'acme.com', name: 'Acme Corp' }, + }) + ) + + expect(res.status).toBe(200) + const data = await res.json() + expect(data.record_id).toBe('company-1') + expect(data.object_type).toBe('companies') + expect(data.hubspot_url).toContain('12345') + }) + + it('HS-I02: batch chain creates company -> contact -> deal', async () => { + setupAuth() + mockSearch.mockResolvedValue({ results: [] }) + mockCreateRecord + .mockResolvedValueOnce({ id: 'company-1', properties: {} }) + .mockResolvedValueOnce({ id: 'contact-1', properties: {} }) + .mockResolvedValueOnce({ id: 'deal-1', properties: {} }) + + const res = await POST( + createRequest({ + create_company: true, + company_data: { domain: 'acme.com', name: 'Acme Corp' }, + create_contact: true, + contact_data: { email: 'user@acme.com' }, + create_deal: true, + deal_data: { dealname: 'Acme -- Beton Signal' }, + }) + ) + + expect(res.status).toBe(200) + const data = await res.json() + expect(data.results.company.record_id).toBe('company-1') + expect(data.results.contact.record_id).toBe('contact-1') + expect(data.results.deal.record_id).toBe('deal-1') + }) + + it('HS-I03: returns 401 when not authenticated', async () => { + mockRequireWorkspace.mockRejectedValue(new Error('Unauthorized')) + + const res = await POST( + createRequest({ + object_type: 'companies', + properties: { domain: 'acme.com' }, + }) + ) + + expect(res.status).toBe(401) + }) + + it('HS-I04: returns 400 when no HubSpot connection found', async () => { + mockRequireWorkspace.mockResolvedValue({ + workspaceId: 'ws-1', + user: { id: 'user-1' }, + }) + mockResolveConnection.mockResolvedValue(null) + + const res = await POST( + createRequest({ + object_type: 'companies', + properties: { domain: 'acme.com' }, + }) + ) + + expect(res.status).toBe(400) + const data = await res.json() + expect(data.error).toContain('No HubSpot connection') + }) + + it('HS-I05: returns 400 for invalid object_type', async () => { + setupAuth() + + const res = await POST( + createRequest({ + object_type: 'invalid_type', + properties: { domain: 'acme.com' }, + }) + ) + + expect(res.status).toBe(400) + const data = await res.json() + expect(data.error).toContain('Invalid object_type') + }) + + it('HS-I06: returns 400 when company_data missing for create_company', async () => { + setupAuth() + + const res = await POST( + createRequest({ + create_company: true, + // company_data intentionally missing + }) + ) + + expect(res.status).toBe(400) + const data = await res.json() + expect(data.error).toContain('company_data is required') + }) + + it('HS-I07: returns 502 on batch partial failure', async () => { + setupAuth() + mockSearch.mockResolvedValue({ results: [] }) + mockCreateRecord + .mockResolvedValueOnce({ id: 'company-1', properties: {} }) + .mockRejectedValueOnce(new Error('Contact API error')) + + const res = await POST( + createRequest({ + create_company: true, + company_data: { domain: 'acme.com', name: 'Acme' }, + create_contact: true, + contact_data: { email: 'user@acme.com' }, + }) + ) + + expect(res.status).toBe(502) + const data = await res.json() + expect(data.partial).toBe(true) + expect(data.results.company.record_id).toBe('company-1') + }) + + it('HS-I08: returns 404 for no workspace', async () => { + mockRequireWorkspace.mockRejectedValue(new Error('No workspace found for user')) + + const res = await POST( + createRequest({ + object_type: 'companies', + properties: { domain: 'acme.com' }, + }) + ) + + expect(res.status).toBe(404) + }) +}) diff --git a/src/app/api/integrations/hubspot/entities/route.ts b/src/app/api/integrations/hubspot/entities/route.ts new file mode 100644 index 00000000..6e684ee8 --- /dev/null +++ b/src/app/api/integrations/hubspot/entities/route.ts @@ -0,0 +1,250 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireWorkspace } from '@/lib/supabase/server' +import { + resolveHubSpotConnection, + getHubSpotConnectionCredentials, + HubSpotRateLimitError, + HubSpotValidationError, +} from '@/lib/integrations/hubspot' +import { HubSpotClient } from '@/lib/integrations/hubspot/client' +import { + upsertCompany, + upsertContact, + createDeal, + batchCreateChain, + buildHubSpotUrl, + ensureBetonProperties, +} from '@/lib/integrations/hubspot/entities' +import type { EntityResult } from '@/lib/integrations/hubspot/entities' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface SingleEntityRequest { + connection_id?: string + object_type: 'companies' | 'contacts' | 'deals' + properties: Record + matching_property?: string +} + +interface BatchEntityRequest { + connection_id?: string + create_company?: boolean + create_contact?: boolean + create_deal?: boolean + company_data?: Record + contact_data?: Record + deal_data?: Record + ensure_properties?: boolean +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const ALLOWED_OBJECT_TYPES = new Set(['companies', 'contacts', 'deals']) + +/** + * Create a HubSpotClient from connection credentials. + */ +async function buildClient( + connectionId: string +): Promise<{ client: HubSpotClient; portalId: string | null }> { + const creds = await getHubSpotConnectionCredentials(connectionId) + if (!creds) { + throw new Error('HubSpot connection not found or credentials unavailable') + } + if (!creds.isActive) { + throw new Error('HubSpot connection is not active') + } + + const client = new HubSpotClient({ + token: creds.token, + authType: creds.authType, + connectionId, + refreshToken: creds.refreshToken || undefined, + tokenExpiresAt: creds.tokenExpiresAt, + }) + + return { client, portalId: creds.hubId } +} + +// --------------------------------------------------------------------------- +// POST /api/integrations/hubspot/entities +// --------------------------------------------------------------------------- + +export async function POST(request: NextRequest) { + try { + const { workspaceId } = await requireWorkspace() + + const body = await request.json() + + // Resolve connection (explicit or primary) + const connection = await resolveHubSpotConnection(workspaceId, body.connection_id) + if (!connection) { + return NextResponse.json( + { error: 'No HubSpot connection found for this workspace' }, + { status: 400 } + ) + } + + const { client, portalId } = await buildClient(connection.id as string) + + // Route: batch mode vs single entity + if ( + body.create_company === true || + body.create_contact === true || + body.create_deal === true + ) { + // Validate batch request fields + if (body.create_company === true && (!body.company_data || typeof body.company_data !== 'object')) { + return NextResponse.json( + { error: 'company_data is required when create_company is true' }, + { status: 400 } + ) + } + if (body.create_contact === true && (!body.contact_data || typeof body.contact_data !== 'object')) { + return NextResponse.json( + { error: 'contact_data is required when create_contact is true' }, + { status: 400 } + ) + } + if (body.create_deal === true && (!body.deal_data || typeof body.deal_data !== 'object')) { + return NextResponse.json( + { error: 'deal_data is required when create_deal is true' }, + { status: 400 } + ) + } + + // Optionally ensure beton_* custom properties exist + if (body.ensure_properties) { + const objectTypes = new Set() + if (body.create_company) objectTypes.add('companies') + if (body.create_contact) objectTypes.add('contacts') + if (body.create_deal) objectTypes.add('deals') + + for (const ot of objectTypes) { + try { + await ensureBetonProperties(client, ot) + } catch { + // Non-fatal — entity creation can proceed without custom properties + } + } + } + + return handleBatch( + client, + body as BatchEntityRequest, + portalId + ) + } + + return handleSingle(client, body as SingleEntityRequest, portalId) + } catch (err) { + if (err instanceof Error && err.message === 'Unauthorized') { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + if (err instanceof Error && err.message.includes('No workspace')) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + console.error('[HubSpot Entities] Error:', err) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +// --------------------------------------------------------------------------- +// Single entity creation +// --------------------------------------------------------------------------- + +async function handleSingle( + client: HubSpotClient, + body: SingleEntityRequest, + portalId: string | null +): Promise { + const { object_type, properties, matching_property } = body + + if (!object_type || !properties) { + return NextResponse.json( + { error: 'object_type and properties are required' }, + { status: 400 } + ) + } + + if (!ALLOWED_OBJECT_TYPES.has(object_type)) { + return NextResponse.json({ error: 'Invalid object_type' }, { status: 400 }) + } + + try { + let result: { recordId: string; action: string; objectType: string } + + switch (object_type) { + case 'companies': + result = await upsertCompany(client, properties, matching_property || 'domain') + break + case 'contacts': + result = await upsertContact(client, properties, matching_property || 'email') + break + case 'deals': + result = await createDeal(client, properties) + break + default: + return NextResponse.json({ error: 'Invalid object_type' }, { status: 400 }) + } + + const entity: EntityResult = { + record_id: result.recordId, + object_type: result.objectType, + hubspot_url: buildHubSpotUrl(portalId, result.objectType, result.recordId), + } + + return NextResponse.json(entity) + } catch (err) { + if (err instanceof HubSpotValidationError) { + return NextResponse.json( + { error: err.message, type: 'validation' }, + { status: 422 } + ) + } + if (err instanceof HubSpotRateLimitError) { + return NextResponse.json( + { error: err.message, retry_after: err.retryAfter }, + { status: 429 } + ) + } + throw err + } +} + +// --------------------------------------------------------------------------- +// Batch entity creation: company -> contact -> deal +// --------------------------------------------------------------------------- + +async function handleBatch( + client: HubSpotClient, + body: BatchEntityRequest, + portalId: string | null +): Promise { + const chainResult = await batchCreateChain(client, { + portalId, + companyData: body.company_data, + contactData: body.contact_data, + dealData: body.deal_data, + createCompany: body.create_company, + createContact: body.create_contact, + createDeal: body.create_deal, + }) + + if (chainResult.error) { + return NextResponse.json( + { + error: chainResult.error, + results: chainResult, + partial: chainResult.partial ?? false, + }, + { status: 502 } + ) + } + + return NextResponse.json({ results: chainResult }) +} diff --git a/src/app/api/integrations/hubspot/lists/route.ts b/src/app/api/integrations/hubspot/lists/route.ts new file mode 100644 index 00000000..3760dcae --- /dev/null +++ b/src/app/api/integrations/hubspot/lists/route.ts @@ -0,0 +1,200 @@ +/** + * POST /api/integrations/hubspot/lists + * + * Creates a HubSpot static list, searches contacts by email, adds as members. + * Removes stale members not in the provided emails array. + * + * Request: + * { + * "connection_id": "uuid" (optional, uses primary if omitted), + * "list_name": "Signal: Pricing Page Interest", + * "emails": ["user1@example.com", "user2@example.com"] + * } + * + * Response: + * { + * "list_id": "123", + * "list_name": "Signal: Pricing Page Interest", + * "members_added": 42, + * "members_failed": 3 + * } + */ + +import { NextRequest, NextResponse } from 'next/server' +import { requireWorkspace } from '@/lib/supabase/server' +import { + resolveHubSpotConnection, + getHubSpotConnectionCredentials, +} from '@/lib/integrations/hubspot' +import { HubSpotClient } from '@/lib/integrations/hubspot/client' +import { upsertContact } from '@/lib/integrations/hubspot/entities' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MAX_EMAILS = 10_000 +const NAME_REGEX = /^[a-zA-Z0-9_.\-: ]+$/ + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +interface CreateListBody { + connection_id?: string + list_name: string + emails: string[] +} + +function validateBody(body: unknown): CreateListBody { + if (!body || typeof body !== 'object') { + throw new Error('Request body must be a JSON object') + } + + const b = body as Record + + if (typeof b.list_name !== 'string' || !b.list_name.trim()) { + throw new Error('list_name is required') + } + if (!NAME_REGEX.test(b.list_name)) { + throw new Error('list_name contains invalid characters') + } + + if (!Array.isArray(b.emails) || b.emails.length === 0) { + throw new Error('emails must be a non-empty array') + } + if (b.emails.length > MAX_EMAILS) { + throw new Error(`emails must have at most ${MAX_EMAILS} items`) + } + for (const email of b.emails) { + if (typeof email !== 'string') { + throw new Error('All emails must be strings') + } + } + + return { + connection_id: typeof b.connection_id === 'string' ? b.connection_id : undefined, + list_name: b.list_name.trim(), + emails: b.emails as string[], + } +} + +// --------------------------------------------------------------------------- +// POST Handler +// --------------------------------------------------------------------------- + +export async function POST(request: NextRequest) { + try { + const { workspaceId } = await requireWorkspace() + + let body: CreateListBody + try { + const raw = await request.json() + body = validateBody(raw) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Invalid request' }, + { status: 400 } + ) + } + + // Resolve connection + const connection = await resolveHubSpotConnection(workspaceId, body.connection_id) + if (!connection) { + return NextResponse.json( + { error: 'No HubSpot connection found for this workspace' }, + { status: 400 } + ) + } + + const creds = await getHubSpotConnectionCredentials(connection.id as string) + if (!creds || !creds.isActive) { + return NextResponse.json( + { error: 'HubSpot connection credentials unavailable' }, + { status: 400 } + ) + } + + const client = new HubSpotClient({ + token: creds.token, + authType: creds.authType, + connectionId: connection.id as string, + refreshToken: creds.refreshToken || undefined, + tokenExpiresAt: creds.tokenExpiresAt, + }) + + // 1. Upsert contacts from email addresses + const contactResults = await Promise.allSettled( + body.emails.map(async (email) => { + const result = await upsertContact(client, { email }) + return { email, contactId: result.recordId } + }) + ) + + const successfulContacts = contactResults + .filter( + (r): r is PromiseFulfilledResult<{ email: string; contactId: string }> => + r.status === 'fulfilled' + ) + .map((r) => r.value) + + if (successfulContacts.length === 0) { + return NextResponse.json( + { error: 'Failed to create any contacts in HubSpot' }, + { status: 500 } + ) + } + + // 2. Create static list via HubSpot Lists API v3 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let listResult: any + try { + listResult = await client.createStaticList(body.list_name) + } catch (err) { + return NextResponse.json( + { + error: `Failed to create list: ${err instanceof Error ? err.message : 'Unknown error'}`, + }, + { status: 502 } + ) + } + + const listId = listResult?.listId || listResult?.list_id || listResult?.id + + // 3. Add contacts to the list + const contactIds = successfulContacts.map((c) => c.contactId) + let membersAdded = 0 + let membersFailed = 0 + + try { + await client.addContactsToList(listId, contactIds) + membersAdded = contactIds.length + } catch { + // Fall back to adding one by one + const addResults = await Promise.allSettled( + contactIds.map((id) => client.addContactsToList(listId, [id])) + ) + membersAdded = addResults.filter((r) => r.status === 'fulfilled').length + membersFailed = addResults.filter((r) => r.status === 'rejected').length + } + + return NextResponse.json( + { + list_id: listId, + list_name: body.list_name, + members_added: membersAdded, + members_failed: membersFailed, + }, + { status: 201 } + ) + } catch (err) { + if (err instanceof Error && err.message === 'Unauthorized') { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + if (err instanceof Error && err.message.includes('No workspace')) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + console.error('[HubSpot Lists] Error:', err) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/integrations/hubspot/mappings/route.ts b/src/app/api/integrations/hubspot/mappings/route.ts new file mode 100644 index 00000000..678c9e3c --- /dev/null +++ b/src/app/api/integrations/hubspot/mappings/route.ts @@ -0,0 +1,109 @@ +/** + * GET/PUT /api/integrations/hubspot/mappings + * + * Manages field mappings between Beton computed fields and HubSpot properties. + * Mappings are stored in the hubspot_connections.field_mappings JSONB column. + * + * GET: returns current field mappings for the primary (or specified) connection + * PUT: updates field mappings for the specified connection + */ + +import { NextRequest, NextResponse } from 'next/server' +import { requireWorkspace } from '@/lib/supabase/server' +import { resolveHubSpotConnection } from '@/lib/integrations/hubspot' +import { createClient } from '@/lib/supabase/server' + +// --------------------------------------------------------------------------- +// GET /api/integrations/hubspot/mappings +// --------------------------------------------------------------------------- + +export async function GET(request: NextRequest) { + try { + const { workspaceId } = await requireWorkspace() + + const connectionId = request.nextUrl.searchParams.get('connection_id') || undefined + + const connection = await resolveHubSpotConnection(workspaceId, connectionId) + if (!connection) { + return NextResponse.json( + { error: 'No HubSpot connection found' }, + { status: 404 } + ) + } + + return NextResponse.json({ + connection_id: connection.id, + field_mappings: connection.field_mappings || {}, + }) + } catch (err) { + if (err instanceof Error && err.message === 'Unauthorized') { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + if (err instanceof Error && err.message.includes('No workspace')) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + console.error('[HubSpot Mappings GET] Error:', err) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} + +// --------------------------------------------------------------------------- +// PUT /api/integrations/hubspot/mappings +// --------------------------------------------------------------------------- + +export async function PUT(request: NextRequest) { + try { + const { workspaceId } = await requireWorkspace() + + const body = await request.json() + const { connection_id, field_mappings } = body + + if (!field_mappings || typeof field_mappings !== 'object') { + return NextResponse.json( + { error: 'field_mappings is required and must be an object' }, + { status: 400 } + ) + } + + // Resolve and verify ownership + const connection = await resolveHubSpotConnection(workspaceId, connection_id) + if (!connection) { + return NextResponse.json( + { error: 'No HubSpot connection found' }, + { status: 404 } + ) + } + + // Update the field_mappings JSONB column + const supabase = await createClient() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error } = await (supabase as any) + .from('hubspot_connections') + .update({ field_mappings: field_mappings }) + .eq('id', connection.id) + .eq('workspace_id', workspaceId) + + if (error) { + console.error('[HubSpot Mappings PUT] DB error:', error) + return NextResponse.json( + { error: 'Failed to save field mappings' }, + { status: 500 } + ) + } + + return NextResponse.json({ + connection_id: connection.id, + field_mappings, + }) + } catch (err) { + if (err instanceof Error && err.message === 'Unauthorized') { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + if (err instanceof Error && err.message.includes('No workspace')) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + console.error('[HubSpot Mappings PUT] Error:', err) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/integrations/hubspot/oauth/authorize/route.ts b/src/app/api/integrations/hubspot/oauth/authorize/route.ts new file mode 100644 index 00000000..f5ed86e9 --- /dev/null +++ b/src/app/api/integrations/hubspot/oauth/authorize/route.ts @@ -0,0 +1,103 @@ +/** + * GET /api/integrations/hubspot/oauth/authorize + * + * Initiates the HubSpot OAuth flow by redirecting the user to HubSpot's + * authorization page. Generates a CSRF state parameter and stores it + * in the database for verification during callback. + * + * Query params: + * - connection_name (optional): Name for the new connection + * + * Requires: + * - HUBSPOT_CLIENT_ID env var + * - HUBSPOT_REDIRECT_URI env var (or constructs from request URL) + */ + +import { NextResponse } from 'next/server' +import { randomBytes } from 'crypto' +import { createClient } from '@/lib/supabase/server' +import { getWorkspaceMembership } from '@/lib/supabase/helpers' +import { getAuthorizeUrl } from '@/lib/integrations/hubspot/auth' +import { DEFAULT_OAUTH_SCOPES } from '@/lib/integrations/hubspot/config' +import { createModuleLogger } from '@/lib/utils/logger' + +const log = createModuleLogger('[HubSpot OAuth Authorize]') + +export async function GET(request: Request) { + try { + const supabase = await createClient() + + // Authenticate user + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + // Get workspace + const membership = await getWorkspaceMembership() + if (!membership) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + + // Check required env vars + const clientId = process.env.HUBSPOT_CLIENT_ID + if (!clientId) { + log.error('HUBSPOT_CLIENT_ID not configured') + return NextResponse.json( + { error: 'HubSpot OAuth is not configured on this server' }, + { status: 503 } + ) + } + + // Build redirect URI + const url = new URL(request.url) + const redirectUri = + process.env.HUBSPOT_REDIRECT_URI || + `${url.protocol}//${url.host}/api/integrations/hubspot/oauth/callback` + + // Parse optional connection name + const connectionName = url.searchParams.get('connection_name') || 'HubSpot' + + // Generate CSRF state (includes workspace ID for callback verification) + const stateToken = randomBytes(32).toString('hex') + const state = `${membership.workspaceId}:${stateToken}` + + // Create a pending connection row with the state for callback verification + const { error: insertError } = await supabase + .from('hubspot_connections') + .insert({ + workspace_id: membership.workspaceId, + name: connectionName, + auth_type: 'oauth', + oauth_state: state, + status: 'pending', + } as never) + + if (insertError) { + log.error('Failed to create pending connection:', insertError) + return NextResponse.json( + { error: 'Failed to initiate OAuth flow' }, + { status: 500 } + ) + } + + // Build authorization URL and redirect + const authorizeUrl = getAuthorizeUrl( + clientId, + redirectUri, + DEFAULT_OAUTH_SCOPES, + state + ) + + return NextResponse.redirect(authorizeUrl) + } catch (error) { + log.error('OAuth authorize error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/integrations/hubspot/oauth/callback/route.test.ts b/src/app/api/integrations/hubspot/oauth/callback/route.test.ts new file mode 100644 index 00000000..1ae3fb89 --- /dev/null +++ b/src/app/api/integrations/hubspot/oauth/callback/route.test.ts @@ -0,0 +1,201 @@ +/// +/** + * Tests for GET /api/integrations/hubspot/oauth/callback + * + * HS-I14: Successful OAuth callback exchanges code and stores tokens + * HS-I15: Missing code/state returns error redirect + * HS-I16: Invalid state returns error redirect + * HS-I17: Token exchange failure returns error redirect + */ + +import { GET } from './route' + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockGetUser = vi.fn() +const mockSelect = vi.fn() +const mockEq = vi.fn() +const mockSingle = vi.fn() +const mockUpdate = vi.fn() + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn().mockImplementation(async () => ({ + auth: { getUser: mockGetUser }, + from: vi.fn().mockReturnValue({ + select: mockSelect.mockReturnValue({ + eq: mockEq.mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: mockSingle, + }), + }), + }), + }), + update: mockUpdate.mockReturnValue({ + eq: vi.fn().mockReturnValue({ + // This returns void — no need to chain further + }), + }), + }), + })), +})) + +vi.mock('@/lib/integrations/hubspot/auth', () => ({ + exchangeCodeForTokens: vi.fn(), +})) + +vi.mock('@/lib/crypto/encryption', () => ({ + encrypt: vi.fn().mockResolvedValue('encrypted-value'), +})) + +vi.mock('@/lib/utils/logger', () => ({ + createModuleLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +import { exchangeCodeForTokens } from '@/lib/integrations/hubspot/auth' + +const mockExchangeCode = exchangeCodeForTokens as ReturnType + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeCallbackRequest(params: Record) { + const url = new URL('http://localhost:3000/api/integrations/hubspot/oauth/callback') + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + return new Request(url.toString()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('GET /api/integrations/hubspot/oauth/callback', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } } }) + }) + + // HS-I15: Missing code/state + it('redirects with error when code is missing', async () => { + const res = await GET(makeCallbackRequest({ state: 'ws-1:token' })) + expect(res.status).toBe(307) + const location = res.headers.get('location') || '' + expect(location).toContain('hubspot_error=') + expect(location).toContain('Missing') + }) + + it('redirects with error when state is missing', async () => { + const res = await GET(makeCallbackRequest({ code: 'auth-code' })) + expect(res.status).toBe(307) + const location = res.headers.get('location') || '' + expect(location).toContain('hubspot_error=') + }) + + // HS-I16: Invalid state + it('redirects with error for invalid state format', async () => { + const res = await GET(makeCallbackRequest({ code: 'auth-code', state: 'invalid' })) + expect(res.status).toBe(307) + const location = res.headers.get('location') || '' + expect(location).toContain('hubspot_error=') + expect(location).toContain('Invalid%20state') + }) + + // HubSpot returns error + it('redirects with HubSpot error when error param present', async () => { + const res = await GET( + makeCallbackRequest({ + error: 'access_denied', + error_description: 'User denied access', + }) + ) + expect(res.status).toBe(307) + const location = res.headers.get('location') || '' + expect(location).toContain('hubspot_error=User%20denied%20access') + }) + + // HS-I14: Not authenticated + it('redirects with error when not authenticated', async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + + const res = await GET( + makeCallbackRequest({ code: 'auth-code', state: 'ws-1:token123' }) + ) + expect(res.status).toBe(307) + const location = res.headers.get('location') || '' + expect(location).toContain('hubspot_error=Not%20authenticated') + }) + + // HS-I17: No matching pending connection + it('redirects with error when no pending connection found', async () => { + mockSingle.mockResolvedValue({ data: null, error: { message: 'Not found' } }) + + const res = await GET( + makeCallbackRequest({ code: 'auth-code', state: 'ws-1:token123' }) + ) + expect(res.status).toBe(307) + const location = res.headers.get('location') || '' + expect(location).toContain('hubspot_error=') + expect(location).toContain('expired') + }) + + // HS-I17: Token exchange failure + it('redirects with error when token exchange fails', async () => { + mockSingle.mockResolvedValue({ + data: { id: 'conn-1', workspace_id: 'ws-1' }, + error: null, + }) + mockExchangeCode.mockRejectedValue(new Error('Token exchange failed')) + + // Need env vars for this path + const originalClientId = process.env.HUBSPOT_CLIENT_ID + const originalClientSecret = process.env.HUBSPOT_CLIENT_SECRET + process.env.HUBSPOT_CLIENT_ID = 'test-client-id' + process.env.HUBSPOT_CLIENT_SECRET = 'test-client-secret' + + const res = await GET( + makeCallbackRequest({ code: 'bad-code', state: 'ws-1:token123' }) + ) + + process.env.HUBSPOT_CLIENT_ID = originalClientId + process.env.HUBSPOT_CLIENT_SECRET = originalClientSecret + + expect(res.status).toBe(307) + const location = res.headers.get('location') || '' + expect(location).toContain('hubspot_error=Token%20exchange%20failed') + }) + + // Missing env vars + it('redirects with error when OAuth env vars missing', async () => { + mockSingle.mockResolvedValue({ + data: { id: 'conn-1', workspace_id: 'ws-1' }, + error: null, + }) + + const originalClientId = process.env.HUBSPOT_CLIENT_ID + const originalClientSecret = process.env.HUBSPOT_CLIENT_SECRET + delete process.env.HUBSPOT_CLIENT_ID + delete process.env.HUBSPOT_CLIENT_SECRET + + const res = await GET( + makeCallbackRequest({ code: 'auth-code', state: 'ws-1:token123' }) + ) + + process.env.HUBSPOT_CLIENT_ID = originalClientId + process.env.HUBSPOT_CLIENT_SECRET = originalClientSecret + + expect(res.status).toBe(307) + const location = res.headers.get('location') || '' + expect(location).toContain('hubspot_error=') + expect(location).toContain('not%20configured') + }) +}) diff --git a/src/app/api/integrations/hubspot/oauth/callback/route.ts b/src/app/api/integrations/hubspot/oauth/callback/route.ts new file mode 100644 index 00000000..8d2675b5 --- /dev/null +++ b/src/app/api/integrations/hubspot/oauth/callback/route.ts @@ -0,0 +1,179 @@ +/** + * GET /api/integrations/hubspot/oauth/callback + * + * Handles the OAuth callback from HubSpot after user authorization. + * Exchanges the authorization code for tokens, encrypts and stores them, + * fetches account info, and updates the connection status. + * + * Query params (from HubSpot redirect): + * - code: Authorization code + * - state: CSRF state parameter (workspace_id:token) + * + * On success: redirects to /settings with success indicator + * On error: redirects to /settings with error details + */ + +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { exchangeCodeForTokens } from '@/lib/integrations/hubspot/auth' +import { encrypt } from '@/lib/crypto/encryption' +import { HUBSPOT_API_BASE } from '@/lib/integrations/hubspot/config' +import { createModuleLogger } from '@/lib/utils/logger' + +const log = createModuleLogger('[HubSpot OAuth Callback]') + +export async function GET(request: Request) { + const url = new URL(request.url) + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + const error = url.searchParams.get('error') + const errorDescription = url.searchParams.get('error_description') + + // Build redirect base URL + const settingsUrl = `${url.protocol}//${url.host}/settings` + + // Handle HubSpot error (user denied, etc.) + if (error) { + log.warn('OAuth error from HubSpot:', error, errorDescription) + return NextResponse.redirect( + `${settingsUrl}?hubspot_error=${encodeURIComponent(errorDescription || error)}` + ) + } + + // Validate required params + if (!code || !state) { + return NextResponse.redirect( + `${settingsUrl}?hubspot_error=${encodeURIComponent('Missing authorization code or state')}` + ) + } + + // Parse state to extract workspace ID + const stateParts = state.split(':') + if (stateParts.length !== 2) { + return NextResponse.redirect( + `${settingsUrl}?hubspot_error=${encodeURIComponent('Invalid state parameter')}` + ) + } + + const [workspaceId] = stateParts + + try { + const supabase = await createClient() + + // Verify user is authenticated + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.redirect( + `${settingsUrl}?hubspot_error=${encodeURIComponent('Not authenticated')}` + ) + } + + // Find the pending connection with matching state + const { data: connection, error: findError } = await supabase + .from('hubspot_connections') + .select('*') + .eq('workspace_id', workspaceId) + .eq('oauth_state', state) + .eq('status', 'pending') + .single() + + if (findError || !connection) { + log.error('No pending connection found for state:', findError) + return NextResponse.redirect( + `${settingsUrl}?hubspot_error=${encodeURIComponent('OAuth session expired or invalid. Please try again.')}` + ) + } + + // Exchange code for tokens + const clientId = process.env.HUBSPOT_CLIENT_ID + const clientSecret = process.env.HUBSPOT_CLIENT_SECRET + + if (!clientId || !clientSecret) { + log.error('Missing HUBSPOT_CLIENT_ID or HUBSPOT_CLIENT_SECRET') + return NextResponse.redirect( + `${settingsUrl}?hubspot_error=${encodeURIComponent('HubSpot OAuth not configured on server')}` + ) + } + + const redirectUri = + process.env.HUBSPOT_REDIRECT_URI || + `${url.protocol}//${url.host}/api/integrations/hubspot/oauth/callback` + + const tokens = await exchangeCodeForTokens(code, clientId, clientSecret, redirectUri) + + // Encrypt tokens + const [accessTokenEncrypted, refreshTokenEncrypted] = await Promise.all([ + encrypt(tokens.access_token), + encrypt(tokens.refresh_token), + ]) + + // Calculate token expiry + const tokenExpiresAt = new Date( + Date.now() + tokens.expires_in * 1000 + ).toISOString() + + // Fetch account info from HubSpot + let hubId: string | null = null + let hubDomain: string | null = null + let accountName: string | null = null + + try { + const accountInfoResponse = await fetch( + `${HUBSPOT_API_BASE}/account-info/v3/details`, + { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + } + ) + + if (accountInfoResponse.ok) { + const accountInfo = await accountInfoResponse.json() + hubId = String(accountInfo.portalId) + hubDomain = accountInfo.uiDomain || null + accountName = accountInfo.accountType || null + } + } catch (err) { + log.warn('Failed to fetch HubSpot account info:', err) + // Non-critical — continue with connection setup + } + + // Update connection with tokens and account info + const { error: updateError } = await supabase + .from('hubspot_connections') + .update({ + access_token_encrypted: accessTokenEncrypted, + refresh_token_encrypted: refreshTokenEncrypted, + token_expires_at: tokenExpiresAt, + hub_id: hubId, + hub_domain: hubDomain, + account_name: accountName, + oauth_state: null, // Clear the state + status: 'connected', + is_active: true, + is_primary: true, // First OAuth connection becomes primary + last_validated_at: new Date().toISOString(), + } as never) + .eq('id', (connection as Record).id) + + if (updateError) { + log.error('Failed to update connection with tokens:', updateError) + return NextResponse.redirect( + `${settingsUrl}?hubspot_error=${encodeURIComponent('Failed to save connection. Please try again.')}` + ) + } + + log.info(`HubSpot OAuth connected for workspace ${workspaceId}, hub ${hubId}`) + + return NextResponse.redirect(`${settingsUrl}?hubspot_connected=true`) + } catch (err) { + log.error('OAuth callback error:', err) + const message = err instanceof Error ? err.message : 'Unknown error' + return NextResponse.redirect( + `${settingsUrl}?hubspot_error=${encodeURIComponent(message)}` + ) + } +} diff --git a/src/app/api/integrations/hubspot/objects/route.ts b/src/app/api/integrations/hubspot/objects/route.ts new file mode 100644 index 00000000..83412dbb --- /dev/null +++ b/src/app/api/integrations/hubspot/objects/route.ts @@ -0,0 +1,87 @@ +/** + * GET /api/integrations/hubspot/objects + * + * Lists available CRM object types (schemas) for a HubSpot connection. + * + * Query params: + * - connection_id: HubSpot connection UUID (required) + */ + +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { getWorkspaceMembership } from '@/lib/supabase/helpers' +import { getHubSpotConnectionCredentials } from '@/lib/integrations/hubspot/config' +import { HubSpotClient } from '@/lib/integrations/hubspot/client' +import { createModuleLogger } from '@/lib/utils/logger' + +const log = createModuleLogger('[HubSpot Objects]') + +export async function GET(request: Request) { + try { + const supabase = await createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const membership = await getWorkspaceMembership() + if (!membership) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + + const url = new URL(request.url) + const connectionId = url.searchParams.get('connection_id') + + if (!connectionId) { + return NextResponse.json({ error: 'connection_id is required' }, { status: 400 }) + } + + // Verify connection belongs to this workspace + const { data: connection } = await supabase + .from('hubspot_connections' as never) + .select('id, workspace_id') + .eq('id', connectionId) + .eq('workspace_id', membership.workspaceId) + .single() + + if (!connection) { + return NextResponse.json({ error: 'Connection not found' }, { status: 404 }) + } + + // Get credentials + const credentials = await getHubSpotConnectionCredentials(connectionId) + if (!credentials) { + return NextResponse.json({ error: 'Failed to load connection credentials' }, { status: 500 }) + } + + // Create client and fetch schemas + const client = new HubSpotClient({ + token: credentials.token, + authType: credentials.authType, + connectionId, + }) + + const schemas = await client.getObjectSchemas() + + // Return simplified object list + return NextResponse.json({ + objects: schemas.results.map((schema) => ({ + id: schema.id, + name: schema.name, + labels: schema.labels, + primaryDisplayProperty: schema.primaryDisplayProperty, + archived: schema.archived, + })), + }) + } catch (error) { + log.error('Objects list error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to list objects' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/integrations/hubspot/properties/route.ts b/src/app/api/integrations/hubspot/properties/route.ts new file mode 100644 index 00000000..f6a3aa61 --- /dev/null +++ b/src/app/api/integrations/hubspot/properties/route.ts @@ -0,0 +1,179 @@ +/** + * GET /api/integrations/hubspot/properties — List properties for an object type + * POST /api/integrations/hubspot/properties — Create a new property + * + * Query params (GET): + * - connection_id: HubSpot connection UUID (required) + * - object_type: CRM object type (required, e.g. 'contacts', 'companies') + * + * Body (POST): + * - connection_id: HubSpot connection UUID + * - object_type: CRM object type + * - name: Property internal name + * - label: Property display label + * - type: Property type (string, number, date, etc.) + * - fieldType: Field type (text, textarea, number, date, etc.) + * - groupName: Property group + * - description: Optional description + */ + +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { getWorkspaceMembership } from '@/lib/supabase/helpers' +import { getHubSpotConnectionCredentials } from '@/lib/integrations/hubspot/config' +import { HubSpotClient } from '@/lib/integrations/hubspot/client' +import { createModuleLogger } from '@/lib/utils/logger' + +const log = createModuleLogger('[HubSpot Properties]') + +/** + * Verify the connection belongs to the user's workspace and get credentials. + */ +async function resolveConnection( + connectionId: string, + workspaceId: string +) { + const supabase = await createClient() + + const { data: connection } = await supabase + .from('hubspot_connections' as never) + .select('id, workspace_id') + .eq('id', connectionId) + .eq('workspace_id', workspaceId) + .single() + + if (!connection) { + return null + } + + const credentials = await getHubSpotConnectionCredentials(connectionId) + if (!credentials) { + return null + } + + return new HubSpotClient({ + token: credentials.token, + authType: credentials.authType, + connectionId, + }) +} + +/** + * GET: List properties for an object type + */ +export async function GET(request: Request) { + try { + const supabase = await createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const membership = await getWorkspaceMembership() + if (!membership) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + + const url = new URL(request.url) + const connectionId = url.searchParams.get('connection_id') + const objectType = url.searchParams.get('object_type') + + if (!connectionId) { + return NextResponse.json({ error: 'connection_id is required' }, { status: 400 }) + } + + if (!objectType) { + return NextResponse.json({ error: 'object_type is required' }, { status: 400 }) + } + + const client = await resolveConnection(connectionId, membership.workspaceId) + if (!client) { + return NextResponse.json({ error: 'Connection not found' }, { status: 404 }) + } + + const properties = await client.getProperties(objectType) + + return NextResponse.json({ + object_type: objectType, + properties: properties.results, + }) + } catch (error) { + log.error('List properties error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to list properties' }, + { status: 500 } + ) + } +} + +/** + * POST: Create a new property on an object type + */ +export async function POST(request: Request) { + try { + const supabase = await createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const membership = await getWorkspaceMembership() + if (!membership) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + + const body = await request.json() + const { + connection_id: connectionId, + object_type: objectType, + name, + label, + type = 'string', + fieldType = 'text', + groupName = 'contactinformation', + description, + } = body + + if (!connectionId) { + return NextResponse.json({ error: 'connection_id is required' }, { status: 400 }) + } + + if (!objectType) { + return NextResponse.json({ error: 'object_type is required' }, { status: 400 }) + } + + if (!name || !label) { + return NextResponse.json({ error: 'name and label are required' }, { status: 400 }) + } + + const client = await resolveConnection(connectionId, membership.workspaceId) + if (!client) { + return NextResponse.json({ error: 'Connection not found' }, { status: 404 }) + } + + const property = await client.createProperty(objectType, { + name, + label, + type, + fieldType, + groupName, + description, + }) + + return NextResponse.json({ property }, { status: 201 }) + } catch (error) { + log.error('Create property error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to create property' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/integrations/hubspot/sample-data/route.ts b/src/app/api/integrations/hubspot/sample-data/route.ts new file mode 100644 index 00000000..9541a9e2 --- /dev/null +++ b/src/app/api/integrations/hubspot/sample-data/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from 'next/server' +import { requireWorkspace } from '@/lib/supabase/server' +import { createClient } from '@/lib/supabase/server' +import { getDefaultSampleData } from '@/lib/setup/sample-data' + +/** + * GET /api/integrations/hubspot/sample-data + * + * Returns one example data record for populating the HubSpot field mapping preview. + * Tries to fetch real account data from the workspace, falls back to hardcoded sample. + */ +export async function GET() { + try { + const { workspaceId } = await requireWorkspace() + const supabase = await createClient() + + // Try to fetch a real account with signals + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: account } = await (supabase as any) + .from('accounts') + .select('id, name, domain, health_score, signal_count') + .eq('workspace_id', workspaceId) + .order('signal_count', { ascending: false }) + .limit(1) + .maybeSingle() + + if (account?.name) { + // Fetch the latest signal scoped to this specific account + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: signal } = await (supabase as any) + .from('signals') + .select('name, signal_type, detected_at') + .eq('workspace_id', workspaceId) + .eq('account_id', account.id) + .order('detected_at', { ascending: false }) + .limit(1) + .maybeSingle() + + const fallback = getDefaultSampleData() + return NextResponse.json({ + sample: { + company_name: account.name || fallback.company_name, + company_domain: account.domain || fallback.company_domain, + user_email: fallback.user_email, + signal_name: signal?.name || fallback.signal_name, + signal_type: signal?.signal_type || fallback.signal_type, + health_score: account.health_score ?? fallback.health_score, + signal_count: account.signal_count ?? fallback.signal_count, + deal_value: fallback.deal_value, + detected_at: signal?.detected_at?.split('T')[0] || fallback.detected_at, + }, + }) + } + + // No real data — use fallback + return NextResponse.json({ sample: getDefaultSampleData() }) + } catch (err) { + if (err instanceof Error && err.message === 'Unauthorized') { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + if (err instanceof Error && err.message.includes('No workspace')) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + // On any error, still return fallback sample (non-critical endpoint) + console.error('[HubSpot Sample Data] Error:', err) + return NextResponse.json({ sample: getDefaultSampleData() }) + } +} diff --git a/src/app/api/integrations/hubspot/search/route.test.ts b/src/app/api/integrations/hubspot/search/route.test.ts new file mode 100644 index 00000000..aae9b90e --- /dev/null +++ b/src/app/api/integrations/hubspot/search/route.test.ts @@ -0,0 +1,185 @@ +/// +/** + * Tests for GET /api/integrations/hubspot/search + * + * HS-I18: Successful search returns results + * HS-I19: Email query uses CONTAINS_TOKEN filter + * HS-I20: Missing connection_id returns 400 + * HS-I21: Not authenticated returns 401 + */ + +// --------------------------------------------------------------------------- +// Mocks — must be declared before imports +// --------------------------------------------------------------------------- + +const mockGetUser = vi.fn() +const mockGetWorkspaceMembership = vi.fn() +const mockSearch = vi.fn() + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn().mockImplementation(async () => ({ + auth: { getUser: mockGetUser }, + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { id: 'conn-1', workspace_id: 'ws-1' }, + error: null, + }), + }), + }), + }), + }), + })), +})) + +vi.mock('@/lib/supabase/helpers', () => ({ + getWorkspaceMembership: () => mockGetWorkspaceMembership(), +})) + +vi.mock('@/lib/integrations/hubspot/config', () => ({ + getHubSpotConnectionCredentials: vi.fn().mockResolvedValue({ + token: 'pat-test-token', + authType: 'private_app', + }), +})) + +vi.mock('@/lib/integrations/hubspot/client', () => { + return { + HubSpotClient: class MockHubSpotClient { + search = mockSearch + }, + } +}) + +vi.mock('@/lib/utils/logger', () => ({ + createModuleLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +// Import after mocks +import { GET } from './route' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeSearchRequest(params: Record) { + const url = new URL('http://localhost:3000/api/integrations/hubspot/search') + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + return new Request(url.toString()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('GET /api/integrations/hubspot/search', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } } }) + mockGetWorkspaceMembership.mockResolvedValue({ + workspaceId: 'ws-1', + userId: 'user-1', + role: 'owner', + }) + mockSearch.mockResolvedValue({ + total: 1, + results: [{ id: '1', properties: { email: 'test@example.com' } }], + }) + }) + + // HS-I21: Not authenticated + it('returns 401 when not authenticated', async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + const res = await GET( + makeSearchRequest({ connection_id: 'conn-1', q: 'test' }) + ) + expect(res.status).toBe(401) + }) + + // HS-I20: Missing connection_id + it('returns 400 when connection_id is missing', async () => { + const res = await GET(makeSearchRequest({ q: 'test' })) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toContain('connection_id') + }) + + // Missing query + it('returns 400 when q is missing', async () => { + const res = await GET(makeSearchRequest({ connection_id: 'conn-1' })) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toContain('q') + }) + + // HS-I18: Successful search + it('returns search results', async () => { + const res = await GET( + makeSearchRequest({ connection_id: 'conn-1', q: 'John' }) + ) + expect(res.status).toBe(200) + + const body = await res.json() + expect(body.total).toBe(1) + expect(body.results).toHaveLength(1) + }) + + // HS-I18: Full-text search passes query + it('uses full-text query for non-email searches', async () => { + await GET( + makeSearchRequest({ connection_id: 'conn-1', q: 'Acme Corp' }) + ) + + expect(mockSearch).toHaveBeenCalled() + const searchCall = mockSearch.mock.calls[0] + const [objectType, searchOptions] = searchCall + expect(objectType).toBe('contacts') + expect(searchOptions.query).toBe('Acme Corp') + }) + + // HS-I19: Email query uses CONTAINS_TOKEN filter + it('uses CONTAINS_TOKEN filter for email queries', async () => { + await GET( + makeSearchRequest({ connection_id: 'conn-1', q: 'test@example.com' }) + ) + + expect(mockSearch).toHaveBeenCalled() + const [, searchOptions] = mockSearch.mock.calls[0] + expect(searchOptions.filterGroups).toBeDefined() + expect(searchOptions.filterGroups[0].filters[0].operator).toBe('CONTAINS_TOKEN') + expect(searchOptions.filterGroups[0].filters[0].value).toBe('test@example.com') + }) + + // Default object type + it('defaults to contacts object type', async () => { + await GET( + makeSearchRequest({ connection_id: 'conn-1', q: 'test' }) + ) + + const [objectType] = mockSearch.mock.calls[0] + expect(objectType).toBe('contacts') + }) + + // Custom object type + it('accepts custom object_type', async () => { + await GET( + makeSearchRequest({ + connection_id: 'conn-1', + q: 'test', + object_type: 'companies', + }) + ) + + const [objectType] = mockSearch.mock.calls[0] + expect(objectType).toBe('companies') + }) +}) diff --git a/src/app/api/integrations/hubspot/search/route.ts b/src/app/api/integrations/hubspot/search/route.ts new file mode 100644 index 00000000..58f38df4 --- /dev/null +++ b/src/app/api/integrations/hubspot/search/route.ts @@ -0,0 +1,144 @@ +/** + * GET /api/integrations/hubspot/search + * + * Proxies search requests to the HubSpot Search API. + * + * Query params: + * - connection_id: HubSpot connection UUID (required) + * - object_type: CRM object type to search (default: 'contacts') + * - q: Search query string (required) + * - limit: Max results (default: 10, max: 100) + * - after: Pagination cursor + * + * Heuristic: if query contains "@", searches by email; otherwise by name/domain. + */ + +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { getWorkspaceMembership } from '@/lib/supabase/helpers' +import { getHubSpotConnectionCredentials } from '@/lib/integrations/hubspot/config' +import { HubSpotClient } from '@/lib/integrations/hubspot/client' +import { createModuleLogger } from '@/lib/utils/logger' +import type { HubSpotSearchOptions, HubSpotObjectType } from '@/lib/integrations/hubspot/types' + +const log = createModuleLogger('[HubSpot Search]') + +export async function GET(request: Request) { + try { + const supabase = await createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const membership = await getWorkspaceMembership() + if (!membership) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + + const url = new URL(request.url) + const connectionId = url.searchParams.get('connection_id') + const objectType = (url.searchParams.get('object_type') || 'contacts') as HubSpotObjectType + const query = url.searchParams.get('q') + const limit = Math.min(parseInt(url.searchParams.get('limit') || '10', 10), 100) + const after = url.searchParams.get('after') || undefined + + if (!connectionId) { + return NextResponse.json({ error: 'connection_id is required' }, { status: 400 }) + } + + if (!query) { + return NextResponse.json({ error: 'q (search query) is required' }, { status: 400 }) + } + + // Verify connection belongs to this workspace + const { data: connection } = await supabase + .from('hubspot_connections' as never) + .select('id, workspace_id') + .eq('id', connectionId) + .eq('workspace_id', membership.workspaceId) + .single() + + if (!connection) { + return NextResponse.json({ error: 'Connection not found' }, { status: 404 }) + } + + // Get credentials + const credentials = await getHubSpotConnectionCredentials(connectionId) + if (!credentials) { + return NextResponse.json({ error: 'Failed to load connection credentials' }, { status: 500 }) + } + + // Create client + const client = new HubSpotClient({ + token: credentials.token, + authType: credentials.authType, + connectionId, + }) + + // Build search options with heuristic + const searchOptions = buildSearchOptions(query, objectType, limit, after) + + // Execute search + const results = await client.search(objectType, searchOptions) + + return NextResponse.json({ + total: results.total, + results: results.results, + paging: results.paging, + }) + } catch (error) { + log.error('Search error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Search failed' }, + { status: 500 } + ) + } +} + +/** + * Build search options with heuristic query detection. + * + * If query contains "@", it's likely an email — search by email property. + * Otherwise, use HubSpot's full-text search (searches name, domain, etc.). + */ +function buildSearchOptions( + query: string, + objectType: HubSpotObjectType, + limit: number, + after?: string +): HubSpotSearchOptions { + const isEmailQuery = query.includes('@') + + if (isEmailQuery) { + // Search by email for contacts, or domain for companies + const emailProperty = objectType === 'companies' ? 'domain' : 'email' + + return { + filterGroups: [ + { + filters: [ + { + propertyName: emailProperty, + operator: 'CONTAINS_TOKEN', + value: query, + }, + ], + }, + ], + limit, + after, + } + } + + // Full-text search (HubSpot searches across name, email, domain, etc.) + return { + query, + limit, + after, + } +} diff --git a/src/app/api/integrations/hubspot/validate/route.test.ts b/src/app/api/integrations/hubspot/validate/route.test.ts new file mode 100644 index 00000000..6891f40e --- /dev/null +++ b/src/app/api/integrations/hubspot/validate/route.test.ts @@ -0,0 +1,229 @@ +/// +/** + * Tests for POST /api/integrations/hubspot/validate + * + * HS-I09: Valid Private App token → connection created + * HS-I10: Invalid token format → 400 + * HS-I11: Token fails connection test → 401 + * HS-I12: Not authenticated → 401 + * HS-I13: PATCH updates config + */ + +import { NextRequest } from 'next/server' +import { POST, PATCH } from './route' + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const mockGetUser = vi.fn() +const mockGetWorkspaceMembership = vi.fn() +const mockUpsert = vi.fn() +const mockUpdate = vi.fn() + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn().mockImplementation(async () => ({ + auth: { getUser: mockGetUser }, + from: vi.fn().mockReturnValue({ + upsert: mockUpsert.mockReturnValue({ + select: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { id: 'conn-1', hub_id: '12345' }, + error: null, + }), + }), + }), + update: mockUpdate.mockReturnValue({ + eq: vi.fn().mockReturnValue({ + eq: vi.fn().mockResolvedValue({ error: null }), + }), + }), + }), + })), +})) + +vi.mock('@/lib/supabase/helpers', () => ({ + getWorkspaceMembership: () => mockGetWorkspaceMembership(), +})) + +vi.mock('@/lib/crypto/encryption', () => ({ + encrypt: vi.fn().mockResolvedValue('encrypted-token'), +})) + +vi.mock('@/lib/utils/logger', () => ({ + createModuleLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +vi.mock('@/lib/utils/api-rate-limit', () => ({ + applyRateLimit: vi.fn().mockReturnValue(null), + RATE_LIMITS: { + VALIDATION: { limit: 10, windowSeconds: 60 }, + }, +})) + +// Mock fetch for HubSpot API calls +const originalFetch = global.fetch + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRequest(body: Record, method = 'POST') { + return new NextRequest('http://localhost:3000/api/integrations/hubspot/validate', { + method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('POST /api/integrations/hubspot/validate', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } } }) + mockGetWorkspaceMembership.mockResolvedValue({ + workspaceId: 'ws-1', + userId: 'user-1', + role: 'owner', + }) + + // Mock successful HubSpot API response + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + portalId: 12345, + uiDomain: 'app.hubspot.com', + accountType: 'STANDARD', + }), + }) + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + // HS-I12: Not authenticated + it('returns 401 when not authenticated', async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + const res = await POST(makeRequest({ token: 'pat-na1-test1234567890' })) + expect(res.status).toBe(401) + }) + + // No workspace + it('returns 404 when no workspace found', async () => { + mockGetWorkspaceMembership.mockResolvedValue(null) + const res = await POST(makeRequest({ token: 'pat-na1-test1234567890' })) + expect(res.status).toBe(404) + }) + + // HS-I10: Invalid token format + it('returns 400 for token without pat- prefix', async () => { + const res = await POST(makeRequest({ token: 'sk-invalid-token-here' })) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.success).toBe(false) + expect(body.error.code).toBe('invalid_token') + }) + + it('returns 400 for empty token', async () => { + const res = await POST(makeRequest({ token: '' })) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.success).toBe(false) + }) + + it('returns 400 for too short token', async () => { + const res = await POST(makeRequest({ token: 'pat-short' })) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('invalid_token') + }) + + // HS-I11: Token fails connection test + it('returns 401 when HubSpot API rejects token', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({ message: 'Unauthorized' }), + }) + + const res = await POST(makeRequest({ token: 'pat-na1-valid-but-rejected1234' })) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.success).toBe(false) + expect(body.error.code).toBe('connection_failed') + }) + + // HS-I09: Valid token → connection created + it('creates connection for valid token', async () => { + const res = await POST( + makeRequest({ token: 'pat-na1-valid1234567890abcdef', name: 'My HubSpot' }) + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + expect(body.connection_id).toBe('conn-1') + expect(body.hub_id).toBe('12345') + }) + + it('uses default name when none provided', async () => { + const res = await POST(makeRequest({ token: 'pat-na1-valid1234567890abcdef' })) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + }) +}) + +describe('PATCH /api/integrations/hubspot/validate', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } } }) + mockGetWorkspaceMembership.mockResolvedValue({ + workspaceId: 'ws-1', + userId: 'user-1', + role: 'owner', + }) + }) + + it('returns 401 when not authenticated', async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + const res = await PATCH( + makeRequest({ connection_id: 'conn-1', config: { enabled_objects: ['contacts'] } }) + ) + expect(res.status).toBe(401) + }) + + it('returns 400 when connection_id is missing', async () => { + const res = await PATCH( + makeRequest({ config: { enabled_objects: ['contacts'] } }) + ) + expect(res.status).toBe(400) + }) + + it('returns 400 when config is missing', async () => { + const res = await PATCH(makeRequest({ connection_id: 'conn-1' })) + expect(res.status).toBe(400) + }) + + // HS-I13: Successful config update + it('updates connection config', async () => { + const res = await PATCH( + makeRequest({ + connection_id: 'conn-1', + config: { enabled_objects: ['contacts', 'companies'] }, + }) + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + }) +}) diff --git a/src/app/api/integrations/hubspot/validate/route.ts b/src/app/api/integrations/hubspot/validate/route.ts new file mode 100644 index 00000000..83fc6d99 --- /dev/null +++ b/src/app/api/integrations/hubspot/validate/route.ts @@ -0,0 +1,260 @@ +/** + * POST /api/integrations/hubspot/validate + * + * Validate a HubSpot Private App token, test the connection, + * encrypt and store credentials, and create a connection row. + * + * Request: + * { + * "token": "pat-...", + * "name": "My HubSpot" (optional, defaults to "HubSpot") + * } + * + * Response (success): + * { + * "success": true, + * "connection_id": "uuid", + * "hub_id": "12345", + * "account_name": "My Company" + * } + * + * PATCH /api/integrations/hubspot/validate + * + * Update connection config (e.g., enabled_objects, sync_interval). + * + * Request: + * { + * "connection_id": "uuid", + * "config": { "enabled_objects": ["contacts", "companies"] } + * } + */ + +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { getWorkspaceMembership } from '@/lib/supabase/helpers' +import { validatePrivateAppToken } from '@/lib/integrations/hubspot/auth' +import { encrypt } from '@/lib/crypto/encryption' +import { HUBSPOT_API_BASE } from '@/lib/integrations/hubspot/config' +import { createModuleLogger } from '@/lib/utils/logger' +import { applyRateLimit, RATE_LIMITS } from '@/lib/utils/api-rate-limit' + +const log = createModuleLogger('[HubSpot Validate]') + +/** + * Test a HubSpot API connection by fetching account info. + */ +async function testHubSpotConnection(token: string): Promise<{ + success: boolean + hubId?: string + hubDomain?: string + accountName?: string + error?: string +}> { + try { + const response = await fetch(`${HUBSPOT_API_BASE}/account-info/v3/details`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + return { success: false, error: 'Invalid token or insufficient permissions' } + } + if (response.status === 429) { + return { success: false, error: 'HubSpot rate limit exceeded. Try again later.' } + } + return { success: false, error: `HubSpot API error: ${response.status}` } + } + + const data = await response.json() + return { + success: true, + hubId: String(data.portalId), + hubDomain: data.uiDomain || null, + accountName: data.accountType || null, + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + if (message.includes('fetch') || message.includes('ECONNREFUSED')) { + return { success: false, error: 'Unable to reach HubSpot API' } + } + return { success: false, error: message } + } +} + +/** + * POST: Validate and store a Private App token + */ +export async function POST(request: Request) { + // Rate limit: 10 validations per minute per IP + const rateLimitResponse = applyRateLimit(request, 'hubspot-validate', RATE_LIMITS.VALIDATION) + if (rateLimitResponse) return rateLimitResponse + + try { + const supabase = await createClient() + + // Authenticate user + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + // Get workspace + const membership = await getWorkspaceMembership() + if (!membership) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + + // Parse request body + const body = await request.json() + const { token, name = 'HubSpot' } = body + + // Validate token format + const validation = validatePrivateAppToken(token) + if (!validation.valid) { + return NextResponse.json( + { + success: false, + error: { + code: 'invalid_token', + message: validation.error || 'Invalid token format', + }, + }, + { status: 400 } + ) + } + + // Test connection to HubSpot + const connectionTest = await testHubSpotConnection(token) + if (!connectionTest.success) { + return NextResponse.json( + { + success: false, + error: { + code: 'connection_failed', + message: connectionTest.error || 'Could not connect to HubSpot', + }, + }, + { status: 401 } + ) + } + + // Encrypt the token + const tokenEncrypted = await encrypt(token) + + // Upsert connection row + const { data: connection, error: upsertError } = await supabase + .from('hubspot_connections') + .upsert( + { + workspace_id: membership.workspaceId, + name, + auth_type: 'private_app', + private_app_token_encrypted: tokenEncrypted, + hub_id: connectionTest.hubId || null, + hub_domain: connectionTest.hubDomain || null, + account_name: connectionTest.accountName || null, + status: 'connected', + is_active: true, + is_primary: true, + last_validated_at: new Date().toISOString(), + last_error: null, + } as never, + { + onConflict: 'workspace_id,name', + } + ) + .select() + .single() + + if (upsertError) { + log.error('Failed to save connection:', upsertError) + return NextResponse.json( + { + success: false, + error: { + code: 'storage_error', + message: 'Failed to save connection. Please try again.', + }, + }, + { status: 500 } + ) + } + + return NextResponse.json({ + success: true, + connection_id: (connection as Record).id, + hub_id: connectionTest.hubId, + account_name: connectionTest.accountName, + message: 'HubSpot connected successfully', + }) + } catch (error) { + log.error('Validate error:', error) + return NextResponse.json( + { + success: false, + error: { + code: 'unknown_error', + message: error instanceof Error ? error.message : 'An unexpected error occurred', + }, + }, + { status: 500 } + ) + } +} + +/** + * PATCH: Update connection configuration + */ +export async function PATCH(request: Request) { + try { + const supabase = await createClient() + + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const membership = await getWorkspaceMembership() + if (!membership) { + return NextResponse.json({ error: 'No workspace found' }, { status: 404 }) + } + + const body = await request.json() + const { connection_id, config } = body + + if (!connection_id) { + return NextResponse.json({ error: 'Missing connection_id' }, { status: 400 }) + } + + if (!config) { + return NextResponse.json({ error: 'Missing config' }, { status: 400 }) + } + + const { error } = await supabase + .from('hubspot_connections') + .update({ config_json: config } as never) + .eq('id', connection_id) + .eq('workspace_id', membership.workspaceId) + + if (error) { + log.error('Failed to update connection config:', error) + return NextResponse.json( + { error: 'Failed to update connection configuration' }, + { status: 500 } + ) + } + + return NextResponse.json({ success: true }) + } catch (error) { + log.error('PATCH error:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/app/api/workspace/setup-status/route.ts b/src/app/api/workspace/setup-status/route.ts index ac1919ed..9b49cdeb 100644 --- a/src/app/api/workspace/setup-status/route.ts +++ b/src/app/api/workspace/setup-status/route.ts @@ -65,6 +65,18 @@ export async function GET() { const posthogConnected = posthogConfig?.is_active && posthogConfig?.status === 'connected' const attioConnected = attioConfig?.is_active && attioConfig?.status === 'connected' + // Check HubSpot connections (optional integration — does not block setup) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: hubspotConnections } = await (supabase as any) + .from('hubspot_connections') + .select('id, is_active, status') + .eq('workspace_id', membership.workspaceId) + .eq('is_active', true) + .eq('status', 'connected') + .limit(1) + + const hubspotConnected = hubspotConnections && hubspotConnections.length > 0 + // Check billing status (only in cloud mode) const billingRequired = isBillingEnabled() let billingConfigured = false @@ -101,6 +113,7 @@ export async function GET() { integrations: { posthog: !!posthogConnected, attio: !!attioConnected, + hubspot: !!hubspotConnected, }, billing: { required: billingRequired, diff --git a/src/components/hubspot/HubSpotEntityCreator.tsx b/src/components/hubspot/HubSpotEntityCreator.tsx new file mode 100644 index 00000000..6e9de574 --- /dev/null +++ b/src/components/hubspot/HubSpotEntityCreator.tsx @@ -0,0 +1,511 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Spinner } from "@/components/ui/spinner" +import { HubSpotEntityChip } from "@/components/setup/previews/HubSpotEntityChip" +import { + Building2, + User, + Handshake, + Check, + ArrowRight, + ExternalLink, +} from "lucide-react" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface EntityResult { + record_id: string + object_type: string + hubspot_url: string | null +} + +interface BatchResult { + company?: EntityResult + contact?: EntityResult + deal?: EntityResult +} + +export interface HubSpotEntityCreatorProps { + companyName?: string + companyDomain?: string + contactName?: string + contactEmail?: string + /** HubSpot portal ID for deep links */ + portalId?: string | number | null + /** Connection ID */ + connectionId?: string + /** Callbacks */ + onEntitiesCreated?: (result: BatchResult) => void + /** inline = embedded in onboarding, dialog = standalone overlay */ + mode?: "inline" | "dialog" + className?: string +} + +type CreationPhase = "idle" | "company" | "contact" | "deal" | "done" | "error" + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function HubSpotEntityCreator({ + companyName, + companyDomain, + contactName, + contactEmail, + portalId, + connectionId, + onEntitiesCreated, + mode = "inline", + className, +}: HubSpotEntityCreatorProps) { + // Checkbox state + const [createCompany, setCreateCompany] = useState(true) + const [createContact, setCreateContact] = useState(true) + const [createDeal, setCreateDeal] = useState(true) + + // Existence check + const [existenceLoading, setExistenceLoading] = useState(false) + const [companyExists, setCompanyExists] = useState<{ + exists: boolean + recordId?: string + } | null>(null) + const [contactExists, setContactExists] = useState<{ + exists: boolean + recordId?: string + } | null>(null) + + // Creation state + const [phase, setPhase] = useState("idle") + const [error, setError] = useState(null) + const [result, setResult] = useState(null) + + // Check if entities already exist in HubSpot + useEffect(() => { + if (!companyDomain && !contactEmail) return + + const controller = new AbortController() + const { signal } = controller + setExistenceLoading(true) + + const checks = [] + + if (companyDomain) { + const params = new URLSearchParams({ + object_type: "companies", + property: "domain", + value: companyDomain, + }) + if (connectionId) params.set("connection_id", connectionId) + + checks.push( + fetch(`/api/integrations/hubspot/search?${params}`, { signal }) + .then((r) => r.json()) + .then((data) => { + if (signal.aborted) return + const match = data.results?.[0] + setCompanyExists( + match + ? { exists: true, recordId: match.id } + : { exists: false } + ) + if (match) setCreateCompany(false) + }) + .catch((err) => { + if (err instanceof DOMException && err.name === "AbortError") return + if (!signal.aborted) setCompanyExists({ exists: false }) + }) + ) + } + + if (contactEmail) { + const params = new URLSearchParams({ + object_type: "contacts", + property: "email", + value: contactEmail, + }) + if (connectionId) params.set("connection_id", connectionId) + + checks.push( + fetch(`/api/integrations/hubspot/search?${params}`, { signal }) + .then((r) => r.json()) + .then((data) => { + if (signal.aborted) return + const match = data.results?.[0] + setContactExists( + match + ? { exists: true, recordId: match.id } + : { exists: false } + ) + if (match) setCreateContact(false) + }) + .catch((err) => { + if (err instanceof DOMException && err.name === "AbortError") return + if (!signal.aborted) setContactExists({ exists: false }) + }) + ) + } + + Promise.all(checks).finally(() => { + if (!signal.aborted) setExistenceLoading(false) + }) + + return () => { + controller.abort() + } + }, [companyDomain, contactEmail, connectionId]) + + // Create entities via batch API + const handleCreate = useCallback(async () => { + let currentPhase: CreationPhase = "company" + setPhase("company") + setError(null) + + try { + const body: Record = {} + if (connectionId) body.connection_id = connectionId + + if (createCompany && companyName) { + body.create_company = true + body.company_data = { + name: companyName, + ...(companyDomain ? { domain: companyDomain } : {}), + } + } + + if (createContact && contactEmail) { + body.create_contact = true + body.contact_data = { + email: contactEmail, + ...(contactName + ? { + firstname: contactName.split(" ")[0], + lastname: contactName.split(" ").slice(1).join(" ") || undefined, + } + : {}), + } + } + + if (createDeal && companyName) { + body.create_deal = true + body.deal_data = { + dealname: `${companyName} -- Beton Signal`, + } + } + + currentPhase = createCompany ? "company" : createContact ? "contact" : "deal" + setPhase(currentPhase) + + const res = await fetch("/api/integrations/hubspot/entities", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + + const data = await res.json() + + if (!res.ok) { + throw new Error(data.error || "Failed to create entities") + } + + const batchResult: BatchResult = data.results || {} + setResult(batchResult) + currentPhase = "done" + setPhase("done") + + onEntitiesCreated?.(batchResult) + } catch (err) { + const msg = err instanceof Error ? err.message : "An error occurred" + setError(msg) + setPhase("error") + } + }, [ + createCompany, + createContact, + createDeal, + companyName, + companyDomain, + contactName, + contactEmail, + connectionId, + onEntitiesCreated, + ]) + + const isCreating = phase !== "idle" && phase !== "done" && phase !== "error" + const nothingToCreate = !createCompany && !createContact && !createDeal + const noData = !companyName && !contactEmail + + // ── Success state + if (phase === "done" && result) { + return ( +
+
+ + + Entities created in HubSpot + +
+
+ {result.company && ( + + )} + {result.contact && ( + + )} + {result.deal && ( + + )} +
+
+ ) + } + + // ── Main form + return ( +
+ {/* Header */} +
+
+ H +
+ + Create in HubSpot + + {existenceLoading && } +
+ + {noData ? ( +

+ Select a contact or enter company data to create HubSpot entities. +

+ ) : ( + <> + {/* Company row */} + {companyName && ( + } + label={companyName} + sublabel={companyDomain} + checked={createCompany} + onCheckedChange={setCreateCompany} + exists={companyExists} + portalId={portalId} + objectType="companies" + disabled={isCreating} + /> + )} + + {/* Chain arrow */} + {companyName && contactEmail && ( +
+ + linked to +
+ )} + + {/* Contact row */} + {contactEmail && ( + } + label={contactName || contactEmail.split("@")[0]} + sublabel={contactEmail} + checked={createContact} + onCheckedChange={setCreateContact} + exists={contactExists} + portalId={portalId} + objectType="contacts" + disabled={isCreating} + /> + )} + + {/* Chain arrow */} + {(companyName || contactEmail) && createDeal && ( +
+ + creates +
+ )} + + {/* Deal row */} + {(companyName || contactEmail) && ( + } + label={`${companyName || "New"} -- Beton Signal`} + checked={createDeal} + onCheckedChange={setCreateDeal} + exists={null} + portalId={portalId} + objectType="deals" + disabled={isCreating} + /> + )} + + {/* Error */} + {error && ( +

{error}

+ )} + + {/* Progress */} + {isCreating && ( +
+ + + {phase === "company" + ? "Creating company..." + : phase === "contact" + ? "Creating contact..." + : "Creating deal..."} + +
+ )} + + {/* Create button */} + + + )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Entity row with checkbox +// --------------------------------------------------------------------------- + +function EntityRow({ + icon, + label, + sublabel, + checked, + onCheckedChange, + exists, + portalId, + objectType, + disabled, +}: { + icon: React.ReactNode + label: string + sublabel?: string + checked: boolean + onCheckedChange: (v: boolean) => void + exists: { exists: boolean; recordId?: string } | null + portalId?: string | number | null + objectType: "companies" | "contacts" | "deals" + disabled?: boolean +}) { + const existsInHubSpot = exists?.exists === true + + const urlObjectType: Record = { + contacts: "contact", + companies: "company", + deals: "deal", + } + const pathType = urlObjectType[objectType] || objectType + + return ( + + ) +} diff --git a/src/components/hubspot/HubSpotFieldMappingStep.tsx b/src/components/hubspot/HubSpotFieldMappingStep.tsx new file mode 100644 index 00000000..d7d87098 --- /dev/null +++ b/src/components/hubspot/HubSpotFieldMappingStep.tsx @@ -0,0 +1,379 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Spinner } from '@/components/ui/spinner' +import { Check, Plus } from 'lucide-react' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface HubSpotProperty { + name: string + label: string + type: string + fieldType: string + groupName: string + calculated: boolean +} + +interface FieldMappingEntry { + betonField: string + betonLabel: string + hubspotProperty: string | null +} + +// --------------------------------------------------------------------------- +// Beton fields for HubSpot mapping +// --------------------------------------------------------------------------- + +const BETON_FIELDS: Array<{ + field: string + label: string + description: string + type: string +}> = [ + { field: 'domain', label: 'Domain', description: 'Company website domain', type: 'string' }, + { field: 'health_score', label: 'Health Score', description: 'Account health (0-100)', type: 'number' }, + { field: 'expansion_score', label: 'Expansion Score', description: 'Expansion potential (0-100)', type: 'number' }, + { field: 'churn_risk_score', label: 'Churn Risk Score', description: 'Churn risk (0-100)', type: 'number' }, + { field: 'concrete_grade', label: 'Concrete Grade', description: 'Account grade (M100-M10)', type: 'enumeration' }, + { field: 'signal_count', label: 'Signal Count', description: 'Total signals detected', type: 'number' }, + { field: 'last_signal_date', label: 'Last Signal Date', description: 'When last signal was detected', type: 'datetime' }, + { field: 'last_signal_type', label: 'Last Signal Type', description: 'Type of last signal', type: 'string' }, +] + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface HubSpotFieldMappingStepProps { + connectionId?: string + onSuccess: (mapping: Record) => void + className?: string +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * HubSpot field mapping step -- maps Beton computed fields to HubSpot properties. + * Auto-discovers HubSpot properties and supports auto-creating beton_* properties. + */ +export function HubSpotFieldMappingStep({ + connectionId, + onSuccess, + className, +}: HubSpotFieldMappingStepProps) { + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [isSaving, setIsSaving] = useState(false) + const [isCreatingProps, setIsCreatingProps] = useState(false) + const [propsCreated, setPropsCreated] = useState(false) + + const [properties, setProperties] = useState([]) + const [objectType] = useState('companies') + const [mapping, setMapping] = useState( + BETON_FIELDS.map((f) => ({ + betonField: f.field, + betonLabel: f.label, + hubspotProperty: null, + })) + ) + + // Load HubSpot properties on mount + useEffect(() => { + async function loadProperties() { + try { + const params = new URLSearchParams({ object_type: objectType }) + if (connectionId) params.set('connection_id', connectionId) + + const res = await fetch(`/api/integrations/hubspot/properties?${params}`) + if (!res.ok) throw new Error('Failed to load HubSpot properties') + const data = await res.json() + + const props: HubSpotProperty[] = (data.properties || data.results || []) + .filter((p: HubSpotProperty) => !p.calculated) + + setProperties(props) + + // Auto-match domain -> domain property + setMapping((prev) => + prev.map((m) => { + if (m.betonField === 'domain') { + const domainMatch = props.find( + (p: HubSpotProperty) => p.name === 'domain' || p.name === 'website' + ) + return { ...m, hubspotProperty: domainMatch?.name || m.hubspotProperty } + } + // Auto-match beton_* properties if they exist + const betonName = `beton_${m.betonField}` + const betonMatch = props.find((p: HubSpotProperty) => p.name === betonName) + if (betonMatch) { + return { ...m, hubspotProperty: betonMatch.name } + } + return m + }) + ) + + // Load existing mappings + try { + const mappingParams = new URLSearchParams() + if (connectionId) mappingParams.set('connection_id', connectionId) + const mapRes = await fetch(`/api/integrations/hubspot/mappings?${mappingParams}`) + if (mapRes.ok) { + const mapData = await mapRes.json() + if (mapData.field_mappings && typeof mapData.field_mappings === 'object') { + setMapping((prev) => + prev.map((m) => { + const saved = mapData.field_mappings[m.betonField] + if (saved) { + return { ...m, hubspotProperty: saved } + } + return m + }) + ) + } + } + } catch { + // Non-critical: continue without saved mappings + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load HubSpot properties') + } finally { + setIsLoading(false) + } + } + loadProperties() + }, [objectType, connectionId]) + + const updateMapping = (betonField: string, hubspotName: string | null) => { + setMapping((prev) => + prev.map((m) => + m.betonField === betonField ? { ...m, hubspotProperty: hubspotName } : m + ) + ) + } + + /** + * Auto-create beton_* custom properties in HubSpot. + */ + const handleCreateBetonProperties = async () => { + setIsCreatingProps(true) + setError(null) + + try { + const res = await fetch('/api/integrations/hubspot/entities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + connection_id: connectionId, + ensure_properties: true, + // Trigger property creation without creating any entities + create_company: false, + create_contact: false, + create_deal: false, + }), + }) + + // Regardless of entity creation result, reload properties + const params = new URLSearchParams({ object_type: objectType }) + if (connectionId) params.set('connection_id', connectionId) + const propsRes = await fetch(`/api/integrations/hubspot/properties?${params}`) + if (propsRes.ok) { + const data = await propsRes.json() + const props: HubSpotProperty[] = (data.properties || data.results || []) + .filter((p: HubSpotProperty) => !p.calculated) + setProperties(props) + + // Auto-map beton_* properties + setMapping((prev) => + prev.map((m) => { + const betonName = `beton_${m.betonField}` + const betonMatch = props.find((p) => p.name === betonName) + if (betonMatch && !m.hubspotProperty) { + return { ...m, hubspotProperty: betonMatch.name } + } + return m + }) + ) + } + + setPropsCreated(true) + + if (!res.ok) { + // Properties might still have been created; don't block user + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create properties') + } finally { + setIsCreatingProps(false) + } + } + + const handleSave = async () => { + setIsSaving(true) + setError(null) + + try { + const mappingRecord: Record = {} + for (const entry of mapping) { + mappingRecord[entry.betonField] = entry.hubspotProperty + } + mappingRecord._object = objectType + + const res = await fetch('/api/integrations/hubspot/mappings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + connection_id: connectionId, + field_mappings: mappingRecord, + }), + }) + + if (!res.ok) { + const data = await res.json().catch(() => null) + throw new Error(data?.error || 'Failed to save field mapping') + } + + onSuccess(mappingRecord) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save') + } finally { + setIsSaving(false) + } + } + + if (isLoading) { + return ( +
+
+ + + Loading HubSpot properties... + +
+
+ ) + } + + return ( +
+
+
+

Map HubSpot Fields

+

+ Choose which HubSpot properties should receive Beton data. + Unmapped fields will be skipped. +

+
+ + {/* Create in HubSpot button */} +
+ + + Auto-creates Beton custom properties on {objectType} + +
+ + {/* Mapping table */} +
+ + + + + + + + + {mapping.map((entry) => { + const fieldInfo = BETON_FIELDS.find((f) => f.field === entry.betonField) + return ( + + + + + ) + })} + +
+ Beton Field + + HubSpot Property +
+
+

{entry.betonLabel}

+

+ {fieldInfo?.description} +

+
+
+ +
+
+ + {error && ( + + Error + {error} + + )} + + + + +
+
+ ) +} diff --git a/src/components/settings/DataSourcesSection.tsx b/src/components/settings/DataSourcesSection.tsx new file mode 100644 index 00000000..b49c1f9b --- /dev/null +++ b/src/components/settings/DataSourcesSection.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Spinner } from "@/components/ui/spinner"; +import { toastManager } from "@/components/ui/toast"; +import { + Dialog, + DialogTrigger, + DialogPopup, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogClose, +} from "@/components/ui/dialog"; +import { Database, Plus, Trash2, RefreshCw } from "lucide-react"; +import { useDataSources, useDeleteDataSource, useValidateDataSource } from "@/lib/hooks/use-data-sources"; + +export function DataSourcesSection() { + const { data, isLoading } = useDataSources(); + const deleteMutation = useDeleteDataSource(); + const validateMutation = useValidateDataSource(); + const [deletingId, setDeletingId] = useState(null); + + const dataSources = data?.data_sources ?? []; + + if (isLoading) { + return ( +
+
+

+ Database Connections +

+
+
+ +
+
+ ); + } + + if (dataSources.length === 0) return null; + + return ( +
+
+

+ Database Connections +

+ +
+ +
+ {dataSources.map((ds: Record) => ( +
+
+
+ +
+
+

{ds.name as string}

+

+ {ds.host as string}:{ds.port as number} / {ds.database_name as string} +

+
+
+ +
+ {ds.status === "connected" ? ( + + Connected + + ) : ds.status === "error" ? ( + Error + ) : ( + + Pending + + )} + + + + setDeletingId(open ? (ds.id as string) : null)} + > + + + + } + /> + + + Remove Data Source + + Are you sure you want to remove “{ds.name as string}”? This + action cannot be undone. + + + + Cancel} /> + + + + +
+
+ ))} +
+
+ ); +} diff --git a/src/components/settings/HubSpotConnectionsSection.tsx b/src/components/settings/HubSpotConnectionsSection.tsx new file mode 100644 index 00000000..c086834a --- /dev/null +++ b/src/components/settings/HubSpotConnectionsSection.tsx @@ -0,0 +1,267 @@ +"use client" + +import { useState, useEffect } from "react" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Spinner } from "@/components/ui/spinner" +import { toastManager } from "@/components/ui/toast" +import { + Dialog, + DialogTrigger, + DialogPopup, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogClose, +} from "@/components/ui/dialog" +import { Plus, Trash2, RefreshCw, Key, Shield } from "lucide-react" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface HubSpotConnection { + id: string + name: string + auth_type: "oauth" | "private_app" + hub_id: string | null + hub_domain: string | null + account_name: string | null + status: string + is_active: boolean + is_primary: boolean + last_validated_at: string | null + last_error: string | null + created_at: string +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function HubSpotConnectionsSection() { + const [connections, setConnections] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [deletingId, setDeletingId] = useState(null) + const [testingId, setTestingId] = useState(null) + + // Load connections + useEffect(() => { + async function loadConnections() { + try { + const res = await fetch("/api/integrations/hubspot/connections", { + credentials: "include", + }) + if (!res.ok) { + setConnections([]) + return + } + const data = await res.json() + setConnections(data.connections || []) + } catch { + setConnections([]) + } finally { + setIsLoading(false) + } + } + loadConnections() + }, []) + + const handleDelete = async (id: string) => { + setDeletingId(id) + try { + const res = await fetch(`/api/integrations/hubspot/connections/${id}`, { + method: "DELETE", + credentials: "include", + }) + if (!res.ok) throw new Error("Delete failed") + setConnections((prev) => prev.filter((c) => c.id !== id)) + toastManager.add({ type: "success", title: "Connection removed" }) + } catch { + toastManager.add({ type: "error", title: "Failed to remove connection" }) + } finally { + setDeletingId(null) + } + } + + const handleTest = async (id: string) => { + setTestingId(id) + try { + const res = await fetch(`/api/integrations/hubspot/connections/${id}`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "test" }), + }) + const data = await res.json() + if (data.success || res.ok) { + toastManager.add({ type: "success", title: "HubSpot connection is healthy" }) + // Update status + setConnections((prev) => + prev.map((c) => + c.id === id ? { ...c, status: "connected", last_error: null } : c + ) + ) + } else { + toastManager.add({ + type: "error", + title: data.error || "Connection test failed", + }) + } + } catch { + toastManager.add({ type: "error", title: "Connection test failed" }) + } finally { + setTestingId(null) + } + } + + if (isLoading) { + return ( +
+
+

+ HubSpot Connections +

+
+
+ +
+
+ ) + } + + if (connections.length === 0) return null + + return ( +
+
+

+ HubSpot Connections +

+ +
+ +
+ {connections.map((conn) => ( +
+
+
+
+ H +
+
+
+
+

{conn.name}

+ {conn.is_primary && ( + + Primary + + )} +
+
+ {conn.auth_type === "oauth" ? ( + + ) : ( + + )} + + {conn.auth_type === "oauth" ? "OAuth" : "Private App"} + {conn.hub_id ? ` - Portal ${conn.hub_id}` : ""} + +
+
+
+ +
+ {conn.status === "connected" ? ( + + Connected + + ) : conn.status === "error" ? ( + + Error + + ) : ( + + {conn.status} + + )} + + + + + + {deletingId === conn.id ? ( + + ) : ( + + )} + + } + /> + + + Remove HubSpot Connection? + + This will remove the connection "{conn.name}" and + stop syncing data. Existing synced data will not be + deleted. + + + + Cancel} + /> + handleDelete(conn.id)} + > + Remove + + } + /> + + + +
+
+ ))} +
+
+ ) +} diff --git a/src/components/setup/SetupWizard.tsx b/src/components/setup/SetupWizard.tsx index fcd21c98..257371d6 100644 --- a/src/components/setup/SetupWizard.tsx +++ b/src/components/setup/SetupWizard.tsx @@ -17,13 +17,17 @@ import { BillingStep } from "./steps/BillingStep"; import { AttioStep } from "./steps/AttioStep"; import { DealFieldMappingStep, type DealMappingState } from "./steps/DealFieldMappingStep"; import { FirecrawlStep } from "./steps/FirecrawlStep"; +import { PostgresStep } from "./steps/PostgresStep"; +import { HubSpotStep } from "./steps/HubSpotStep"; import { ContactPicker, type SelectedContact } from "./fields/ContactPicker"; import { getDefaultSampleData, deriveCompanyFromEmail, type SampleData } from "@/lib/setup/sample-data"; import { useSession } from "@/components/auth/session-provider"; import { WebsiteStep } from "./steps/WebsiteStep"; import { PostHogPreview } from "./previews/PostHogPreview"; import { AttioConnectionPreview } from "./previews/AttioConnectionPreview"; +import { HubSpotConnectionPreview } from "./previews/HubSpotConnectionPreview"; import { FirecrawlPreview } from "./previews/FirecrawlPreview"; +import { PostgresPreview } from "./previews/PostgresPreview"; import { SlackNotificationPreview } from "./previews/SlackNotificationPreview"; import { CrmCardPreview } from "./previews/CrmCardPreview"; import { useIntegrationDefinitions } from "@/lib/hooks/use-integration-definitions"; @@ -171,6 +175,13 @@ export function SetupWizard({ const [firecrawlConnected, setFirecrawlConnected] = useState(false); const [firecrawlMode, setFirecrawlMode] = useState<"cloud" | "self_hosted" | null>(null); const [firecrawlProxy, setFirecrawlProxy] = useState(null); + const [postgresConnected, setPostgresConnected] = useState(false); + const [postgresDbName, setPostgresDbName] = useState(""); + const [hubspotConnected, setHubspotConnected] = useState(false); + const [hubspotPortalName, setHubspotPortalName] = useState(""); + const [hubspotPortalId, setHubspotPortalId] = useState(""); + const [hubspotAuthType, setHubspotAuthType] = useState<"oauth" | "private_app" | null>(null); + const [hubspotEnabledObjects, setHubspotEnabledObjects] = useState([]); // Deal mapping state for live preview const [dealMappingState, setDealMappingState] = useState({ @@ -240,6 +251,10 @@ export function SetupWizard({ if (at?.is_connected) setAttioConnected(true); const fc = definitions.find((d) => d.name === "firecrawl"); if (fc?.is_connected) setFirecrawlConnected(true); + const pg = definitions.find((d) => d.name === "postgres"); + if (pg?.is_connected) setPostgresConnected(true); + const hs = definitions.find((d) => d.name === "hubspot"); + if (hs?.is_connected) setHubspotConnected(true); } }, [definitions]); @@ -370,6 +385,36 @@ export function SetupWizard({ advanceFrom("skipped"); }, [advanceFrom]); + const handlePostgresSuccess = useCallback(() => { + setPostgresConnected(true); + advanceFrom("completed"); + }, [advanceFrom]); + + const handlePostgresSkip = useCallback(() => { + advanceFrom("skipped"); + }, [advanceFrom]); + + const handleHubSpotSuccess = useCallback( + (data: { + portalName: string; + portalId: string; + authType: "oauth" | "private_app"; + enabledObjects: string[]; + }) => { + setHubspotConnected(true); + setHubspotPortalName(data.portalName); + setHubspotPortalId(data.portalId); + setHubspotAuthType(data.authType); + setHubspotEnabledObjects(data.enabledObjects); + advanceFrom("completed"); + }, + [advanceFrom] + ); + + const handleHubSpotSkip = useCallback(() => { + advanceFrom("skipped"); + }, [advanceFrom]); + // Auth bypass skip button — shown on every step when auth is bypassed const authBypassSkipButton = authBypass ? ( + )} + + ), + preview: ( + + ), + }; + + case "hubspot": + return { + config: ( +
+ + {authBypassSkipButton} +
+ ), + preview: ( + + ), + }; + default: return { config: null, preview: null }; } diff --git a/src/components/setup/previews/HubSpotConnectionPreview.tsx b/src/components/setup/previews/HubSpotConnectionPreview.tsx new file mode 100644 index 00000000..c3f5a93f --- /dev/null +++ b/src/components/setup/previews/HubSpotConnectionPreview.tsx @@ -0,0 +1,136 @@ +"use client" + +import { cn } from "@/lib/utils" +import { Check, Link2, Key, Shield } from "lucide-react" +import { Badge } from "@/components/ui/badge" + +interface HubSpotConnectionPreviewProps { + isConnected: boolean + portalName?: string | null + portalId?: string | null + authType?: "oauth" | "private_app" | null + enabledObjects?: string[] + className?: string +} + +/** + * Right panel preview for the HubSpot connection step. + * Shows HubSpot branding + portal name after successful connection. + */ +export function HubSpotConnectionPreview({ + isConnected, + portalName, + portalId, + authType, + enabledObjects, + className, +}: HubSpotConnectionPreviewProps) { + return ( +
+ {/* HubSpot header */} +
+
+ + + +
+
+

HubSpot

+

CRM Platform

+
+ {isConnected && ( +
+ + Connected +
+ )} +
+ +
+ {isConnected ? ( + <> + {/* Portal display */} + {portalName && ( +
+
{portalName}
+
+ Portal{portalId ? ` (${portalId})` : ""} +
+
+ )} + + {/* Auth type indicator */} + {authType && ( +
+ {authType === "oauth" ? ( + + + OAuth + + ) : ( + + + Private App + + )} +
+ )} + + {/* Enabled objects */} + {enabledObjects && enabledObjects.length > 0 && ( +
+ {enabledObjects.map((obj) => ( + + {obj.charAt(0).toUpperCase() + obj.slice(1)} + + ))} +
+ )} + +
+
+ + Companies and contacts will be synced +
+
+ + Deals created from detected signals +
+
+ + Beton scores enriched on records +
+
+ + ) : ( + /* Pre-connection state */ +
+
+
+ + Connect your HubSpot CRM +
+
+
+ Beton creates deals on signal detection +
+
+
+ Enriches contacts with usage signals +
+
+
+ Map custom fields in the next step +
+
+
+ )} +
+
+ ) +} diff --git a/src/components/setup/previews/HubSpotEntityChip.tsx b/src/components/setup/previews/HubSpotEntityChip.tsx new file mode 100644 index 00000000..6150e620 --- /dev/null +++ b/src/components/setup/previews/HubSpotEntityChip.tsx @@ -0,0 +1,82 @@ +"use client" + +import { cn } from "@/lib/utils" +import { ExternalLink } from "lucide-react" + +interface HubSpotEntityChipProps { + /** Entity display name (e.g., "Acme Corp") */ + name: string + /** HubSpot object type: "companies", "contacts", or "deals" */ + objectType: "companies" | "contacts" | "deals" + /** HubSpot portal ID for building the deep link */ + portalId?: string | number | null + /** HubSpot record ID for deep linking */ + recordId?: string | null + /** Whether the entity exists in HubSpot */ + linked?: boolean + className?: string +} + +/** + * Pill badge linking to a HubSpot entity page. + * + * - Linked state (solid border): clickable, opens in new tab + * - Unlinked state (dashed border): muted, shows "(not linked)" tooltip + * - Neobrutalist hover: shadow offset + translate + */ +export function HubSpotEntityChip({ + name, + objectType, + portalId, + recordId, + linked = true, + className, +}: HubSpotEntityChipProps) { + const urlObjectType: Record = { + contacts: "contact", + companies: "company", + deals: "deal", + } + const pathType = urlObjectType[objectType] || objectType + + const href = + linked && portalId && recordId + ? `https://app.hubspot.com/contacts/${encodeURIComponent(String(portalId))}/${pathType}/${encodeURIComponent(recordId)}` + : undefined + + const Wrapper = href ? "a" : "span" + const wrapperProps = href + ? { href, target: "_blank" as const, rel: "noopener noreferrer" } + : {} + + return ( + + {/* HubSpot icon - small inline */} +
+ H +
+ + {/* Status dot */} + + + {name} + + {href && } +
+ ) +} diff --git a/src/components/setup/previews/PostgresPreview.tsx b/src/components/setup/previews/PostgresPreview.tsx new file mode 100644 index 00000000..4d901ee5 --- /dev/null +++ b/src/components/setup/previews/PostgresPreview.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Database, Check, Loader2 } from "lucide-react"; + +interface PostgresPreviewProps { + isConnected: boolean; + databaseName?: string; + host?: string; +} + +export function PostgresPreview({ + isConnected, + databaseName, + host, +}: PostgresPreviewProps) { + return ( +
+
+
+ +
+
+

PostgreSQL

+

+ {isConnected ? "Connected" : "Connect your database"} +

+
+ {isConnected && ( +
+ +
+ )} +
+ + {isConnected ? ( +
+ {databaseName && ( +
+ Database + {databaseName} +
+ )} + {host && ( +
+ Host + {host} +
+ )} +
+ Access + + Read-only + +
+
+ ) : ( +
+
+
+ Explore schema and tables +
+
+
+ Run read-only analytics queries +
+
+
+ AI-powered data exploration +
+
+ )} +
+ ); +} diff --git a/src/components/setup/steps/HubSpotStep.tsx b/src/components/setup/steps/HubSpotStep.tsx new file mode 100644 index 00000000..6647617a --- /dev/null +++ b/src/components/setup/steps/HubSpotStep.tsx @@ -0,0 +1,425 @@ +"use client" + +import { useState, useCallback } from "react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert" +import { Spinner } from "@/components/ui/spinner" +import { Check, AlertCircle, Eye, EyeOff, Link2, Key, Shield } from "lucide-react" +import { + trackIntegrationConnected, + trackIntegrationConnectionFailed, + trackOnboardingStepSkipped, +} from "@/lib/analytics" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type AuthMethod = "oauth" | "private_app" +type StepState = "idle" | "validating" | "success" | "error" + +const ERROR_MESSAGES: Record = { + "401": "Invalid token. Please check and try again.", + "403": "Access denied. Please verify your token has the required scopes.", + network: "Unable to reach HubSpot. Check your connection.", + unknown: "An unexpected error occurred. Please try again.", +} + +/** Default HubSpot object types to enable */ +const DEFAULT_OBJECT_TYPES = [ + { id: "contacts", label: "Contacts", defaultChecked: true }, + { id: "companies", label: "Companies", defaultChecked: true }, + { id: "deals", label: "Deals", defaultChecked: true }, + { id: "tickets", label: "Tickets", defaultChecked: false }, +] as const + +export interface HubSpotStepProps { + /** Callback when HubSpot connection is successfully validated */ + onSuccess: (data: { + portalName: string + portalId: string + authType: AuthMethod + enabledObjects: string[] + }) => void + /** Optional callback to skip this step */ + onSkip?: () => void + /** Optional CSS class for the container */ + className?: string +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * HubSpot CRM connection step for the setup wizard. + * + * Features: + * - Auth method selector: OAuth vs Private App Token + * - OAuth: "Connect HubSpot" button -> opens OAuth flow + * - Private App: API key input (masked, must start with `pat-`) + * - Connection name input + * - Object type checkboxes + * - Skip button (HubSpot is optional) + */ +export function HubSpotStep({ onSuccess, onSkip, className }: HubSpotStepProps) { + // Auth method + const [authMethod, setAuthMethod] = useState("private_app") + + // Form state + const [token, setToken] = useState("") + const [showToken, setShowToken] = useState(false) + const [connectionName, setConnectionName] = useState("") + const [enabledObjects, setEnabledObjects] = useState>( + new Set(DEFAULT_OBJECT_TYPES.filter((o) => o.defaultChecked).map((o) => o.id)) + ) + + // Validation state + const [state, setState] = useState("idle") + const [error, setError] = useState(null) + const [portalInfo, setPortalInfo] = useState<{ + portalName: string + portalId: string + } | null>(null) + + const getErrorMessage = useCallback((err: unknown): string => { + if (err instanceof Error) { + if (err.message.includes("fetch") || err.message.includes("network")) { + return ERROR_MESSAGES.network + } + return err.message + } + const errorStr = String(err) + for (const code of Object.keys(ERROR_MESSAGES)) { + if (errorStr.includes(code)) { + return ERROR_MESSAGES[code] + } + } + return ERROR_MESSAGES.unknown + }, []) + + const toggleObjectType = (objectId: string) => { + setEnabledObjects((prev) => { + const next = new Set(prev) + if (next.has(objectId)) { + next.delete(objectId) + } else { + next.add(objectId) + } + return next + }) + } + + /** + * Handle OAuth flow — opens HubSpot authorization page. + */ + const handleOAuth = useCallback(() => { + window.open( + "/api/integrations/hubspot/oauth/authorize", + "_blank", + "width=600,height=700" + ) + }, []) + + /** + * Validate Private App token via the validate endpoint. + */ + const handleValidate = useCallback(async () => { + if (state === "validating") return + + if (!token.trim()) { + setError("Please enter your Private App token.") + return + } + + if (!token.trim().startsWith("pat-")) { + setError('HubSpot Private App tokens must start with "pat-".') + return + } + + setError(null) + setState("validating") + + try { + const validateResponse = await fetch("/api/integrations/hubspot/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + token: token.trim(), + auth_type: "private_app", + name: connectionName.trim() || undefined, + enabled_objects: Array.from(enabledObjects), + }), + }) + + if (!validateResponse.ok) { + const data = await validateResponse.json().catch(() => ({})) + throw new Error(data.error || `${validateResponse.status}`) + } + + const data = await validateResponse.json() + const pName = data.portal_name || data.portalName || data.account_name || "" + const pId = data.portal_id || data.portalId || data.hub_id || "" + + setPortalInfo({ portalName: pName, portalId: String(pId) }) + setState("success") + + trackIntegrationConnected("hubspot", { + category: "crm", + auth_type: "private_app", + portal_id: String(pId), + } as Record) + + onSuccess({ + portalName: pName, + portalId: String(pId), + authType: "private_app", + enabledObjects: Array.from(enabledObjects), + }) + } catch (err) { + setState("error") + const msg = getErrorMessage(err) + setError(msg) + trackIntegrationConnectionFailed({ + integration_name: "hubspot", + error_message: msg, + }) + } + }, [token, connectionName, enabledObjects, onSuccess, getErrorMessage, state]) + + const isLoading = state === "validating" + const isSuccess = state === "success" + + return ( +
+ {/* Header */} +
+
+ + Connect to HubSpot CRM +
+

+ Beton will sync high-intent signals to your HubSpot CRM, creating and + enriching contacts, companies, and deals with product usage data. +

+
+ + {/* Auth Method Selector */} +
+ +
+ + +
+
+ + {/* OAuth Flow */} + {authMethod === "oauth" && !isSuccess && ( +
+

+ Click below to authorize Beton with your HubSpot account. + You will be redirected to HubSpot to grant access. +

+ +
+ )} + + {/* Private App Token Input */} + {authMethod === "private_app" && ( + <> +
+ +
+ setToken(e.target.value)} + placeholder="pat-na1-..." + disabled={isLoading || isSuccess} + className="pr-10" + /> + +
+

+ Create one in{" "} + + HubSpot Settings → Private Apps + +

+
+ + {/* Connection Name */} +
+ + setConnectionName(e.target.value)} + placeholder="My HubSpot" + disabled={isLoading || isSuccess} + /> +
+ + )} + + {/* Object Type Selection */} + {!isSuccess && ( +
+ +
+ {DEFAULT_OBJECT_TYPES.map((obj) => ( + + ))} +
+
+ )} + + {/* Error Display */} + {error && ( + + + Connection Failed + {error} + + )} + + {/* Success Display */} + {isSuccess && portalInfo && ( + + + Connected! + + Successfully connected to HubSpot + {portalInfo.portalName && ( + <> + {" "} + portal: {portalInfo.portalName} + + )} + {portalInfo.portalId && ( + + (ID: {portalInfo.portalId}) + + )} + + + )} + + {/* Validate Button (Private App mode) */} + {authMethod === "private_app" && !isSuccess && ( + + )} + + {/* Skip Button */} + {!isSuccess && onSkip && ( + + )} +
+ ) +} + +export default HubSpotStep diff --git a/src/components/setup/steps/PostgresStep.tsx b/src/components/setup/steps/PostgresStep.tsx new file mode 100644 index 00000000..7b3d01a7 --- /dev/null +++ b/src/components/setup/steps/PostgresStep.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; +import { Spinner } from "@/components/ui/spinner"; +import { Check, AlertCircle, Eye, EyeOff, Database, Link2 } from "lucide-react"; +import { parseConnectionString } from "@/lib/integrations/postgres/connection-string"; +import { isPrivateHostname } from "@/lib/utils/ssrf"; +import { + createDataSource, + validateDataSource, +} from "@/lib/api/data-sources"; + +type InputMode = "fields" | "connection_string"; +type StepState = "idle" | "validating" | "success" | "error"; + +const SSL_MODES = [ + { id: "require", label: "Require (recommended)" }, + { id: "prefer", label: "Prefer" }, + { id: "disable", label: "Disable" }, +] as const; + +export interface PostgresStepProps { + onSuccess: (data: { dataSourceId: string; name: string }) => void; + className?: string; +} + +export function PostgresStep({ onSuccess, className }: PostgresStepProps) { + // Input mode + const [inputMode, setInputMode] = useState("connection_string"); + + // Form fields + const [name, setName] = useState(""); + const [connectionString, setConnectionString] = useState(""); + const [host, setHost] = useState(""); + const [port, setPort] = useState("5432"); + const [database, setDatabase] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [sslMode, setSslMode] = useState("require"); + + // UI state + const [showPassword, setShowPassword] = useState(false); + const [state, setState] = useState("idle"); + const [errorMessage, setErrorMessage] = useState(""); + + // Parse connection string into fields on blur + const handleConnectionStringBlur = useCallback(() => { + if (!connectionString.trim()) return; + try { + const parsed = parseConnectionString(connectionString); + if (parsed.host) setHost(parsed.host); + if (parsed.port) setPort(String(parsed.port)); + if (parsed.database) setDatabase(parsed.database); + if (parsed.user) setUsername(parsed.user); + if (parsed.password) setPassword(parsed.password); + if (parsed.sslMode) setSslMode(parsed.sslMode); + } catch { + // Invalid string — user will see error on submit + } + }, [connectionString]); + + const handleConnect = useCallback(async () => { + // Validate required fields + if (!name.trim()) { + setErrorMessage("Please give this data source a name (e.g., \"Production DB\")"); + setState("error"); + return; + } + if (!host.trim()) { + setErrorMessage("Host is required"); + setState("error"); + return; + } + if (!database.trim()) { + setErrorMessage("Database name is required"); + setState("error"); + return; + } + if (!username.trim()) { + setErrorMessage("Username is required"); + setState("error"); + return; + } + if (!password) { + setErrorMessage("Password is required"); + setState("error"); + return; + } + + // Client-side SSRF check + if (isPrivateHostname(host.trim())) { + setErrorMessage("Cannot connect to private/internal addresses. Use a publicly accessible host."); + setState("error"); + return; + } + + setState("validating"); + setErrorMessage(""); + + try { + // Step 1: Create the data source + const createResult = await createDataSource({ + name: name.trim(), + host: host.trim(), + port: parseInt(port) || 5432, + database_name: database.trim(), + username: username.trim(), + password, + ssl_mode: sslMode, + }); + + const dsId = (createResult.data_source as { id: string }).id; + + // Step 2: Test the connection + const validateResult = await validateDataSource(dsId); + + if (validateResult.status === "connected") { + setState("success"); + onSuccess({ dataSourceId: dsId, name: name.trim() }); + } else { + setState("error"); + setErrorMessage(validateResult.message || "Connection failed"); + } + } catch (err) { + setState("error"); + setErrorMessage( + err instanceof Error ? err.message : "An unexpected error occurred" + ); + } + }, [name, host, port, database, username, password, sslMode, onSuccess]); + + const isReady = + name.trim() && + host.trim() && + database.trim() && + username.trim() && + password; + + return ( +
+ {/* Header */} +
+
+ +
+ Connect PostgreSQL +
+ +

+ Connect your PostgreSQL database to explore data and run analytics queries. +

+ + {/* Data source name */} +
+ + setName(e.target.value)} + disabled={state === "validating" || state === "success"} + /> +
+ + {/* Input mode toggle */} +
+ + +
+ + {/* Connection string input */} + {inputMode === "connection_string" && ( +
+ + setConnectionString(e.target.value)} + onBlur={handleConnectionStringBlur} + disabled={state === "validating" || state === "success"} + className="font-mono text-xs" + /> +

+ Paste your connection string — fields below will be populated automatically. +

+
+ )} + + {/* Individual fields (always shown, populated from conn string) */} +
+
+ + setHost(e.target.value)} + disabled={state === "validating" || state === "success"} + /> +
+
+ + setPort(e.target.value)} + disabled={state === "validating" || state === "success"} + /> +
+
+ + setDatabase(e.target.value)} + disabled={state === "validating" || state === "success"} + /> +
+
+ + setUsername(e.target.value)} + disabled={state === "validating" || state === "success"} + /> +
+
+ +
+ setPassword(e.target.value)} + disabled={state === "validating" || state === "success"} + className="pr-9" + /> + +
+
+
+ + {/* SSL mode */} +
+ +
+ {SSL_MODES.map((mode) => ( + + ))} +
+
+ + {/* Error */} + {state === "error" && errorMessage && ( + + + Connection Failed + {errorMessage} + + )} + + {/* Success */} + {state === "success" && ( +
+
+ +

+ Connected to {database} +

+
+

+ {host}:{port} · User: {username} +

+
+ )} + + {/* Connect button */} + {state !== "success" && ( + + )} + + {/* Recommendation */} +

+ For security, we recommend creating a read-only database role for + Beton. All queries are enforced as read-only, but a dedicated role adds + an extra layer of protection. +

+
+ ); +} diff --git a/src/lib/agent/pg-handler.ts b/src/lib/agent/pg-handler.ts new file mode 100644 index 00000000..755e70a0 --- /dev/null +++ b/src/lib/agent/pg-handler.ts @@ -0,0 +1,128 @@ +/** + * Shared middleware for Postgres agent endpoints. + * + * Handles the common boilerplate across all /api/agent/pg/* routes: + * auth → parse params → resolve session → rate limit → resolve data source + * + * Returns a ready-to-use context with { workspaceId, sessionUUID, dataSource }. + */ + +import { NextRequest, NextResponse } from 'next/server' +import { validateAgentRequest } from '@/lib/agent/auth' +import { rateLimitResponse } from '@/lib/agent/rate-limit' +import { resolveSession } from '@/lib/agent/session' +import { + getDataSourceAdmin, + getDataSourceByNameAdmin, +} from '@/lib/integrations/postgres/credentials' +import type { DataSourceRecord } from '@/lib/integrations/postgres/types' + +export interface PgAgentContext { + workspaceId: string + sessionUUID: string + dataSource: DataSourceRecord +} + +interface PgAgentOptions { + maxRequests?: number // Rate limit override (default: 30) +} + +/** + * Wrap a Postgres agent route handler with shared middleware. + * + * Usage: + * ```ts + * export const GET = withPgAgentHandler(async (req, ctx) => { + * const result = await listSchemas(ctx.dataSource) + * return NextResponse.json(result) + * }) + * ``` + */ +export function withPgAgentHandler( + handler: (req: NextRequest, ctx: PgAgentContext) => Promise, + options?: PgAgentOptions, +) { + return async (req: NextRequest): Promise => { + // Step 1: Validate agent auth + if (!validateAgentRequest(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Step 2: Parse params (GET → searchParams, POST → body) + let sessionId: string | null = null + let dataSourceId: string | null = null + let dataSourceName: string | null = null + + if (req.method === 'GET') { + const params = req.nextUrl.searchParams + sessionId = params.get('session_id') + dataSourceId = params.get('data_source_id') + dataSourceName = params.get('data_source_name') + } else { + // For POST, we need to clone the request so the handler can also read the body + const body = await req.clone().json() + sessionId = body.session_id ?? null + dataSourceId = body.data_source_id ?? null + dataSourceName = body.data_source_name ?? null + } + + if (!sessionId) { + return NextResponse.json({ error: 'Missing session_id' }, { status: 400 }) + } + + if (!dataSourceId && !dataSourceName) { + return NextResponse.json( + { error: 'Missing data_source_id or data_source_name' }, + { status: 400 }, + ) + } + + // Step 3: Resolve session → workspaceId + let workspaceId: string + let sessionUUID: string + try { + const session = await resolveSession(sessionId) + workspaceId = session.workspaceId + sessionUUID = session.sessionUUID + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid session' + return NextResponse.json({ error: msg }, { status: 404 }) + } + + // Step 4: Rate limit + const limited = rateLimitResponse(workspaceId, { + maxRequests: options?.maxRequests ?? 30, + }) + if (limited) return limited + + // Step 5: Resolve data source + let dataSource: DataSourceRecord | null = null + try { + if (dataSourceId) { + dataSource = await getDataSourceAdmin(workspaceId, dataSourceId) + } else if (dataSourceName) { + dataSource = await getDataSourceByNameAdmin(workspaceId, dataSourceName) + } + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to resolve data source' + return NextResponse.json({ error: msg }, { status: 500 }) + } + + if (!dataSource) { + return NextResponse.json( + { error: `Data source not found${dataSourceName ? `: "${dataSourceName}"` : ''}` }, + { status: 404 }, + ) + } + + if (!dataSource.is_active) { + return NextResponse.json( + { error: 'Data source is inactive' }, + { status: 403 }, + ) + } + + // Step 6: Call the actual handler + return handler(req, { workspaceId, sessionUUID, dataSource }) + } +} diff --git a/src/lib/api/data-sources.ts b/src/lib/api/data-sources.ts new file mode 100644 index 00000000..8ed0cf85 --- /dev/null +++ b/src/lib/api/data-sources.ts @@ -0,0 +1,61 @@ +/** + * Data Sources API Client + * + * Fetch-based client for the /api/data-sources endpoints. + */ + +import type { + CreateDataSourceRequest, + UpdateDataSourceRequest, +} from '@/lib/integrations/postgres/types' + +const BASE = '/api/data-sources' + +async function handleResponse(res: Response): Promise { + const body = await res.json() + if (!res.ok) { + throw new Error(body.error ?? `Request failed: ${res.status}`) + } + return body as T +} + +export async function listDataSources() { + const res = await fetch(BASE) + return handleResponse<{ data_sources: Record[] }>(res) +} + +export async function getDataSource(id: string) { + const res = await fetch(`${BASE}/${id}`) + return handleResponse<{ data_source: Record }>(res) +} + +export async function createDataSource(config: CreateDataSourceRequest) { + const res = await fetch(BASE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }) + return handleResponse<{ data_source: Record }>(res) +} + +export async function updateDataSource( + id: string, + patch: UpdateDataSourceRequest, +) { + const res = await fetch(`${BASE}/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }) + return handleResponse<{ data_source: Record }>(res) +} + +export async function deleteDataSource(id: string) { + const res = await fetch(`${BASE}/${id}`, { method: 'DELETE' }) + return handleResponse<{ deleted: boolean }>(res) +} + +export async function validateDataSource(id: string) { + const res = await fetch(`${BASE}/${id}/validate`, { method: 'POST' }) + return handleResponse<{ status: string; message: string }>(res) +} diff --git a/src/lib/heuristics/scoring-config.ts b/src/lib/heuristics/scoring-config.ts index 090871f6..80069ef2 100644 --- a/src/lib/heuristics/scoring-config.ts +++ b/src/lib/heuristics/scoring-config.ts @@ -140,6 +140,35 @@ export const DEFAULT_SCORING_CONFIG: ScoringConfig = { category: 'neutral', description: 'Decision maker on free plan', }, + + // HubSpot CRM signals — Expansion + hubspot_deal_stage_change: { + weight: 18, + category: 'expansion', + description: 'Deal stage progressed in HubSpot', + }, + hubspot_new_deal: { + weight: 22, + category: 'expansion', + description: 'New deal created in HubSpot', + }, + hubspot_lifecycle_change: { + weight: 15, + category: 'expansion', + description: 'Contact lifecycle stage advanced in HubSpot', + }, + hubspot_meeting_booked: { + weight: 12, + category: 'expansion', + description: 'Meeting booked in HubSpot', + }, + + // HubSpot CRM signals — Churn risk + hubspot_ticket_created: { + weight: -14, + category: 'churn_risk', + description: 'Support ticket increase in HubSpot', + }, }, } diff --git a/src/lib/heuristics/signals/detectors/hubspot-deal-stage-change.ts b/src/lib/heuristics/signals/detectors/hubspot-deal-stage-change.ts new file mode 100644 index 00000000..41572266 --- /dev/null +++ b/src/lib/heuristics/signals/detectors/hubspot-deal-stage-change.ts @@ -0,0 +1,76 @@ +/** + * HubSpot Deal Stage Change Detector + * Fires when a deal's stage changes (detected via HubSpot sync data). + * Source: HubSpot CRM synced records (hubspot_records table). + */ + +import type { SignalDetectorDefinition, DetectorContext, DetectedSignal } from '../types' +import { signalExists, createDetectedSignal, getHubSpotIdsForAccount } from '../helpers' + +export const hubspotDealStageChangeDetector: SignalDetectorDefinition = { + meta: { + name: 'hubspot_deal_stage_change', + category: 'expansion', + description: 'Deal stage changed in HubSpot CRM', + defaultConfig: { + lookback_days: 1, + time_window_days: 7, + }, + }, + + async detect(accountId: string, context: DetectorContext): Promise { + const { supabase, workspaceId, config } = context + const lookbackDays = config?.lookback_days ?? 1 + const timeWindowDays = config?.time_window_days ?? 7 + + // Check for existing signal (dedup) + if (await signalExists(supabase, accountId, 'hubspot_deal_stage_change', lookbackDays)) { + return null + } + + // Look for deals linked to this account that have changed stage recently + const cutoffDate = new Date() + cutoffDate.setDate(cutoffDate.getDate() - timeWindowDays) + + // Query hubspot_records for deals synced recently that belong to this account's domain + // First get the account's domain for matching + const { data: account } = await supabase + .from('accounts') + .select('domain, name') + .eq('id', accountId) + .single() + + if (!account?.domain) return null + + // Find deals associated with this account's HubSpot company + const dealIds = await getHubSpotIdsForAccount(supabase, workspaceId, account.domain, 'deals') + if (dealIds.length === 0) return null + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: deals } = await (supabase as any) + .from('hubspot_records') + .select('hubspot_id, properties, hubspot_updated_at') + .eq('workspace_id', workspaceId) + .eq('object_type', 'deals') + .in('hubspot_id', dealIds) + .gte('hubspot_updated_at', cutoffDate.toISOString()) + .limit(10) + + if (!deals || deals.length === 0) return null + + // Check if any deal has a dealstage that differs from what we've seen before + // For simplicity, fire on any deal that was updated recently + const recentDeal = deals[0] + const dealStage = recentDeal.properties?.dealstage + const dealName = recentDeal.properties?.dealname + + if (!dealStage) return null + + return createDetectedSignal(accountId, workspaceId, 'hubspot_deal_stage_change', 1, { + deal_id: recentDeal.hubspot_id, + deal_name: dealName || 'Unknown', + current_stage: dealStage, + source: 'hubspot', + }) + }, +} diff --git a/src/lib/heuristics/signals/detectors/hubspot-lifecycle-change.ts b/src/lib/heuristics/signals/detectors/hubspot-lifecycle-change.ts new file mode 100644 index 00000000..2b124b80 --- /dev/null +++ b/src/lib/heuristics/signals/detectors/hubspot-lifecycle-change.ts @@ -0,0 +1,99 @@ +/** + * HubSpot Lifecycle Stage Change Detector + * Fires when a contact's lifecycle stage changes in HubSpot. + * This can indicate progression (expansion) or regression (churn risk). + * Source: HubSpot CRM synced records (hubspot_records table). + */ + +import type { SignalDetectorDefinition, DetectorContext, DetectedSignal } from '../types' +import { signalExists, createDetectedSignal, getHubSpotIdsForAccount } from '../helpers' + +/** HubSpot lifecycle stages in order of progression */ +const LIFECYCLE_PROGRESSION = [ + 'subscriber', + 'lead', + 'marketingqualifiedlead', + 'salesqualifiedlead', + 'opportunity', + 'customer', + 'evangelist', +] + +export const hubspotLifecycleChangeDetector: SignalDetectorDefinition = { + meta: { + name: 'hubspot_lifecycle_change', + category: 'expansion', + description: 'Contact lifecycle stage changed in HubSpot', + defaultConfig: { + lookback_days: 1, + time_window_days: 7, + }, + }, + + async detect(accountId: string, context: DetectorContext): Promise { + const { supabase, workspaceId, config } = context + const lookbackDays = config?.lookback_days ?? 1 + const timeWindowDays = config?.time_window_days ?? 7 + + // Dedup + if (await signalExists(supabase, accountId, 'hubspot_lifecycle_change', lookbackDays)) { + return null + } + + const cutoffDate = new Date() + cutoffDate.setDate(cutoffDate.getDate() - timeWindowDays) + + // Get account domain to scope query to this account's HubSpot records + const { data: account } = await supabase + .from('accounts') + .select('domain') + .eq('id', accountId) + .single() + + if (!account?.domain) return null + + const contactIds = await getHubSpotIdsForAccount(supabase, workspaceId, account.domain, 'contacts') + if (contactIds.length === 0) return null + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: contacts } = await (supabase as any) + .from('hubspot_records') + .select('hubspot_id, properties, hubspot_updated_at') + .eq('workspace_id', workspaceId) + .eq('object_type', 'contacts') + .in('hubspot_id', contactIds) + .gte('hubspot_updated_at', cutoffDate.toISOString()) + .order('hubspot_updated_at', { ascending: false }) + .limit(10) + + if (!contacts || contacts.length === 0) return null + + // Look for contacts with lifecycle stage set + for (const contact of contacts) { + const lifecycle = contact.properties?.lifecyclestage as string | undefined + if (!lifecycle) continue + + const stageIndex = LIFECYCLE_PROGRESSION.indexOf(lifecycle.toLowerCase()) + if (stageIndex < 0) continue + + // Consider it noteworthy if the stage is at least "salesqualifiedlead" or beyond + if (stageIndex >= 3) { + return createDetectedSignal( + accountId, + workspaceId, + 'hubspot_lifecycle_change', + stageIndex, + { + contact_id: contact.hubspot_id, + email: contact.properties?.email || null, + lifecycle_stage: lifecycle, + stage_index: stageIndex, + source: 'hubspot', + } + ) + } + } + + return null + }, +} diff --git a/src/lib/heuristics/signals/detectors/hubspot-meeting-booked.ts b/src/lib/heuristics/signals/detectors/hubspot-meeting-booked.ts new file mode 100644 index 00000000..95b39c1b --- /dev/null +++ b/src/lib/heuristics/signals/detectors/hubspot-meeting-booked.ts @@ -0,0 +1,78 @@ +/** + * HubSpot Meeting Booked Detector + * Fires when a new meeting engagement is logged in HubSpot for an account. + * Meetings indicate active engagement — positive expansion signal. + * Source: HubSpot CRM synced records (hubspot_records table). + */ + +import type { SignalDetectorDefinition, DetectorContext, DetectedSignal } from '../types' +import { signalExists, createDetectedSignal, getHubSpotIdsForAccount } from '../helpers' + +export const hubspotMeetingBookedDetector: SignalDetectorDefinition = { + meta: { + name: 'hubspot_meeting_booked', + category: 'expansion', + description: 'New meeting logged in HubSpot', + defaultConfig: { + lookback_days: 1, + time_window_days: 7, + }, + }, + + async detect(accountId: string, context: DetectorContext): Promise { + const { supabase, workspaceId, config } = context + const lookbackDays = config?.lookback_days ?? 1 + const timeWindowDays = config?.time_window_days ?? 7 + + // Dedup + if (await signalExists(supabase, accountId, 'hubspot_meeting_booked', lookbackDays)) { + return null + } + + const cutoffDate = new Date() + cutoffDate.setDate(cutoffDate.getDate() - timeWindowDays) + + // Get account domain to scope query to this account's HubSpot records + const { data: account } = await supabase + .from('accounts') + .select('domain') + .eq('id', accountId) + .single() + + if (!account?.domain) return null + + const meetingIds = await getHubSpotIdsForAccount(supabase, workspaceId, account.domain, 'meetings') + if (meetingIds.length === 0) return null + + // Note: meetings object_type is not currently synced (only contacts, companies, deals, tickets) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: meetings, count } = await (supabase as any) + .from('hubspot_records') + .select('hubspot_id, properties, hubspot_created_at', { count: 'exact' }) + .eq('workspace_id', workspaceId) + .eq('object_type', 'meetings') + .in('hubspot_id', meetingIds) + .gte('hubspot_created_at', cutoffDate.toISOString()) + .order('hubspot_created_at', { ascending: false }) + .limit(5) + + const meetingCount = count ?? meetings?.length ?? 0 + if (meetingCount === 0) return null + + const latestMeeting = meetings?.[0] + + return createDetectedSignal( + accountId, + workspaceId, + 'hubspot_meeting_booked', + meetingCount, + { + meeting_count: meetingCount, + latest_meeting_id: latestMeeting?.hubspot_id || null, + latest_timestamp: latestMeeting?.properties?.hs_timestamp || null, + time_window_days: timeWindowDays, + source: 'hubspot', + } + ) + }, +} diff --git a/src/lib/heuristics/signals/detectors/hubspot-new-deal.ts b/src/lib/heuristics/signals/detectors/hubspot-new-deal.ts new file mode 100644 index 00000000..f1103df7 --- /dev/null +++ b/src/lib/heuristics/signals/detectors/hubspot-new-deal.ts @@ -0,0 +1,74 @@ +/** + * HubSpot New Deal Detector + * Fires when a new deal is created in HubSpot CRM for an account. + * Source: HubSpot CRM synced records (hubspot_records table). + */ + +import type { SignalDetectorDefinition, DetectorContext, DetectedSignal } from '../types' +import { signalExists, createDetectedSignal, getHubSpotIdsForAccount } from '../helpers' + +export const hubspotNewDealDetector: SignalDetectorDefinition = { + meta: { + name: 'hubspot_new_deal', + category: 'expansion', + description: 'New deal created in HubSpot CRM', + defaultConfig: { + lookback_days: 1, + time_window_days: 7, + }, + }, + + async detect(accountId: string, context: DetectorContext): Promise { + const { supabase, workspaceId, config } = context + const lookbackDays = config?.lookback_days ?? 1 + const timeWindowDays = config?.time_window_days ?? 7 + + // Dedup + if (await signalExists(supabase, accountId, 'hubspot_new_deal', lookbackDays)) { + return null + } + + const cutoffDate = new Date() + cutoffDate.setDate(cutoffDate.getDate() - timeWindowDays) + + // Get account domain to scope query to this account's HubSpot records + const { data: account } = await supabase + .from('accounts') + .select('domain') + .eq('id', accountId) + .single() + + if (!account?.domain) return null + + const dealIds = await getHubSpotIdsForAccount(supabase, workspaceId, account.domain, 'deals') + if (dealIds.length === 0) return null + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: deals } = await (supabase as any) + .from('hubspot_records') + .select('hubspot_id, properties, hubspot_created_at') + .eq('workspace_id', workspaceId) + .eq('object_type', 'deals') + .in('hubspot_id', dealIds) + .gte('hubspot_created_at', cutoffDate.toISOString()) + .order('hubspot_created_at', { ascending: false }) + .limit(5) + + if (!deals || deals.length === 0) return null + + const newDeal = deals[0] + const amount = newDeal.properties?.amount + ? parseFloat(String(newDeal.properties.amount)) + : null + + return createDetectedSignal(accountId, workspaceId, 'hubspot_new_deal', amount, { + deal_id: newDeal.hubspot_id, + deal_name: newDeal.properties?.dealname || 'Unknown', + amount: amount, + pipeline: newDeal.properties?.pipeline || null, + deal_stage: newDeal.properties?.dealstage || null, + created_at: newDeal.hubspot_created_at, + source: 'hubspot', + }) + }, +} diff --git a/src/lib/heuristics/signals/detectors/hubspot-ticket-created.ts b/src/lib/heuristics/signals/detectors/hubspot-ticket-created.ts new file mode 100644 index 00000000..60f6d7c3 --- /dev/null +++ b/src/lib/heuristics/signals/detectors/hubspot-ticket-created.ts @@ -0,0 +1,80 @@ +/** + * HubSpot Ticket Created Detector + * Fires when a new support ticket is created in HubSpot for an account. + * Classified as churn_risk since increased support volume may indicate issues. + * Source: HubSpot CRM synced records (hubspot_records table). + */ + +import type { SignalDetectorDefinition, DetectorContext, DetectedSignal } from '../types' +import { signalExists, createDetectedSignal, getHubSpotIdsForAccount } from '../helpers' + +export const hubspotTicketCreatedDetector: SignalDetectorDefinition = { + meta: { + name: 'hubspot_ticket_created', + category: 'churn_risk', + description: 'New support ticket created in HubSpot', + defaultConfig: { + lookback_days: 1, + time_window_days: 7, + threshold: 2, // fire when >= 2 tickets in the window + }, + }, + + async detect(accountId: string, context: DetectorContext): Promise { + const { supabase, workspaceId, config } = context + const lookbackDays = config?.lookback_days ?? 1 + const timeWindowDays = config?.time_window_days ?? 7 + const threshold = config?.threshold ?? 2 + + // Dedup + if (await signalExists(supabase, accountId, 'hubspot_ticket_created', lookbackDays)) { + return null + } + + const cutoffDate = new Date() + cutoffDate.setDate(cutoffDate.getDate() - timeWindowDays) + + // Get account domain to scope query to this account's HubSpot records + const { data: account } = await supabase + .from('accounts') + .select('domain') + .eq('id', accountId) + .single() + + if (!account?.domain) return null + + const ticketIds = await getHubSpotIdsForAccount(supabase, workspaceId, account.domain, 'tickets') + if (ticketIds.length === 0) return null + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: tickets, count } = await (supabase as any) + .from('hubspot_records') + .select('hubspot_id, properties', { count: 'exact' }) + .eq('workspace_id', workspaceId) + .eq('object_type', 'tickets') + .in('hubspot_id', ticketIds) + .gte('hubspot_created_at', cutoffDate.toISOString()) + .limit(5) + + const ticketCount = count ?? tickets?.length ?? 0 + + if (ticketCount < threshold) return null + + // Get the most recent ticket for details + const latestTicket = tickets?.[0] + + return createDetectedSignal( + accountId, + workspaceId, + 'hubspot_ticket_created', + ticketCount, + { + ticket_count: ticketCount, + latest_subject: latestTicket?.properties?.subject || null, + latest_priority: latestTicket?.properties?.hs_ticket_priority || null, + time_window_days: timeWindowDays, + source: 'hubspot', + } + ) + }, +} diff --git a/src/lib/heuristics/signals/detectors/index.ts b/src/lib/heuristics/signals/detectors/index.ts index 54ff1754..0df8acf5 100644 --- a/src/lib/heuristics/signals/detectors/index.ts +++ b/src/lib/heuristics/signals/detectors/index.ts @@ -1,6 +1,6 @@ /** * Signal Detectors Index - * Exports all 20 signal detectors organized by category + * Exports all 25 signal detectors organized by category */ // Expansion signals @@ -17,6 +17,12 @@ export { upgradePageVisitDetector } from './upgrade-page-visit' export { approachingSeatLimitDetector } from './approaching-seat-limit' export { overageDetector } from './overage' +// HubSpot expansion signals +export { hubspotDealStageChangeDetector } from './hubspot-deal-stage-change' +export { hubspotNewDealDetector } from './hubspot-new-deal' +export { hubspotLifecycleChangeDetector } from './hubspot-lifecycle-change' +export { hubspotMeetingBookedDetector } from './hubspot-meeting-booked' + // Churn risk signals export { usageDropDetector } from './usage-drop' export { lowNPSDetector } from './low-nps' @@ -27,6 +33,9 @@ export { arrDecreaseDetector } from './arr-decrease' export { incompleteOnboardingDetector } from './incomplete-onboarding' export { futureCancellationDetector } from './future-cancellation' +// HubSpot churn risk signals +export { hubspotTicketCreatedDetector } from './hubspot-ticket-created' + import type { SignalDetectorDefinition } from '../types' import { usageSpikeDetector } from './usage-spike' @@ -41,6 +50,10 @@ import { freeDecisionMakerDetector } from './free-decision-maker' import { upgradePageVisitDetector } from './upgrade-page-visit' import { approachingSeatLimitDetector } from './approaching-seat-limit' import { overageDetector } from './overage' +import { hubspotDealStageChangeDetector } from './hubspot-deal-stage-change' +import { hubspotNewDealDetector } from './hubspot-new-deal' +import { hubspotLifecycleChangeDetector } from './hubspot-lifecycle-change' +import { hubspotMeetingBookedDetector } from './hubspot-meeting-booked' import { usageDropDetector } from './usage-drop' import { lowNPSDetector } from './low-nps' import { inactivityDetector } from './inactivity' @@ -49,6 +62,7 @@ import { healthScoreDecreaseDetector } from './health-score-decrease' import { arrDecreaseDetector } from './arr-decrease' import { incompleteOnboardingDetector } from './incomplete-onboarding' import { futureCancellationDetector } from './future-cancellation' +import { hubspotTicketCreatedDetector } from './hubspot-ticket-created' /** * All expansion signal detectors @@ -66,6 +80,11 @@ export const expansionDetectors: SignalDetectorDefinition[] = [ upgradePageVisitDetector, approachingSeatLimitDetector, overageDetector, + // HubSpot expansion signals + hubspotDealStageChangeDetector, + hubspotNewDealDetector, + hubspotLifecycleChangeDetector, + hubspotMeetingBookedDetector, ] /** @@ -80,10 +99,12 @@ export const churnRiskDetectors: SignalDetectorDefinition[] = [ arrDecreaseDetector, incompleteOnboardingDetector, futureCancellationDetector, + // HubSpot churn risk signals + hubspotTicketCreatedDetector, ] /** - * All signal detectors (20 total) + * All signal detectors (25 total) */ export const allDetectors: SignalDetectorDefinition[] = [ ...expansionDetectors, diff --git a/src/lib/heuristics/signals/helpers.ts b/src/lib/heuristics/signals/helpers.ts index 23ac5449..baae6f89 100644 --- a/src/lib/heuristics/signals/helpers.ts +++ b/src/lib/heuristics/signals/helpers.ts @@ -202,3 +202,61 @@ export function daysBetween(date1: Date | string, date2: Date | string = new Dat const d2 = typeof date2 === 'string' ? new Date(date2) : date2 return Math.floor((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24)) } + +/** + * Find HubSpot record IDs that belong to a specific Beton account. + * + * Matches by finding HubSpot company records whose `domain` property matches + * the account domain, then finding associated records of the target object type + * via the `hubspot_associations` table (checking both directions). + * + * Returns an array of HubSpot record IDs for the given object type, + * or an empty array if no matching records are found. + */ +export async function getHubSpotIdsForAccount( + supabase: AnySupabaseClient, + workspaceId: string, + accountDomain: string, + objectType: string +): Promise { + // Step 1: Find HubSpot companies matching the account domain + // Uses JSONB containment (@>) which is supported by the GIN index + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: companies } = await (supabase as any) + .from('hubspot_records') + .select('hubspot_id') + .eq('workspace_id', workspaceId) + .eq('object_type', 'companies') + .contains('properties', { domain: accountDomain.toLowerCase() }) + .limit(10) + + if (!companies || companies.length === 0) return [] + const companyIds: string[] = companies.map((c: { hubspot_id: string }) => c.hubspot_id) + + // For companies, return directly + if (objectType === 'companies') return companyIds + + // Step 2: Find associated records (check both association directions in parallel) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [fwd, rev] = await Promise.all([ + (supabase as any) + .from('hubspot_associations') + .select('from_hubspot_id') + .eq('from_object_type', objectType) + .eq('to_object_type', 'companies') + .in('to_hubspot_id', companyIds) + .limit(100), + (supabase as any) + .from('hubspot_associations') + .select('to_hubspot_id') + .eq('from_object_type', 'companies') + .eq('to_object_type', objectType) + .in('from_hubspot_id', companyIds) + .limit(100), + ]) + + const ids = new Set() + for (const a of fwd.data || []) ids.add(a.from_hubspot_id) + for (const a of rev.data || []) ids.add(a.to_hubspot_id) + return [...ids] +} diff --git a/src/lib/hooks/use-data-sources.ts b/src/lib/hooks/use-data-sources.ts new file mode 100644 index 00000000..cbfb9f61 --- /dev/null +++ b/src/lib/hooks/use-data-sources.ts @@ -0,0 +1,80 @@ +/** + * React Query hooks for data sources. + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + listDataSources, + getDataSource, + createDataSource, + updateDataSource, + deleteDataSource, + validateDataSource, +} from '@/lib/api/data-sources' +import type { + CreateDataSourceRequest, + UpdateDataSourceRequest, +} from '@/lib/integrations/postgres/types' + +const KEYS = { + all: ['data-sources'] as const, + list: ['data-sources', 'list'] as const, + detail: (id: string) => ['data-sources', 'detail', id] as const, +} + +export function useDataSources() { + return useQuery({ + queryKey: KEYS.list, + queryFn: listDataSources, + }) +} + +export function useDataSource(id: string) { + return useQuery({ + queryKey: KEYS.detail(id), + queryFn: () => getDataSource(id), + enabled: !!id, + }) +} + +export function useCreateDataSource() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (config: CreateDataSourceRequest) => createDataSource(config), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: KEYS.list }) + }, + }) +} + +export function useUpdateDataSource() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ id, patch }: { id: string; patch: UpdateDataSourceRequest }) => + updateDataSource(id, patch), + onSuccess: (_data, { id }) => { + queryClient.invalidateQueries({ queryKey: KEYS.list }) + queryClient.invalidateQueries({ queryKey: KEYS.detail(id) }) + }, + }) +} + +export function useDeleteDataSource() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => deleteDataSource(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: KEYS.list }) + }, + }) +} + +export function useValidateDataSource() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => validateDataSource(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: KEYS.list }) + }, + }) +} diff --git a/src/lib/integrations/hubspot/associations.test.ts b/src/lib/integrations/hubspot/associations.test.ts new file mode 100644 index 00000000..1a05b79a --- /dev/null +++ b/src/lib/integrations/hubspot/associations.test.ts @@ -0,0 +1,154 @@ +/// +/** + * Tests for HubSpot Association Management + * + * HS-U23: createAssociation creates single association with correct type ID + * HS-U24: createAssociation auto-resolves association type ID + * HS-U25: createAssociation throws on unknown type pair + * HS-U26: batchCreateAssociations groups by type pair + * HS-U27: batchCreateAssociations chunks at 2000 + * HS-U28: batchCreateAssociations handles empty array + */ + +import { + createAssociation, + batchCreateAssociations, + ASSOCIATION_TYPE_IDS, +} from './associations' + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('@/lib/utils/logger', () => ({ + createModuleLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +// --------------------------------------------------------------------------- +// Mock Client +// --------------------------------------------------------------------------- + +function createMockClient() { + return { + createAssociationV4: vi.fn().mockResolvedValue(undefined), + batchCreateAssociationsV4: vi.fn().mockResolvedValue(undefined), + } as any +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('HubSpot Association Management', () => { + describe('ASSOCIATION_TYPE_IDS', () => { + it('has correct type IDs', () => { + expect(ASSOCIATION_TYPE_IDS['contacts_to_companies']).toBe(1) + expect(ASSOCIATION_TYPE_IDS['companies_to_contacts']).toBe(2) + expect(ASSOCIATION_TYPE_IDS['deals_to_contacts']).toBe(3) + expect(ASSOCIATION_TYPE_IDS['contacts_to_deals']).toBe(4) + expect(ASSOCIATION_TYPE_IDS['deals_to_companies']).toBe(5) + expect(ASSOCIATION_TYPE_IDS['companies_to_deals']).toBe(342) + }) + }) + + describe('createAssociation', () => { + it('HS-U23: creates single association with explicit type ID', async () => { + const client = createMockClient() + + await createAssociation(client, 'contacts', '100', 'companies', '200', 1) + + expect(client.createAssociationV4).toHaveBeenCalledWith( + 'contacts', + '100', + 'companies', + '200', + 1 + ) + }) + + it('HS-U24: auto-resolves association type ID', async () => { + const client = createMockClient() + + await createAssociation(client, 'contacts', '100', 'companies', '200') + + expect(client.createAssociationV4).toHaveBeenCalledWith( + 'contacts', + '100', + 'companies', + '200', + 1 // contacts_to_companies = 1 + ) + }) + + it('resolves deals_to_companies type ID', async () => { + const client = createMockClient() + + await createAssociation(client, 'deals', '100', 'companies', '200') + + expect(client.createAssociationV4).toHaveBeenCalledWith( + 'deals', + '100', + 'companies', + '200', + 5 + ) + }) + + it('HS-U25: throws on unknown type pair', async () => { + const client = createMockClient() + + await expect( + createAssociation(client, 'unknown_a', '100', 'unknown_b', '200') + ).rejects.toThrow('Unknown association type') + }) + }) + + describe('batchCreateAssociations', () => { + it('HS-U26: groups by type pair and calls batch API', async () => { + const client = createMockClient() + + const associations = [ + { fromType: 'contacts', fromId: '1', toType: 'companies', toId: '10' }, + { fromType: 'contacts', fromId: '2', toType: 'companies', toId: '20' }, + { fromType: 'deals', fromId: '3', toType: 'contacts', toId: '1' }, + ] + + const result = await batchCreateAssociations(client, associations) + + // Should make 2 batch calls (one for contacts->companies, one for deals->contacts) + expect(client.batchCreateAssociationsV4).toHaveBeenCalledTimes(2) + expect(result.succeeded).toBe(3) + expect(result.failed).toBe(0) + }) + + it('HS-U28: handles empty array', async () => { + const client = createMockClient() + + const result = await batchCreateAssociations(client, []) + + expect(result.succeeded).toBe(0) + expect(result.failed).toBe(0) + expect(client.batchCreateAssociationsV4).not.toHaveBeenCalled() + }) + + it('counts failures on batch error', async () => { + const client = createMockClient() + client.batchCreateAssociationsV4.mockRejectedValue(new Error('Batch failed')) + + const associations = [ + { fromType: 'contacts', fromId: '1', toType: 'companies', toId: '10' }, + { fromType: 'contacts', fromId: '2', toType: 'companies', toId: '20' }, + ] + + const result = await batchCreateAssociations(client, associations) + + expect(result.succeeded).toBe(0) + expect(result.failed).toBe(2) + }) + }) +}) diff --git a/src/lib/integrations/hubspot/associations.ts b/src/lib/integrations/hubspot/associations.ts new file mode 100644 index 00000000..a6fc3dbe --- /dev/null +++ b/src/lib/integrations/hubspot/associations.ts @@ -0,0 +1,175 @@ +/** + * HubSpot Association Management + * + * Handles creating and managing associations between HubSpot CRM objects. + * Uses HubSpot v4 association endpoints. + * + * Association type IDs (HubSpot-defined defaults): + * - contact -> company: 1 + * - company -> contact: 2 + * - deal -> contact: 3 + * - contact -> deal: 4 + * - deal -> company: 5 + * - company -> deal: 342 + */ + +import type { HubSpotClient } from './client' +import { createModuleLogger } from '@/lib/utils/logger' + +const log = createModuleLogger('[HubSpot Associations]') + +// ============================================ +// Association Type IDs +// ============================================ + +/** + * Default HubSpot association type IDs. + * These are the standard association types defined by HubSpot. + */ +export const ASSOCIATION_TYPE_IDS: Record = { + 'contacts_to_companies': 1, + 'companies_to_contacts': 2, + 'deals_to_contacts': 3, + 'contacts_to_deals': 4, + 'deals_to_companies': 5, + 'companies_to_deals': 342, +} + +/** + * Get the association type ID for a given from/to object type pair. + */ +function getAssociationTypeId(fromType: string, toType: string): number | null { + const key = `${fromType}_to_${toType}` + return ASSOCIATION_TYPE_IDS[key] ?? null +} + +// ============================================ +// Types +// ============================================ + +export interface AssociationInput { + fromType: string + fromId: string + toType: string + toId: string + associationTypeId?: number +} + +// ============================================ +// Single Association +// ============================================ + +/** + * Create a single association between two CRM records. + * + * @param client - HubSpot API client + * @param fromType - Source object type (e.g., 'contacts') + * @param fromId - Source record ID + * @param toType - Target object type (e.g., 'companies') + * @param toId - Target record ID + * @param associationTypeId - Optional explicit type ID (auto-resolved if omitted) + */ +export async function createAssociation( + client: HubSpotClient, + fromType: string, + fromId: string, + toType: string, + toId: string, + associationTypeId?: number +): Promise { + const typeId = associationTypeId ?? getAssociationTypeId(fromType, toType) + + if (typeId === null) { + throw new Error( + `Unknown association type: ${fromType} -> ${toType}. ` + + `Provide an explicit associationTypeId.` + ) + } + + await client.createAssociationV4(fromType, fromId, toType, toId, typeId) + log.info(`Created association: ${fromType}/${fromId} -> ${toType}/${toId} (type ${typeId})`) +} + +// ============================================ +// Batch Associations +// ============================================ + +/** Maximum associations per batch request (HubSpot limit) */ +const MAX_BATCH_SIZE = 2000 + +/** + * Batch create associations between CRM records. + * Automatically chunks into batches of 2000 (HubSpot limit). + * + * @param client - HubSpot API client + * @param associations - Array of association inputs + * @returns Count of successful and failed associations + */ +export async function batchCreateAssociations( + client: HubSpotClient, + associations: AssociationInput[] +): Promise<{ succeeded: number; failed: number }> { + if (associations.length === 0) { + return { succeeded: 0, failed: 0 } + } + + // Group by from/to type pairs (batch API requires same types per call) + const grouped = new Map() + for (const assoc of associations) { + const key = `${assoc.fromType}::${assoc.toType}` + const group = grouped.get(key) || [] + group.push(assoc) + grouped.set(key, group) + } + + let succeeded = 0 + let failed = 0 + + for (const [key, group] of grouped) { + const [fromType, toType] = key.split('::') + + // Process in chunks of MAX_BATCH_SIZE + for (let i = 0; i < group.length; i += MAX_BATCH_SIZE) { + const chunk = group.slice(i, i + MAX_BATCH_SIZE) + + const inputs = chunk.map((assoc) => { + const typeId = + assoc.associationTypeId ?? getAssociationTypeId(fromType, toType) + + if (typeId === null) { + throw new Error( + `Unknown association type: ${fromType} -> ${toType}. ` + + `Provide an explicit associationTypeId.` + ) + } + + return { + from: { id: assoc.fromId }, + to: { id: assoc.toId }, + types: [ + { + associationCategory: 'HUBSPOT_DEFINED', + associationTypeId: typeId, + }, + ], + } + }) + + try { + await client.batchCreateAssociationsV4(fromType, toType, inputs) + succeeded += chunk.length + log.info( + `Batch created ${chunk.length} associations: ${fromType} -> ${toType}` + ) + } catch (err) { + failed += chunk.length + log.error( + `Batch association creation failed for ${fromType} -> ${toType}:`, + err + ) + } + } + } + + return { succeeded, failed } +} diff --git a/src/lib/integrations/hubspot/auth.test.ts b/src/lib/integrations/hubspot/auth.test.ts new file mode 100644 index 00000000..8d274044 --- /dev/null +++ b/src/lib/integrations/hubspot/auth.test.ts @@ -0,0 +1,247 @@ +/// +/** + * Tests for HubSpot auth module + * + * HS-U21: getAuthorizeUrl builds correct URL with params + * HS-U22: exchangeCodeForTokens sends correct POST + * HS-U23: refreshAccessToken handles refresh flow + * HS-U24: validatePrivateAppToken validates format + * HS-U25: isTokenExpired checks expiry with buffer + */ + +import { + getAuthorizeUrl, + exchangeCodeForTokens, + refreshAccessToken, + validatePrivateAppToken, + isTokenExpired, +} from './auth' + +// --------------------------------------------------------------------------- +// HS-U21: getAuthorizeUrl +// --------------------------------------------------------------------------- + +describe('getAuthorizeUrl', () => { + it('builds correct OAuth URL with all params', () => { + const url = getAuthorizeUrl( + 'client-123', + 'https://example.com/callback', + ['crm.objects.contacts.read', 'crm.objects.companies.read'], + 'test-state-token' + ) + + expect(url).toContain('https://app.hubspot.com/oauth/authorize') + expect(url).toContain('client_id=client-123') + expect(url).toContain('redirect_uri=https%3A%2F%2Fexample.com%2Fcallback') + expect(url).toContain('scope=crm.objects.contacts.read+crm.objects.companies.read') + expect(url).toContain('state=test-state-token') + expect(url).toContain('response_type=code') + }) + + it('handles empty scopes array', () => { + const url = getAuthorizeUrl('client-123', 'https://example.com/cb', [], 'state') + expect(url).toContain('scope=') + }) + + it('encodes special characters in params', () => { + const url = getAuthorizeUrl( + 'client-123', + 'https://example.com/cb?foo=bar', + ['scope.one'], + 'state:with:colons' + ) + expect(url).toContain('redirect_uri=') + expect(url).toContain('state=') + }) +}) + +// --------------------------------------------------------------------------- +// HS-U22: exchangeCodeForTokens +// --------------------------------------------------------------------------- + +describe('exchangeCodeForTokens', () => { + const originalFetch = global.fetch + + afterEach(() => { + global.fetch = originalFetch + }) + + it('sends correct POST request and returns tokens', async () => { + const mockTokens = { + access_token: 'access-123', + refresh_token: 'refresh-456', + expires_in: 1800, + token_type: 'bearer', + } + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockTokens), + }) + + const result = await exchangeCodeForTokens( + 'auth-code', + 'client-id', + 'client-secret', + 'https://example.com/callback' + ) + + expect(result).toEqual(mockTokens) + + const fetchCall = (global.fetch as ReturnType).mock.calls[0] + expect(fetchCall[0]).toBe('https://api.hubapi.com/oauth/v1/token') + expect(fetchCall[1].method).toBe('POST') + expect(fetchCall[1].headers['Content-Type']).toBe('application/x-www-form-urlencoded') + + const body = fetchCall[1].body + expect(body).toContain('grant_type=authorization_code') + expect(body).toContain('code=auth-code') + expect(body).toContain('client_id=client-id') + expect(body).toContain('client_secret=client-secret') + }) + + it('throws on non-OK response', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + text: () => Promise.resolve('Invalid grant'), + }) + + await expect( + exchangeCodeForTokens('bad-code', 'client-id', 'secret', 'https://example.com/cb') + ).rejects.toThrow('HubSpot token exchange failed (400)') + }) +}) + +// --------------------------------------------------------------------------- +// HS-U23: refreshAccessToken +// --------------------------------------------------------------------------- + +describe('refreshAccessToken', () => { + const originalFetch = global.fetch + + afterEach(() => { + global.fetch = originalFetch + }) + + it('sends correct refresh request', async () => { + const mockTokens = { + access_token: 'new-access', + refresh_token: 'new-refresh', + expires_in: 1800, + token_type: 'bearer', + } + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockTokens), + }) + + const result = await refreshAccessToken('old-refresh', 'client-id', 'client-secret') + + expect(result).toEqual(mockTokens) + + const body = (global.fetch as ReturnType).mock.calls[0][1].body + expect(body).toContain('grant_type=refresh_token') + expect(body).toContain('refresh_token=old-refresh') + }) + + it('throws on failed refresh', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + text: () => Promise.resolve('Invalid refresh token'), + }) + + await expect( + refreshAccessToken('bad-token', 'client-id', 'secret') + ).rejects.toThrow('HubSpot token refresh failed (401)') + }) +}) + +// --------------------------------------------------------------------------- +// HS-U24: validatePrivateAppToken +// --------------------------------------------------------------------------- + +describe('validatePrivateAppToken', () => { + it('accepts valid pat- token', () => { + const result = validatePrivateAppToken('pat-na1-abcdef1234567890abcd') + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + }) + + it('rejects empty/null token', () => { + expect(validatePrivateAppToken('')).toEqual({ + valid: false, + error: 'Token is required', + }) + expect(validatePrivateAppToken(null as unknown as string)).toEqual({ + valid: false, + error: 'Token is required', + }) + expect(validatePrivateAppToken(undefined as unknown as string)).toEqual({ + valid: false, + error: 'Token is required', + }) + }) + + it('rejects token without pat- prefix', () => { + const result = validatePrivateAppToken('sk-abcdef1234567890abcd') + expect(result.valid).toBe(false) + expect(result.error).toContain('pat-') + }) + + it('rejects token that is too short', () => { + const result = validatePrivateAppToken('pat-short') + expect(result.valid).toBe(false) + expect(result.error).toContain('too short') + }) + + it('trims whitespace before validation', () => { + const result = validatePrivateAppToken(' pat-na1-abcdef1234567890abcd ') + // After trim, starts with ' ' so won't match pat- prefix + // Actually, ' pat-...' trimmed becomes 'pat-...' + expect(result.valid).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// HS-U25: isTokenExpired +// --------------------------------------------------------------------------- + +describe('isTokenExpired', () => { + it('returns true for null/undefined expiry', () => { + expect(isTokenExpired(null)).toBe(true) + expect(isTokenExpired(undefined)).toBe(true) + }) + + it('returns true for expired token', () => { + const pastDate = new Date(Date.now() - 60_000).toISOString() + expect(isTokenExpired(pastDate)).toBe(true) + }) + + it('returns true for token expiring within buffer', () => { + // Token expires in 3 minutes, but buffer is 5 minutes + const soonDate = new Date(Date.now() + 3 * 60_000).toISOString() + expect(isTokenExpired(soonDate, 5)).toBe(true) + }) + + it('returns false for token with plenty of time left', () => { + const futureDate = new Date(Date.now() + 30 * 60_000).toISOString() + expect(isTokenExpired(futureDate, 5)).toBe(false) + }) + + it('respects custom buffer minutes', () => { + // Token expires in 2 minutes + const soonDate = new Date(Date.now() + 2 * 60_000).toISOString() + // 1 minute buffer → not expired + expect(isTokenExpired(soonDate, 1)).toBe(false) + // 3 minute buffer → expired + expect(isTokenExpired(soonDate, 3)).toBe(true) + }) + + it('accepts Date object', () => { + const futureDate = new Date(Date.now() + 60 * 60_000) + expect(isTokenExpired(futureDate)).toBe(false) + }) +}) diff --git a/src/lib/integrations/hubspot/auth.ts b/src/lib/integrations/hubspot/auth.ts new file mode 100644 index 00000000..aea6e77b --- /dev/null +++ b/src/lib/integrations/hubspot/auth.ts @@ -0,0 +1,192 @@ +/** + * HubSpot Authentication Module + * + * Handles OAuth 2.0 flow and Private App Token validation for HubSpot CRM. + * + * OAuth flow: + * 1. getAuthorizeUrl() → redirect user to HubSpot + * 2. exchangeCodeForTokens() → exchange auth code for tokens + * 3. refreshAccessToken() → refresh expired access tokens + * + * Private App: + * 1. validatePrivateAppToken() → format validation (starts with 'pat-') + */ + +import type { HubSpotOAuthTokens } from './types' + +const HUBSPOT_OAUTH_BASE = 'https://app.hubspot.com/oauth/authorize' +const HUBSPOT_TOKEN_URL = 'https://api.hubapi.com/oauth/v1/token' + +// ============================================ +// OAuth Flow +// ============================================ + +/** + * Build the HubSpot OAuth authorization URL. + * + * @param clientId - HubSpot app client ID + * @param redirectUri - Callback URL after authorization + * @param scopes - Requested OAuth scopes + * @param state - CSRF state parameter + * @returns Full authorization URL + */ +export function getAuthorizeUrl( + clientId: string, + redirectUri: string, + scopes: string[], + state: string +): string { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + scope: scopes.join(' '), + state, + response_type: 'code', + }) + + return `${HUBSPOT_OAUTH_BASE}?${params.toString()}` +} + +/** + * Exchange an authorization code for access and refresh tokens. + * + * @param code - Authorization code from HubSpot callback + * @param clientId - HubSpot app client ID + * @param clientSecret - HubSpot app client secret + * @param redirectUri - Must match the redirect_uri used in the authorize request + * @returns OAuth tokens (access_token, refresh_token, expires_in) + * @throws Error if token exchange fails + */ +export async function exchangeCodeForTokens( + code: string, + clientId: string, + clientSecret: string, + redirectUri: string +): Promise { + const response = await fetch(HUBSPOT_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + code, + }).toString(), + }) + + if (!response.ok) { + const errorBody = await response.text().catch(() => 'Unknown error') + throw new Error(`HubSpot token exchange failed (${response.status}): ${errorBody}`) + } + + return response.json() +} + +/** + * Refresh an expired access token using a refresh token. + * + * @param refreshToken - The refresh token from initial authorization + * @param clientId - HubSpot app client ID + * @param clientSecret - HubSpot app client secret + * @returns New OAuth tokens (access_token, refresh_token, expires_in) + * @throws Error if refresh fails + */ +export async function refreshAccessToken( + refreshToken: string, + clientId: string, + clientSecret: string +): Promise { + const response = await fetch(HUBSPOT_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + }).toString(), + }) + + if (!response.ok) { + const errorBody = await response.text().catch(() => 'Unknown error') + throw new Error(`HubSpot token refresh failed (${response.status}): ${errorBody}`) + } + + return response.json() +} + +// ============================================ +// Private App Token Validation +// ============================================ + +/** + * Validate a HubSpot Private App token format. + * + * Private App tokens: + * - Must start with 'pat-' prefix + * - Must be at least 20 characters long + * + * @param token - The Private App token to validate + * @returns Object with valid flag and optional error message + */ +export function validatePrivateAppToken(token: string): { + valid: boolean + error?: string +} { + if (!token || typeof token !== 'string') { + return { valid: false, error: 'Token is required' } + } + + const trimmed = token.trim() + + if (trimmed.length === 0) { + return { valid: false, error: 'Token cannot be empty' } + } + + if (!trimmed.startsWith('pat-')) { + return { + valid: false, + error: 'HubSpot Private App token must start with "pat-"', + } + } + + if (trimmed.length < 20) { + return { + valid: false, + error: 'HubSpot Private App token appears too short', + } + } + + return { valid: true } +} + +// ============================================ +// Token Expiry Check +// ============================================ + +/** + * Check if an OAuth access token has expired or will expire soon. + * + * @param expiresAt - Token expiry timestamp (ISO 8601 or Date) + * @param bufferMinutes - Minutes of buffer before actual expiry (default: 5) + * @returns true if the token is expired or will expire within the buffer period + */ +export function isTokenExpired( + expiresAt: string | Date | null | undefined, + bufferMinutes: number = 5 +): boolean { + if (!expiresAt) { + return true + } + + const expiryTime = new Date(expiresAt).getTime() + const now = Date.now() + const bufferMs = bufferMinutes * 60 * 1000 + + return now >= expiryTime - bufferMs +} diff --git a/src/lib/integrations/hubspot/client.test.ts b/src/lib/integrations/hubspot/client.test.ts new file mode 100644 index 00000000..bfa33d2c --- /dev/null +++ b/src/lib/integrations/hubspot/client.test.ts @@ -0,0 +1,357 @@ +/// +/** + * Tests for HubSpot API Client + * + * HS-U01: Constructor validates config + * HS-U02: testConnection success/failure + * HS-U03: getAccountInfo returns account data + * HS-U04: getContacts fetches with properties and pagination + * HS-U05: getCompanies fetches with properties + * HS-U06: getDeals fetches with properties + * HS-U07: getRecord fetches single record + * HS-U08: search builds correct request + * HS-U09: Retry logic on 429 responses + * HS-U10: Auth errors throw HubSpotAuthError + * HS-U11: Not found errors throw HubSpotNotFoundError + * HS-U12: Server errors retry then throw + */ + +import { HubSpotClient } from './client' +import { + HubSpotError, + HubSpotAuthError, + HubSpotNotFoundError, + HubSpotRateLimitError, +} from './types' +import { resetAll } from './rate-limiter' + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('@/lib/utils/logger', () => ({ + createModuleLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +const originalFetch = global.fetch + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createClient(overrides: Record = {}) { + return new HubSpotClient({ + token: 'pat-test-token-12345678', + authType: 'private_app', + connectionId: 'test-conn-1', + ...overrides, + }) +} + +function mockFetchSuccess(data: unknown) { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(data), + headers: new Headers({ + 'x-hubspot-ratelimit-daily-remaining': '1000', + 'x-hubspot-ratelimit-daily': '250000', + }), + }) +} + +function mockFetchError(status: number, body: string = '') { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status, + statusText: `Error ${status}`, + text: () => Promise.resolve(body), + headers: new Headers({ + ...(status === 429 ? { 'retry-after': '1' } : {}), + }), + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('HubSpotClient', () => { + beforeEach(() => { + vi.clearAllMocks() + resetAll() + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + // HS-U01: Constructor validates config + describe('constructor', () => { + it('throws when token is missing', () => { + expect(() => new HubSpotClient({ + token: '', + authType: 'private_app', + connectionId: 'conn-1', + })).toThrow('token is required') + }) + + it('creates client with valid config', () => { + const client = createClient() + expect(client).toBeInstanceOf(HubSpotClient) + }) + }) + + // HS-U02: testConnection + describe('testConnection', () => { + it('returns success when API responds OK', async () => { + mockFetchSuccess({ portalId: 12345 }) + const client = createClient() + const result = await client.testConnection() + expect(result.success).toBe(true) + }) + + it('returns error when API fails', async () => { + mockFetchError(401, '{"message": "Unauthorized"}') + const client = createClient() + const result = await client.testConnection() + expect(result.success).toBe(false) + expect(result.error).toBeDefined() + }) + }) + + // HS-U03: getAccountInfo + describe('getAccountInfo', () => { + it('returns account info', async () => { + const accountData = { + portalId: 12345, + accountType: 'STANDARD', + timeZone: 'US/Eastern', + companyCurrency: 'USD', + } + mockFetchSuccess(accountData) + + const client = createClient() + const result = await client.getAccountInfo() + expect(result.portalId).toBe(12345) + }) + }) + + // HS-U04: getContacts + describe('getContacts', () => { + it('fetches contacts with properties', async () => { + mockFetchSuccess({ + results: [ + { id: '1', properties: { email: 'test@example.com' }, createdAt: '', updatedAt: '', archived: false }, + ], + paging: { next: { after: '2' } }, + }) + + const client = createClient() + const result = await client.getContacts({ + properties: ['email', 'firstname'], + limit: 50, + }) + + expect(result.results).toHaveLength(1) + expect(result.paging?.next?.after).toBe('2') + + // Verify URL includes properties + const fetchCall = (global.fetch as ReturnType).mock.calls[0] + expect(fetchCall[0]).toContain('properties=email%2Cfirstname') + expect(fetchCall[0]).toContain('limit=50') + }) + + it('handles pagination with after cursor', async () => { + mockFetchSuccess({ results: [], paging: undefined }) + + const client = createClient() + await client.getContacts({ after: 'cursor-123' }) + + const fetchCall = (global.fetch as ReturnType).mock.calls[0] + expect(fetchCall[0]).toContain('after=cursor-123') + }) + }) + + // HS-U05: getCompanies + describe('getCompanies', () => { + it('fetches companies', async () => { + mockFetchSuccess({ + results: [ + { id: '100', properties: { name: 'Acme Corp' }, createdAt: '', updatedAt: '', archived: false }, + ], + }) + + const client = createClient() + const result = await client.getCompanies({ properties: ['name', 'domain'] }) + expect(result.results).toHaveLength(1) + }) + }) + + // HS-U06: getDeals + describe('getDeals', () => { + it('fetches deals', async () => { + mockFetchSuccess({ + results: [ + { id: '200', properties: { dealname: 'Big Deal' }, createdAt: '', updatedAt: '', archived: false }, + ], + }) + + const client = createClient() + const result = await client.getDeals() + expect(result.results).toHaveLength(1) + }) + }) + + // HS-U07: getRecord + describe('getRecord', () => { + it('fetches a single record by ID', async () => { + mockFetchSuccess({ + id: '42', + properties: { email: 'john@test.com' }, + createdAt: '2024-01-01', + updatedAt: '2024-06-01', + archived: false, + }) + + const client = createClient() + const result = await client.getRecord('contacts', '42', ['email']) + expect(result.id).toBe('42') + expect(result.properties.email).toBe('john@test.com') + }) + }) + + // HS-U08: search + describe('search', () => { + it('sends correct search request', async () => { + mockFetchSuccess({ + total: 1, + results: [{ id: '1', properties: {}, createdAt: '', updatedAt: '', archived: false }], + }) + + const client = createClient() + await client.search('contacts', { + query: 'test', + limit: 5, + properties: ['email'], + }) + + const fetchCall = (global.fetch as ReturnType).mock.calls[0] + expect(fetchCall[0]).toContain('/crm/v3/objects/contacts/search') + expect(fetchCall[1].method).toBe('POST') + + const body = JSON.parse(fetchCall[1].body) + expect(body.query).toBe('test') + expect(body.limit).toBe(5) + }) + + it('sends filter groups correctly', async () => { + mockFetchSuccess({ total: 0, results: [] }) + + const client = createClient() + await client.search('contacts', { + filterGroups: [ + { + filters: [ + { propertyName: 'email', operator: 'CONTAINS_TOKEN', value: '@test.com' }, + ], + }, + ], + }) + + const body = JSON.parse( + (global.fetch as ReturnType).mock.calls[0][1].body + ) + expect(body.filterGroups[0].filters[0].propertyName).toBe('email') + }) + }) + + // HS-U09: Retry logic on 429 + describe('retry logic', () => { + it('retries on 429 responses', async () => { + let callCount = 0 + global.fetch = vi.fn().mockImplementation(async () => { + callCount++ + if (callCount <= 2) { + return { + ok: false, + status: 429, + statusText: 'Too Many Requests', + text: () => Promise.resolve('Rate limited'), + headers: new Headers({ 'retry-after': '0' }), + } + } + return { + ok: true, + status: 200, + json: () => Promise.resolve({ portalId: 12345 }), + headers: new Headers({ + 'x-hubspot-ratelimit-daily-remaining': '1000', + 'x-hubspot-ratelimit-daily': '250000', + }), + } + }) + + const client = createClient() + const result = await client.getAccountInfo() + expect(result.portalId).toBe(12345) + expect(callCount).toBe(3) // 2 retries + 1 success + }, 30000) + + it('throws after max retries on persistent 429', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + text: () => Promise.resolve('Rate limited'), + headers: new Headers({ 'retry-after': '0' }), + }) + + const client = createClient() + await expect(client.getAccountInfo()).rejects.toThrow(HubSpotRateLimitError) + }, 30000) + }) + + // HS-U10: Auth errors + describe('auth errors', () => { + it('throws HubSpotAuthError on 401', async () => { + mockFetchError(401, '{"message": "Invalid token"}') + const client = createClient() + await expect(client.getAccountInfo()).rejects.toThrow(HubSpotAuthError) + }) + + it('throws HubSpotAuthError on 403', async () => { + mockFetchError(403, '{"message": "Forbidden"}') + const client = createClient() + await expect(client.getAccountInfo()).rejects.toThrow(HubSpotAuthError) + }) + }) + + // HS-U11: Not found errors + describe('not found errors', () => { + it('throws HubSpotNotFoundError on 404', async () => { + mockFetchError(404, '{"message": "Not found"}') + const client = createClient() + await expect(client.getRecord('contacts', '999')).rejects.toThrow( + HubSpotNotFoundError + ) + }) + }) + + // HS-U12: Server errors retry then throw + describe('server errors', () => { + it('retries on 500 then throws', async () => { + mockFetchError(500, 'Internal Server Error') + const client = createClient() + await expect(client.getAccountInfo()).rejects.toThrow(HubSpotError) + + // Should have attempted MAX_RETRIES + 1 times + expect((global.fetch as ReturnType).mock.calls.length).toBeGreaterThan(1) + }, 30000) + }) +}) diff --git a/src/lib/integrations/hubspot/client.ts b/src/lib/integrations/hubspot/client.ts new file mode 100644 index 00000000..b2950e16 --- /dev/null +++ b/src/lib/integrations/hubspot/client.ts @@ -0,0 +1,788 @@ +/** + * HubSpot CRM API Client + * + * Provides a typed interface to the HubSpot CRM API with: + * - OAuth and Private App Token authentication + * - Automatic token refresh for OAuth connections + * - Rate limiting via token bucket (rate-limiter.ts) + * - Retry logic with exponential backoff for 429 responses + * - Methods for all standard CRM objects (contacts, companies, deals, tickets) + * + * HubSpot API base: https://api.hubapi.com + */ + +import { + HubSpotError, + HubSpotAuthError, + HubSpotRateLimitError, + HubSpotNotFoundError, + HubSpotValidationError, + type HubSpotRecord, + type HubSpotListResponse, + type HubSpotSearchOptions, + type HubSpotSearchResponse, + type HubSpotAccountInfo, + type HubSpotOwner, + type HubSpotPropertyDefinition, + type HubSpotObjectSchema, + type HubSpotBatchAssociationResponse, + type HubSpotAuthType, + type RateLimitPriority, +} from './types' +import { HUBSPOT_API_BASE, RATE_LIMITS } from './config' +import { + acquire, + handleRateLimitResponse, + parseRateLimitHeaders, + updateFromHeaders, +} from './rate-limiter' +import { createModuleLogger } from '@/lib/utils/logger' + +const log = createModuleLogger('[HubSpot Client]') + +// ============================================ +// Client Configuration +// ============================================ + +export interface HubSpotClientConfig { + /** OAuth access token or Private App token */ + token: string + /** Auth type determines rate limit capacity */ + authType: HubSpotAuthType + /** Connection ID for rate limiting */ + connectionId: string + /** OAuth refresh token (only for OAuth connections) */ + refreshToken?: string + /** Callback to refresh OAuth tokens */ + onTokenRefresh?: (newToken: string, newRefreshToken: string, expiresAt: string) => Promise + /** Token expiry timestamp (ISO 8601) */ + tokenExpiresAt?: string | null +} + +// ============================================ +// HubSpot Client Class +// ============================================ + +export class HubSpotClient { + private token: string + private authType: HubSpotAuthType + private connectionId: string + private refreshToken?: string + private onTokenRefresh?: HubSpotClientConfig['onTokenRefresh'] + private tokenExpiresAt?: string | null + + constructor(config: HubSpotClientConfig) { + if (!config.token) { + throw new HubSpotError('HubSpot token is required') + } + + this.token = config.token + this.authType = config.authType + this.connectionId = config.connectionId + this.refreshToken = config.refreshToken + this.onTokenRefresh = config.onTokenRefresh + this.tokenExpiresAt = config.tokenExpiresAt + } + + // ============================================ + // Core HTTP Methods + // ============================================ + + /** + * Make an authenticated request to the HubSpot API with rate limiting and retries. + */ + private async request( + endpoint: string, + options: RequestInit = {}, + priority: RateLimitPriority = 'normal' + ): Promise { + // Auto-refresh OAuth tokens if needed + await this.ensureValidToken() + + // Acquire rate limit token + await acquire(this.connectionId, priority, this.authType) + + const url = `${HUBSPOT_API_BASE}${endpoint}` + + let lastError: Error | null = null + + for (let attempt = 0; attempt <= RATE_LIMITS.MAX_RETRIES; attempt++) { + try { + const response = await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${this.token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + ...options.headers, + }, + }) + + // Update rate limit state from response headers + const rateLimitHeaders = parseRateLimitHeaders(response) + updateFromHeaders(this.connectionId, rateLimitHeaders) + + if (response.ok) { + // Some endpoints return 204 with no body + if (response.status === 204) { + return undefined as T + } + return response.json() + } + + // Handle specific error codes + const errorBody = await response.text().catch(() => '') + let parsedError: { message?: string; category?: string } = {} + try { + parsedError = JSON.parse(errorBody) + } catch { + // Ignore parse errors + } + + const errorMessage = parsedError.message || response.statusText + + switch (response.status) { + case 401: + throw new HubSpotAuthError(`Authentication failed: ${errorMessage}`) + case 403: + throw new HubSpotAuthError(`Access forbidden: ${errorMessage}`) + case 404: + throw new HubSpotNotFoundError(`Resource not found: ${errorMessage}`) + case 422: + throw new HubSpotValidationError(`Validation failed: ${errorMessage}`) + case 429: { + const retryAfter = parseInt( + response.headers.get('retry-after') || '10', + 10 + ) + handleRateLimitResponse(this.connectionId, retryAfter) + + if (attempt < RATE_LIMITS.MAX_RETRIES) { + const delay = + RATE_LIMITS.BASE_RETRY_DELAY_MS * Math.pow(2, attempt) + + Math.random() * 1000 + log.warn( + `Rate limited (attempt ${attempt + 1}/${RATE_LIMITS.MAX_RETRIES}), ` + + `retrying in ${Math.round(delay)}ms` + ) + await new Promise((resolve) => setTimeout(resolve, delay)) + continue + } + + throw new HubSpotRateLimitError( + `Rate limit exceeded after ${RATE_LIMITS.MAX_RETRIES} retries`, + retryAfter + ) + } + default: + if (response.status >= 500 && attempt < RATE_LIMITS.MAX_RETRIES) { + const delay = + RATE_LIMITS.BASE_RETRY_DELAY_MS * Math.pow(2, attempt) + + Math.random() * 1000 + log.warn( + `Server error ${response.status} (attempt ${attempt + 1}), retrying in ${Math.round(delay)}ms` + ) + await new Promise((resolve) => setTimeout(resolve, delay)) + continue + } + throw new HubSpotError(`API error (${response.status}): ${errorMessage}`) + } + } catch (error) { + if ( + error instanceof HubSpotAuthError || + error instanceof HubSpotNotFoundError || + error instanceof HubSpotValidationError + ) { + throw error + } + + if (error instanceof HubSpotRateLimitError) { + throw error + } + + if (error instanceof HubSpotError) { + throw error + } + + lastError = error instanceof Error ? error : new Error(String(error)) + + if (attempt < RATE_LIMITS.MAX_RETRIES) { + const delay = RATE_LIMITS.BASE_RETRY_DELAY_MS * Math.pow(2, attempt) + log.warn(`Network error (attempt ${attempt + 1}), retrying in ${delay}ms`) + await new Promise((resolve) => setTimeout(resolve, delay)) + continue + } + } + } + + throw lastError || new HubSpotError('Request failed after all retries') + } + + /** + * Ensure the OAuth token is still valid, refreshing if needed. + */ + private async ensureValidToken(): Promise { + if (this.authType !== 'oauth' || !this.tokenExpiresAt || !this.refreshToken) { + return + } + + const { isTokenExpired } = await import('./auth') + + if (!isTokenExpired(this.tokenExpiresAt)) { + return + } + + log.info(`Refreshing expired OAuth token for connection ${this.connectionId}`) + + const clientId = process.env.HUBSPOT_CLIENT_ID + const clientSecret = process.env.HUBSPOT_CLIENT_SECRET + + if (!clientId || !clientSecret) { + throw new HubSpotAuthError('Cannot refresh token: HUBSPOT_CLIENT_ID/SECRET not configured') + } + + const { refreshAccessToken } = await import('./auth') + const tokens = await refreshAccessToken(this.refreshToken, clientId, clientSecret) + + this.token = tokens.access_token + this.refreshToken = tokens.refresh_token + this.tokenExpiresAt = new Date( + Date.now() + tokens.expires_in * 1000 + ).toISOString() + + // Notify caller to persist new tokens + if (this.onTokenRefresh) { + await this.onTokenRefresh( + tokens.access_token, + tokens.refresh_token, + this.tokenExpiresAt + ) + } + } + + // ============================================ + // Connection & Account + // ============================================ + + /** + * Test the HubSpot API connection. + */ + async testConnection(): Promise<{ success: boolean; error?: string }> { + try { + await this.getAccountInfo() + return { success: true } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + } + } + + /** + * Get HubSpot account information. + */ + async getAccountInfo(): Promise { + return this.request( + '/account-info/v3/details', + {}, + 'high' + ) + } + + // ============================================ + // CRM Objects — List + // ============================================ + + /** + * Get contacts with optional property selection and pagination. + */ + async getContacts(options: { + properties?: string[] + limit?: number + after?: string + } = {}): Promise { + const params = new URLSearchParams() + if (options.limit) params.set('limit', String(options.limit)) + if (options.after) params.set('after', options.after) + if (options.properties?.length) { + params.set('properties', options.properties.join(',')) + } + + return this.request( + `/crm/v3/objects/contacts?${params.toString()}` + ) + } + + /** + * Get companies with optional property selection and pagination. + */ + async getCompanies(options: { + properties?: string[] + limit?: number + after?: string + } = {}): Promise { + const params = new URLSearchParams() + if (options.limit) params.set('limit', String(options.limit)) + if (options.after) params.set('after', options.after) + if (options.properties?.length) { + params.set('properties', options.properties.join(',')) + } + + return this.request( + `/crm/v3/objects/companies?${params.toString()}` + ) + } + + /** + * Get deals with optional property selection and pagination. + */ + async getDeals(options: { + properties?: string[] + limit?: number + after?: string + } = {}): Promise { + const params = new URLSearchParams() + if (options.limit) params.set('limit', String(options.limit)) + if (options.after) params.set('after', options.after) + if (options.properties?.length) { + params.set('properties', options.properties.join(',')) + } + + return this.request( + `/crm/v3/objects/deals?${params.toString()}` + ) + } + + /** + * Get tickets with optional property selection and pagination. + */ + async getTickets(options: { + properties?: string[] + limit?: number + after?: string + } = {}): Promise { + const params = new URLSearchParams() + if (options.limit) params.set('limit', String(options.limit)) + if (options.after) params.set('after', options.after) + if (options.properties?.length) { + params.set('properties', options.properties.join(',')) + } + + return this.request( + `/crm/v3/objects/tickets?${params.toString()}` + ) + } + + /** + * Get custom objects by object type ID. + */ + async getCustomObjects( + objectTypeId: string, + options: { + properties?: string[] + limit?: number + after?: string + } = {} + ): Promise { + const params = new URLSearchParams() + if (options.limit) params.set('limit', String(options.limit)) + if (options.after) params.set('after', options.after) + if (options.properties?.length) { + params.set('properties', options.properties.join(',')) + } + + return this.request( + `/crm/v3/objects/${objectTypeId}?${params.toString()}` + ) + } + + /** + * Get engagements (calls, emails, meetings, notes, tasks). + */ + async getEngagements( + engagementType: string, + options: { + properties?: string[] + limit?: number + after?: string + } = {} + ): Promise { + const params = new URLSearchParams() + if (options.limit) params.set('limit', String(options.limit)) + if (options.after) params.set('after', options.after) + if (options.properties?.length) { + params.set('properties', options.properties.join(',')) + } + + return this.request( + `/crm/v3/objects/${engagementType}?${params.toString()}` + ) + } + + // ============================================ + // CRM Objects — Single Record + // ============================================ + + /** + * Get a single CRM record by ID. + */ + async getRecord( + objectType: string, + recordId: string, + properties?: string[] + ): Promise { + const params = new URLSearchParams() + if (properties?.length) { + params.set('properties', properties.join(',')) + } + + return this.request( + `/crm/v3/objects/${objectType}/${recordId}?${params.toString()}`, + {}, + 'high' + ) + } + + /** + * Batch read records by IDs. + */ + async batchRead( + objectType: string, + ids: string[], + properties?: string[] + ): Promise<{ results: HubSpotRecord[] }> { + return this.request<{ results: HubSpotRecord[] }>( + `/crm/v3/objects/${objectType}/batch/read`, + { + method: 'POST', + body: JSON.stringify({ + inputs: ids.map((id) => ({ id })), + properties: properties || [], + }), + } + ) + } + + // ============================================ + // Search + // ============================================ + + /** + * Search CRM objects with filters, sorts, and full-text query. + */ + async search( + objectType: string, + searchOptions: HubSpotSearchOptions + ): Promise { + return this.request( + `/crm/v3/objects/${objectType}/search`, + { + method: 'POST', + body: JSON.stringify({ + filterGroups: searchOptions.filterGroups || [], + sorts: searchOptions.sorts || [], + query: searchOptions.query || '', + properties: searchOptions.properties || [], + limit: searchOptions.limit || 10, + after: searchOptions.after || '0', + }), + }, + 'high' + ) + } + + // ============================================ + // Associations + // ============================================ + + /** + * Get associations between objects. + */ + async getAssociations( + fromObjectType: string, + fromObjectId: string, + toObjectType: string + ): Promise { + return this.request( + `/crm/v4/objects/${fromObjectType}/${fromObjectId}/associations/${toObjectType}` + ) + } + + // ============================================ + // Schema & Properties + // ============================================ + + /** + * Get all CRM object schemas. + */ + async getObjectSchemas(): Promise<{ results: HubSpotObjectSchema[] }> { + return this.request<{ results: HubSpotObjectSchema[] }>( + '/crm/v3/schemas' + ) + } + + /** + * Get properties for an object type. + */ + async getProperties(objectType: string): Promise<{ results: HubSpotPropertyDefinition[] }> { + return this.request<{ results: HubSpotPropertyDefinition[] }>( + `/crm/v3/properties/${objectType}` + ) + } + + /** + * Create a new property on an object type. + */ + async createProperty( + objectType: string, + property: { + name: string + label: string + type: string + fieldType: string + groupName: string + description?: string + } + ): Promise { + return this.request( + `/crm/v3/properties/${objectType}`, + { + method: 'POST', + body: JSON.stringify(property), + }, + 'high' + ) + } + + /** + * Create a property group on an object type. + */ + async createPropertyGroup( + objectType: string, + group: { name: string; label: string; displayOrder?: number } + ): Promise<{ name: string; label: string }> { + return this.request<{ name: string; label: string }>( + `/crm/v3/properties/${objectType}/groups`, + { + method: 'POST', + body: JSON.stringify(group), + }, + 'high' + ) + } + + // ============================================ + // CRM Objects — Create / Update + // ============================================ + + /** + * Create a single CRM record. + */ + async createRecord( + objectType: string, + properties: Record + ): Promise { + return this.request( + `/crm/v3/objects/${objectType}`, + { + method: 'POST', + body: JSON.stringify({ properties }), + }, + 'high' + ) + } + + /** + * Update a single CRM record. + */ + async updateRecord( + objectType: string, + recordId: string, + properties: Record + ): Promise { + return this.request( + `/crm/v3/objects/${objectType}/${recordId}`, + { + method: 'PATCH', + body: JSON.stringify({ properties }), + }, + 'high' + ) + } + + // ============================================ + // Associations — Create + // ============================================ + + /** + * Create an association between two CRM records (v4 API). + */ + async createAssociationV4( + fromObjectType: string, + fromObjectId: string, + toObjectType: string, + toObjectId: string, + associationTypeId: number + ): Promise { + await this.request( + `/crm/v4/objects/${fromObjectType}/${fromObjectId}/associations/${toObjectType}/${toObjectId}`, + { + method: 'PUT', + body: JSON.stringify([ + { + associationCategory: 'HUBSPOT_DEFINED', + associationTypeId, + }, + ]), + }, + 'high' + ) + } + + /** + * Batch create associations between CRM records (v4 API). + */ + async batchCreateAssociationsV4( + fromObjectType: string, + toObjectType: string, + inputs: Array<{ + from: { id: string } + to: { id: string } + types: Array<{ associationCategory: string; associationTypeId: number }> + }> + ): Promise { + await this.request( + `/crm/v4/associations/${fromObjectType}/${toObjectType}/batch/create`, + { + method: 'POST', + body: JSON.stringify({ inputs }), + }, + 'high' + ) + } + + // ============================================ + // Lists + // ============================================ + + /** + * Create a static contact list. + */ + async createStaticList( + name: string + ): Promise<{ listId: string; name: string }> { + const result = await this.request<{ + listId?: string + list_id?: string + id?: string + name?: string + }>( + '/crm/v3/lists', + { + method: 'POST', + body: JSON.stringify({ + name, + objectTypeId: '0-1', // contacts + processingType: 'MANUAL', + }), + }, + 'high' + ) + + return { + listId: String(result.listId || result.list_id || result.id || ''), + name: result.name || name, + } + } + + /** + * Add contacts to a static list by contact IDs. + */ + async addContactsToList( + listId: string, + contactIds: string[] + ): Promise { + await this.request( + `/crm/v3/lists/${listId}/memberships/add`, + { + method: 'PUT', + body: JSON.stringify(contactIds), + }, + 'high' + ) + } + + // ============================================ + // Owners + // ============================================ + + /** + * Get all owners in the HubSpot account. + */ + async getOwners(): Promise<{ results: HubSpotOwner[] }> { + return this.request<{ results: HubSpotOwner[] }>( + '/crm/v3/owners' + ) + } +} + +// ============================================ +// Factory Functions +// ============================================ + +/** + * Create a HubSpot client from configuration. + */ +export function createHubSpotClient(config: HubSpotClientConfig): HubSpotClient { + return new HubSpotClient(config) +} + +/** + * Create a HubSpot client from stored connection credentials. + * Uses the admin client to bypass RLS (for cron/agent routes). + */ +export async function createHubSpotClientForConnection( + connectionId: string +): Promise { + const { getHubSpotConnectionCredentialsAdmin } = await import('./config') + const { encrypt } = await import('@/lib/crypto/encryption') + + const credentials = await getHubSpotConnectionCredentialsAdmin(connectionId) + if (!credentials) { + throw new HubSpotError(`HubSpot connection ${connectionId} not found or decryption failed`) + } + + if (!credentials.isActive) { + throw new HubSpotError(`HubSpot connection ${connectionId} is not active`) + } + + return new HubSpotClient({ + token: credentials.token, + authType: credentials.authType, + connectionId, + refreshToken: credentials.refreshToken || undefined, + tokenExpiresAt: credentials.tokenExpiresAt, + onTokenRefresh: async (newToken, newRefreshToken, expiresAt) => { + // Persist refreshed tokens back to database + const { createAdminClient } = await import('@/lib/supabase/admin') + const supabase = createAdminClient() + + const [accessEncrypted, refreshEncrypted] = await Promise.all([ + encrypt(newToken), + encrypt(newRefreshToken), + ]) + + // Cast needed: hubspot_connections not yet in generated types until migration is applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (supabase as any) + .from('hubspot_connections') + .update({ + access_token_encrypted: accessEncrypted, + refresh_token_encrypted: refreshEncrypted, + token_expires_at: expiresAt, + }) + .eq('id', connectionId) + }, + }) +} diff --git a/src/lib/integrations/hubspot/config.ts b/src/lib/integrations/hubspot/config.ts new file mode 100644 index 00000000..122d77c9 --- /dev/null +++ b/src/lib/integrations/hubspot/config.ts @@ -0,0 +1,248 @@ +/** + * HubSpot Configuration Helpers (server-side only) + * + * Centralizes credential retrieval and validation for HubSpot connections. + * Unlike other integrations that use integration_configs, HubSpot uses its own + * hubspot_connections table to support multiple connections per workspace. + * + * SECURITY: Decrypted tokens must NEVER be sent to the frontend. + */ + +import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/admin' +import { decrypt } from '@/lib/crypto/encryption' +import { createModuleLogger } from '@/lib/utils/logger' +import type { HubSpotAuthType } from './types' + +const log = createModuleLogger('[HubSpot Config]') + +// ============================================ +// Constants +// ============================================ + +export const HUBSPOT_API_BASE = 'https://api.hubapi.com' + +/** Default OAuth scopes requested during authorization */ +export const DEFAULT_OAUTH_SCOPES = [ + 'crm.objects.contacts.read', + 'crm.objects.companies.read', + 'crm.objects.deals.read', + 'crm.objects.owners.read', + 'crm.schemas.contacts.read', + 'crm.schemas.companies.read', + 'crm.schemas.deals.read', +] + +/** Rate limit constants */ +export const RATE_LIMITS = { + /** OAuth apps: 110 requests per 10 seconds (we use 50% = 55) */ + OAUTH_TOKENS_PER_10S: 55, + /** Private apps (free tier): 100 requests per 10 seconds (we use 50% = 50) */ + PRIVATE_APP_TOKENS_PER_10S: 50, + /** Daily limit for OAuth: 250,000 (we use 80% = 200,000) */ + OAUTH_DAILY_LIMIT: 200_000, + /** Max retry attempts for rate-limited requests */ + MAX_RETRIES: 3, + /** Base delay for exponential backoff (ms) */ + BASE_RETRY_DELAY_MS: 1000, +} as const + +/** Sync configuration */ +export const SYNC_CONFIG = { + /** Default page size for HubSpot list requests */ + PAGE_SIZE: 100, + /** Maximum page size HubSpot allows */ + MAX_PAGE_SIZE: 100, + /** Backfill window: how far back to sync on first connection (days) */ + BACKFILL_DAYS: 90, + /** Sync lock timeout (minutes) — auto-unlock after this period */ + LOCK_TIMEOUT_MINUTES: 10, + /** Maximum records per sync batch */ + MAX_RECORDS_PER_BATCH: 10_000, +} as const + +// ============================================ +// Credential Types +// ============================================ + +export interface HubSpotCredentials { + authType: HubSpotAuthType + /** OAuth access token or Private App token */ + token: string + /** OAuth refresh token (only for OAuth connections) */ + refreshToken: string | null + /** Token expiry time (only for OAuth connections) */ + tokenExpiresAt: string | null + /** HubSpot portal/hub ID */ + hubId: string | null + /** Connection row ID */ + connectionId: string + /** Connection name */ + connectionName: string + /** Whether the connection is active */ + isActive: boolean + /** Connection status */ + status: string +} + +// ============================================ +// Credential Retrieval +// ============================================ + +/** + * Shared implementation: fetch and decrypt HubSpot connection credentials. + */ +async function _getConnectionCredentials( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + supabase: { from: (...args: any[]) => any }, + connectionId: string +): Promise { + const { data, error } = await supabase + .from('hubspot_connections') + .select('*') + .eq('id', connectionId) + .single() + + if (error || !data) { + log.error('Failed to fetch HubSpot connection:', error) + return null + } + + try { + let token: string + let refreshToken: string | null = null + + if (data.auth_type === 'oauth') { + if (!data.access_token_encrypted) { + log.error('OAuth connection missing access token') + return null + } + token = await decrypt(data.access_token_encrypted) + if (data.refresh_token_encrypted) { + refreshToken = await decrypt(data.refresh_token_encrypted) + } + } else { + if (!data.private_app_token_encrypted) { + log.error('Private App connection missing token') + return null + } + token = await decrypt(data.private_app_token_encrypted) + } + + return { + authType: data.auth_type as HubSpotAuthType, + token, + refreshToken, + tokenExpiresAt: data.token_expires_at, + hubId: data.hub_id, + connectionId: data.id, + connectionName: data.name, + isActive: data.is_active, + status: data.status, + } + } catch (err) { + log.error('Failed to decrypt HubSpot credentials:', err) + return null + } +} + +/** + * Retrieve and decrypt HubSpot connection credentials. + * Uses the current user's session (RLS-protected). + * + * @param connectionId - The hubspot_connections row UUID + * @returns Decrypted credentials or null if not found + */ +export async function getHubSpotConnectionCredentials( + connectionId: string +): Promise { + const supabase = await createClient() + return _getConnectionCredentials(supabase, connectionId) +} + +/** + * Retrieve and decrypt HubSpot connection credentials using the admin client. + * Bypasses RLS — use only in cron jobs, agent routes, etc. + * + * @param connectionId - The hubspot_connections row UUID + * @returns Decrypted credentials or null if not found + */ +export async function getHubSpotConnectionCredentialsAdmin( + connectionId: string +): Promise { + const supabase = createAdminClient() + return _getConnectionCredentials(supabase, connectionId) +} + +// ============================================ +// Connection Resolution +// ============================================ + +/** + * Resolve which HubSpot connection to use for a workspace. + * + * If connectionId is provided, returns that connection (after verifying workspace). + * Otherwise, returns the primary active connection for the workspace. + * + * @param workspaceId - The workspace UUID + * @param connectionId - Optional specific connection UUID + * @returns Connection row or null + */ +export async function resolveHubSpotConnection( + workspaceId: string, + connectionId?: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise | null> { + const supabase = await createClient() + return _resolveConnection(supabase, workspaceId, connectionId) +} + +/** + * Resolve which HubSpot connection to use — admin variant. + * Bypasses RLS. Use in agent routes, cron jobs, etc. + */ +export async function resolveHubSpotConnectionAdmin( + workspaceId: string, + connectionId?: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise | null> { + const supabase = createAdminClient() + return _resolveConnection(supabase, workspaceId, connectionId) +} + +/** + * Shared implementation for connection resolution. + */ +async function _resolveConnection( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + supabase: { from: (...args: any[]) => any }, + workspaceId: string, + connectionId?: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Promise | null> { + if (connectionId) { + const { data, error } = await supabase + .from('hubspot_connections') + .select('*') + .eq('id', connectionId) + .eq('workspace_id', workspaceId) + .single() + + if (error || !data) return null + return data as Record + } + + // Find primary active connection + const { data, error } = await supabase + .from('hubspot_connections') + .select('*') + .eq('workspace_id', workspaceId) + .eq('is_active', true) + .order('is_primary', { ascending: false }) + .order('created_at', { ascending: true }) + .limit(1) + .single() + + if (error || !data) return null + return data as Record +} diff --git a/src/lib/integrations/hubspot/entities.test.ts b/src/lib/integrations/hubspot/entities.test.ts new file mode 100644 index 00000000..16e4fee4 --- /dev/null +++ b/src/lib/integrations/hubspot/entities.test.ts @@ -0,0 +1,338 @@ +/// +/** + * Tests for HubSpot Entity Operations + * + * HS-U13: upsertCompany creates when no match found + * HS-U14: upsertCompany updates when match found + * HS-U15: upsertContact creates when no match found + * HS-U16: upsertContact updates when match found + * HS-U17: createDeal creates deal successfully + * HS-U18: batchCreateChain creates company -> contact -> deal + * HS-U19: batchCreateChain handles partial failure + * HS-U20: ensureBetonProperties creates missing properties + * HS-U21: buildHubSpotUrl generates correct URLs + * HS-U22: filterNullProperties removes null/undefined values + */ + +import { + upsertCompany, + upsertContact, + createDeal, + batchCreateChain, + ensureBetonProperties, + buildHubSpotUrl, +} from './entities' +import { HubSpotValidationError } from './types' + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('@/lib/utils/logger', () => ({ + createModuleLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +vi.mock('./associations', () => ({ + createAssociation: vi.fn().mockResolvedValue(undefined), +})) + +// --------------------------------------------------------------------------- +// Mock Client +// --------------------------------------------------------------------------- + +function createMockClient() { + return { + search: vi.fn(), + createRecord: vi.fn(), + updateRecord: vi.fn(), + getProperties: vi.fn(), + createProperty: vi.fn(), + createPropertyGroup: vi.fn(), + createAssociationV4: vi.fn(), + } as any +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('HubSpot Entity Operations', () => { + describe('buildHubSpotUrl', () => { + it('HS-U21: generates correct URL for contacts', () => { + const url = buildHubSpotUrl('12345', 'contacts', '789') + expect(url).toBe('https://app.hubspot.com/contacts/12345/contact/789') + }) + + it('generates correct URL for companies', () => { + const url = buildHubSpotUrl('12345', 'companies', '789') + expect(url).toBe('https://app.hubspot.com/contacts/12345/company/789') + }) + + it('generates correct URL for deals', () => { + const url = buildHubSpotUrl('12345', 'deals', '789') + expect(url).toBe('https://app.hubspot.com/contacts/12345/deal/789') + }) + + it('returns null when portalId is null', () => { + expect(buildHubSpotUrl(null, 'contacts', '789')).toBeNull() + }) + + it('returns null when portalId is undefined', () => { + expect(buildHubSpotUrl(undefined, 'contacts', '789')).toBeNull() + }) + + it('works with numeric portalId', () => { + const url = buildHubSpotUrl(12345, 'deals', '789') + expect(url).toBe('https://app.hubspot.com/contacts/12345/deal/789') + }) + }) + + describe('upsertCompany', () => { + it('HS-U13: creates company when no match found', async () => { + const client = createMockClient() + client.search.mockResolvedValue({ results: [] }) + client.createRecord.mockResolvedValue({ id: 'new-company-1', properties: {} }) + + const result = await upsertCompany(client, { + domain: 'acme.com', + name: 'Acme Corp', + }) + + expect(result.recordId).toBe('new-company-1') + expect(result.action).toBe('created') + expect(result.objectType).toBe('companies') + expect(client.search).toHaveBeenCalledWith('companies', expect.objectContaining({ + filterGroups: [{ filters: [{ propertyName: 'domain', operator: 'EQ', value: 'acme.com' }] }], + })) + }) + + it('HS-U14: updates company when match found', async () => { + const client = createMockClient() + client.search.mockResolvedValue({ + results: [{ id: 'existing-company-1', properties: { domain: 'acme.com' } }], + }) + client.updateRecord.mockResolvedValue({ id: 'existing-company-1', properties: {} }) + + const result = await upsertCompany(client, { + domain: 'acme.com', + name: 'Acme Corp Updated', + }) + + expect(result.recordId).toBe('existing-company-1') + expect(result.action).toBe('updated') + expect(client.updateRecord).toHaveBeenCalledWith( + 'companies', + 'existing-company-1', + expect.objectContaining({ domain: 'acme.com', name: 'Acme Corp Updated' }) + ) + }) + + it('throws validation error when matching property is missing', async () => { + const client = createMockClient() + + await expect( + upsertCompany(client, { name: 'Acme Corp' }) + ).rejects.toThrow(HubSpotValidationError) + }) + + it('HS-U22: filters null and undefined properties', async () => { + const client = createMockClient() + client.search.mockResolvedValue({ results: [] }) + client.createRecord.mockResolvedValue({ id: 'new-1', properties: {} }) + + await upsertCompany(client, { + domain: 'acme.com', + name: null, + city: undefined, + empty_string: '', + }) + + expect(client.createRecord).toHaveBeenCalledWith( + 'companies', + { domain: 'acme.com' } + ) + }) + }) + + describe('upsertContact', () => { + it('HS-U15: creates contact when no match found', async () => { + const client = createMockClient() + client.search.mockResolvedValue({ results: [] }) + client.createRecord.mockResolvedValue({ id: 'new-contact-1', properties: {} }) + + const result = await upsertContact(client, { + email: 'user@acme.com', + firstname: 'Jane', + lastname: 'Doe', + }) + + expect(result.recordId).toBe('new-contact-1') + expect(result.action).toBe('created') + expect(result.objectType).toBe('contacts') + }) + + it('HS-U16: updates contact when match found', async () => { + const client = createMockClient() + client.search.mockResolvedValue({ + results: [{ id: 'existing-contact-1', properties: { email: 'user@acme.com' } }], + }) + client.updateRecord.mockResolvedValue({ id: 'existing-contact-1', properties: {} }) + + const result = await upsertContact(client, { + email: 'user@acme.com', + firstname: 'Jane Updated', + }) + + expect(result.recordId).toBe('existing-contact-1') + expect(result.action).toBe('updated') + }) + + it('throws validation error when email is missing', async () => { + const client = createMockClient() + + await expect( + upsertContact(client, { firstname: 'Jane' }) + ).rejects.toThrow(HubSpotValidationError) + }) + }) + + describe('createDeal', () => { + it('HS-U17: creates deal successfully', async () => { + const client = createMockClient() + client.createRecord.mockResolvedValue({ id: 'new-deal-1', properties: {} }) + + const result = await createDeal(client, { + dealname: 'Acme Corp -- Beton Signal', + amount: '50000', + }) + + expect(result.recordId).toBe('new-deal-1') + expect(result.action).toBe('created') + expect(result.objectType).toBe('deals') + }) + + it('throws validation error when dealname is missing', async () => { + const client = createMockClient() + + await expect( + createDeal(client, { amount: '50000' }) + ).rejects.toThrow(HubSpotValidationError) + }) + }) + + describe('batchCreateChain', () => { + it('HS-U18: creates company -> contact -> deal chain', async () => { + const client = createMockClient() + client.search.mockResolvedValue({ results: [] }) + client.createRecord + .mockResolvedValueOnce({ id: 'company-1', properties: {} }) + .mockResolvedValueOnce({ id: 'contact-1', properties: {} }) + .mockResolvedValueOnce({ id: 'deal-1', properties: {} }) + + const result = await batchCreateChain(client, { + portalId: '12345', + createCompany: true, + companyData: { domain: 'acme.com', name: 'Acme Corp' }, + createContact: true, + contactData: { email: 'user@acme.com', firstname: 'Jane' }, + createDeal: true, + dealData: { dealname: 'Acme -- Beton Signal' }, + }) + + expect(result.company?.record_id).toBe('company-1') + expect(result.contact?.record_id).toBe('contact-1') + expect(result.deal?.record_id).toBe('deal-1') + expect(result.company?.hubspot_url).toContain('12345') + expect(result.error).toBeUndefined() + }) + + it('HS-U19: handles partial failure at contact step', async () => { + const client = createMockClient() + // Company succeeds + client.search.mockResolvedValue({ results: [] }) + client.createRecord + .mockResolvedValueOnce({ id: 'company-1', properties: {} }) + .mockRejectedValueOnce(new Error('Contact creation failed')) + + const result = await batchCreateChain(client, { + portalId: '12345', + createCompany: true, + companyData: { domain: 'acme.com', name: 'Acme Corp' }, + createContact: true, + contactData: { email: 'user@acme.com' }, + createDeal: true, + dealData: { dealname: 'Acme -- Beton Signal' }, + }) + + expect(result.company?.record_id).toBe('company-1') + expect(result.contact).toBeUndefined() + expect(result.deal).toBeUndefined() + expect(result.error).toBeDefined() + expect(result.partial).toBe(true) + }) + + it('handles company-only creation', async () => { + const client = createMockClient() + client.search.mockResolvedValue({ results: [] }) + client.createRecord.mockResolvedValue({ id: 'company-1', properties: {} }) + + const result = await batchCreateChain(client, { + createCompany: true, + companyData: { domain: 'acme.com', name: 'Acme Corp' }, + createContact: false, + createDeal: false, + }) + + expect(result.company?.record_id).toBe('company-1') + expect(result.contact).toBeUndefined() + expect(result.deal).toBeUndefined() + }) + }) + + describe('ensureBetonProperties', () => { + it('HS-U20: creates missing properties and skips existing', async () => { + const client = createMockClient() + + // Property group creation succeeds + client.createPropertyGroup.mockResolvedValue({ name: 'beton', label: 'Beton Inspector' }) + + // Some properties already exist + client.getProperties.mockResolvedValue({ + results: [ + { name: 'beton_health_score' }, + { name: 'domain' }, + ], + }) + + // Property creation succeeds + client.createProperty.mockResolvedValue({ name: 'beton_signal_count' }) + + const result = await ensureBetonProperties(client, 'companies') + + expect(result.skipped).toContain('beton_health_score') + expect(result.created.length).toBeGreaterThan(0) + // beton_health_score was in existing, so should be skipped + expect(result.created).not.toContain('beton_health_score') + }) + + it('handles property group already existing (409)', async () => { + const client = createMockClient() + + const error = new Error('409') + error.name = 'HubSpotError' + client.createPropertyGroup.mockRejectedValue(error) + + client.getProperties.mockResolvedValue({ results: [] }) + client.createProperty.mockResolvedValue({ name: 'test' }) + + // Should not throw — 409 is handled gracefully + const result = await ensureBetonProperties(client, 'companies') + expect(result.errors.length).toBe(0) + }) + }) +}) diff --git a/src/lib/integrations/hubspot/entities.ts b/src/lib/integrations/hubspot/entities.ts new file mode 100644 index 00000000..5444581a --- /dev/null +++ b/src/lib/integrations/hubspot/entities.ts @@ -0,0 +1,575 @@ +/** + * HubSpot Entity Operations (Destination) + * + * Provides entity creation and management for HubSpot CRM: + * - upsertCompany: search by domain, create or update + * - upsertContact: search by email, create or update + * - createDeal: create deal (no dedup by default) + * - batchCreateChain: company -> contact -> deal with associations + * - ensureBetonProperties: auto-create beton_* custom properties + * - buildHubSpotUrl: URL builder for deep links + * + * Uses HubSpot v3 CRM API endpoints. + */ + +import type { HubSpotClient } from './client' +import type { HubSpotRecord, HubSpotPropertyDefinition } from './types' +import { HubSpotError, HubSpotValidationError, HubSpotNotFoundError } from './types' +import { createModuleLogger } from '@/lib/utils/logger' + +const log = createModuleLogger('[HubSpot Entities]') + +// ============================================ +// Types +// ============================================ + +export interface UpsertResult { + recordId: string + action: 'created' | 'updated' + objectType: string +} + +export interface EntityResult { + record_id: string + object_type: string + hubspot_url: string | null +} + +export interface BatchCreateChainOptions { + /** Portal ID for building URLs */ + portalId?: string | number | null + /** Company data (domain, name, etc.) */ + companyData?: Record + /** Contact data (email, firstname, lastname, etc.) */ + contactData?: Record + /** Deal data (dealname, pipeline, etc.) */ + dealData?: Record + /** Whether to create company */ + createCompany?: boolean + /** Whether to create contact */ + createContact?: boolean + /** Whether to create deal */ + createDeal?: boolean +} + +export interface BatchCreateChainResult { + company?: EntityResult + contact?: EntityResult + deal?: EntityResult + error?: string + partial?: boolean +} + +// ============================================ +// Beton Custom Properties +// ============================================ + +/** Property group name for all Beton-created properties */ +const BETON_GROUP_NAME = 'beton' +const BETON_GROUP_LABEL = 'Beton Inspector' + +/** Beton custom property definitions */ +const BETON_PROPERTIES: Array<{ + name: string + label: string + type: string + fieldType: string + description: string + objectTypes: string[] + options?: Array<{ label: string; value: string; displayOrder: number; hidden: boolean }> +}> = [ + { + name: 'beton_health_score', + label: 'Beton Health Score', + type: 'number', + fieldType: 'number', + description: 'Account health score calculated by Beton Inspector (0-100)', + objectTypes: ['companies', 'contacts'], + }, + { + name: 'beton_expansion_score', + label: 'Beton Expansion Score', + type: 'number', + fieldType: 'number', + description: 'Account expansion potential score calculated by Beton Inspector (0-100)', + objectTypes: ['companies'], + }, + { + name: 'beton_churn_risk_score', + label: 'Beton Churn Risk Score', + type: 'number', + fieldType: 'number', + description: 'Account churn risk score calculated by Beton Inspector (0-100)', + objectTypes: ['companies'], + }, + { + name: 'beton_concrete_grade', + label: 'Beton Concrete Grade', + type: 'enumeration', + fieldType: 'select', + description: 'Account concrete grade from Beton Inspector', + objectTypes: ['companies'], + options: [ + { label: 'M100', value: 'M100', displayOrder: 0, hidden: false }, + { label: 'M75', value: 'M75', displayOrder: 1, hidden: false }, + { label: 'M50', value: 'M50', displayOrder: 2, hidden: false }, + { label: 'M25', value: 'M25', displayOrder: 3, hidden: false }, + { label: 'M10', value: 'M10', displayOrder: 4, hidden: false }, + ], + }, + { + name: 'beton_signal_count', + label: 'Beton Signal Count', + type: 'number', + fieldType: 'number', + description: 'Total number of signals detected by Beton Inspector', + objectTypes: ['companies', 'contacts'], + }, + { + name: 'beton_last_signal_date', + label: 'Beton Last Signal Date', + type: 'datetime', + fieldType: 'date', + description: 'Date of the most recent signal detected by Beton Inspector', + objectTypes: ['companies', 'contacts'], + }, + { + name: 'beton_last_signal_type', + label: 'Beton Last Signal Type', + type: 'string', + fieldType: 'text', + description: 'Type of the most recent signal detected by Beton Inspector', + objectTypes: ['companies', 'contacts'], + }, +] + +// ============================================ +// Helpers +// ============================================ + +/** + * Filter out null/undefined values from a properties object. + */ +function filterNullProperties( + properties: Record +): Record { + const filtered: Record = {} + for (const [key, value] of Object.entries(properties)) { + if (value !== null && value !== undefined && value !== '') { + // HubSpot properties must be strings, numbers, or booleans + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + filtered[key] = value + } else { + filtered[key] = String(value) + } + } + } + return filtered +} + +/** + * Build a HubSpot web URL for a CRM record. + * + * @param portalId - HubSpot portal/hub ID + * @param objectType - CRM object type (contacts, companies, deals) + * @param recordId - HubSpot record ID + * @returns Full URL or null if portalId is missing + */ +export function buildHubSpotUrl( + portalId: string | number | null | undefined, + objectType: string, + recordId: string +): string | null { + if (!portalId) return null + + // Map object types to HubSpot URL paths + const urlObjectType: Record = { + contacts: 'contact', + companies: 'company', + deals: 'deal', + tickets: 'ticket', + } + + const pathType = urlObjectType[objectType] || objectType + return `https://app.hubspot.com/contacts/${portalId}/${pathType}/${recordId}` +} + +// ============================================ +// Upsert Operations +// ============================================ + +/** + * Upsert a company in HubSpot. Searches by domain first; creates or updates. + * + * @param client - HubSpot API client + * @param data - Company properties (must include domain for matching) + * @param matchingProperty - Property to match on (default: 'domain') + * @returns Upsert result with record ID and action taken + */ +export async function upsertCompany( + client: HubSpotClient, + data: Record, + matchingProperty: string = 'domain' +): Promise { + const properties = filterNullProperties(data) + const matchValue = properties[matchingProperty] + + if (!matchValue) { + throw new HubSpotValidationError( + `Missing required matching property "${matchingProperty}" for company upsert` + ) + } + + // Search for existing company + try { + const searchResult = await client.search('companies', { + filterGroups: [ + { + filters: [ + { + propertyName: matchingProperty, + operator: 'EQ', + value: String(matchValue), + }, + ], + }, + ], + properties: ['domain', 'name'], + limit: 1, + }) + + if (searchResult.results.length > 0) { + // Update existing company + const existingId = searchResult.results[0].id + await client.updateRecord('companies', existingId, properties) + log.info(`Updated company ${existingId} (matched on ${matchingProperty}=${matchValue})`) + return { + recordId: existingId, + action: 'updated', + objectType: 'companies', + } + } + } catch (err) { + // If search fails, try creating anyway + if (!(err instanceof HubSpotNotFoundError)) { + log.warn('Company search failed, attempting create:', err) + } + } + + // Create new company + const created = await client.createRecord('companies', properties) + log.info(`Created company ${created.id}`) + return { + recordId: created.id, + action: 'created', + objectType: 'companies', + } +} + +/** + * Upsert a contact in HubSpot. Searches by email first; creates or updates. + * + * @param client - HubSpot API client + * @param data - Contact properties (must include email for matching) + * @param matchingProperty - Property to match on (default: 'email') + * @returns Upsert result with record ID and action taken + */ +export async function upsertContact( + client: HubSpotClient, + data: Record, + matchingProperty: string = 'email' +): Promise { + const properties = filterNullProperties(data) + const matchValue = properties[matchingProperty] + + if (!matchValue) { + throw new HubSpotValidationError( + `Missing required matching property "${matchingProperty}" for contact upsert` + ) + } + + // Search for existing contact + try { + const searchResult = await client.search('contacts', { + filterGroups: [ + { + filters: [ + { + propertyName: matchingProperty, + operator: 'EQ', + value: String(matchValue), + }, + ], + }, + ], + properties: ['email', 'firstname', 'lastname'], + limit: 1, + }) + + if (searchResult.results.length > 0) { + // Update existing contact + const existingId = searchResult.results[0].id + await client.updateRecord('contacts', existingId, properties) + log.info(`Updated contact ${existingId} (matched on ${matchingProperty}=${matchValue})`) + return { + recordId: existingId, + action: 'updated', + objectType: 'contacts', + } + } + } catch (err) { + if (!(err instanceof HubSpotNotFoundError)) { + log.warn('Contact search failed, attempting create:', err) + } + } + + // Create new contact + const created = await client.createRecord('contacts', properties) + log.info(`Created contact ${created.id}`) + return { + recordId: created.id, + action: 'created', + objectType: 'contacts', + } +} + +/** + * Create a deal in HubSpot. No dedup by default. + * + * @param client - HubSpot API client + * @param data - Deal properties (dealname required) + * @returns Created deal record ID + */ +export async function createDeal( + client: HubSpotClient, + data: Record +): Promise { + const properties = filterNullProperties(data) + + if (!properties.dealname) { + throw new HubSpotValidationError('dealname is required for deal creation') + } + + const created = await client.createRecord('deals', properties) + log.info(`Created deal ${created.id}`) + return { + recordId: created.id, + action: 'created', + objectType: 'deals', + } +} + +// ============================================ +// Batch Create Chain +// ============================================ + +/** + * Create a chain of company -> contact -> deal with associations. + * Executes in sequence: company first, then contact (linked), then deal (linked). + * Supports partial failure - returns whatever succeeded. + * + * @param client - HubSpot API client + * @param opts - Chain options with entity data and flags + * @returns Result with created entity IDs and URLs + */ +export async function batchCreateChain( + client: HubSpotClient, + opts: BatchCreateChainOptions +): Promise { + const { createAssociation } = await import('./associations') + const result: BatchCreateChainResult = {} + + // 1. Company (if requested) + let companyId: string | undefined + if (opts.createCompany && opts.companyData) { + try { + const companyResult = await upsertCompany(client, opts.companyData) + companyId = companyResult.recordId + result.company = { + record_id: companyResult.recordId, + object_type: 'companies', + hubspot_url: buildHubSpotUrl(opts.portalId, 'companies', companyResult.recordId), + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Company creation failed' + log.error('Company creation failed:', err) + return { ...result, error: msg, partial: false } + } + } + + // 2. Contact (if requested) + let contactId: string | undefined + if (opts.createContact && opts.contactData) { + try { + const contactResult = await upsertContact(client, opts.contactData) + contactId = contactResult.recordId + result.contact = { + record_id: contactResult.recordId, + object_type: 'contacts', + hubspot_url: buildHubSpotUrl(opts.portalId, 'contacts', contactResult.recordId), + } + + // Associate contact -> company + if (companyId) { + try { + await createAssociation(client, 'contacts', contactId, 'companies', companyId) + } catch (assocErr) { + log.warn('Failed to associate contact to company:', assocErr) + // Non-fatal: entities are created, just not linked + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Contact creation failed' + log.error('Contact creation failed:', err) + return { ...result, error: msg, partial: true } + } + } + + // 3. Deal (if requested) + if (opts.createDeal && opts.dealData) { + try { + const dealResult = await createDeal(client, opts.dealData) + result.deal = { + record_id: dealResult.recordId, + object_type: 'deals', + hubspot_url: buildHubSpotUrl(opts.portalId, 'deals', dealResult.recordId), + } + + // Associate deal -> company + if (companyId) { + try { + await createAssociation(client, 'deals', dealResult.recordId, 'companies', companyId) + } catch (assocErr) { + log.warn('Failed to associate deal to company:', assocErr) + } + } + + // Associate deal -> contact + if (contactId) { + try { + await createAssociation(client, 'deals', dealResult.recordId, 'contacts', contactId) + } catch (assocErr) { + log.warn('Failed to associate deal to contact:', assocErr) + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : 'Deal creation failed' + log.error('Deal creation failed:', err) + return { ...result, error: msg, partial: true } + } + } + + return result +} + +// ============================================ +// Custom Properties +// ============================================ + +/** + * Ensure the Beton property group and custom properties exist on a given object type. + * Idempotent: skips properties that already exist. + * + * @param client - HubSpot API client + * @param objectType - CRM object type ('companies', 'contacts', 'deals') + * @returns Object with created and skipped property names + */ +export async function ensureBetonProperties( + client: HubSpotClient, + objectType: string +): Promise<{ created: string[]; skipped: string[]; errors: string[] }> { + const created: string[] = [] + const skipped: string[] = [] + const errors: string[] = [] + + // 1. Ensure the beton property group exists + try { + await client.createPropertyGroup(objectType, { + name: BETON_GROUP_NAME, + label: BETON_GROUP_LABEL, + displayOrder: -1, + }) + log.info(`Created property group "${BETON_GROUP_NAME}" on ${objectType}`) + } catch (err) { + // 409 Conflict means group already exists — that's fine + if (err instanceof HubSpotError && err.message.includes('409')) { + // Group already exists, continue + } else if (err instanceof HubSpotValidationError) { + // Likely already exists + } else { + log.warn(`Failed to create property group on ${objectType}:`, err) + // Non-fatal: try creating properties anyway (they might work with default group) + } + } + + // 2. Get existing properties to check what already exists + let existingNames: Set + try { + const existingProps = await client.getProperties(objectType) + existingNames = new Set(existingProps.results.map((p: HubSpotPropertyDefinition) => p.name)) + } catch (err) { + log.error('Failed to list existing properties:', err) + return { created: [], skipped: [], errors: ['Failed to list existing properties'] } + } + + // 3. Create missing properties + const applicableProps = BETON_PROPERTIES.filter((p) => + p.objectTypes.includes(objectType) + ) + + for (const prop of applicableProps) { + if (existingNames.has(prop.name)) { + skipped.push(prop.name) + continue + } + + try { + const createPayload: Record = { + name: prop.name, + label: prop.label, + type: prop.type, + fieldType: prop.fieldType, + groupName: BETON_GROUP_NAME, + description: prop.description, + } + + if (prop.options) { + createPayload.options = prop.options + } + + await client.createProperty(objectType, createPayload as { + name: string + label: string + type: string + fieldType: string + groupName: string + description?: string + }) + + created.push(prop.name) + log.info(`Created property "${prop.name}" on ${objectType}`) + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error' + errors.push(`${prop.name}: ${msg}`) + log.warn(`Failed to create property "${prop.name}" on ${objectType}:`, err) + } + } + + return { created, skipped, errors } +} + +/** + * Get the list of Beton property definitions for a given object type. + * Useful for field mapping UI. + */ +export function getBetonPropertyDefinitions(objectType: string) { + return BETON_PROPERTIES.filter((p) => p.objectTypes.includes(objectType)).map( + (p) => ({ + name: p.name, + label: p.label, + type: p.type, + fieldType: p.fieldType, + description: p.description, + }) + ) +} diff --git a/src/lib/integrations/hubspot/index.ts b/src/lib/integrations/hubspot/index.ts new file mode 100644 index 00000000..c8fe2334 --- /dev/null +++ b/src/lib/integrations/hubspot/index.ts @@ -0,0 +1,58 @@ +/** + * HubSpot Integration Module + * + * Exports all HubSpot CRM integration components: + * - Types: API response types, connection config, sync state + * - Auth: OAuth flow and Private App token validation + * - Config: Credential retrieval, constants, connection resolution + * - Client: HubSpot API client + * - Entities: Entity operations (upsert, batch create, custom properties) + * - Associations: Association management + * - Polling: Sync engine for incremental data sync + * - Rate Limiter: Token bucket rate limiting + */ + +export * from './types' +export * from './auth' +export { + HUBSPOT_API_BASE, + DEFAULT_OAUTH_SCOPES, + RATE_LIMITS as HUBSPOT_RATE_LIMITS, + SYNC_CONFIG as HUBSPOT_SYNC_CONFIG, + getHubSpotConnectionCredentials, + getHubSpotConnectionCredentialsAdmin, + resolveHubSpotConnection, + resolveHubSpotConnectionAdmin, + type HubSpotCredentials, +} from './config' +export { + HubSpotClient, + createHubSpotClient, + createHubSpotClientForConnection, + type HubSpotClientConfig, +} from './client' +export { + upsertCompany, + upsertContact, + createDeal, + batchCreateChain, + ensureBetonProperties, + buildHubSpotUrl, + getBetonPropertyDefinitions, + type UpsertResult, + type EntityResult, + type BatchCreateChainOptions, + type BatchCreateChainResult, +} from './entities' +export { + createAssociation, + batchCreateAssociations, + ASSOCIATION_TYPE_IDS, + type AssociationInput, +} from './associations' +export { + syncObjectType, + syncConnection, + type SyncResult, + type ConnectionSyncResult, +} from './polling' diff --git a/src/lib/integrations/hubspot/polling.ts b/src/lib/integrations/hubspot/polling.ts new file mode 100644 index 00000000..52e24f90 --- /dev/null +++ b/src/lib/integrations/hubspot/polling.ts @@ -0,0 +1,494 @@ +/** + * HubSpot Polling / Sync Engine + * + * Handles incremental sync of HubSpot CRM objects to the local cache: + * - syncConnection(): sync all enabled objects for a connection + * - syncObjectType(): sync one object type (contacts, companies, etc.) + * + * Features: + * - Incremental sync using lastmodifieddate watermark + * - Backfill: last 90 days on first sync + * - Sync lock to prevent concurrent syncs + * - Upserts to hubspot_records cache table + * - Sync state tracking via hubspot_sync_state table + */ + +import { createAdminClient } from '@/lib/supabase/admin' +import { createHubSpotClientForConnection, type HubSpotClient } from './client' +import { SYNC_CONFIG } from './config' +import { STANDARD_OBJECT_TYPES, type HubSpotObjectType, type HubSpotSyncStatus } from './types' +import { createModuleLogger } from '@/lib/utils/logger' +import { randomBytes } from 'crypto' + +const log = createModuleLogger('[HubSpot Polling]') + +// ============================================ +// Types +// ============================================ + +export interface SyncResult { + objectType: string + recordsSynced: number + status: 'success' | 'error' | 'skipped' + error?: string + durationMs: number +} + +export interface ConnectionSyncResult { + connectionId: string + hubId: string | null + results: SyncResult[] + totalRecordsSynced: number + totalErrors: number + durationMs: number +} + +// ============================================ +// Sync Lock Management +// ============================================ + +/** + * Acquire a sync lock for an object type on a connection. + * Returns the lock ID if acquired, null if already locked. + */ +async function acquireSyncLock( + connectionId: string, + objectType: string +): Promise { + const supabase = createAdminClient() + const lockId = randomBytes(16).toString('hex') + const lockExpiresAt = new Date( + Date.now() + SYNC_CONFIG.LOCK_TIMEOUT_MINUTES * 60 * 1000 + ).toISOString() + + // Upsert sync state row, only acquiring lock if not already locked + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error } = await (supabase as any) + .from('hubspot_sync_state') + .upsert( + { + connection_id: connectionId, + object_type: objectType, + sync_lock_id: lockId, + sync_lock_expires_at: lockExpiresAt, + status: 'syncing', + }, + { onConflict: 'connection_id,object_type' } + ) + .select('sync_lock_id') + .single() + + if (error) { + // If there's a conflict, check if the existing lock has expired + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: existing } = await (supabase as any) + .from('hubspot_sync_state') + .select('sync_lock_id, sync_lock_expires_at, status') + .eq('connection_id', connectionId) + .eq('object_type', objectType) + .single() + + if (existing) { + const lockExpired = + !existing.sync_lock_expires_at || + new Date(existing.sync_lock_expires_at) < new Date() + + if (lockExpired || existing.status !== 'syncing') { + // Expired lock — forcibly take it + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (supabase as any) + .from('hubspot_sync_state') + .update({ + sync_lock_id: lockId, + sync_lock_expires_at: lockExpiresAt, + status: 'syncing', + }) + .eq('connection_id', connectionId) + .eq('object_type', objectType) + + return lockId + } + + log.warn( + `Sync lock held for ${objectType} on connection ${connectionId}, skipping` + ) + return null + } + + log.error('Failed to acquire sync lock:', error) + return null + } + + // Verify we got our lock + if (data?.sync_lock_id === lockId) { + return lockId + } + + return lockId // Upsert succeeded, we own the lock +} + +/** + * Release a sync lock. + */ +async function releaseSyncLock( + connectionId: string, + objectType: string, + lockId: string, + status: HubSpotSyncStatus = 'idle' +): Promise { + const supabase = createAdminClient() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (supabase as any) + .from('hubspot_sync_state') + .update({ + sync_lock_id: null, + sync_lock_expires_at: null, + status, + }) + .eq('connection_id', connectionId) + .eq('object_type', objectType) + .eq('sync_lock_id', lockId) +} + +// ============================================ +// Object Type Sync +// ============================================ + +/** + * Sync a single object type for a connection. + * + * Uses incremental sync (lastmodifieddate > last watermark) for ongoing syncs + * and backfills the last 90 days on first sync. + */ +export async function syncObjectType( + client: HubSpotClient, + connectionId: string, + workspaceId: string, + objectType: HubSpotObjectType +): Promise { + const startTime = Date.now() + + // Acquire sync lock + const lockId = await acquireSyncLock(connectionId, objectType) + if (!lockId) { + return { + objectType, + recordsSynced: 0, + status: 'skipped', + error: 'Sync already in progress', + durationMs: Date.now() - startTime, + } + } + + try { + const supabase = createAdminClient() + + // Get current sync state + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: syncState } = await (supabase as any) + .from('hubspot_sync_state') + .select('*') + .eq('connection_id', connectionId) + .eq('object_type', objectType) + .single() + + // Determine sync window + const isBackfill = !syncState?.last_sync_at + const lastModifiedAt = syncState?.last_modified_at + ? new Date(syncState.last_modified_at) + : null + + // Build filter for incremental sync + const properties = getDefaultProperties(objectType) + let after: string | undefined + let totalSynced = 0 + let latestModifiedDate: string | null = null + + if (isBackfill) { + log.info(`Backfilling ${objectType} for connection ${connectionId} (last ${SYNC_CONFIG.BACKFILL_DAYS} days)`) + + // Update status to backfilling + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (supabase as any) + .from('hubspot_sync_state') + .update({ status: 'backfilling' }) + .eq('connection_id', connectionId) + .eq('object_type', objectType) + } + + // Paginate through records + let hasMore = true + while (hasMore && totalSynced < SYNC_CONFIG.MAX_RECORDS_PER_BATCH) { + // Use search API for incremental sync (supports filters) + // Use list API for backfill (simpler, no filter needed) + let records + let nextAfter: string | undefined + + if (lastModifiedAt && !isBackfill) { + // Incremental: search for records modified since last sync + const searchResult = await client.search(objectType, { + filterGroups: [ + { + filters: [ + { + propertyName: 'lastmodifieddate', + operator: 'GTE', + value: lastModifiedAt.toISOString(), + }, + ], + }, + ], + sorts: [{ propertyName: 'lastmodifieddate', direction: 'ASCENDING' }], + properties, + limit: SYNC_CONFIG.PAGE_SIZE, + after, + }) + + records = searchResult.results + nextAfter = searchResult.paging?.next?.after + } else { + // Backfill: list all records + const listResult = await listObjectType(client, objectType, { + properties, + limit: SYNC_CONFIG.PAGE_SIZE, + after, + }) + + records = listResult.results + nextAfter = listResult.paging?.next?.after + } + + if (records.length === 0) { + hasMore = false + break + } + + // Upsert records to cache + const upsertPayload = records.map((record) => ({ + connection_id: connectionId, + workspace_id: workspaceId, + hubspot_id: record.id, + object_type: objectType, + properties: record.properties, + hubspot_created_at: record.createdAt || null, + hubspot_updated_at: record.updatedAt || null, + synced_at: new Date().toISOString(), + })) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error: upsertError } = await (supabase as any) + .from('hubspot_records') + .upsert(upsertPayload, { + onConflict: 'connection_id,object_type,hubspot_id', + }) + + if (upsertError) { + log.error(`Failed to upsert ${objectType} records:`, upsertError) + throw new Error(`Upsert failed: ${upsertError.message}`) + } + + totalSynced += records.length + + // Track the latest modified date for watermark + for (const record of records) { + const modDate = record.updatedAt || record.properties?.lastmodifieddate + if (modDate && (!latestModifiedDate || String(modDate) > latestModifiedDate)) { + latestModifiedDate = String(modDate) + } + } + + // Advance pagination + if (nextAfter) { + after = nextAfter + } else { + hasMore = false + } + } + + // Update sync state + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (supabase as any) + .from('hubspot_sync_state') + .update({ + last_sync_at: new Date().toISOString(), + last_modified_at: latestModifiedDate || syncState?.last_modified_at || null, + records_synced: (syncState?.records_synced || 0) + totalSynced, + last_error: null, + status: 'idle', + }) + .eq('connection_id', connectionId) + .eq('object_type', objectType) + + // Release lock + await releaseSyncLock(connectionId, objectType, lockId, 'idle') + + log.info( + `Synced ${totalSynced} ${objectType} records for connection ${connectionId} ` + + `in ${Date.now() - startTime}ms` + ) + + return { + objectType, + recordsSynced: totalSynced, + status: 'success', + durationMs: Date.now() - startTime, + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + log.error(`Error syncing ${objectType} for connection ${connectionId}:`, error) + + // Update sync state with error + const supabase = createAdminClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (supabase as any) + .from('hubspot_sync_state') + .update({ + status: 'error', + last_error: errorMessage, + }) + .eq('connection_id', connectionId) + .eq('object_type', objectType) + + // Release lock + await releaseSyncLock(connectionId, objectType, lockId, 'error') + + return { + objectType, + recordsSynced: 0, + status: 'error', + error: errorMessage, + durationMs: Date.now() - startTime, + } + } +} + +// ============================================ +// Connection Sync +// ============================================ + +/** + * Sync all enabled object types for a HubSpot connection. + */ +export async function syncConnection( + connectionId: string +): Promise { + const startTime = Date.now() + + const supabase = createAdminClient() + + // Fetch connection details + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: connection, error } = await (supabase as any) + .from('hubspot_connections') + .select('*') + .eq('id', connectionId) + .eq('is_active', true) + .eq('status', 'connected') + .single() + + if (error || !connection) { + log.warn(`Connection ${connectionId} not found or not active`) + return { + connectionId, + hubId: null, + results: [], + totalRecordsSynced: 0, + totalErrors: 1, + durationMs: Date.now() - startTime, + } + } + + // Create client + const client = await createHubSpotClientForConnection(connectionId) + + // Determine which object types to sync + const enabledObjects: HubSpotObjectType[] = + connection.config_json?.enabled_objects || [...STANDARD_OBJECT_TYPES] + + log.info( + `Syncing ${enabledObjects.length} object types for connection ${connectionId} ` + + `(hub: ${connection.hub_id})` + ) + + // Sync each object type sequentially to respect rate limits + const results: SyncResult[] = [] + for (const objectType of enabledObjects) { + const result = await syncObjectType( + client, + connectionId, + connection.workspace_id, + objectType + ) + results.push(result) + } + + const totalRecordsSynced = results.reduce((sum, r) => sum + r.recordsSynced, 0) + const totalErrors = results.filter((r) => r.status === 'error').length + + return { + connectionId, + hubId: connection.hub_id, + results, + totalRecordsSynced, + totalErrors, + durationMs: Date.now() - startTime, + } +} + +// ============================================ +// Helpers +// ============================================ + +/** + * Get default properties to fetch for an object type. + */ +function getDefaultProperties(objectType: HubSpotObjectType): string[] { + switch (objectType) { + case 'contacts': + return [ + 'email', 'firstname', 'lastname', 'phone', 'company', + 'jobtitle', 'lifecyclestage', 'lastmodifieddate', 'createdate', + ] + case 'companies': + return [ + 'name', 'domain', 'industry', 'numberofemployees', + 'annualrevenue', 'city', 'state', 'country', + 'lastmodifieddate', 'createdate', + ] + case 'deals': + return [ + 'dealname', 'dealstage', 'pipeline', 'amount', 'closedate', + 'hubspot_owner_id', 'lastmodifieddate', 'createdate', + ] + case 'tickets': + return [ + 'subject', 'content', 'hs_pipeline', 'hs_pipeline_stage', + 'hs_ticket_priority', 'lastmodifieddate', 'createdate', + ] + default: + return ['lastmodifieddate', 'createdate'] + } +} + +/** + * List objects using the appropriate client method. + */ +async function listObjectType( + client: HubSpotClient, + objectType: HubSpotObjectType, + options: { properties: string[]; limit: number; after?: string } +) { + switch (objectType) { + case 'contacts': + return client.getContacts(options) + case 'companies': + return client.getCompanies(options) + case 'deals': + return client.getDeals(options) + case 'tickets': + return client.getTickets(options) + default: + return client.getCustomObjects(objectType, options) + } +} diff --git a/src/lib/integrations/hubspot/rate-limiter.test.ts b/src/lib/integrations/hubspot/rate-limiter.test.ts new file mode 100644 index 00000000..f8863d11 --- /dev/null +++ b/src/lib/integrations/hubspot/rate-limiter.test.ts @@ -0,0 +1,177 @@ +/// +/** + * Tests for HubSpot Rate Limiter + * + * HS-U10: Token bucket acquires and depletes + * HS-U11: 429 handling pauses and recovers + * HS-U12: Priority allocation works + */ + +import { + tryAcquire, + handleRateLimitResponse, + parseRateLimitHeaders, + getStats, + reset, + resetAll, +} from './rate-limiter' + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('@/lib/utils/logger', () => ({ + createModuleLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('HubSpot Rate Limiter', () => { + beforeEach(() => { + resetAll() + }) + + // HS-U10: Token bucket + describe('tryAcquire', () => { + it('allows requests when bucket has tokens', () => { + const result = tryAcquire('conn-1', 'normal', 'oauth') + expect(result.allowed).toBe(true) + expect(result.waitMs).toBe(0) + }) + + it('creates separate buckets per connection', () => { + const r1 = tryAcquire('conn-1', 'normal', 'oauth') + const r2 = tryAcquire('conn-2', 'normal', 'private_app') + expect(r1.allowed).toBe(true) + expect(r2.allowed).toBe(true) + + const stats1 = getStats('conn-1') + const stats2 = getStats('conn-2') + expect(stats1?.maxTokens).toBe(55) // OAuth: 50% of 110 + expect(stats2?.maxTokens).toBe(50) // Private App: 50% of 100 + }) + + it('depletes tokens on repeated requests', () => { + // Drain the bucket — tokens refill slightly between calls, so use a generous threshold + for (let i = 0; i < 55; i++) { + tryAcquire('conn-drain', 'high', 'oauth') + } + + const stats = getStats('conn-drain') + // Tokens should be significantly reduced (some may refill between calls) + expect(stats?.tokens).toBeLessThan(stats!.maxTokens * 0.5) + }) + }) + + // HS-U11: 429 handling + describe('handleRateLimitResponse', () => { + it('pauses requests after 429', () => { + handleRateLimitResponse('conn-429', 5) + + const stats = getStats('conn-429') + expect(stats?.paused).toBe(true) + expect(stats?.tokens).toBe(0) + + // Requests should be blocked + const result = tryAcquire('conn-429', 'high', 'oauth') + expect(result.allowed).toBe(false) + expect(result.waitMs).toBeGreaterThan(0) + }) + + it('recovers after pause period expires', async () => { + // Pause for 0.1 seconds + handleRateLimitResponse('conn-recover', 0.1) + + // Wait for recovery + await new Promise((resolve) => setTimeout(resolve, 200)) + + // Should be able to acquire again + const result = tryAcquire('conn-recover', 'normal', 'oauth') + expect(result.allowed).toBe(true) + + // Recovery gives 25% capacity + const stats = getStats('conn-recover') + // After one acquire, should have (25% of 55) - 1 tokens + expect(stats?.tokens).toBeLessThanOrEqual(Math.floor(55 * 0.25)) + }) + }) + + // HS-U12: Priority allocation + describe('priority', () => { + it('high priority gets 60% allocation', () => { + // With full bucket, high priority should succeed + const result = tryAcquire('conn-pri', 'high', 'oauth') + expect(result.allowed).toBe(true) + }) + + it('low priority may be blocked when tokens are scarce', () => { + // Drain most of the bucket + for (let i = 0; i < 54; i++) { + tryAcquire('conn-low', 'high', 'oauth') + } + + // After draining, tokens should be significantly reduced + const stats = getStats('conn-low') + expect(stats?.tokens).toBeLessThan(stats!.maxTokens * 0.5) + }) + }) + + // parseRateLimitHeaders + describe('parseRateLimitHeaders', () => { + it('parses standard headers', () => { + const response = new Response('', { + headers: { + 'x-hubspot-ratelimit-daily-remaining': '5000', + 'x-hubspot-ratelimit-daily': '250000', + 'x-hubspot-ratelimit-interval-milliseconds': '10000', + }, + }) + + const headers = parseRateLimitHeaders(response) + expect(headers.remaining).toBe(5000) + expect(headers.limit).toBe(250000) + expect(headers.reset).toBe(10) + expect(headers.retryAfter).toBeUndefined() + }) + + it('parses retry-after on 429', () => { + const response = new Response('', { + status: 429, + headers: { + 'retry-after': '15', + }, + }) + + const headers = parseRateLimitHeaders(response) + expect(headers.retryAfter).toBe(15) + }) + }) + + // reset + describe('reset', () => { + it('removes bucket for a connection', () => { + tryAcquire('conn-reset', 'normal', 'oauth') + expect(getStats('conn-reset')).not.toBeNull() + + reset('conn-reset') + expect(getStats('conn-reset')).toBeNull() + }) + + it('resetAll clears all buckets', () => { + tryAcquire('conn-a', 'normal', 'oauth') + tryAcquire('conn-b', 'normal', 'oauth') + + resetAll() + + expect(getStats('conn-a')).toBeNull() + expect(getStats('conn-b')).toBeNull() + }) + }) +}) diff --git a/src/lib/integrations/hubspot/rate-limiter.ts b/src/lib/integrations/hubspot/rate-limiter.ts new file mode 100644 index 00000000..7db7dff2 --- /dev/null +++ b/src/lib/integrations/hubspot/rate-limiter.ts @@ -0,0 +1,275 @@ +/** + * HubSpot Rate Limiter + * + * Token bucket algorithm per connection with priority queue support. + * Conservative: operates at 50% of HubSpot's published capacity. + * + * HubSpot limits: + * - OAuth: 110 requests per 10 seconds + * - Private App (free): 100 requests per 10 seconds + * - 429 responses include Retry-After header + * + * Priority levels: + * - high (60%): user-initiated actions (UI clicks, searches) + * - normal (30%): scheduled sync operations + * - low (10%): backfill and background tasks + */ + +import type { RateLimitPriority, RateLimitHeaders } from './types' +import { RATE_LIMITS } from './config' +import { createModuleLogger } from '@/lib/utils/logger' + +const log = createModuleLogger('[HubSpot Rate Limiter]') + +// ============================================ +// Token Bucket +// ============================================ + +interface TokenBucket { + tokens: number + maxTokens: number + refillRate: number // tokens per second + lastRefill: number // timestamp ms + paused: boolean + pausedUntil: number // timestamp ms +} + +/** Priority allocation percentages */ +const PRIORITY_ALLOCATION: Record = { + high: 0.6, + normal: 0.3, + low: 0.1, +} + +/** Active token buckets per connection ID */ +const buckets = new Map() + +/** + * Get or create a token bucket for a connection. + * + * @param connectionId - Unique connection identifier + * @param authType - 'oauth' or 'private_app' (determines capacity) + * @returns The connection's token bucket + */ +function getBucket(connectionId: string, authType: 'oauth' | 'private_app' = 'oauth'): TokenBucket { + let bucket = buckets.get(connectionId) + + if (!bucket) { + const maxTokens = + authType === 'oauth' + ? RATE_LIMITS.OAUTH_TOKENS_PER_10S + : RATE_LIMITS.PRIVATE_APP_TOKENS_PER_10S + + bucket = { + tokens: maxTokens, + maxTokens, + refillRate: maxTokens / 10, // tokens per second (capacity per 10s / 10) + lastRefill: Date.now(), + paused: false, + pausedUntil: 0, + } + buckets.set(connectionId, bucket) + } + + return bucket +} + +/** + * Refill tokens based on elapsed time. + */ +function refillTokens(bucket: TokenBucket): void { + const now = Date.now() + const elapsed = (now - bucket.lastRefill) / 1000 // seconds + const tokensToAdd = elapsed * bucket.refillRate + + bucket.tokens = Math.min(bucket.maxTokens, bucket.tokens + tokensToAdd) + bucket.lastRefill = now +} + +/** + * Check if a request can proceed, consuming a token if allowed. + * + * @param connectionId - Connection identifier + * @param priority - Request priority level + * @param authType - Auth type for capacity calculation + * @returns Object with allowed flag and wait time in ms if not allowed + */ +export function tryAcquire( + connectionId: string, + priority: RateLimitPriority = 'normal', + authType: 'oauth' | 'private_app' = 'oauth' +): { allowed: boolean; waitMs: number } { + const bucket = getBucket(connectionId, authType) + + // Check if paused (429 recovery) + if (bucket.paused && Date.now() < bucket.pausedUntil) { + return { + allowed: false, + waitMs: bucket.pausedUntil - Date.now(), + } + } + + // Unpause if time has passed + if (bucket.paused && Date.now() >= bucket.pausedUntil) { + bucket.paused = false + // Gradual recovery: start with 25% capacity + bucket.tokens = Math.floor(bucket.maxTokens * 0.25) + log.info(`Rate limiter recovered for connection ${connectionId}`) + } + + // Refill tokens + refillTokens(bucket) + + // Check priority allocation + const allocatedTokens = bucket.maxTokens * PRIORITY_ALLOCATION[priority] + const minimumRequired = Math.max(1, Math.floor(allocatedTokens * 0.1)) + + if (bucket.tokens < minimumRequired) { + // Not enough tokens for this priority level + const waitMs = Math.ceil((minimumRequired - bucket.tokens) / bucket.refillRate * 1000) + return { allowed: false, waitMs } + } + + // Consume a token + bucket.tokens -= 1 + return { allowed: true, waitMs: 0 } +} + +/** + * Wait for a token to become available, then acquire it. + * Respects priority levels and paused state. + * + * @param connectionId - Connection identifier + * @param priority - Request priority level + * @param authType - Auth type for capacity calculation + * @param maxWaitMs - Maximum time to wait (default: 30s) + * @returns Promise that resolves when a token is acquired + * @throws Error if max wait time exceeded + */ +export async function acquire( + connectionId: string, + priority: RateLimitPriority = 'normal', + authType: 'oauth' | 'private_app' = 'oauth', + maxWaitMs: number = 30_000 +): Promise { + const startTime = Date.now() + + while (true) { + const result = tryAcquire(connectionId, priority, authType) + + if (result.allowed) { + return + } + + // Check if we'd exceed max wait + if (Date.now() - startTime + result.waitMs > maxWaitMs) { + throw new Error( + `Rate limit: waited ${Date.now() - startTime}ms, need ${result.waitMs}ms more (max: ${maxWaitMs}ms)` + ) + } + + // Wait and retry + await new Promise((resolve) => setTimeout(resolve, Math.min(result.waitMs, 1000))) + } +} + +/** + * Handle a 429 response from HubSpot. + * Pauses all requests for this connection until Retry-After expires. + * + * @param connectionId - Connection identifier + * @param retryAfterSeconds - Retry-After header value (seconds) + */ +export function handleRateLimitResponse( + connectionId: string, + retryAfterSeconds: number +): void { + const bucket = getBucket(connectionId) + bucket.paused = true + bucket.pausedUntil = Date.now() + retryAfterSeconds * 1000 + bucket.tokens = 0 + + log.warn( + `Rate limited on connection ${connectionId}, pausing for ${retryAfterSeconds}s` + ) +} + +/** + * Update rate limit state based on HubSpot response headers. + * Dynamically adjusts capacity based on observed limits. + * + * @param connectionId - Connection identifier + * @param headers - Parsed rate limit headers from HubSpot response + */ +export function updateFromHeaders( + connectionId: string, + headers: RateLimitHeaders +): void { + const bucket = getBucket(connectionId) + + // If HubSpot tells us we have very few remaining requests, slow down + if (headers.remaining < 5) { + log.warn( + `Low remaining requests (${headers.remaining}/${headers.limit}) for connection ${connectionId}` + ) + // Reduce available tokens to match remaining + bucket.tokens = Math.min(bucket.tokens, headers.remaining) + } + + // Handle 429 Retry-After + if (headers.retryAfter !== undefined) { + handleRateLimitResponse(connectionId, headers.retryAfter) + } +} + +/** + * Parse rate limit headers from a HubSpot API response. + * + * @param response - Fetch Response object + * @returns Parsed rate limit headers + */ +export function parseRateLimitHeaders(response: Response): RateLimitHeaders { + return { + remaining: parseInt(response.headers.get('x-hubspot-ratelimit-daily-remaining') || '1000', 10), + limit: parseInt(response.headers.get('x-hubspot-ratelimit-daily') || '250000', 10), + reset: parseInt(response.headers.get('x-hubspot-ratelimit-interval-milliseconds') || '10000', 10) / 1000, + retryAfter: response.status === 429 + ? parseInt(response.headers.get('retry-after') || '10', 10) + : undefined, + } +} + +/** + * Get current rate limiter stats for a connection (for monitoring/debugging). + */ +export function getStats(connectionId: string): { + tokens: number + maxTokens: number + paused: boolean + pausedUntil: number +} | null { + const bucket = buckets.get(connectionId) + if (!bucket) return null + + refillTokens(bucket) + return { + tokens: Math.floor(bucket.tokens), + maxTokens: bucket.maxTokens, + paused: bucket.paused, + pausedUntil: bucket.pausedUntil, + } +} + +/** + * Reset rate limiter for a connection (useful for testing or reconnection). + */ +export function reset(connectionId: string): void { + buckets.delete(connectionId) +} + +/** + * Reset all rate limiters (useful for testing). + */ +export function resetAll(): void { + buckets.clear() +} diff --git a/src/lib/integrations/hubspot/types.ts b/src/lib/integrations/hubspot/types.ts new file mode 100644 index 00000000..2cc4b5d1 --- /dev/null +++ b/src/lib/integrations/hubspot/types.ts @@ -0,0 +1,411 @@ +/** + * HubSpot Integration Types + * + * Type definitions for HubSpot CRM API responses, connection configuration, + * sync state, and rate limiting. + */ + +// ============================================ +// HubSpot Object Types +// ============================================ + +export type HubSpotObjectType = + | 'contacts' + | 'companies' + | 'deals' + | 'tickets' + | 'line_items' + | 'products' + | 'quotes' + +/** Standard HubSpot CRM object types that most accounts have */ +export const STANDARD_OBJECT_TYPES: HubSpotObjectType[] = [ + 'contacts', + 'companies', + 'deals', + 'tickets', +] + +// ============================================ +// HubSpot API Response Types +// ============================================ + +export interface HubSpotProperty { + [key: string]: string | number | boolean | null +} + +export interface HubSpotRecord { + id: string + properties: HubSpotProperty + createdAt: string + updatedAt: string + archived: boolean +} + +export interface HubSpotContact extends HubSpotRecord { + properties: HubSpotProperty & { + email?: string + firstname?: string + lastname?: string + phone?: string + company?: string + jobtitle?: string + lifecyclestage?: string + lastmodifieddate?: string + createdate?: string + } +} + +export interface HubSpotCompany extends HubSpotRecord { + properties: HubSpotProperty & { + name?: string + domain?: string + industry?: string + numberofemployees?: string + annualrevenue?: string + city?: string + state?: string + country?: string + lastmodifieddate?: string + createdate?: string + } +} + +export interface HubSpotDeal extends HubSpotRecord { + properties: HubSpotProperty & { + dealname?: string + dealstage?: string + pipeline?: string + amount?: string + closedate?: string + hubspot_owner_id?: string + lastmodifieddate?: string + createdate?: string + } +} + +export interface HubSpotTicket extends HubSpotRecord { + properties: HubSpotProperty & { + subject?: string + content?: string + hs_pipeline?: string + hs_pipeline_stage?: string + hs_ticket_priority?: string + lastmodifieddate?: string + createdate?: string + } +} + +export interface HubSpotCustomObject extends HubSpotRecord { + objectTypeId: string +} + +// ============================================ +// HubSpot Pagination +// ============================================ + +export interface HubSpotPaging { + next?: { + after: string + link?: string + } +} + +export interface HubSpotListResponse { + results: T[] + paging?: HubSpotPaging +} + +// ============================================ +// HubSpot Search Types +// ============================================ + +export interface HubSpotFilter { + propertyName: string + operator: + | 'EQ' + | 'NEQ' + | 'LT' + | 'LTE' + | 'GT' + | 'GTE' + | 'HAS_PROPERTY' + | 'NOT_HAS_PROPERTY' + | 'CONTAINS_TOKEN' + | 'NOT_CONTAINS_TOKEN' + | 'IN' + | 'NOT_IN' + value?: string + values?: string[] + highValue?: string +} + +export interface HubSpotFilterGroup { + filters: HubSpotFilter[] +} + +export interface HubSpotSort { + propertyName: string + direction: 'ASCENDING' | 'DESCENDING' +} + +export interface HubSpotSearchOptions { + filterGroups?: HubSpotFilterGroup[] + sorts?: HubSpotSort[] + query?: string + properties?: string[] + limit?: number + after?: string +} + +export interface HubSpotSearchResponse { + total: number + results: T[] + paging?: HubSpotPaging +} + +// ============================================ +// HubSpot Association Types +// ============================================ + +export interface HubSpotAssociation { + id: string + type: string +} + +export interface HubSpotAssociationResult { + from: { id: string } + to: HubSpotAssociation[] + paging?: HubSpotPaging +} + +export interface HubSpotBatchAssociationResponse { + results: HubSpotAssociationResult[] +} + +// ============================================ +// HubSpot Account Info +// ============================================ + +export interface HubSpotAccountInfo { + portalId: number + accountType: string + timeZone: string + companyCurrency: string + additionalCurrencies: string[] + utcOffset: string + utcOffsetMilliseconds: number + uiDomain: string + dataHostingLocation: string +} + +// ============================================ +// HubSpot Owner +// ============================================ + +export interface HubSpotOwner { + id: string + email: string + firstName: string + lastName: string + userId: number + createdAt: string + updatedAt: string + archived: boolean +} + +// ============================================ +// HubSpot Property Definition +// ============================================ + +export interface HubSpotPropertyDefinition { + name: string + label: string + type: string + fieldType: string + description: string + groupName: string + options: Array<{ + label: string + value: string + description?: string + displayOrder: number + hidden: boolean + }> + displayOrder: number + hasUniqueValue: boolean + hidden: boolean + formField: boolean + calculated: boolean +} + +// ============================================ +// HubSpot Object Schema +// ============================================ + +export interface HubSpotObjectSchema { + id: string + name: string + labels: { + singular: string + plural: string + } + requiredProperties: string[] + searchableProperties: string[] + primaryDisplayProperty: string + secondaryDisplayProperties: string[] + archived: boolean + properties: HubSpotPropertyDefinition[] +} + +// ============================================ +// Connection Types +// ============================================ + +export type HubSpotAuthType = 'oauth' | 'private_app' + +export interface HubSpotConnectionConfig { + /** Enabled object types for syncing */ + enabled_objects?: HubSpotObjectType[] + /** Sync interval in minutes (default: 15) */ + sync_interval_minutes?: number + /** Custom property mappings */ + property_mappings?: Record +} + +export interface HubSpotConnection { + id: string + workspace_id: string + name: string + auth_type: HubSpotAuthType + hub_id: string | null + hub_domain: string | null + account_name: string | null + scopes: string[] | null + config_json: HubSpotConnectionConfig + is_primary: boolean + status: 'pending' | 'connected' | 'error' | 'disconnected' | 'expired' + last_validated_at: string | null + last_error: string | null + is_active: boolean + created_at: string + updated_at: string +} + +// ============================================ +// OAuth Types +// ============================================ + +export interface HubSpotOAuthTokens { + access_token: string + refresh_token: string + expires_in: number + token_type: string +} + +// ============================================ +// Sync Types +// ============================================ + +export type HubSpotSyncStatus = 'idle' | 'syncing' | 'error' | 'backfilling' + +export interface HubSpotSyncState { + id: string + connection_id: string + object_type: string + last_sync_at: string | null + last_sync_cursor: string | null + last_modified_at: string | null + records_synced: number + total_records: number | null + sync_lock_id: string | null + sync_lock_expires_at: string | null + status: HubSpotSyncStatus + last_error: string | null +} + +// ============================================ +// Rate Limit Types +// ============================================ + +export type RateLimitPriority = 'high' | 'normal' | 'low' + +export interface RateLimitConfig { + /** Max tokens in the bucket */ + maxTokens: number + /** Tokens refilled per second */ + refillRate: number + /** Current tokens available */ + currentTokens: number + /** Last refill timestamp */ + lastRefill: number +} + +export interface RateLimitHeaders { + /** Remaining requests in the current window */ + remaining: number + /** Total requests allowed in the window */ + limit: number + /** Seconds until the limit resets */ + reset: number + /** Retry-After value from 429 responses */ + retryAfter?: number +} + +// ============================================ +// Engagement Types +// ============================================ + +export interface HubSpotEngagement { + id: string + properties: HubSpotProperty & { + hs_timestamp?: string + hubspot_owner_id?: string + hs_engagement_type?: string + } + createdAt: string + updatedAt: string + archived: boolean +} + +// ============================================ +// Error Types +// ============================================ + +export class HubSpotError extends Error { + constructor(message: string) { + super(message) + this.name = 'HubSpotError' + } +} + +export class HubSpotAuthError extends HubSpotError { + constructor(message: string) { + super(message) + this.name = 'HubSpotAuthError' + } +} + +export class HubSpotRateLimitError extends HubSpotError { + retryAfter: number + + constructor(message: string, retryAfter: number = 10) { + super(message) + this.name = 'HubSpotRateLimitError' + this.retryAfter = retryAfter + } +} + +export class HubSpotNotFoundError extends HubSpotError { + constructor(message: string) { + super(message) + this.name = 'HubSpotNotFoundError' + } +} + +export class HubSpotValidationError extends HubSpotError { + constructor(message: string) { + super(message) + this.name = 'HubSpotValidationError' + } +} diff --git a/src/lib/integrations/postgres/__tests__/connection-string.test.ts b/src/lib/integrations/postgres/__tests__/connection-string.test.ts new file mode 100644 index 00000000..42458e00 --- /dev/null +++ b/src/lib/integrations/postgres/__tests__/connection-string.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest' +import { parseConnectionString, buildConnectionConfig } from '../connection-string' +import type { DataSourceRecord } from '../types' + +describe('parseConnectionString', () => { + it('parses a standard connection string', () => { + const result = parseConnectionString( + 'postgresql://myuser:mypass@db.example.com:5432/mydb' + ) + expect(result).toEqual({ + host: 'db.example.com', + port: 5432, + database: 'mydb', + user: 'myuser', + password: 'mypass', + sslMode: undefined, + }) + }) + + it('parses postgres:// scheme (alias for postgresql://)', () => { + const result = parseConnectionString( + 'postgres://user:pass@host.com:5433/testdb' + ) + expect(result).toEqual({ + host: 'host.com', + port: 5433, + database: 'testdb', + user: 'user', + password: 'pass', + sslMode: undefined, + }) + }) + + it('handles percent-encoded password with special characters', () => { + // Password is "p@ss:w0rd/foo" → encoded as "p%40ss%3Aw0rd%2Ffoo" + const result = parseConnectionString( + 'postgresql://user:p%40ss%3Aw0rd%2Ffoo@host.com:5432/db' + ) + expect(result.password).toBe('p@ss:w0rd/foo') + }) + + it('handles password with # character (encoded)', () => { + const result = parseConnectionString( + 'postgresql://user:pass%23word@host.com:5432/db' + ) + expect(result.password).toBe('pass#word') + }) + + it('defaults port to undefined when not specified', () => { + const result = parseConnectionString( + 'postgresql://user:pass@host.com/db' + ) + expect(result.port).toBeUndefined() + }) + + it('extracts sslmode from query parameters', () => { + const result = parseConnectionString( + 'postgresql://user:pass@host.com:5432/db?sslmode=require' + ) + expect(result.sslMode).toBe('require') + }) + + it('extracts sslmode=disable', () => { + const result = parseConnectionString( + 'postgresql://user:pass@host.com:5432/db?sslmode=disable' + ) + expect(result.sslMode).toBe('disable') + }) + + it('handles connection string without password', () => { + const result = parseConnectionString( + 'postgresql://user@host.com:5432/db' + ) + expect(result.user).toBe('user') + expect(result.password).toBeUndefined() + }) + + it('handles connection string without user', () => { + const result = parseConnectionString( + 'postgresql://host.com:5432/db' + ) + expect(result.host).toBe('host.com') + expect(result.user).toBeUndefined() + expect(result.password).toBeUndefined() + }) + + it('handles empty database name (path is "/")', () => { + const result = parseConnectionString( + 'postgresql://user:pass@host.com:5432/' + ) + expect(result.database).toBeUndefined() + }) + + it('handles IPv6 host', () => { + const result = parseConnectionString( + 'postgresql://user:pass@[::1]:5432/db' + ) + expect(result.host).toBe('::1') + }) + + it('handles Supabase-style connection strings', () => { + const result = parseConnectionString( + 'postgresql://postgres.abcdefghij:MyPassword123@aws-0-us-east-1.pooler.supabase.com:6543/postgres' + ) + expect(result.host).toBe('aws-0-us-east-1.pooler.supabase.com') + expect(result.port).toBe(6543) + expect(result.user).toBe('postgres.abcdefghij') + expect(result.password).toBe('MyPassword123') + expect(result.database).toBe('postgres') + }) + + it('handles Render-style connection strings', () => { + const result = parseConnectionString( + 'postgresql://myuser:AbCdEf123456@dpg-abc123-a.oregon-postgres.render.com:5432/mydb_1234' + ) + expect(result.host).toBe('dpg-abc123-a.oregon-postgres.render.com') + expect(result.database).toBe('mydb_1234') + }) + + it('throws on invalid connection string', () => { + expect(() => parseConnectionString('not-a-url')).toThrow() + expect(() => parseConnectionString('')).toThrow() + }) + + it('throws on non-postgres scheme', () => { + expect(() => + parseConnectionString('mysql://user:pass@host:3306/db') + ).toThrow() + }) + + it('handles multiple query parameters', () => { + const result = parseConnectionString( + 'postgresql://user:pass@host.com:5432/db?sslmode=verify-full&connect_timeout=10' + ) + expect(result.sslMode).toBe('verify-full') + // Other query params are ignored (we only extract sslmode) + }) +}) + +describe('buildConnectionConfig', () => { + const baseRecord: DataSourceRecord = { + id: '00000000-0000-0000-0000-000000000001', + workspace_id: '00000000-0000-0000-0000-000000000002', + source_type: 'postgres', + name: 'Test DB', + host: 'db.example.com', + port: 5432, + database_name: 'mydb', + username: 'myuser', + password_encrypted: 'encrypted-value', + ssl_mode: 'require', + config_json: {}, + status: 'connected', + last_validated_at: null, + last_error: null, + is_active: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + } + + it('builds config from a data source record with decrypted password', () => { + const config = buildConnectionConfig(baseRecord, 'decrypted-password') + expect(config).toEqual({ + host: 'db.example.com', + port: 5432, + database: 'mydb', + user: 'myuser', + password: 'decrypted-password', + ssl: 'require', + }) + }) + + it('maps ssl_mode=disable to ssl=false', () => { + const config = buildConnectionConfig( + { ...baseRecord, ssl_mode: 'disable' }, + 'pass' + ) + expect(config.ssl).toBe(false) + }) + + it('maps ssl_mode=prefer to ssl=prefer', () => { + const config = buildConnectionConfig( + { ...baseRecord, ssl_mode: 'prefer' }, + 'pass' + ) + expect(config.ssl).toBe('prefer') + }) + + it('maps ssl_mode=verify-full to ssl=verify-full', () => { + const config = buildConnectionConfig( + { ...baseRecord, ssl_mode: 'verify-full' }, + 'pass' + ) + expect(config.ssl).toBe('verify-full') + }) +}) diff --git a/src/lib/integrations/postgres/__tests__/security.test.ts b/src/lib/integrations/postgres/__tests__/security.test.ts new file mode 100644 index 00000000..23be8011 --- /dev/null +++ b/src/lib/integrations/postgres/__tests__/security.test.ts @@ -0,0 +1,472 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Hoist dns mock to module scope so it's applied before security.ts imports dns +const mockResolve4 = vi.fn() +const mockResolve6 = vi.fn() + +vi.mock('dns', () => ({ + default: { + promises: { + resolve4: (...args: unknown[]) => mockResolve4(...args), + resolve6: (...args: unknown[]) => mockResolve6(...args), + }, + }, + promises: { + resolve4: (...args: unknown[]) => mockResolve4(...args), + resolve6: (...args: unknown[]) => mockResolve6(...args), + }, +})) + +import { validatePostgresHost, PostgresQueryValidator } from '../security' + +// ── SSRF Host Validation ────────────────────────────────── + +describe('validatePostgresHost', () => { + beforeEach(() => { + mockResolve4.mockReset() + mockResolve6.mockReset() + }) + + it('blocks localhost hostname', async () => { + const result = await validatePostgresHost('localhost') + expect(result).toContain('blocked') + }) + + it('blocks 127.0.0.1', async () => { + const result = await validatePostgresHost('127.0.0.1') + expect(result).toContain('blocked') + }) + + it('blocks 10.x.x.x', async () => { + const result = await validatePostgresHost('10.0.0.1') + expect(result).toContain('blocked') + }) + + it('blocks 172.16.x.x', async () => { + const result = await validatePostgresHost('172.16.0.1') + expect(result).toContain('blocked') + }) + + it('blocks 192.168.x.x', async () => { + const result = await validatePostgresHost('192.168.1.1') + expect(result).toContain('blocked') + }) + + it('blocks 169.254.169.254 (metadata endpoint)', async () => { + const result = await validatePostgresHost('169.254.169.254') + expect(result).toContain('blocked') + }) + + it('blocks metadata.google.internal', async () => { + const result = await validatePostgresHost('metadata.google.internal') + expect(result).toContain('blocked') + }) + + it('blocks .local hostnames', async () => { + const result = await validatePostgresHost('mydb.local') + expect(result).toContain('blocked') + }) + + it('blocks .internal hostnames', async () => { + const result = await validatePostgresHost('db.internal') + expect(result).toContain('blocked') + }) + + it('blocks 0.0.0.0', async () => { + const result = await validatePostgresHost('0.0.0.0') + expect(result).toContain('blocked') + }) + + it('blocks IPv6 loopback ::1', async () => { + const result = await validatePostgresHost('::1') + expect(result).toContain('blocked') + }) + + it('blocks empty hostname', async () => { + const result = await validatePostgresHost('') + expect(result).toContain('empty') + }) + + it('allows public hostname that resolves to public IP', async () => { + mockResolve4.mockResolvedValue(['54.23.100.5']) + mockResolve6.mockRejectedValue(new Error('no AAAA')) + const result = await validatePostgresHost('db.example.com') + expect(result).toBeNull() + }) + + it('blocks hostname that resolves to private IP (DNS rebinding)', async () => { + mockResolve4.mockResolvedValue(['10.0.0.5']) + mockResolve6.mockRejectedValue(new Error('no AAAA')) + const result = await validatePostgresHost('evil.example.com') + expect(result).toContain('blocked') + }) + + it('blocks hostname that resolves to 127.x.x.x', async () => { + mockResolve4.mockResolvedValue(['127.0.0.1']) + mockResolve6.mockRejectedValue(new Error('no AAAA')) + const result = await validatePostgresHost('sneaky.example.com') + expect(result).toContain('blocked') + }) + + it('blocks if any resolved IP is private (mixed resolution)', async () => { + mockResolve4.mockResolvedValue(['54.23.100.5', '10.0.0.1']) + mockResolve6.mockRejectedValue(new Error('no AAAA')) + const result = await validatePostgresHost('mixed.example.com') + expect(result).toContain('blocked') + }) + + it('allows if DNS resolution fails (strict mode: block)', async () => { + mockResolve4.mockRejectedValue(new Error('ENOTFOUND')) + mockResolve6.mockRejectedValue(new Error('ENOTFOUND')) + const result = await validatePostgresHost('nonexistent.example.com') + // Cannot resolve = cannot validate = block for safety + expect(result).toContain('resolve') + }) + + it('skips DNS resolution for direct IP addresses', async () => { + // A public IP should pass without DNS resolution + const result = await validatePostgresHost('54.23.100.5') + expect(result).toBeNull() + expect(mockResolve4).not.toHaveBeenCalled() + }) +}) + +// ── SQL Query Validator ─────────────────────────────────── + +describe('PostgresQueryValidator', () => { + const validator = new PostgresQueryValidator() + + // ── Allowed queries ── + + describe('allows valid queries', () => { + it('allows simple SELECT', () => { + expect(() => validator.validate('SELECT * FROM users')).not.toThrow() + }) + + it('allows SELECT with WHERE', () => { + expect(() => + validator.validate('SELECT id, name FROM users WHERE id = 1') + ).not.toThrow() + }) + + it('allows CTE (WITH clause)', () => { + expect(() => + validator.validate( + 'WITH active AS (SELECT * FROM users WHERE active = true) SELECT * FROM active' + ) + ).not.toThrow() + }) + + it('allows EXPLAIN', () => { + expect(() => + validator.validate('EXPLAIN SELECT * FROM users') + ).not.toThrow() + }) + + it('allows EXPLAIN (FORMAT JSON)', () => { + expect(() => + validator.validate('EXPLAIN (FORMAT JSON) SELECT * FROM users') + ).not.toThrow() + }) + + it('allows EXPLAIN ANALYZE', () => { + expect(() => + validator.validate('EXPLAIN ANALYZE SELECT * FROM users') + ).not.toThrow() + }) + + it('allows subqueries', () => { + expect(() => + validator.validate( + 'SELECT * FROM users WHERE id IN (SELECT user_id FROM orders)' + ) + ).not.toThrow() + }) + + it('allows aggregate functions', () => { + expect(() => + validator.validate( + 'SELECT count(*), avg(price) FROM orders GROUP BY status' + ) + ).not.toThrow() + }) + + it('allows information_schema queries', () => { + expect(() => + validator.validate( + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'" + ) + ).not.toThrow() + }) + + it('allows window functions', () => { + expect(() => + validator.validate( + 'SELECT id, row_number() OVER (PARTITION BY status ORDER BY created_at) FROM orders' + ) + ).not.toThrow() + }) + + it('allows CASE expressions', () => { + expect(() => + validator.validate( + "SELECT CASE WHEN status = 'active' THEN 1 ELSE 0 END FROM users" + ) + ).not.toThrow() + }) + + it('allows trailing semicolon', () => { + expect(() => + validator.validate('SELECT * FROM users;') + ).not.toThrow() + }) + }) + + // ── Blocked: DML writes ── + + describe('blocks DML write operations', () => { + it('blocks INSERT', () => { + expect(() => + validator.validate("INSERT INTO users (name) VALUES ('test')") + ).toThrow() + }) + + it('blocks UPDATE', () => { + expect(() => + validator.validate("UPDATE users SET name = 'test' WHERE id = 1") + ).toThrow() + }) + + it('blocks DELETE', () => { + expect(() => + validator.validate('DELETE FROM users WHERE id = 1') + ).toThrow() + }) + + it('blocks TRUNCATE', () => { + expect(() => validator.validate('TRUNCATE users')).toThrow() + }) + }) + + // ── Blocked: DDL operations ── + + describe('blocks DDL operations', () => { + it('blocks CREATE TABLE', () => { + expect(() => + validator.validate('CREATE TABLE evil (id int)') + ).toThrow() + }) + + it('blocks ALTER TABLE', () => { + expect(() => + validator.validate('ALTER TABLE users ADD COLUMN evil text') + ).toThrow() + }) + + it('blocks DROP TABLE', () => { + expect(() => validator.validate('DROP TABLE users')).toThrow() + }) + + it('blocks DROP DATABASE', () => { + expect(() => validator.validate('DROP DATABASE mydb')).toThrow() + }) + }) + + // ── Blocked: Postgres-specific dangerous commands ── + + describe('blocks Postgres-specific dangerous commands', () => { + it('blocks COPY', () => { + expect(() => + validator.validate("COPY users TO '/tmp/evil.csv'") + ).toThrow() + }) + + it('blocks DO (anonymous code block)', () => { + expect(() => + validator.validate("DO $$ BEGIN RAISE NOTICE 'hello'; END $$") + ).toThrow() + }) + + it('blocks LISTEN', () => { + expect(() => validator.validate('LISTEN my_channel')).toThrow() + }) + + it('blocks NOTIFY', () => { + expect(() => + validator.validate("NOTIFY my_channel, 'payload'") + ).toThrow() + }) + + it('blocks GRANT', () => { + expect(() => + validator.validate('GRANT ALL ON users TO evil_role') + ).toThrow() + }) + + it('blocks REVOKE', () => { + expect(() => + validator.validate('REVOKE ALL ON users FROM evil_role') + ).toThrow() + }) + + it('blocks SET', () => { + expect(() => + validator.validate('SET default_transaction_read_only = off') + ).toThrow() + }) + + it('blocks VACUUM', () => { + expect(() => validator.validate('VACUUM users')).toThrow() + }) + + it('blocks REINDEX', () => { + expect(() => validator.validate('REINDEX TABLE users')).toThrow() + }) + + it('blocks CLUSTER', () => { + expect(() => validator.validate('CLUSTER users')).toThrow() + }) + }) + + // ── Verify dangerous keywords ARE caught when embedded in SELECT ── + + describe('blocks dangerous keywords embedded in valid-looking queries', () => { + it('blocks SELECT into subquery with INSERT', () => { + expect(() => + validator.validate( + "SELECT * FROM users; INSERT INTO evil VALUES (1)" + ) + ).toThrow() + }) + + it('blocks dangerous keywords in CTE', () => { + expect(() => + validator.validate( + "WITH del AS (DELETE FROM users RETURNING *) SELECT * FROM del" + ) + ).toThrow(/dangerous/) + }) + + it('blocks UPDATE in CTE', () => { + expect(() => + validator.validate( + "WITH upd AS (UPDATE users SET name='x' RETURNING *) SELECT * FROM upd" + ) + ).toThrow(/dangerous/) + }) + }) + + // ── Blocked: Multi-statement injection ── + + describe('blocks multi-statement injection', () => { + it('blocks semicolon followed by another statement', () => { + expect(() => + validator.validate('SELECT 1; DROP TABLE users') + ).toThrow(/multiple/i) + }) + + it('blocks semicolon with whitespace between statements', () => { + expect(() => + validator.validate('SELECT 1; DELETE FROM users') + ).toThrow(/multiple/i) + }) + }) + + // ── Blocked: Query too long ── + + describe('blocks oversized queries', () => { + it('blocks queries exceeding 10,000 characters', () => { + const longQuery = 'SELECT ' + 'a'.repeat(10_001) + expect(() => validator.validate(longQuery)).toThrow(/length/) + }) + }) + + // ── Blocked: pg_ catalog access ── + + describe('blocks pg_ catalog access', () => { + it('blocks pg_catalog queries', () => { + expect(() => + validator.validate('SELECT * FROM pg_catalog.pg_tables') + ).toThrow(/pg_catalog/) + }) + + it('blocks pg_shadow (password hashes)', () => { + expect(() => + validator.validate('SELECT * FROM pg_shadow') + ).toThrow(/pg_/) + }) + + it('blocks pg_authid', () => { + expect(() => + validator.validate('SELECT * FROM pg_authid') + ).toThrow(/pg_/) + }) + + it('allows pg_ as a column or table name in quotes (false positive protection)', () => { + // "pg_count" as a column alias is fine — it's the TABLE reference we block + expect(() => + validator.validate('SELECT count(*) as pg_count FROM users') + ).not.toThrow() + }) + }) + + // ── Blocked: Comment-based bypass ── + + describe('blocks comment-based bypass attempts', () => { + it('blocks dangerous keywords hidden in comments then re-appearing', () => { + // After comment stripping, this becomes: SELECT 1; DROP TABLE users + expect(() => + validator.validate('SELECT 1; /* safe */ DROP TABLE users') + ).toThrow() + }) + + it('blocks single-line comment bypass', () => { + expect(() => + validator.validate('SELECT 1; -- safe\nDROP TABLE users') + ).toThrow() + }) + }) + + // ── Blocked: Unicode bypass ── + + describe('blocks unicode bypass attempts', () => { + it('normalizes fullwidth semicolon', () => { + expect(() => + validator.validate('SELECT 1\uFF1B DROP TABLE users') + ).toThrow() + }) + }) + + // ── Edge cases ── + + describe('edge cases', () => { + it('rejects empty query', () => { + expect(() => validator.validate('')).toThrow(/empty/) + }) + + it('rejects whitespace-only query', () => { + expect(() => validator.validate(' ')).toThrow(/empty/) + }) + + it('handles case-insensitive keywords', () => { + expect(() => validator.validate('select * from users')).not.toThrow() + expect(() => validator.validate('INSERT into users values (1)')).toThrow() + expect(() => validator.validate('DrOp TABLE users')).toThrow() + }) + + it('does not false-positive on column names containing keywords', () => { + // "updated_at" contains "update", "created" contains "create" + expect(() => + validator.validate( + 'SELECT updated_at, created_at, deleted FROM orders' + ) + ).not.toThrow() + }) + + it('does not false-positive on table names with "set" substring', () => { + expect(() => + validator.validate('SELECT * FROM settings WHERE key = 1') + ).not.toThrow() + }) + }) +}) diff --git a/src/lib/integrations/postgres/client.ts b/src/lib/integrations/postgres/client.ts new file mode 100644 index 00000000..789b8447 --- /dev/null +++ b/src/lib/integrations/postgres/client.ts @@ -0,0 +1,265 @@ +/** + * Postgres Client — Read-only connection wrapper + * + * Uses postgres.js (the `postgres` npm package) for serverless-friendly + * connections. Each call creates a fresh connection, executes, and disconnects. + * + * Security (defense in depth): + * 1. PostgresQueryValidator blocks non-SELECT/EXPLAIN queries + * 2. SET default_transaction_read_only = on at connection level + * 3. statement_timeout = 30s prevents runaway queries + * + * Concurrency: per-data-source semaphore (max 3 concurrent connections) + * SSRF: DNS resolution + IP validation before connecting + */ + +import postgres from 'postgres' +import type { + PostgresConnectionConfig, + PostgresQueryResult, + PostgresExplainResult, + PostgresSchemaInfo, + PostgresTableInfo, + PostgresColumnInfo, + PostgresTableStats, + DataSourceRecord, +} from './types' +import { buildConnectionConfig } from './connection-string' +import { validatePostgresHost, PostgresQueryValidator } from './security' +import { acquireConnectionSlot, releaseConnectionSlot } from './concurrency' +import { decrypt } from '@/lib/crypto/encryption' + +const queryValidator = new PostgresQueryValidator() + +// ── Connection Lifecycle ────────────────────────────────── + +/** + * Create a postgres.js connection from config. + * Sets read-only mode and timeouts immediately. + */ +function createConnection(config: PostgresConnectionConfig) { + const sslConfig = + config.ssl === false + ? false + : config.ssl === true || config.ssl === 'require' + ? { rejectUnauthorized: false } + : config.ssl === 'verify-full' || config.ssl === 'verify-ca' + ? { rejectUnauthorized: true } + : false + + return postgres({ + host: config.host, + port: config.port, + database: config.database, + username: config.user, + password: config.password, + ssl: sslConfig, + connect_timeout: 10, // 10 seconds connect timeout + idle_timeout: 5, // 5 seconds idle timeout + max: 1, // Single connection (no pool) + onnotice: () => {}, // Suppress NOTICE messages + }) +} + +/** + * Execute a function with a managed Postgres connection. + * + * Handles: SSRF validation → semaphore acquire → connect → + * set read-only + timeout → execute callback → close → release + * + * @param dataSource - The data source record from DB + * @param fn - Callback receiving the sql connection + * @returns Result of the callback + */ +export async function withPostgresConnection( + dataSource: DataSourceRecord, + fn: (sql: postgres.Sql) => Promise, +): Promise { + // Step 1: SSRF validation (includes DNS resolution) + const ssrfError = await validatePostgresHost(dataSource.host) + if (ssrfError) { + throw new Error(`SSRF blocked: ${ssrfError}`) + } + + // Step 2: Decrypt password + const password = await decrypt(dataSource.password_encrypted) + const config = buildConnectionConfig(dataSource, password) + + // Step 3: Acquire concurrency slot + await acquireConnectionSlot(dataSource.id) + + const sql = createConnection(config) + try { + // Step 4: Set read-only mode and timeouts + await sql.unsafe('SET default_transaction_read_only = on') + await sql.unsafe("SET statement_timeout = '30s'") + await sql.unsafe("SET idle_in_transaction_session_timeout = '5s'") + + // Step 5: Execute the callback + return await fn(sql) + } finally { + // Step 6: Always close and release + await sql.end({ timeout: 3 }).catch(() => {}) + releaseConnectionSlot(dataSource.id) + } +} + +// ── Public API Methods ──────────────────────────────────── + +/** + * Test the connection by running a simple query. + * Returns true if successful. + */ +export async function testConnection(dataSource: DataSourceRecord): Promise { + await withPostgresConnection(dataSource, async (sql) => { + await sql`SELECT 1 as ok` + }) + return true +} + +/** + * List all schemas in the database (excluding system schemas). + */ +export async function listSchemas( + dataSource: DataSourceRecord, +): Promise { + return withPostgresConnection(dataSource, async (sql) => { + const rows = await sql` + SELECT schema_name + FROM information_schema.schemata + WHERE schema_name NOT IN ( + 'pg_catalog', 'pg_toast', 'pg_temp_1', 'pg_toast_temp_1', + 'information_schema' + ) + ORDER BY schema_name + ` + return rows + }) +} + +/** + * List all tables in a schema. + */ +export async function listTables( + dataSource: DataSourceRecord, + schema = 'public', +): Promise { + return withPostgresConnection(dataSource, async (sql) => { + const rows = await sql` + SELECT + t.table_name, + t.table_schema, + t.table_type, + s.n_live_tup::int as estimated_row_count + FROM information_schema.tables t + LEFT JOIN pg_stat_user_tables s + ON s.schemaname = t.table_schema + AND s.relname = t.table_name + WHERE t.table_schema = ${schema} + AND t.table_type IN ('BASE TABLE', 'VIEW') + ORDER BY t.table_name + ` + return rows + }) +} + +/** + * List all columns for a table. + */ +export async function listColumns( + dataSource: DataSourceRecord, + table: string, + schema = 'public', +): Promise { + return withPostgresConnection(dataSource, async (sql) => { + const rows = await sql` + SELECT + column_name, + data_type, + (is_nullable = 'YES') as is_nullable, + column_default, + ordinal_position, + character_maximum_length + FROM information_schema.columns + WHERE table_schema = ${schema} + AND table_name = ${table} + ORDER BY ordinal_position + ` + return rows + }) +} + +/** + * Execute a read-only SQL query. + * Validates the query through PostgresQueryValidator before execution. + */ +export async function executeQuery( + dataSource: DataSourceRecord, + query: string, +): Promise { + // Validate query BEFORE connecting (fail fast) + queryValidator.validate(query) + + return withPostgresConnection(dataSource, async (sql) => { + const start = Date.now() + const result = await sql.unsafe(query) + const executionTimeMs = Date.now() - start + + // postgres.js returns an array-like object; extract column names from .columns + const columns = result.columns?.map((c: { name: string }) => c.name) ?? [] + + return { + columns, + rows: result.map((row: Record) => + columns.map((col: string) => row[col]), + ), + row_count: result.length, + execution_time_ms: executionTimeMs, + } + }) +} + +/** + * EXPLAIN a query without executing it. + * Returns the JSON query plan. + */ +export async function explainQuery( + dataSource: DataSourceRecord, + query: string, +): Promise { + // Validate the inner query + queryValidator.validate(query) + + return withPostgresConnection(dataSource, async (sql) => { + const start = Date.now() + const result = await sql.unsafe(`EXPLAIN (FORMAT JSON) ${query}`) + const executionTimeMs = Date.now() - start + + return { + plan: result[0]?.['QUERY PLAN'] ?? result, + execution_time_ms: executionTimeMs, + } + }) +} + +/** + * Get table statistics (row counts and sizes) for a schema. + */ +export async function getTableStats( + dataSource: DataSourceRecord, + schema = 'public', +): Promise { + return withPostgresConnection(dataSource, async (sql) => { + const rows = await sql` + SELECT + relname as table_name, + schemaname as table_schema, + n_live_tup::int as estimated_row_count, + pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname))::bigint as total_size_bytes + FROM pg_stat_user_tables + WHERE schemaname = ${schema} + ORDER BY n_live_tup DESC + ` + return rows + }) +} diff --git a/src/lib/integrations/postgres/concurrency.ts b/src/lib/integrations/postgres/concurrency.ts new file mode 100644 index 00000000..45b8b9d9 --- /dev/null +++ b/src/lib/integrations/postgres/concurrency.ts @@ -0,0 +1,99 @@ +/** + * Per-data-source concurrency limiter. + * + * Limits the number of simultaneous Postgres connections per data source + * to protect users' databases from connection exhaustion. Uses an in-memory + * semaphore pattern (similar to the existing rate limiter). + * + * Default: max 3 concurrent connections per data source. + */ + +const DEFAULT_MAX_CONCURRENT = 3 +const ACQUIRE_TIMEOUT_MS = 5_000 // Wait up to 5s for a slot + +interface SemaphoreState { + active: number + waiters: Array<{ + resolve: () => void + reject: (err: Error) => void + timer: ReturnType + }> +} + +const semaphores = new Map() + +function getOrCreateState(dataSourceId: string): SemaphoreState { + let state = semaphores.get(dataSourceId) + if (!state) { + state = { active: 0, waiters: [] } + semaphores.set(dataSourceId, state) + } + return state +} + +/** + * Acquire a connection slot for a data source. + * If the max is reached, waits up to ACQUIRE_TIMEOUT_MS for a slot. + * @throws Error if the timeout is reached + */ +export function acquireConnectionSlot( + dataSourceId: string, + maxConcurrent = DEFAULT_MAX_CONCURRENT, +): Promise { + const state = getOrCreateState(dataSourceId) + + if (state.active < maxConcurrent) { + state.active++ + return Promise.resolve() + } + + // Queue the request + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + // Remove this waiter on timeout + const idx = state.waiters.findIndex((w) => w.resolve === resolve) + if (idx >= 0) state.waiters.splice(idx, 1) + reject( + new Error( + `Connection pool exhausted for data source ${dataSourceId} — max ${maxConcurrent} concurrent connections`, + ), + ) + }, ACQUIRE_TIMEOUT_MS) + + state.waiters.push({ resolve, reject, timer }) + }) +} + +/** + * Release a connection slot for a data source. + * If there are queued waiters, the next one gets the slot. + */ +export function releaseConnectionSlot(dataSourceId: string): void { + const state = semaphores.get(dataSourceId) + if (!state) return + + if (state.waiters.length > 0) { + // Give the slot to the next waiter + const waiter = state.waiters.shift()! + clearTimeout(waiter.timer) + waiter.resolve() + } else { + state.active-- + if (state.active <= 0) { + // Clean up empty semaphores to prevent memory leaks + semaphores.delete(dataSourceId) + } + } +} + +/** + * Get current concurrency stats for a data source (for monitoring/debugging). + */ +export function getConnectionStats(dataSourceId: string): { + active: number + waiting: number +} { + const state = semaphores.get(dataSourceId) + if (!state) return { active: 0, waiting: 0 } + return { active: state.active, waiting: state.waiters.length } +} diff --git a/src/lib/integrations/postgres/connection-string.ts b/src/lib/integrations/postgres/connection-string.ts new file mode 100644 index 00000000..b318af14 --- /dev/null +++ b/src/lib/integrations/postgres/connection-string.ts @@ -0,0 +1,107 @@ +/** + * Connection String Parser & Config Builder + * + * Parses PostgreSQL connection strings (postgresql:// or postgres://) into + * individual fields for storage and display. Also builds connection configs + * from stored data source records for use with the postgres.js driver. + */ + +import type { DataSourceRecord, ParsedConnectionFields, PostgresConnectionConfig } from './types' + +/** + * Parse a PostgreSQL connection string into individual fields. + * + * Handles: + * - postgresql:// and postgres:// schemes + * - Percent-encoded passwords (e.g., special chars like @, :, /, #) + * - IPv6 hosts (e.g., [::1]) + * - sslmode query parameter + * - Missing optional fields (port, user, password, database) + * + * @throws Error if the string is not a valid postgresql:// or postgres:// URL + */ +export function parseConnectionString(connStr: string): ParsedConnectionFields { + if (!connStr || connStr.trim().length === 0) { + throw new Error('Connection string cannot be empty') + } + + // postgres.js and libpq both accept postgres:// and postgresql:// + // The URL constructor doesn't natively handle postgres://, so normalize to https:// + // for parsing, then validate the original scheme. + const trimmed = connStr.trim() + + if (!trimmed.startsWith('postgresql://') && !trimmed.startsWith('postgres://')) { + throw new Error('Connection string must start with postgresql:// or postgres://') + } + + // Replace scheme with https:// so URL constructor can parse it + const normalized = trimmed.replace(/^postgres(ql)?:\/\//, 'https://') + + let parsed: URL + try { + parsed = new URL(normalized) + } catch { + throw new Error('Invalid connection string format') + } + + // Extract fields + const host = parsed.hostname + ? parsed.hostname.replace(/^\[|\]$/g, '') // Strip IPv6 brackets + : undefined + + const port = parsed.port ? parseInt(parsed.port, 10) : undefined + + // Database is the pathname without leading "/" + const rawPath = parsed.pathname.slice(1) + const database = rawPath.length > 0 ? rawPath : undefined + + // URL constructor does NOT auto-decode username/password — decode explicitly + const user = parsed.username ? decodeURIComponent(parsed.username) : undefined + const password = parsed.password ? decodeURIComponent(parsed.password) : undefined + + // Extract sslmode from query parameters + const sslMode = parsed.searchParams.get('sslmode') ?? undefined + + return { + host, + port, + database, + user, + password, + sslMode, + } +} + +/** + * Build a PostgresConnectionConfig from a stored data source record + * and a decrypted password. Used when establishing actual connections. + */ +export function buildConnectionConfig( + record: DataSourceRecord, + decryptedPassword: string, +): PostgresConnectionConfig { + return { + host: record.host, + port: record.port, + database: record.database_name, + user: record.username, + password: decryptedPassword, + ssl: mapSslMode(record.ssl_mode), + } +} + +/** + * Map the stored ssl_mode string to the postgres.js ssl option. + * - 'disable' → false (no SSL) + * - 'require', 'prefer', 'verify-ca', 'verify-full' → passed through as string + */ +function mapSslMode( + sslMode: string, +): PostgresConnectionConfig['ssl'] { + if (sslMode === 'disable') return false + if (['require', 'prefer', 'verify-ca', 'verify-full'].includes(sslMode)) { + return sslMode as 'require' | 'prefer' | 'verify-ca' | 'verify-full' + } + // Default to require for unknown values + return 'require' +} diff --git a/src/lib/integrations/postgres/credentials.ts b/src/lib/integrations/postgres/credentials.ts new file mode 100644 index 00000000..f8422825 --- /dev/null +++ b/src/lib/integrations/postgres/credentials.ts @@ -0,0 +1,181 @@ +/** + * Data Source Credential Helpers + * + * CRUD operations on the `data_sources` table, with password masking + * for public-facing responses and admin variants for agent routes. + */ + +import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/admin' +import type { DataSourceRecord, DataSourcePublic } from './types' + +// ── Password Masking ────────────────────────────────────── + +function maskPassword(encrypted: string): string { + // Show just enough to identify which credential, never the actual value + if (encrypted.length <= 12) return '********' + return encrypted.slice(0, 4) + '****' + encrypted.slice(-4) +} + +function toPublic(record: DataSourceRecord): DataSourcePublic { + const { password_encrypted, ...rest } = record + return { + ...rest, + password_masked: maskPassword(password_encrypted), + } +} + +// ── User-facing (RLS-protected) ─────────────────────────── + +/** + * List all data sources for the current user's workspace. + * Passwords are masked — never exposed to the frontend. + */ +export async function listDataSources( + workspaceId: string, +): Promise { + const supabase = await createClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error } = await (supabase as any) + .from('data_sources') + .select('*') + .eq('workspace_id', workspaceId) + .order('created_at', { ascending: true }) + + if (error) throw new Error(`Failed to list data sources: ${error.message}`) + return (data as unknown as DataSourceRecord[]).map(toPublic) +} + +/** + * Get a single data source by ID (RLS-protected, password masked). + */ +export async function getDataSource( + workspaceId: string, + dataSourceId: string, +): Promise { + const supabase = await createClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error } = await (supabase as any) + .from('data_sources') + .select('*') + .eq('workspace_id', workspaceId) + .eq('id', dataSourceId) + .single() + + if (error) { + if (error.code === 'PGRST116') return null // not found + throw new Error(`Failed to get data source: ${error.message}`) + } + return toPublic(data as unknown as DataSourceRecord) +} + +// ── Admin (bypass RLS — for agent routes) ───────────────── + +/** + * Get a data source record by ID, bypassing RLS. + * Returns the full record including encrypted password for decryption. + * Used by agent routes where auth is via x-agent-secret, not user session. + */ +export async function getDataSourceAdmin( + workspaceId: string, + dataSourceId: string, +): Promise { + const admin = createAdminClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error } = await (admin as any) + .from('data_sources') + .select('*') + .eq('workspace_id', workspaceId) + .eq('id', dataSourceId) + .single() + + if (error) { + if (error.code === 'PGRST116') return null + throw new Error(`Failed to get data source: ${error.message}`) + } + return data as unknown as DataSourceRecord +} + +/** + * Get a data source by name within a workspace (admin, bypass RLS). + */ +export async function getDataSourceByNameAdmin( + workspaceId: string, + name: string, +): Promise { + const admin = createAdminClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error } = await (admin as any) + .from('data_sources') + .select('*') + .eq('workspace_id', workspaceId) + .eq('name', name) + .single() + + if (error) { + if (error.code === 'PGRST116') return null + throw new Error(`Failed to get data source by name: ${error.message}`) + } + return data as unknown as DataSourceRecord +} + +/** + * List all data sources for a workspace (admin, bypass RLS). + * Returns full records including encrypted passwords. + */ +export async function listDataSourcesAdmin( + workspaceId: string, +): Promise { + const admin = createAdminClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error } = await (admin as any) + .from('data_sources') + .select('*') + .eq('workspace_id', workspaceId) + .order('created_at', { ascending: true }) + + if (error) throw new Error(`Failed to list data sources: ${error.message}`) + return data as unknown as DataSourceRecord[] +} + +/** + * Check if any Postgres data source is connected for a workspace. + * Used by the integration definitions API to enrich is_connected. + */ +export async function hasConnectedPostgresSource( + workspaceId: string, +): Promise { + const admin = createAdminClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { count, error } = await (admin as any) + .from('data_sources') + .select('id', { count: 'exact', head: true }) + .eq('workspace_id', workspaceId) + .eq('source_type', 'postgres') + .eq('status', 'connected') + + if (error) return false + return (count ?? 0) > 0 +} + +/** + * Update data source status (admin, used after connection validation). + */ +export async function updateDataSourceStatus( + dataSourceId: string, + status: 'connected' | 'error' | 'disconnected', + lastError?: string, +): Promise { + const admin = createAdminClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error } = await (admin as any) + .from('data_sources') + .update({ + status, + last_validated_at: new Date().toISOString(), + last_error: lastError ?? null, + } as Record) + .eq('id', dataSourceId) + + if (error) throw new Error(`Failed to update data source status: ${error.message}`) +} diff --git a/src/lib/integrations/postgres/index.ts b/src/lib/integrations/postgres/index.ts new file mode 100644 index 00000000..e797a25d --- /dev/null +++ b/src/lib/integrations/postgres/index.ts @@ -0,0 +1,58 @@ +/** + * Postgres Data Source Integration + * + * Barrel export for the Postgres data source module. + */ + +// Types +export type { + PostgresConnectionConfig, + ParsedConnectionFields, + DataSourceRecord, + DataSourcePublic, + DataSourceStatus, + PostgresQueryResult, + PostgresExplainResult, + PostgresSchemaInfo, + PostgresTableInfo, + PostgresColumnInfo, + PostgresTableStats, + CreateDataSourceRequest, + UpdateDataSourceRequest, +} from './types' + +// Connection string utilities +export { parseConnectionString, buildConnectionConfig } from './connection-string' + +// Security +export { validatePostgresHost, PostgresQueryValidator } from './security' + +// Client operations +export { + withPostgresConnection, + testConnection, + listSchemas, + listTables, + listColumns, + executeQuery, + explainQuery, + getTableStats, +} from './client' + +// Credential management +export { + listDataSources, + getDataSource, + getDataSourceAdmin, + getDataSourceByNameAdmin, + listDataSourcesAdmin, + hasConnectedPostgresSource, + updateDataSourceStatus, +} from './credentials' + +// Concurrency +export { + acquireConnectionSlot, + releaseConnectionSlot, + getConnectionStats, +} from './concurrency' diff --git a/src/lib/integrations/postgres/security.ts b/src/lib/integrations/postgres/security.ts new file mode 100644 index 00000000..7cb6e35d --- /dev/null +++ b/src/lib/integrations/postgres/security.ts @@ -0,0 +1,268 @@ +/** + * Postgres Data Source Security Module + * + * Two concerns: + * 1. SSRF prevention — validate hostnames AND resolved IPs before connecting + * 2. SQL query validation — enforce read-only (SELECT/EXPLAIN only) + */ + +import dns from 'dns' +import { isPrivateHostname } from '@/lib/utils/ssrf' + +// ── DNS Resolution Cache ────────────────────────────────── + +interface DnsCacheEntry { + ips: string[] + expiresAt: number +} + +const DNS_CACHE = new Map() +const DNS_CACHE_TTL_MS = 60_000 // 60 seconds + +function getCachedDns(hostname: string): string[] | null { + const entry = DNS_CACHE.get(hostname) + if (entry && entry.expiresAt > Date.now()) return entry.ips + if (entry) DNS_CACHE.delete(hostname) + return null +} + +function setCachedDns(hostname: string, ips: string[]): void { + DNS_CACHE.set(hostname, { ips, expiresAt: Date.now() + DNS_CACHE_TTL_MS }) +} + +// ── IP Address Detection ────────────────────────────────── + +const IPV4_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/ +const IPV6_PATTERN = /^[0-9a-f:]+$/i + +function isIpAddress(hostname: string): boolean { + return IPV4_PATTERN.test(hostname) || IPV6_PATTERN.test(hostname) +} + +// ── SSRF Validation (with DNS resolution) ───────────────── + +/** + * Validate a Postgres host for SSRF safety. + * + * 1. Rejects known-private hostnames (localhost, RFC1918, metadata endpoints) + * 2. For domain names, resolves DNS and validates all resolved IPs + * 3. Uses a 60s cache to avoid redundant DNS lookups + * + * Returns null if safe, or an error message string if blocked. + */ +export async function validatePostgresHost(hostname: string): Promise { + if (!hostname || hostname.trim().length === 0) { + return 'Hostname is empty' + } + + const cleaned = hostname.trim().toLowerCase() + + // Step 1: Check the hostname string itself + if (isPrivateHostname(cleaned)) { + return `Access to host "${cleaned}" is blocked (private/internal address)` + } + + // Step 2: If it's a literal IP, we already checked it — done + if (isIpAddress(cleaned)) { + return null + } + + // Step 3: Resolve DNS and validate resolved IPs + const cached = getCachedDns(cleaned) + const ips = cached ?? (await resolveDns(cleaned)) + + if (!ips || ips.length === 0) { + return `Cannot resolve hostname "${cleaned}" — DNS resolution failed` + } + + // Cache the result + if (!cached) setCachedDns(cleaned, ips) + + // Validate every resolved IP + for (const ip of ips) { + if (isPrivateHostname(ip)) { + return `Access to host "${cleaned}" is blocked — resolves to private IP ${ip}` + } + } + + return null +} + +async function resolveDns(hostname: string): Promise { + const ips: string[] = [] + try { + const ipv4 = await dns.promises.resolve4(hostname) + ips.push(...ipv4) + } catch { + // No A records — try AAAA only + } + try { + const ipv6 = await dns.promises.resolve6(hostname) + ips.push(...ipv6) + } catch { + // No AAAA records + } + return ips.length > 0 ? ips : null +} + +// ── SQL Query Validator ─────────────────────────────────── + +const MAX_QUERY_LENGTH = 10_000 + +/** + * Dangerous SQL keywords that modify data, schema, or server state. + * Checked as whole words (word boundary match) to avoid false positives + * on column names like "updated_at" or table names like "settings". + */ +const DANGEROUS_KEYWORDS = [ + 'INSERT', + 'UPDATE', + 'DELETE', + 'TRUNCATE', + 'DROP', + 'ALTER', + 'CREATE', + 'GRANT', + 'REVOKE', + 'COPY', + 'MERGE', + 'REPLACE', +] as const + +/** + * Postgres commands that should never appear as the start of a query. + * These are checked separately from DANGEROUS_KEYWORDS because they + * are full commands, not keywords that could appear in SELECT contexts. + */ +const BLOCKED_COMMANDS = [ + 'SET', + 'DO', + 'LISTEN', + 'NOTIFY', + 'UNLISTEN', + 'VACUUM', + 'ANALYZE', // As a standalone command (not EXPLAIN ANALYZE) + 'REINDEX', + 'CLUSTER', + 'DISCARD', + 'RESET', + 'PREPARE', + 'EXECUTE', + 'DEALLOCATE', + 'LOCK', + 'BEGIN', + 'COMMIT', + 'ROLLBACK', + 'SAVEPOINT', + 'RELEASE', + 'CHECKPOINT', + 'REFRESH', +] as const + +/** + * Patterns for pg_ system catalog tables that expose sensitive metadata. + * We allow information_schema (needed for schema introspection) but block + * direct pg_catalog/pg_shadow/pg_authid access. + */ +const BLOCKED_PG_TABLE_PATTERN = /\bpg_(?:catalog\.|shadow|authid|roles|user|stat_activity|stat_replication|settings|hba_file_rules)\b/i + +/** + * Unicode fullwidth characters that should be normalized to ASCII. + */ +const UNICODE_NORMALIZATIONS: [RegExp, string][] = [ + [/\uFF1B/g, ';'], // Fullwidth semicolon + [/\uFF08/g, '('], // Fullwidth left paren + [/\uFF09/g, ')'], // Fullwidth right paren +] + +/** + * Pattern for SQL comments. + */ +const COMMENT_PATTERNS = [ + /--.*$/gm, // Single line comments + /\/\*[\s\S]*?\*\//g, // Multi-line comments +] + +/** + * Pattern to detect multiple statements (semicolon followed by non-whitespace). + */ +const MULTIPLE_STATEMENTS_PATTERN = /;\s*\S/ + +/** + * Pattern to detect valid query starters: SELECT, WITH (CTE), EXPLAIN, or parenthesized SELECT. + */ +const VALID_QUERY_START = /^\s*(?:\(?\s*SELECT\s|WITH\s|EXPLAIN\s)/i + +export class PostgresQueryValidator { + /** + * Validate a SQL query for read-only safety. + * @throws Error if the query is blocked + */ + validate(query: string): void { + if (!query || query.trim().length === 0) { + throw new Error('Query cannot be empty') + } + + if (query.length > MAX_QUERY_LENGTH) { + throw new Error( + `Query exceeds maximum length of ${MAX_QUERY_LENGTH} characters` + ) + } + + // Unicode normalization + let normalized = query + for (const [pattern, replacement] of UNICODE_NORMALIZATIONS) { + normalized = normalized.replace(pattern, replacement) + } + + // Strip comments for analysis + let stripped = normalized + for (const pattern of COMMENT_PATTERNS) { + stripped = stripped.replace(pattern, ' ') + } + stripped = stripped.trim() + + // Check for multiple statements + // First, remove trailing semicolon (legitimate single-statement terminator) + const withoutTrailingSemicolon = stripped.replace(/;\s*$/, '') + if (MULTIPLE_STATEMENTS_PATTERN.test(withoutTrailingSemicolon)) { + throw new Error( + 'Query contains multiple statements — only single statements are allowed' + ) + } + + // Must start with SELECT, WITH, or EXPLAIN + if (!VALID_QUERY_START.test(stripped)) { + throw new Error( + 'Only SELECT, WITH (CTE), and EXPLAIN queries are allowed' + ) + } + + // Check for dangerous keywords (whole-word match to avoid false positives) + const dangerousFound = this.findDangerousKeywords(stripped) + if (dangerousFound.length > 0) { + throw new Error( + `Query contains dangerous keywords: ${dangerousFound.join(', ')}` + ) + } + + // Check for pg_ catalog access + if (BLOCKED_PG_TABLE_PATTERN.test(stripped)) { + const match = stripped.match(BLOCKED_PG_TABLE_PATTERN) + throw new Error( + `Access to pg_ system catalog is blocked: ${match?.[0] ?? 'pg_*'}` + ) + } + } + + private findDangerousKeywords(query: string): string[] { + const found: string[] = [] + for (const keyword of DANGEROUS_KEYWORDS) { + const pattern = new RegExp(`\\b${keyword}\\b`, 'i') + if (pattern.test(query)) { + found.push(keyword) + } + } + return found + } +} diff --git a/src/lib/integrations/postgres/types.ts b/src/lib/integrations/postgres/types.ts new file mode 100644 index 00000000..2635346c --- /dev/null +++ b/src/lib/integrations/postgres/types.ts @@ -0,0 +1,128 @@ +/** + * Types for the Postgres data source integration. + * + * Covers: connection configuration, DB row shapes, query results, + * and schema introspection results. + */ + +// ── Connection Config ───────────────────────────────────── + +export interface PostgresConnectionConfig { + host: string + port: number + database: string + user: string + password: string + ssl: boolean | 'require' | 'prefer' | 'disable' | 'verify-ca' | 'verify-full' +} + +/** + * Parsed fields from a connection string, before any defaults are applied. + * Fields may be undefined if not present in the connection string. + */ +export interface ParsedConnectionFields { + host?: string + port?: number + database?: string + user?: string + password?: string + sslMode?: string +} + +// ── Database Row Shape ──────────────────────────────────── + +export type DataSourceStatus = 'pending' | 'connected' | 'error' | 'disconnected' + +export interface DataSourceRecord { + id: string + workspace_id: string + source_type: string + name: string + host: string + port: number + database_name: string + username: string + password_encrypted: string + ssl_mode: string + config_json: Record + status: DataSourceStatus + last_validated_at: string | null + last_error: string | null + is_active: boolean + created_at: string + updated_at: string +} + +/** + * Public-facing data source (password masked, never returned to clients). + */ +export type DataSourcePublic = Omit & { + password_masked: string +} + +// ── Query Results ───────────────────────────────────────── + +export interface PostgresQueryResult { + columns: string[] + rows: unknown[][] + row_count: number + execution_time_ms: number +} + +export interface PostgresExplainResult { + plan: unknown + execution_time_ms: number +} + +// ── Schema Introspection ────────────────────────────────── + +export interface PostgresSchemaInfo { + schema_name: string +} + +export interface PostgresTableInfo { + table_name: string + table_schema: string + table_type: string // 'BASE TABLE', 'VIEW', etc. + estimated_row_count: number | null +} + +export interface PostgresColumnInfo { + column_name: string + data_type: string + is_nullable: boolean + column_default: string | null + ordinal_position: number + character_maximum_length: number | null +} + +export interface PostgresTableStats { + table_name: string + table_schema: string + estimated_row_count: number + total_size_bytes: number | null +} + +// ── API Request/Response Shapes ─────────────────────────── + +export interface CreateDataSourceRequest { + name: string + host: string + port?: number + database_name: string + username: string + password: string + ssl_mode?: string + config_json?: Record +} + +export interface UpdateDataSourceRequest { + name?: string + host?: string + port?: number + database_name?: string + username?: string + password?: string + ssl_mode?: string + config_json?: Record +} diff --git a/src/lib/integrations/supported.ts b/src/lib/integrations/supported.ts index b1fb6fd7..606c4d01 100644 --- a/src/lib/integrations/supported.ts +++ b/src/lib/integrations/supported.ts @@ -9,6 +9,8 @@ const _SUPPORTED_INTEGRATIONS = [ 'attio', 'apollo', 'firecrawl', + 'postgres', + 'hubspot', ] as const export type IntegrationName = (typeof _SUPPORTED_INTEGRATIONS)[number] diff --git a/src/lib/utils/ssrf.ts b/src/lib/utils/ssrf.ts index 0f60fe43..4774aae6 100644 --- a/src/lib/utils/ssrf.ts +++ b/src/lib/utils/ssrf.ts @@ -9,6 +9,9 @@ * IP and 127.0.0.1 could bypass these checks. This is an accepted risk here * because Firecrawl (the actual fetcher) runs in its own network — the SSRF * vector would be within Firecrawl's infra, not the Next.js server. + * + * For Postgres connections (which go directly from Vercel), use the + * DNS-resolving variant in `src/lib/integrations/postgres/security.ts`. */ const BLOCKED_HOSTNAME_PATTERNS = [ @@ -37,7 +40,19 @@ const BLOCKED_HOSTS = new Set([ ]) /** - * Check whether a hostname resolves to a private/internal address. + * Check whether a raw hostname (not a URL) is a private/internal address. + * Shared building block for both URL-based and raw-hostname SSRF checks. + * + * Returns true if the hostname should be blocked. + */ +export function isPrivateHostname(hostname: string): boolean { + const cleaned = hostname.replace(/^\[|\]$/g, '') + if (BLOCKED_HOSTS.has(cleaned)) return true + return BLOCKED_HOSTNAME_PATTERNS.some(p => p.test(cleaned)) +} + +/** + * Check whether a URL points to a private/internal address. * Returns true if the host is private (i.e. should be blocked). */ export function isPrivateHost(urlStr: string): boolean { @@ -49,11 +64,7 @@ export function isPrivateHost(urlStr: string): boolean { return true } - const hostname = parsed.hostname.replace(/^\[|\]$/g, '') - - if (BLOCKED_HOSTS.has(hostname)) return true - - return BLOCKED_HOSTNAME_PATTERNS.some(p => p.test(hostname)) + return isPrivateHostname(parsed.hostname) } catch { return true // invalid URL treated as blocked } @@ -75,16 +86,11 @@ export function validateUrl(rawUrl: string): string | null { return 'Only HTTP and HTTPS protocols are allowed' } - const hostname = parsed.hostname.replace(/^\[|\]$/g, '') - - if (BLOCKED_HOSTS.has(hostname)) { - return 'Access to this host is blocked (metadata endpoint)' - } - - for (const pattern of BLOCKED_HOSTNAME_PATTERNS) { - if (pattern.test(hostname)) { - return 'Access to private/internal addresses is blocked' + if (isPrivateHostname(parsed.hostname)) { + if (BLOCKED_HOSTS.has(parsed.hostname.replace(/^\[|\]$/g, ''))) { + return 'Access to this host is blocked (metadata endpoint)' } + return 'Access to private/internal addresses is blocked' } return null diff --git a/supabase/migrations/025_data_sources.sql b/supabase/migrations/025_data_sources.sql new file mode 100644 index 00000000..b50dceee --- /dev/null +++ b/supabase/migrations/025_data_sources.sql @@ -0,0 +1,106 @@ +-- Migration: 025_data_sources +-- Description: Data sources table — multi-instance, workspace-scoped storage +-- for external database connections (initially Postgres). Separate from +-- integration_configs to support N connections per workspace per source type. +-- Created: 2026-03-30 + +-- ============================================ +-- TABLE: data_sources +-- Workspace-scoped external database connections. +-- Supports multiple connections per workspace, each with a unique name. +-- ============================================ + +CREATE TABLE data_sources ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + + -- Identity + source_type TEXT NOT NULL, -- 'postgres' (extensible to 'mysql', 'bigquery', etc.) + name TEXT NOT NULL, -- User-friendly alias, e.g. "Production DB" + + -- Connection fields (stored separately for individual editing) + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 5432, + database_name TEXT NOT NULL, + username TEXT NOT NULL, + password_encrypted TEXT NOT NULL, -- AES-256-GCM via existing encrypt() + ssl_mode TEXT NOT NULL DEFAULT 'require', -- 'require', 'prefer', 'disable', 'verify-ca', 'verify-full' + + -- Extra config (future: schema filters, read replica flag, etc.) + config_json JSONB NOT NULL DEFAULT '{}', + + -- Status + status TEXT NOT NULL DEFAULT 'pending', -- 'pending', 'connected', 'error', 'disconnected' + last_validated_at TIMESTAMPTZ, + last_error TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- A workspace cannot have two data sources with the same name + CONSTRAINT data_sources_workspace_name_unique UNIQUE (workspace_id, name) +); + +-- ============================================ +-- INDEXES +-- Primary lookup: by workspace (list all sources for a workspace) +-- Secondary: by workspace + type (list all Postgres sources) +-- ============================================ + +CREATE INDEX idx_data_sources_workspace ON data_sources(workspace_id); +CREATE INDEX idx_data_sources_workspace_type ON data_sources(workspace_id, source_type); + +-- ============================================ +-- RLS: Workspace members can view and manage their data sources +-- ============================================ + +ALTER TABLE data_sources ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view workspace data sources" + ON data_sources FOR SELECT + USING (workspace_id IN (SELECT get_user_workspaces())); + +CREATE POLICY "Users can manage workspace data sources" + ON data_sources FOR ALL + USING (workspace_id IN (SELECT get_user_workspaces())); + +-- ============================================ +-- TRIGGER: Auto-update updated_at +-- ============================================ + +CREATE TRIGGER update_data_sources_updated_at + BEFORE UPDATE ON data_sources + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================ +-- COMMENTS: Document encrypted columns +-- ============================================ + +COMMENT ON COLUMN data_sources.password_encrypted IS 'AES-256-GCM encrypted database password (format: salt:iv:tag:ciphertext)'; +COMMENT ON TABLE data_sources IS 'External database connections, workspace-scoped with multi-instance support. Password encrypted via AES-256-GCM.'; + +-- ============================================ +-- SEED: Add postgres to integration_definitions registry +-- This drives the setup wizard and settings page. +-- ============================================ + +INSERT INTO integration_definitions ( + name, display_name, description, category, + icon_url, icon_url_light, + required, display_order, setup_step_key, + supports_self_hosted, config_schema +) +VALUES ( + 'postgres', + 'PostgreSQL', + 'Connect directly to a PostgreSQL database for analytics and data exploration', + 'data_source', + 'https://cdn.brandfetch.io/idD6M_K1dV/theme/dark/symbol.svg', + 'https://cdn.brandfetch.io/idD6M_K1dV/theme/light/symbol.svg', + false, -- Not required (PostHog is the primary required data source) + 15, -- After PostHog (10), before Attio (20) + 'postgres', -- Maps to wizard step component + false, -- No cloud/self-hosted distinction — it is always the user's own DB + '{"type": "object", "properties": {"host": {"type": "string"}, "port": {"type": "integer", "default": 5432}, "database": {"type": "string"}, "ssl_mode": {"type": "string", "enum": ["require", "prefer", "disable", "verify-ca", "verify-full"]}}, "description": "PostgreSQL connection settings"}'::jsonb +); diff --git a/supabase/migrations/026_hubspot_integration.sql b/supabase/migrations/026_hubspot_integration.sql new file mode 100644 index 00000000..0c777202 --- /dev/null +++ b/supabase/migrations/026_hubspot_integration.sql @@ -0,0 +1,291 @@ +-- Migration: 026_hubspot_integration +-- Description: HubSpot CRM integration tables — OAuth/Private App connections, +-- sync state tracking, record cache, and association mapping. +-- Supports multiple connections per workspace. +-- Created: 2026-03-31 + +-- ============================================ +-- TABLE: hubspot_connections +-- Workspace-scoped HubSpot CRM connections. +-- Supports OAuth and Private App Token auth types. +-- ============================================ + +CREATE TABLE hubspot_connections ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + + -- Identity + name TEXT NOT NULL DEFAULT 'HubSpot', -- User-friendly alias + auth_type TEXT NOT NULL CHECK (auth_type IN ('oauth', 'private_app')), + + -- OAuth credentials (encrypted) + access_token_encrypted TEXT, -- AES-256-GCM encrypted + refresh_token_encrypted TEXT, -- AES-256-GCM encrypted + token_expires_at TIMESTAMPTZ, + + -- Private App credentials (encrypted) + private_app_token_encrypted TEXT, -- AES-256-GCM encrypted + + -- HubSpot account info (populated after successful auth) + hub_id TEXT, -- HubSpot portal/hub ID + hub_domain TEXT, -- e.g. "mycompany.hubspot.com" + account_name TEXT, -- Display name from HubSpot + + -- OAuth metadata + scopes TEXT[], -- Granted OAuth scopes + oauth_state TEXT, -- CSRF state parameter (temporary, cleared after callback) + + -- Connection config + config_json JSONB NOT NULL DEFAULT '{}', -- Extra settings (e.g., enabled objects, sync intervals) + is_primary BOOLEAN NOT NULL DEFAULT false, -- Primary connection for the workspace + + -- Status + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'connected', 'error', 'disconnected', 'expired')), + last_validated_at TIMESTAMPTZ, + last_error TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- A workspace cannot have two connections with the same name + CONSTRAINT hubspot_connections_workspace_name_unique UNIQUE (workspace_id, name) +); + +-- ============================================ +-- INDEXES +-- ============================================ + +CREATE INDEX idx_hubspot_connections_workspace ON hubspot_connections(workspace_id); +CREATE INDEX idx_hubspot_connections_workspace_active ON hubspot_connections(workspace_id) + WHERE is_active = true; +CREATE INDEX idx_hubspot_connections_hub_id ON hubspot_connections(hub_id); + +-- ============================================ +-- TABLE: hubspot_sync_state +-- Tracks sync progress per connection per object type. +-- ============================================ + +CREATE TABLE hubspot_sync_state ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + connection_id UUID NOT NULL REFERENCES hubspot_connections(id) ON DELETE CASCADE, + object_type TEXT NOT NULL, -- 'contacts', 'companies', 'deals', 'tickets', etc. + + -- Sync progress + last_sync_at TIMESTAMPTZ, -- When the last successful sync completed + last_sync_cursor TEXT, -- HubSpot pagination cursor for incremental sync + last_modified_at TIMESTAMPTZ, -- HubSpot lastmodifieddate watermark + records_synced INTEGER NOT NULL DEFAULT 0, + total_records INTEGER, -- Total records in HubSpot (if known) + + -- Sync lock (prevents concurrent syncs) + sync_lock_id TEXT, -- Unique lock identifier + sync_lock_expires_at TIMESTAMPTZ, -- Lock expiry (auto-unlock after timeout) + + -- Status + status TEXT NOT NULL DEFAULT 'idle' + CHECK (status IN ('idle', 'syncing', 'error', 'backfilling')), + last_error TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- One sync state per connection per object type + CONSTRAINT hubspot_sync_state_connection_object_unique UNIQUE (connection_id, object_type) +); + +-- ============================================ +-- INDEXES +-- ============================================ + +CREATE INDEX idx_hubspot_sync_state_connection ON hubspot_sync_state(connection_id); + +-- ============================================ +-- TABLE: hubspot_records +-- Local cache of HubSpot CRM records for fast lookups and signal detection. +-- ============================================ + +CREATE TABLE hubspot_records ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + connection_id UUID NOT NULL REFERENCES hubspot_connections(id) ON DELETE CASCADE, + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + + -- HubSpot identity + hubspot_id TEXT NOT NULL, -- HubSpot record ID (numeric string) + object_type TEXT NOT NULL, -- 'contacts', 'companies', 'deals', 'tickets', etc. + + -- Record data (full HubSpot properties snapshot) + properties JSONB NOT NULL DEFAULT '{}', + + -- Timestamps from HubSpot + hubspot_created_at TIMESTAMPTZ, + hubspot_updated_at TIMESTAMPTZ, + + -- Local metadata + synced_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- One record per connection per HubSpot ID per object type + CONSTRAINT hubspot_records_connection_object_id_unique UNIQUE (connection_id, object_type, hubspot_id) +); + +-- ============================================ +-- INDEXES +-- ============================================ + +CREATE INDEX idx_hubspot_records_workspace ON hubspot_records(workspace_id); +CREATE INDEX idx_hubspot_records_connection ON hubspot_records(connection_id); +CREATE INDEX idx_hubspot_records_connection_type ON hubspot_records(connection_id, object_type); +CREATE INDEX idx_hubspot_records_hubspot_id ON hubspot_records(hubspot_id); +CREATE INDEX idx_hubspot_records_updated ON hubspot_records(hubspot_updated_at); +-- GIN index on properties for JSONB queries +CREATE INDEX idx_hubspot_records_properties ON hubspot_records USING gin(properties); + +-- ============================================ +-- TABLE: hubspot_associations +-- Maps associations between HubSpot records (e.g., contact → company). +-- ============================================ + +CREATE TABLE hubspot_associations ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + connection_id UUID NOT NULL REFERENCES hubspot_connections(id) ON DELETE CASCADE, + + -- From record + from_object_type TEXT NOT NULL, + from_hubspot_id TEXT NOT NULL, + + -- To record + to_object_type TEXT NOT NULL, + to_hubspot_id TEXT NOT NULL, + + -- Association metadata + association_type TEXT NOT NULL, -- e.g., 'contact_to_company', 'deal_to_company' + association_label TEXT, -- Optional label from HubSpot + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- One association per direction per connection + CONSTRAINT hubspot_associations_unique UNIQUE ( + connection_id, from_object_type, from_hubspot_id, to_object_type, to_hubspot_id, association_type + ) +); + +-- ============================================ +-- INDEXES +-- ============================================ + +CREATE INDEX idx_hubspot_associations_connection ON hubspot_associations(connection_id); +CREATE INDEX idx_hubspot_associations_from ON hubspot_associations(connection_id, from_object_type, from_hubspot_id); +CREATE INDEX idx_hubspot_associations_to ON hubspot_associations(connection_id, to_object_type, to_hubspot_id); + +-- ============================================ +-- RLS: Workspace isolation via workspace_members +-- ============================================ + +ALTER TABLE hubspot_connections ENABLE ROW LEVEL SECURITY; +ALTER TABLE hubspot_sync_state ENABLE ROW LEVEL SECURITY; +ALTER TABLE hubspot_records ENABLE ROW LEVEL SECURITY; +ALTER TABLE hubspot_associations ENABLE ROW LEVEL SECURITY; + +-- hubspot_connections: direct workspace_id column +CREATE POLICY "Users can view workspace hubspot connections" + ON hubspot_connections FOR SELECT + USING (workspace_id IN (SELECT get_user_workspaces())); + +CREATE POLICY "Users can manage workspace hubspot connections" + ON hubspot_connections FOR ALL + USING (workspace_id IN (SELECT get_user_workspaces())); + +-- hubspot_sync_state: via connection_id → hubspot_connections.workspace_id +CREATE POLICY "Users can view workspace hubspot sync state" + ON hubspot_sync_state FOR SELECT + USING (connection_id IN ( + SELECT id FROM hubspot_connections + WHERE workspace_id IN (SELECT get_user_workspaces()) + )); + +CREATE POLICY "Users can manage workspace hubspot sync state" + ON hubspot_sync_state FOR ALL + USING (connection_id IN ( + SELECT id FROM hubspot_connections + WHERE workspace_id IN (SELECT get_user_workspaces()) + )); + +-- hubspot_records: direct workspace_id column +CREATE POLICY "Users can view workspace hubspot records" + ON hubspot_records FOR SELECT + USING (workspace_id IN (SELECT get_user_workspaces())); + +CREATE POLICY "Users can manage workspace hubspot records" + ON hubspot_records FOR ALL + USING (workspace_id IN (SELECT get_user_workspaces())); + +-- hubspot_associations: via connection_id → hubspot_connections.workspace_id +CREATE POLICY "Users can view workspace hubspot associations" + ON hubspot_associations FOR SELECT + USING (connection_id IN ( + SELECT id FROM hubspot_connections + WHERE workspace_id IN (SELECT get_user_workspaces()) + )); + +CREATE POLICY "Users can manage workspace hubspot associations" + ON hubspot_associations FOR ALL + USING (connection_id IN ( + SELECT id FROM hubspot_connections + WHERE workspace_id IN (SELECT get_user_workspaces()) + )); + +-- ============================================ +-- TRIGGERS: Auto-update updated_at +-- ============================================ + +CREATE TRIGGER update_hubspot_connections_updated_at + BEFORE UPDATE ON hubspot_connections + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_hubspot_sync_state_updated_at + BEFORE UPDATE ON hubspot_sync_state + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_hubspot_records_updated_at + BEFORE UPDATE ON hubspot_records + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================ +-- COMMENTS +-- ============================================ + +COMMENT ON TABLE hubspot_connections IS 'HubSpot CRM connections, workspace-scoped. Supports OAuth and Private App Token auth.'; +COMMENT ON COLUMN hubspot_connections.access_token_encrypted IS 'AES-256-GCM encrypted OAuth access token (format: salt:iv:tag:ciphertext)'; +COMMENT ON COLUMN hubspot_connections.refresh_token_encrypted IS 'AES-256-GCM encrypted OAuth refresh token (format: salt:iv:tag:ciphertext)'; +COMMENT ON COLUMN hubspot_connections.private_app_token_encrypted IS 'AES-256-GCM encrypted Private App token (format: salt:iv:tag:ciphertext)'; +COMMENT ON TABLE hubspot_sync_state IS 'Tracks sync progress per HubSpot connection per object type.'; +COMMENT ON TABLE hubspot_records IS 'Local cache of HubSpot CRM records for fast lookups and signal detection.'; +COMMENT ON TABLE hubspot_associations IS 'Cached associations between HubSpot CRM records.'; + +-- ============================================ +-- SEED: Add hubspot to integration_definitions registry +-- ============================================ + +INSERT INTO integration_definitions ( + name, display_name, description, category, + icon_url, icon_url_light, + required, display_order, setup_step_key, + supports_self_hosted, config_schema +) +VALUES ( + 'hubspot', + 'HubSpot', + 'Connect your HubSpot CRM to sync contacts, companies, deals, and tickets', + 'crm', + 'https://cdn.brandfetch.io/idnNYrmBqL/theme/dark/symbol.svg', + 'https://cdn.brandfetch.io/idnNYrmBqL/theme/light/symbol.svg', + false, -- Not required + 25, -- After Attio (20) + 'hubspot', -- Maps to wizard step component + false, -- Cloud-only HubSpot API + '{"type": "object", "properties": {"auth_type": {"type": "string", "enum": ["oauth", "private_app"]}, "hub_id": {"type": "string"}, "scopes": {"type": "array", "items": {"type": "string"}}}, "description": "HubSpot CRM connection settings"}'::jsonb +); diff --git a/vercel.json b/vercel.json index 49bc18a2..14da829b 100644 --- a/vercel.json +++ b/vercel.json @@ -35,6 +35,10 @@ { "path": "/api/cron/mcp-cleanup", "schedule": "0 3 * * *" + }, + { + "path": "/api/cron/hubspot-sync", + "schedule": "*/15 * * * *" } ] } diff --git a/vitest.config.ts b/vitest.config.ts index e717c14f..d4a0aaa9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ globals: true, setupFiles: ['./src/test/setup.ts'], include: ['**/*.{test,spec}.{ts,tsx}'], - exclude: ['node_modules', '.next', 'e2e', 'packages/**/node_modules/**'], + exclude: ['node_modules', '.next', 'e2e', 'packages/**/node_modules/**', '.claude/worktrees/**'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'],