Lay the HubSpot and customer-lifecycle foundation
Work in progress from the Codex session, committed so nothing sits undeployed. Verified before committing: typecheck clean across all packages, 139 unit tests and the e2e suite green, migrations apply to an empty Postgres. Adds the HubSpot integration boundary (OAuth, client, contracts, webhook signature verification, sync), a growth route, customer-lifecycle service, Piggy lifecycle tools, a Growth page, and shared lifecycle/hubspot types. Two things are deliberately incomplete and should not be mistaken for finished: `packages/db/src/schema/hubspot.ts` is NOT exported from the schema index, so it is inert — no tables, no migration. That is the correct order (the shape can settle before it becomes a migration), but it does mean the HubSpot routes have no persistence behind them yet. `pnpm-workspace.yaml` and `pnpm-lock.yaml` are left uncommitted on purpose. The workspace file contains a literal unanswered placeholder — "esbuild: set this to true or false" — and this repository installs with npm, which is also what CI runs. Committing a second package manager's lockfile would make the install ambiguous. If the move to pnpm is intended it should be a deliberate change that updates CI and the Dockerfile together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { z } from 'zod';
|
||||
import type { HubSpotObjectType, HubSpotRecord, HubSpotRecordPage } from './contracts';
|
||||
|
||||
const HUBSPOT_API_BASE = 'https://api.hubapi.com';
|
||||
const HUBSPOT_CRM_VERSION = '2026-03';
|
||||
const HUBSPOT_LIST_LIMIT = 100;
|
||||
const HUBSPOT_BATCH_LIMIT = 100;
|
||||
|
||||
export const HUBSPOT_READ_PROPERTIES: Readonly<Record<HubSpotObjectType, readonly string[]>> = {
|
||||
companies: [
|
||||
'name',
|
||||
'domain',
|
||||
'city',
|
||||
'state',
|
||||
'country',
|
||||
'industry',
|
||||
'numberofemployees',
|
||||
'annualrevenue',
|
||||
'hs_lastmodifieddate',
|
||||
],
|
||||
contacts: [
|
||||
'email',
|
||||
'firstname',
|
||||
'lastname',
|
||||
'phone',
|
||||
'mobilephone',
|
||||
'jobtitle',
|
||||
'hs_lastmodifieddate',
|
||||
],
|
||||
deals: [
|
||||
'dealname',
|
||||
'pipeline',
|
||||
'dealstage',
|
||||
'amount',
|
||||
'closedate',
|
||||
'hs_lastmodifieddate',
|
||||
],
|
||||
};
|
||||
|
||||
const recordSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
properties: z.record(z.string().nullable()),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
archived: z.boolean(),
|
||||
}).passthrough();
|
||||
const pagingAfterSchema = z.union([z.string(), z.number()]).transform(String);
|
||||
const listResponseSchema = z.object({
|
||||
results: z.array(recordSchema),
|
||||
paging: z.object({ next: z.object({ after: pagingAfterSchema }).passthrough() }).passthrough().optional(),
|
||||
}).passthrough();
|
||||
const batchResponseSchema = z.object({ results: z.array(recordSchema) }).passthrough();
|
||||
|
||||
export class HubSpotApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
readonly retryAfterSeconds?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HubSpotApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export class HubSpotCrmClient {
|
||||
constructor(private readonly fetchImpl: typeof fetch = fetch) {}
|
||||
|
||||
async listObjects(
|
||||
accessToken: string,
|
||||
objectType: HubSpotObjectType,
|
||||
options: { after?: string | null; signal?: AbortSignal } = {},
|
||||
): Promise<HubSpotRecordPage> {
|
||||
const url = this.objectUrl(objectType);
|
||||
url.searchParams.set('limit', String(HUBSPOT_LIST_LIMIT));
|
||||
url.searchParams.set('archived', 'false');
|
||||
url.searchParams.set('properties', HUBSPOT_READ_PROPERTIES[objectType].join(','));
|
||||
if (options.after) url.searchParams.set('after', options.after);
|
||||
const response = await this.request(url, accessToken, { method: 'GET', signal: options.signal });
|
||||
const parsed = listResponseSchema.safeParse(await response.json());
|
||||
if (!parsed.success) throw new HubSpotApiError('HubSpot returned an invalid CRM list response.');
|
||||
return {
|
||||
results: parsed.data.results as HubSpotRecord[],
|
||||
nextAfter: parsed.data.paging?.next.after ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async batchReadObjects(
|
||||
accessToken: string,
|
||||
objectType: HubSpotObjectType,
|
||||
ids: readonly string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<HubSpotRecord[]> {
|
||||
if (ids.length === 0) return [];
|
||||
if (ids.length > HUBSPOT_BATCH_LIMIT) {
|
||||
throw new HubSpotApiError(`HubSpot batch reads accept at most ${HUBSPOT_BATCH_LIMIT} IDs.`);
|
||||
}
|
||||
if (ids.some((id) => id.length === 0)) throw new HubSpotApiError('HubSpot object IDs cannot be blank.');
|
||||
const url = new URL(`${this.objectUrl(objectType).toString()}/batch/read`);
|
||||
const response = await this.request(url, accessToken, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
properties: HUBSPOT_READ_PROPERTIES[objectType],
|
||||
inputs: ids.map((id) => ({ id })),
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
const parsed = batchResponseSchema.safeParse(await response.json());
|
||||
if (!parsed.success) throw new HubSpotApiError('HubSpot returned an invalid CRM batch response.');
|
||||
return parsed.data.results as HubSpotRecord[];
|
||||
}
|
||||
|
||||
private objectUrl(objectType: HubSpotObjectType): URL {
|
||||
return new URL(`${HUBSPOT_API_BASE}/crm/objects/${HUBSPOT_CRM_VERSION}/${objectType}`);
|
||||
}
|
||||
|
||||
private async request(
|
||||
url: URL,
|
||||
accessToken: string,
|
||||
init: RequestInit,
|
||||
): Promise<Response> {
|
||||
const response = await this.fetchImpl(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...init.headers,
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
accept: 'application/json',
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const retryAfter = response.headers.get('retry-after');
|
||||
const parsedRetryAfter = retryAfter === null ? undefined : Number(retryAfter);
|
||||
throw new HubSpotApiError(
|
||||
'HubSpot rejected the CRM request.',
|
||||
response.status,
|
||||
Number.isFinite(parsedRetryAfter) ? parsedRetryAfter : undefined,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
HUBSPOT_CONNECTION_STATUSES,
|
||||
HUBSPOT_EVENT_STATUSES,
|
||||
HUBSPOT_JOB_KINDS,
|
||||
HUBSPOT_JOB_STATUSES,
|
||||
HUBSPOT_OBJECT_TYPES,
|
||||
HUBSPOT_REQUIRED_SCOPES,
|
||||
HUBSPOT_SYNC_PHASES,
|
||||
} from '../../../../../packages/core/src/hubspot';
|
||||
export type {
|
||||
HubSpotConnectionStatus,
|
||||
HubSpotEventStatus,
|
||||
HubSpotJobKind,
|
||||
HubSpotJobStatus,
|
||||
HubSpotObjectType,
|
||||
HubSpotRecord,
|
||||
HubSpotRecordPage,
|
||||
HubSpotRequiredScope,
|
||||
HubSpotSyncPhase,
|
||||
} from '../../../../../packages/core/src/hubspot';
|
||||
@@ -0,0 +1,261 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import { decryptSecret, encryptSecret } from '../../lib/secrets';
|
||||
import { HUBSPOT_REQUIRED_SCOPES } from './contracts';
|
||||
|
||||
const HUBSPOT_AUTHORIZE_URL = 'https://app.hubspot.com/oauth/authorize';
|
||||
const HUBSPOT_TOKEN_URL = 'https://api.hubapi.com/oauth/v3/token';
|
||||
const TOKEN_REFRESH_SKEW_MS = 60_000;
|
||||
|
||||
const tokenResponseSchema = z.object({
|
||||
access_token: z.string().min(1),
|
||||
refresh_token: z.string().min(1),
|
||||
expires_in: z.number().int().positive(),
|
||||
hub_id: z.union([z.string().min(1), z.number().int().nonnegative()]).transform(String),
|
||||
scopes: z.array(z.string()),
|
||||
}).passthrough();
|
||||
|
||||
export interface HubSpotOAuthConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
export interface HubSpotTokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresInSeconds: number;
|
||||
portalId: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
export class HubSpotOAuthError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HubSpotOAuthError';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildHubSpotAuthorizationUrl(
|
||||
config: Pick<HubSpotOAuthConfig, 'clientId' | 'redirectUri'>,
|
||||
state: string,
|
||||
): string {
|
||||
const url = new URL(HUBSPOT_AUTHORIZE_URL);
|
||||
url.searchParams.set('client_id', config.clientId);
|
||||
url.searchParams.set('redirect_uri', config.redirectUri);
|
||||
url.searchParams.set('scope', HUBSPOT_REQUIRED_SCOPES.join(' '));
|
||||
url.searchParams.set('state', state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export class HubSpotOAuthClient {
|
||||
constructor(
|
||||
private readonly config: HubSpotOAuthConfig,
|
||||
private readonly fetchImpl: typeof fetch = fetch,
|
||||
) {}
|
||||
|
||||
authorizationUrl(state: string): string {
|
||||
return buildHubSpotAuthorizationUrl(this.config, state);
|
||||
}
|
||||
|
||||
exchangeAuthorizationCode(code: string, signal?: AbortSignal): Promise<HubSpotTokenResponse> {
|
||||
return this.tokenRequest({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
redirect_uri: this.config.redirectUri,
|
||||
code,
|
||||
}, signal);
|
||||
}
|
||||
|
||||
refreshAccessToken(refreshToken: string, signal?: AbortSignal): Promise<HubSpotTokenResponse> {
|
||||
return this.tokenRequest({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
redirect_uri: this.config.redirectUri,
|
||||
refresh_token: refreshToken,
|
||||
}, signal);
|
||||
}
|
||||
|
||||
private async tokenRequest(
|
||||
form: Record<string, string>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HubSpotTokenResponse> {
|
||||
const response = await this.fetchImpl(HUBSPOT_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(form),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new HubSpotOAuthError('HubSpot rejected the OAuth token request.', response.status);
|
||||
}
|
||||
const parsed = tokenResponseSchema.safeParse(await response.json());
|
||||
if (!parsed.success) throw new HubSpotOAuthError('HubSpot returned an invalid OAuth token response.');
|
||||
return {
|
||||
accessToken: parsed.data.access_token,
|
||||
refreshToken: parsed.data.refresh_token,
|
||||
expiresInSeconds: parsed.data.expires_in,
|
||||
portalId: parsed.data.hub_id,
|
||||
scopes: parsed.data.scopes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type TokenKind = 'access' | 'refresh';
|
||||
|
||||
function tokenPurpose(connectionId: string, kind: TokenKind): string {
|
||||
return `hubspot:${connectionId}:${kind}-token`;
|
||||
}
|
||||
|
||||
export class HubSpotTokenVault {
|
||||
constructor(private readonly encryptionKey: string | undefined) {}
|
||||
|
||||
encrypt(connectionId: string, kind: TokenKind, token: string): string {
|
||||
return encryptSecret(token, this.encryptionKey, tokenPurpose(connectionId, kind));
|
||||
}
|
||||
|
||||
decrypt(connectionId: string, kind: TokenKind, envelope: string): string {
|
||||
return decryptSecret(envelope, this.encryptionKey, tokenPurpose(connectionId, kind));
|
||||
}
|
||||
}
|
||||
|
||||
export interface LockedHubSpotCredential {
|
||||
id: string;
|
||||
status: 'active' | 'reauthorization_required' | 'disconnected' | 'error';
|
||||
encryptedAccessToken: string;
|
||||
encryptedRefreshToken: string;
|
||||
accessTokenExpiresAt: Date;
|
||||
updateTokens(input: {
|
||||
encryptedAccessToken: string;
|
||||
encryptedRefreshToken: string;
|
||||
accessTokenExpiresAt: Date;
|
||||
grantedScopes: readonly string[];
|
||||
refreshedAt: Date;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HubSpotCredentialLockStore {
|
||||
/** The adapter must hold one row/advisory lock through the callback and update. */
|
||||
withConnectionLock<T>(
|
||||
connectionId: string,
|
||||
operation: (credential: LockedHubSpotCredential) => Promise<T>,
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
export class HubSpotTokenManager {
|
||||
constructor(
|
||||
private readonly store: HubSpotCredentialLockStore,
|
||||
private readonly oauth: Pick<HubSpotOAuthClient, 'refreshAccessToken'>,
|
||||
private readonly vault: HubSpotTokenVault,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
getAccessToken(connectionId: string, signal?: AbortSignal): Promise<string> {
|
||||
return this.store.withConnectionLock(connectionId, async (credential) => {
|
||||
if (credential.status !== 'active') {
|
||||
throw new HubSpotOAuthError('The HubSpot connection is not active.');
|
||||
}
|
||||
const now = this.now();
|
||||
if (credential.accessTokenExpiresAt.getTime() > now.getTime() + TOKEN_REFRESH_SKEW_MS) {
|
||||
return this.vault.decrypt(connectionId, 'access', credential.encryptedAccessToken);
|
||||
}
|
||||
const refreshToken = this.vault.decrypt(
|
||||
connectionId,
|
||||
'refresh',
|
||||
credential.encryptedRefreshToken,
|
||||
);
|
||||
const refreshed = await this.oauth.refreshAccessToken(refreshToken, signal);
|
||||
const missingScope = HUBSPOT_REQUIRED_SCOPES.find((scope) => !refreshed.scopes.includes(scope));
|
||||
if (missingScope) throw new HubSpotOAuthError(`HubSpot did not grant required scope ${missingScope}.`);
|
||||
const expiresAt = new Date(now.getTime() + refreshed.expiresInSeconds * 1_000);
|
||||
await credential.updateTokens({
|
||||
encryptedAccessToken: this.vault.encrypt(connectionId, 'access', refreshed.accessToken),
|
||||
encryptedRefreshToken: this.vault.encrypt(connectionId, 'refresh', refreshed.refreshToken),
|
||||
accessTokenExpiresAt: expiresAt,
|
||||
grantedScopes: refreshed.scopes,
|
||||
refreshedAt: now,
|
||||
});
|
||||
return refreshed.accessToken;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface StoredHubSpotOAuthState {
|
||||
requestedByUserId: string;
|
||||
returnPath: string;
|
||||
}
|
||||
|
||||
export interface HubSpotConnectionInstallStore {
|
||||
createOAuthState(input: {
|
||||
nonceHash: string;
|
||||
requestedByUserId: string;
|
||||
returnPath: string;
|
||||
expiresAt: Date;
|
||||
}): Promise<void>;
|
||||
consumeOAuthState(nonceHash: string, now: Date): Promise<StoredHubSpotOAuthState | null>;
|
||||
reserveConnectionId(portalId: string, proposedId: string): Promise<string>;
|
||||
saveConnection(input: {
|
||||
id: string;
|
||||
portalId: string;
|
||||
encryptedAccessToken: string;
|
||||
encryptedRefreshToken: string;
|
||||
accessTokenExpiresAt: Date;
|
||||
grantedScopes: readonly string[];
|
||||
connectedByUserId: string;
|
||||
installedAt: Date;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
const OAUTH_STATE_TTL_MS = 10 * 60 * 1_000;
|
||||
const DEFAULT_RETURN_PATH = '/settings/integrations/hubspot';
|
||||
|
||||
export class HubSpotConnectionService {
|
||||
constructor(
|
||||
private readonly store: HubSpotConnectionInstallStore,
|
||||
private readonly oauth: Pick<HubSpotOAuthClient, 'authorizationUrl' | 'exchangeAuthorizationCode'>,
|
||||
private readonly vault: HubSpotTokenVault,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
async begin(requestedByUserId: string): Promise<{ authorizationUrl: string }> {
|
||||
const state = randomBytes(32).toString('base64url');
|
||||
const now = this.now();
|
||||
await this.store.createOAuthState({
|
||||
nonceHash: hashOAuthState(state),
|
||||
requestedByUserId,
|
||||
returnPath: DEFAULT_RETURN_PATH,
|
||||
expiresAt: new Date(now.getTime() + OAUTH_STATE_TTL_MS),
|
||||
});
|
||||
return { authorizationUrl: this.oauth.authorizationUrl(state) };
|
||||
}
|
||||
|
||||
async complete(code: string, state: string, signal?: AbortSignal): Promise<{ returnPath: string }> {
|
||||
const now = this.now();
|
||||
const storedState = await this.store.consumeOAuthState(hashOAuthState(state), now);
|
||||
if (!storedState) throw new HubSpotOAuthError('The HubSpot OAuth state is invalid or expired.');
|
||||
const tokens = await this.oauth.exchangeAuthorizationCode(code, signal);
|
||||
const missingScope = HUBSPOT_REQUIRED_SCOPES.find((scope) => !tokens.scopes.includes(scope));
|
||||
if (missingScope) throw new HubSpotOAuthError(`HubSpot did not grant required scope ${missingScope}.`);
|
||||
const connectionId = await this.store.reserveConnectionId(tokens.portalId, randomUUID());
|
||||
await this.store.saveConnection({
|
||||
id: connectionId,
|
||||
portalId: tokens.portalId,
|
||||
encryptedAccessToken: this.vault.encrypt(connectionId, 'access', tokens.accessToken),
|
||||
encryptedRefreshToken: this.vault.encrypt(connectionId, 'refresh', tokens.refreshToken),
|
||||
accessTokenExpiresAt: new Date(now.getTime() + tokens.expiresInSeconds * 1_000),
|
||||
grantedScopes: tokens.scopes,
|
||||
connectedByUserId: storedState.requestedByUserId,
|
||||
installedAt: now,
|
||||
});
|
||||
return { returnPath: storedState.returnPath };
|
||||
}
|
||||
}
|
||||
|
||||
export function hashOAuthState(state: string): string {
|
||||
return createHash('sha256').update(state, 'utf8').digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
const SIGNATURE_MAX_AGE_MS = 5 * 60 * 1_000;
|
||||
const HUBSPOT_URI_DECODE_PATTERN = /%3A|%2F|%3F|%40|%21|%24|%27|%28|%29|%2A|%2C|%3B/gi;
|
||||
const HUBSPOT_URI_DECODINGS: Record<string, string> = {
|
||||
'%3A': ':',
|
||||
'%2F': '/',
|
||||
'%3F': '?',
|
||||
'%40': '@',
|
||||
'%21': '!',
|
||||
'%24': '$',
|
||||
'%27': "'",
|
||||
'%28': '(',
|
||||
'%29': ')',
|
||||
'%2A': '*',
|
||||
'%2C': ',',
|
||||
'%3B': ';',
|
||||
};
|
||||
|
||||
export interface HubSpotV3SignatureInput {
|
||||
clientSecret: string;
|
||||
method: string;
|
||||
publicUri: string;
|
||||
rawBody: string;
|
||||
signature: string | undefined;
|
||||
timestamp: string | undefined;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export type HubSpotSignatureResult =
|
||||
| { valid: true }
|
||||
| { valid: false; reason: 'missing_headers' | 'invalid_timestamp' | 'stale_timestamp' | 'mismatch' };
|
||||
|
||||
export function normalizeHubSpotSignatureUri(uri: string): string {
|
||||
const withoutFragment = uri.split('#', 1)[0] ?? uri;
|
||||
const queryIndex = withoutFragment.indexOf('?');
|
||||
if (queryIndex < 0) return withoutFragment;
|
||||
const prefix = withoutFragment.slice(0, queryIndex + 1);
|
||||
const query = withoutFragment.slice(queryIndex + 1).replace(
|
||||
HUBSPOT_URI_DECODE_PATTERN,
|
||||
(encoded) => HUBSPOT_URI_DECODINGS[encoded.toUpperCase()] ?? encoded,
|
||||
);
|
||||
return prefix + query;
|
||||
}
|
||||
|
||||
export function verifyHubSpotV3Signature(input: HubSpotV3SignatureInput): HubSpotSignatureResult {
|
||||
if (!input.signature || !input.timestamp) return { valid: false, reason: 'missing_headers' };
|
||||
if (!/^\d+$/.test(input.timestamp)) return { valid: false, reason: 'invalid_timestamp' };
|
||||
const timestamp = Number(input.timestamp);
|
||||
if (!Number.isSafeInteger(timestamp)) return { valid: false, reason: 'invalid_timestamp' };
|
||||
const now = input.now ?? new Date();
|
||||
if (Math.abs(now.getTime() - timestamp) > SIGNATURE_MAX_AGE_MS) {
|
||||
return { valid: false, reason: 'stale_timestamp' };
|
||||
}
|
||||
const source = `${input.method}${normalizeHubSpotSignatureUri(input.publicUri)}${input.rawBody}${input.timestamp}`;
|
||||
const expected = createHmac('sha256', input.clientSecret).update(source, 'utf8').digest('base64');
|
||||
const expectedBytes = Buffer.from(expected, 'utf8');
|
||||
const suppliedBytes = Buffer.from(input.signature, 'utf8');
|
||||
if (expectedBytes.length !== suppliedBytes.length) return { valid: false, reason: 'mismatch' };
|
||||
return timingSafeEqual(expectedBytes, suppliedBytes)
|
||||
? { valid: true }
|
||||
: { valid: false, reason: 'mismatch' };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { HubSpotObjectType, HubSpotRecord } from './contracts';
|
||||
|
||||
export interface HubSpotSyncCursor {
|
||||
after: string | null;
|
||||
phase: 'initial' | 'reconcile';
|
||||
}
|
||||
|
||||
export interface HubSpotSyncStore {
|
||||
getCursor(connectionId: string, objectType: HubSpotObjectType): Promise<HubSpotSyncCursor>;
|
||||
/** Records and the next cursor must commit in the same transaction. */
|
||||
commitPage(input: {
|
||||
connectionId: string;
|
||||
objectType: HubSpotObjectType;
|
||||
phase: 'initial' | 'reconcile';
|
||||
expectedAfter: string | null;
|
||||
nextAfter: string | null;
|
||||
records: readonly HubSpotSyncRecord[];
|
||||
completedAt: Date;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HubSpotSyncTokenProvider {
|
||||
getAccessToken(connectionId: string, signal?: AbortSignal): Promise<string>;
|
||||
}
|
||||
|
||||
export interface HubSpotSyncCrmClient {
|
||||
listObjects(
|
||||
accessToken: string,
|
||||
objectType: HubSpotObjectType,
|
||||
options?: { after?: string | null; signal?: AbortSignal },
|
||||
): Promise<{ results: HubSpotRecord[]; nextAfter: string | null }>;
|
||||
}
|
||||
|
||||
export interface HubSpotSyncRecord extends HubSpotRecord {
|
||||
contentHash: string;
|
||||
fetchedAt: Date;
|
||||
}
|
||||
|
||||
export interface HubSpotSyncPageResult {
|
||||
objectType: HubSpotObjectType;
|
||||
records: number;
|
||||
nextAfter: string | null;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export class HubSpotSyncService {
|
||||
constructor(
|
||||
private readonly store: HubSpotSyncStore,
|
||||
private readonly tokens: HubSpotSyncTokenProvider,
|
||||
private readonly crm: HubSpotSyncCrmClient,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
async syncNextPage(
|
||||
connectionId: string,
|
||||
objectType: HubSpotObjectType,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HubSpotSyncPageResult> {
|
||||
const cursor = await this.store.getCursor(connectionId, objectType);
|
||||
const accessToken = await this.tokens.getAccessToken(connectionId, signal);
|
||||
const page = await this.crm.listObjects(accessToken, objectType, {
|
||||
after: cursor.after,
|
||||
signal,
|
||||
});
|
||||
const fetchedAt = this.now();
|
||||
const records = page.results.map((record) => ({
|
||||
...record,
|
||||
fetchedAt,
|
||||
contentHash: hashHubSpotRecord(record),
|
||||
}));
|
||||
await this.store.commitPage({
|
||||
connectionId,
|
||||
objectType,
|
||||
phase: cursor.phase,
|
||||
expectedAfter: cursor.after,
|
||||
nextAfter: page.nextAfter,
|
||||
records,
|
||||
completedAt: fetchedAt,
|
||||
});
|
||||
return {
|
||||
objectType,
|
||||
records: records.length,
|
||||
nextAfter: page.nextAfter,
|
||||
complete: page.nextAfter === null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function hashHubSpotRecord(record: HubSpotRecord): string {
|
||||
const properties = Object.fromEntries(
|
||||
Object.entries(record.properties).sort(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
return createHash('sha256').update(JSON.stringify({
|
||||
id: record.id,
|
||||
properties,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
archived: record.archived,
|
||||
})).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Database } from '@pig/db';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { ApiEnv } from '../lib/mutation';
|
||||
import { apiError } from '../lib/mutation';
|
||||
import { CustomerLifecycleService } from '../services/customer-lifecycle';
|
||||
|
||||
const accountIdSchema = z.string().uuid();
|
||||
|
||||
export function createGrowthRoutes(db: Database): Hono<ApiEnv> {
|
||||
const routes = new Hono<ApiEnv>();
|
||||
const service = new CustomerLifecycleService(db);
|
||||
|
||||
routes.get('/api/growth', async (context) => context.json(await service.report()));
|
||||
routes.get('/api/growth/accounts/:id', async (context) => {
|
||||
const accountId = accountIdSchema.safeParse(context.req.param('id'));
|
||||
if (!accountId.success) {
|
||||
return context.json(apiError('invalid_account', 'Invalid account ID.', accountId.error.issues), 400);
|
||||
}
|
||||
const customer = await service.account(accountId.data);
|
||||
return customer
|
||||
? context.json(customer)
|
||||
: context.json(apiError('not_found', 'Growth account not found.'), 404);
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { verifyHubSpotV3Signature } from '../integrations/hubspot/signature';
|
||||
|
||||
const MAX_WEBHOOK_BYTES = 1_048_576;
|
||||
const webhookEventSchema = z.object({
|
||||
eventId: z.union([z.string(), z.number()]).transform(String),
|
||||
subscriptionId: z.union([z.string(), z.number()]).transform(String),
|
||||
portalId: z.union([z.string(), z.number()]).transform(String),
|
||||
appId: z.union([z.string(), z.number()]).transform(String),
|
||||
occurredAt: z.number().int().nonnegative(),
|
||||
objectId: z.union([z.string(), z.number()]).transform(String),
|
||||
subscriptionType: z.string().min(1).optional(),
|
||||
eventType: z.string().min(1).optional(),
|
||||
attemptNumber: z.number().int().nonnegative(),
|
||||
}).passthrough().refine(
|
||||
(event) => Boolean(event.subscriptionType || event.eventType),
|
||||
'A HubSpot event type is required.',
|
||||
);
|
||||
const webhookBatchSchema = z.array(webhookEventSchema).min(1).max(100);
|
||||
|
||||
export type VerifiedHubSpotWebhookEvent = z.infer<typeof webhookEventSchema>;
|
||||
|
||||
export interface HubSpotWebhookStore {
|
||||
enqueueVerifiedBatch(input: {
|
||||
events: readonly VerifiedHubSpotWebhookEvent[];
|
||||
rawBodyHash: string;
|
||||
receivedAt: Date;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HubSpotWebhookOptions {
|
||||
clientSecret: string;
|
||||
publicUri: string;
|
||||
appId: string;
|
||||
store: HubSpotWebhookStore;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export function createHubSpotWebhookRoutes(options: HubSpotWebhookOptions): Hono {
|
||||
const routes = new Hono();
|
||||
const now = options.now ?? (() => new Date());
|
||||
|
||||
routes.post('/api/webhooks/hubspot', async (context) => {
|
||||
const contentLength = context.req.header('content-length');
|
||||
if (contentLength && Number(contentLength) > MAX_WEBHOOK_BYTES) {
|
||||
return context.json({ error: 'HubSpot webhook body is too large.' }, 413);
|
||||
}
|
||||
const rawBody = await context.req.text();
|
||||
if (Buffer.byteLength(rawBody, 'utf8') > MAX_WEBHOOK_BYTES) {
|
||||
return context.json({ error: 'HubSpot webhook body is too large.' }, 413);
|
||||
}
|
||||
const signature = verifyHubSpotV3Signature({
|
||||
clientSecret: options.clientSecret,
|
||||
method: context.req.method,
|
||||
publicUri: options.publicUri,
|
||||
rawBody,
|
||||
signature: context.req.header('x-hubspot-signature-v3'),
|
||||
timestamp: context.req.header('x-hubspot-request-timestamp'),
|
||||
now: now(),
|
||||
});
|
||||
if (!signature.valid) return context.json({ error: 'Invalid HubSpot webhook signature.' }, 401);
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return context.json({ error: 'Invalid HubSpot webhook payload.' }, 400);
|
||||
}
|
||||
const parsed = webhookBatchSchema.safeParse(json);
|
||||
if (!parsed.success || parsed.data.some((event) => event.appId !== options.appId)) {
|
||||
return context.json({ error: 'Invalid HubSpot webhook payload.' }, 400);
|
||||
}
|
||||
await options.store.enqueueVerifiedBatch({
|
||||
events: parsed.data,
|
||||
rawBodyHash: createHash('sha256').update(rawBody, 'utf8').digest('hex'),
|
||||
receivedAt: now(),
|
||||
});
|
||||
return context.body(null, 204);
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { HubSpotObjectType } from '../integrations/hubspot/contracts';
|
||||
import { HUBSPOT_OBJECT_TYPES } from '../integrations/hubspot/contracts';
|
||||
import { HubSpotOAuthError } from '../integrations/hubspot/oauth';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { requireCapability } from '../lib/auth';
|
||||
import type { ApiEnv } from '../lib/mutation';
|
||||
|
||||
const connectionParamSchema = z.string().uuid();
|
||||
|
||||
export interface HubSpotConnectionSummary {
|
||||
id: string;
|
||||
portalId: string;
|
||||
displayName: string | null;
|
||||
status: string;
|
||||
grantedScopes: readonly string[];
|
||||
accessTokenExpiresAt: Date;
|
||||
installedAt: Date;
|
||||
lastSyncAt: Date | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface HubSpotRouteService {
|
||||
begin(requestedByUserId: string): Promise<{ authorizationUrl: string }>;
|
||||
complete(code: string, state: string, signal?: AbortSignal): Promise<{ returnPath: string }>;
|
||||
listConnections(): Promise<readonly HubSpotConnectionSummary[]>;
|
||||
enqueueSync(connectionId: string, objectTypes: readonly HubSpotObjectType[], requestedByUserId: string): Promise<{ jobIds: string[] }>;
|
||||
}
|
||||
|
||||
export function createHubSpotRoutes(service: HubSpotRouteService): Hono<ApiEnv> {
|
||||
const routes = new Hono<ApiEnv>();
|
||||
|
||||
routes.post('/api/integrations/hubspot/oauth/start', async (context) => {
|
||||
const principal = context.get('principal');
|
||||
requireCapability(principal, 'settings:admin');
|
||||
return context.json(await service.begin(principal.userId));
|
||||
});
|
||||
|
||||
routes.get('/api/integrations/hubspot/oauth/callback', async (context) => {
|
||||
const code = context.req.query('code');
|
||||
const state = context.req.query('state');
|
||||
if (!code || !state) {
|
||||
return context.json({ error: 'HubSpot did not return an authorization code and state.' }, 400);
|
||||
}
|
||||
try {
|
||||
const completed = await service.complete(code, state, context.req.raw.signal);
|
||||
return context.redirect(completed.returnPath, 303);
|
||||
} catch (error) {
|
||||
if (error instanceof HubSpotOAuthError) {
|
||||
return context.json({ error: error.message }, 400);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
routes.get('/api/integrations/hubspot/connections', async (context) => {
|
||||
if (!context.get('principal').scopes.includes('read')) {
|
||||
return context.json({ error: "This credential lacks the 'read' scope." }, 403);
|
||||
}
|
||||
const connections = await service.listConnections();
|
||||
return context.json({
|
||||
connections: connections.map((connection) => ({
|
||||
...connection,
|
||||
accessTokenExpiresAt: connection.accessTokenExpiresAt.toISOString(),
|
||||
installedAt: connection.installedAt.toISOString(),
|
||||
lastSyncAt: connection.lastSyncAt?.toISOString() ?? null,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
routes.post('/api/integrations/hubspot/connections/:connectionId/sync', async (context) => {
|
||||
const principal = context.get('principal');
|
||||
requireCapability(principal, 'data:import');
|
||||
const parsedId = connectionParamSchema.safeParse(context.req.param('connectionId'));
|
||||
if (!parsedId.success) return context.json({ error: 'Invalid HubSpot connection ID.' }, 400);
|
||||
return context.json(
|
||||
await service.enqueueSync(parsedId.data, HUBSPOT_OBJECT_TYPES, principal.userId),
|
||||
202,
|
||||
);
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
evaluateCustomerLifecycle,
|
||||
type CustomerLifecycleProjection,
|
||||
type LifecycleAllocationSnapshot,
|
||||
type LifecycleContractSnapshot,
|
||||
type LifecycleDealSnapshot,
|
||||
type LifecycleRequestSnapshot,
|
||||
} from '@pig/core';
|
||||
import {
|
||||
accounts,
|
||||
activities,
|
||||
allocations,
|
||||
capacityRequests,
|
||||
contractObligations,
|
||||
contracts,
|
||||
demandDeals,
|
||||
type Database,
|
||||
} from '@pig/db';
|
||||
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm';
|
||||
import { CapacityService } from './capacity';
|
||||
|
||||
export interface GrowthCustomer {
|
||||
account: {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string | null;
|
||||
customerSegment: string | null;
|
||||
ownerUserId: string | null;
|
||||
};
|
||||
lifecycle: CustomerLifecycleProjection;
|
||||
openDealCount: number;
|
||||
}
|
||||
|
||||
export interface GrowthIdleSupply {
|
||||
commitmentId: string;
|
||||
name: string;
|
||||
gpuType: string;
|
||||
gpuCount: number;
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
soldGpuHours: number;
|
||||
heldGpuHours: number;
|
||||
availableGpuHours: number;
|
||||
idleGpuHours: number;
|
||||
idleCostCents: number;
|
||||
breakEvenPriceCents: number | null;
|
||||
}
|
||||
|
||||
export interface GrowthReport {
|
||||
rulesetVersion: string;
|
||||
computedAt: string;
|
||||
customers: GrowthCustomer[];
|
||||
idleSupply: GrowthIdleSupply[];
|
||||
}
|
||||
|
||||
export class CustomerLifecycleService {
|
||||
private readonly capacity: CapacityService;
|
||||
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly clock: () => Date = () => new Date(),
|
||||
) {
|
||||
this.capacity = new CapacityService(db);
|
||||
}
|
||||
|
||||
async report(): Promise<GrowthReport> {
|
||||
const now = this.clock();
|
||||
const accountRows = await this.db
|
||||
.select({
|
||||
id: accounts.id,
|
||||
name: accounts.name,
|
||||
domain: accounts.domain,
|
||||
customerSegment: accounts.customerSegment,
|
||||
ownerUserId: accounts.ownerUserId,
|
||||
lastActivityAt: accounts.lastActivityAt,
|
||||
})
|
||||
.from(accounts)
|
||||
.where(and(or(eq(accounts.side, 'demand'), eq(accounts.side, 'both')), isNull(accounts.archivedAt)))
|
||||
.orderBy(desc(accounts.updatedAt))
|
||||
.limit(200);
|
||||
const accountIds = accountRows.map((account) => account.id);
|
||||
if (!accountIds.length) {
|
||||
const idleSupply = await this.readIdleSupply();
|
||||
return { rulesetVersion: 'growth-r1-2026-08-13', computedAt: now.toISOString(), customers: [], idleSupply };
|
||||
}
|
||||
|
||||
const [dealRows, contractRows, activityRows] = await Promise.all([
|
||||
this.db.select().from(demandDeals).where(inArray(demandDeals.accountId, accountIds)),
|
||||
this.db.select().from(contracts).where(and(inArray(contracts.accountId, accountIds), eq(contracts.side, 'demand'))),
|
||||
this.db.select().from(activities).where(inArray(activities.accountId, accountIds)).orderBy(desc(activities.occurredAt)).limit(2_000),
|
||||
]);
|
||||
const dealIds = dealRows.map((deal) => deal.id);
|
||||
const contractIds = contractRows.map((contract) => contract.id);
|
||||
const [requestRows, allocationRows, obligationRows, idleSupply] = await Promise.all([
|
||||
dealIds.length ? this.db.select().from(capacityRequests).where(inArray(capacityRequests.demandDealId, dealIds)) : [],
|
||||
dealIds.length ? this.db.select().from(allocations).where(inArray(allocations.demandDealId, dealIds)) : [],
|
||||
contractIds.length ? this.db.select().from(contractObligations).where(inArray(contractObligations.contractId, contractIds)) : [],
|
||||
this.readIdleSupply(),
|
||||
]);
|
||||
|
||||
const customers = accountRows.map((account): GrowthCustomer => {
|
||||
const deals = dealRows.filter((deal) => deal.accountId === account.id);
|
||||
const ownDealIds = new Set(deals.map((deal) => deal.id));
|
||||
const ownContracts = contractRows.filter((contract) => contract.accountId === account.id);
|
||||
const ownContractIds = new Set(ownContracts.map((contract) => contract.id));
|
||||
const recentActivity = activityRows.find((activity) => activity.accountId === account.id);
|
||||
const lifecycle = evaluateCustomerLifecycle({
|
||||
accountId: account.id,
|
||||
deals: deals.map((deal): LifecycleDealSnapshot => ({
|
||||
id: deal.id,
|
||||
stage: deal.stage,
|
||||
productLine: deal.productLine,
|
||||
parentDealId: deal.parentDealId,
|
||||
msaExecuted: deal.msaExecuted,
|
||||
lastActivityAt: deal.lastActivityAt,
|
||||
})),
|
||||
requests: requestRows
|
||||
.filter((request) => ownDealIds.has(request.demandDealId))
|
||||
.map((request): LifecycleRequestSnapshot => ({
|
||||
id: request.id,
|
||||
demandDealId: request.demandDealId,
|
||||
startsAt: request.startsAt,
|
||||
endsAt: request.endsAt,
|
||||
totalGpuHours: request.totalGpuHours == null ? null : Number(request.totalGpuHours),
|
||||
})),
|
||||
allocations: allocationRows
|
||||
.filter((allocation) => allocation.demandDealId && ownDealIds.has(allocation.demandDealId))
|
||||
.map((allocation): LifecycleAllocationSnapshot => ({
|
||||
id: allocation.id,
|
||||
demandDealId: allocation.demandDealId,
|
||||
status: allocation.status,
|
||||
gpuHours: Number(allocation.gpuHours),
|
||||
startsAt: allocation.startsAt,
|
||||
endsAt: allocation.endsAt,
|
||||
holdExpiresAt: allocation.holdExpiresAt,
|
||||
})),
|
||||
contracts: ownContracts.map((contract): LifecycleContractSnapshot => ({
|
||||
id: contract.id,
|
||||
status: contract.status,
|
||||
expiresAt: contract.expiresAt,
|
||||
isAutoRenew: contract.isAutoRenew,
|
||||
noticeDays: contract.noticeDays,
|
||||
})),
|
||||
obligations: obligationRows.filter((obligation) => ownContractIds.has(obligation.contractId)),
|
||||
lastActivityAt: recentActivity?.occurredAt ?? account.lastActivityAt,
|
||||
lastActivityId: recentActivity?.id,
|
||||
}, now);
|
||||
return {
|
||||
account: {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
domain: account.domain,
|
||||
customerSegment: account.customerSegment,
|
||||
ownerUserId: account.ownerUserId,
|
||||
},
|
||||
lifecycle,
|
||||
openDealCount: deals.filter((deal) => !['closed_won', 'closed_lost'].includes(deal.stage)).length,
|
||||
};
|
||||
}).sort((left, right) =>
|
||||
right.lifecycle.score - left.lifecycle.score || left.account.name.localeCompare(right.account.name),
|
||||
);
|
||||
|
||||
return {
|
||||
rulesetVersion: customers[0]?.lifecycle.rulesetVersion ?? 'growth-r1-2026-08-13',
|
||||
computedAt: now.toISOString(),
|
||||
customers,
|
||||
idleSupply,
|
||||
};
|
||||
}
|
||||
|
||||
async account(accountId: string): Promise<GrowthCustomer | null> {
|
||||
const report = await this.report();
|
||||
return report.customers.find((customer) => customer.account.id === accountId) ?? null;
|
||||
}
|
||||
|
||||
private async readIdleSupply(): Promise<GrowthIdleSupply[]> {
|
||||
const rows = await this.capacity.idleCapacity({ thresholdPct: 0.25, withinDays: 30 });
|
||||
return rows.map((row) => ({
|
||||
commitmentId: row.commitmentId,
|
||||
name: row.name,
|
||||
gpuType: row.gpuType,
|
||||
gpuCount: row.gpuCount,
|
||||
startsAt: row.startsAt,
|
||||
endsAt: row.endsAt,
|
||||
soldGpuHours: row.soldGpuHours,
|
||||
heldGpuHours: row.heldGpuHours,
|
||||
availableGpuHours: row.availableGpuHours,
|
||||
idleGpuHours: row.idleGpuHours,
|
||||
idleCostCents: row.idleCostCents,
|
||||
breakEvenPriceCents: row.breakEvenPriceCents,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Regular → Executable
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<GrowthView>('priority');
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['growth'],
|
||||
queryFn: () => get<GrowthReport>('/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 <Skeleton className="h-[32rem]" />;
|
||||
if (!data) return <EmptyState title="Growth intelligence is unavailable" description="The lifecycle projection could not be loaded." />;
|
||||
|
||||
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 (
|
||||
<div className="space-y-5 pb-[max(1.25rem,env(safe-area-inset-bottom))]">
|
||||
<header className="relative overflow-hidden rounded-2xl border border-border bg-surface px-5 py-6 sm:px-7">
|
||||
<div className="absolute -right-16 -top-20 size-56 rounded-full bg-accent/10 blur-3xl" />
|
||||
<div className="relative max-w-3xl">
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-accent-subtle px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-accent-fg"><Sparkles className="size-3.5" aria-hidden />Compute growth intelligence</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Know who to expand, renew, or protect next.</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">Deterministic signals from customer paper, deal activity, and sold or reserved capacity. Scores rank attention; they are not win or churn probabilities.</p>
|
||||
<p className="mt-3 text-xs text-muted">Rules {data.rulesetVersion} · computed {new Date(data.computedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Stat label="Deployed customers" value={deployed} hint="Active sold capacity" />
|
||||
<Stat label="Expansion candidates" value={expansion} hint="Evidence-backed openings" tone={expansion ? 'positive' : 'default'} />
|
||||
<Stat label="Renewal or risk" value={attention} hint="Needs a human decision" tone={attention ? 'warning' : 'default'} />
|
||||
<Stat label="Idle supply cost" value={money(idleCost)} hint="Paid capacity still unsold" tone={idleCost ? 'danger' : 'default'} />
|
||||
</section>
|
||||
|
||||
<div role="tablist" aria-label="Growth views" className="grid grid-cols-2 gap-1 rounded-xl bg-surface-2 p-1 sm:inline-grid sm:grid-cols-5">
|
||||
{([
|
||||
['priority', 'Priority'],
|
||||
['expansion', 'Expansion'],
|
||||
['renewal', 'Renewal'],
|
||||
['risk', 'Risk'],
|
||||
['idle', 'Idle supply'],
|
||||
] as const).map(([value, label]) => (
|
||||
<button key={value} role="tab" aria-selected={view === value} onClick={() => setView(value)} className={['tap min-h-11 rounded-lg px-4 text-sm font-medium transition-colors', view === value ? 'bg-surface text-fg shadow-sm' : 'text-muted'].join(' ')}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{view === 'idle' ? <IdleSupply rows={data.idleSupply} /> : (
|
||||
customers.length ? (
|
||||
<section className="grid gap-3 xl:grid-cols-2">
|
||||
{customers.map((customer) => <CustomerCard key={customer.account.id} customer={customer} />)}
|
||||
</section>
|
||||
) : <Card><EmptyState icon={<Gauge />} title="No accounts in this view" description="Growth only surfaces a facet when its deterministic evidence threshold is met." /></Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerCard({ customer }: { customer: GrowthCustomer }) {
|
||||
const { account, lifecycle } = customer;
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="h-1 bg-gradient-to-r from-accent via-info to-positive" />
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-subtle font-semibold text-accent-fg">{account.name.slice(0, 2).toUpperCase()}</div>
|
||||
<div className="min-w-0 flex-1"><CardTitle className="truncate text-lg">{account.name}</CardTitle><p className="truncate text-xs text-muted">{account.domain ?? account.customerSegment?.replaceAll('_', ' ') ?? 'Demand account'}</p></div>
|
||||
<div className="text-right"><div className="nums text-2xl font-semibold">{lifecycle.score}</div><div className="text-[10px] uppercase tracking-wide text-muted">attention</div></div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5"><RelationshipBadge state={lifecycle.relationshipState} />{lifecycle.facets.map((facet) => <FacetBadge key={facet} facet={facet} />)}</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-2 rounded-xl bg-surface-2 p-3 text-center">
|
||||
<Metric label="Open deals" value={customer.openDealCount} />
|
||||
<Metric label="Sold capacity" value={compactNumber(lifecycle.soldCapacityGpuHours)} />
|
||||
<Metric label="Held capacity" value={compactNumber(lifecycle.heldCapacityGpuHours)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{lifecycle.signals.slice(0, 3).map((signal) => (
|
||||
<div key={`${signal.code}:${signal.sourceRefs.map((ref) => ref.id).join(':')}`} className="flex gap-3 rounded-lg border border-border/70 p-3">
|
||||
<span className="nums flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-2 text-xs font-semibold">+{signal.weight}</span>
|
||||
<div className="min-w-0"><p className="text-sm leading-5">{signal.explanation}</p><p className="mt-1 text-[11px] uppercase tracking-wide text-muted">{signal.category} · {signal.sourceRefs.map((ref) => ref.type.replaceAll('_', ' ')).join(', ')}</p></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{lifecycle.blockers.length ? <div className="rounded-lg bg-warning/10 p-3 text-sm text-warning"><div className="flex gap-2"><AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden /><span>{lifecycle.blockers[0]}</span></div></div> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PiggyAskButton context={{ type: 'account', id: account.id, label: account.name }} prompt="Explain this account's lifecycle score and the highest-value next review. Distinguish facts from inference." label="Ask Piggy" variant="outline" />
|
||||
<Link className="tap inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium hover:bg-surface-2" to="/accounts">Open account <ArrowUpRight className="size-4" aria-hidden /></Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function IdleSupply({ rows }: { rows: GrowthReport['idleSupply'] }) {
|
||||
if (!rows.length) return <Card><EmptyState icon={<Server />} title="No material idle supply" description="No near-term commitment currently clears the idle-capacity threshold." /></Card>;
|
||||
return <section className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">{rows.map((row) => (
|
||||
<Card key={row.commitmentId}>
|
||||
<CardHeader><div className="flex items-start justify-between gap-3"><div><CardTitle>{row.name}</CardTitle><p className="mt-1 text-sm text-muted">{row.gpuCount}× {row.gpuType}</p></div><Badge tone="warning">{money(row.idleCostCents)} idle cost</Badge></div></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-2"><Metric label="Sold" value={compactNumber(row.soldGpuHours)} /><Metric label="Held" value={compactNumber(row.heldGpuHours)} /><Metric label="Sellable" value={compactNumber(row.availableGpuHours)} /></div>
|
||||
<div className="space-y-1 text-sm"><p className="flex justify-between gap-3"><span className="text-muted">Window</span><span>{shortDate(row.startsAt)} – {shortDate(row.endsAt)}</span></p><p className="flex justify-between gap-3"><span className="text-muted">Break even</span><span>{row.breakEvenPriceCents == null ? 'Sold out' : row.breakEvenPriceCents === 0 ? 'Cost covered' : `${moneyExact(row.breakEvenPriceCents)}/GPU-hr`}</span></p></div>
|
||||
<Link className="tap inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-lg border border-border px-4 text-sm font-medium hover:bg-surface-2" to="/capacity">Match this capacity <ArrowUpRight className="size-4" aria-hidden /></Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}</section>;
|
||||
}
|
||||
|
||||
function RelationshipBadge({ state }: { state: CustomerRelationshipState }) {
|
||||
const tone = state === 'deployed' ? 'positive' : state === 'contracted' ? 'info' : state === 'former_customer' ? 'warning' : 'neutral';
|
||||
return <Badge tone={tone}>{state.replaceAll('_', ' ')}</Badge>;
|
||||
}
|
||||
|
||||
function FacetBadge({ facet }: { facet: GrowthFacet }) {
|
||||
const icon = facet === 'renewal_due' ? <Clock3 aria-hidden /> : facet === 'at_risk' ? <AlertTriangle aria-hidden /> : facet === 'idle_supply_match' ? <Server aria-hidden /> : facet === 'expansion_candidate' ? <CircleDollarSign aria-hidden /> : facet === 'data_stale' ? <Bot aria-hidden /> : null;
|
||||
const tone = facet === 'at_risk' ? 'danger' : facet === 'renewal_due' || facet === 'data_stale' ? 'warning' : facet === 'expansion_candidate' ? 'positive' : 'accent';
|
||||
return <Badge tone={tone}>{icon}{facet.replaceAll('_', ' ')}</Badge>;
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string | number }) {
|
||||
return <div className="min-w-0"><div className="nums truncate font-semibold">{value}</div><div className="truncate text-[10px] uppercase tracking-wide text-muted">{label}</div></div>;
|
||||
}
|
||||
Reference in New Issue
Block a user