539 lines
18 KiB
TypeScript
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;
|
|
}
|