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,193 @@
|
||||
import {
|
||||
evaluateCustomerLifecycle,
|
||||
type CustomerLifecycleProjection,
|
||||
type LifecycleAllocationSnapshot,
|
||||
type LifecycleContractSnapshot,
|
||||
type LifecycleDealSnapshot,
|
||||
type LifecycleRequestSnapshot,
|
||||
} from '@pig/core';
|
||||
import {
|
||||
accounts,
|
||||
activities,
|
||||
allocations,
|
||||
capacityRequests,
|
||||
contractObligations,
|
||||
contracts,
|
||||
demandDeals,
|
||||
type Database,
|
||||
} from '@pig/db';
|
||||
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm';
|
||||
import { CapacityService } from './capacity';
|
||||
|
||||
export interface GrowthCustomer {
|
||||
account: {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string | null;
|
||||
customerSegment: string | null;
|
||||
ownerUserId: string | null;
|
||||
};
|
||||
lifecycle: CustomerLifecycleProjection;
|
||||
openDealCount: number;
|
||||
}
|
||||
|
||||
export interface GrowthIdleSupply {
|
||||
commitmentId: string;
|
||||
name: string;
|
||||
gpuType: string;
|
||||
gpuCount: number;
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
soldGpuHours: number;
|
||||
heldGpuHours: number;
|
||||
availableGpuHours: number;
|
||||
idleGpuHours: number;
|
||||
idleCostCents: number;
|
||||
breakEvenPriceCents: number | null;
|
||||
}
|
||||
|
||||
export interface GrowthReport {
|
||||
rulesetVersion: string;
|
||||
computedAt: string;
|
||||
customers: GrowthCustomer[];
|
||||
idleSupply: GrowthIdleSupply[];
|
||||
}
|
||||
|
||||
export class CustomerLifecycleService {
|
||||
private readonly capacity: CapacityService;
|
||||
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly clock: () => Date = () => new Date(),
|
||||
) {
|
||||
this.capacity = new CapacityService(db);
|
||||
}
|
||||
|
||||
async report(): Promise<GrowthReport> {
|
||||
const now = this.clock();
|
||||
const accountRows = await this.db
|
||||
.select({
|
||||
id: accounts.id,
|
||||
name: accounts.name,
|
||||
domain: accounts.domain,
|
||||
customerSegment: accounts.customerSegment,
|
||||
ownerUserId: accounts.ownerUserId,
|
||||
lastActivityAt: accounts.lastActivityAt,
|
||||
})
|
||||
.from(accounts)
|
||||
.where(and(or(eq(accounts.side, 'demand'), eq(accounts.side, 'both')), isNull(accounts.archivedAt)))
|
||||
.orderBy(desc(accounts.updatedAt))
|
||||
.limit(200);
|
||||
const accountIds = accountRows.map((account) => account.id);
|
||||
if (!accountIds.length) {
|
||||
const idleSupply = await this.readIdleSupply();
|
||||
return { rulesetVersion: 'growth-r1-2026-08-13', computedAt: now.toISOString(), customers: [], idleSupply };
|
||||
}
|
||||
|
||||
const [dealRows, contractRows, activityRows] = await Promise.all([
|
||||
this.db.select().from(demandDeals).where(inArray(demandDeals.accountId, accountIds)),
|
||||
this.db.select().from(contracts).where(and(inArray(contracts.accountId, accountIds), eq(contracts.side, 'demand'))),
|
||||
this.db.select().from(activities).where(inArray(activities.accountId, accountIds)).orderBy(desc(activities.occurredAt)).limit(2_000),
|
||||
]);
|
||||
const dealIds = dealRows.map((deal) => deal.id);
|
||||
const contractIds = contractRows.map((contract) => contract.id);
|
||||
const [requestRows, allocationRows, obligationRows, idleSupply] = await Promise.all([
|
||||
dealIds.length ? this.db.select().from(capacityRequests).where(inArray(capacityRequests.demandDealId, dealIds)) : [],
|
||||
dealIds.length ? this.db.select().from(allocations).where(inArray(allocations.demandDealId, dealIds)) : [],
|
||||
contractIds.length ? this.db.select().from(contractObligations).where(inArray(contractObligations.contractId, contractIds)) : [],
|
||||
this.readIdleSupply(),
|
||||
]);
|
||||
|
||||
const customers = accountRows.map((account): GrowthCustomer => {
|
||||
const deals = dealRows.filter((deal) => deal.accountId === account.id);
|
||||
const ownDealIds = new Set(deals.map((deal) => deal.id));
|
||||
const ownContracts = contractRows.filter((contract) => contract.accountId === account.id);
|
||||
const ownContractIds = new Set(ownContracts.map((contract) => contract.id));
|
||||
const recentActivity = activityRows.find((activity) => activity.accountId === account.id);
|
||||
const lifecycle = evaluateCustomerLifecycle({
|
||||
accountId: account.id,
|
||||
deals: deals.map((deal): LifecycleDealSnapshot => ({
|
||||
id: deal.id,
|
||||
stage: deal.stage,
|
||||
productLine: deal.productLine,
|
||||
parentDealId: deal.parentDealId,
|
||||
msaExecuted: deal.msaExecuted,
|
||||
lastActivityAt: deal.lastActivityAt,
|
||||
})),
|
||||
requests: requestRows
|
||||
.filter((request) => ownDealIds.has(request.demandDealId))
|
||||
.map((request): LifecycleRequestSnapshot => ({
|
||||
id: request.id,
|
||||
demandDealId: request.demandDealId,
|
||||
startsAt: request.startsAt,
|
||||
endsAt: request.endsAt,
|
||||
totalGpuHours: request.totalGpuHours == null ? null : Number(request.totalGpuHours),
|
||||
})),
|
||||
allocations: allocationRows
|
||||
.filter((allocation) => allocation.demandDealId && ownDealIds.has(allocation.demandDealId))
|
||||
.map((allocation): LifecycleAllocationSnapshot => ({
|
||||
id: allocation.id,
|
||||
demandDealId: allocation.demandDealId,
|
||||
status: allocation.status,
|
||||
gpuHours: Number(allocation.gpuHours),
|
||||
startsAt: allocation.startsAt,
|
||||
endsAt: allocation.endsAt,
|
||||
holdExpiresAt: allocation.holdExpiresAt,
|
||||
})),
|
||||
contracts: ownContracts.map((contract): LifecycleContractSnapshot => ({
|
||||
id: contract.id,
|
||||
status: contract.status,
|
||||
expiresAt: contract.expiresAt,
|
||||
isAutoRenew: contract.isAutoRenew,
|
||||
noticeDays: contract.noticeDays,
|
||||
})),
|
||||
obligations: obligationRows.filter((obligation) => ownContractIds.has(obligation.contractId)),
|
||||
lastActivityAt: recentActivity?.occurredAt ?? account.lastActivityAt,
|
||||
lastActivityId: recentActivity?.id,
|
||||
}, now);
|
||||
return {
|
||||
account: {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
domain: account.domain,
|
||||
customerSegment: account.customerSegment,
|
||||
ownerUserId: account.ownerUserId,
|
||||
},
|
||||
lifecycle,
|
||||
openDealCount: deals.filter((deal) => !['closed_won', 'closed_lost'].includes(deal.stage)).length,
|
||||
};
|
||||
}).sort((left, right) =>
|
||||
right.lifecycle.score - left.lifecycle.score || left.account.name.localeCompare(right.account.name),
|
||||
);
|
||||
|
||||
return {
|
||||
rulesetVersion: customers[0]?.lifecycle.rulesetVersion ?? 'growth-r1-2026-08-13',
|
||||
computedAt: now.toISOString(),
|
||||
customers,
|
||||
idleSupply,
|
||||
};
|
||||
}
|
||||
|
||||
async account(accountId: string): Promise<GrowthCustomer | null> {
|
||||
const report = await this.report();
|
||||
return report.customers.find((customer) => customer.account.id === accountId) ?? null;
|
||||
}
|
||||
|
||||
private async readIdleSupply(): Promise<GrowthIdleSupply[]> {
|
||||
const rows = await this.capacity.idleCapacity({ thresholdPct: 0.25, withinDays: 30 });
|
||||
return rows.map((row) => ({
|
||||
commitmentId: row.commitmentId,
|
||||
name: row.name,
|
||||
gpuType: row.gpuType,
|
||||
gpuCount: row.gpuCount,
|
||||
startsAt: row.startsAt,
|
||||
endsAt: row.endsAt,
|
||||
soldGpuHours: row.soldGpuHours,
|
||||
heldGpuHours: row.heldGpuHours,
|
||||
availableGpuHours: row.availableGpuHours,
|
||||
idleGpuHours: row.idleGpuHours,
|
||||
idleCostCents: row.idleCostCents,
|
||||
breakEvenPriceCents: row.breakEvenPriceCents,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user