Files
breakpilot-core/pitch-deck/lib/version-diff.ts
Sharang Parnerkar 1872079504
Some checks failed
CI / go-lint (pull_request) Failing after 13s
CI / python-lint (pull_request) Failing after 13s
CI / nodejs-lint (pull_request) Failing after 8s
CI / test-go-consent (pull_request) Failing after 3s
CI / test-python-voice (pull_request) Failing after 10s
CI / test-bqas (pull_request) Failing after 11s
CI / Deploy (pull_request) Has been skipped
feat(pitch-deck): full pitch versioning with git-style history + bug fixes
Adds a complete version management system where every piece of pitch
data (all 12 tables: company, team, financials, market, competitors,
features, milestones, metrics, funding, products, fm_scenarios,
fm_assumptions) can be versioned, diffed, and assigned per-investor.

Version lifecycle: create draft → edit freely → commit (immutable) →
fork to create new draft. Parent chain gives full git-style history.

Backend:
- Migration 003: pitch_versions, pitch_version_data tables + investor
  assigned_version_id column
- lib/version-helpers.ts: snapshot base tables, copy between versions
- lib/version-diff.ts: per-table row+field diffing engine
- 7 new API routes: versions CRUD, commit, fork, per-table data
  GET/PUT, diff endpoint
- /api/data + /api/financial-model: version-aware loading (check
  investor's assigned_version_id, serve version data or fall back
  to base tables)
- Investor PATCH: accepts assigned_version_id (validates committed)

Frontend:
- /pitch-admin/versions: list with status badges, fork/commit/delete
- /pitch-admin/versions/new: create from base tables or fork existing
- /pitch-admin/versions/[id]: 12-tab JSON editor (one per data table)
  with save-per-table, commit button, fork button
- /pitch-admin/versions/[id]/diff/[otherId]: side-by-side diff view
  with added/removed/changed highlighting per field
- Investors list: version column showing assigned version name
- Investor detail: version selector dropdown (committed versions only)
- AdminShell: Versions nav item added

Bug fixes:
- FM editor: [object Object] for JSONB array values → JSON.stringify
- Admin pages not scrollable → h-screen + overflow-hidden on shell,
  min-h-0 on flex column

Also includes migration 000 for fresh installs (pitch data tables).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:34:03 +02:00

103 lines
2.8 KiB
TypeScript

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
}