Files
pig/apps/api/src/services/customer-lifecycle.ts
T
karti 1318c0b841
CI / verify (push) Successful in 3m51s
Ship growth intelligence and demo polish
2026-08-13 04:44:14 -07:00

208 lines
7.4 KiB
TypeScript

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<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),
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<GrowthCustomer | null> {
const report = await this.report(accountId);
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,
}));
}
}