Lay the HubSpot and customer-lifecycle foundation
Work in progress from the Codex session, committed so nothing sits undeployed. Verified before committing: typecheck clean across all packages, 139 unit tests and the e2e suite green, migrations apply to an empty Postgres. Adds the HubSpot integration boundary (OAuth, client, contracts, webhook signature verification, sync), a growth route, customer-lifecycle service, Piggy lifecycle tools, a Growth page, and shared lifecycle/hubspot types. Two things are deliberately incomplete and should not be mistaken for finished: `packages/db/src/schema/hubspot.ts` is NOT exported from the schema index, so it is inert — no tables, no migration. That is the correct order (the shape can settle before it becomes a migration), but it does mean the HubSpot routes have no persistence behind them yet. `pnpm-workspace.yaml` and `pnpm-lock.yaml` are left uncommitted on purpose. The workspace file contains a literal unanswered placeholder — "esbuild: set this to true or false" — and this repository installs with npm, which is also what CI runs. Committing a second package manager's lockfile would make the install ambiguous. If the move to pnpm is intended it should be a deliberate change that updates CI and the Dockerfile together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { z } from 'zod';
|
||||
import type { HubSpotObjectType, HubSpotRecord, HubSpotRecordPage } from './contracts';
|
||||
|
||||
const HUBSPOT_API_BASE = 'https://api.hubapi.com';
|
||||
const HUBSPOT_CRM_VERSION = '2026-03';
|
||||
const HUBSPOT_LIST_LIMIT = 100;
|
||||
const HUBSPOT_BATCH_LIMIT = 100;
|
||||
|
||||
export const HUBSPOT_READ_PROPERTIES: Readonly<Record<HubSpotObjectType, readonly string[]>> = {
|
||||
companies: [
|
||||
'name',
|
||||
'domain',
|
||||
'city',
|
||||
'state',
|
||||
'country',
|
||||
'industry',
|
||||
'numberofemployees',
|
||||
'annualrevenue',
|
||||
'hs_lastmodifieddate',
|
||||
],
|
||||
contacts: [
|
||||
'email',
|
||||
'firstname',
|
||||
'lastname',
|
||||
'phone',
|
||||
'mobilephone',
|
||||
'jobtitle',
|
||||
'hs_lastmodifieddate',
|
||||
],
|
||||
deals: [
|
||||
'dealname',
|
||||
'pipeline',
|
||||
'dealstage',
|
||||
'amount',
|
||||
'closedate',
|
||||
'hs_lastmodifieddate',
|
||||
],
|
||||
};
|
||||
|
||||
const recordSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
properties: z.record(z.string().nullable()),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
archived: z.boolean(),
|
||||
}).passthrough();
|
||||
const pagingAfterSchema = z.union([z.string(), z.number()]).transform(String);
|
||||
const listResponseSchema = z.object({
|
||||
results: z.array(recordSchema),
|
||||
paging: z.object({ next: z.object({ after: pagingAfterSchema }).passthrough() }).passthrough().optional(),
|
||||
}).passthrough();
|
||||
const batchResponseSchema = z.object({ results: z.array(recordSchema) }).passthrough();
|
||||
|
||||
export class HubSpotApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
readonly retryAfterSeconds?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HubSpotApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export class HubSpotCrmClient {
|
||||
constructor(private readonly fetchImpl: typeof fetch = fetch) {}
|
||||
|
||||
async listObjects(
|
||||
accessToken: string,
|
||||
objectType: HubSpotObjectType,
|
||||
options: { after?: string | null; signal?: AbortSignal } = {},
|
||||
): Promise<HubSpotRecordPage> {
|
||||
const url = this.objectUrl(objectType);
|
||||
url.searchParams.set('limit', String(HUBSPOT_LIST_LIMIT));
|
||||
url.searchParams.set('archived', 'false');
|
||||
url.searchParams.set('properties', HUBSPOT_READ_PROPERTIES[objectType].join(','));
|
||||
if (options.after) url.searchParams.set('after', options.after);
|
||||
const response = await this.request(url, accessToken, { method: 'GET', signal: options.signal });
|
||||
const parsed = listResponseSchema.safeParse(await response.json());
|
||||
if (!parsed.success) throw new HubSpotApiError('HubSpot returned an invalid CRM list response.');
|
||||
return {
|
||||
results: parsed.data.results as HubSpotRecord[],
|
||||
nextAfter: parsed.data.paging?.next.after ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async batchReadObjects(
|
||||
accessToken: string,
|
||||
objectType: HubSpotObjectType,
|
||||
ids: readonly string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<HubSpotRecord[]> {
|
||||
if (ids.length === 0) return [];
|
||||
if (ids.length > HUBSPOT_BATCH_LIMIT) {
|
||||
throw new HubSpotApiError(`HubSpot batch reads accept at most ${HUBSPOT_BATCH_LIMIT} IDs.`);
|
||||
}
|
||||
if (ids.some((id) => id.length === 0)) throw new HubSpotApiError('HubSpot object IDs cannot be blank.');
|
||||
const url = new URL(`${this.objectUrl(objectType).toString()}/batch/read`);
|
||||
const response = await this.request(url, accessToken, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
properties: HUBSPOT_READ_PROPERTIES[objectType],
|
||||
inputs: ids.map((id) => ({ id })),
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
const parsed = batchResponseSchema.safeParse(await response.json());
|
||||
if (!parsed.success) throw new HubSpotApiError('HubSpot returned an invalid CRM batch response.');
|
||||
return parsed.data.results as HubSpotRecord[];
|
||||
}
|
||||
|
||||
private objectUrl(objectType: HubSpotObjectType): URL {
|
||||
return new URL(`${HUBSPOT_API_BASE}/crm/objects/${HUBSPOT_CRM_VERSION}/${objectType}`);
|
||||
}
|
||||
|
||||
private async request(
|
||||
url: URL,
|
||||
accessToken: string,
|
||||
init: RequestInit,
|
||||
): Promise<Response> {
|
||||
const response = await this.fetchImpl(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...init.headers,
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
accept: 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const retryAfter = response.headers.get('retry-after');
|
||||
const parsedRetryAfter = retryAfter === null ? undefined : Number(retryAfter);
|
||||
throw new HubSpotApiError(
|
||||
'HubSpot rejected the CRM request.',
|
||||
response.status,
|
||||
Number.isFinite(parsedRetryAfter) ? parsedRetryAfter : undefined,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
HUBSPOT_CONNECTION_STATUSES,
|
||||
HUBSPOT_EVENT_STATUSES,
|
||||
HUBSPOT_JOB_KINDS,
|
||||
HUBSPOT_JOB_STATUSES,
|
||||
HUBSPOT_OBJECT_TYPES,
|
||||
HUBSPOT_REQUIRED_SCOPES,
|
||||
HUBSPOT_SYNC_PHASES,
|
||||
} from '../../../../../packages/core/src/hubspot';
|
||||
export type {
|
||||
HubSpotConnectionStatus,
|
||||
HubSpotEventStatus,
|
||||
HubSpotJobKind,
|
||||
HubSpotJobStatus,
|
||||
HubSpotObjectType,
|
||||
HubSpotRecord,
|
||||
HubSpotRecordPage,
|
||||
HubSpotRequiredScope,
|
||||
HubSpotSyncPhase,
|
||||
} from '../../../../../packages/core/src/hubspot';
|
||||
@@ -0,0 +1,261 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import { decryptSecret, encryptSecret } from '../../lib/secrets';
|
||||
import { HUBSPOT_REQUIRED_SCOPES } from './contracts';
|
||||
|
||||
const HUBSPOT_AUTHORIZE_URL = 'https://app.hubspot.com/oauth/authorize';
|
||||
const HUBSPOT_TOKEN_URL = 'https://api.hubapi.com/oauth/v3/token';
|
||||
const TOKEN_REFRESH_SKEW_MS = 60_000;
|
||||
|
||||
const tokenResponseSchema = z.object({
|
||||
access_token: z.string().min(1),
|
||||
refresh_token: z.string().min(1),
|
||||
expires_in: z.number().int().positive(),
|
||||
hub_id: z.union([z.string().min(1), z.number().int().nonnegative()]).transform(String),
|
||||
scopes: z.array(z.string()),
|
||||
}).passthrough();
|
||||
|
||||
export interface HubSpotOAuthConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
export interface HubSpotTokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresInSeconds: number;
|
||||
portalId: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export class HubSpotOAuthError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HubSpotOAuthError';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildHubSpotAuthorizationUrl(
|
||||
config: Pick<HubSpotOAuthConfig, 'clientId' | 'redirectUri'>,
|
||||
state: string,
|
||||
): string {
|
||||
const url = new URL(HUBSPOT_AUTHORIZE_URL);
|
||||
url.searchParams.set('client_id', config.clientId);
|
||||
url.searchParams.set('redirect_uri', config.redirectUri);
|
||||
url.searchParams.set('scope', HUBSPOT_REQUIRED_SCOPES.join(' '));
|
||||
url.searchParams.set('state', state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export class HubSpotOAuthClient {
|
||||
constructor(
|
||||
private readonly config: HubSpotOAuthConfig,
|
||||
private readonly fetchImpl: typeof fetch = fetch,
|
||||
) {}
|
||||
|
||||
authorizationUrl(state: string): string {
|
||||
return buildHubSpotAuthorizationUrl(this.config, state);
|
||||
}
|
||||
|
||||
exchangeAuthorizationCode(code: string, signal?: AbortSignal): Promise<HubSpotTokenResponse> {
|
||||
return this.tokenRequest({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
redirect_uri: this.config.redirectUri,
|
||||
code,
|
||||
}, signal);
|
||||
}
|
||||
|
||||
refreshAccessToken(refreshToken: string, signal?: AbortSignal): Promise<HubSpotTokenResponse> {
|
||||
return this.tokenRequest({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
redirect_uri: this.config.redirectUri,
|
||||
refresh_token: refreshToken,
|
||||
}, signal);
|
||||
}
|
||||
|
||||
private async tokenRequest(
|
||||
form: Record<string, string>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HubSpotTokenResponse> {
|
||||
const response = await this.fetchImpl(HUBSPOT_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(form),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new HubSpotOAuthError('HubSpot rejected the OAuth token request.', response.status);
|
||||
}
|
||||
const parsed = tokenResponseSchema.safeParse(await response.json());
|
||||
if (!parsed.success) throw new HubSpotOAuthError('HubSpot returned an invalid OAuth token response.');
|
||||
return {
|
||||
accessToken: parsed.data.access_token,
|
||||
refreshToken: parsed.data.refresh_token,
|
||||
expiresInSeconds: parsed.data.expires_in,
|
||||
portalId: parsed.data.hub_id,
|
||||
scopes: parsed.data.scopes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type TokenKind = 'access' | 'refresh';
|
||||
|
||||
function tokenPurpose(connectionId: string, kind: TokenKind): string {
|
||||
return `hubspot:${connectionId}:${kind}-token`;
|
||||
}
|
||||
|
||||
export class HubSpotTokenVault {
|
||||
constructor(private readonly encryptionKey: string | undefined) {}
|
||||
|
||||
encrypt(connectionId: string, kind: TokenKind, token: string): string {
|
||||
return encryptSecret(token, this.encryptionKey, tokenPurpose(connectionId, kind));
|
||||
}
|
||||
|
||||
decrypt(connectionId: string, kind: TokenKind, envelope: string): string {
|
||||
return decryptSecret(envelope, this.encryptionKey, tokenPurpose(connectionId, kind));
|
||||
}
|
||||
}
|
||||
|
||||
export interface LockedHubSpotCredential {
|
||||
id: string;
|
||||
status: 'active' | 'reauthorization_required' | 'disconnected' | 'error';
|
||||
encryptedAccessToken: string;
|
||||
encryptedRefreshToken: string;
|
||||
accessTokenExpiresAt: Date;
|
||||
updateTokens(input: {
|
||||
encryptedAccessToken: string;
|
||||
encryptedRefreshToken: string;
|
||||
accessTokenExpiresAt: Date;
|
||||
grantedScopes: readonly string[];
|
||||
refreshedAt: Date;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HubSpotCredentialLockStore {
|
||||
/** The adapter must hold one row/advisory lock through the callback and update. */
|
||||
withConnectionLock<T>(
|
||||
connectionId: string,
|
||||
operation: (credential: LockedHubSpotCredential) => Promise<T>,
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
export class HubSpotTokenManager {
|
||||
constructor(
|
||||
private readonly store: HubSpotCredentialLockStore,
|
||||
private readonly oauth: Pick<HubSpotOAuthClient, 'refreshAccessToken'>,
|
||||
private readonly vault: HubSpotTokenVault,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
getAccessToken(connectionId: string, signal?: AbortSignal): Promise<string> {
|
||||
return this.store.withConnectionLock(connectionId, async (credential) => {
|
||||
if (credential.status !== 'active') {
|
||||
throw new HubSpotOAuthError('The HubSpot connection is not active.');
|
||||
}
|
||||
const now = this.now();
|
||||
if (credential.accessTokenExpiresAt.getTime() > now.getTime() + TOKEN_REFRESH_SKEW_MS) {
|
||||
return this.vault.decrypt(connectionId, 'access', credential.encryptedAccessToken);
|
||||
}
|
||||
const refreshToken = this.vault.decrypt(
|
||||
connectionId,
|
||||
'refresh',
|
||||
credential.encryptedRefreshToken,
|
||||
);
|
||||
const refreshed = await this.oauth.refreshAccessToken(refreshToken, signal);
|
||||
const missingScope = HUBSPOT_REQUIRED_SCOPES.find((scope) => !refreshed.scopes.includes(scope));
|
||||
if (missingScope) throw new HubSpotOAuthError(`HubSpot did not grant required scope ${missingScope}.`);
|
||||
const expiresAt = new Date(now.getTime() + refreshed.expiresInSeconds * 1_000);
|
||||
await credential.updateTokens({
|
||||
encryptedAccessToken: this.vault.encrypt(connectionId, 'access', refreshed.accessToken),
|
||||
encryptedRefreshToken: this.vault.encrypt(connectionId, 'refresh', refreshed.refreshToken),
|
||||
accessTokenExpiresAt: expiresAt,
|
||||
grantedScopes: refreshed.scopes,
|
||||
refreshedAt: now,
|
||||
});
|
||||
return refreshed.accessToken;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface StoredHubSpotOAuthState {
|
||||
requestedByUserId: string;
|
||||
returnPath: string;
|
||||
}
|
||||
|
||||
export interface HubSpotConnectionInstallStore {
|
||||
createOAuthState(input: {
|
||||
nonceHash: string;
|
||||
requestedByUserId: string;
|
||||
returnPath: string;
|
||||
expiresAt: Date;
|
||||
}): Promise<void>;
|
||||
consumeOAuthState(nonceHash: string, now: Date): Promise<StoredHubSpotOAuthState | null>;
|
||||
reserveConnectionId(portalId: string, proposedId: string): Promise<string>;
|
||||
saveConnection(input: {
|
||||
id: string;
|
||||
portalId: string;
|
||||
encryptedAccessToken: string;
|
||||
encryptedRefreshToken: string;
|
||||
accessTokenExpiresAt: Date;
|
||||
grantedScopes: readonly string[];
|
||||
connectedByUserId: string;
|
||||
installedAt: Date;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
const OAUTH_STATE_TTL_MS = 10 * 60 * 1_000;
|
||||
const DEFAULT_RETURN_PATH = '/settings/integrations/hubspot';
|
||||
|
||||
export class HubSpotConnectionService {
|
||||
constructor(
|
||||
private readonly store: HubSpotConnectionInstallStore,
|
||||
private readonly oauth: Pick<HubSpotOAuthClient, 'authorizationUrl' | 'exchangeAuthorizationCode'>,
|
||||
private readonly vault: HubSpotTokenVault,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
async begin(requestedByUserId: string): Promise<{ authorizationUrl: string }> {
|
||||
const state = randomBytes(32).toString('base64url');
|
||||
const now = this.now();
|
||||
await this.store.createOAuthState({
|
||||
nonceHash: hashOAuthState(state),
|
||||
requestedByUserId,
|
||||
returnPath: DEFAULT_RETURN_PATH,
|
||||
expiresAt: new Date(now.getTime() + OAUTH_STATE_TTL_MS),
|
||||
});
|
||||
return { authorizationUrl: this.oauth.authorizationUrl(state) };
|
||||
}
|
||||
|
||||
async complete(code: string, state: string, signal?: AbortSignal): Promise<{ returnPath: string }> {
|
||||
const now = this.now();
|
||||
const storedState = await this.store.consumeOAuthState(hashOAuthState(state), now);
|
||||
if (!storedState) throw new HubSpotOAuthError('The HubSpot OAuth state is invalid or expired.');
|
||||
const tokens = await this.oauth.exchangeAuthorizationCode(code, signal);
|
||||
const missingScope = HUBSPOT_REQUIRED_SCOPES.find((scope) => !tokens.scopes.includes(scope));
|
||||
if (missingScope) throw new HubSpotOAuthError(`HubSpot did not grant required scope ${missingScope}.`);
|
||||
const connectionId = await this.store.reserveConnectionId(tokens.portalId, randomUUID());
|
||||
await this.store.saveConnection({
|
||||
id: connectionId,
|
||||
portalId: tokens.portalId,
|
||||
encryptedAccessToken: this.vault.encrypt(connectionId, 'access', tokens.accessToken),
|
||||
encryptedRefreshToken: this.vault.encrypt(connectionId, 'refresh', tokens.refreshToken),
|
||||
accessTokenExpiresAt: new Date(now.getTime() + tokens.expiresInSeconds * 1_000),
|
||||
grantedScopes: tokens.scopes,
|
||||
connectedByUserId: storedState.requestedByUserId,
|
||||
installedAt: now,
|
||||
});
|
||||
return { returnPath: storedState.returnPath };
|
||||
}
|
||||
}
|
||||
|
||||
export function hashOAuthState(state: string): string {
|
||||
return createHash('sha256').update(state, 'utf8').digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
const SIGNATURE_MAX_AGE_MS = 5 * 60 * 1_000;
|
||||
const HUBSPOT_URI_DECODE_PATTERN = /%3A|%2F|%3F|%40|%21|%24|%27|%28|%29|%2A|%2C|%3B/gi;
|
||||
const HUBSPOT_URI_DECODINGS: Record<string, string> = {
|
||||
'%3A': ':',
|
||||
'%2F': '/',
|
||||
'%3F': '?',
|
||||
'%40': '@',
|
||||
'%21': '!',
|
||||
'%24': '$',
|
||||
'%27': "'",
|
||||
'%28': '(',
|
||||
'%29': ')',
|
||||
'%2A': '*',
|
||||
'%2C': ',',
|
||||
'%3B': ';',
|
||||
};
|
||||
|
||||
export interface HubSpotV3SignatureInput {
|
||||
clientSecret: string;
|
||||
method: string;
|
||||
publicUri: string;
|
||||
rawBody: string;
|
||||
signature: string | undefined;
|
||||
timestamp: string | undefined;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export type HubSpotSignatureResult =
|
||||
| { valid: true }
|
||||
| { valid: false; reason: 'missing_headers' | 'invalid_timestamp' | 'stale_timestamp' | 'mismatch' };
|
||||
|
||||
export function normalizeHubSpotSignatureUri(uri: string): string {
|
||||
const withoutFragment = uri.split('#', 1)[0] ?? uri;
|
||||
const queryIndex = withoutFragment.indexOf('?');
|
||||
if (queryIndex < 0) return withoutFragment;
|
||||
const prefix = withoutFragment.slice(0, queryIndex + 1);
|
||||
const query = withoutFragment.slice(queryIndex + 1).replace(
|
||||
HUBSPOT_URI_DECODE_PATTERN,
|
||||
(encoded) => HUBSPOT_URI_DECODINGS[encoded.toUpperCase()] ?? encoded,
|
||||
);
|
||||
return prefix + query;
|
||||
}
|
||||
|
||||
export function verifyHubSpotV3Signature(input: HubSpotV3SignatureInput): HubSpotSignatureResult {
|
||||
if (!input.signature || !input.timestamp) return { valid: false, reason: 'missing_headers' };
|
||||
if (!/^\d+$/.test(input.timestamp)) return { valid: false, reason: 'invalid_timestamp' };
|
||||
const timestamp = Number(input.timestamp);
|
||||
if (!Number.isSafeInteger(timestamp)) return { valid: false, reason: 'invalid_timestamp' };
|
||||
const now = input.now ?? new Date();
|
||||
if (Math.abs(now.getTime() - timestamp) > SIGNATURE_MAX_AGE_MS) {
|
||||
return { valid: false, reason: 'stale_timestamp' };
|
||||
}
|
||||
const source = `${input.method}${normalizeHubSpotSignatureUri(input.publicUri)}${input.rawBody}${input.timestamp}`;
|
||||
const expected = createHmac('sha256', input.clientSecret).update(source, 'utf8').digest('base64');
|
||||
const expectedBytes = Buffer.from(expected, 'utf8');
|
||||
const suppliedBytes = Buffer.from(input.signature, 'utf8');
|
||||
if (expectedBytes.length !== suppliedBytes.length) return { valid: false, reason: 'mismatch' };
|
||||
return timingSafeEqual(expectedBytes, suppliedBytes)
|
||||
? { valid: true }
|
||||
: { valid: false, reason: 'mismatch' };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { HubSpotObjectType, HubSpotRecord } from './contracts';
|
||||
|
||||
export interface HubSpotSyncCursor {
|
||||
after: string | null;
|
||||
phase: 'initial' | 'reconcile';
|
||||
}
|
||||
|
||||
export interface HubSpotSyncStore {
|
||||
getCursor(connectionId: string, objectType: HubSpotObjectType): Promise<HubSpotSyncCursor>;
|
||||
/** Records and the next cursor must commit in the same transaction. */
|
||||
commitPage(input: {
|
||||
connectionId: string;
|
||||
objectType: HubSpotObjectType;
|
||||
phase: 'initial' | 'reconcile';
|
||||
expectedAfter: string | null;
|
||||
nextAfter: string | null;
|
||||
records: readonly HubSpotSyncRecord[];
|
||||
completedAt: Date;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HubSpotSyncTokenProvider {
|
||||
getAccessToken(connectionId: string, signal?: AbortSignal): Promise<string>;
|
||||
}
|
||||
|
||||
export interface HubSpotSyncCrmClient {
|
||||
listObjects(
|
||||
accessToken: string,
|
||||
objectType: HubSpotObjectType,
|
||||
options?: { after?: string | null; signal?: AbortSignal },
|
||||
): Promise<{ results: HubSpotRecord[]; nextAfter: string | null }>;
|
||||
}
|
||||
|
||||
export interface HubSpotSyncRecord extends HubSpotRecord {
|
||||
contentHash: string;
|
||||
fetchedAt: Date;
|
||||
}
|
||||
|
||||
export interface HubSpotSyncPageResult {
|
||||
objectType: HubSpotObjectType;
|
||||
records: number;
|
||||
nextAfter: string | null;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export class HubSpotSyncService {
|
||||
constructor(
|
||||
private readonly store: HubSpotSyncStore,
|
||||
private readonly tokens: HubSpotSyncTokenProvider,
|
||||
private readonly crm: HubSpotSyncCrmClient,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
async syncNextPage(
|
||||
connectionId: string,
|
||||
objectType: HubSpotObjectType,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HubSpotSyncPageResult> {
|
||||
const cursor = await this.store.getCursor(connectionId, objectType);
|
||||
const accessToken = await this.tokens.getAccessToken(connectionId, signal);
|
||||
const page = await this.crm.listObjects(accessToken, objectType, {
|
||||
after: cursor.after,
|
||||
signal,
|
||||
});
|
||||
const fetchedAt = this.now();
|
||||
const records = page.results.map((record) => ({
|
||||
...record,
|
||||
fetchedAt,
|
||||
contentHash: hashHubSpotRecord(record),
|
||||
}));
|
||||
await this.store.commitPage({
|
||||
connectionId,
|
||||
objectType,
|
||||
phase: cursor.phase,
|
||||
expectedAfter: cursor.after,
|
||||
nextAfter: page.nextAfter,
|
||||
records,
|
||||
completedAt: fetchedAt,
|
||||
});
|
||||
return {
|
||||
objectType,
|
||||
records: records.length,
|
||||
nextAfter: page.nextAfter,
|
||||
complete: page.nextAfter === null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function hashHubSpotRecord(record: HubSpotRecord): string {
|
||||
const properties = Object.fromEntries(
|
||||
Object.entries(record.properties).sort(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
return createHash('sha256').update(JSON.stringify({
|
||||
id: record.id,
|
||||
properties,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
archived: record.archived,
|
||||
})).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Database } from '@pig/db';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { ApiEnv } from '../lib/mutation';
|
||||
import { apiError } from '../lib/mutation';
|
||||
import { CustomerLifecycleService } from '../services/customer-lifecycle';
|
||||
|
||||
const accountIdSchema = z.string().uuid();
|
||||
|
||||
export function createGrowthRoutes(db: Database): Hono<ApiEnv> {
|
||||
const routes = new Hono<ApiEnv>();
|
||||
const service = new CustomerLifecycleService(db);
|
||||
|
||||
routes.get('/api/growth', async (context) => context.json(await service.report()));
|
||||
routes.get('/api/growth/accounts/:id', async (context) => {
|
||||
const accountId = accountIdSchema.safeParse(context.req.param('id'));
|
||||
if (!accountId.success) {
|
||||
return context.json(apiError('invalid_account', 'Invalid account ID.', accountId.error.issues), 400);
|
||||
}
|
||||
const customer = await service.account(accountId.data);
|
||||
return customer
|
||||
? context.json(customer)
|
||||
: context.json(apiError('not_found', 'Growth account not found.'), 404);
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { verifyHubSpotV3Signature } from '../integrations/hubspot/signature';
|
||||
|
||||
const MAX_WEBHOOK_BYTES = 1_048_576;
|
||||
const webhookEventSchema = z.object({
|
||||
eventId: z.union([z.string(), z.number()]).transform(String),
|
||||
subscriptionId: z.union([z.string(), z.number()]).transform(String),
|
||||
portalId: z.union([z.string(), z.number()]).transform(String),
|
||||
appId: z.union([z.string(), z.number()]).transform(String),
|
||||
occurredAt: z.number().int().nonnegative(),
|
||||
objectId: z.union([z.string(), z.number()]).transform(String),
|
||||
subscriptionType: z.string().min(1).optional(),
|
||||
eventType: z.string().min(1).optional(),
|
||||
attemptNumber: z.number().int().nonnegative(),
|
||||
}).passthrough().refine(
|
||||
(event) => Boolean(event.subscriptionType || event.eventType),
|
||||
'A HubSpot event type is required.',
|
||||
);
|
||||
const webhookBatchSchema = z.array(webhookEventSchema).min(1).max(100);
|
||||
|
||||
export type VerifiedHubSpotWebhookEvent = z.infer<typeof webhookEventSchema>;
|
||||
|
||||
export interface HubSpotWebhookStore {
|
||||
enqueueVerifiedBatch(input: {
|
||||
events: readonly VerifiedHubSpotWebhookEvent[];
|
||||
rawBodyHash: string;
|
||||
receivedAt: Date;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HubSpotWebhookOptions {
|
||||
clientSecret: string;
|
||||
publicUri: string;
|
||||
appId: string;
|
||||
store: HubSpotWebhookStore;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export function createHubSpotWebhookRoutes(options: HubSpotWebhookOptions): Hono {
|
||||
const routes = new Hono();
|
||||
const now = options.now ?? (() => new Date());
|
||||
|
||||
routes.post('/api/webhooks/hubspot', async (context) => {
|
||||
const contentLength = context.req.header('content-length');
|
||||
if (contentLength && Number(contentLength) > MAX_WEBHOOK_BYTES) {
|
||||
return context.json({ error: 'HubSpot webhook body is too large.' }, 413);
|
||||
}
|
||||
const rawBody = await context.req.text();
|
||||
if (Buffer.byteLength(rawBody, 'utf8') > MAX_WEBHOOK_BYTES) {
|
||||
return context.json({ error: 'HubSpot webhook body is too large.' }, 413);
|
||||
}
|
||||
const signature = verifyHubSpotV3Signature({
|
||||
clientSecret: options.clientSecret,
|
||||
method: context.req.method,
|
||||
publicUri: options.publicUri,
|
||||
rawBody,
|
||||
signature: context.req.header('x-hubspot-signature-v3'),
|
||||
timestamp: context.req.header('x-hubspot-request-timestamp'),
|
||||
now: now(),
|
||||
});
|
||||
if (!signature.valid) return context.json({ error: 'Invalid HubSpot webhook signature.' }, 401);
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return context.json({ error: 'Invalid HubSpot webhook payload.' }, 400);
|
||||
}
|
||||
const parsed = webhookBatchSchema.safeParse(json);
|
||||
if (!parsed.success || parsed.data.some((event) => event.appId !== options.appId)) {
|
||||
return context.json({ error: 'Invalid HubSpot webhook payload.' }, 400);
|
||||
}
|
||||
await options.store.enqueueVerifiedBatch({
|
||||
events: parsed.data,
|
||||
rawBodyHash: createHash('sha256').update(rawBody, 'utf8').digest('hex'),
|
||||
receivedAt: now(),
|
||||
});
|
||||
return context.body(null, 204);
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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 { 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<readonly HubSpotConnectionSummary[]>;
|
||||
enqueueSync(connectionId: string, objectTypes: readonly HubSpotObjectType[], requestedByUserId: string): Promise<{ jobIds: string[] }>;
|
||||
}
|
||||
|
||||
export function createHubSpotRoutes(service: HubSpotRouteService): Hono<ApiEnv> {
|
||||
const routes = new Hono<ApiEnv>();
|
||||
|
||||
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');
|
||||
requireCapability(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;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
evaluateCustomerLifecycle,
|
||||
type CustomerLifecycleProjection,
|
||||
type LifecycleAllocationSnapshot,
|
||||
type LifecycleContractSnapshot,
|
||||
type LifecycleDealSnapshot,
|
||||
type LifecycleRequestSnapshot,
|
||||
} from '@pig/core';
|
||||
import {
|
||||
accounts,
|
||||
activities,
|
||||
allocations,
|
||||
capacityRequests,
|
||||
contractObligations,
|
||||
contracts,
|
||||
demandDeals,
|
||||
type Database,
|
||||
} from '@pig/db';
|
||||
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm';
|
||||
import { CapacityService } from './capacity';
|
||||
|
||||
export interface GrowthCustomer {
|
||||
account: {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string | null;
|
||||
customerSegment: string | null;
|
||||
ownerUserId: string | null;
|
||||
};
|
||||
lifecycle: CustomerLifecycleProjection;
|
||||
openDealCount: number;
|
||||
}
|
||||
|
||||
export interface GrowthIdleSupply {
|
||||
commitmentId: string;
|
||||
name: string;
|
||||
gpuType: string;
|
||||
gpuCount: number;
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
soldGpuHours: number;
|
||||
heldGpuHours: number;
|
||||
availableGpuHours: number;
|
||||
idleGpuHours: number;
|
||||
idleCostCents: number;
|
||||
breakEvenPriceCents: number | null;
|
||||
}
|
||||
|
||||
export interface GrowthReport {
|
||||
rulesetVersion: string;
|
||||
computedAt: string;
|
||||
customers: GrowthCustomer[];
|
||||
idleSupply: GrowthIdleSupply[];
|
||||
}
|
||||
|
||||
export class CustomerLifecycleService {
|
||||
private readonly capacity: CapacityService;
|
||||
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly clock: () => Date = () => new Date(),
|
||||
) {
|
||||
this.capacity = new CapacityService(db);
|
||||
}
|
||||
|
||||
async report(): Promise<GrowthReport> {
|
||||
const now = this.clock();
|
||||
const accountRows = await this.db
|
||||
.select({
|
||||
id: accounts.id,
|
||||
name: accounts.name,
|
||||
domain: accounts.domain,
|
||||
customerSegment: accounts.customerSegment,
|
||||
ownerUserId: accounts.ownerUserId,
|
||||
lastActivityAt: accounts.lastActivityAt,
|
||||
})
|
||||
.from(accounts)
|
||||
.where(and(or(eq(accounts.side, 'demand'), eq(accounts.side, 'both')), isNull(accounts.archivedAt)))
|
||||
.orderBy(desc(accounts.updatedAt))
|
||||
.limit(200);
|
||||
const accountIds = accountRows.map((account) => account.id);
|
||||
if (!accountIds.length) {
|
||||
const idleSupply = await this.readIdleSupply();
|
||||
return { rulesetVersion: 'growth-r1-2026-08-13', computedAt: now.toISOString(), customers: [], idleSupply };
|
||||
}
|
||||
|
||||
const [dealRows, contractRows, activityRows] = await Promise.all([
|
||||
this.db.select().from(demandDeals).where(inArray(demandDeals.accountId, accountIds)),
|
||||
this.db.select().from(contracts).where(and(inArray(contracts.accountId, accountIds), eq(contracts.side, 'demand'))),
|
||||
this.db.select().from(activities).where(inArray(activities.accountId, accountIds)).orderBy(desc(activities.occurredAt)).limit(2_000),
|
||||
]);
|
||||
const dealIds = dealRows.map((deal) => deal.id);
|
||||
const contractIds = contractRows.map((contract) => contract.id);
|
||||
const [requestRows, allocationRows, obligationRows, idleSupply] = await Promise.all([
|
||||
dealIds.length ? this.db.select().from(capacityRequests).where(inArray(capacityRequests.demandDealId, dealIds)) : [],
|
||||
dealIds.length ? this.db.select().from(allocations).where(inArray(allocations.demandDealId, dealIds)) : [],
|
||||
contractIds.length ? this.db.select().from(contractObligations).where(inArray(contractObligations.contractId, contractIds)) : [],
|
||||
this.readIdleSupply(),
|
||||
]);
|
||||
|
||||
const customers = accountRows.map((account): GrowthCustomer => {
|
||||
const deals = dealRows.filter((deal) => deal.accountId === account.id);
|
||||
const ownDealIds = new Set(deals.map((deal) => deal.id));
|
||||
const ownContracts = contractRows.filter((contract) => contract.accountId === account.id);
|
||||
const ownContractIds = new Set(ownContracts.map((contract) => contract.id));
|
||||
const recentActivity = activityRows.find((activity) => activity.accountId === account.id);
|
||||
const lifecycle = evaluateCustomerLifecycle({
|
||||
accountId: account.id,
|
||||
deals: deals.map((deal): LifecycleDealSnapshot => ({
|
||||
id: deal.id,
|
||||
stage: deal.stage,
|
||||
productLine: deal.productLine,
|
||||
parentDealId: deal.parentDealId,
|
||||
msaExecuted: deal.msaExecuted,
|
||||
lastActivityAt: deal.lastActivityAt,
|
||||
})),
|
||||
requests: requestRows
|
||||
.filter((request) => ownDealIds.has(request.demandDealId))
|
||||
.map((request): LifecycleRequestSnapshot => ({
|
||||
id: request.id,
|
||||
demandDealId: request.demandDealId,
|
||||
startsAt: request.startsAt,
|
||||
endsAt: request.endsAt,
|
||||
totalGpuHours: request.totalGpuHours == null ? null : Number(request.totalGpuHours),
|
||||
})),
|
||||
allocations: allocationRows
|
||||
.filter((allocation) => allocation.demandDealId && ownDealIds.has(allocation.demandDealId))
|
||||
.map((allocation): LifecycleAllocationSnapshot => ({
|
||||
id: allocation.id,
|
||||
demandDealId: allocation.demandDealId,
|
||||
status: allocation.status,
|
||||
gpuHours: Number(allocation.gpuHours),
|
||||
startsAt: allocation.startsAt,
|
||||
endsAt: allocation.endsAt,
|
||||
holdExpiresAt: allocation.holdExpiresAt,
|
||||
})),
|
||||
contracts: ownContracts.map((contract): LifecycleContractSnapshot => ({
|
||||
id: contract.id,
|
||||
status: contract.status,
|
||||
expiresAt: contract.expiresAt,
|
||||
isAutoRenew: contract.isAutoRenew,
|
||||
noticeDays: contract.noticeDays,
|
||||
})),
|
||||
obligations: obligationRows.filter((obligation) => ownContractIds.has(obligation.contractId)),
|
||||
lastActivityAt: recentActivity?.occurredAt ?? account.lastActivityAt,
|
||||
lastActivityId: recentActivity?.id,
|
||||
}, now);
|
||||
return {
|
||||
account: {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
domain: account.domain,
|
||||
customerSegment: account.customerSegment,
|
||||
ownerUserId: account.ownerUserId,
|
||||
},
|
||||
lifecycle,
|
||||
openDealCount: deals.filter((deal) => !['closed_won', 'closed_lost'].includes(deal.stage)).length,
|
||||
};
|
||||
}).sort((left, right) =>
|
||||
right.lifecycle.score - left.lifecycle.score || left.account.name.localeCompare(right.account.name),
|
||||
);
|
||||
|
||||
return {
|
||||
rulesetVersion: customers[0]?.lifecycle.rulesetVersion ?? 'growth-r1-2026-08-13',
|
||||
computedAt: now.toISOString(),
|
||||
customers,
|
||||
idleSupply,
|
||||
};
|
||||
}
|
||||
|
||||
async account(accountId: string): Promise<GrowthCustomer | null> {
|
||||
const report = await this.report();
|
||||
return report.customers.find((customer) => customer.account.id === accountId) ?? null;
|
||||
}
|
||||
|
||||
private async readIdleSupply(): Promise<GrowthIdleSupply[]> {
|
||||
const rows = await this.capacity.idleCapacity({ thresholdPct: 0.25, withinDays: 30 });
|
||||
return rows.map((row) => ({
|
||||
commitmentId: row.commitmentId,
|
||||
name: row.name,
|
||||
gpuType: row.gpuType,
|
||||
gpuCount: row.gpuCount,
|
||||
startsAt: row.startsAt,
|
||||
endsAt: row.endsAt,
|
||||
soldGpuHours: row.soldGpuHours,
|
||||
heldGpuHours: row.heldGpuHours,
|
||||
availableGpuHours: row.availableGpuHours,
|
||||
idleGpuHours: row.idleGpuHours,
|
||||
idleCostCents: row.idleCostCents,
|
||||
breakEvenPriceCents: row.breakEvenPriceCents,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user