feat(pitch-deck): branded short links for magic URLs (pitch.breakpilot.ai/p/ab3xk2)
Build pitch-deck / build-push-deploy (push) Successful in 1m31s
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 32s
CI / test-python-voice (push) Successful in 34s
CI / test-bqas (push) Successful in 30s
Build pitch-deck / build-push-deploy (push) Successful in 1m31s
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 32s
CI / test-python-voice (push) Successful in 34s
CI / test-bqas (push) Successful in 30s
- New pitch_short_links table stores 6-char alphanumeric codes mapped to magic link tokens - GET /p/[code] redirects to /auth/verify?token=... (302, validates expiry) - All magic link generation points (invite, generate-link, resend) now create a short code - Emails (invite + resend) use the short URL — less token-like, cleaner for spam filters - Copy-link UI shows short URL prominently with full URL as fallback - Migration 008 added to /api/admin/migrate Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||||||
import pool from '@/lib/db'
|
import pool from '@/lib/db'
|
||||||
import { generateToken } from '@/lib/auth'
|
import { generateToken } from '@/lib/auth'
|
||||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||||
|
import { createShortLink } from '@/lib/short-links'
|
||||||
|
|
||||||
interface RouteContext {
|
interface RouteContext {
|
||||||
params: Promise<{ id: string }>
|
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 baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai'
|
||||||
const url = `${baseUrl}/auth/verify?token=${token}`
|
const url = `${baseUrl}/auth/verify?token=${token}`
|
||||||
|
const shortUrl = await createShortLink(token)
|
||||||
|
|
||||||
await logAdminAudit(
|
await logAdminAudit(
|
||||||
adminId,
|
adminId,
|
||||||
@@ -56,5 +58,5 @@ export async function POST(request: NextRequest, ctx: RouteContext) {
|
|||||||
investor.id,
|
investor.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return NextResponse.json({ url, expires_at: expiresAt.toISOString() })
|
return NextResponse.json({ url, short_url: shortUrl, expires_at: expiresAt.toISOString() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { generateToken } from '@/lib/auth'
|
|||||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||||
import { sendMagicLinkEmail } from '@/lib/email'
|
import { sendMagicLinkEmail } from '@/lib/email'
|
||||||
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
||||||
|
import { createShortLink } from '@/lib/short-links'
|
||||||
|
|
||||||
interface RouteContext {
|
interface RouteContext {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
@@ -44,9 +45,8 @@ export async function POST(request: NextRequest, ctx: RouteContext) {
|
|||||||
[investor.id, token, expiresAt],
|
[investor.id, token, expiresAt],
|
||||||
)
|
)
|
||||||
|
|
||||||
const baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai'
|
const shortUrl = await createShortLink(token)
|
||||||
const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}`
|
await sendMagicLinkEmail(investor.email, investor.name, shortUrl)
|
||||||
await sendMagicLinkEmail(investor.email, investor.name, magicLinkUrl)
|
|
||||||
|
|
||||||
await logAdminAudit(
|
await logAdminAudit(
|
||||||
adminId,
|
adminId,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import pool from '@/lib/db'
|
|||||||
import { generateToken } from '@/lib/auth'
|
import { generateToken } from '@/lib/auth'
|
||||||
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
import { requireAdmin, logAdminAudit } from '@/lib/admin-auth'
|
||||||
import { sendMagicLinkEmail } from '@/lib/email'
|
import { sendMagicLinkEmail } from '@/lib/email'
|
||||||
|
import { createShortLink } from '@/lib/short-links'
|
||||||
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
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 baseUrl = process.env.PITCH_BASE_URL || 'https://pitch.breakpilot.ai'
|
||||||
const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}`
|
const magicLinkUrl = `${baseUrl}/auth/verify?token=${token}`
|
||||||
|
const shortUrl = await createShortLink(token)
|
||||||
|
|
||||||
if (send_email) {
|
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(
|
await logAdminAudit(
|
||||||
@@ -99,5 +101,6 @@ export async function POST(request: NextRequest) {
|
|||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
expires_at: expiresAt.toISOString(),
|
expires_at: expiresAt.toISOString(),
|
||||||
magic_link_url: magicLinkUrl,
|
magic_link_url: magicLinkUrl,
|
||||||
|
short_url: shortUrl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,14 @@ export async function POST(request: NextRequest) {
|
|||||||
`ALTER TABLE dataroom_investor_uploads ADD COLUMN IF NOT EXISTS description_en TEXT`,
|
`ALTER TABLE dataroom_investor_uploads ADD COLUMN IF NOT EXISTS description_en TEXT`,
|
||||||
// 007 — investor preferred language
|
// 007 — investor preferred language
|
||||||
`ALTER TABLE pitch_investors ADD COLUMN IF NOT EXISTS preferred_lang VARCHAR(5) NOT NULL DEFAULT 'de'`,
|
`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) {
|
for (const sql of statements) {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -116,11 +116,12 @@ export default function InvestorDetailPage() {
|
|||||||
setBusy(false)
|
setBusy(false)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const d = await res.json()
|
const d = await res.json()
|
||||||
|
const link = d.short_url || d.url
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(d.url)
|
await navigator.clipboard.writeText(link)
|
||||||
flashToast('Magic link copied to clipboard')
|
flashToast('Short link copied to clipboard')
|
||||||
} catch {
|
} catch {
|
||||||
flashToast(`Link (copy manually): ${d.url}`)
|
flashToast(`Link (copy manually): ${link}`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json().catch(() => ({}))
|
const err = await res.json().catch(() => ({}))
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function NewInvestorPage() {
|
|||||||
const [closingEdited, setClosingEdited] = useState(false)
|
const [closingEdited, setClosingEdited] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [createdLink, setCreatedLink] = useState<string | null>(null)
|
const [createdLink, setCreatedLink] = useState<{ short: string; full: string } | null>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const prevLang = useRef<Lang>('de')
|
const prevLang = useRef<Lang>('de')
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ export default function NewInvestorPage() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!sendEmail) {
|
if (!sendEmail) {
|
||||||
setCreatedLink(data.magic_link_url)
|
setCreatedLink({ short: data.short_url, full: data.magic_link_url })
|
||||||
} else {
|
} else {
|
||||||
router.push('/pitch-admin/investors')
|
router.push('/pitch-admin/investors')
|
||||||
router.refresh()
|
router.refresh()
|
||||||
@@ -103,7 +103,7 @@ export default function NewInvestorPage() {
|
|||||||
|
|
||||||
async function copyLink() {
|
async function copyLink() {
|
||||||
if (!createdLink) return
|
if (!createdLink) return
|
||||||
await navigator.clipboard.writeText(createdLink)
|
await navigator.clipboard.writeText(createdLink.short)
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000)
|
||||||
}
|
}
|
||||||
@@ -130,9 +130,15 @@ export default function NewInvestorPage() {
|
|||||||
<p className="text-sm text-white/50">No email was sent. Copy the magic link below to use in your outreach.</p>
|
<p className="text-sm text-white/50">No email was sent. Copy the magic link below to use in your outreach.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-black/40 border border-white/10 rounded-xl p-4 text-left">
|
<div className="bg-black/40 border border-white/10 rounded-xl p-4 text-left space-y-3">
|
||||||
<p className="text-[10px] font-semibold text-white/40 uppercase tracking-wider mb-2">Magic Link</p>
|
<div>
|
||||||
<p className="text-xs text-indigo-300 break-all leading-relaxed">{createdLink}</p>
|
<p className="text-[10px] font-semibold text-white/40 uppercase tracking-wider mb-1">Short Link <span className="text-green-400/70 normal-case font-normal">(use this in emails)</span></p>
|
||||||
|
<p className="text-sm text-indigo-300 font-mono">{createdLink?.short}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold text-white/25 uppercase tracking-wider mb-1">Full Link</p>
|
||||||
|
<p className="text-[10px] text-white/30 break-all leading-relaxed font-mono">{createdLink?.full}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
|||||||
@@ -59,11 +59,12 @@ export default function InvestorsPage() {
|
|||||||
setBusy(null)
|
setBusy(null)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
const link = data.short_url || data.url
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(data.url)
|
await navigator.clipboard.writeText(link)
|
||||||
flashToast('Magic link copied to clipboard')
|
flashToast('Short link copied to clipboard')
|
||||||
} catch {
|
} catch {
|
||||||
flashToast(`Link (copy manually): ${data.url}`)
|
flashToast(`Link (copy manually): ${link}`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json().catch(() => ({}))
|
const err = await res.json().catch(() => ({}))
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user