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');
|
||||
}
|
||||
Reference in New Issue
Block a user