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

@@ -1,32 +1,63 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { getSessionFromCookie } from '@/lib/auth'
export const dynamic = 'force-dynamic'
// GET: Load all scenarios with their assumptions
function assembleScenarios(scenarioRows: Record<string, unknown>[], assumptionRows: Record<string, unknown>[]) {
return scenarioRows.map(s => ({
...s,
assumptions: assumptionRows
.filter((a: Record<string, unknown>) => a.scenario_id === (s as Record<string, unknown>).id)
.map((a: Record<string, unknown>) => ({
...a,
value: typeof a.value === 'string' ? JSON.parse(a.value as string) : a.value,
})),
}))
}
// GET: Load all scenarios with their assumptions (version-aware)
export async function GET() {
try {
// Check if investor has an assigned version with FM data
const session = await getSessionFromCookie()
let versionId: string | null = null
if (session) {
const inv = await pool.query(
`SELECT assigned_version_id FROM pitch_investors WHERE id = $1`,
[session.sub],
)
versionId = inv.rows[0]?.assigned_version_id || null
}
if (versionId) {
const [scenarioData, assumptionData] = await Promise.all([
pool.query(`SELECT data FROM pitch_version_data WHERE version_id = $1 AND table_name = 'fm_scenarios'`, [versionId]),
pool.query(`SELECT data FROM pitch_version_data WHERE version_id = $1 AND table_name = 'fm_assumptions'`, [versionId]),
])
if (scenarioData.rows.length > 0) {
const scenarios = typeof scenarioData.rows[0].data === 'string'
? JSON.parse(scenarioData.rows[0].data) : scenarioData.rows[0].data
const assumptions = assumptionData.rows.length > 0
? (typeof assumptionData.rows[0].data === 'string'
? JSON.parse(assumptionData.rows[0].data) : assumptionData.rows[0].data)
: []
return NextResponse.json(assembleScenarios(scenarios, assumptions))
}
}
// Fallback: base tables
const client = await pool.connect()
try {
const scenarios = await client.query(
'SELECT * FROM pitch_fm_scenarios ORDER BY is_default DESC, name'
)
const assumptions = await client.query(
'SELECT * FROM pitch_fm_assumptions ORDER BY 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(result)
return NextResponse.json(assembleScenarios(scenarios.rows, assumptions.rows))
} finally {
client.release()
}