import { NextRequest, NextResponse } from 'next/server'; import { wwwUrl } from '@/lib/hosts'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; import { db } from '@/lib/db'; import { z } from 'zod'; import { csrfProtection } from '@/lib/csrf'; import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit'; import { signupSchema, validateRequest } from '@/lib/validationSchemas'; import { sendEmailVerificationEmail } from '@/lib/email'; import { sendConversionEvent } from '@/lib/metaConversions'; import { ATTRIBUTION_COOKIE_NAME, getEmailDomain, parseAttributionCookie, } from '@/lib/revops'; import { triggerLifecycleScoring } from '@/lib/revops-server'; async function issueVerificationEmail(user: { email: string; name: string | null }) { const verificationToken = crypto.randomBytes(32).toString('base64url'); // Public link in an outgoing email, so it points at the marketing host. The endpoint // itself is served on both hosts and redirects into the app afterwards. const verificationUrl = new URL(wwwUrl('/api/auth/verify-email')); verificationUrl.searchParams.set('token', verificationToken); await db.verificationToken.deleteMany({ where: { identifier: user.email } }); await db.verificationToken.create({ data: { identifier: user.email, token: verificationToken, expires: new Date(Date.now() + 24 * 60 * 60 * 1000), }, }); try { await sendEmailVerificationEmail(user.email, user.name ?? 'there', verificationUrl.toString()); } catch (error) { await db.verificationToken.deleteMany({ where: { token: verificationToken } }); throw error; } } export async function POST(request: NextRequest) { try { // CSRF Protection const csrfCheck = csrfProtection(request); if (!csrfCheck.valid) { return NextResponse.json( { error: csrfCheck.error }, { status: 403 } ); } // Rate Limiting const clientId = getClientIdentifier(request); const rateLimitResult = rateLimit(clientId, RateLimits.SIGNUP); if (!rateLimitResult.success) { return NextResponse.json( { error: 'Too many signup attempts. Please try again later.', retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000) }, { status: 429, headers: { 'X-RateLimit-Limit': rateLimitResult.limit.toString(), 'X-RateLimit-Remaining': rateLimitResult.remaining.toString(), 'X-RateLimit-Reset': rateLimitResult.reset.toString(), } } ); } const body = await request.json(); // Validate request body const validation = await validateRequest(signupSchema, body); if (!validation.success) { return NextResponse.json(validation.error, { status: 400 }); } const { name, email, password } = validation.data; // Check if user already exists const existingUser = await db.user.findUnique({ where: { email }, }); if (existingUser) { if (!existingUser.emailVerified && existingUser.password && await bcrypt.compare(password, existingUser.password)) { try { await issueVerificationEmail(existingUser); return NextResponse.json({ success: true, requiresEmailVerification: true, email: existingUser.email }); } catch (emailError) { console.error('Email verification resend failed:', emailError); return NextResponse.json({ error: 'We could not send the confirmation email. Please try again.' }, { status: 503 }); } } return NextResponse.json( { error: 'User already exists' }, { status: 400 } ); } // Hash password const hashedPassword = await bcrypt.hash(password, 12); const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value); const onboardingStartedAt = new Date(); // Create user const user = await db.user.create({ data: { name, email, password: hashedPassword, onboardingStartedAt, emailDomain: getEmailDomain(email), signupSource: firstTouch?.signupSource || null, signupMedium: firstTouch?.signupMedium || null, signupCampaign: firstTouch?.signupCampaign || null, signupContent: firstTouch?.signupContent || null, signupTerm: firstTouch?.signupTerm || null, signupReferrer: firstTouch?.signupReferrer || null, signupLandingPath: firstTouch?.signupLandingPath || '/signup', signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt, }, }); triggerLifecycleScoring(user.id, 'signup'); // A confirmed address is required before the account can be used or receive campaigns. try { await issueVerificationEmail(user); } catch (emailError) { console.error('Email verification message failed:', emailError); await db.user.delete({ where: { id: user.id } }); return NextResponse.json({ error: 'We could not send the confirmation email. Please try again.' }, { status: 503 }); } // Meta Conversions API - CompleteRegistration event sendConversionEvent({ eventName: 'CompleteRegistration', userData: { email: user.email, ip: request.headers.get('x-forwarded-for')?.split(',')[0] ?? undefined, userAgent: request.headers.get('user-agent') ?? undefined, fbc: request.cookies.get('_fbc')?.value, fbp: request.cookies.get('_fbp')?.value, }, eventSourceUrl: wwwUrl('/signup'), }).catch(console.error); // Create response const response = NextResponse.json({ success: true, requiresEmailVerification: true, email: user.email, }); response.cookies.delete(ATTRIBUTION_COOKIE_NAME); return response; } catch (error) { if (error instanceof z.ZodError) { return NextResponse.json( { error: 'Invalid input', details: error.errors }, { status: 400 } ); } console.error('Signup error:', error); return NextResponse.json( { error: 'Internal server error' }, { status: 500 } ); } }