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 index d0586fc..b84842f 100644 --- a/pitch-deck/app/api/admin/investors/[id]/generate-link/route.ts +++ b/pitch-deck/app/api/admin/investors/[id]/generate-link/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' import { generateToken } from '@/lib/auth' import { requireAdmin, logAdminAudit } from '@/lib/admin-auth' +import { createShortLink } from '@/lib/short-links' interface RouteContext { params: Promise<{ id: string }> @@ -47,6 +48,7 @@ export async function POST(request: NextRequest, ctx: RouteContext) { const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai' const url = `${baseUrl}/auth/verify?token=${token}` + const shortUrl = await createShortLink(token) await logAdminAudit( adminId, @@ -56,5 +58,5 @@ export async function POST(request: NextRequest, ctx: RouteContext) { investor.id, ) - return NextResponse.json({ url, expires_at: expiresAt.toISOString() }) + return NextResponse.json({ url, short_url: shortUrl, expires_at: expiresAt.toISOString() }) } diff --git a/pitch-deck/app/api/admin/investors/[id]/resend/route.ts b/pitch-deck/app/api/admin/investors/[id]/resend/route.ts index 6156ab7..92f9617 100644 --- a/pitch-deck/app/api/admin/investors/[id]/resend/route.ts +++ b/pitch-deck/app/api/admin/investors/[id]/resend/route.ts @@ -4,6 +4,7 @@ import { generateToken } from '@/lib/auth' import { requireAdmin, logAdminAudit } from '@/lib/admin-auth' import { sendMagicLinkEmail } from '@/lib/email' import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit' +import { createShortLink } from '@/lib/short-links' interface RouteContext { params: Promise<{ id: string }> @@ -44,9 +45,8 @@ export async function POST(request: NextRequest, ctx: RouteContext) { [investor.id, token, expiresAt], ) - const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai' - const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}` - await sendMagicLinkEmail(investor.email, investor.name, magicLinkUrl) + const shortUrl = await createShortLink(token) + await sendMagicLinkEmail(investor.email, investor.name, shortUrl) await logAdminAudit( adminId, diff --git a/pitch-deck/app/api/admin/invite/route.ts b/pitch-deck/app/api/admin/invite/route.ts index fbbc4a2..1de9957 100644 --- a/pitch-deck/app/api/admin/invite/route.ts +++ b/pitch-deck/app/api/admin/invite/route.ts @@ -3,6 +3,7 @@ import pool from '@/lib/db' import { generateToken } from '@/lib/auth' import { requireAdmin, logAdminAudit } from '@/lib/admin-auth' import { sendMagicLinkEmail } from '@/lib/email' +import { createShortLink } from '@/lib/short-links' import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit' export async function POST(request: NextRequest) { @@ -72,9 +73,10 @@ export async function POST(request: NextRequest) { const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai' const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}` + const shortUrl = await createShortLink(token) if (send_email) { - await sendMagicLinkEmail(normalizedEmail, name || null, magicLinkUrl, greeting, message, closing, normalizedLang) + await sendMagicLinkEmail(normalizedEmail, name || null, shortUrl, greeting, message, closing, normalizedLang) } await logAdminAudit( @@ -99,5 +101,6 @@ export async function POST(request: NextRequest) { email: normalizedEmail, expires_at: expiresAt.toISOString(), magic_link_url: magicLinkUrl, + short_url: shortUrl, }) } diff --git a/pitch-deck/app/api/admin/migrate/route.ts b/pitch-deck/app/api/admin/migrate/route.ts index b70a9b8..18e2f1f 100644 --- a/pitch-deck/app/api/admin/migrate/route.ts +++ b/pitch-deck/app/api/admin/migrate/route.ts @@ -162,6 +162,14 @@ export async function POST(request: NextRequest) { `ALTER TABLE dataroom_investor_uploads ADD COLUMN IF NOT EXISTS description_en TEXT`, // 007 — investor preferred language `ALTER TABLE pitch_investors ADD COLUMN IF NOT EXISTS preferred_lang VARCHAR(5) NOT NULL DEFAULT 'de'`, + // 008 — short links for magic URLs + `CREATE TABLE IF NOT EXISTS pitch_short_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + short_code VARCHAR(10) UNIQUE NOT NULL, + token VARCHAR(128) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + `CREATE INDEX IF NOT EXISTS idx_pitch_short_links_code ON pitch_short_links(short_code)`, ] for (const sql of statements) { diff --git a/pitch-deck/app/p/[code]/route.ts b/pitch-deck/app/p/[code]/route.ts new file mode 100644 index 0000000..396b533 --- /dev/null +++ b/pitch-deck/app/p/[code]/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server' +import pool from '@/lib/db' + +interface Ctx { params: Promise<{ code: string }> } + +export async function GET(request: NextRequest, ctx: Ctx) { + const { code } = await ctx.params + + const { rows } = await pool.query( + `SELECT sl.token FROM pitch_short_links sl + JOIN pitch_magic_links ml ON ml.token = sl.token + WHERE sl.short_code = $1 AND ml.expires_at > NOW()`, + [code.toLowerCase()], + ) + + if (rows.length === 0) { + return NextResponse.redirect(new URL('/auth?error=invalid', request.url)) + } + + return NextResponse.redirect(new URL(`/auth/verify?token=${rows[0].token}`, request.url)) +} 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 1fd602f..ced1dbc 100644 --- a/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx +++ b/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx @@ -116,11 +116,12 @@ export default function InvestorDetailPage() { setBusy(false) if (res.ok) { const d = await res.json() + const link = d.short_url || d.url try { - await navigator.clipboard.writeText(d.url) - flashToast('Magic link copied to clipboard') + await navigator.clipboard.writeText(link) + flashToast('Short link copied to clipboard') } catch { - flashToast(`Link (copy manually): ${d.url}`) + flashToast(`Link (copy manually): ${link}`) } } else { const err = await res.json().catch(() => ({})) diff --git a/pitch-deck/app/pitch-admin/(authed)/investors/new/page.tsx b/pitch-deck/app/pitch-admin/(authed)/investors/new/page.tsx index 86a6407..d95245a 100644 --- a/pitch-deck/app/pitch-admin/(authed)/investors/new/page.tsx +++ b/pitch-deck/app/pitch-admin/(authed)/investors/new/page.tsx @@ -35,7 +35,7 @@ export default function NewInvestorPage() { const [closingEdited, setClosingEdited] = useState(false) const [error, setError] = useState('') const [submitting, setSubmitting] = useState(false) - const [createdLink, setCreatedLink] = useState(null) + const [createdLink, setCreatedLink] = useState<{ short: string; full: string } | null>(null) const [copied, setCopied] = useState(false) const prevLang = useRef('de') @@ -85,7 +85,7 @@ export default function NewInvestorPage() { if (res.ok) { const data = await res.json() if (!sendEmail) { - setCreatedLink(data.magic_link_url) + setCreatedLink({ short: data.short_url, full: data.magic_link_url }) } else { router.push('/pitch-admin/investors') router.refresh() @@ -103,7 +103,7 @@ export default function NewInvestorPage() { async function copyLink() { if (!createdLink) return - await navigator.clipboard.writeText(createdLink) + await navigator.clipboard.writeText(createdLink.short) setCopied(true) setTimeout(() => setCopied(false), 2000) } @@ -130,9 +130,15 @@ export default function NewInvestorPage() {

No email was sent. Copy the magic link below to use in your outreach.

-
-

Magic Link

-

{createdLink}

+
+
+

Short Link (use this in emails)

+

{createdLink?.short}

+
+
+

Full Link

+

{createdLink?.full}

+
diff --git a/pitch-deck/app/pitch-admin/(authed)/investors/page.tsx b/pitch-deck/app/pitch-admin/(authed)/investors/page.tsx index 7725c5e..bd7aac9 100644 --- a/pitch-deck/app/pitch-admin/(authed)/investors/page.tsx +++ b/pitch-deck/app/pitch-admin/(authed)/investors/page.tsx @@ -59,11 +59,12 @@ export default function InvestorsPage() { setBusy(null) if (res.ok) { const data = await res.json() + const link = data.short_url || data.url try { - await navigator.clipboard.writeText(data.url) - flashToast('Magic link copied to clipboard') + await navigator.clipboard.writeText(link) + flashToast('Short link copied to clipboard') } catch { - flashToast(`Link (copy manually): ${data.url}`) + flashToast(`Link (copy manually): ${link}`) } } else { const err = await res.json().catch(() => ({})) diff --git a/pitch-deck/lib/short-links.ts b/pitch-deck/lib/short-links.ts new file mode 100644 index 0000000..951789f --- /dev/null +++ b/pitch-deck/lib/short-links.ts @@ -0,0 +1,28 @@ +import 'server-only' +import { randomBytes } from 'crypto' +import pool from '@/lib/db' + +// Alphanumeric, confusable chars removed (0, 1, i, l, o) +const CHARS = 'abcdefghjkmnpqrstuvwxyz23456789' + +function generateCode(): string { + const bytes = randomBytes(6) + return Array.from(bytes, b => CHARS[b % CHARS.length]).join('') +} + +export async function createShortLink(token: string): Promise { + for (let attempt = 0; attempt < 10; attempt++) { + const code = generateCode() + try { + await pool.query( + `INSERT INTO pitch_short_links (short_code, token) VALUES ($1, $2)`, + [code, token], + ) + const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai' + return `${baseUrl}/p/${code}` + } catch { + // Unique constraint collision — retry with a new code + } + } + throw new Error('Failed to generate unique short code after 10 attempts') +}