import type { HubSpotObjectType } from '../integrations/hubspot/contracts'; import { HUBSPOT_OBJECT_TYPES } from '../integrations/hubspot/contracts'; import { HubSpotOAuthError } from '../integrations/hubspot/oauth'; import { Hono } from 'hono'; import { z } from 'zod'; import { requireAnyTeamCapability, requireCapability } from '../lib/auth'; import type { ApiEnv } from '../lib/mutation'; const connectionParamSchema = z.string().uuid(); export interface HubSpotConnectionSummary { id: string; portalId: string; displayName: string | null; status: string; grantedScopes: readonly string[]; accessTokenExpiresAt: Date; installedAt: Date; lastSyncAt: Date | null; lastError: string | null; } export interface HubSpotRouteService { begin(requestedByUserId: string): Promise<{ authorizationUrl: string }>; complete(code: string, state: string, signal?: AbortSignal): Promise<{ returnPath: string }>; listConnections(): Promise; enqueueSync(connectionId: string, objectTypes: readonly HubSpotObjectType[], requestedByUserId: string): Promise<{ jobIds: string[] }>; } export function createHubSpotRoutes(service: HubSpotRouteService): Hono { const routes = new Hono(); routes.post('/api/integrations/hubspot/oauth/start', async (context) => { const principal = context.get('principal'); requireCapability(principal, 'settings:admin'); return context.json(await service.begin(principal.userId)); }); routes.get('/api/integrations/hubspot/oauth/callback', async (context) => { const code = context.req.query('code'); const state = context.req.query('state'); if (!code || !state) { return context.json({ error: 'HubSpot did not return an authorization code and state.' }, 400); } try { const completed = await service.complete(code, state, context.req.raw.signal); return context.redirect(completed.returnPath, 303); } catch (error) { if (error instanceof HubSpotOAuthError) { return context.json({ error: error.message }, 400); } throw error; } }); routes.get('/api/integrations/hubspot/connections', async (context) => { if (!context.get('principal').scopes.includes('read')) { return context.json({ error: "This credential lacks the 'read' scope." }, 403); } const connections = await service.listConnections(); return context.json({ connections: connections.map((connection) => ({ ...connection, accessTokenExpiresAt: connection.accessTokenExpiresAt.toISOString(), installedAt: connection.installedAt.toISOString(), lastSyncAt: connection.lastSyncAt?.toISOString() ?? null, })), }); }); routes.post('/api/integrations/hubspot/connections/:connectionId/sync', async (context) => { const principal = context.get('principal'); // Any team, and honestly so: a HubSpot sync pulls companies, contacts and // deals from both sides at once, so there is no single team to scope it to. // Narrowing it would need the sync to accept an object-type filter first. requireAnyTeamCapability(principal, 'data:import'); const parsedId = connectionParamSchema.safeParse(context.req.param('connectionId')); if (!parsedId.success) return context.json({ error: 'Invalid HubSpot connection ID.' }, 400); return context.json( await service.enqueueSync(parsedId.data, HUBSPOT_OBJECT_TYPES, principal.userId), 202, ); }); return routes; }