From 1c3cec2c06d55a602b38ca512abbd1db786f6490 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Fri, 10 Apr 2026 07:37:33 +0000 Subject: [PATCH] feat(pitch-deck): full pitch versioning with git-style history (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full pitch versioning: 12 data tables versioned as JSONB snapshots, git-style parent chain (draft→commit→fork), per-investor assignment, side-by-side diff engine, version-aware /api/data + /api/financial-model. Bug fixes: FM editor [object Object] for JSONB arrays, admin scroll. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/api/admin/investors/[id]/route.ts | 46 +++- pitch-deck/app/api/admin/investors/route.ts | 2 + .../api/admin/versions/[id]/commit/route.ts | 31 +++ .../versions/[id]/data/[tableName]/route.ts | 73 ++++++ .../versions/[id]/diff/[otherId]/route.ts | 39 +++ .../app/api/admin/versions/[id]/fork/route.ts | 38 +++ .../app/api/admin/versions/[id]/route.ts | 85 +++++++ pitch-deck/app/api/admin/versions/route.ts | 56 +++++ pitch-deck/app/api/data/route.ts | 56 +++-- pitch-deck/app/api/financial-model/route.ts | 59 +++-- .../financial-model/[scenarioId]/page.tsx | 2 +- .../(authed)/investors/[id]/page.tsx | 42 ++++ .../pitch-admin/(authed)/investors/page.tsx | 10 + .../versions/[id]/diff/[otherId]/page.tsx | 116 +++++++++ .../(authed)/versions/[id]/page.tsx | 222 ++++++++++++++++++ .../(authed)/versions/new/page.tsx | 120 ++++++++++ .../pitch-admin/(authed)/versions/page.tsx | 198 ++++++++++++++++ .../components/pitch-admin/AdminShell.tsx | 6 +- pitch-deck/lib/version-diff.ts | 102 ++++++++ pitch-deck/lib/version-helpers.ts | 76 ++++++ .../migrations/000_pitch_data_tables.sql | 191 +++++++++++++++ pitch-deck/migrations/003_pitch_versions.sql | 36 +++ 22 files changed, 1564 insertions(+), 42 deletions(-) create mode 100644 pitch-deck/app/api/admin/versions/[id]/commit/route.ts create mode 100644 pitch-deck/app/api/admin/versions/[id]/data/[tableName]/route.ts create mode 100644 pitch-deck/app/api/admin/versions/[id]/diff/[otherId]/route.ts create mode 100644 pitch-deck/app/api/admin/versions/[id]/fork/route.ts create mode 100644 pitch-deck/app/api/admin/versions/[id]/route.ts create mode 100644 pitch-deck/app/api/admin/versions/route.ts create mode 100644 pitch-deck/app/pitch-admin/(authed)/versions/[id]/diff/[otherId]/page.tsx create mode 100644 pitch-deck/app/pitch-admin/(authed)/versions/[id]/page.tsx create mode 100644 pitch-deck/app/pitch-admin/(authed)/versions/new/page.tsx create mode 100644 pitch-deck/app/pitch-admin/(authed)/versions/page.tsx create mode 100644 pitch-deck/lib/version-diff.ts create mode 100644 pitch-deck/lib/version-helpers.ts create mode 100644 pitch-deck/migrations/000_pitch_data_tables.sql create mode 100644 pitch-deck/migrations/003_pitch_versions.sql diff --git a/pitch-deck/app/api/admin/investors/[id]/route.ts b/pitch-deck/app/api/admin/investors/[id]/route.ts index ad447cd..56a7ff1 100644 --- a/pitch-deck/app/api/admin/investors/[id]/route.ts +++ b/pitch-deck/app/api/admin/investors/[id]/route.ts @@ -14,8 +14,12 @@ export async function GET(request: NextRequest, ctx: RouteContext) { const [investor, sessions, snapshots, audit] = await Promise.all([ pool.query( - `SELECT id, email, name, company, status, last_login_at, login_count, created_at, updated_at - FROM pitch_investors WHERE id = $1`, + `SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count, + i.created_at, i.updated_at, i.assigned_version_id, + v.name AS version_name, v.status AS version_status + FROM pitch_investors i + LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id + WHERE i.id = $1`, [id], ), pool.query( @@ -60,36 +64,58 @@ export async function PATCH(request: NextRequest, ctx: RouteContext) { const { id } = await ctx.params const body = await request.json().catch(() => ({})) - const { name, company } = body + const { name, company, assigned_version_id } = body - if (name === undefined && company === undefined) { - return NextResponse.json({ error: 'name or company required' }, { status: 400 }) + if (name === undefined && company === undefined && assigned_version_id === undefined) { + return NextResponse.json({ error: 'name, company, or assigned_version_id required' }, { status: 400 }) } const before = await pool.query( - `SELECT name, company FROM pitch_investors WHERE id = $1`, + `SELECT name, company, assigned_version_id FROM pitch_investors WHERE id = $1`, [id], ) if (before.rows.length === 0) { return NextResponse.json({ error: 'Investor not found' }, { status: 404 }) } + // Validate version exists and is committed (if assigning) + if (assigned_version_id !== undefined && assigned_version_id !== null) { + const ver = await pool.query( + `SELECT id, status FROM pitch_versions WHERE id = $1`, + [assigned_version_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: 'Can only assign committed versions' }, { status: 400 }) + } + } + + // Use null to clear version assignment, undefined to leave unchanged + const versionValue = assigned_version_id === undefined ? before.rows[0].assigned_version_id : (assigned_version_id || null) + const { rows } = await pool.query( `UPDATE pitch_investors SET name = COALESCE($1, name), company = COALESCE($2, company), + assigned_version_id = $4, updated_at = NOW() WHERE id = $3 - RETURNING id, email, name, company, status`, - [name ?? null, company ?? null, id], + RETURNING id, email, name, company, status, assigned_version_id`, + [name ?? null, company ?? null, id, versionValue], ) + const action = assigned_version_id !== undefined && assigned_version_id !== before.rows[0].assigned_version_id + ? 'investor_version_assigned' + : 'investor_edited' + await logAdminAudit( adminId, - 'investor_edited', + action, { before: before.rows[0], - after: { name: rows[0].name, company: rows[0].company }, + after: { name: rows[0].name, company: rows[0].company, assigned_version_id: rows[0].assigned_version_id }, }, request, id, diff --git a/pitch-deck/app/api/admin/investors/route.ts b/pitch-deck/app/api/admin/investors/route.ts index 712eff0..0ba4ccf 100644 --- a/pitch-deck/app/api/admin/investors/route.ts +++ b/pitch-deck/app/api/admin/investors/route.ts @@ -8,9 +8,11 @@ export async function GET(request: NextRequest) { const { rows } = await pool.query( `SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count, i.created_at, + i.assigned_version_id, v.name AS version_name, (SELECT COUNT(*) FROM pitch_audit_logs a WHERE a.investor_id = i.id AND a.action = 'slide_viewed') as slides_viewed, (SELECT MAX(a.created_at) FROM pitch_audit_logs a WHERE a.investor_id = i.id) as last_activity FROM pitch_investors i + LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id ORDER BY i.created_at DESC`, ) diff --git a/pitch-deck/app/api/admin/versions/[id]/commit/route.ts b/pitch-deck/app/api/admin/versions/[id]/commit/route.ts new file mode 100644 index 0000000..e0562a5 --- /dev/null +++ b/pitch-deck/app/api/admin/versions/[id]/commit/route.ts @@ -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] }) +} diff --git a/pitch-deck/app/api/admin/versions/[id]/data/[tableName]/route.ts b/pitch-deck/app/api/admin/versions/[id]/data/[tableName]/route.ts new file mode 100644 index 0000000..5918328 --- /dev/null +++ b/pitch-deck/app/api/admin/versions/[id]/data/[tableName]/route.ts @@ -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 }) +} diff --git a/pitch-deck/app/api/admin/versions/[id]/diff/[otherId]/route.ts b/pitch-deck/app/api/admin/versions/[id]/diff/[otherId]/route.ts new file mode 100644 index 0000000..a95ac2a --- /dev/null +++ b/pitch-deck/app/api/admin/versions/[id]/diff/[otherId]/route.ts @@ -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), + }) +} diff --git a/pitch-deck/app/api/admin/versions/[id]/fork/route.ts b/pitch-deck/app/api/admin/versions/[id]/fork/route.ts new file mode 100644 index 0000000..f35dab9 --- /dev/null +++ b/pitch-deck/app/api/admin/versions/[id]/fork/route.ts @@ -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 }) +} diff --git a/pitch-deck/app/api/admin/versions/[id]/route.ts b/pitch-deck/app/api/admin/versions/[id]/route.ts new file mode 100644 index 0000000..acb2024 --- /dev/null +++ b/pitch-deck/app/api/admin/versions/[id]/route.ts @@ -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 }) +} diff --git a/pitch-deck/app/api/admin/versions/route.ts b/pitch-deck/app/api/admin/versions/route.ts new file mode 100644 index 0000000..add82e4 --- /dev/null +++ b/pitch-deck/app/api/admin/versions/route.ts @@ -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 }) +} diff --git a/pitch-deck/app/api/data/route.ts b/pitch-deck/app/api/data/route.ts index ef8a500..413a179 100644 --- a/pitch-deck/app/api/data/route.ts +++ b/pitch-deck/app/api/data/route.ts @@ -1,24 +1,53 @@ import { NextResponse } from 'next/server' import pool from '@/lib/db' +import { getSessionFromCookie } from '@/lib/auth' export const dynamic = 'force-dynamic' export async function GET() { try { - const client = await pool.connect() + // Check if investor has an assigned version + 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 version assigned, load from pitch_version_data + if (versionId) { + const { rows } = await pool.query( + `SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`, + [versionId], + ) + const map: Record = {} + for (const row of rows) { + map[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data + } + return NextResponse.json({ + company: (map.company || [])[0] || null, + team: map.team || [], + financials: map.financials || [], + market: map.market || [], + competitors: map.competitors || [], + features: map.features || [], + milestones: map.milestones || [], + metrics: map.metrics || [], + funding: (map.funding || [])[0] || null, + products: map.products || [], + }) + } + + // Fallback: read from base tables (backward compatible) + const client = await pool.connect() try { const [ - companyRes, - teamRes, - financialsRes, - marketRes, - competitorsRes, - featuresRes, - milestonesRes, - metricsRes, - fundingRes, - productsRes, + companyRes, teamRes, financialsRes, marketRes, competitorsRes, + featuresRes, milestonesRes, metricsRes, fundingRes, productsRes, ] = await Promise.all([ client.query('SELECT * FROM pitch_company LIMIT 1'), client.query('SELECT * FROM pitch_team ORDER BY sort_order'), @@ -49,9 +78,6 @@ export async function GET() { } } catch (error) { console.error('Database query error:', error) - return NextResponse.json( - { error: 'Failed to load pitch data' }, - { status: 500 } - ) + return NextResponse.json({ error: 'Failed to load pitch data' }, { status: 500 }) } } diff --git a/pitch-deck/app/api/financial-model/route.ts b/pitch-deck/app/api/financial-model/route.ts index a74caaf..4dc7f30 100644 --- a/pitch-deck/app/api/financial-model/route.ts +++ b/pitch-deck/app/api/financial-model/route.ts @@ -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[], assumptionRows: Record[]) { + return scenarioRows.map(s => ({ + ...s, + assumptions: assumptionRows + .filter((a: Record) => a.scenario_id === (s as Record).id) + .map((a: Record) => ({ + ...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() } diff --git a/pitch-deck/app/pitch-admin/(authed)/financial-model/[scenarioId]/page.tsx b/pitch-deck/app/pitch-admin/(authed)/financial-model/[scenarioId]/page.tsx index 02b1038..24ddda6 100644 --- a/pitch-deck/app/pitch-admin/(authed)/financial-model/[scenarioId]/page.tsx +++ b/pitch-deck/app/pitch-admin/(authed)/financial-model/[scenarioId]/page.tsx @@ -130,7 +130,7 @@ export default function EditScenarioPage() { const isEdited = edits[a.id] !== undefined const currentValue = isEdited ? edits[a.id] - : a.value_type === 'timeseries' + : typeof a.value === 'object' ? JSON.stringify(a.value) : String(a.value) diff --git a/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx b/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx index f3c7b61..f1cd6ac 100644 --- a/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx +++ b/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx @@ -16,6 +16,9 @@ interface InvestorDetail { last_login_at: string | null login_count: number created_at: string + assigned_version_id: string | null + version_name: string | null + version_status: string | null } sessions: Array<{ id: string @@ -60,6 +63,11 @@ export default function InvestorDetailPage() { const [company, setCompany] = useState('') const [busy, setBusy] = useState(false) const [toast, setToast] = useState(null) + const [versions, setVersions] = useState>([]) + + useEffect(() => { + fetch('/api/admin/versions').then(r => r.json()).then(d => setVersions((d.versions || []).filter((v: { status: string }) => v.status === 'committed'))) + }, []) function flashToast(msg: string) { setToast(msg) @@ -236,6 +244,40 @@ export default function InvestorDetailPage() { + {/* Version assignment */} +
+

Pitch Version

+
+ + + {inv.assigned_version_id + ? `Investor sees version "${inv.version_name || ''}"` + : 'Investor sees default pitch data'} + +
+
+ {/* Audit log for this investor */}

Activity

diff --git a/pitch-deck/app/pitch-admin/(authed)/investors/page.tsx b/pitch-deck/app/pitch-admin/(authed)/investors/page.tsx index 16724d1..082abde 100644 --- a/pitch-deck/app/pitch-admin/(authed)/investors/page.tsx +++ b/pitch-deck/app/pitch-admin/(authed)/investors/page.tsx @@ -15,6 +15,8 @@ interface Investor { created_at: string slides_viewed: number last_activity: string | null + assigned_version_id: string | null + version_name: string | null } const STATUS_STYLES: Record = { @@ -139,6 +141,7 @@ export default function InvestorsPage() { Status Logins Slides + Version Last login Actions @@ -166,6 +169,13 @@ export default function InvestorsPage() { {inv.login_count} {inv.slides_viewed} + + {inv.version_name ? ( + {inv.version_name} + ) : ( + Default + )} + {inv.last_login_at ? new Date(inv.last_login_at).toLocaleDateString() : '—'} diff --git a/pitch-deck/app/pitch-admin/(authed)/versions/[id]/diff/[otherId]/page.tsx b/pitch-deck/app/pitch-admin/(authed)/versions/[id]/diff/[otherId]/page.tsx new file mode 100644 index 0000000..bf05e00 --- /dev/null +++ b/pitch-deck/app/pitch-admin/(authed)/versions/[id]/diff/[otherId]/page.tsx @@ -0,0 +1,116 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams } from 'next/navigation' +import Link from 'next/link' +import { ArrowLeft } from 'lucide-react' + +interface FieldDiff { + key: string + before: unknown + after: unknown +} + +interface RowDiff { + status: 'added' | 'removed' | 'changed' | 'unchanged' + fields: FieldDiff[] +} + +interface TableDiff { + tableName: string + rows: RowDiff[] + hasChanges: boolean +} + +interface DiffData { + versionA: { id: string; name: string } + versionB: { id: string; name: string } + diffs: TableDiff[] + total_changes: number +} + +const STATUS_COLORS: Record = { + added: 'bg-green-500/10 border-green-500/20', + removed: 'bg-rose-500/10 border-rose-500/20', + changed: 'bg-amber-500/10 border-amber-500/20', +} + +export default function DiffPage() { + const { id, otherId } = useParams<{ id: string; otherId: string }>() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!id || !otherId) return + setLoading(true) + fetch(`/api/admin/versions/${id}/diff/${otherId}`) + .then(r => r.json()) + .then(setData) + .finally(() => setLoading(false)) + }, [id, otherId]) + + if (loading) return
+ if (!data) return
Failed to load diff
+ + return ( +
+ + Back to version + + +
+

Diff

+

+ {data.versionA.name} + {' → '} + {data.versionB.name} + {' — '}{data.total_changes} change{data.total_changes !== 1 ? 's' : ''} across {data.diffs.length} table{data.diffs.length !== 1 ? 's' : ''} +

+
+ + {data.diffs.length === 0 ? ( +
+ No differences found +
+ ) : ( +
+ {data.diffs.map(table => ( +
+ + {table.tableName.replace(/_/g, ' ')} + + {table.rows.filter(r => r.status !== 'unchanged').length} change{table.rows.filter(r => r.status !== 'unchanged').length !== 1 ? 's' : ''} + + +
+ {table.rows.filter(r => r.status !== 'unchanged').map((row, i) => ( +
+
+ {row.status} +
+ {row.fields.length > 0 && ( +
+ {row.fields.map(f => ( +
+ {f.key} + {JSON.stringify(f.before)} + + {JSON.stringify(f.after)} +
+ ))} +
+ )} +
+ ))} +
+
+ ))} +
+ )} +
+ ) +} diff --git a/pitch-deck/app/pitch-admin/(authed)/versions/[id]/page.tsx b/pitch-deck/app/pitch-admin/(authed)/versions/[id]/page.tsx new file mode 100644 index 0000000..2697406 --- /dev/null +++ b/pitch-deck/app/pitch-admin/(authed)/versions/[id]/page.tsx @@ -0,0 +1,222 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { useParams, useRouter } from 'next/navigation' +import Link from 'next/link' +import { ArrowLeft, Lock, Save, GitFork } from 'lucide-react' + +const TABLE_LABELS: Record = { + company: 'Company', + team: 'Team', + financials: 'Financials', + market: 'Market', + competitors: 'Competitors', + features: 'Features', + milestones: 'Milestones', + metrics: 'Metrics', + funding: 'Funding', + products: 'Products', + fm_scenarios: 'FM Scenarios', + fm_assumptions: 'FM Assumptions', +} + +const TABLE_NAMES = Object.keys(TABLE_LABELS) + +interface Version { + id: string + name: string + description: string | null + status: 'draft' | 'committed' + parent_id: string | null + committed_at: string | null +} + +export default function VersionEditorPage() { + const { id } = useParams<{ id: string }>() + const router = useRouter() + const [version, setVersion] = useState(null) + const [allData, setAllData] = useState>({}) + const [activeTab, setActiveTab] = useState('company') + const [loading, setLoading] = useState(true) + const [editorValue, setEditorValue] = useState('') + const [dirty, setDirty] = useState(false) + const [saving, setSaving] = useState(false) + const [toast, setToast] = useState(null) + + function flashToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) } + + const load = useCallback(async () => { + setLoading(true) + const res = await fetch(`/api/admin/versions/${id}`) + if (res.ok) { + const d = await res.json() + setVersion(d.version) + setAllData(d.data) + } + setLoading(false) + }, [id]) + + useEffect(() => { if (id) load() }, [id, load]) + + // When tab changes, set editor value + useEffect(() => { + const data = allData[activeTab] + if (data !== undefined) { + setEditorValue(JSON.stringify(data, null, 2)) + setDirty(false) + } + }, [activeTab, allData]) + + async function saveTable() { + let parsed: unknown + try { + parsed = JSON.parse(editorValue) + } catch { + flashToast('Invalid JSON') + return + } + + setSaving(true) + const res = await fetch(`/api/admin/versions/${id}/data/${activeTab}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: parsed }), + }) + setSaving(false) + if (res.ok) { + setDirty(false) + setAllData(prev => ({ ...prev, [activeTab]: Array.isArray(parsed) ? parsed : [parsed] })) + flashToast('Saved') + } else { + const d = await res.json().catch(() => ({})) + flashToast(d.error || 'Save failed') + } + } + + async function commitVersion() { + if (!confirm('Commit this version? It becomes immutable and available for investor assignment.')) return + const res = await fetch(`/api/admin/versions/${id}/commit`, { method: 'POST' }) + if (res.ok) { flashToast('Committed'); load() } + else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') } + } + + async function forkVersion() { + const name = prompt('Name for the new draft:') + if (!name) return + const res = await fetch(`/api/admin/versions/${id}/fork`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }) + if (res.ok) { + const d = await res.json() + router.push(`/pitch-admin/versions/${d.version.id}`) + } else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') } + } + + if (loading) return
+ if (!version) return
Version not found
+ + const isDraft = version.status === 'draft' + + return ( +
+ + Back to versions + + + {/* Header */} +
+
+
+

{version.name}

+ {version.status} +
+ {version.description &&

{version.description}

} +
+
+ {isDraft && ( + + )} + + {version.parent_id && ( + + Diff with parent + + )} +
+
+ + {/* Tab navigation */} +
+ {TABLE_NAMES.map(t => ( + + ))} +
+ + {/* JSON editor */} +
+
+
+ {TABLE_LABELS[activeTab]} + {dirty && Unsaved} +
+ {isDraft && ( + + )} +
+