Files
chorus-services/modules/teaser/app/api/early-access/route.ts
tony c8fb816775 feat: Add CHORUS teaser website with mobile-responsive design
- Created complete Next.js 15 teaser website with CHORUS brand styling
- Implemented mobile-responsive 3D logo (128px mobile, 512px desktop)
- Added proper Exo font loading via Next.js Google Fonts for iOS/Chrome compatibility
- Built comprehensive early access form with GDPR compliance and rate limiting
- Integrated PostgreSQL database with complete schema for lead capture
- Added scroll indicators that auto-hide when scrolling begins
- Optimized mobile modal forms with proper scrolling and submit button access
- Deployed via Docker Swarm with Traefik SSL termination at chorus.services
- Includes database migrations, consent tracking, and email notifications

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-26 13:57:30 +10:00

282 lines
8.7 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { query, transaction } from '../../../lib/db'
import nodemailer from 'nodemailer'
// Enhanced validation schema for early access leads
const EarlyAccessSchema = z.object({
firstName: z.string().min(1, 'First name is required').max(50),
lastName: z.string().min(1, 'Last name is required').max(50),
email: z.string().email('Invalid email address'),
companyName: z.string().optional(),
companyRole: z.string().optional(),
interestLevel: z.enum(['technical_evaluation', 'strategic_demo', 'general_interest']),
gdprConsent: z.boolean().refine(val => val === true, 'GDPR consent is required'),
marketingConsent: z.boolean().default(false),
leadSource: z.enum(['early_access_waitlist', 'request_early_access']),
countryCode: z.string().default('AU'),
customMessage: z.string().optional(),
})
// Database-based rate limiting
async function checkDatabaseRateLimit(clientIP: string, email: string): Promise<void> {
const now = new Date()
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000)
// Check IP-based rate limit (5 per hour)
const ipAttempts = await query(`
SELECT COUNT(*) as count FROM rate_limits
WHERE identifier = $1 AND identifier_type = 'ip'
AND window_start > $2 AND (blocked_until IS NULL OR blocked_until < NOW())
`, [clientIP, oneHourAgo])
if (parseInt(ipAttempts[0].count) >= 5) {
throw new Error('Too many requests from this IP address. Please try again later.')
}
// Check email-based rate limit (3 per day)
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const emailAttempts = await query(`
SELECT COUNT(*) as count FROM rate_limits
WHERE identifier = $1 AND identifier_type = 'email'
AND window_start > $2 AND (blocked_until IS NULL OR blocked_until < NOW())
`, [email, oneDayAgo])
if (parseInt(emailAttempts[0].count) >= 3) {
throw new Error('This email has been used too frequently. Please try again tomorrow.')
}
// Record this attempt (insert without conflict handling for now)
try {
await query(`
INSERT INTO rate_limits (identifier, identifier_type, action_type, attempt_count)
VALUES ($1, 'ip', 'form_submission', 1)
`, [clientIP])
} catch (e) {
// Ignore duplicate key errors for now
}
try {
await query(`
INSERT INTO rate_limits (identifier, identifier_type, action_type, attempt_count)
VALUES ($1, 'email', 'form_submission', 1)
`, [email])
} catch (e) {
// Ignore duplicate key errors for now
}
}
// Email configuration
const createTransporter = () => {
return nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
}
// Send notification email
async function sendNotificationEmail(leadData: any, leadId: string) {
if (!process.env.SMTP_USER || !process.env.NOTIFICATION_TO_EMAIL) {
console.log('Email notifications not configured, skipping...')
return
}
try {
const transporter = createTransporter()
const emailContent = `
New ${leadData.leadSource === 'request_early_access' ? 'Early Access Request' : 'Waitlist Signup'} - CHORUS Teaser Website
Lead ID: ${leadId}
Name: ${leadData.firstName} ${leadData.lastName}
Email: ${leadData.email}
Company: ${leadData.companyName || 'Not provided'}
Role: ${leadData.companyRole || 'Not provided'}
Interest Level: ${leadData.interestLevel}
Lead Source: ${leadData.leadSource}
GDPR Consent: ${leadData.gdprConsent ? 'Given' : 'Not given'}
Marketing Consent: ${leadData.marketingConsent ? 'Given' : 'Not given'}
Custom Message: ${leadData.customMessage || 'None'}
Timestamp: ${new Date().toISOString()}
IP Address: ${leadData.clientIP}
User Agent: ${leadData.userAgent}
`
await transporter.sendMail({
from: process.env.NOTIFICATION_FROM_EMAIL,
to: process.env.NOTIFICATION_TO_EMAIL,
subject: `New CHORUS ${leadData.leadSource === 'request_early_access' ? 'Early Access Request' : 'Waitlist Signup'}`,
text: emailContent,
})
console.log('Notification email sent successfully')
} catch (error) {
console.error('Failed to send notification email:', error)
}
}
export async function POST(request: NextRequest) {
try {
// Parse request body
const body = await request.json()
// Validate input
const validatedData = EarlyAccessSchema.parse(body)
// Get client IP for rate limiting
const clientIP = request.headers.get('x-forwarded-for') ||
request.headers.get('x-real-ip') ||
'127.0.0.1'
const userAgent = request.headers.get('user-agent') || ''
// Check rate limits using database
await checkDatabaseRateLimit(clientIP, validatedData.email)
// Insert lead into database using transaction
const leadId = await transaction(async (client) => {
// Insert the main lead record
const leadResult = await client.query(`
INSERT INTO leads (
first_name, last_name, email, company_name, company_role,
lead_source, inquiry_details, custom_message, ip_address, user_agent,
country_code, gdpr_consent_given, gdpr_consent_date, marketing_consent
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id
`, [
validatedData.firstName,
validatedData.lastName,
validatedData.email,
validatedData.companyName || null,
validatedData.companyRole || null,
validatedData.leadSource,
validatedData.interestLevel,
validatedData.customMessage || null,
clientIP,
userAgent,
validatedData.countryCode,
validatedData.gdprConsent,
validatedData.gdprConsent ? new Date() : null, // gdpr_consent_date
validatedData.marketingConsent
])
const leadId = leadResult.rows[0].id
// Insert consent audit record
await client.query(`
INSERT INTO consent_audit (
lead_id, consent_type, consent_status, consent_method, ip_address, user_agent
) VALUES ($1, $2, $3, $4, $5, $6)
`, [
leadId,
'gdpr_consent',
'given',
'web_form',
clientIP,
userAgent
])
// If marketing consent given, record that too
if (validatedData.marketingConsent) {
await client.query(`
INSERT INTO consent_audit (
lead_id, consent_type, consent_status, consent_method, ip_address, user_agent
) VALUES ($1, $2, $3, $4, $5, $6)
`, [
leadId,
'marketing_consent',
'given',
'web_form',
clientIP,
userAgent
])
}
return leadId
})
// Send notification email (async, don't block response)
sendNotificationEmail({
...validatedData,
clientIP,
userAgent
}, leadId).catch(console.error)
// Log successful capture
console.log('Lead successfully captured:', {
leadId,
email: validatedData.email,
leadSource: validatedData.leadSource,
timestamp: new Date().toISOString()
})
// Send success response
const message = validatedData.leadSource === 'request_early_access'
? 'Early access request submitted successfully!'
: 'Successfully joined the CHORUS waitlist!'
return NextResponse.json({
success: true,
message,
leadId,
}, { status: 201 })
} catch (error) {
console.error('Early access signup error:', error)
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'Validation error',
details: error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}))
},
{ status: 400 }
)
}
if (error instanceof Error) {
// Check for specific database errors
if (error.message.includes('duplicate key')) {
return NextResponse.json(
{ error: 'This email has already been registered.' },
{ status: 409 }
)
}
if (error.message.includes('rate limit')) {
return NextResponse.json(
{ error: error.message },
{ status: 429 }
)
}
}
return NextResponse.json(
{ error: 'Internal server error. Please try again.' },
{ status: 500 }
)
}
}
// Handle OPTIONS for CORS
export async function OPTIONS(request: NextRequest) {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
})
}