feat(pitch-deck): full pitch versioning with git-style history (#4)
Some checks failed
Build pitch-deck / build-and-push (push) Failing after 1m8s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 32s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 32s
CI / Deploy (push) Failing after 4s

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) <noreply@anthropic.com>
This commit was merged in pull request #4.
This commit is contained in:
2026-04-10 07:37:33 +00:00
parent 746daaef6d
commit 1c3cec2c06
22 changed files with 1564 additions and 42 deletions

View File

@@ -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,

View File

@@ -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`,
)

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 })
}

View File

@@ -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<string, unknown[]> = {}
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 })
}
}

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()
}

View File

@@ -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)

View File

@@ -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<string | null>(null)
const [versions, setVersions] = useState<Array<{ id: string; name: string; status: string }>>([])
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() {
</div>
</div>
{/* Version assignment */}
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
<h2 className="text-sm font-semibold text-white mb-3">Pitch Version</h2>
<div className="flex items-center gap-3">
<select
value={inv.assigned_version_id || ''}
onChange={async (e) => {
const versionId = e.target.value || null
setBusy(true)
const res = await fetch(`/api/admin/investors/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assigned_version_id: versionId }),
})
setBusy(false)
if (res.ok) { flashToast('Version updated'); load() }
else { flashToast('Update failed') }
}}
disabled={busy}
className="bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
>
<option value="">Default (base tables)</option>
{versions.map(v => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
<span className="text-xs text-white/40">
{inv.assigned_version_id
? `Investor sees version "${inv.version_name || ''}"`
: 'Investor sees default pitch data'}
</span>
</div>
</section>
{/* Audit log for this investor */}
<section className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5">
<h2 className="text-sm font-semibold text-white mb-4">Activity</h2>

View File

@@ -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<string, string> = {
@@ -139,6 +141,7 @@ export default function InvestorsPage() {
<th className="py-3 px-4 font-medium">Status</th>
<th className="py-3 px-4 font-medium text-right">Logins</th>
<th className="py-3 px-4 font-medium text-right">Slides</th>
<th className="py-3 px-4 font-medium">Version</th>
<th className="py-3 px-4 font-medium">Last login</th>
<th className="py-3 px-4 font-medium text-right">Actions</th>
</tr>
@@ -166,6 +169,13 @@ export default function InvestorsPage() {
</td>
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.login_count}</td>
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.slides_viewed}</td>
<td className="py-3 px-4">
{inv.version_name ? (
<span className="text-[10px] px-2 py-0.5 rounded bg-purple-500/15 text-purple-300 border border-purple-500/30">{inv.version_name}</span>
) : (
<span className="text-xs text-white/30">Default</span>
)}
</td>
<td className="py-3 px-4 text-white/50 text-xs whitespace-nowrap">
{inv.last_login_at ? new Date(inv.last_login_at).toLocaleDateString() : '—'}
</td>

View File

@@ -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<string, string> = {
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<DiffData | null>(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 <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
if (!data) return <div className="text-rose-400">Failed to load diff</div>
return (
<div className="space-y-6">
<Link href={`/pitch-admin/versions/${id}`} className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80">
<ArrowLeft className="w-4 h-4" /> Back to version
</Link>
<div>
<h1 className="text-2xl font-semibold text-white mb-1">Diff</h1>
<p className="text-sm text-white/50">
<span className="text-indigo-300">{data.versionA.name}</span>
{' → '}
<span className="text-purple-300">{data.versionB.name}</span>
{' — '}{data.total_changes} change{data.total_changes !== 1 ? 's' : ''} across {data.diffs.length} table{data.diffs.length !== 1 ? 's' : ''}
</p>
</div>
{data.diffs.length === 0 ? (
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-12 text-center text-white/50">
No differences found
</div>
) : (
<div className="space-y-4">
{data.diffs.map(table => (
<details key={table.tableName} open className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
<summary className="px-5 py-3 cursor-pointer flex items-center justify-between hover:bg-white/[0.02]">
<span className="text-sm font-semibold text-white capitalize">{table.tableName.replace(/_/g, ' ')}</span>
<span className="text-xs text-white/40">
{table.rows.filter(r => r.status !== 'unchanged').length} change{table.rows.filter(r => r.status !== 'unchanged').length !== 1 ? 's' : ''}
</span>
</summary>
<div className="px-5 pb-4 space-y-2">
{table.rows.filter(r => r.status !== 'unchanged').map((row, i) => (
<div key={i} className={`rounded-lg border p-3 ${STATUS_COLORS[row.status] || ''}`}>
<div className="flex items-center gap-2 mb-2">
<span className={`text-[9px] px-1.5 py-0.5 rounded uppercase font-semibold ${
row.status === 'added' ? 'text-green-300' :
row.status === 'removed' ? 'text-rose-300' :
'text-amber-300'
}`}>{row.status}</span>
</div>
{row.fields.length > 0 && (
<div className="space-y-1">
{row.fields.map(f => (
<div key={f.key} className="text-xs font-mono grid grid-cols-12 gap-2">
<span className="col-span-3 text-white/60 truncate">{f.key}</span>
<span className="col-span-4 text-rose-300/80 truncate">{JSON.stringify(f.before)}</span>
<span className="col-span-1 text-white/30 text-center"></span>
<span className="col-span-4 text-green-300/80 truncate">{JSON.stringify(f.after)}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
</details>
))}
</div>
)}
</div>
)
}

View File

@@ -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<string, string> = {
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<Version | null>(null)
const [allData, setAllData] = useState<Record<string, unknown[]>>({})
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<string | null>(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 <div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
if (!version) return <div className="text-rose-400">Version not found</div>
const isDraft = version.status === 'draft'
return (
<div className="space-y-6">
<Link href="/pitch-admin/versions" className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80">
<ArrowLeft className="w-4 h-4" /> Back to versions
</Link>
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-2 mb-1">
<h1 className="text-2xl font-semibold text-white">{version.name}</h1>
<span className={`text-[9px] px-2 py-0.5 rounded-full border uppercase font-semibold ${
isDraft ? 'bg-amber-500/15 text-amber-300 border-amber-500/30' : 'bg-green-500/15 text-green-300 border-green-500/30'
}`}>{version.status}</span>
</div>
{version.description && <p className="text-sm text-white/50">{version.description}</p>}
</div>
<div className="flex items-center gap-2">
{isDraft && (
<button
onClick={commitVersion}
className="bg-green-500/15 hover:bg-green-500/25 text-green-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2"
>
<Lock className="w-4 h-4" /> Commit
</button>
)}
<button
onClick={forkVersion}
className="bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2"
>
<GitFork className="w-4 h-4" /> Fork
</button>
{version.parent_id && (
<Link
href={`/pitch-admin/versions/${id}/diff/${version.parent_id}`}
className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg"
>
Diff with parent
</Link>
)}
</div>
</div>
{/* Tab navigation */}
<div className="flex gap-1 overflow-x-auto pb-1">
{TABLE_NAMES.map(t => (
<button
key={t}
onClick={() => { if (dirty && !confirm('Discard unsaved changes?')) return; setActiveTab(t) }}
className={`px-3 py-1.5 rounded-lg text-xs whitespace-nowrap transition-colors ${
activeTab === t
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
: 'bg-white/[0.04] text-white/40 border border-transparent hover:text-white/60'
}`}
>
{TABLE_LABELS[t]}
</button>
))}
</div>
{/* JSON editor */}
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-white">{TABLE_LABELS[activeTab]}</span>
{dirty && <span className="text-[9px] px-2 py-0.5 rounded bg-amber-500/20 text-amber-300">Unsaved</span>}
</div>
{isDraft && (
<button
onClick={saveTable}
disabled={saving || !dirty}
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-1.5 rounded-lg flex items-center gap-2 disabled:opacity-30"
>
<Save className="w-3.5 h-3.5" /> {saving ? 'Saving…' : 'Save'}
</button>
)}
</div>
<textarea
value={editorValue}
onChange={e => { setEditorValue(e.target.value); setDirty(true) }}
readOnly={!isDraft}
className="w-full bg-transparent text-white/90 font-mono text-xs p-4 focus:outline-none resize-none"
style={{ minHeight: '400px' }}
spellCheck={false}
/>
</div>
{!isDraft && (
<p className="text-xs text-white/30 text-center">
This version is committed and read-only. Fork it to make changes.
</p>
)}
{toast && (
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm z-50">
{toast}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,120 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
interface VersionOption {
id: string
name: string
status: string
}
export default function NewVersionPage() {
const router = useRouter()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [parentId, setParentId] = useState<string>('')
const [versions, setVersions] = useState<VersionOption[]>([])
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
fetch('/api/admin/versions')
.then(r => r.json())
.then(d => setVersions(d.versions || []))
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setSubmitting(true)
const res = await fetch('/api/admin/versions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
description: description || undefined,
parent_id: parentId || undefined,
}),
})
setSubmitting(false)
if (res.ok) {
const d = await res.json()
router.push(`/pitch-admin/versions/${d.version.id}`)
} else {
const d = await res.json().catch(() => ({}))
setError(d.error || 'Creation failed')
}
}
return (
<div className="max-w-xl">
<Link href="/pitch-admin/versions" className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white/80 mb-6">
<ArrowLeft className="w-4 h-4" /> Back to versions
</Link>
<h1 className="text-2xl font-semibold text-white mb-2">Create Version</h1>
<p className="text-sm text-white/50 mb-6">
A new draft will be created with a full copy of all pitch data.
Choose a parent to fork from, or leave empty to snapshot the current base tables.
</p>
<form onSubmit={handleSubmit} className="bg-white/[0.04] border border-white/[0.08] rounded-2xl p-6 space-y-4">
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">
Name <span className="text-rose-400">*</span>
</label>
<input
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="e.g. Conservative Q4, Series A Ready"
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
/>
</div>
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
placeholder="Optional notes about this version"
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40 resize-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Fork from</label>
<select
value={parentId}
onChange={e => setParentId(e.target.value)}
className="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/40"
>
<option value="">Base tables (current pitch data)</option>
{versions.map(v => (
<option key={v.id} value={v.id}>{v.name} ({v.status})</option>
))}
</select>
</div>
{error && (
<div className="text-sm text-rose-300 bg-rose-500/10 border border-rose-500/20 rounded-lg px-3 py-2">{error}</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Link href="/pitch-admin/versions" className="text-sm text-white/60 hover:text-white px-4 py-2">Cancel</Link>
<button
type="submit"
disabled={submitting}
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-5 py-2.5 rounded-lg disabled:opacity-50 shadow-lg shadow-indigo-500/20"
>
{submitting ? 'Creating…' : 'Create draft'}
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,198 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { GitBranch, Plus, Lock, Pencil, Trash2, GitFork, Users } from 'lucide-react'
interface Version {
id: string
name: string
description: string | null
parent_id: string | null
status: 'draft' | 'committed'
created_by_name: string | null
created_by_email: string | null
committed_at: string | null
created_at: string
assigned_count: number
}
export default function VersionsPage() {
const [versions, setVersions] = useState<Version[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null)
const [toast, setToast] = useState<string | null>(null)
function flashToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 3000) }
async function load() {
setLoading(true)
const res = await fetch('/api/admin/versions')
if (res.ok) { const d = await res.json(); setVersions(d.versions) }
setLoading(false)
}
useEffect(() => { load() }, [])
async function commitVersion(id: string) {
if (!confirm('Commit this version? It becomes immutable and available for investor assignment.')) return
setBusy(id)
const res = await fetch(`/api/admin/versions/${id}/commit`, { method: 'POST' })
setBusy(null)
if (res.ok) { flashToast('Committed'); load() }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
async function forkVersion(id: string) {
const name = prompt('Name for the new draft:')
if (!name) return
setBusy(id)
const res = await fetch(`/api/admin/versions/${id}/fork`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
setBusy(null)
if (res.ok) { flashToast('Forked'); load() }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
async function deleteVersion(id: string, name: string) {
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return
setBusy(id)
const res = await fetch(`/api/admin/versions/${id}`, { method: 'DELETE' })
setBusy(null)
if (res.ok) { flashToast('Deleted'); load() }
else { const d = await res.json().catch(() => ({})); flashToast(d.error || 'Failed') }
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">Pitch Versions</h1>
<p className="text-sm text-white/50 mt-1">
{versions.length} version{versions.length !== 1 ? 's' : ''} each is a complete snapshot of all pitch data
</p>
</div>
<Link
href="/pitch-admin/versions/new"
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white text-sm font-medium px-4 py-2 rounded-lg shadow-lg shadow-indigo-500/20 flex items-center gap-2"
>
<Plus className="w-4 h-4" /> New Version
</Link>
</div>
{loading ? (
<div className="flex items-center justify-center h-64"><div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" /></div>
) : versions.length === 0 ? (
<div className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-12 text-center">
<GitBranch className="w-12 h-12 text-white/20 mx-auto mb-4" />
<p className="text-white/60 mb-4">No versions yet. Create your first version to snapshot the current pitch data.</p>
<Link
href="/pitch-admin/versions/new"
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium px-4 py-2 rounded-lg inline-flex items-center gap-2"
>
<Plus className="w-4 h-4" /> Create First Version
</Link>
</div>
) : (
<div className="space-y-3">
{versions.map(v => {
const parent = v.parent_id ? versions.find(p => p.id === v.parent_id) : null
return (
<div key={v.id} className="bg-white/[0.04] border border-white/[0.06] rounded-2xl p-5 hover:border-white/[0.12] transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<Link href={`/pitch-admin/versions/${v.id}`} className="text-base font-semibold text-white hover:text-indigo-300">
{v.name}
</Link>
<span className={`text-[9px] px-2 py-0.5 rounded-full border uppercase font-semibold ${
v.status === 'committed'
? 'bg-green-500/15 text-green-300 border-green-500/30'
: 'bg-amber-500/15 text-amber-300 border-amber-500/30'
}`}>
{v.status}
</span>
{v.assigned_count > 0 && (
<span className="text-[9px] px-2 py-0.5 rounded-full bg-indigo-500/15 text-indigo-300 border border-indigo-500/30 flex items-center gap-1">
<Users className="w-3 h-3" /> {v.assigned_count}
</span>
)}
</div>
{v.description && <p className="text-sm text-white/50 mb-1">{v.description}</p>}
<div className="flex items-center gap-3 text-xs text-white/40">
<span>by {v.created_by_name || v.created_by_email || 'system'}</span>
<span>{new Date(v.created_at).toLocaleDateString()}</span>
{parent && (
<span className="flex items-center gap-1">
<GitBranch className="w-3 h-3" /> from {parent.name}
</span>
)}
{v.committed_at && <span>committed {new Date(v.committed_at).toLocaleDateString()}</span>}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Link
href={`/pitch-admin/versions/${v.id}`}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-white/[0.06] hover:text-white"
title="Edit"
>
<Pencil className="w-4 h-4" />
</Link>
{v.status === 'draft' && (
<button
onClick={() => commitVersion(v.id)}
disabled={busy === v.id}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-green-500/15 hover:text-green-300 disabled:opacity-30"
title="Commit"
>
<Lock className="w-4 h-4" />
</button>
)}
<button
onClick={() => forkVersion(v.id)}
disabled={busy === v.id}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-indigo-500/15 hover:text-indigo-300 disabled:opacity-30"
title="Fork"
>
<GitFork className="w-4 h-4" />
</button>
<button
onClick={() => deleteVersion(v.id, v.name)}
disabled={busy === v.id}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-rose-500/15 hover:text-rose-300 disabled:opacity-30"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Quick diff link if has parent */}
{v.parent_id && (
<div className="mt-3 pt-3 border-t border-white/[0.04]">
<Link
href={`/pitch-admin/versions/${v.id}/diff/${v.parent_id}`}
className="text-xs text-indigo-400 hover:text-indigo-300"
>
Compare with parent
</Link>
</div>
)}
</div>
)
})}
</div>
)}
{toast && (
<div className="fixed bottom-6 right-6 bg-indigo-500/20 border border-indigo-500/40 text-indigo-200 text-sm px-4 py-2 rounded-lg backdrop-blur-sm z-50">
{toast}
</div>
)}
</div>
)
}