Some checks failed
CI / go-lint (pull_request) Failing after 17s
CI / python-lint (pull_request) Failing after 12s
CI / nodejs-lint (pull_request) Failing after 7s
CI / test-go-consent (pull_request) Failing after 11s
CI / test-python-voice (pull_request) Failing after 11s
CI / test-bqas (pull_request) Failing after 11s
CI / Deploy (pull_request) Has been skipped
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>
74 lines
2.2 KiB
TypeScript
74 lines
2.2 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef, useCallback } from 'react'
|
|
|
|
interface AuditTrackerOptions {
|
|
investorId: string | null
|
|
currentSlide: string
|
|
enabled: boolean
|
|
}
|
|
|
|
export function useAuditTracker({ investorId, currentSlide, enabled }: AuditTrackerOptions) {
|
|
const lastSlide = useRef<string>('')
|
|
const slideTimestamps = useRef<Map<string, number>>(new Map())
|
|
const pendingEvents = useRef<Array<{ action: string; details: Record<string, unknown>; slide_id?: string }>>([])
|
|
const flushTimer = useRef<NodeJS.Timeout | null>(null)
|
|
|
|
const flush = useCallback(async () => {
|
|
if (pendingEvents.current.length === 0) return
|
|
const events = [...pendingEvents.current]
|
|
pendingEvents.current = []
|
|
|
|
// Send events one at a time (they're debounced so there shouldn't be many)
|
|
for (const event of events) {
|
|
try {
|
|
await fetch('/api/audit', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(event),
|
|
})
|
|
} catch {
|
|
// Silently fail - audit should not block UX
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
const track = useCallback((action: string, details: Record<string, unknown> = {}, slideId?: string) => {
|
|
if (!enabled || !investorId) return
|
|
|
|
pendingEvents.current.push({ action, details, slide_id: slideId })
|
|
|
|
// Debounce flush by 500ms
|
|
if (flushTimer.current) clearTimeout(flushTimer.current)
|
|
flushTimer.current = setTimeout(flush, 500)
|
|
}, [enabled, investorId, flush])
|
|
|
|
// Track slide views
|
|
useEffect(() => {
|
|
if (!enabled || !investorId || !currentSlide) return
|
|
if (currentSlide === lastSlide.current) return
|
|
|
|
const now = Date.now()
|
|
const prevTimestamp = slideTimestamps.current.get(lastSlide.current)
|
|
const dwellTime = prevTimestamp ? now - prevTimestamp : 0
|
|
|
|
lastSlide.current = currentSlide
|
|
slideTimestamps.current.set(currentSlide, now)
|
|
|
|
track('slide_viewed', {
|
|
slide_id: currentSlide,
|
|
previous_dwell_ms: dwellTime,
|
|
}, currentSlide)
|
|
}, [currentSlide, enabled, investorId, track])
|
|
|
|
// Flush on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (flushTimer.current) clearTimeout(flushTimer.current)
|
|
flush()
|
|
}
|
|
}, [flush])
|
|
|
|
return { track }
|
|
}
|