diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 57536d2..197b366 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -173,6 +173,39 @@ services: networks: - breakpilot-network + # ========================================================= + # PITCH DECK + # ========================================================= + pitch-deck: + build: + context: ./pitch-deck + dockerfile: Dockerfile + container_name: bp-core-pitch-deck + expose: + - "3000" + environment: + DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT:-5432}/${POSTGRES_DB} + PITCH_JWT_SECRET: ${PITCH_JWT_SECRET} + PITCH_ADMIN_SECRET: ${PITCH_ADMIN_SECRET} + PITCH_BASE_URL: ${PITCH_BASE_URL:-https://pitch.breakpilot.ai} + MAGIC_LINK_TTL_HOURS: ${MAGIC_LINK_TTL_HOURS:-72} + SMTP_HOST: ${SMTP_HOST} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USERNAME: ${SMTP_USERNAME} + SMTP_PASSWORD: ${SMTP_PASSWORD} + SMTP_FROM_NAME: ${SMTP_FROM_NAME:-BreakPilot} + SMTP_FROM_ADDR: ${SMTP_FROM_ADDR:-noreply@breakpilot.ai} + NODE_ENV: production + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/health"] + interval: 30s + timeout: 10s + start_period: 15s + retries: 3 + restart: unless-stopped + networks: + - breakpilot-network + # ========================================================= # HEALTH AGGREGATOR # ========================================================= @@ -185,7 +218,7 @@ services: - "8099" environment: PORT: 8099 - CHECK_SERVICES: "valkey:6379,consent-service:8081,rag-service:8097,embedding-service:8087,paddleocr-service:8095" + CHECK_SERVICES: "valkey:6379,consent-service:8081,rag-service:8097,embedding-service:8087,paddleocr-service:8095,pitch-deck:3000" healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:8099/health"] interval: 30s diff --git a/pitch-deck/app/api/admin/audit-logs/route.ts b/pitch-deck/app/api/admin/audit-logs/route.ts new file mode 100644 index 0000000..67204ef --- /dev/null +++ b/pitch-deck/app/api/admin/audit-logs/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { validateAdminSecret } from '@/lib/auth' + +export async function GET(request: NextRequest) { + if (!validateAdminSecret(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const investorId = searchParams.get('investor_id') + const action = searchParams.get('action') + const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500) + const offset = parseInt(searchParams.get('offset') || '0') + + const conditions: string[] = [] + const params: unknown[] = [] + let paramIdx = 1 + + if (investorId) { + conditions.push(`a.investor_id = $${paramIdx++}`) + params.push(investorId) + } + if (action) { + conditions.push(`a.action = $${paramIdx++}`) + params.push(action) + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + + const { rows } = await pool.query( + `SELECT a.*, i.email as investor_email, i.name as investor_name + FROM pitch_audit_logs a + LEFT JOIN pitch_investors i ON i.id = a.investor_id + ${where} + ORDER BY a.created_at DESC + LIMIT $${paramIdx++} OFFSET $${paramIdx++}`, + [...params, limit, offset] + ) + + return NextResponse.json({ logs: rows }) +} diff --git a/pitch-deck/app/api/admin/investors/route.ts b/pitch-deck/app/api/admin/investors/route.ts new file mode 100644 index 0000000..ad9fe69 --- /dev/null +++ b/pitch-deck/app/api/admin/investors/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { validateAdminSecret } from '@/lib/auth' + +export async function GET(request: NextRequest) { + if (!validateAdminSecret(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { rows } = await pool.query( + `SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count, i.created_at, + (SELECT COUNT(*) FROM pitch_audit_logs a WHERE a.investor_id = i.id AND a.action = 'slide_viewed') as slides_viewed, + (SELECT MAX(a.created_at) FROM pitch_audit_logs a WHERE a.investor_id = i.id) as last_activity + FROM pitch_investors i + ORDER BY i.created_at DESC` + ) + + return NextResponse.json({ investors: rows }) +} diff --git a/pitch-deck/app/api/admin/invite/route.ts b/pitch-deck/app/api/admin/invite/route.ts new file mode 100644 index 0000000..4607efb --- /dev/null +++ b/pitch-deck/app/api/admin/invite/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { validateAdminSecret, generateToken, logAudit, getClientIp } from '@/lib/auth' +import { sendMagicLinkEmail } from '@/lib/email' +import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit' + +export async function POST(request: NextRequest) { + if (!validateAdminSecret(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { email, name, company } = body + + if (!email || typeof email !== 'string') { + return NextResponse.json({ error: 'Email required' }, { status: 400 }) + } + + // Rate limit by email + const rl = checkRateLimit(`magic-link:${email.toLowerCase()}`, RATE_LIMITS.magicLink) + if (!rl.allowed) { + return NextResponse.json({ error: 'Too many invites for this email. Try again later.' }, { status: 429 }) + } + + const normalizedEmail = email.toLowerCase().trim() + + // Upsert investor + const { rows } = await pool.query( + `INSERT INTO pitch_investors (email, name, company) + VALUES ($1, $2, $3) + ON CONFLICT (email) DO UPDATE SET + name = COALESCE(EXCLUDED.name, pitch_investors.name), + company = COALESCE(EXCLUDED.company, pitch_investors.company), + status = CASE WHEN pitch_investors.status = 'revoked' THEN 'invited' ELSE pitch_investors.status END, + updated_at = NOW() + RETURNING id, status`, + [normalizedEmail, name || null, company || null] + ) + + const investor = rows[0] + + // Generate magic link + const token = generateToken() + const ttlHours = parseInt(process.env.MAGIC_LINK_TTL_HOURS || '72') + const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000) + + await pool.query( + `INSERT INTO pitch_magic_links (investor_id, token, expires_at) + VALUES ($1, $2, $3)`, + [investor.id, token, expiresAt] + ) + + // Build magic link URL + const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai' + const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}` + + // Send email + await sendMagicLinkEmail(normalizedEmail, name || null, magicLinkUrl) + + await logAudit(investor.id, 'magic_link_sent', { email: normalizedEmail }, request) + + return NextResponse.json({ + success: true, + investor_id: investor.id, + email: normalizedEmail, + expires_at: expiresAt.toISOString(), + }) +} diff --git a/pitch-deck/app/api/admin/revoke/route.ts b/pitch-deck/app/api/admin/revoke/route.ts new file mode 100644 index 0000000..366ee0c --- /dev/null +++ b/pitch-deck/app/api/admin/revoke/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { validateAdminSecret, revokeAllSessions, logAudit } from '@/lib/auth' + +export async function POST(request: NextRequest) { + if (!validateAdminSecret(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { investor_id } = body + + if (!investor_id) { + return NextResponse.json({ error: 'investor_id required' }, { status: 400 }) + } + + await pool.query( + `UPDATE pitch_investors SET status = 'revoked', updated_at = NOW() WHERE id = $1`, + [investor_id] + ) + + await revokeAllSessions(investor_id) + await logAudit(investor_id, 'investor_revoked', {}, request) + + return NextResponse.json({ success: true }) +} diff --git a/pitch-deck/app/api/audit/route.ts b/pitch-deck/app/api/audit/route.ts new file mode 100644 index 0000000..e8c0a75 --- /dev/null +++ b/pitch-deck/app/api/audit/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSessionFromCookie, logAudit } from '@/lib/auth' + +export async function POST(request: NextRequest) { + const session = await getSessionFromCookie() + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const body = await request.json() + const { action, details, slide_id } = body + + if (!action || typeof action !== 'string') { + return NextResponse.json({ error: 'action required' }, { status: 400 }) + } + + // Only allow known client-side actions + const allowedActions = ['slide_viewed', 'assumption_changed', 'chat_message_sent', 'snapshot_saved', 'snapshot_restored'] + if (!allowedActions.includes(action)) { + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) + } + + await logAudit(session.sub, action, details || {}, request, slide_id, session.sessionId) + + return NextResponse.json({ success: true }) +} diff --git a/pitch-deck/app/api/auth/logout/route.ts b/pitch-deck/app/api/auth/logout/route.ts new file mode 100644 index 0000000..f95713b --- /dev/null +++ b/pitch-deck/app/api/auth/logout/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSessionFromCookie, revokeSession, clearSessionCookie, logAudit } from '@/lib/auth' + +export async function POST(request: NextRequest) { + const session = await getSessionFromCookie() + + if (session) { + await revokeSession(session.sessionId) + await logAudit(session.sub, 'logout', {}, request, undefined, session.sessionId) + } + + await clearSessionCookie() + return NextResponse.json({ success: true }) +} diff --git a/pitch-deck/app/api/auth/me/route.ts b/pitch-deck/app/api/auth/me/route.ts new file mode 100644 index 0000000..5d053d8 --- /dev/null +++ b/pitch-deck/app/api/auth/me/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server' +import pool from '@/lib/db' +import { getSessionFromCookie, validateSession } from '@/lib/auth' + +export async function GET() { + const session = await getSessionFromCookie() + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const valid = await validateSession(session.sessionId, session.sub) + if (!valid) { + return NextResponse.json({ error: 'Session expired' }, { status: 401 }) + } + + const { rows } = await pool.query( + `SELECT id, email, name, company, status, last_login_at, login_count, created_at + FROM pitch_investors WHERE id = $1`, + [session.sub] + ) + + if (rows.length === 0 || rows[0].status === 'revoked') { + return NextResponse.json({ error: 'Access revoked' }, { status: 403 }) + } + + return NextResponse.json({ investor: rows[0] }) +} diff --git a/pitch-deck/app/api/auth/verify/route.ts b/pitch-deck/app/api/auth/verify/route.ts new file mode 100644 index 0000000..2b29e6c --- /dev/null +++ b/pitch-deck/app/api/auth/verify/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { createSession, setSessionCookie, getClientIp, logAudit, hashToken } from '@/lib/auth' +import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit' + +export async function POST(request: NextRequest) { + const ip = getClientIp(request) || 'unknown' + + // Rate limit by IP + const rl = checkRateLimit(`auth-verify:${ip}`, RATE_LIMITS.authVerify) + if (!rl.allowed) { + return NextResponse.json({ error: 'Too many attempts. Try again later.' }, { status: 429 }) + } + + const body = await request.json() + const { token } = body + + if (!token || typeof token !== 'string') { + return NextResponse.json({ error: 'Token required' }, { status: 400 }) + } + + // Find the magic link + const { rows } = await pool.query( + `SELECT ml.id, ml.investor_id, ml.expires_at, ml.used_at, i.email, i.status as investor_status + FROM pitch_magic_links ml + JOIN pitch_investors i ON i.id = ml.investor_id + WHERE ml.token = $1`, + [token] + ) + + if (rows.length === 0) { + await logAudit(null, 'login_failed', { reason: 'invalid_token' }, request) + return NextResponse.json({ error: 'Invalid link' }, { status: 401 }) + } + + const link = rows[0] + + if (link.used_at) { + await logAudit(link.investor_id, 'login_failed', { reason: 'token_already_used' }, request) + return NextResponse.json({ error: 'This link has already been used. Please request a new one.' }, { status: 401 }) + } + + if (new Date(link.expires_at) < new Date()) { + await logAudit(link.investor_id, 'login_failed', { reason: 'token_expired' }, request) + return NextResponse.json({ error: 'This link has expired. Please request a new one.' }, { status: 401 }) + } + + if (link.investor_status === 'revoked') { + await logAudit(link.investor_id, 'login_failed', { reason: 'investor_revoked' }, request) + return NextResponse.json({ error: 'Access has been revoked.' }, { status: 403 }) + } + + const ua = request.headers.get('user-agent') + + // Mark token as used + await pool.query( + `UPDATE pitch_magic_links SET used_at = NOW(), ip_address = $1, user_agent = $2 WHERE id = $3`, + [ip, ua, link.id] + ) + + // Activate investor if first login + await pool.query( + `UPDATE pitch_investors SET status = 'active', last_login_at = NOW(), login_count = login_count + 1, updated_at = NOW() + WHERE id = $1`, + [link.investor_id] + ) + + // Create session and set cookie + const { sessionId, jwt } = await createSession(link.investor_id, ip, ua) + await setSessionCookie(jwt) + + // Check for new IP + const { rows: prevSessions } = await pool.query( + `SELECT DISTINCT ip_address FROM pitch_sessions WHERE investor_id = $1 AND id != $2 AND ip_address IS NOT NULL`, + [link.investor_id, sessionId] + ) + const knownIps = prevSessions.map((r: { ip_address: string }) => r.ip_address) + if (knownIps.length > 0 && !knownIps.includes(ip)) { + await logAudit(link.investor_id, 'new_ip_detected', { ip, known_ips: knownIps }, request, undefined, sessionId) + } + + await logAudit(link.investor_id, 'login_success', { email: link.email }, request, undefined, sessionId) + + return NextResponse.json({ success: true, redirect: '/' }) +} diff --git a/pitch-deck/app/api/health/route.ts b/pitch-deck/app/api/health/route.ts new file mode 100644 index 0000000..dacd456 --- /dev/null +++ b/pitch-deck/app/api/health/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() }) +} diff --git a/pitch-deck/app/api/snapshots/route.ts b/pitch-deck/app/api/snapshots/route.ts new file mode 100644 index 0000000..d5c2798 --- /dev/null +++ b/pitch-deck/app/api/snapshots/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' +import { getSessionFromCookie } from '@/lib/auth' + +export async function GET() { + const session = await getSessionFromCookie() + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const { rows } = await pool.query( + `SELECT id, scenario_id, assumptions, label, is_latest, created_at + FROM pitch_investor_snapshots + WHERE investor_id = $1 AND is_latest = true + ORDER BY created_at DESC`, + [session.sub] + ) + + return NextResponse.json({ snapshots: rows }) +} + +export async function POST(request: NextRequest) { + const session = await getSessionFromCookie() + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const body = await request.json() + const { scenario_id, assumptions, label } = body + + if (!scenario_id || !assumptions) { + return NextResponse.json({ error: 'scenario_id and assumptions required' }, { status: 400 }) + } + + // Mark previous latest as not latest + await pool.query( + `UPDATE pitch_investor_snapshots SET is_latest = false + WHERE investor_id = $1 AND scenario_id = $2 AND is_latest = true`, + [session.sub, scenario_id] + ) + + // Insert new snapshot + const { rows } = await pool.query( + `INSERT INTO pitch_investor_snapshots (investor_id, scenario_id, assumptions, label, is_latest) + VALUES ($1, $2, $3, $4, true) + RETURNING id, created_at`, + [session.sub, scenario_id, JSON.stringify(assumptions), label || null] + ) + + return NextResponse.json({ snapshot: rows[0] }) +} + +export async function DELETE(request: NextRequest) { + const session = await getSessionFromCookie() + if (!session) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const id = searchParams.get('id') + + if (!id) { + return NextResponse.json({ error: 'Snapshot id required' }, { status: 400 }) + } + + await pool.query( + `DELETE FROM pitch_investor_snapshots WHERE id = $1 AND investor_id = $2`, + [id, session.sub] + ) + + return NextResponse.json({ success: true }) +} diff --git a/pitch-deck/app/auth/page.tsx b/pitch-deck/app/auth/page.tsx new file mode 100644 index 0000000..a9d4053 --- /dev/null +++ b/pitch-deck/app/auth/page.tsx @@ -0,0 +1,57 @@ +'use client' + +import { motion } from 'framer-motion' + +export default function AuthPage() { + return ( +
+ {/* Background gradient */} +
+ + +
+

+ BreakPilot ComplAI +

+

Investor Pitch Deck

+
+ +
+
+ + + +
+ +

+ Invitation Required +

+ +

+ This interactive pitch deck is available by invitation only. + Please check your email for an access link. +

+ +
+

+ Questions? Contact us at{' '} + + pitch@breakpilot.ai + +

+
+
+ +

+ We are an AI-first company. No PDFs. No slide decks. Just code. +

+
+
+ ) +} diff --git a/pitch-deck/app/auth/verify/page.tsx b/pitch-deck/app/auth/verify/page.tsx new file mode 100644 index 0000000..cd2b5d3 --- /dev/null +++ b/pitch-deck/app/auth/verify/page.tsx @@ -0,0 +1,112 @@ +'use client' + +import { Suspense, useEffect, useState } from 'react' +import { useSearchParams, useRouter } from 'next/navigation' +import { motion } from 'framer-motion' + +function VerifyContent() { + const searchParams = useSearchParams() + const router = useRouter() + const token = searchParams.get('token') + + const [status, setStatus] = useState<'verifying' | 'success' | 'error'>('verifying') + const [errorMsg, setErrorMsg] = useState('') + + useEffect(() => { + if (!token) { + setStatus('error') + setErrorMsg('No access token provided.') + return + } + + async function verify() { + try { + const res = await fetch('/api/auth/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) + + if (res.ok) { + setStatus('success') + setTimeout(() => router.push('/'), 1000) + } else { + const data = await res.json() + setStatus('error') + setErrorMsg(data.error || 'Verification failed.') + } + } catch { + setStatus('error') + setErrorMsg('Network error. Please try again.') + } + } + + verify() + }, [token, router]) + + return ( + +
+ {status === 'verifying' && ( + <> +
+

Verifying your access link...

+ + )} + + {status === 'success' && ( + <> +
+ + + +
+

Access verified!

+

Redirecting to pitch deck...

+ + )} + + {status === 'error' && ( + <> +
+ + + +
+

Access Denied

+

{errorMsg}

+ + Back to login + + + )} +
+ + ) +} + +export default function VerifyPage() { + return ( +
+
+ +
+

Loading...

+
+ } + > + +
+
+ ) +} diff --git a/pitch-deck/app/layout.tsx b/pitch-deck/app/layout.tsx index 209399e..0290b77 100644 --- a/pitch-deck/app/layout.tsx +++ b/pitch-deck/app/layout.tsx @@ -1,9 +1,23 @@ -import type { Metadata } from 'next' +import type { Metadata, Viewport } from 'next' import './globals.css' export const metadata: Metadata = { title: 'BreakPilot ComplAI — Investor Pitch Deck', description: 'Datensouveraenitaet meets KI-Compliance. Pre-Seed Q4 2026.', + manifest: '/manifest.json', + robots: { index: false, follow: false }, + appleWebApp: { + capable: true, + statusBarStyle: 'black-translucent', + title: 'BreakPilot Pitch', + }, +} + +export const viewport: Viewport = { + themeColor: '#6366f1', + width: 'device-width', + initialScale: 1, + maximumScale: 1, } export default function RootLayout({ @@ -13,8 +27,22 @@ export default function RootLayout({ }) { return ( + + + {children} +