Files
breakpilot-core/pitch-deck/lib/hooks/useFinancialModel.ts
Sharang Parnerkar 244071d63b feat(pitch-deck): add passwordless investor auth, audit logs, snapshots & PWA
Implement a complete investor access system for the pitch deck:

- Passwordless magic link auth (jose JWT + nodemailer SMTP)
- Per-investor audit logging (slide views, assumption changes, chat)
- Financial model snapshot persistence (auto-save/restore per investor)
- PWA support (manifest, service worker, offline caching, icons)
- Security safeguards (watermark overlay, rate limiting, anti-scraping
  headers, content protection, single-session enforcement)
- Admin API for invite/revoke/audit-log management
- Integrated into docker-compose.coolify.yml for production deployment

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

191 lines
6.4 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { FMScenario, FMResult, FMComputeResponse, InvestorSnapshot } from '../types'
export function useFinancialModel(investorId?: string | null) {
const [scenarios, setScenarios] = useState<FMScenario[]>([])
const [activeScenarioId, setActiveScenarioId] = useState<string | null>(null)
const [compareMode, setCompareMode] = useState(false)
const [results, setResults] = useState<Map<string, FMComputeResponse>>(new Map())
const [loading, setLoading] = useState(true)
const [computing, setComputing] = useState(false)
const [snapshotStatus, setSnapshotStatus] = useState<'default' | 'saving' | 'saved' | 'restored'>('default')
const computeTimer = useRef<NodeJS.Timeout | null>(null)
const snapshotTimer = useRef<NodeJS.Timeout | null>(null)
const snapshotsLoaded = useRef(false)
// Load scenarios on mount, then apply snapshots if investor is logged in
useEffect(() => {
async function load() {
try {
const res = await fetch('/api/financial-model')
if (res.ok) {
let data: FMScenario[] = await res.json()
// If investor is logged in, restore their snapshots
if (investorId && !snapshotsLoaded.current) {
try {
const snapRes = await fetch('/api/snapshots')
if (snapRes.ok) {
const { snapshots } = await snapRes.json() as { snapshots: InvestorSnapshot[] }
if (snapshots.length > 0) {
data = data.map(scenario => {
const snapshot = snapshots.find(s => s.scenario_id === scenario.id)
if (!snapshot) return scenario
return {
...scenario,
assumptions: scenario.assumptions.map(a => {
const savedValue = snapshot.assumptions[a.key]
return savedValue !== undefined ? { ...a, value: savedValue } : a
}),
}
})
setSnapshotStatus('restored')
}
}
} catch {
// Snapshot restore failed — use defaults
}
snapshotsLoaded.current = true
}
setScenarios(data)
const defaultScenario = data.find(s => s.is_default) || data[0]
if (defaultScenario) {
setActiveScenarioId(defaultScenario.id)
}
}
} catch (err) {
console.error('Failed to load financial model:', err)
} finally {
setLoading(false)
}
}
load()
}, [investorId])
// Compute when active scenario changes
useEffect(() => {
if (activeScenarioId && !results.has(activeScenarioId)) {
compute(activeScenarioId)
}
}, [activeScenarioId]) // eslint-disable-line react-hooks/exhaustive-deps
const compute = useCallback(async (scenarioId: string) => {
setComputing(true)
try {
const res = await fetch('/api/financial-model/compute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scenarioId }),
})
if (res.ok) {
const data: FMComputeResponse = await res.json()
setResults(prev => new Map(prev).set(scenarioId, data))
}
} catch (err) {
console.error('Compute failed:', err)
} finally {
setComputing(false)
}
}, [])
// Auto-save snapshot (debounced)
const saveSnapshot = useCallback(async (scenarioId: string) => {
if (!investorId) return
const scenario = scenarios.find(s => s.id === scenarioId)
if (!scenario) return
const assumptions: Record<string, number | number[]> = {}
scenario.assumptions.forEach(a => { assumptions[a.key] = a.value })
setSnapshotStatus('saving')
try {
await fetch('/api/snapshots', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scenario_id: scenarioId, assumptions }),
})
setSnapshotStatus('saved')
} catch {
setSnapshotStatus('default')
}
}, [investorId, scenarios])
const updateAssumption = useCallback(async (scenarioId: string, key: string, value: number | number[]) => {
// Optimistic update in local state
setScenarios(prev => prev.map(s => {
if (s.id !== scenarioId) return s
return {
...s,
assumptions: s.assumptions.map(a => a.key === key ? { ...a, value } : a),
}
}))
// Save to DB
await fetch('/api/financial-model/assumptions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scenarioId, key, value }),
})
// Debounced recompute
if (computeTimer.current) clearTimeout(computeTimer.current)
computeTimer.current = setTimeout(() => compute(scenarioId), 300)
// Debounced snapshot save (2s after last change)
if (snapshotTimer.current) clearTimeout(snapshotTimer.current)
snapshotTimer.current = setTimeout(() => saveSnapshot(scenarioId), 2000)
}, [compute, saveSnapshot])
const resetToDefaults = useCallback(async (scenarioId: string) => {
// Reload from server (without snapshots)
try {
const res = await fetch('/api/financial-model')
if (res.ok) {
const data: FMScenario[] = await res.json()
const defaultScenario = data.find(s => s.id === scenarioId)
if (defaultScenario) {
setScenarios(prev => prev.map(s => s.id === scenarioId ? defaultScenario : s))
// Delete snapshot
if (investorId) {
await fetch(`/api/snapshots?id=${scenarioId}`, { method: 'DELETE' })
}
setSnapshotStatus('default')
compute(scenarioId)
}
}
} catch {
// ignore
}
}, [compute, investorId])
const computeAll = useCallback(async () => {
for (const s of scenarios) {
await compute(s.id)
}
}, [scenarios, compute])
const activeScenario = scenarios.find(s => s.id === activeScenarioId) || null
const activeResults = activeScenarioId ? results.get(activeScenarioId) || null : null
return {
scenarios,
activeScenario,
activeScenarioId,
setActiveScenarioId,
activeResults,
results,
loading,
computing,
compareMode,
setCompareMode,
compute,
computeAll,
updateAssumption,
resetToDefaults,
snapshotStatus,
}
}