45b70b17f0
THE PAGE. The anonymous route rendered outside Shell, so it sat flush against
the viewport edge and read as a form rather than a product — which is the first
thing anyone at Prime Intellect sees when the link is shared. It now brings its
own chrome and leads with a hero; the platform track is a numbered course, the
concept tracks are a poster grid, and admin add/archive moved behind one Manage
toggle so they stop competing with the content. Verified in Chrome at 1440 and
393, light and dark: horizontal overflow is 0 in all three access states.
THE VIDEOS. Five ~30s walkthroughs, narrated in Karti's cloned voice through
Chatterbox and cut against real screen capture of the seeded demo book. The
audio is rendered FIRST and its measured duration drives the capture, because a
shot list that runs short leaves the narrator talking over a frozen frame and
one that runs long gets cut mid-sentence. Levels are loudness-normalised so
clips do not jump between videos.
Cap cannot take a programmatic upload — video.karti.ai needs an interactive
login — so PIG serves these itself. A native <video> on this origin needs no
iframe and therefore no CSP frame-src at all; Karti's own Cap recordings still
render through the existing iframe path, which is why the resolver is now a
discriminated union.
THREE THINGS THE VERIFIERS CAUGHT, all of which shipped green:
- createMediaRoutes was never mounted. Every layer landed — migration, seed,
both feeds, the bind mount, the docs — except the one that serves the bytes,
so /media/learn/* fell through to the SPA fallback and answered HTTP 200
text/html. The player showed a black box with working controls and no error.
The tests certified the route factory in isolation, which proves the handler
and says nothing about whether it is wired in. There is now an assertion
against the ASSEMBLED app, and it fails loudly on content-type — the failure
mode is a 200, not a 404.
- A symlink in the media directory escaped the root. resolve() is lexical and
stat() follows links, so the containment check this file's own header
promised did not hold. realpath before the check closes it.
- Vite proxied only /api, so self-hosted playback broke for anyone running the
app the documented way — in the same invisible 200-text/html manner.
Also: a duplicate media slug used to throw from the middle of seedDemo() and
take out every later section; it now reports and skips that one entry. And the
player has an onError state, because content-addressed filenames mean a
re-render deliberately leaves the old row pointing at a file that is gone.
The three DEMO platform rows are dropped — five real recordings supersede them,
and placeholders sitting under real ones made the page read as half-finished to
the audience it is meant to convince. The supply and demand concept rows stay:
there are no real recordings for those tracks yet, and an empty track hides the
shape of the page.
Tests 275, typecheck clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
536 lines
19 KiB
TypeScript
536 lines
19 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,
|
|
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 { createMediaRoutes } from './lib/media';
|
|
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, platformPiggyEnabled } 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 { createCalendarRoutes } from './routes/calendar';
|
|
import { createLearnRoutes, LEARN_ACCESS_PATH, LEARN_PUBLIC_PATH } from './routes/learn';
|
|
import { createReadGuardRoutes } from './routes/read-guards';
|
|
import { createActivityRoutes } from './routes/activities';
|
|
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,
|
|
}),
|
|
);
|
|
|
|
/*
|
|
* Learn videos PIG serves itself. Mounted here — before the authenticator,
|
|
* and before server.ts's SPA fallback — because a <video> re-requests byte
|
|
* ranges on every seek and carries no bearer token while doing it.
|
|
*
|
|
* The FILES are unauthenticated; the LISTING behind /api/learn is not. See
|
|
* lib/media.ts for that trade and what it costs. Position IS the access
|
|
* decision here: the /api/* allowlist below can never match /media/learn/*,
|
|
* so adding an entry there would be dead code.
|
|
*/
|
|
app.route('/', createMediaRoutes());
|
|
|
|
// 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
|
|
// Learn is reachable with a share code and no account. These two paths
|
|
// are exact-string matches, deliberately: /api/learn and
|
|
// /api/learn/resources/* stay behind the authenticator, and the public
|
|
// reader is structurally incapable of naming a row that is not both
|
|
// platform-track and code-visible.
|
|
|| path === LEARN_ACCESS_PATH
|
|
|| path === LEARN_PUBLIC_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();
|
|
});
|
|
|
|
/*
|
|
* Read authorisation, mounted before every handler it guards.
|
|
*
|
|
* Hono runs matched handlers in registration order, so a guard registered
|
|
* after its route never runs and returns 200 while looking correct. That is
|
|
* why this sits here rather than beside the feature routes below, and why
|
|
* read-governance.test.ts pins the ordering in both directions.
|
|
*
|
|
* The policy is one table in read-guards.ts precisely so that "who can see
|
|
* cost?" has a single answer rather than one per route.
|
|
*/
|
|
app.route('/', createReadGuardRoutes());
|
|
|
|
// ---------------------------------------------------------------- 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('/', createCalendarRoutes(db));
|
|
app.route('/', createLearnRoutes(db));
|
|
app.route(
|
|
'/',
|
|
createPiggyChatRoutes({
|
|
enabled: config.PIGGY_ENABLED,
|
|
internalUrl: config.PIGGY_INTERNAL_URL,
|
|
internalToken: config.PIGGY_INTERNAL_TOKEN,
|
|
// Without this the stored toggle is never consulted and isAvailable()
|
|
// short-circuits to the environment variable, which is the bug the
|
|
// resolver exists to fix. The tests inject their own resolver, so they
|
|
// stay green whether or not this line is here — it is the composition
|
|
// that has to be right.
|
|
resolvePiggyEnabled: platformPiggyEnabled(config, db),
|
|
}),
|
|
);
|
|
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));
|
|
|
|
app.route('/', createActivityRoutes(db));
|
|
|
|
// ---------------------------------------------------------------- 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;
|
|
}
|