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[]; customersTruncated: boolean; 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(accountId?: string): 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), accountId ? eq(accounts.id, accountId) : undefined, )) .orderBy(desc(accounts.updatedAt)) .limit(accountId ? 1 : 201); const customersTruncated = accountRows.length > 200; if (customersTruncated) accountRows.length = 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: [], customersTruncated: false, 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, customersTruncated, idleSupply, }; } async account(accountId: string): Promise { const report = await this.report(accountId); 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, })); } }