/** * 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 { 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 } = {}, ) { const app = new Hono(); 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( '/', 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>(); 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`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(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; }