Build the agent-native compute CRM platform
CI / verify (push) Successful in 3m6s

This commit is contained in:
2026-08-13 01:39:01 -07:00
parent bfd2f8d95a
commit 853bde2265
160 changed files with 61812 additions and 483 deletions
+72 -102
View File
@@ -15,7 +15,6 @@ import type { Database } from '@pig/db';
import {
accounts,
activities,
agentTasks,
allocations,
capacityCommitments,
contacts,
@@ -27,11 +26,9 @@ import {
} from '@pig/db';
import {
ACCENTS,
ACCOUNT_SIDES,
ACTIVITY_TYPES,
CUSTOMER_SEGMENTS,
DEMAND_STAGES,
SUPPLIER_TYPES,
SECURITY_TIERS,
SUPPLY_STAGES,
TEAMS,
THEME_MODES,
@@ -39,17 +36,48 @@ import {
isValidThemeMode,
} from '@pig/core';
import type { Config } from './lib/config';
import { AuthError, createAuthenticator, type Principal } from './lib/auth';
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) {
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);
const auth = createAuthenticator(config, db, authProvider);
const capacity = new CapacityService(db);
const notifications = new NotificationOutbox(db);
if (!config.isProduction) app.use('*', logger());
@@ -68,7 +96,7 @@ export function createApp(config: Config, db: Database) {
* 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));
app.route('/', createSignupRoute(config, db, authProvider));
/*
* Registration. Also before the auth middleware, and necessarily so: the
@@ -111,6 +139,8 @@ export function createApp(config: Config, db: Database) {
path === '/api/config' ||
path === '/api/signup' ||
path === '/api/register'
|| path === SLACK_CAPACITY_COMMAND_PATH
|| path === NOTION_OAUTH_CALLBACK_PATH
) {
return next();
}
@@ -135,6 +165,7 @@ export function createApp(config: Config, db: Database) {
name: p.name,
isPlatformAdmin: p.isPlatformAdmin,
teams: p.teams,
permissions: effectivePermissions(p),
via: p.via,
});
});
@@ -175,6 +206,34 @@ export function createApp(config: Config, db: Database) {
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({
@@ -263,54 +322,6 @@ export function createApp(config: Config, db: Database) {
});
});
// 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) => {
@@ -341,50 +352,9 @@ export function createApp(config: Config, db: Database) {
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);
});
app.patch('/api/deals/demand/:id/stage', createDemandStageMutation(db, notifications));
app.route('/', createCapacityWriteRoutes(db));
app.route('/', createFactsRoute(db));
// ------------------------------------------------------------- activities
@@ -460,7 +430,7 @@ export function createApp(config: Config, db: Database) {
gpuCount: z.number().int().positive(),
totalGpuHours: z.number().positive().optional(),
requiresHighSpeedInterconnect: z.boolean().optional(),
minSecurityTier: z.enum(['secure_cloud', 'community_cloud']).optional(),
minSecurityTier: z.enum(SECURITY_TIERS).optional(),
startsAt: z.string().datetime().optional(),
endsAt: z.string().datetime().optional(),
maxPricePerGpuHourCents: z.number().int().positive().optional(),
@@ -553,7 +523,7 @@ export function createApp(config: Config, db: Database) {
app.onError((error, c) => {
if (error instanceof AuthError) {
return c.json({ error: error.message, code: error.code }, error.status);
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.