feat(pitch-deck): full pitch versioning with git-style history + bug fixes
Some checks failed
CI / go-lint (pull_request) Failing after 13s
CI / python-lint (pull_request) Failing after 13s
CI / nodejs-lint (pull_request) Failing after 8s
CI / test-go-consent (pull_request) Failing after 3s
CI / test-python-voice (pull_request) Failing after 10s
CI / test-bqas (pull_request) Failing after 11s
CI / Deploy (pull_request) Has been skipped

Adds a complete version management system where every piece of pitch
data (all 12 tables: company, team, financials, market, competitors,
features, milestones, metrics, funding, products, fm_scenarios,
fm_assumptions) can be versioned, diffed, and assigned per-investor.

Version lifecycle: create draft → edit freely → commit (immutable) →
fork to create new draft. Parent chain gives full git-style history.

Backend:
- Migration 003: pitch_versions, pitch_version_data tables + investor
  assigned_version_id column
- lib/version-helpers.ts: snapshot base tables, copy between versions
- lib/version-diff.ts: per-table row+field diffing engine
- 7 new API routes: versions CRUD, commit, fork, per-table data
  GET/PUT, diff endpoint
- /api/data + /api/financial-model: version-aware loading (check
  investor's assigned_version_id, serve version data or fall back
  to base tables)
- Investor PATCH: accepts assigned_version_id (validates committed)

Frontend:
- /pitch-admin/versions: list with status badges, fork/commit/delete
- /pitch-admin/versions/new: create from base tables or fork existing
- /pitch-admin/versions/[id]: 12-tab JSON editor (one per data table)
  with save-per-table, commit button, fork button
- /pitch-admin/versions/[id]/diff/[otherId]: side-by-side diff view
  with added/removed/changed highlighting per field
- Investors list: version column showing assigned version name
- Investor detail: version selector dropdown (committed versions only)
- AdminShell: Versions nav item added

Bug fixes:
- FM editor: [object Object] for JSONB array values → JSON.stringify
- Admin pages not scrollable → h-screen + overflow-hidden on shell,
  min-h-0 on flex column

Also includes migration 000 for fresh installs (pitch data tables).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-10 09:34:03 +02:00
parent 746daaef6d
commit 1872079504
22 changed files with 1564 additions and 42 deletions

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
interface Ctx { params: Promise<{ id: string }> }
export async function POST(request: NextRequest, ctx: Ctx) {
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 ver = await pool.query(`SELECT status, name FROM pitch_versions WHERE id = $1`, [id])
if (ver.rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (ver.rows[0].status === 'committed') {
return NextResponse.json({ error: 'Already committed' }, { status: 400 })
}
const { rows } = await pool.query(
`UPDATE pitch_versions SET status = 'committed', committed_at = NOW() WHERE id = $1 RETURNING *`,
[id],
)
await logAdminAudit(adminId, 'version_committed', {
version_id: id,
name: rows[0].name,
}, request)
return NextResponse.json({ version: rows[0] })
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
import { VERSION_TABLES, VersionTableName } from '@/lib/version-helpers'
interface Ctx { params: Promise<{ id: string; tableName: string }> }
export async function GET(request: NextRequest, ctx: Ctx) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const { id, tableName } = await ctx.params
if (!VERSION_TABLES.includes(tableName as VersionTableName)) {
return NextResponse.json({ error: `Invalid table: ${tableName}` }, { status: 400 })
}
const { rows } = await pool.query(
`SELECT data, updated_at, updated_by FROM pitch_version_data
WHERE version_id = $1 AND table_name = $2`,
[id, tableName],
)
if (rows.length === 0) {
return NextResponse.json({ data: [], updated_at: null })
}
const data = typeof rows[0].data === 'string' ? JSON.parse(rows[0].data) : rows[0].data
return NextResponse.json({ data, updated_at: rows[0].updated_at })
}
export async function PUT(request: NextRequest, ctx: Ctx) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const adminId = guard.kind === 'admin' ? guard.admin.id : null
const { id, tableName } = await ctx.params
if (!VERSION_TABLES.includes(tableName as VersionTableName)) {
return NextResponse.json({ error: `Invalid table: ${tableName}` }, { status: 400 })
}
// Verify version is a draft
const ver = await pool.query(`SELECT status FROM pitch_versions WHERE id = $1`, [id])
if (ver.rows.length === 0) return NextResponse.json({ error: 'Version not found' }, { status: 404 })
if (ver.rows[0].status === 'committed') {
return NextResponse.json({ error: 'Cannot edit a committed version' }, { status: 400 })
}
const body = await request.json().catch(() => ({}))
const { data } = body
if (!Array.isArray(data) && typeof data !== 'object') {
return NextResponse.json({ error: 'data must be an array or object' }, { status: 400 })
}
// Wrap single-record tables in array for consistency
const normalizedData = Array.isArray(data) ? data : [data]
await pool.query(
`INSERT INTO pitch_version_data (version_id, table_name, data, updated_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (version_id, table_name) DO UPDATE SET
data = $3, updated_at = NOW(), updated_by = $4`,
[id, tableName, JSON.stringify(normalizedData), adminId],
)
await logAdminAudit(adminId, 'version_data_edited', {
version_id: id,
table_name: tableName,
}, request)
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin } from '@/lib/admin-auth'
import { loadVersionData, VERSION_TABLES } from '@/lib/version-helpers'
import { diffTable } from '@/lib/version-diff'
interface Ctx { params: Promise<{ id: string; otherId: string }> }
export async function GET(request: NextRequest, ctx: Ctx) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const { id, otherId } = await ctx.params
// Verify both versions exist
const [vA, vB] = await Promise.all([
pool.query(`SELECT id, name, status, created_at FROM pitch_versions WHERE id = $1`, [id]),
pool.query(`SELECT id, name, status, created_at FROM pitch_versions WHERE id = $1`, [otherId]),
])
if (vA.rows.length === 0 || vB.rows.length === 0) {
return NextResponse.json({ error: 'One or both versions not found' }, { status: 404 })
}
const [dataA, dataB] = await Promise.all([
loadVersionData(id),
loadVersionData(otherId),
])
const diffs = VERSION_TABLES.map(tableName =>
diffTable(tableName, dataA[tableName] || [], dataB[tableName] || [])
).filter(d => d.hasChanges)
return NextResponse.json({
versionA: vA.rows[0],
versionB: vB.rows[0],
diffs,
total_changes: diffs.reduce((sum, d) => sum + d.rows.filter(r => r.status !== 'unchanged').length, 0),
})
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
import { copyVersionData } from '@/lib/version-helpers'
interface Ctx { params: Promise<{ id: string }> }
export async function POST(request: NextRequest, ctx: Ctx) {
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 = body.name || ''
const parent = await pool.query(`SELECT id, name, status FROM pitch_versions WHERE id = $1`, [id])
if (parent.rows.length === 0) return NextResponse.json({ error: 'Parent version not found' }, { status: 404 })
const forkName = name.trim() || `${parent.rows[0].name} (fork)`
const { rows } = await pool.query(
`INSERT INTO pitch_versions (name, parent_id, status, created_by)
VALUES ($1, $2, 'draft', $3) RETURNING *`,
[forkName, id, adminId],
)
const version = rows[0]
await copyVersionData(id, version.id, adminId)
await logAdminAudit(adminId, 'version_forked', {
version_id: version.id,
parent_id: id,
parent_name: parent.rows[0].name,
name: forkName,
}, request)
return NextResponse.json({ version })
}

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
import { loadVersionData } from '@/lib/version-helpers'
interface Ctx { params: Promise<{ id: string }> }
export async function GET(request: NextRequest, ctx: Ctx) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const { id } = await ctx.params
const { rows } = await pool.query(
`SELECT v.*, a.name AS created_by_name, a.email AS created_by_email,
(SELECT COUNT(*)::int FROM pitch_investors i WHERE i.assigned_version_id = v.id) AS assigned_count
FROM pitch_versions v
LEFT JOIN pitch_admins a ON a.id = v.created_by
WHERE v.id = $1`,
[id],
)
if (rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const data = await loadVersionData(id)
return NextResponse.json({ version: rows[0], data })
}
export async function PATCH(request: NextRequest, ctx: Ctx) {
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 } = body
const before = await pool.query(`SELECT name, description, status FROM pitch_versions WHERE id = $1`, [id])
if (before.rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (before.rows[0].status === 'committed') {
return NextResponse.json({ error: 'Cannot edit a committed version' }, { status: 400 })
}
const { rows } = await pool.query(
`UPDATE pitch_versions SET name = COALESCE($1, name), description = COALESCE($2, description)
WHERE id = $3 RETURNING *`,
[name ?? null, description ?? null, id],
)
await logAdminAudit(adminId, 'version_edited', {
version_id: id,
before: { name: before.rows[0].name, description: before.rows[0].description },
after: { name: rows[0].name, description: rows[0].description },
}, request)
return NextResponse.json({ version: rows[0] })
}
export async function DELETE(request: NextRequest, ctx: Ctx) {
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 ver = await pool.query(`SELECT status, name FROM pitch_versions WHERE id = $1`, [id])
if (ver.rows.length === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
// Prevent deleting committed versions that have children or assigned investors
if (ver.rows[0].status === 'committed') {
const children = await pool.query(`SELECT id FROM pitch_versions WHERE parent_id = $1 LIMIT 1`, [id])
if (children.rows.length > 0) {
return NextResponse.json({ error: 'Cannot delete: has child versions' }, { status: 400 })
}
const investors = await pool.query(`SELECT id FROM pitch_investors WHERE assigned_version_id = $1 LIMIT 1`, [id])
if (investors.rows.length > 0) {
return NextResponse.json({ error: 'Cannot delete: assigned to investors' }, { status: 400 })
}
}
await pool.query(`DELETE FROM pitch_versions WHERE id = $1`, [id])
await logAdminAudit(adminId, 'version_deleted', { version_id: id, name: ver.rows[0].name }, request)
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
import { snapshotBaseTables, copyVersionData } from '@/lib/version-helpers'
export async function GET(request: NextRequest) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const { rows } = await pool.query(`
SELECT v.*,
a.name AS created_by_name, a.email AS created_by_email,
(SELECT COUNT(*)::int FROM pitch_investors i WHERE i.assigned_version_id = v.id) AS assigned_count
FROM pitch_versions v
LEFT JOIN pitch_admins a ON a.id = v.created_by
ORDER BY v.created_at DESC
`)
return NextResponse.json({ versions: rows })
}
export async function POST(request: NextRequest) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const adminId = guard.kind === 'admin' ? guard.admin.id : null
const body = await request.json().catch(() => ({}))
const { name, description, parent_id } = body
if (!name || typeof name !== 'string') {
return NextResponse.json({ error: 'name required' }, { status: 400 })
}
// Create the version row
const { rows } = await pool.query(
`INSERT INTO pitch_versions (name, description, parent_id, status, created_by)
VALUES ($1, $2, $3, 'draft', $4) RETURNING *`,
[name.trim(), description || null, parent_id || null, adminId],
)
const version = rows[0]
// Copy data from parent or snapshot base tables
if (parent_id) {
await copyVersionData(parent_id, version.id, adminId)
} else {
await snapshotBaseTables(version.id, adminId)
}
await logAdminAudit(adminId, 'version_created', {
version_id: version.id,
name: version.name,
parent_id: parent_id || null,
}, request)
return NextResponse.json({ version })
}