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
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:
@@ -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]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user