This commit is contained in:
@@ -64,6 +64,7 @@ import { createSlackRoutes, SLACK_CAPACITY_COMMAND_PATH } from './routes/slack';
|
||||
import { createBuzzRoutes } from './routes/buzz';
|
||||
import { createIntegrationSettingsRoutes } from './routes/integration-settings';
|
||||
import { createNotionImportRoutes, NOTION_OAUTH_CALLBACK_PATH } from './routes/notion-import';
|
||||
import { createGrowthRoutes } from './routes/growth';
|
||||
import { NotificationOutbox } from './services/notification-outbox';
|
||||
|
||||
type Env = { Variables: { principal: Principal } };
|
||||
@@ -218,6 +219,7 @@ export function createApp(
|
||||
publicUrl: config.PIG_PUBLIC_URL,
|
||||
}));
|
||||
app.route('/', createContractRoutes(db));
|
||||
app.route('/', createGrowthRoutes(db));
|
||||
app.route(
|
||||
'/',
|
||||
createPiggyChatRoutes({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Database } from '@pig/db';
|
||||
import { Hono } from 'hono';
|
||||
import { Hono, type Context } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { ApiEnv } from '../lib/mutation';
|
||||
import { apiError } from '../lib/mutation';
|
||||
@@ -7,12 +7,29 @@ import { CustomerLifecycleService } from '../services/customer-lifecycle';
|
||||
|
||||
const accountIdSchema = z.string().uuid();
|
||||
|
||||
export function growthReadAllowed(scopes: readonly string[]): boolean {
|
||||
return scopes.includes('read');
|
||||
}
|
||||
|
||||
function requireGrowthRead(context: Context<ApiEnv>) {
|
||||
if (growthReadAllowed(context.get('principal').scopes)) return null;
|
||||
return context.json(
|
||||
apiError('insufficient_scope', "This credential lacks the 'read' scope."),
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
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', async (context) => {
|
||||
const denied = requireGrowthRead(context);
|
||||
return denied ?? context.json(await service.report());
|
||||
});
|
||||
routes.get('/api/growth/accounts/:id', async (context) => {
|
||||
const denied = requireGrowthRead(context);
|
||||
if (denied) return denied;
|
||||
const accountId = accountIdSchema.safeParse(context.req.param('id'));
|
||||
if (!accountId.success) {
|
||||
return context.json(apiError('invalid_account', 'Invalid account ID.', accountId.error.issues), 400);
|
||||
|
||||
@@ -93,10 +93,10 @@ export function createPiggyChatRoutes(options: PiggyChatProxyOptions) {
|
||||
);
|
||||
|
||||
if (!upstream.ok) {
|
||||
const detail = await upstream.text().catch(() => '');
|
||||
await upstream.body?.cancel().catch(() => {});
|
||||
return c.json(
|
||||
{
|
||||
error: detail.slice(0, 500) || 'Piggy chat service did not respond.',
|
||||
error: 'Piggy chat service did not respond.',
|
||||
code: 'piggy_upstream_error',
|
||||
},
|
||||
502,
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface GrowthReport {
|
||||
rulesetVersion: string;
|
||||
computedAt: string;
|
||||
customers: GrowthCustomer[];
|
||||
customersTruncated: boolean;
|
||||
idleSupply: GrowthIdleSupply[];
|
||||
}
|
||||
|
||||
@@ -63,7 +64,7 @@ export class CustomerLifecycleService {
|
||||
this.capacity = new CapacityService(db);
|
||||
}
|
||||
|
||||
async report(): Promise<GrowthReport> {
|
||||
async report(accountId?: string): Promise<GrowthReport> {
|
||||
const now = this.clock();
|
||||
const accountRows = await this.db
|
||||
.select({
|
||||
@@ -75,13 +76,25 @@ export class CustomerLifecycleService {
|
||||
lastActivityAt: accounts.lastActivityAt,
|
||||
})
|
||||
.from(accounts)
|
||||
.where(and(or(eq(accounts.side, 'demand'), eq(accounts.side, 'both')), isNull(accounts.archivedAt)))
|
||||
.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(200);
|
||||
.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: [], idleSupply };
|
||||
return {
|
||||
rulesetVersion: 'growth-r1-2026-08-13',
|
||||
computedAt: now.toISOString(),
|
||||
customers: [],
|
||||
customersTruncated: false,
|
||||
idleSupply,
|
||||
};
|
||||
}
|
||||
|
||||
const [dealRows, contractRows, activityRows] = await Promise.all([
|
||||
@@ -164,12 +177,13 @@ export class CustomerLifecycleService {
|
||||
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();
|
||||
const report = await this.report(accountId);
|
||||
return report.customers.find((customer) => customer.account.id === accountId) ?? null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user