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

539 lines
18 KiB
TypeScript

/**
* The HTTP application.
*
* Deliberately thin. Following the rule stated in the README — *intelligence
* never lives in the API* — these handlers validate input, check authorization,
* call a service, and serialise the result. Research, enrichment, scoring and
* matching heuristics live in the service layer or in the agent, never here.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { and, desc, eq, ilike, isNull, or, sql } from 'drizzle-orm';
import { z } from 'zod';
import type { Database } from '@pig/db';
import {
accounts,
activities,
allocations,
capacityCommitments,
contacts,
contracts,
demandDeals,
supplyDeals,
teamMemberships,
users,
} from '@pig/db';
import {
ACCENTS,
ACTIVITY_TYPES,
DEMAND_STAGES,
SECURITY_TIERS,
SUPPLY_STAGES,
TEAMS,
THEME_MODES,
isValidAccent,
isValidThemeMode,
} from '@pig/core';
import type { Config } from './lib/config';
import {
AuthError,
createAuthenticator,
effectivePermissions,
type Principal,
} from './lib/auth';
import {
createConfiguredAuthProvider,
type AuthProvider,
} from './lib/auth-provider';
import { apiError } from './lib/mutation';
import { CapacityService } from './services/capacity';
import { createSignupRoute } from './routes/signup';
import { createRegisterRoute } from './routes/register';
import { createDemandStageMutation } from './routes/deals';
import { createFactsRoute } from './routes/facts';
import { createApiKeyRoutes } from './routes/api-keys';
import { createCapacityWriteRoutes } from './routes/capacity-writes';
import { createRecordRoutes } from './routes/records';
import { createImportRoutes } from './routes/imports';
import { createGoogleSheetsRoutes } from './routes/google-sheets';
import { createContractRoutes } from './routes/contracts';
import { createPiggyChatRoutes } from './routes/piggy-chat';
import { createAdminSettingsRoutes } from './routes/admin-settings';
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 } };
export function createApp(
config: Config,
db: Database,
authProvider: AuthProvider | null = createConfiguredAuthProvider(config),
runtime: { onPlatformSettingsChanged?: () => Promise<void> } = {},
) {
const app = new Hono<Env>();
const auth = createAuthenticator(config, db, authProvider);
const capacity = new CapacityService(db);
const notifications = new NotificationOutbox(db);
if (!config.isProduction) app.use('*', logger());
app.use(
'/api/*',
cors({
// In production the front end is served from the same origin, so no
// cross-origin allowance is needed. In development Vite runs separately.
origin: config.isProduction ? config.PIG_PUBLIC_URL : ['http://localhost:5173'],
credentials: true,
}),
);
/*
* Profile creation. Mounted BEFORE the auth middleware because it is the
* route that turns an authenticated stranger into a member — requiring
* membership to reach it would be circular. It verifies the token itself.
*/
app.route('/', createSignupRoute(config, db, authProvider));
/*
* Registration. Also before the auth middleware, and necessarily so: the
* caller has no account yet, so there is no token to present. The invite
* code is the only gate, which is why it is validated before anything is
* created anywhere.
*/
app.route('/', createRegisterRoute(config, db));
/** Liveness. Unauthenticated by design so a load balancer can reach it. */
app.get('/api/health', (c) => c.json({ ok: true, service: 'pig', version: '0.1.0' }));
/**
* Public configuration for the front end — what it needs before anyone has
* logged in. Contains only values that are safe in a browser: the anon key is
* designed to be public, and no secret is exposed here.
*/
app.get('/api/config', (c) =>
c.json({
supabaseUrl: config.SUPABASE_URL ?? null,
supabaseAnonKey: config.SUPABASE_ANON_KEY ?? null,
authDisabled: !config.SUPABASE_URL,
inviteRequired: Boolean(config.PIG_INVITE_CODE),
// Whether someone with an invite can create an account outright, or must
// be provisioned by an administrator first.
canSelfRegister: Boolean(config.SUPABASE_URL && config.SUPABASE_SERVICE_KEY),
accents: ACCENTS.map((a) => ({ key: a.key, label: a.label })),
teams: TEAMS,
}),
);
// Everything below requires a principal.
app.use('/api/*', async (c, next) => {
const path = new URL(c.req.url).pathname;
// Public by necessity: health for load balancers, config for the front
// end before sign-in, and the two join routes for people who are not yet
// members. Each verifies whatever it needs itself.
if (
path === '/api/health' ||
path === '/api/config' ||
path === '/api/signup' ||
path === '/api/register'
|| path === SLACK_CAPACITY_COMMAND_PATH
|| path === NOTION_OAUTH_CALLBACK_PATH
) {
return next();
}
try {
c.set('principal', await auth.authenticate(c.req.header('authorization')));
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: error.message, code: error.code }, error.status);
}
throw error;
}
return next();
});
// ---------------------------------------------------------------- identity
app.get('/api/me', (c) => {
const p = c.get('principal');
return c.json({
id: p.userId,
email: p.email,
name: p.name,
isPlatformAdmin: p.isPlatformAdmin,
teams: p.teams,
permissions: effectivePermissions(p),
via: p.via,
});
});
const preferencesSchema = z.object({
themeMode: z.enum(THEME_MODES).optional(),
accentColor: z.string().refine(isValidAccent, 'Unknown accent').optional(),
name: z.string().min(1).max(120).optional(),
handle: z.string().min(2).max(40).regex(/^[a-z0-9_-]+$/i).optional(),
title: z.string().max(160).optional(),
timezone: z.string().max(80).optional(),
});
/**
* Appearance and profile preferences.
*
* Persisted server-side rather than in localStorage so that a person's chosen
* theme follows them from laptop to phone — which matters more than it might
* seem, because a CRM is genuinely used on both.
*/
app.patch('/api/me/preferences', async (c) => {
const p = c.get('principal');
const parsed = preferencesSchema.safeParse(await c.req.json());
if (!parsed.success) {
return c.json({ error: 'Invalid preferences', issues: parsed.error.issues }, 400);
}
const [updated] = await db
.update(users)
.set({ ...parsed.data, updatedAt: new Date() })
.where(eq(users.id, p.userId))
.returning();
return c.json(updated);
});
app.get('/api/me/profile', async (c) => {
const p = c.get('principal');
const [user] = await db.select().from(users).where(eq(users.id, p.userId)).limit(1);
return c.json(user ?? null);
});
app.route('/', createApiKeyRoutes(db));
app.route('/', createRecordRoutes(db, notifications));
app.route('/', createImportRoutes(db));
app.route('/', createNotionImportRoutes(config, db));
app.route('/', createGoogleSheetsRoutes(db, {
clientId: config.GOOGLE_CLIENT_ID,
clientSecret: config.GOOGLE_CLIENT_SECRET,
redirectUri: config.GOOGLE_REDIRECT_URI,
encryptionKey: config.PIG_SETTINGS_ENCRYPTION_KEY,
publicUrl: config.PIG_PUBLIC_URL,
}));
app.route('/', createContractRoutes(db));
app.route('/', createGrowthRoutes(db));
app.route(
'/',
createPiggyChatRoutes({
enabled: config.PIGGY_ENABLED,
internalUrl: config.PIGGY_INTERNAL_URL,
internalToken: config.PIGGY_INTERNAL_TOKEN,
}),
);
app.route('/', createSlackRoutes(config, db, capacity));
if (config.BUZZ_RELAY_URL) app.route('/', createBuzzRoutes(db, config.BUZZ_RELAY_URL));
app.route('/', createIntegrationSettingsRoutes(config));
app.route(
'/',
createAdminSettingsRoutes(config, db, runtime.onPlatformSettingsChanged),
);
app.get('/api/team', async (c) => {
const rows = await db
.select({
id: users.id,
name: users.name,
email: users.email,
handle: users.handle,
title: users.title,
avatarUrl: users.avatarUrl,
team: teamMemberships.team,
role: teamMemberships.role,
})
.from(users)
.leftJoin(teamMemberships, eq(teamMemberships.userId, users.id))
.where(isNull(users.deactivatedAt));
// Collapse the join: one row per person, carrying every team they're on.
const byId = new Map<string, Record<string, unknown>>();
for (const row of rows) {
const existing = byId.get(row.id);
const membership = row.team ? { team: row.team, role: row.role } : null;
if (existing) {
if (membership) (existing.teams as unknown[]).push(membership);
} else {
byId.set(row.id, {
id: row.id,
name: row.name,
email: row.email,
handle: row.handle,
title: row.title,
avatarUrl: row.avatarUrl,
teams: membership ? [membership] : [],
});
}
}
return c.json([...byId.values()]);
});
// ---------------------------------------------------------------- accounts
app.get('/api/accounts', async (c) => {
const side = c.req.query('side');
const q = c.req.query('q');
const rows = await db
.select()
.from(accounts)
.where(
and(
isNull(accounts.archivedAt),
side && side !== 'all'
? or(eq(accounts.side, side as 'supply'), eq(accounts.side, 'both'))
: undefined,
q ? ilike(accounts.name, `%${q}%`) : undefined,
),
)
.orderBy(desc(accounts.lastActivityAt), accounts.name)
.limit(200);
return c.json(rows);
});
app.get('/api/accounts/:id', async (c) => {
const id = c.req.param('id');
const [account] = await db.select().from(accounts).where(eq(accounts.id, id)).limit(1);
if (!account) return c.json({ error: 'Not found' }, 404);
const [accountContacts, demand, supply, paperwork, recentActivity] = await Promise.all([
db.select().from(contacts).where(eq(contacts.accountId, id)),
db.select().from(demandDeals).where(eq(demandDeals.accountId, id)),
db.select().from(supplyDeals).where(eq(supplyDeals.accountId, id)),
db.select().from(contracts).where(eq(contracts.accountId, id)),
db
.select()
.from(activities)
.where(eq(activities.accountId, id))
.orderBy(desc(activities.occurredAt))
.limit(50),
]);
return c.json({
account,
contacts: accountContacts,
demandDeals: demand,
supplyDeals: supply,
contracts: paperwork,
activities: recentActivity,
});
});
// ------------------------------------------------------------------- deals
app.get('/api/deals/demand', async (c) => {
const rows = await db
.select({
deal: demandDeals,
accountName: accounts.name,
accountDomain: accounts.domain,
})
.from(demandDeals)
.leftJoin(accounts, eq(accounts.id, demandDeals.accountId))
.orderBy(desc(demandDeals.updatedAt))
.limit(300);
return c.json({ stages: DEMAND_STAGES, deals: rows });
});
app.get('/api/deals/supply', async (c) => {
const rows = await db
.select({
deal: supplyDeals,
accountName: accounts.name,
accountDomain: accounts.domain,
})
.from(supplyDeals)
.leftJoin(accounts, eq(accounts.id, supplyDeals.accountId))
.orderBy(desc(supplyDeals.updatedAt))
.limit(300);
return c.json({ stages: SUPPLY_STAGES, deals: rows });
});
app.patch('/api/deals/demand/:id/stage', createDemandStageMutation(db, notifications));
app.route('/', createCapacityWriteRoutes(db));
app.route('/', createFactsRoute(db));
// ------------------------------------------------------------- activities
const activitySchema = z.object({
accountId: z.string().uuid().optional(),
contactId: z.string().uuid().optional(),
demandDealId: z.string().uuid().optional(),
supplyDealId: z.string().uuid().optional(),
type: z.enum(ACTIVITY_TYPES),
subject: z.string().min(1).max(200),
body: z.string().max(8000).optional(),
occurredAt: z.string().datetime().optional(),
externalId: z.string().max(200).optional(),
});
app.post('/api/activities', async (c) => {
const p = c.get('principal');
const parsed = activitySchema.safeParse(await c.req.json());
if (!parsed.success) {
return c.json({ error: 'Invalid activity', issues: parsed.error.issues }, 400);
}
const { occurredAt, ...rest } = parsed.data;
const when = occurredAt ? new Date(occurredAt) : new Date();
const [created] = await db
.insert(activities)
.values({
...rest,
occurredAt: when,
actorUserId: p.userId,
// An agent acting for someone is recorded as such, so the log
// distinguishes what a person did from what was done on their behalf.
actorAgent: p.via === 'api_key' ? 'agent' : null,
source: p.via === 'api_key' ? 'agent' : 'manual',
})
// An `externalId` collision means this event was already synced from
// Slack or Buzz; silently ignoring the duplicate keeps sync idempotent.
.onConflictDoNothing()
.returning();
if (rest.accountId) {
await db
.update(accounts)
.set({ lastActivityAt: when })
.where(eq(accounts.id, rest.accountId));
}
return c.json(created ?? { deduplicated: true }, created ? 201 : 200);
});
// ---------------------------------------------------------------- capacity
app.get('/api/capacity/availability', async (c) => {
const gpuType = c.req.query('gpuType') ?? undefined;
return c.json(await capacity.availability({ gpuType }));
});
app.get('/api/capacity/idle', async (c) => {
const threshold = Number(c.req.query('threshold') ?? '0.25');
return c.json(
await capacity.idleCapacity({
thresholdPct: Number.isFinite(threshold) ? threshold : 0.25,
withinDays: Number(c.req.query('withinDays') ?? '30') || 30,
}),
);
});
app.get('/api/capacity/margin', async (c) => c.json(await capacity.marginReport()));
const matchSchema = z.object({
gpuType: z.string().optional(),
gpuTypeAlternatives: z.array(z.string()).optional(),
gpuCount: z.number().int().positive(),
totalGpuHours: z.number().positive().optional(),
requiresHighSpeedInterconnect: z.boolean().optional(),
minSecurityTier: z.enum(SECURITY_TIERS).optional(),
startsAt: z.string().datetime().optional(),
endsAt: z.string().datetime().optional(),
maxPricePerGpuHourCents: z.number().int().positive().optional(),
});
app.post('/api/capacity/match', async (c) => {
const parsed = matchSchema.safeParse(await c.req.json());
if (!parsed.success) {
return c.json({ error: 'Invalid requirement', issues: parsed.error.issues }, 400);
}
const { startsAt, endsAt, ...rest } = parsed.data;
return c.json(
await capacity.match({
...rest,
startsAt: startsAt ? new Date(startsAt) : undefined,
endsAt: endsAt ? new Date(endsAt) : undefined,
}),
);
});
app.get('/api/inventory', async (c) =>
c.json(
await capacity.searchInventory({
gpuType: c.req.query('gpuType') ?? undefined,
minGpuCount: Number(c.req.query('minGpuCount')) || undefined,
maxPriceCents: Number(c.req.query('maxPriceCents')) || undefined,
requiresHighSpeedInterconnect: c.req.query('fastFabric') === 'true',
limit: Number(c.req.query('limit')) || 50,
}),
),
);
app.get('/api/commitments', async (c) => {
const rows = await db
.select({ commitment: capacityCommitments, accountName: accounts.name })
.from(capacityCommitments)
.leftJoin(accounts, eq(accounts.id, capacityCommitments.accountId))
.orderBy(desc(capacityCommitments.startsAt));
return c.json(rows);
});
app.get('/api/allocations', async (c) => {
const rows = await db
.select()
.from(allocations)
.orderBy(desc(allocations.startsAt))
.limit(300);
return c.json(rows);
});
// --------------------------------------------------------------- dashboard
/**
* The landing view. One round trip rather than six, because this renders on
* a phone on a cellular connection as often as on a desk.
*/
app.get('/api/dashboard', async (c) => {
const p = c.get('principal');
const [margin, idle, openDemand, openSupply, recent] = await Promise.all([
capacity.marginReport(),
// 0.15 rather than 0.2: a block sitting exactly on the threshold would
// otherwise flip in and out of the alert list on floating-point noise,
// and 15% idle is worth a seller's attention anyway.
capacity.idleCapacity({ thresholdPct: 0.15 }),
db
.select({ count: sql<number>`count(*)::int` })
.from(demandDeals)
.where(sql`${demandDeals.stage} NOT IN ('closed_won','closed_lost')`),
db
.select({ count: sql<number>`count(*)::int` })
.from(supplyDeals)
.where(sql`${supplyDeals.stage} NOT IN ('live','churned','rejected')`),
db
.select()
.from(activities)
.orderBy(desc(activities.occurredAt))
.limit(12),
]);
return c.json({
me: { name: p.name, teams: p.teams },
margin: margin.totals,
blocks: margin.blocks.length,
idleAlerts: idle.slice(0, 5),
openDemandDeals: openDemand[0]?.count ?? 0,
openSupplyDeals: openSupply[0]?.count ?? 0,
recentActivity: recent,
});
});
app.onError((error, c) => {
if (error instanceof AuthError) {
return c.json(apiError(error.code, error.message), error.status);
}
console.error('[pig] unhandled error', error);
// Never leak internals to a client; the detail is in the server log.
return c.json({ error: 'Internal error' }, 500);
});
app.notFound((c) => c.json({ error: 'Not found' }, 404));
return app;
}