From 23b233bda352ad427d465a05f5c6afe4779dbbfb Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:55:29 +0200 Subject: [PATCH] feat(pitch-admin): generate magic link + 72h investor data masking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../investors/[id]/generate-link/route.ts | 60 +++++++++++++++ .../app/api/admin/investors/[id]/route.ts | 6 +- pitch-deck/app/api/admin/investors/route.ts | 4 + pitch-deck/app/api/admin/migrate/route.ts | 6 ++ pitch-deck/app/api/auth/verify/route.ts | 17 ++++- .../(authed)/investors/[id]/page.tsx | 74 +++++++++++++++---- .../pitch-admin/(authed)/investors/page.tsx | 53 ++++++++++--- pitch-deck/lib/masking.ts | 30 ++++++++ .../migrations/004_investor_masking.sql | 9 +++ 9 files changed, 231 insertions(+), 28 deletions(-) create mode 100644 pitch-deck/app/api/admin/investors/[id]/generate-link/route.ts create mode 100644 pitch-deck/lib/masking.ts create mode 100644 pitch-deck/migrations/004_investor_masking.sql diff --git a/pitch-deck/app/api/admin/investors/[id]/generate-link/route.ts b/pitch-deck/app/api/admin/investors/[id]/generate-link/route.ts new file mode 100644 index 0000000..d0586fc --- /dev/null +++ b/pitch-deck/app/api/admin/investors/[id]/generate-link/route.ts @@ -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() }) +} diff --git a/pitch-deck/app/api/admin/investors/[id]/route.ts b/pitch-deck/app/api/admin/investors/[id]/route.ts index 56a7ff1..3998a25 100644 --- a/pitch-deck/app/api/admin/investors/[id]/route.ts +++ b/pitch-deck/app/api/admin/investors/[id]/route.ts @@ -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 diff --git a/pitch-deck/app/api/admin/investors/route.ts b/pitch-deck/app/api/admin/investors/route.ts index 0ba4ccf..85d8715 100644 --- a/pitch-deck/app/api/admin/investors/route.ts +++ b/pitch-deck/app/api/admin/investors/route.ts @@ -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 diff --git a/pitch-deck/app/api/admin/migrate/route.ts b/pitch-deck/app/api/admin/migrate/route.ts index 149cdd1..63169a5 100644 --- a/pitch-deck/app/api/admin/migrate/route.ts +++ b/pitch-deck/app/api/admin/migrate/route.ts @@ -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', diff --git a/pitch-deck/app/api/auth/verify/route.ts b/pitch-deck/app/api/auth/verify/route.ts index 2b29e6c..8e1e545 100644 --- a/pitch-deck/app/api/auth/verify/route.ts +++ b/pitch-deck/app/api/auth/verify/route.ts @@ -21,7 +21,9 @@ export async function POST(request: NextRequest) { // 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 + `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 JOIN pitch_investors i ON i.id = ml.investor_id 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 }) } + 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') { await logAudit(link.investor_id, 'login_failed', { reason: 'investor_revoked' }, request) return NextResponse.json({ error: 'Access has been revoked.' }, { status: 403 }) @@ -58,9 +64,14 @@ export async function POST(request: NextRequest) { [ip, ua, link.id] ) - // Activate investor if first login + // Activate investor if first login; record first_activity_at once 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`, [link.investor_id] ) diff --git a/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx b/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx index f1cd6ac..d642dce 100644 --- a/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx +++ b/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' 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' interface InvestorDetail { @@ -16,6 +16,8 @@ interface InvestorDetail { last_login_at: string | null login_count: number created_at: string + first_activity_at: string | null + data_masked_at: string | null assigned_version_id: string | null version_name: string | null version_status: string | null @@ -51,6 +53,7 @@ const STATUS_STYLES: Record = { invited: 'bg-amber-500/15 text-amber-300 border-amber-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', + anonymized: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/30', } 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() { setBusy(true) const res = await fetch(`/api/admin/investors/${id}/resend`, { method: 'POST' }) setBusy(false) if (res.ok) { - flashToast('Magic link resent') + flashToast('Magic link resent via email') load() } else { const err = await res.json().catch(() => ({})) @@ -168,13 +189,28 @@ export default function InvestorDetailPage() { ) : ( <>
-

{inv.name || inv.email}

- - {inv.status} +

+ {inv.data_masked_at ? [data protected] : (inv.name || inv.email)} +

+ + {inv.data_masked_at ? 'anonymized' : inv.status}
-
{inv.company || '—'}
-
{inv.email}
+ {inv.data_masked_at ? ( +
+ Data anonymized on {new Date(inv.data_masked_at).toLocaleString()} · 72h window elapsed after first activity +
+ ) : ( + <> +
{inv.company || '—'}
+
{inv.email}
+ {inv.first_activity_at && ( +
+ ⏱ Data window: 72h from first login · expires {new Date(new Date(inv.first_activity_at).getTime() + 72 * 60 * 60 * 1000).toLocaleString()} +
+ )} + + )} )} @@ -197,18 +233,28 @@ export default function InvestorDetailPage() { ) : ( <> + {!inv.data_masked_at && ( + + )} +