feat(pitch-deck): admin UI for investor + financial-model management (#3)
All checks were successful
CI / test-go-consent (push) Successful in 42s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 30s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / Deploy (push) Successful in 2s
All checks were successful
CI / test-go-consent (push) Successful in 42s
CI / test-python-voice (push) Successful in 30s
CI / test-bqas (push) Successful in 30s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / Deploy (push) Successful in 2s
Adds /pitch-admin dashboard with real bcrypt admin accounts and full audit attribution for every state-changing action. - pitch_admins + pitch_admin_sessions tables (migration 002) - pitch_audit_logs.admin_id + target_investor_id columns - lib/admin-auth.ts: bcryptjs, single-session, jose JWT with audience claim - middleware.ts: two-cookie gating with bearer-secret CLI fallback - 14 new API routes (admin-auth, dashboard, investor detail/edit/resend, admins CRUD, fm scenarios + assumptions PATCH) - 9 admin pages: login, dashboard, investors list/new/[id], audit, financial-model list/[id], admins - Bootstrap CLI: npm run admin:create - 36 vitest tests covering auth, admin-auth, rate-limit primitives Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #3.
This commit is contained in:
93
pitch-deck/app/api/admin/fm/assumptions/[id]/route.ts
Normal file
93
pitch-deck/app/api/admin/fm/assumptions/[id]/route.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, ctx: RouteContext) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { value, min_value, max_value, step_size, label_de, label_en } = body
|
||||
|
||||
const before = await pool.query(
|
||||
`SELECT scenario_id, key, label_de, label_en, value, min_value, max_value, step_size
|
||||
FROM pitch_fm_assumptions WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
if (before.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Assumption not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const updates: string[] = []
|
||||
const params: unknown[] = []
|
||||
let p = 1
|
||||
|
||||
if (value !== undefined) {
|
||||
updates.push(`value = $${p++}`)
|
||||
params.push(JSON.stringify(value))
|
||||
}
|
||||
if (min_value !== undefined) {
|
||||
updates.push(`min_value = $${p++}`)
|
||||
params.push(min_value)
|
||||
}
|
||||
if (max_value !== undefined) {
|
||||
updates.push(`max_value = $${p++}`)
|
||||
params.push(max_value)
|
||||
}
|
||||
if (step_size !== undefined) {
|
||||
updates.push(`step_size = $${p++}`)
|
||||
params.push(step_size)
|
||||
}
|
||||
if (typeof label_de === 'string') {
|
||||
updates.push(`label_de = $${p++}`)
|
||||
params.push(label_de)
|
||||
}
|
||||
if (typeof label_en === 'string') {
|
||||
updates.push(`label_en = $${p++}`)
|
||||
params.push(label_en)
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
params.push(id)
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pitch_fm_assumptions SET ${updates.join(', ')} WHERE id = $${p} RETURNING *`,
|
||||
params,
|
||||
)
|
||||
|
||||
// Invalidate cached results for this scenario so the next compute uses the new value
|
||||
await pool.query(`DELETE FROM pitch_fm_results WHERE scenario_id = $1`, [before.rows[0].scenario_id])
|
||||
|
||||
await logAdminAudit(
|
||||
adminId,
|
||||
'assumption_edited',
|
||||
{
|
||||
assumption_id: id,
|
||||
scenario_id: before.rows[0].scenario_id,
|
||||
key: before.rows[0].key,
|
||||
before: {
|
||||
value: typeof before.rows[0].value === 'string' ? JSON.parse(before.rows[0].value) : before.rows[0].value,
|
||||
min_value: before.rows[0].min_value,
|
||||
max_value: before.rows[0].max_value,
|
||||
step_size: before.rows[0].step_size,
|
||||
},
|
||||
after: {
|
||||
value: typeof rows[0].value === 'string' ? JSON.parse(rows[0].value) : rows[0].value,
|
||||
min_value: rows[0].min_value,
|
||||
max_value: rows[0].max_value,
|
||||
step_size: rows[0].step_size,
|
||||
},
|
||||
},
|
||||
request,
|
||||
)
|
||||
|
||||
return NextResponse.json({ assumption: rows[0] })
|
||||
}
|
||||
52
pitch-deck/app/api/admin/fm/scenarios/[id]/route.ts
Normal file
52
pitch-deck/app/api/admin/fm/scenarios/[id]/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, ctx: RouteContext) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
const adminId = guard.kind === 'admin' ? guard.admin.id : null
|
||||
|
||||
const { id } = await ctx.params
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { name, description, color } = body
|
||||
|
||||
if (name === undefined && description === undefined && color === undefined) {
|
||||
return NextResponse.json({ error: 'name, description, or color required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const before = await pool.query(
|
||||
`SELECT name, description, color FROM pitch_fm_scenarios WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
if (before.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Scenario not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE pitch_fm_scenarios SET
|
||||
name = COALESCE($1, name),
|
||||
description = COALESCE($2, description),
|
||||
color = COALESCE($3, color)
|
||||
WHERE id = $4
|
||||
RETURNING *`,
|
||||
[name ?? null, description ?? null, color ?? null, id],
|
||||
)
|
||||
|
||||
await logAdminAudit(
|
||||
adminId,
|
||||
'scenario_edited',
|
||||
{
|
||||
scenario_id: id,
|
||||
before: before.rows[0],
|
||||
after: { name: rows[0].name, description: rows[0].description, color: rows[0].color },
|
||||
},
|
||||
request,
|
||||
)
|
||||
|
||||
return NextResponse.json({ scenario: rows[0] })
|
||||
}
|
||||
27
pitch-deck/app/api/admin/fm/scenarios/route.ts
Normal file
27
pitch-deck/app/api/admin/fm/scenarios/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import pool from '@/lib/db'
|
||||
import { requireAdmin } from '@/lib/admin-auth'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const guard = await requireAdmin(request)
|
||||
if (guard.kind === 'response') return guard.response
|
||||
|
||||
const scenarios = await pool.query(
|
||||
`SELECT * FROM pitch_fm_scenarios ORDER BY is_default DESC, name`,
|
||||
)
|
||||
const assumptions = await pool.query(
|
||||
`SELECT * FROM pitch_fm_assumptions ORDER BY scenario_id, sort_order`,
|
||||
)
|
||||
|
||||
const result = scenarios.rows.map(s => ({
|
||||
...s,
|
||||
assumptions: assumptions.rows
|
||||
.filter(a => a.scenario_id === s.id)
|
||||
.map(a => ({
|
||||
...a,
|
||||
value: typeof a.value === 'string' ? JSON.parse(a.value) : a.value,
|
||||
})),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ scenarios: result })
|
||||
}
|
||||
Reference in New Issue
Block a user