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

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

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