merge: gitea/main — resolve pitch-deck conflicts (accept theirs)
Some checks failed
CI / test-go-consent (push) Successful in 45s
CI / test-python-voice (push) Successful in 37s
CI / test-bqas (push) Successful in 34s
CI / Deploy (push) Failing after 5s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
Some checks failed
CI / test-go-consent (push) Successful in 45s
CI / test-python-voice (push) Successful in 37s
CI / test-bqas (push) Successful in 34s
CI / Deploy (push) Failing after 5s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
206
pitch-deck/lib/admin-auth.ts
Normal file
206
pitch-deck/lib/admin-auth.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { SignJWT, jwtVerify } from 'jose'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { cookies } from 'next/headers'
|
||||
import { NextResponse } from 'next/server'
|
||||
import pool from './db'
|
||||
import { hashToken, generateToken, getClientIp, logAudit } from './auth'
|
||||
|
||||
const ADMIN_COOKIE_NAME = 'pitch_admin_session'
|
||||
const ADMIN_JWT_AUDIENCE = 'pitch-admin'
|
||||
const ADMIN_JWT_EXPIRY = '2h'
|
||||
const ADMIN_SESSION_EXPIRY_HOURS = 12
|
||||
|
||||
function getJwtSecret() {
|
||||
const secret = process.env.PITCH_JWT_SECRET
|
||||
if (!secret) throw new Error('PITCH_JWT_SECRET not set')
|
||||
return new TextEncoder().encode(secret)
|
||||
}
|
||||
|
||||
export interface Admin {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
last_login_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminJwtPayload {
|
||||
sub: string // admin id
|
||||
email: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 12)
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash)
|
||||
}
|
||||
|
||||
export async function createAdminJwt(payload: AdminJwtPayload): Promise<string> {
|
||||
return new SignJWT({ ...payload })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(ADMIN_JWT_EXPIRY)
|
||||
.setAudience(ADMIN_JWT_AUDIENCE)
|
||||
.sign(getJwtSecret())
|
||||
}
|
||||
|
||||
export async function verifyAdminJwt(token: string): Promise<AdminJwtPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwtSecret(), { audience: ADMIN_JWT_AUDIENCE })
|
||||
return payload as unknown as AdminJwtPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAdminSession(
|
||||
adminId: string,
|
||||
ip: string | null,
|
||||
userAgent: string | null,
|
||||
): Promise<{ sessionId: string; jwt: string }> {
|
||||
// Single session per admin
|
||||
await pool.query(
|
||||
`UPDATE pitch_admin_sessions SET revoked = true WHERE admin_id = $1 AND revoked = false`,
|
||||
[adminId],
|
||||
)
|
||||
|
||||
const sessionToken = generateToken()
|
||||
const tokenHash = hashToken(sessionToken)
|
||||
const expiresAt = new Date(Date.now() + ADMIN_SESSION_EXPIRY_HOURS * 60 * 60 * 1000)
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO pitch_admin_sessions (admin_id, token_hash, ip_address, user_agent, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[adminId, tokenHash, ip, userAgent, expiresAt],
|
||||
)
|
||||
|
||||
const sessionId = rows[0].id
|
||||
|
||||
const adminRes = await pool.query(`SELECT email FROM pitch_admins WHERE id = $1`, [adminId])
|
||||
const jwt = await createAdminJwt({
|
||||
sub: adminId,
|
||||
email: adminRes.rows[0].email,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
return { sessionId, jwt }
|
||||
}
|
||||
|
||||
export async function validateAdminSession(sessionId: string, adminId: string): Promise<boolean> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.id FROM pitch_admin_sessions s
|
||||
JOIN pitch_admins a ON a.id = s.admin_id
|
||||
WHERE s.id = $1 AND s.admin_id = $2 AND s.revoked = false AND s.expires_at > NOW() AND a.is_active = true`,
|
||||
[sessionId, adminId],
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
export async function revokeAdminSession(sessionId: string): Promise<void> {
|
||||
await pool.query(`UPDATE pitch_admin_sessions SET revoked = true WHERE id = $1`, [sessionId])
|
||||
}
|
||||
|
||||
export async function revokeAllAdminSessions(adminId: string): Promise<void> {
|
||||
await pool.query(`UPDATE pitch_admin_sessions SET revoked = true WHERE admin_id = $1`, [adminId])
|
||||
}
|
||||
|
||||
export async function setAdminCookie(jwt: string): Promise<void> {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set(ADMIN_COOKIE_NAME, jwt, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: ADMIN_SESSION_EXPIRY_HOURS * 60 * 60,
|
||||
})
|
||||
}
|
||||
|
||||
export async function clearAdminCookie(): Promise<void> {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.delete(ADMIN_COOKIE_NAME)
|
||||
}
|
||||
|
||||
export async function getAdminPayloadFromCookie(): Promise<AdminJwtPayload | null> {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get(ADMIN_COOKIE_NAME)?.value
|
||||
if (!token) return null
|
||||
return verifyAdminJwt(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side: read the admin row from the cookie. Returns null if no valid session
|
||||
* or the admin is inactive. Use in layout.tsx and API routes.
|
||||
*/
|
||||
export async function getAdminFromCookie(): Promise<Admin | null> {
|
||||
const payload = await getAdminPayloadFromCookie()
|
||||
if (!payload) return null
|
||||
|
||||
const valid = await validateAdminSession(payload.sessionId, payload.sub)
|
||||
if (!valid) return null
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, email, name, is_active, last_login_at, created_at
|
||||
FROM pitch_admins WHERE id = $1`,
|
||||
[payload.sub],
|
||||
)
|
||||
if (rows.length === 0 || !rows[0].is_active) return null
|
||||
return rows[0] as Admin
|
||||
}
|
||||
|
||||
/**
|
||||
* API guard: returns the Admin row, OR a NextResponse 401/403 to return early.
|
||||
* Also accepts the legacy PITCH_ADMIN_SECRET bearer header for CLI/automation —
|
||||
* in that case the returned admin id is null but the request is allowed.
|
||||
*/
|
||||
export type AdminGuardResult =
|
||||
| { kind: 'admin'; admin: Admin }
|
||||
| { kind: 'cli' }
|
||||
| { kind: 'response'; response: NextResponse }
|
||||
|
||||
export async function requireAdmin(request: Request): Promise<AdminGuardResult> {
|
||||
// CLI fallback via shared secret
|
||||
const secret = process.env.PITCH_ADMIN_SECRET
|
||||
if (secret) {
|
||||
const auth = request.headers.get('authorization')
|
||||
if (auth === `Bearer ${secret}`) {
|
||||
return { kind: 'cli' }
|
||||
}
|
||||
}
|
||||
|
||||
const admin = await getAdminFromCookie()
|
||||
if (!admin) {
|
||||
return {
|
||||
kind: 'response',
|
||||
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
}
|
||||
}
|
||||
return { kind: 'admin', admin }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: log an admin-initiated audit event. Falls back to CLI actor when admin is null.
|
||||
*/
|
||||
export async function logAdminAudit(
|
||||
adminId: string | null,
|
||||
action: string,
|
||||
details: Record<string, unknown> = {},
|
||||
request?: Request,
|
||||
targetInvestorId?: string | null,
|
||||
): Promise<void> {
|
||||
await logAudit(
|
||||
null, // investor_id
|
||||
action,
|
||||
details,
|
||||
request,
|
||||
undefined, // slide_id
|
||||
undefined, // session_id
|
||||
adminId,
|
||||
targetInvestorId ?? null,
|
||||
)
|
||||
}
|
||||
|
||||
export { ADMIN_COOKIE_NAME }
|
||||
163
pitch-deck/lib/auth.ts
Normal file
163
pitch-deck/lib/auth.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { SignJWT, jwtVerify } from 'jose'
|
||||
import { randomBytes, createHash } from 'crypto'
|
||||
import { cookies } from 'next/headers'
|
||||
import pool from './db'
|
||||
|
||||
const COOKIE_NAME = 'pitch_session'
|
||||
const JWT_EXPIRY = '1h'
|
||||
const SESSION_EXPIRY_HOURS = 24
|
||||
|
||||
function getJwtSecret() {
|
||||
const secret = process.env.PITCH_JWT_SECRET
|
||||
if (!secret) throw new Error('PITCH_JWT_SECRET not set')
|
||||
return new TextEncoder().encode(secret)
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
export function generateToken(): string {
|
||||
return randomBytes(48).toString('hex')
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string
|
||||
email: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export async function createJwt(payload: JwtPayload): Promise<string> {
|
||||
return new SignJWT({ ...payload })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(JWT_EXPIRY)
|
||||
.sign(getJwtSecret())
|
||||
}
|
||||
|
||||
export async function verifyJwt(token: string): Promise<JwtPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwtSecret())
|
||||
return payload as unknown as JwtPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
investorId: string,
|
||||
ip: string | null,
|
||||
userAgent: string | null
|
||||
): Promise<{ sessionId: string; jwt: string }> {
|
||||
// Revoke all existing sessions for this investor (single session enforcement)
|
||||
await pool.query(
|
||||
`UPDATE pitch_sessions SET revoked = true WHERE investor_id = $1 AND revoked = false`,
|
||||
[investorId]
|
||||
)
|
||||
|
||||
const sessionToken = generateToken()
|
||||
const tokenHash = hashToken(sessionToken)
|
||||
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_HOURS * 60 * 60 * 1000)
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO pitch_sessions (investor_id, token_hash, ip_address, user_agent, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[investorId, tokenHash, ip, userAgent, expiresAt]
|
||||
)
|
||||
|
||||
const sessionId = rows[0].id
|
||||
|
||||
// Get investor email for JWT
|
||||
const investor = await pool.query(
|
||||
`SELECT email FROM pitch_investors WHERE id = $1`,
|
||||
[investorId]
|
||||
)
|
||||
|
||||
const jwt = await createJwt({
|
||||
sub: investorId,
|
||||
email: investor.rows[0].email,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
return { sessionId, jwt }
|
||||
}
|
||||
|
||||
export async function validateSession(sessionId: string, investorId: string): Promise<boolean> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id FROM pitch_sessions
|
||||
WHERE id = $1 AND investor_id = $2 AND revoked = false AND expires_at > NOW()`,
|
||||
[sessionId, investorId]
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
export async function revokeSession(sessionId: string): Promise<void> {
|
||||
await pool.query(
|
||||
`UPDATE pitch_sessions SET revoked = true WHERE id = $1`,
|
||||
[sessionId]
|
||||
)
|
||||
}
|
||||
|
||||
export async function revokeAllSessions(investorId: string): Promise<void> {
|
||||
await pool.query(
|
||||
`UPDATE pitch_sessions SET revoked = true WHERE investor_id = $1`,
|
||||
[investorId]
|
||||
)
|
||||
}
|
||||
|
||||
export async function setSessionCookie(jwt: string): Promise<void> {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set(COOKIE_NAME, jwt, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: SESSION_EXPIRY_HOURS * 60 * 60,
|
||||
})
|
||||
}
|
||||
|
||||
export async function clearSessionCookie(): Promise<void> {
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.delete(COOKIE_NAME)
|
||||
}
|
||||
|
||||
export async function getSessionFromCookie(): Promise<JwtPayload | null> {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get(COOKIE_NAME)?.value
|
||||
if (!token) return null
|
||||
return verifyJwt(token)
|
||||
}
|
||||
|
||||
export function getClientIp(request: Request): string | null {
|
||||
const forwarded = request.headers.get('x-forwarded-for')
|
||||
if (forwarded) return forwarded.split(',')[0].trim()
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateAdminSecret(request: Request): boolean {
|
||||
const secret = process.env.PITCH_ADMIN_SECRET
|
||||
if (!secret) return false
|
||||
const auth = request.headers.get('authorization')
|
||||
if (!auth) return false
|
||||
return auth === `Bearer ${secret}`
|
||||
}
|
||||
|
||||
export async function logAudit(
|
||||
investorId: string | null,
|
||||
action: string,
|
||||
details: Record<string, unknown> = {},
|
||||
request?: Request,
|
||||
slideId?: string,
|
||||
sessionId?: string,
|
||||
adminId?: string | null,
|
||||
targetInvestorId?: string | null,
|
||||
): Promise<void> {
|
||||
const ip = request ? getClientIp(request) : null
|
||||
const ua = request ? request.headers.get('user-agent') : null
|
||||
await pool.query(
|
||||
`INSERT INTO pitch_audit_logs
|
||||
(investor_id, action, details, ip_address, user_agent, slide_id, session_id, admin_id, target_investor_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[investorId, action, JSON.stringify(details), ip, ua, slideId, sessionId, adminId ?? null, targetInvestorId ?? null]
|
||||
)
|
||||
}
|
||||
91
pitch-deck/lib/email.ts
Normal file
91
pitch-deck/lib/email.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: process.env.SMTP_PORT === '465',
|
||||
auth: {
|
||||
user: process.env.SMTP_USERNAME,
|
||||
pass: process.env.SMTP_PASSWORD,
|
||||
},
|
||||
})
|
||||
|
||||
const fromName = process.env.SMTP_FROM_NAME || 'BreakPilot'
|
||||
const fromAddr = process.env.SMTP_FROM_ADDR || 'noreply@breakpilot.ai'
|
||||
|
||||
export async function sendMagicLinkEmail(
|
||||
to: string,
|
||||
investorName: string | null,
|
||||
magicLinkUrl: string
|
||||
): Promise<void> {
|
||||
const greeting = investorName ? `Hello ${investorName}` : 'Hello'
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${fromName}" <${fromAddr}>`,
|
||||
to,
|
||||
subject: 'Your BreakPilot Pitch Deck Access',
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#0a0a1a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0a0a1a;padding:40px 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="background:#111127;border-radius:12px;border:1px solid rgba(99,102,241,0.2);">
|
||||
<tr>
|
||||
<td style="padding:40px 40px 20px;">
|
||||
<h1 style="margin:0;font-size:24px;color:#e0e0ff;font-weight:600;">
|
||||
BreakPilot ComplAI
|
||||
</h1>
|
||||
<p style="margin:8px 0 0;font-size:13px;color:rgba(255,255,255,0.4);">
|
||||
Investor Pitch Deck
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px 40px;">
|
||||
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.8);line-height:1.6;">
|
||||
${greeting},
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.8);line-height:1.6;">
|
||||
You have been invited to view the BreakPilot ComplAI investor pitch deck.
|
||||
Click the button below to access the interactive presentation.
|
||||
</p>
|
||||
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
|
||||
<tr>
|
||||
<td style="background:linear-gradient(135deg,#6366f1,#8b5cf6);border-radius:8px;padding:14px 32px;">
|
||||
<a href="${magicLinkUrl}" style="color:#ffffff;font-size:16px;font-weight:600;text-decoration:none;display:inline-block;">
|
||||
View Pitch Deck
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 8px;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.5;">
|
||||
This link expires in ${process.env.MAGIC_LINK_TTL_HOURS || '72'} hours and can only be used once.
|
||||
</p>
|
||||
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.3);line-height:1.5;word-break:break-all;">
|
||||
${magicLinkUrl}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px 40px 40px;border-top:1px solid rgba(255,255,255,0.05);">
|
||||
<p style="margin:0;font-size:12px;color:rgba(255,255,255,0.25);line-height:1.5;">
|
||||
If you did not expect this email, you can safely ignore it.
|
||||
This is an AI-first company — we don't do PDFs.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
})
|
||||
}
|
||||
73
pitch-deck/lib/hooks/useAuditTracker.ts
Normal file
73
pitch-deck/lib/hooks/useAuditTracker.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
'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 }
|
||||
}
|
||||
43
pitch-deck/lib/hooks/useAuth.ts
Normal file
43
pitch-deck/lib/hooks/useAuth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export interface Investor {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
company: string | null
|
||||
status: string
|
||||
last_login_at: string | null
|
||||
login_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [investor, setInvestor] = useState<Investor | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchMe() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/me')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setInvestor(data.investor)
|
||||
}
|
||||
} catch {
|
||||
// Not authenticated
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchMe()
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
window.location.href = '/auth'
|
||||
}, [])
|
||||
|
||||
return { investor, loading, logout }
|
||||
}
|
||||
@@ -1,24 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { FMScenario, FMResult, FMComputeResponse } from '../types'
|
||||
import { FMScenario, FMResult, FMComputeResponse, InvestorSnapshot } from '../types'
|
||||
|
||||
export function useFinancialModel() {
|
||||
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
|
||||
// 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) {
|
||||
const data: FMScenario[] = await res.json()
|
||||
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) {
|
||||
@@ -32,7 +63,7 @@ export function useFinancialModel() {
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
}, [investorId])
|
||||
|
||||
// Compute when active scenario changes
|
||||
useEffect(() => {
|
||||
@@ -41,17 +72,17 @@ export function useFinancialModel() {
|
||||
}
|
||||
}, [activeScenarioId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const compute = useCallback(async (scenarioId: string, source?: string) => {
|
||||
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, source }),
|
||||
body: JSON.stringify({ scenarioId }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data: FMComputeResponse = await res.json()
|
||||
setResults(prev => new Map(prev).set(source === 'finanzplan' ? 'finanzplan' : scenarioId, data))
|
||||
setResults(prev => new Map(prev).set(scenarioId, data))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Compute failed:', err)
|
||||
@@ -60,9 +91,27 @@ export function useFinancialModel() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const computeFromFinanzplan = useCallback(async () => {
|
||||
await compute('', 'finanzplan')
|
||||
}, [compute])
|
||||
// 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
|
||||
@@ -84,7 +133,33 @@ export function useFinancialModel() {
|
||||
// Debounced recompute
|
||||
if (computeTimer.current) clearTimeout(computeTimer.current)
|
||||
computeTimer.current = setTimeout(() => compute(scenarioId), 300)
|
||||
}, [compute])
|
||||
|
||||
// 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) {
|
||||
@@ -94,7 +169,6 @@ export function useFinancialModel() {
|
||||
|
||||
const activeScenario = scenarios.find(s => s.id === activeScenarioId) || null
|
||||
const activeResults = activeScenarioId ? results.get(activeScenarioId) || null : null
|
||||
const finanzplanResults = results.get('finanzplan') || null
|
||||
|
||||
return {
|
||||
scenarios,
|
||||
@@ -102,7 +176,6 @@ export function useFinancialModel() {
|
||||
activeScenarioId,
|
||||
setActiveScenarioId,
|
||||
activeResults,
|
||||
finanzplanResults,
|
||||
results,
|
||||
loading,
|
||||
computing,
|
||||
@@ -110,7 +183,8 @@ export function useFinancialModel() {
|
||||
setCompareMode,
|
||||
compute,
|
||||
computeAll,
|
||||
computeFromFinanzplan,
|
||||
updateAssumption,
|
||||
resetToDefaults,
|
||||
snapshotStatus,
|
||||
}
|
||||
}
|
||||
|
||||
52
pitch-deck/lib/rate-limit.ts
Normal file
52
pitch-deck/lib/rate-limit.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
interface RateLimitEntry {
|
||||
count: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
const store = new Map<string, RateLimitEntry>()
|
||||
|
||||
// Cleanup stale entries every 60 seconds
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of store) {
|
||||
if (entry.resetAt <= now) store.delete(key)
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
export interface RateLimitConfig {
|
||||
/** Max requests in the window */
|
||||
limit: number
|
||||
/** Window size in seconds */
|
||||
windowSec: number
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean
|
||||
remaining: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
export function checkRateLimit(key: string, config: RateLimitConfig): RateLimitResult {
|
||||
const now = Date.now()
|
||||
const entry = store.get(key)
|
||||
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
store.set(key, { count: 1, resetAt: now + config.windowSec * 1000 })
|
||||
return { allowed: true, remaining: config.limit - 1, resetAt: now + config.windowSec * 1000 }
|
||||
}
|
||||
|
||||
if (entry.count >= config.limit) {
|
||||
return { allowed: false, remaining: 0, resetAt: entry.resetAt }
|
||||
}
|
||||
|
||||
entry.count++
|
||||
return { allowed: true, remaining: config.limit - entry.count, resetAt: entry.resetAt }
|
||||
}
|
||||
|
||||
// Preset configurations
|
||||
export const RATE_LIMITS = {
|
||||
magicLink: { limit: 3, windowSec: 3600 } as RateLimitConfig, // 3 per email per hour
|
||||
authVerify: { limit: 10, windowSec: 900 } as RateLimitConfig, // 10 per IP per 15min
|
||||
api: { limit: 60, windowSec: 60 } as RateLimitConfig, // 60 per session per minute
|
||||
chat: { limit: 20, windowSec: 60 } as RateLimitConfig, // 20 per session per minute
|
||||
} as const
|
||||
@@ -193,6 +193,27 @@ export interface FMComputeResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// Investor Auth Types
|
||||
export interface Investor {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
company: string | null
|
||||
status: 'invited' | 'active' | 'revoked'
|
||||
last_login_at: string | null
|
||||
login_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface InvestorSnapshot {
|
||||
id: string
|
||||
scenario_id: string
|
||||
assumptions: Record<string, number | number[]>
|
||||
label: string | null
|
||||
is_latest: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type Language = 'de' | 'en'
|
||||
|
||||
export interface ChatMessage {
|
||||
|
||||
Reference in New Issue
Block a user