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
+14 -3
View File
@@ -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]
)