diff --git a/apps/api/src/integrations/hubspot/client.ts b/apps/api/src/integrations/hubspot/client.ts new file mode 100644 index 0000000..4152214 --- /dev/null +++ b/apps/api/src/integrations/hubspot/client.ts @@ -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> = { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/api/src/integrations/hubspot/contracts.ts b/apps/api/src/integrations/hubspot/contracts.ts new file mode 100644 index 0000000..dfadbf2 --- /dev/null +++ b/apps/api/src/integrations/hubspot/contracts.ts @@ -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'; diff --git a/apps/api/src/integrations/hubspot/oauth.ts b/apps/api/src/integrations/hubspot/oauth.ts new file mode 100644 index 0000000..d169504 --- /dev/null +++ b/apps/api/src/integrations/hubspot/oauth.ts @@ -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, + 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 { + 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 { + 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, + signal?: AbortSignal, + ): Promise { + 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; +} + +export interface HubSpotCredentialLockStore { + /** The adapter must hold one row/advisory lock through the callback and update. */ + withConnectionLock( + connectionId: string, + operation: (credential: LockedHubSpotCredential) => Promise, + ): Promise; +} + +export class HubSpotTokenManager { + constructor( + private readonly store: HubSpotCredentialLockStore, + private readonly oauth: Pick, + private readonly vault: HubSpotTokenVault, + private readonly now: () => Date = () => new Date(), + ) {} + + getAccessToken(connectionId: string, signal?: AbortSignal): Promise { + 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; + consumeOAuthState(nonceHash: string, now: Date): Promise; + reserveConnectionId(portalId: string, proposedId: string): Promise; + saveConnection(input: { + id: string; + portalId: string; + encryptedAccessToken: string; + encryptedRefreshToken: string; + accessTokenExpiresAt: Date; + grantedScopes: readonly string[]; + connectedByUserId: string; + installedAt: Date; + }): Promise; +} + +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, + 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'); +} diff --git a/apps/api/src/integrations/hubspot/signature.ts b/apps/api/src/integrations/hubspot/signature.ts new file mode 100644 index 0000000..44a8952 --- /dev/null +++ b/apps/api/src/integrations/hubspot/signature.ts @@ -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 = { + '%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' }; +} diff --git a/apps/api/src/integrations/hubspot/sync.ts b/apps/api/src/integrations/hubspot/sync.ts new file mode 100644 index 0000000..4109b5e --- /dev/null +++ b/apps/api/src/integrations/hubspot/sync.ts @@ -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; + /** 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; +} + +export interface HubSpotSyncTokenProvider { + getAccessToken(connectionId: string, signal?: AbortSignal): Promise; +} + +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 { + 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'); +} diff --git a/apps/api/src/routes/growth.ts b/apps/api/src/routes/growth.ts new file mode 100644 index 0000000..3b065e3 --- /dev/null +++ b/apps/api/src/routes/growth.ts @@ -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 { + const routes = new Hono(); + 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; +} diff --git a/apps/api/src/routes/hubspot-webhook.ts b/apps/api/src/routes/hubspot-webhook.ts new file mode 100644 index 0000000..cda3ffa --- /dev/null +++ b/apps/api/src/routes/hubspot-webhook.ts @@ -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; + +export interface HubSpotWebhookStore { + enqueueVerifiedBatch(input: { + events: readonly VerifiedHubSpotWebhookEvent[]; + rawBodyHash: string; + receivedAt: Date; + }): Promise; +} + +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; +} diff --git a/apps/api/src/routes/hubspot.ts b/apps/api/src/routes/hubspot.ts new file mode 100644 index 0000000..d5407a1 --- /dev/null +++ b/apps/api/src/routes/hubspot.ts @@ -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; + 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'); + 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; +} diff --git a/apps/api/src/services/customer-lifecycle.ts b/apps/api/src/services/customer-lifecycle.ts new file mode 100644 index 0000000..f29b6da --- /dev/null +++ b/apps/api/src/services/customer-lifecycle.ts @@ -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 { + 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 { + const report = await this.report(); + return report.customers.find((customer) => customer.account.id === accountId) ?? null; + } + + private async readIdleSupply(): Promise { + 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, + })); + } +} diff --git a/apps/api/test/hubspot-foundation.test.ts b/apps/api/test/hubspot-foundation.test.ts new file mode 100644 index 0000000..775fd05 --- /dev/null +++ b/apps/api/test/hubspot-foundation.test.ts @@ -0,0 +1,244 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { HUBSPOT_REQUIRED_SCOPES } from '../../../packages/core/src/hubspot'; +import { HubSpotCrmClient, HUBSPOT_READ_PROPERTIES } from '../src/integrations/hubspot/client'; +import { + buildHubSpotAuthorizationUrl, + HubSpotOAuthClient, + HubSpotTokenManager, + HubSpotTokenVault, + type LockedHubSpotCredential, +} from '../src/integrations/hubspot/oauth'; +import { + normalizeHubSpotSignatureUri, + verifyHubSpotV3Signature, +} from '../src/integrations/hubspot/signature'; +import { HubSpotSyncService } from '../src/integrations/hubspot/sync'; +import { createHubSpotWebhookRoutes } from '../src/routes/hubspot-webhook'; + +const tokenPayload = { + access_token: 'access-token', + refresh_token: 'refresh-token', + expires_in: 1_800, + hub_id: 12345, + scopes: [...HUBSPOT_REQUIRED_SCOPES], +}; + +describe('HubSpot OAuth decisions', () => { + it('requests only the three read scopes and binds state plus redirect URI', () => { + const value = buildHubSpotAuthorizationUrl({ + clientId: 'client-id', + redirectUri: 'https://pig.example/api/integrations/hubspot/oauth/callback', + }, 'state-value'); + const url = new URL(value); + assert.equal(url.origin + url.pathname, 'https://app.hubspot.com/oauth/authorize'); + assert.equal(url.searchParams.get('state'), 'state-value'); + assert.equal(url.searchParams.get('redirect_uri'), 'https://pig.example/api/integrations/hubspot/oauth/callback'); + assert.deepEqual(url.searchParams.get('scope')?.split(' '), [...HUBSPOT_REQUIRED_SCOPES]); + assert.equal(HUBSPOT_REQUIRED_SCOPES.some((scope) => scope.endsWith('.write')), false); + }); + + it('uses the official form-encoded v3 token exchange', async () => { + let request: Request | undefined; + const oauth = new HubSpotOAuthClient({ + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://pig.example/callback', + }, async (input, init) => { + request = new Request(input, init); + return Response.json(tokenPayload); + }); + const tokens = await oauth.exchangeAuthorizationCode('authorization-code'); + assert.equal(request?.url, 'https://api.hubapi.com/oauth/v3/token'); + assert.equal(request?.method, 'POST'); + assert.equal(request?.headers.get('content-type'), 'application/x-www-form-urlencoded'); + const form = new URLSearchParams(await request?.text()); + assert.equal(form.get('grant_type'), 'authorization_code'); + assert.equal(form.get('code'), 'authorization-code'); + assert.equal(tokens.portalId, '12345'); + }); + + it('purpose-binds token envelopes to connection and token kind', () => { + const vault = new HubSpotTokenVault(randomBytes(32).toString('base64')); + const envelope = vault.encrypt('connection-a', 'access', 'secret-token'); + assert.equal(vault.decrypt('connection-a', 'access', envelope), 'secret-token'); + assert.throws(() => vault.decrypt('connection-a', 'refresh', envelope)); + assert.throws(() => vault.decrypt('connection-b', 'access', envelope)); + }); + + it('refreshes an expired token while the connection lock is held', async () => { + const vault = new HubSpotTokenVault(randomBytes(32).toString('base64')); + const events: string[] = []; + const credential: LockedHubSpotCredential = { + id: 'connection-a', + status: 'active', + encryptedAccessToken: vault.encrypt('connection-a', 'access', 'expired'), + encryptedRefreshToken: vault.encrypt('connection-a', 'refresh', 'stored-refresh'), + accessTokenExpiresAt: new Date('2026-01-01T00:00:00Z'), + updateTokens: async (input) => { + events.push('update'); + assert.equal(vault.decrypt('connection-a', 'access', input.encryptedAccessToken), 'new-access'); + }, + }; + const manager = new HubSpotTokenManager({ + withConnectionLock: async (_id, operation) => { + events.push('lock'); + const result = await operation(credential); + events.push('unlock'); + return result; + }, + }, { + refreshAccessToken: async (token) => { + events.push('refresh'); + assert.equal(token, 'stored-refresh'); + return { ...tokenPayload, accessToken: 'new-access', refreshToken: 'new-refresh', expiresInSeconds: 1_800, portalId: '12345' }; + }, + }, vault, () => new Date('2026-01-01T01:00:00Z')); + assert.equal(await manager.getAccessToken('connection-a'), 'new-access'); + assert.deepEqual(events, ['lock', 'refresh', 'update', 'unlock']); + }); +}); + +describe('HubSpot v3 request verification', () => { + it('uses the exact raw body and only HubSpot-approved query decoding', () => { + const clientSecret = 'client-secret'; + const method = 'POST'; + const publicUri = 'https://pig.example/api/webhooks/hubspot?next=%2Fcrm%3Fid%3D1'; + const normalized = 'https://pig.example/api/webhooks/hubspot?next=/crm?id%3D1'; + const rawBody = '[{"eventId":1}]'; + const timestamp = '1786453200000'; + const signature = createHmac('sha256', clientSecret) + .update(`${method}${normalized}${rawBody}${timestamp}`) + .digest('base64'); + assert.equal(normalizeHubSpotSignatureUri(publicUri), normalized); + assert.deepEqual(verifyHubSpotV3Signature({ + clientSecret, + method, + publicUri, + rawBody, + signature, + timestamp, + now: new Date(Number(timestamp)), + }), { valid: true }); + assert.equal(verifyHubSpotV3Signature({ + clientSecret, + method, + publicUri, + rawBody: `${rawBody} `, + signature, + timestamp, + now: new Date(Number(timestamp)), + }).valid, false); + }); + + it('rejects timestamps outside the five-minute window', () => { + assert.deepEqual(verifyHubSpotV3Signature({ + clientSecret: 'secret', + method: 'POST', + publicUri: 'https://pig.example/api/webhooks/hubspot', + rawBody: '[]', + signature: 'not-used', + timestamp: '1000', + now: new Date(301_001), + }), { valid: false, reason: 'stale_timestamp' }); + }); +}); + +describe('read-only CRM and resumable sync', () => { + it('lists explicit official properties and follows the opaque after cursor', async () => { + let request: Request | undefined; + const client = new HubSpotCrmClient(async (input, init) => { + request = new Request(input, init); + return Response.json({ results: [], paging: { next: { after: 'next-page' } } }); + }); + const page = await client.listObjects('token', 'companies', { after: 'current-page' }); + const url = new URL(request?.url ?? 'https://invalid'); + assert.equal(request?.method, 'GET'); + assert.equal(url.pathname, '/crm/objects/2026-03/companies'); + assert.equal(url.searchParams.get('after'), 'current-page'); + assert.equal(url.searchParams.get('properties'), HUBSPOT_READ_PROPERTIES.companies.join(',')); + assert.equal(request?.headers.get('authorization'), 'Bearer token'); + assert.equal(page.nextAfter, 'next-page'); + }); + + it('commits records and the next cursor as one page decision', async () => { + const commits: unknown[] = []; + const service = new HubSpotSyncService({ + getCursor: async () => ({ after: '17', phase: 'initial' }), + commitPage: async (input) => { commits.push(input); }, + }, { + getAccessToken: async () => 'access-token', + }, { + listObjects: async (_token, type, options) => { + assert.equal(type, 'contacts'); + assert.equal(options?.after, '17'); + return { + results: [{ + id: '42', + properties: { email: 'person@example.com' }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + archived: false, + }], + nextAfter: '18', + }; + }, + }, () => new Date('2026-01-03T00:00:00.000Z')); + const result = await service.syncNextPage('connection-a', 'contacts'); + assert.equal(result.complete, false); + assert.equal(result.nextAfter, '18'); + assert.equal(commits.length, 1); + assert.deepEqual( + Object.assign({}, commits[0], { records: undefined, completedAt: undefined }), + { + connectionId: 'connection-a', + objectType: 'contacts', + phase: 'initial', + expectedAfter: '17', + nextAfter: '18', + records: undefined, + completedAt: undefined, + }, + ); + }); +}); + +describe('HubSpot webhook boundary', () => { + it('verifies, bounds and durably hands off a batch before returning 204', async () => { + const body = JSON.stringify([{ + eventId: 1, + subscriptionId: 2, + portalId: 3, + appId: 4, + occurredAt: 1_786_453_200_000, + objectId: 5, + subscriptionType: 'contact.creation', + attemptNumber: 0, + }]); + const timestamp = '1786453200000'; + const publicUri = 'https://pig.example/api/webhooks/hubspot'; + const signature = createHmac('sha256', 'client-secret') + .update(`POST${publicUri}${body}${timestamp}`) + .digest('base64'); + let received = 0; + const routes = createHubSpotWebhookRoutes({ + clientSecret: 'client-secret', + publicUri, + appId: '4', + now: () => new Date(Number(timestamp)), + store: { enqueueVerifiedBatch: async ({ events }) => { received = events.length; } }, + }); + const response = await routes.request(publicUri, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-hubspot-signature-v3': signature, + 'x-hubspot-request-timestamp': timestamp, + }, + body, + }); + assert.equal(response.status, 204); + assert.equal(received, 1); + }); +}); diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts old mode 100644 new mode 100755 diff --git a/apps/piggy/src/chat-tools.ts b/apps/piggy/src/chat-tools.ts index d9ed02e..89ce623 100644 --- a/apps/piggy/src/chat-tools.ts +++ b/apps/piggy/src/chat-tools.ts @@ -15,6 +15,7 @@ import { eq } from 'drizzle-orm'; import { z } from 'zod'; import type { PiggyChatContext } from './chat'; import { defineTool, type AgentTool } from './provider'; +import { createAccountLifecycleTool } from './lifecycle-tools'; const noInput = z.object({}).strict(); @@ -35,6 +36,19 @@ export function createInteractivePigTools( }), ]; } + if (context.type === 'account') { + return [ + defineTool({ + name: 'pig_get_record', + description: + 'Read the PIG record currently in focus and its directly related commercial data. ' + + 'This tool accepts no id and cannot inspect a different record.', + inputSchema: noInput, + execute: async () => readFocusedRecord(db, context), + }), + createAccountLifecycleTool(db, context.id), + ]; + } return [ defineTool({ name: 'pig_get_record', diff --git a/apps/piggy/src/lifecycle-tools.ts b/apps/piggy/src/lifecycle-tools.ts new file mode 100644 index 0000000..1f002ab --- /dev/null +++ b/apps/piggy/src/lifecycle-tools.ts @@ -0,0 +1,54 @@ +import { evaluateCustomerLifecycle } from '@pig/core'; +import { + accounts, + activities, + allocations, + capacityRequests, + contractObligations, + contracts, + demandDeals, + type Database, +} from '@pig/db'; +import { and, desc, eq, inArray } from 'drizzle-orm'; +import { z } from 'zod'; +import { defineTool, type AgentTool } from './provider'; + +const noInput = z.object({}).strict(); + +export function createAccountLifecycleTool(db: Database, accountId: string): AgentTool { + return defineTool({ + name: 'pig_get_account_lifecycle', + description: 'Read the deterministic lifecycle score, source-backed signals, blockers, and sold or reserved capacity summary for the account in focus. Scores rank attention and are not probabilities or workload telemetry.', + inputSchema: noInput, + execute: async () => { + const [account] = await db.select().from(accounts).where(eq(accounts.id, accountId)).limit(1); + if (!account) throw new Error('The account in focus no longer exists.'); + const [deals, paperwork, recentActivity] = await Promise.all([ + db.select().from(demandDeals).where(eq(demandDeals.accountId, accountId)).limit(100), + db.select().from(contracts).where(and(eq(contracts.accountId, accountId), eq(contracts.side, 'demand'))).limit(100), + db.select().from(activities).where(eq(activities.accountId, accountId)).orderBy(desc(activities.occurredAt)).limit(1), + ]); + const dealIds = deals.map((deal) => deal.id); + const contractIds = paperwork.map((contract) => contract.id); + const [requests, reservations, obligations] = await Promise.all([ + dealIds.length ? db.select().from(capacityRequests).where(inArray(capacityRequests.demandDealId, dealIds)) : [], + dealIds.length ? db.select().from(allocations).where(inArray(allocations.demandDealId, dealIds)) : [], + contractIds.length ? db.select().from(contractObligations).where(inArray(contractObligations.contractId, contractIds)) : [], + ]); + return { + account: { id: account.id, name: account.name }, + lifecycle: evaluateCustomerLifecycle({ + accountId, + deals: deals.map((deal) => ({ ...deal })), + requests: requests.map((request) => ({ ...request, totalGpuHours: request.totalGpuHours == null ? null : Number(request.totalGpuHours) })), + allocations: reservations.map((allocation) => ({ ...allocation, gpuHours: Number(allocation.gpuHours) })), + contracts: paperwork, + obligations, + lastActivityAt: recentActivity[0]?.occurredAt ?? account.lastActivityAt, + lastActivityId: recentActivity[0]?.id, + }), + interpretation: 'Scores rank review attention. Capacity totals mean sold or reserved capacity, not customer workload utilization.', + }; + }, + }); +} diff --git a/apps/piggy/test/lifecycle-tools.test.ts b/apps/piggy/test/lifecycle-tools.test.ts new file mode 100644 index 0000000..679e989 --- /dev/null +++ b/apps/piggy/test/lifecycle-tools.test.ts @@ -0,0 +1,20 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import type { Database } from '@pig/db'; +import { createInteractivePigTools } from '../src/chat-tools'; + +describe('interactive lifecycle tool boundary', () => { + it('exposes deterministic lifecycle context only for the account in focus', () => { + const accountTools = createInteractivePigTools({} as Database, { + type: 'account', + id: '10000000-0000-4000-8000-000000000001', + }); + const contractTools = createInteractivePigTools({} as Database, { + type: 'contract', + id: '20000000-0000-4000-8000-000000000001', + }); + + assert.deepEqual(accountTools.map((tool) => tool.name), ['pig_get_record', 'pig_get_account_lifecycle']); + assert.equal(contractTools.some((tool) => tool.name === 'pig_get_account_lifecycle'), false); + }); +}); diff --git a/apps/web/src/pages/Growth.tsx b/apps/web/src/pages/Growth.tsx new file mode 100644 index 0000000..97ac354 --- /dev/null +++ b/apps/web/src/pages/Growth.tsx @@ -0,0 +1,181 @@ +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { + CustomerLifecycleProjection, + CustomerRelationshipState, + GrowthFacet, +} from '@pig/core'; +import { + AlertTriangle, + ArrowUpRight, + Bot, + CircleDollarSign, + Clock3, + Gauge, + Server, + Sparkles, +} from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { PiggyAskButton } from '@/components/PiggyChat'; +import { Badge, Card, CardContent, CardHeader, CardTitle, EmptyState, Skeleton, Stat } from '@/components/ui'; +import { compactNumber, get, money, moneyExact, shortDate } from '@/lib/api'; +import { usePageTitle } from '@/lib/title'; + +interface GrowthCustomer { + account: { id: string; name: string; domain: string | null; customerSegment: string | null; ownerUserId: string | null }; + lifecycle: CustomerLifecycleProjection; + openDealCount: number; +} + +interface GrowthReport { + rulesetVersion: string; + computedAt: string; + customers: GrowthCustomer[]; + idleSupply: Array<{ + commitmentId: string; + name: string; + gpuType: string; + gpuCount: number; + startsAt: string; + endsAt: string; + soldGpuHours: number; + heldGpuHours: number; + availableGpuHours: number; + idleGpuHours: number; + idleCostCents: number; + breakEvenPriceCents: number | null; + }>; +} + +type GrowthView = 'priority' | 'expansion' | 'renewal' | 'risk' | 'idle'; + +export function Growth() { + usePageTitle('Growth'); + const [view, setView] = useState('priority'); + const { data, isLoading } = useQuery({ + queryKey: ['growth'], + queryFn: () => get('/api/growth'), + }); + const customers = useMemo(() => { + if (!data) return []; + if (view === 'expansion') return data.customers.filter((row) => row.lifecycle.facets.includes('expansion_candidate')); + if (view === 'renewal') return data.customers.filter((row) => row.lifecycle.facets.includes('renewal_due')); + if (view === 'risk') return data.customers.filter((row) => row.lifecycle.facets.includes('at_risk') || row.lifecycle.facets.includes('data_stale')); + return data.customers; + }, [data, view]); + + if (isLoading) return ; + if (!data) return ; + + const deployed = data.customers.filter((row) => row.lifecycle.relationshipState === 'deployed').length; + const expansion = data.customers.filter((row) => row.lifecycle.facets.includes('expansion_candidate')).length; + const attention = data.customers.filter((row) => row.lifecycle.facets.includes('renewal_due') || row.lifecycle.facets.includes('at_risk')).length; + const idleCost = data.idleSupply.reduce((sum, row) => sum + row.idleCostCents, 0); + + return ( +
+
+
+
+
Compute growth intelligence
+

Know who to expand, renew, or protect next.

+

Deterministic signals from customer paper, deal activity, and sold or reserved capacity. Scores rank attention; they are not win or churn probabilities.

+

Rules {data.rulesetVersion} · computed {new Date(data.computedAt).toLocaleString()}

+
+
+ +
+ + + + +
+ +
+ {([ + ['priority', 'Priority'], + ['expansion', 'Expansion'], + ['renewal', 'Renewal'], + ['risk', 'Risk'], + ['idle', 'Idle supply'], + ] as const).map(([value, label]) => ( + + ))} +
+ + {view === 'idle' ? : ( + customers.length ? ( +
+ {customers.map((customer) => )} +
+ ) : } title="No accounts in this view" description="Growth only surfaces a facet when its deterministic evidence threshold is met." /> + )} +
+ ); +} + +function CustomerCard({ customer }: { customer: GrowthCustomer }) { + const { account, lifecycle } = customer; + return ( + +
+ +
+
{account.name.slice(0, 2).toUpperCase()}
+
{account.name}

{account.domain ?? account.customerSegment?.replaceAll('_', ' ') ?? 'Demand account'}

+
{lifecycle.score}
attention
+
+
{lifecycle.facets.map((facet) => )}
+
+ +
+ + + +
+
+ {lifecycle.signals.slice(0, 3).map((signal) => ( +
ref.id).join(':')}`} className="flex gap-3 rounded-lg border border-border/70 p-3"> + +{signal.weight} +

{signal.explanation}

{signal.category} · {signal.sourceRefs.map((ref) => ref.type.replaceAll('_', ' ')).join(', ')}

+
+ ))} +
+ {lifecycle.blockers.length ?
{lifecycle.blockers[0]}
: null} +
+ + Open account +
+
+ + ); +} + +function IdleSupply({ rows }: { rows: GrowthReport['idleSupply'] }) { + if (!rows.length) return } title="No material idle supply" description="No near-term commitment currently clears the idle-capacity threshold." />; + return
{rows.map((row) => ( + +
{row.name}

{row.gpuCount}× {row.gpuType}

{money(row.idleCostCents)} idle cost
+ +
+

Window{shortDate(row.startsAt)} – {shortDate(row.endsAt)}

Break even{row.breakEvenPriceCents == null ? 'Sold out' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${moneyExact(row.breakEvenPriceCents)}/GPU-hr`}

+ Match this capacity +
+
+ ))}
; +} + +function RelationshipBadge({ state }: { state: CustomerRelationshipState }) { + const tone = state === 'deployed' ? 'positive' : state === 'contracted' ? 'info' : state === 'former_customer' ? 'warning' : 'neutral'; + return {state.replaceAll('_', ' ')}; +} + +function FacetBadge({ facet }: { facet: GrowthFacet }) { + const icon = facet === 'renewal_due' ? : facet === 'at_risk' ? : facet === 'idle_supply_match' ? : facet === 'expansion_candidate' ? : facet === 'data_stale' ? : null; + const tone = facet === 'at_risk' ? 'danger' : facet === 'renewal_due' || facet === 'data_stale' ? 'warning' : facet === 'expansion_candidate' ? 'positive' : 'accent'; + return {icon}{facet.replaceAll('_', ' ')}; +} + +function Metric({ label, value }: { label: string; value: string | number }) { + return
{value}
{label}
; +} diff --git a/packages/core/src/hubspot.ts b/packages/core/src/hubspot.ts new file mode 100644 index 0000000..5014a08 --- /dev/null +++ b/packages/core/src/hubspot.ts @@ -0,0 +1,42 @@ +export const HUBSPOT_OBJECT_TYPES = ['companies', 'contacts', 'deals'] as const; +export type HubSpotObjectType = (typeof HUBSPOT_OBJECT_TYPES)[number]; + +export const HUBSPOT_REQUIRED_SCOPES = [ + 'crm.objects.companies.read', + 'crm.objects.contacts.read', + 'crm.objects.deals.read', +] as const; +export type HubSpotRequiredScope = (typeof HUBSPOT_REQUIRED_SCOPES)[number]; + +export const HUBSPOT_CONNECTION_STATUSES = [ + 'active', + 'reauthorization_required', + 'disconnected', + 'error', +] as const; +export type HubSpotConnectionStatus = (typeof HUBSPOT_CONNECTION_STATUSES)[number]; + +export const HUBSPOT_SYNC_PHASES = ['initial', 'reconcile'] as const; +export type HubSpotSyncPhase = (typeof HUBSPOT_SYNC_PHASES)[number]; + +export const HUBSPOT_EVENT_STATUSES = ['pending', 'processing', 'processed', 'failed'] as const; +export type HubSpotEventStatus = (typeof HUBSPOT_EVENT_STATUSES)[number]; + +export const HUBSPOT_JOB_KINDS = ['full_sync', 'fetch_record', 'reconcile'] as const; +export type HubSpotJobKind = (typeof HUBSPOT_JOB_KINDS)[number]; + +export const HUBSPOT_JOB_STATUSES = ['pending', 'processing', 'completed', 'failed'] as const; +export type HubSpotJobStatus = (typeof HUBSPOT_JOB_STATUSES)[number]; + +export interface HubSpotRecord { + id: string; + properties: Record; + createdAt: string; + updatedAt: string; + archived: boolean; +} + +export interface HubSpotRecordPage { + results: HubSpotRecord[]; + nextAfter: string | null; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7fd11d6..4f1bd74 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,3 +3,4 @@ export * from './margin'; export * from './permissions'; export * from './theme'; export * from './imports'; +export * from './lifecycle'; diff --git a/packages/core/src/lifecycle.ts b/packages/core/src/lifecycle.ts new file mode 100644 index 0000000..c5d40e6 --- /dev/null +++ b/packages/core/src/lifecycle.ts @@ -0,0 +1,366 @@ +import { + DEMAND_OPEN_STAGES, + type AllocationStatus, + type ContractStatus, + type DemandStage, + type ProductLine, +} from './ontology'; + +export const CUSTOMER_RELATIONSHIP_STATES = [ + 'prospect', + 'contracted', + 'deployed', + 'former_customer', +] as const; +export type CustomerRelationshipState = (typeof CUSTOMER_RELATIONSHIP_STATES)[number]; + +export const GROWTH_FACETS = [ + 'expansion_candidate', + 'renewal_due', + 'at_risk', + 'idle_supply_match', + 'coverage_gap', + 'data_stale', +] as const; +export type GrowthFacet = (typeof GROWTH_FACETS)[number]; + +export const GROWTH_SIGNAL_CATEGORIES = [ + 'expansion', + 'renewal', + 'risk', + 'coverage', + 'supply', +] as const; +export type GrowthSignalCategory = (typeof GROWTH_SIGNAL_CATEGORIES)[number]; + +export const GROWTH_RULESET_VERSION = 'growth-r1-2026-08-13'; + +export interface LifecycleSourceRef { + type: 'account' | 'demand_deal' | 'capacity_request' | 'allocation' | 'contract' | 'obligation' | 'activity' | 'commitment'; + id: string; +} + +export interface GrowthSignal { + code: string; + category: GrowthSignalCategory; + weight: number; + explanation: string; + sourceRefs: LifecycleSourceRef[]; +} + +export interface LifecycleDealSnapshot { + id: string; + stage: DemandStage; + productLine: ProductLine; + parentDealId?: string | null; + msaExecuted: boolean; + lastActivityAt?: Date | null; +} + +export interface LifecycleRequestSnapshot { + id: string; + demandDealId: string; + startsAt?: Date | null; + endsAt?: Date | null; + totalGpuHours?: number | null; +} + +export interface LifecycleAllocationSnapshot { + id: string; + demandDealId?: string | null; + status: AllocationStatus; + gpuHours: number; + startsAt: Date; + endsAt: Date; + holdExpiresAt?: Date | null; +} + +export interface LifecycleContractSnapshot { + id: string; + status: ContractStatus; + expiresAt?: Date | null; + isAutoRenew: boolean; + noticeDays?: number | null; +} + +export interface LifecycleObligationSnapshot { + id: string; + contractId: string; + dueAt: Date; + completedAt?: Date | null; +} + +export interface LifecycleIdleMatchSnapshot { + requestId: string; + commitmentId: string; + score: number; + rationale: string[]; +} + +export interface CustomerLifecycleInput { + accountId: string; + deals: LifecycleDealSnapshot[]; + requests: LifecycleRequestSnapshot[]; + allocations: LifecycleAllocationSnapshot[]; + contracts: LifecycleContractSnapshot[]; + obligations: LifecycleObligationSnapshot[]; + lastActivityAt?: Date | null; + lastActivityId?: string | null; + idleMatches?: LifecycleIdleMatchSnapshot[]; +} + +export interface CustomerLifecycleProjection { + relationshipState: CustomerRelationshipState; + facets: GrowthFacet[]; + score: number; + scoreByCategory: Record; + rulesetVersion: string; + computedAt: string; + signals: GrowthSignal[]; + blockers: string[]; + soldCapacityGpuHours: number; + heldCapacityGpuHours: number; +} + +const DAY_MS = 86_400_000; + +export function requestHasCoverage( + request: LifecycleRequestSnapshot, + allocations: readonly LifecycleAllocationSnapshot[], + now: Date, +): boolean { + return allocations.some((allocation) => { + if (allocation.demandDealId !== request.demandDealId || !reservesCapacity(allocation, now)) { + return false; + } + if (request.startsAt && allocation.startsAt > request.startsAt) return false; + if (request.endsAt && allocation.endsAt < request.endsAt) return false; + if (request.totalGpuHours != null && allocation.gpuHours < request.totalGpuHours) return false; + return true; + }); +} + +export function evaluateCustomerLifecycle( + input: CustomerLifecycleInput, + now = new Date(), +): CustomerLifecycleProjection { + const signals: GrowthSignal[] = []; + const blockers: string[] = []; + const dealById = new Map(input.deals.map((deal) => [deal.id, deal])); + const activeAllocations = input.allocations.filter( + (allocation) => allocation.status === 'active' && overlaps(allocation, now), + ); + const liveContracts = input.contracts.filter( + (contract) => + contract.status === 'executed' && (!contract.expiresAt || contract.expiresAt > now), + ); + const futureCommitted = input.allocations.some( + (allocation) => allocation.status === 'committed' && allocation.endsAt > now, + ); + const wasCustomer = + input.allocations.some((allocation) => allocation.status === 'completed' || allocation.endsAt <= now) + || input.contracts.some((contract) => + contract.status === 'expired' || contract.status === 'terminated' || Boolean(contract.expiresAt && contract.expiresAt <= now), + ) + || input.deals.some((deal) => deal.stage === 'closed_won'); + + const relationshipState: CustomerRelationshipState = activeAllocations.length + ? 'deployed' + : liveContracts.length || futureCommitted + ? 'contracted' + : wasCustomer + ? 'former_customer' + : 'prospect'; + + const openExpansionDeals = input.deals.filter( + (deal) => deal.stage === 'expansion' && isOpenStage(deal.stage), + ); + for (const deal of openExpansionDeals) { + addSignal(signals, 'open_expansion_deal', 'expansion', 35, 'An explicit expansion opportunity is open.', [ + { type: 'demand_deal', id: deal.id }, + ]); + } + + const uncoveredRequests = input.requests.filter( + (request) => !requestHasCoverage(request, input.allocations, now), + ); + for (const request of uncoveredRequests) { + addSignal(signals, 'uncovered_capacity_request', 'coverage', 30, 'A recorded capacity requirement has no sold or reserved capacity covering its requested shape and window.', [ + { type: 'capacity_request', id: request.id }, + { type: 'demand_deal', id: request.demandDealId }, + ]); + } + + const expansionAnchored = openExpansionDeals.length > 0 || uncoveredRequests.length > 0; + const lastActivityAt = latestDate([ + input.lastActivityAt, + ...input.deals.map((deal) => deal.lastActivityAt), + ]); + if ( + expansionAnchored + && (relationshipState === 'deployed' || relationshipState === 'contracted') + && lastActivityAt + && now.getTime() - lastActivityAt.getTime() <= 30 * DAY_MS + ) { + addSignal(signals, 'recent_customer_activity', 'expansion', 15, 'Recent recorded customer activity strengthens an already-evidenced expansion or coverage opportunity.', [ + { type: input.lastActivityId ? 'activity' : 'account', id: input.lastActivityId ?? input.accountId }, + ]); + } + + for (const match of input.idleMatches ?? []) { + addSignal(signals, 'idle_supply_match', 'supply', 10, `Authoritative capacity matching found idle supply: ${match.rationale.join(' ')}`, [ + { type: 'capacity_request', id: match.requestId }, + { type: 'commitment', id: match.commitmentId }, + ]); + } + if (uncoveredRequests.length && !(input.idleMatches?.length)) { + blockers.push('Customer-to-capacity matching is withheld until an allocation-level compliance decision is available.'); + } + + for (const contract of input.contracts) { + if (!contract.expiresAt) continue; + const daysToExpiry = Math.ceil((contract.expiresAt.getTime() - now.getTime()) / DAY_MS); + const noticeAt = contract.noticeDays == null + ? null + : new Date(contract.expiresAt.getTime() - contract.noticeDays * DAY_MS); + if (contract.isAutoRenew && noticeAt && noticeAt <= now && contract.expiresAt > now) { + addSignal(signals, 'renewal_notice_due', 'renewal', 50, 'The contractual notice window is open now; renewal terms need a decision.', [ + { type: 'contract', id: contract.id }, + ]); + } else if (contract.isAutoRenew && noticeAt && noticeAt.getTime() - now.getTime() <= 30 * DAY_MS && noticeAt > now) { + addSignal(signals, 'renewal_notice_soon', 'renewal', 35, 'The contractual renewal notice window opens within 30 days.', [ + { type: 'contract', id: contract.id }, + ]); + } + if (daysToExpiry >= 0 && daysToExpiry <= 120) { + addSignal(signals, 'contract_expiring', 'renewal', 25, `Executed customer paper expires in ${daysToExpiry} days.`, [ + { type: 'contract', id: contract.id }, + ]); + } + } + + for (const allocation of activeAllocations) { + const futureCoverage = input.allocations.some((candidate) => + candidate.id !== allocation.id + && candidate.demandDealId === allocation.demandDealId + && (candidate.status === 'planned' || candidate.status === 'committed' || candidate.status === 'active') + && reservesCapacity(candidate, now) + && candidate.endsAt > allocation.endsAt + && candidate.startsAt.getTime() <= allocation.endsAt.getTime() + 7 * DAY_MS, + ); + const daysToEnd = Math.ceil((allocation.endsAt.getTime() - now.getTime()) / DAY_MS); + if (daysToEnd >= 0 && daysToEnd <= 30 && !futureCoverage) { + addSignal(signals, 'allocation_ending_uncovered', 'risk', 30, `Active sold capacity ends in ${daysToEnd} days and no future reservation is recorded.`, [ + { type: 'allocation', id: allocation.id }, + ]); + } + const deal = allocation.demandDealId ? dealById.get(allocation.demandDealId) : undefined; + if (deal && !deal.msaExecuted) { + addSignal(signals, 'msa_missing_for_active_capacity', 'risk', 25, 'Active sold capacity is linked to a deal whose MSA evidence is not marked executed.', [ + { type: 'allocation', id: allocation.id }, + { type: 'demand_deal', id: deal.id }, + ]); + } + } + if (activeAllocations.length && !liveContracts.length) { + addSignal(signals, 'active_capacity_without_live_contract', 'risk', 40, 'Active sold capacity has no currently executed demand-side contract on the account.', activeAllocations.map((allocation) => ({ + type: 'allocation' as const, + id: allocation.id, + }))); + blockers.push('Confirm governing customer paper before changing or extending sold capacity.'); + } + + for (const obligation of input.obligations) { + if (!obligation.completedAt && obligation.dueAt < now) { + addSignal(signals, 'overdue_contract_obligation', 'risk', 25, 'A customer contract obligation is overdue.', [ + { type: 'obligation', id: obligation.id }, + { type: 'contract', id: obligation.contractId }, + ]); + } + } + + const staleDays = lastActivityAt + ? Math.floor((now.getTime() - lastActivityAt.getTime()) / DAY_MS) + : null; + if (staleDays == null || staleDays >= 90) { + addSignal(signals, 'crm_evidence_stale_90', 'risk', 25, staleDays == null + ? 'CRM evidence is stale: no customer activity is recorded. This does not establish customer disengagement.' + : `CRM evidence is stale: no customer activity is recorded in ${staleDays} days. This does not establish customer disengagement.`, [ + { type: input.lastActivityId ? 'activity' : 'account', id: input.lastActivityId ?? input.accountId }, + ]); + } else if (staleDays >= 60) { + addSignal(signals, 'crm_evidence_stale_60', 'risk', 15, `CRM evidence is stale: no customer activity is recorded in ${staleDays} days. This does not establish customer disengagement.`, [ + { type: input.lastActivityId ? 'activity' : 'account', id: input.lastActivityId ?? input.accountId }, + ]); + } + + signals.sort((left, right) => + right.weight - left.weight + || left.code.localeCompare(right.code) + || sourceKey(left).localeCompare(sourceKey(right)), + ); + blockers.sort(); + const scoreByCategory = Object.fromEntries( + GROWTH_SIGNAL_CATEGORIES.map((category) => [ + category, + Math.min(100, signals.filter((signal) => signal.category === category).reduce((sum, signal) => sum + signal.weight, 0)), + ]), + ) as Record; + const facets: GrowthFacet[] = []; + if (scoreByCategory.expansion >= 30 || scoreByCategory.coverage >= 30) facets.push('expansion_candidate'); + if (signals.some((signal) => signal.category === 'renewal')) facets.push('renewal_due'); + if (signals.some((signal) => signal.category === 'risk' && !signal.code.startsWith('crm_evidence_stale'))) facets.push('at_risk'); + if (signals.some((signal) => signal.category === 'supply')) facets.push('idle_supply_match'); + if (uncoveredRequests.length) facets.push('coverage_gap'); + if (signals.some((signal) => signal.code.startsWith('crm_evidence_stale'))) facets.push('data_stale'); + + return { + relationshipState, + facets, + score: Math.min(100, signals.reduce((sum, signal) => sum + signal.weight, 0)), + scoreByCategory, + rulesetVersion: GROWTH_RULESET_VERSION, + computedAt: now.toISOString(), + signals, + blockers, + soldCapacityGpuHours: input.allocations + .filter((allocation) => ['committed', 'active', 'completed'].includes(allocation.status)) + .reduce((sum, allocation) => sum + allocation.gpuHours, 0), + heldCapacityGpuHours: input.allocations + .filter((allocation) => allocation.status === 'planned' && reservesCapacity(allocation, now)) + .reduce((sum, allocation) => sum + allocation.gpuHours, 0), + }; +} + +function reservesCapacity(allocation: LifecycleAllocationSnapshot, now: Date): boolean { + if (allocation.status === 'released' || allocation.status === 'completed') return false; + return allocation.status !== 'planned' || !allocation.holdExpiresAt || allocation.holdExpiresAt > now; +} + +function overlaps(allocation: LifecycleAllocationSnapshot, now: Date): boolean { + return allocation.startsAt <= now && allocation.endsAt > now; +} + +function isOpenStage(stage: DemandStage): boolean { + return (DEMAND_OPEN_STAGES as readonly DemandStage[]).includes(stage); +} + +function latestDate(values: readonly (Date | null | undefined)[]): Date | null { + return values.reduce((latest, value) => + value && (!latest || value > latest) ? value : latest, null); +} + +function addSignal( + target: GrowthSignal[], + code: string, + category: GrowthSignalCategory, + weight: number, + explanation: string, + sourceRefs: LifecycleSourceRef[], +): void { + target.push({ code, category, weight, explanation, sourceRefs }); +} + +function sourceKey(signal: GrowthSignal): string { + return signal.sourceRefs.map((ref) => `${ref.type}:${ref.id}`).join('|'); +} diff --git a/packages/core/test/lifecycle.test.ts b/packages/core/test/lifecycle.test.ts new file mode 100644 index 0000000..e688c81 --- /dev/null +++ b/packages/core/test/lifecycle.test.ts @@ -0,0 +1,91 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { + evaluateCustomerLifecycle, + type CustomerLifecycleInput, +} from '../src/lifecycle'; + +const NOW = new Date('2026-08-13T12:00:00.000Z'); +const ACCOUNT_ID = '10000000-0000-4000-8000-000000000001'; +const DEAL_ID = '20000000-0000-4000-8000-000000000001'; + +function input(overrides: Partial = {}): CustomerLifecycleInput { + return { + accountId: ACCOUNT_ID, + deals: [], + requests: [], + allocations: [], + contracts: [], + obligations: [], + lastActivityAt: NOW, + ...overrides, + }; +} + +describe('customer lifecycle rules', () => { + it('uses allocation state and a fixed clock rather than treating a hold as deployment', () => { + const base = { + id: '30000000-0000-4000-8000-000000000001', + demandDealId: DEAL_ID, + gpuHours: 1_000, + startsAt: new Date('2026-08-01T00:00:00.000Z'), + endsAt: new Date('2026-09-01T00:00:00.000Z'), + }; + const held = evaluateCustomerLifecycle(input({ allocations: [{ ...base, status: 'planned', holdExpiresAt: new Date('2026-08-14T00:00:00.000Z') }] }), NOW); + const deployed = evaluateCustomerLifecycle(input({ allocations: [{ ...base, status: 'active' }] }), NOW); + + assert.equal(held.relationshipState, 'prospect'); + assert.equal(held.heldCapacityGpuHours, 1_000); + assert.equal(deployed.relationshipState, 'deployed'); + }); + + it('suppresses ending-capacity risk when a future reservation covers the same deal', () => { + const current = { + id: '30000000-0000-4000-8000-000000000001', + demandDealId: DEAL_ID, + status: 'active' as const, + gpuHours: 1_000, + startsAt: new Date('2026-07-01T00:00:00.000Z'), + endsAt: new Date('2026-08-20T00:00:00.000Z'), + }; + const future = { + ...current, + id: '30000000-0000-4000-8000-000000000002', + status: 'committed' as const, + startsAt: new Date('2026-08-20T00:00:00.000Z'), + endsAt: new Date('2026-10-01T00:00:00.000Z'), + }; + const result = evaluateCustomerLifecycle(input({ allocations: [current, future] }), NOW); + + assert.equal(result.signals.some((signal) => signal.code === 'allocation_ending_uncovered'), false); + }); + + it('describes missing activity as stale CRM evidence, never customer disengagement', () => { + const result = evaluateCustomerLifecycle(input({ lastActivityAt: null }), NOW); + const stale = result.signals.find((signal) => signal.code === 'crm_evidence_stale_90'); + + assert.match(stale?.explanation ?? '', /CRM evidence is stale/); + assert.match(stale?.explanation ?? '', /does not establish customer disengagement/); + }); + + it('orders explainable weighted signals deterministically', () => { + const result = evaluateCustomerLifecycle(input({ + deals: [{ id: DEAL_ID, stage: 'expansion', productLine: 'compute_reserved', msaExecuted: true, lastActivityAt: NOW }], + requests: [{ id: '40000000-0000-4000-8000-000000000001', demandDealId: DEAL_ID, totalGpuHours: 500 }], + contracts: [{ id: '50000000-0000-4000-8000-000000000001', status: 'executed', isAutoRenew: true, noticeDays: 30, expiresAt: new Date('2026-09-01T00:00:00.000Z') }], + }), NOW); + + assert.deepEqual(result.signals.map((signal) => signal.weight), [...result.signals.map((signal) => signal.weight)].sort((a, b) => b - a)); + assert.ok(result.signals.every((signal) => signal.explanation && signal.sourceRefs.length)); + assert.equal(result.rulesetVersion, 'growth-r1-2026-08-13'); + assert.equal(result.computedAt, NOW.toISOString()); + }); + + it('labels allocation volume only as sold or held capacity', () => { + const result = evaluateCustomerLifecycle(input(), NOW); + const serialized = JSON.stringify(result); + assert.doesNotMatch(serialized, /workload utilization|customer utilization/i); + assert.ok('soldCapacityGpuHours' in result); + assert.ok('heldCapacityGpuHours' in result); + }); +}); diff --git a/packages/db/src/schema/hubspot.ts b/packages/db/src/schema/hubspot.ts new file mode 100644 index 0000000..bf92b0a --- /dev/null +++ b/packages/db/src/schema/hubspot.ts @@ -0,0 +1,196 @@ +import { + index, + integer, + jsonb, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + uuid, +} from 'drizzle-orm/pg-core'; +import { users } from './identity'; + +export const hubspotConnections = pgTable( + 'hubspot_connections', + { + id: uuid('id').primaryKey().defaultRandom(), + portalId: text('portal_id').notNull(), + displayName: text('display_name'), + status: text('status').notNull().default('active'), + encryptedAccessToken: text('encrypted_access_token').notNull(), + encryptedRefreshToken: text('encrypted_refresh_token').notNull(), + accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }).notNull(), + grantedScopes: text('granted_scopes').array().notNull(), + connectedByUserId: uuid('connected_by_user_id').references(() => users.id, { + onDelete: 'set null', + }), + installedAt: timestamp('installed_at', { withTimezone: true }).notNull().defaultNow(), + refreshedAt: timestamp('refreshed_at', { withTimezone: true }), + disconnectedAt: timestamp('disconnected_at', { withTimezone: true }), + lastError: text('last_error'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('hubspot_connections_portal_unique').on(table.portalId), + index('hubspot_connections_status_idx').on(table.status), + ], +); + +export const hubspotOauthStates = pgTable( + 'hubspot_oauth_states', + { + id: uuid('id').primaryKey().defaultRandom(), + nonceHash: text('nonce_hash').notNull(), + requestedByUserId: uuid('requested_by_user_id').notNull().references(() => users.id, { + onDelete: 'cascade', + }), + returnPath: text('return_path').notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + consumedAt: timestamp('consumed_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('hubspot_oauth_states_nonce_unique').on(table.nonceHash), + index('hubspot_oauth_states_expiry_idx').on(table.expiresAt), + ], +); + +export const hubspotExternalRecords = pgTable( + 'hubspot_external_records', + { + id: uuid('id').primaryKey().defaultRandom(), + connectionId: uuid('connection_id').notNull().references(() => hubspotConnections.id, { + onDelete: 'cascade', + }), + objectType: text('object_type').notNull(), + externalId: text('external_id').notNull(), + properties: jsonb('properties').$type>().notNull(), + archivedAt: timestamp('archived_at', { withTimezone: true }), + externalCreatedAt: timestamp('external_created_at', { withTimezone: true }).notNull(), + externalUpdatedAt: timestamp('external_updated_at', { withTimezone: true }).notNull(), + fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull(), + contentHash: text('content_hash').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('hubspot_external_records_identity_unique').on( + table.connectionId, + table.objectType, + table.externalId, + ), + index('hubspot_external_records_updated_idx').on( + table.connectionId, + table.objectType, + table.externalUpdatedAt, + ), + ], +); + +export const hubspotExternalLinks = pgTable( + 'hubspot_external_links', + { + id: uuid('id').primaryKey().defaultRandom(), + connectionId: uuid('connection_id').notNull().references(() => hubspotConnections.id, { + onDelete: 'cascade', + }), + externalObjectType: text('external_object_type').notNull(), + externalId: text('external_id').notNull(), + localEntity: text('local_entity').notNull(), + localRecordId: uuid('local_record_id').notNull(), + linkedByUserId: uuid('linked_by_user_id').references(() => users.id, { + onDelete: 'set null', + }), + linkedAt: timestamp('linked_at', { withTimezone: true }).notNull().defaultNow(), + unlinkedAt: timestamp('unlinked_at', { withTimezone: true }), + }, + (table) => [ + uniqueIndex('hubspot_external_links_external_unique').on( + table.connectionId, + table.externalObjectType, + table.externalId, + ), + index('hubspot_external_links_local_idx').on( + table.connectionId, + table.localEntity, + table.localRecordId, + ), + ], +); + +export const hubspotSyncCursors = pgTable( + 'hubspot_sync_cursors', + { + connectionId: uuid('connection_id').notNull().references(() => hubspotConnections.id, { + onDelete: 'cascade', + }), + objectType: text('object_type').notNull(), + phase: text('phase').notNull().default('initial'), + after: text('after'), + pageStartedAt: timestamp('page_started_at', { withTimezone: true }), + lastCompletedAt: timestamp('last_completed_at', { withTimezone: true }), + lastError: text('last_error'), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [primaryKey({ columns: [table.connectionId, table.objectType] })], +); + +export const hubspotWebhookEvents = pgTable( + 'hubspot_webhook_events', + { + id: uuid('id').primaryKey().defaultRandom(), + connectionId: uuid('connection_id').references(() => hubspotConnections.id, { + onDelete: 'set null', + }), + portalId: text('portal_id').notNull(), + appId: text('app_id').notNull(), + eventId: text('event_id').notNull(), + eventType: text('event_type').notNull(), + objectId: text('object_id').notNull(), + occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(), + dedupeKey: text('dedupe_key').notNull(), + rawBodyHash: text('raw_body_hash').notNull(), + payload: jsonb('payload').$type>().notNull(), + status: text('status').notNull().default('pending'), + attempts: integer('attempts').notNull().default(0), + lastError: text('last_error'), + receivedAt: timestamp('received_at', { withTimezone: true }).notNull().defaultNow(), + processedAt: timestamp('processed_at', { withTimezone: true }), + }, + (table) => [ + uniqueIndex('hubspot_webhook_events_dedupe_unique').on(table.dedupeKey), + index('hubspot_webhook_events_pending_idx').on(table.status, table.receivedAt), + index('hubspot_webhook_events_portal_idx').on(table.portalId, table.occurredAt), + ], +); + +export const hubspotJobs = pgTable( + 'hubspot_jobs', + { + id: uuid('id').primaryKey().defaultRandom(), + connectionId: uuid('connection_id').notNull().references(() => hubspotConnections.id, { + onDelete: 'cascade', + }), + kind: text('kind').notNull(), + objectType: text('object_type'), + payload: jsonb('payload').$type>().notNull().default({}), + status: text('status').notNull().default('pending'), + attempts: integer('attempts').notNull().default(0), + runAfter: timestamp('run_after', { withTimezone: true }).notNull().defaultNow(), + lockedAt: timestamp('locked_at', { withTimezone: true }), + lockedBy: text('locked_by'), + lastError: text('last_error'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + completedAt: timestamp('completed_at', { withTimezone: true }), + }, + (table) => [ + index('hubspot_jobs_claim_idx').on(table.status, table.runAfter), + index('hubspot_jobs_connection_idx').on(table.connectionId, table.createdAt), + ], +); + +export type HubSpotConnection = typeof hubspotConnections.$inferSelect; +export type HubSpotExternalRecord = typeof hubspotExternalRecords.$inferSelect;