feat(pitch-admin): generate magic link + 72h investor data masking
Build pitch-deck / build-push-deploy (push) Successful in 1m30s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 29s
CI / test-python-voice (push) Successful in 29s
CI / test-bqas (push) Successful in 30s

- New POST /api/admin/investors/[id]/generate-link endpoint: creates a
  magic link without sending email, returns the URL for the admin to
  copy and share manually (for when email is filtered)
- Adds 'Copy Link' button (emerald) to investor list and detail pages;
  link is copied to clipboard on click
- New lib/masking.ts: maskOverdueInvestors() UPDATE that anonymizes
  email/name/company → revokes sessions 72h after first investor login
- first_activity_at recorded on first verify (COALESCE, set once only)
- migration 004 adds first_activity_at + data_masked_at columns with
  partial index; also wired into /api/admin/migrate for one-shot apply
- Admin UI shows 'anonymized' badge, expiry countdown, and masked state;
  Copy Link + Resend are disabled for anonymized investors
- verify route returns 410 if data_masked_at is set (belt-and-suspenders
  alongside the revoked status check)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-30 14:55:29 +02:00
parent adfff6cfe4
commit 23b233bda3
9 changed files with 231 additions and 28 deletions
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { generateToken } from '@/lib/auth'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
interface RouteContext {
params: Promise<{ id: string }>
}
export async function POST(request: NextRequest, ctx: RouteContext) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
const adminId = guard.kind === 'admin' ? guard.admin.id : null
const { id } = await ctx.params
const { rows } = await pool.query(
`SELECT id, email, name, status, data_masked_at FROM pitch_investors WHERE id = $1`,
[id],
)
if (rows.length === 0) {
return NextResponse.json({ error: 'Investor not found' }, { status: 404 })
}
const investor = rows[0]
if (investor.data_masked_at) {
return NextResponse.json(
{ error: 'Investor data has been anonymized after the 72h window. Cannot generate a new link.' },
{ status: 410 },
)
}
if (investor.status === 'revoked') {
return NextResponse.json(
{ error: 'Investor is revoked. Re-invite to reactivate.' },
{ status: 400 },
)
}
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],
)
const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai'
const url = `${baseUrl}/auth/verify?token=${token}`
await logAdminAudit(
adminId,
'magic_link_generated',
{ email: investor.email, expires_at: expiresAt.toISOString(), channel: 'manual_copy' },
request,
investor.id,
)
return NextResponse.json({ url, expires_at: expiresAt.toISOString() })
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db' import pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth' import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
import { maskOverdueInvestors } from '@/lib/masking'
interface RouteContext { interface RouteContext {
params: Promise<{ id: string }> params: Promise<{ id: string }>
@@ -12,10 +13,13 @@ export async function GET(request: NextRequest, ctx: RouteContext) {
const { id } = await ctx.params const { id } = await ctx.params
await maskOverdueInvestors()
const [investor, sessions, snapshots, audit] = await Promise.all([ const [investor, sessions, snapshots, audit] = await Promise.all([
pool.query( pool.query(
`SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count, `SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count,
i.created_at, i.updated_at, i.assigned_version_id, i.created_at, i.updated_at, i.first_activity_at, i.data_masked_at,
i.assigned_version_id,
v.name AS version_name, v.status AS version_status v.name AS version_name, v.status AS version_status
FROM pitch_investors i FROM pitch_investors i
LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id
@@ -1,13 +1,17 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db' import pool from '@/lib/db'
import { requireAdmin } from '@/lib/admin-auth' import { requireAdmin } from '@/lib/admin-auth'
import { maskOverdueInvestors } from '@/lib/masking'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const guard = await requireAdmin(request) const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response if (guard.kind === 'response') return guard.response
await maskOverdueInvestors()
const { rows } = await pool.query( 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 i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count, i.created_at,
i.first_activity_at, i.data_masked_at,
i.assigned_version_id, v.name AS version_name, i.assigned_version_id, v.name AS version_name,
(SELECT COUNT(*) FROM pitch_audit_logs a WHERE a.investor_id = i.id AND a.action = 'slide_viewed') as slides_viewed, (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 (SELECT MAX(a.created_at) FROM pitch_audit_logs a WHERE a.investor_id = i.id) as last_activity
@@ -10,6 +10,12 @@ export async function POST(request: NextRequest) {
// Finanzplan tables — the ones missing on production // Finanzplan tables — the ones missing on production
const statements = [ const statements = [
// 004 — investor data masking columns
`ALTER TABLE pitch_investors ADD COLUMN IF NOT EXISTS first_activity_at TIMESTAMPTZ`,
`ALTER TABLE pitch_investors ADD COLUMN IF NOT EXISTS data_masked_at TIMESTAMPTZ`,
`CREATE INDEX IF NOT EXISTS idx_pitch_investors_mask_check
ON pitch_investors (first_activity_at)
WHERE first_activity_at IS NOT NULL AND data_masked_at IS NULL`,
`CREATE TABLE IF NOT EXISTS fp_scenarios ( `CREATE TABLE IF NOT EXISTS fp_scenarios (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL DEFAULT 'Base Case', name TEXT NOT NULL DEFAULT 'Base Case',
+14 -3
View File
@@ -21,7 +21,9 @@ export async function POST(request: NextRequest) {
// Find the magic link // Find the magic link
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT ml.id, ml.investor_id, ml.expires_at, ml.used_at, i.email, i.status as investor_status `SELECT ml.id, ml.investor_id, ml.expires_at, ml.used_at,
i.email, i.status as investor_status,
i.first_activity_at, i.data_masked_at
FROM pitch_magic_links ml FROM pitch_magic_links ml
JOIN pitch_investors i ON i.id = ml.investor_id JOIN pitch_investors i ON i.id = ml.investor_id
WHERE ml.token = $1`, WHERE ml.token = $1`,
@@ -45,6 +47,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'This link has expired. Please request a new one.' }, { status: 401 }) return NextResponse.json({ error: 'This link has expired. Please request a new one.' }, { status: 401 })
} }
if (link.data_masked_at) {
return NextResponse.json({ error: 'This access period has ended and data has been anonymized.' }, { status: 410 })
}
if (link.investor_status === 'revoked') { if (link.investor_status === 'revoked') {
await logAudit(link.investor_id, 'login_failed', { reason: 'investor_revoked' }, request) await logAudit(link.investor_id, 'login_failed', { reason: 'investor_revoked' }, request)
return NextResponse.json({ error: 'Access has been revoked.' }, { status: 403 }) return NextResponse.json({ error: 'Access has been revoked.' }, { status: 403 })
@@ -58,9 +64,14 @@ export async function POST(request: NextRequest) {
[ip, ua, link.id] [ip, ua, link.id]
) )
// Activate investor if first login // Activate investor if first login; record first_activity_at once
await pool.query( await pool.query(
`UPDATE pitch_investors SET status = 'active', last_login_at = NOW(), login_count = login_count + 1, updated_at = NOW() `UPDATE pitch_investors
SET status = 'active',
last_login_at = NOW(),
login_count = login_count + 1,
first_activity_at = COALESCE(first_activity_at, NOW()),
updated_at = NOW()
WHERE id = $1`, WHERE id = $1`,
[link.investor_id] [link.investor_id]
) )
@@ -3,7 +3,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { ArrowLeft, Mail, Ban, Save } from 'lucide-react' import { ArrowLeft, Mail, Ban, Save, Link2, RefreshCw } from 'lucide-react'
import AuditLogTable from '@/components/pitch-admin/AuditLogTable' import AuditLogTable from '@/components/pitch-admin/AuditLogTable'
interface InvestorDetail { interface InvestorDetail {
@@ -16,6 +16,8 @@ interface InvestorDetail {
last_login_at: string | null last_login_at: string | null
login_count: number login_count: number
created_at: string created_at: string
first_activity_at: string | null
data_masked_at: string | null
assigned_version_id: string | null assigned_version_id: string | null
version_name: string | null version_name: string | null
version_status: string | null version_status: string | null
@@ -51,6 +53,7 @@ const STATUS_STYLES: Record<string, string> = {
invited: 'bg-amber-500/15 text-amber-300 border-amber-500/30', invited: 'bg-amber-500/15 text-amber-300 border-amber-500/30',
active: 'bg-green-500/15 text-green-300 border-green-500/30', active: 'bg-green-500/15 text-green-300 border-green-500/30',
revoked: 'bg-rose-500/15 text-rose-300 border-rose-500/30', revoked: 'bg-rose-500/15 text-rose-300 border-rose-500/30',
anonymized: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/30',
} }
export default function InvestorDetailPage() { export default function InvestorDetailPage() {
@@ -105,12 +108,30 @@ export default function InvestorDetailPage() {
} }
} }
async function generateLink() {
setBusy(true)
const res = await fetch(`/api/admin/investors/${id}/generate-link`, { method: 'POST' })
setBusy(false)
if (res.ok) {
const d = await res.json()
try {
await navigator.clipboard.writeText(d.url)
flashToast('Magic link copied to clipboard')
} catch {
flashToast(`Link (copy manually): ${d.url}`)
}
} else {
const err = await res.json().catch(() => ({}))
flashToast(err.error || 'Failed to generate link')
}
}
async function resend() { async function resend() {
setBusy(true) setBusy(true)
const res = await fetch(`/api/admin/investors/${id}/resend`, { method: 'POST' }) const res = await fetch(`/api/admin/investors/${id}/resend`, { method: 'POST' })
setBusy(false) setBusy(false)
if (res.ok) { if (res.ok) {
flashToast('Magic link resent') flashToast('Magic link resent via email')
load() load()
} else { } else {
const err = await res.json().catch(() => ({})) const err = await res.json().catch(() => ({}))
@@ -168,13 +189,28 @@ export default function InvestorDetailPage() {
) : ( ) : (
<> <>
<div className="flex items-center gap-3 mb-1 flex-wrap"> <div className="flex items-center gap-3 mb-1 flex-wrap">
<h1 className="text-2xl font-semibold text-white">{inv.name || inv.email}</h1> <h1 className="text-2xl font-semibold text-white">
<span className={`text-[10px] px-2 py-0.5 rounded-full border uppercase font-semibold ${STATUS_STYLES[inv.status]}`}> {inv.data_masked_at ? <span className="text-zinc-500 italic">[data protected]</span> : (inv.name || inv.email)}
{inv.status} </h1>
<span className={`text-[10px] px-2 py-0.5 rounded-full border uppercase font-semibold ${inv.data_masked_at ? STATUS_STYLES.anonymized : STATUS_STYLES[inv.status]}`}>
{inv.data_masked_at ? 'anonymized' : inv.status}
</span> </span>
</div> </div>
<div className="text-sm text-white/60">{inv.company || '—'}</div> {inv.data_masked_at ? (
<div className="text-xs text-white/40 mt-1">{inv.email}</div> <div className="text-xs text-zinc-500 mt-1">
Data anonymized on {new Date(inv.data_masked_at).toLocaleString()} · 72h window elapsed after first activity
</div>
) : (
<>
<div className="text-sm text-white/60">{inv.company || '—'}</div>
<div className="text-xs text-white/40 mt-1">{inv.email}</div>
{inv.first_activity_at && (
<div className="text-xs text-amber-400/70 mt-1">
Data window: 72h from first login · expires {new Date(new Date(inv.first_activity_at).getTime() + 72 * 60 * 60 * 1000).toLocaleString()}
</div>
)}
</>
)}
</> </>
)} )}
</div> </div>
@@ -197,18 +233,28 @@ export default function InvestorDetailPage() {
</> </>
) : ( ) : (
<> <>
{!inv.data_masked_at && (
<button
onClick={() => setEditing(true)}
className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg"
>
Edit
</button>
)}
<button <button
onClick={() => setEditing(true)} onClick={generateLink}
className="bg-white/[0.06] hover:bg-white/[0.1] text-white text-sm px-4 py-2 rounded-lg" disabled={busy || !!inv.data_masked_at || inv.status === 'revoked'}
className="bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-30"
title="Generate link without sending email — copies to clipboard"
> >
Edit {busy ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Link2 className="w-4 h-4" />} Copy Link
</button> </button>
<button <button
onClick={resend} onClick={resend}
disabled={busy || inv.status === 'revoked'} disabled={busy || !!inv.data_masked_at || inv.status === 'revoked'}
className="bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-30" className="bg-indigo-500/15 hover:bg-indigo-500/25 text-indigo-300 text-sm px-4 py-2 rounded-lg flex items-center gap-2 disabled:opacity-30"
> >
<Mail className="w-4 h-4" /> Resend Link <Mail className="w-4 h-4" /> Resend Email
</button> </button>
<button <button
onClick={revoke} onClick={revoke}
@@ -228,9 +274,9 @@ export default function InvestorDetailPage() {
<div className="text-xl text-white font-semibold mt-1">{inv.login_count}</div> <div className="text-xl text-white font-semibold mt-1">{inv.login_count}</div>
</div> </div>
<div> <div>
<div className="text-xs text-white/40 uppercase tracking-wider">Last login</div> <div className="text-xs text-white/40 uppercase tracking-wider">First activity</div>
<div className="text-sm text-white/80 mt-1"> <div className="text-sm text-white/80 mt-1">
{inv.last_login_at ? new Date(inv.last_login_at).toLocaleString() : '—'} {inv.first_activity_at ? new Date(inv.first_activity_at).toLocaleString() : '—'}
</div> </div>
</div> </div>
<div> <div>
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { Search, Mail, Ban, Eye, RefreshCw } from 'lucide-react' import { Search, Mail, Ban, Eye, RefreshCw, Link2 } from 'lucide-react'
interface Investor { interface Investor {
id: string id: string
@@ -17,12 +17,15 @@ interface Investor {
last_activity: string | null last_activity: string | null
assigned_version_id: string | null assigned_version_id: string | null
version_name: string | null version_name: string | null
first_activity_at: string | null
data_masked_at: string | null
} }
const STATUS_STYLES: Record<string, string> = { const STATUS_STYLES: Record<string, string> = {
invited: 'bg-amber-500/15 text-amber-300 border-amber-500/30', invited: 'bg-amber-500/15 text-amber-300 border-amber-500/30',
active: 'bg-green-500/15 text-green-300 border-green-500/30', active: 'bg-green-500/15 text-green-300 border-green-500/30',
revoked: 'bg-rose-500/15 text-rose-300 border-rose-500/30', revoked: 'bg-rose-500/15 text-rose-300 border-rose-500/30',
anonymized: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/30',
} }
export default function InvestorsPage() { export default function InvestorsPage() {
@@ -50,6 +53,24 @@ export default function InvestorsPage() {
setTimeout(() => setToast(null), 3000) setTimeout(() => setToast(null), 3000)
} }
async function generateLink(id: string) {
setBusy(id)
const res = await fetch(`/api/admin/investors/${id}/generate-link`, { method: 'POST' })
setBusy(null)
if (res.ok) {
const data = await res.json()
try {
await navigator.clipboard.writeText(data.url)
flashToast('Magic link copied to clipboard')
} catch {
flashToast(`Link (copy manually): ${data.url}`)
}
} else {
const err = await res.json().catch(() => ({}))
flashToast(err.error || 'Failed to generate link')
}
}
async function resend(id: string) { async function resend(id: string) {
setBusy(id) setBusy(id)
const res = await fetch(`/api/admin/investors/${id}/resend`, { method: 'POST' }) const res = await fetch(`/api/admin/investors/${id}/resend`, { method: 'POST' })
@@ -156,15 +177,19 @@ export default function InvestorsPage() {
<tr key={inv.id} className="border-b border-white/[0.04] hover:bg-white/[0.02]"> <tr key={inv.id} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
<td className="py-3 px-4"> <td className="py-3 px-4">
<Link href={`/pitch-admin/investors/${inv.id}`} className="block min-w-0 hover:text-indigo-300"> <Link href={`/pitch-admin/investors/${inv.id}`} className="block min-w-0 hover:text-indigo-300">
<div className="text-white/90 font-medium truncate">{inv.name || inv.email}</div> <div className="text-white/90 font-medium truncate">
{inv.data_masked_at ? <span className="text-zinc-500 italic">[data protected]</span> : (inv.name || inv.email)}
</div>
<div className="text-xs text-white/40 truncate"> <div className="text-xs text-white/40 truncate">
{inv.company ? `${inv.company} · ` : ''}{inv.email} {inv.data_masked_at
? `Anonymized ${new Date(inv.data_masked_at).toLocaleDateString()}`
: `${inv.company ? `${inv.company} · ` : ''}${inv.email}`}
</div> </div>
</Link> </Link>
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<span className={`text-[10px] px-2 py-0.5 rounded-full border uppercase font-semibold ${STATUS_STYLES[inv.status] || ''}`}> <span className={`text-[10px] px-2 py-0.5 rounded-full border uppercase font-semibold ${inv.data_masked_at ? STATUS_STYLES.anonymized : (STATUS_STYLES[inv.status] || '')}`}>
{inv.status} {inv.data_masked_at ? 'anonymized' : inv.status}
</span> </span>
</td> </td>
<td className="py-3 px-4 text-right text-white/70 font-mono">{inv.login_count}</td> <td className="py-3 px-4 text-right text-white/70 font-mono">{inv.login_count}</td>
@@ -189,12 +214,20 @@ export default function InvestorsPage() {
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
</Link> </Link>
<button <button
onClick={() => resend(inv.id)} onClick={() => generateLink(inv.id)}
disabled={busy === inv.id || inv.status === 'revoked'} disabled={busy === inv.id || inv.status === 'revoked' || !!inv.data_masked_at}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-indigo-500/15 hover:text-indigo-300 disabled:opacity-30 disabled:cursor-not-allowed" className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-emerald-500/15 hover:text-emerald-300 disabled:opacity-30 disabled:cursor-not-allowed"
title="Resend magic link" title="Generate & copy magic link (no email)"
> >
{busy === inv.id ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Mail className="w-4 h-4" />} {busy === inv.id ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Link2 className="w-4 h-4" />}
</button>
<button
onClick={() => resend(inv.id)}
disabled={busy === inv.id || inv.status === 'revoked' || !!inv.data_masked_at}
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/50 hover:bg-indigo-500/15 hover:text-indigo-300 disabled:opacity-30 disabled:cursor-not-allowed"
title="Resend magic link via email"
>
<Mail className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => revoke(inv.id, inv.email)} onClick={() => revoke(inv.id, inv.email)}
+30
View File
@@ -0,0 +1,30 @@
import pool from '@/lib/db'
const MASKING_HOURS = parseInt(process.env.DATA_MASKING_HOURS || '72')
export async function maskOverdueInvestors(): Promise<void> {
const cutoff = new Date(Date.now() - MASKING_HOURS * 60 * 60 * 1000)
const { rows } = await pool.query<{ id: string }>(
`UPDATE pitch_investors
SET email = 'anon.' || id::text,
name = NULL,
company = NULL,
status = 'revoked',
data_masked_at = NOW(),
updated_at = NOW()
WHERE first_activity_at IS NOT NULL
AND first_activity_at < $1
AND data_masked_at IS NULL
RETURNING id`,
[cutoff],
)
if (rows.length > 0) {
await pool.query(
`UPDATE pitch_sessions SET revoked = true
WHERE investor_id = ANY($1::uuid[]) AND NOT revoked`,
[rows.map((r) => r.id)],
)
}
}
@@ -0,0 +1,9 @@
-- Investor data masking: track first activity and enforce 72h anonymization window
ALTER TABLE pitch_investors
ADD COLUMN IF NOT EXISTS first_activity_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS data_masked_at TIMESTAMPTZ;
-- Partial index for fast masking-eligibility scans
CREATE INDEX IF NOT EXISTS idx_pitch_investors_mask_check
ON pitch_investors (first_activity_at)
WHERE first_activity_at IS NOT NULL AND data_masked_at IS NULL;