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 pool from '@/lib/db'
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
import { maskOverdueInvestors } from '@/lib/masking'
interface RouteContext {
params: Promise<{ id: string }>
@@ -12,10 +13,13 @@ export async function GET(request: NextRequest, ctx: RouteContext) {
const { id } = await ctx.params
await maskOverdueInvestors()
const [investor, sessions, snapshots, audit] = await Promise.all([
pool.query(
`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
FROM pitch_investors i
LEFT JOIN pitch_versions v ON v.id = i.assigned_version_id
@@ -1,13 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db'
import { requireAdmin } from '@/lib/admin-auth'
import { maskOverdueInvestors } from '@/lib/masking'
export async function GET(request: NextRequest) {
const guard = await requireAdmin(request)
if (guard.kind === 'response') return guard.response
await maskOverdueInvestors()
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,
i.first_activity_at, i.data_masked_at,
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 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
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 (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL DEFAULT 'Base Case',