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
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:
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
)
|
||||
|
||||
|
||||
31
pitch-deck/app/api/admin/versions/[id]/commit/route.ts
Normal file
31
pitch-deck/app/api/admin/versions/[id]/commit/route.ts
Normal 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] })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
38
pitch-deck/app/api/admin/versions/[id]/fork/route.ts
Normal file
38
pitch-deck/app/api/admin/versions/[id]/fork/route.ts
Normal 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 })
|
||||
}
|
||||
85
pitch-deck/app/api/admin/versions/[id]/route.ts
Normal file
85
pitch-deck/app/api/admin/versions/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
56
pitch-deck/app/api/admin/versions/route.ts
Normal file
56
pitch-deck/app/api/admin/versions/route.ts
Normal 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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
222
pitch-deck/app/pitch-admin/(authed)/versions/[id]/page.tsx
Normal file
222
pitch-deck/app/pitch-admin/(authed)/versions/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
120
pitch-deck/app/pitch-admin/(authed)/versions/new/page.tsx
Normal file
120
pitch-deck/app/pitch-admin/(authed)/versions/new/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
198
pitch-deck/app/pitch-admin/(authed)/versions/page.tsx
Normal file
198
pitch-deck/app/pitch-admin/(authed)/versions/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FileText,
|
||||
TrendingUp,
|
||||
ShieldCheck,
|
||||
GitBranch,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
@@ -22,6 +23,7 @@ interface AdminShellProps {
|
||||
const NAV = [
|
||||
{ href: '/pitch-admin', label: 'Dashboard', icon: LayoutDashboard, exact: true },
|
||||
{ href: '/pitch-admin/investors', label: 'Investors', icon: Users },
|
||||
{ href: '/pitch-admin/versions', label: 'Versions', icon: GitBranch },
|
||||
{ href: '/pitch-admin/audit', label: 'Audit Log', icon: FileText },
|
||||
{ href: '/pitch-admin/financial-model', label: 'Financial Model', icon: TrendingUp },
|
||||
{ href: '/pitch-admin/admins', label: 'Admins', icon: ShieldCheck },
|
||||
@@ -43,7 +45,7 @@ export default function AdminShell({ admin, children }: AdminShellProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a1a] text-white flex">
|
||||
<div className="h-screen bg-[#0a0a1a] text-white flex overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed lg:static inset-y-0 left-0 z-40 w-64 bg-black/40 backdrop-blur-xl border-r border-white/[0.06]
|
||||
@@ -111,7 +113,7 @@ export default function AdminShell({ admin, children }: AdminShellProps) {
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex-1 flex flex-col min-w-0 min-h-0">
|
||||
<header className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
|
||||
102
pitch-deck/lib/version-diff.ts
Normal file
102
pitch-deck/lib/version-diff.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
export interface FieldDiff {
|
||||
key: string
|
||||
before: unknown
|
||||
after: unknown
|
||||
}
|
||||
|
||||
export interface RowDiff {
|
||||
status: 'added' | 'removed' | 'changed' | 'unchanged'
|
||||
id?: string | number
|
||||
fields: FieldDiff[]
|
||||
before?: Record<string, unknown>
|
||||
after?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface TableDiff {
|
||||
tableName: string
|
||||
rows: RowDiff[]
|
||||
hasChanges: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff two arrays of row objects. Matches rows by `id` field if present,
|
||||
* otherwise by array position.
|
||||
*/
|
||||
export function diffTable(
|
||||
tableName: string,
|
||||
before: unknown[],
|
||||
after: unknown[],
|
||||
): TableDiff {
|
||||
const beforeArr = (before || []) as Record<string, unknown>[]
|
||||
const afterArr = (after || []) as Record<string, unknown>[]
|
||||
|
||||
const rows: RowDiff[] = []
|
||||
|
||||
// Build lookup by id if available
|
||||
const hasIds = beforeArr.length > 0 && 'id' in (beforeArr[0] || {})
|
||||
|
||||
if (hasIds) {
|
||||
const beforeMap = new Map(beforeArr.map(r => [String(r.id), r]))
|
||||
const afterMap = new Map(afterArr.map(r => [String(r.id), r]))
|
||||
const allIds = new Set([...beforeMap.keys(), ...afterMap.keys()])
|
||||
|
||||
for (const id of allIds) {
|
||||
const b = beforeMap.get(id)
|
||||
const a = afterMap.get(id)
|
||||
|
||||
if (!b && a) {
|
||||
rows.push({ status: 'added', id: a.id as string, fields: [], after: a })
|
||||
} else if (b && !a) {
|
||||
rows.push({ status: 'removed', id: b.id as string, fields: [], before: b })
|
||||
} else if (b && a) {
|
||||
const fields = diffFields(b, a)
|
||||
rows.push({
|
||||
status: fields.length > 0 ? 'changed' : 'unchanged',
|
||||
id: b.id as string,
|
||||
fields,
|
||||
before: b,
|
||||
after: a,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Positional comparison
|
||||
const maxLen = Math.max(beforeArr.length, afterArr.length)
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const b = beforeArr[i]
|
||||
const a = afterArr[i]
|
||||
if (!b && a) {
|
||||
rows.push({ status: 'added', fields: [], after: a })
|
||||
} else if (b && !a) {
|
||||
rows.push({ status: 'removed', fields: [], before: b })
|
||||
} else if (b && a) {
|
||||
const fields = diffFields(b, a)
|
||||
rows.push({
|
||||
status: fields.length > 0 ? 'changed' : 'unchanged',
|
||||
fields,
|
||||
before: b,
|
||||
after: a,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tableName,
|
||||
rows,
|
||||
hasChanges: rows.some(r => r.status !== 'unchanged'),
|
||||
}
|
||||
}
|
||||
|
||||
function diffFields(before: Record<string, unknown>, after: Record<string, unknown>): FieldDiff[] {
|
||||
const diffs: FieldDiff[] = []
|
||||
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)])
|
||||
for (const key of allKeys) {
|
||||
const bVal = JSON.stringify(before[key] ?? null)
|
||||
const aVal = JSON.stringify(after[key] ?? null)
|
||||
if (bVal !== aVal) {
|
||||
diffs.push({ key, before: before[key], after: after[key] })
|
||||
}
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
76
pitch-deck/lib/version-helpers.ts
Normal file
76
pitch-deck/lib/version-helpers.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import pool from './db'
|
||||
|
||||
/**
|
||||
* The 12 data tables tracked per version.
|
||||
* Each maps to a pitch_version_data.table_name value.
|
||||
*/
|
||||
export const VERSION_TABLES = [
|
||||
'company', 'team', 'financials', 'market', 'competitors',
|
||||
'features', 'milestones', 'metrics', 'funding', 'products',
|
||||
'fm_scenarios', 'fm_assumptions',
|
||||
] as const
|
||||
|
||||
export type VersionTableName = typeof VERSION_TABLES[number]
|
||||
|
||||
/** Maps version table names to the actual DB table + ORDER BY */
|
||||
const TABLE_QUERIES: Record<VersionTableName, string> = {
|
||||
company: 'SELECT * FROM pitch_company LIMIT 1',
|
||||
team: 'SELECT * FROM pitch_team ORDER BY sort_order',
|
||||
financials: 'SELECT * FROM pitch_financials ORDER BY year',
|
||||
market: 'SELECT * FROM pitch_market ORDER BY id',
|
||||
competitors: 'SELECT * FROM pitch_competitors ORDER BY id',
|
||||
features: 'SELECT * FROM pitch_features ORDER BY sort_order',
|
||||
milestones: 'SELECT * FROM pitch_milestones ORDER BY sort_order',
|
||||
metrics: 'SELECT * FROM pitch_metrics ORDER BY id',
|
||||
funding: 'SELECT * FROM pitch_funding LIMIT 1',
|
||||
products: 'SELECT * FROM pitch_products ORDER BY sort_order',
|
||||
fm_scenarios: 'SELECT * FROM pitch_fm_scenarios ORDER BY is_default DESC, name',
|
||||
fm_assumptions: 'SELECT * FROM pitch_fm_assumptions ORDER BY sort_order',
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot all base tables into pitch_version_data for a given version.
|
||||
*/
|
||||
export async function snapshotBaseTables(versionId: string, adminId: string | null): Promise<void> {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
for (const tableName of VERSION_TABLES) {
|
||||
const { rows } = await client.query(TABLE_QUERIES[tableName])
|
||||
await client.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`,
|
||||
[versionId, tableName, JSON.stringify(rows), adminId],
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy all version data from one version to another.
|
||||
*/
|
||||
export async function copyVersionData(fromVersionId: string, toVersionId: string, adminId: string | null): Promise<void> {
|
||||
await pool.query(
|
||||
`INSERT INTO pitch_version_data (version_id, table_name, data, updated_by)
|
||||
SELECT $1, table_name, data, $3
|
||||
FROM pitch_version_data WHERE version_id = $2`,
|
||||
[toVersionId, fromVersionId, adminId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all version data as a map of table_name → JSONB rows.
|
||||
*/
|
||||
export async function loadVersionData(versionId: string): Promise<Record<string, unknown[]>> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT table_name, data FROM pitch_version_data WHERE version_id = $1`,
|
||||
[versionId],
|
||||
)
|
||||
const result: Record<string, unknown[]> = {}
|
||||
for (const row of rows) {
|
||||
result[row.table_name] = typeof row.data === 'string' ? JSON.parse(row.data) : row.data
|
||||
}
|
||||
return result
|
||||
}
|
||||
191
pitch-deck/migrations/000_pitch_data_tables.sql
Normal file
191
pitch-deck/migrations/000_pitch_data_tables.sql
Normal file
@@ -0,0 +1,191 @@
|
||||
-- =========================================================
|
||||
-- Pitch Deck: Core data tables + Financial Model
|
||||
-- Run BEFORE 001_investor_auth.sql
|
||||
-- =========================================================
|
||||
|
||||
-- Company info
|
||||
CREATE TABLE IF NOT EXISTS pitch_company (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT,
|
||||
legal_form TEXT,
|
||||
founding_date TEXT,
|
||||
tagline_de TEXT,
|
||||
tagline_en TEXT,
|
||||
mission_de TEXT,
|
||||
mission_en TEXT,
|
||||
website TEXT,
|
||||
hq_city TEXT
|
||||
);
|
||||
|
||||
-- Team members
|
||||
CREATE TABLE IF NOT EXISTS pitch_team (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT,
|
||||
role_de TEXT,
|
||||
role_en TEXT,
|
||||
bio_de TEXT,
|
||||
bio_en TEXT,
|
||||
equity_pct NUMERIC,
|
||||
expertise TEXT[],
|
||||
linkedin_url TEXT,
|
||||
photo_url TEXT,
|
||||
sort_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
-- Historical financials
|
||||
CREATE TABLE IF NOT EXISTS pitch_financials (
|
||||
id SERIAL PRIMARY KEY,
|
||||
year INT,
|
||||
revenue_eur BIGINT,
|
||||
costs_eur BIGINT,
|
||||
mrr_eur BIGINT,
|
||||
burn_rate_eur BIGINT,
|
||||
customers_count INT,
|
||||
employees_count INT,
|
||||
arr_eur BIGINT
|
||||
);
|
||||
|
||||
-- Market segments (TAM/SAM/SOM)
|
||||
CREATE TABLE IF NOT EXISTS pitch_market (
|
||||
id SERIAL PRIMARY KEY,
|
||||
market_segment TEXT,
|
||||
label TEXT,
|
||||
value_eur BIGINT,
|
||||
growth_rate_pct NUMERIC,
|
||||
source TEXT
|
||||
);
|
||||
|
||||
-- Competitors
|
||||
CREATE TABLE IF NOT EXISTS pitch_competitors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT,
|
||||
customers_count INT,
|
||||
pricing_range TEXT,
|
||||
strengths TEXT[],
|
||||
weaknesses TEXT[],
|
||||
website TEXT
|
||||
);
|
||||
|
||||
-- Feature comparison matrix
|
||||
CREATE TABLE IF NOT EXISTS pitch_features (
|
||||
id SERIAL PRIMARY KEY,
|
||||
feature_name_de TEXT,
|
||||
feature_name_en TEXT,
|
||||
category TEXT,
|
||||
breakpilot BOOLEAN,
|
||||
proliance BOOLEAN,
|
||||
dataguard BOOLEAN,
|
||||
heydata BOOLEAN,
|
||||
is_differentiator BOOLEAN,
|
||||
sort_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
-- Milestones / timeline
|
||||
CREATE TABLE IF NOT EXISTS pitch_milestones (
|
||||
id SERIAL PRIMARY KEY,
|
||||
milestone_date TEXT,
|
||||
title_de TEXT,
|
||||
title_en TEXT,
|
||||
description_de TEXT,
|
||||
description_en TEXT,
|
||||
status TEXT,
|
||||
category TEXT,
|
||||
sort_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
-- Key metrics
|
||||
CREATE TABLE IF NOT EXISTS pitch_metrics (
|
||||
id SERIAL PRIMARY KEY,
|
||||
metric_name TEXT,
|
||||
label_de TEXT,
|
||||
label_en TEXT,
|
||||
value TEXT,
|
||||
unit TEXT,
|
||||
is_live BOOLEAN
|
||||
);
|
||||
|
||||
-- Funding round
|
||||
CREATE TABLE IF NOT EXISTS pitch_funding (
|
||||
id SERIAL PRIMARY KEY,
|
||||
round_name TEXT,
|
||||
amount_eur BIGINT,
|
||||
use_of_funds JSONB,
|
||||
instrument TEXT,
|
||||
target_date TEXT,
|
||||
status TEXT
|
||||
);
|
||||
|
||||
-- Products / tiers
|
||||
CREATE TABLE IF NOT EXISTS pitch_products (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT,
|
||||
hardware TEXT,
|
||||
hardware_cost_eur NUMERIC,
|
||||
monthly_price_eur NUMERIC,
|
||||
llm_model TEXT,
|
||||
llm_size TEXT,
|
||||
llm_capability_de TEXT,
|
||||
llm_capability_en TEXT,
|
||||
features_de TEXT[],
|
||||
features_en TEXT[],
|
||||
is_popular BOOLEAN,
|
||||
operating_cost_eur NUMERIC,
|
||||
sort_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
-- =========================================================
|
||||
-- Financial Model
|
||||
-- =========================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pitch_fm_scenarios (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
is_default BOOLEAN DEFAULT false,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
sort_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pitch_fm_assumptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scenario_id UUID REFERENCES pitch_fm_scenarios(id) ON DELETE CASCADE,
|
||||
key TEXT,
|
||||
label_de TEXT,
|
||||
label_en TEXT,
|
||||
value JSONB,
|
||||
value_type TEXT DEFAULT 'scalar',
|
||||
unit TEXT,
|
||||
min_value NUMERIC,
|
||||
max_value NUMERIC,
|
||||
step_size NUMERIC,
|
||||
category TEXT,
|
||||
sort_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pitch_fm_results (
|
||||
id SERIAL PRIMARY KEY,
|
||||
scenario_id UUID REFERENCES pitch_fm_scenarios(id) ON DELETE CASCADE,
|
||||
month INT,
|
||||
year INT,
|
||||
month_in_year INT,
|
||||
new_customers INT,
|
||||
churned_customers INT,
|
||||
total_customers INT,
|
||||
mrr_eur NUMERIC,
|
||||
arr_eur NUMERIC,
|
||||
revenue_eur NUMERIC,
|
||||
cogs_eur NUMERIC,
|
||||
personnel_eur NUMERIC,
|
||||
infra_eur NUMERIC,
|
||||
marketing_eur NUMERIC,
|
||||
total_costs_eur NUMERIC,
|
||||
employees_count INT,
|
||||
gross_margin_pct NUMERIC,
|
||||
burn_rate_eur NUMERIC,
|
||||
runway_months NUMERIC,
|
||||
cac_eur NUMERIC,
|
||||
ltv_eur NUMERIC,
|
||||
ltv_cac_ratio NUMERIC,
|
||||
cash_balance_eur NUMERIC,
|
||||
cumulative_revenue_eur NUMERIC
|
||||
);
|
||||
36
pitch-deck/migrations/003_pitch_versions.sql
Normal file
36
pitch-deck/migrations/003_pitch_versions.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- =========================================================
|
||||
-- Pitch Deck: Version Management (Git-Style History)
|
||||
-- =========================================================
|
||||
|
||||
-- Version metadata: each version points to its parent (git-style DAG)
|
||||
CREATE TABLE IF NOT EXISTS pitch_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
parent_id UUID REFERENCES pitch_versions(id) ON DELETE SET NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'committed')),
|
||||
created_by UUID REFERENCES pitch_admins(id) ON DELETE SET NULL,
|
||||
committed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_versions_parent ON pitch_versions(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_versions_status ON pitch_versions(status);
|
||||
|
||||
-- Version content: one row per data table per version (fully materialized)
|
||||
-- table_name values: company, team, financials, market, competitors, features,
|
||||
-- milestones, metrics, funding, products, fm_scenarios, fm_assumptions
|
||||
CREATE TABLE IF NOT EXISTS pitch_version_data (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
version_id UUID NOT NULL REFERENCES pitch_versions(id) ON DELETE CASCADE,
|
||||
table_name TEXT NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by UUID REFERENCES pitch_admins(id) ON DELETE SET NULL,
|
||||
UNIQUE(version_id, table_name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pitch_version_data_version ON pitch_version_data(version_id);
|
||||
|
||||
-- Per-investor version assignment (NULL = use base tables)
|
||||
ALTER TABLE pitch_investors
|
||||
ADD COLUMN IF NOT EXISTS assigned_version_id UUID REFERENCES pitch_versions(id) ON DELETE SET NULL;
|
||||
Reference in New Issue
Block a user