This commit is contained in:
+72
-102
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user