/** * 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, agentTasks, allocations, capacityCommitments, contacts, contracts, demandDeals, supplyDeals, teamMemberships, users, } from '@pig/db'; import { ACCENTS, ACCOUNT_SIDES, ACTIVITY_TYPES, CUSTOMER_SEGMENTS, DEMAND_STAGES, SUPPLIER_TYPES, SUPPLY_STAGES, TEAMS, THEME_MODES, isValidAccent, isValidThemeMode, } from '@pig/core'; import type { Config } from './lib/config'; import { AuthError, createAuthenticator, type Principal } from './lib/auth'; import { CapacityService } from './services/capacity'; type Env = { Variables: { principal: Principal } }; export function createApp(config: Config, db: Database) { const app = new Hono(); const auth = createAuthenticator(config, db); const capacity = new CapacityService(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, }), ); /** 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), 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; if (path === '/api/health' || path === '/api/config' || path === '/api/signup') { 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, 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.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>(); 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, }); }); // Enum members are drawn from the ontology rather than retyped, so a value // added there is accepted here without a second edit — and, more importantly, // a value removed there stops validating rather than silently persisting. const accountSchema = z.object({ name: z.string().min(1).max(200), domain: z.string().max(200).optional(), website: z.string().max(400).optional(), description: z.string().max(4000).optional(), side: z.enum(ACCOUNT_SIDES).default('demand'), supplierType: z.enum(SUPPLIER_TYPES).optional(), customerSegment: z.enum(CUSTOMER_SEGMENTS).optional(), country: z.string().max(100).optional(), region: z.string().max(100).optional(), ultimateParentName: z.string().max(200).optional(), ultimateParentCountry: z.string().max(100).optional(), }); app.post('/api/accounts', async (c) => { const p = c.get('principal'); const parsed = accountSchema.safeParse(await c.req.json()); if (!parsed.success) { return c.json({ error: 'Invalid account', issues: parsed.error.issues }, 400); } const [created] = await db .insert(accounts) .values({ ...parsed.data, ownerUserId: p.userId, source: 'manual', }) .returning(); // Queue enrichment rather than performing it inline. The API must never // block on a model, and the queue survives the agent being down. if (created) { await db .insert(agentTasks) .values({ kind: 'enrich_account', subject: created.id, reason: `New account created by ${p.name}`, requestedByUserId: p.userId, }) .onConflictDoNothing(); } return c.json(created, 201); }); // ------------------------------------------------------------------- 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 }); }); const stageSchema = z.object({ stage: z.string() }); /** * Stage transitions are recorded as activities, not merely written. * * "When did this move to procurement, and who moved it?" is the question * asked in every pipeline review, and a column that only holds the current * value cannot answer it. */ app.patch('/api/deals/demand/:id/stage', async (c) => { const p = c.get('principal'); const id = c.req.param('id'); const parsed = stageSchema.safeParse(await c.req.json()); if (!parsed.success || !(DEMAND_STAGES as readonly string[]).includes(parsed.data.stage)) { return c.json({ error: 'Unknown stage' }, 400); } const [before] = await db.select().from(demandDeals).where(eq(demandDeals.id, id)).limit(1); if (!before) return c.json({ error: 'Not found' }, 404); const now = new Date(); const [updated] = await db .update(demandDeals) .set({ stage: parsed.data.stage as 'qualification', stageChangedAt: now, updatedAt: now, lastActivityAt: now, }) .where(eq(demandDeals.id, id)) .returning(); await db.insert(activities).values({ type: 'stage_change', subject: `${before.stage} → ${parsed.data.stage}`, accountId: before.accountId, demandDealId: id, actorUserId: p.userId, occurredAt: now, meta: { from: before.stage, to: parsed.data.stage }, }); return c.json(updated); }); // ------------------------------------------------------------- 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(['secure_cloud', 'community_cloud']).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(), capacity.idleCapacity({ thresholdPct: 0.2 }), db .select({ count: sql`count(*)::int` }) .from(demandDeals) .where(sql`${demandDeals.stage} NOT IN ('closed_won','closed_lost')`), db .select({ count: sql`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({ error: error.message, code: error.code }, 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; }