11import { createLogger } from "@databuddy/shared/logger" ;
22import { type NextRequest , NextResponse } from "next/server" ;
3- import { formRateLimit } from "@/lib/rate-limit" ;
43
54const logger = createLogger ( "ambassador-form" ) ;
5+ const SLACK_WEBHOOK_URL = process . env . SLACK_WEBHOOK_URL || "" ;
6+ const SLACK_TIMEOUT_MS = 10_000 ;
7+
8+ const MIN_NAME_LENGTH = 2 ;
9+ const MIN_WHY_AMBASSADOR_LENGTH = 10 ;
610
711type AmbassadorFormData = {
812 name : string ;
@@ -13,169 +17,307 @@ type AmbassadorFormData = {
1317 experience ?: string ;
1418 audience ?: string ;
1519 referralSource ?: string ;
16- }
20+ } ;
21+
22+ type ValidationResult =
23+ | { valid : true ; data : AmbassadorFormData }
24+ | { valid : false ; errors : string [ ] } ;
1725
1826function getClientIP ( request : NextRequest ) : string {
19- const forwarded = request . headers . get ( "x-forwarded-for" ) ;
20- const realIP = request . headers . get ( "x-real-ip" ) ;
2127 const cfConnectingIP = request . headers . get ( "cf-connecting-ip" ) ;
28+ if ( cfConnectingIP ) {
29+ return cfConnectingIP . trim ( ) ;
30+ }
2231
32+ const forwarded = request . headers . get ( "x-forwarded-for" ) ;
2333 if ( forwarded ) {
24- return forwarded . split ( "," ) [ 0 ] . trim ( ) ;
34+ const firstIP = forwarded . split ( "," ) [ 0 ] ?. trim ( ) ;
35+ if ( firstIP ) {
36+ return firstIP ;
37+ }
2538 }
39+
40+ const realIP = request . headers . get ( "x-real-ip" ) ;
2641 if ( realIP ) {
27- return realIP ;
28- }
29- if ( cfConnectingIP ) {
30- return cfConnectingIP ;
42+ return realIP . trim ( ) ;
3143 }
3244
3345 return "unknown" ;
3446}
3547
36- function validateFormData ( data : unknown ) : { valid : boolean ; errors : string [ ] } {
37- const errors : string [ ] = [ ] ;
48+ function getUserAgent ( request : NextRequest ) : string {
49+ return request . headers . get ( "user-agent" ) || "unknown" ;
50+ }
51+
52+ function isValidEmail ( email : string ) : boolean {
53+ return email . includes ( "@" ) && email . length > 3 ;
54+ }
55+
56+ function isValidURL ( url : string ) : boolean {
57+ try {
58+ new URL ( url ) ;
59+ return true ;
60+ } catch {
61+ return false ;
62+ }
63+ }
3864
65+ function isValidXHandle ( handle : string ) : boolean {
66+ return ! ( handle . includes ( "@" ) || handle . includes ( "http" ) ) ;
67+ }
68+
69+ function validateFormData ( data : unknown ) : ValidationResult {
3970 if ( ! data || typeof data !== "object" ) {
4071 return { valid : false , errors : [ "Invalid form data" ] } ;
4172 }
4273
4374 const formData = data as Record < string , unknown > ;
75+ const errors : string [ ] = [ ] ;
4476
45- // Required fields
77+ const name = formData . name ;
4678 if (
47- ! formData . name ||
48- typeof formData . name !== "string" ||
49- formData . name . trim ( ) . length < 2
79+ ! name ||
80+ typeof name !== "string" ||
81+ name . trim ( ) . length < MIN_NAME_LENGTH
5082 ) {
5183 errors . push ( "Name is required and must be at least 2 characters" ) ;
5284 }
5385
54- if (
55- ! formData . email ||
56- typeof formData . email !== "string" ||
57- ! formData . email . includes ( "@" )
58- ) {
86+ const email = formData . email ;
87+ if ( ! email || typeof email !== "string" || ! isValidEmail ( email ) ) {
5988 errors . push ( "Valid email is required" ) ;
6089 }
6190
91+ const whyAmbassador = formData . whyAmbassador ;
6292 if (
63- ! formData . whyAmbassador ||
64- typeof formData . whyAmbassador !== "string" ||
65- formData . whyAmbassador . trim ( ) . length < 10
93+ ! whyAmbassador ||
94+ typeof whyAmbassador !== "string" ||
95+ whyAmbassador . trim ( ) . length < MIN_WHY_AMBASSADOR_LENGTH
6696 ) {
6797 errors . push (
6898 "Please explain why you want to be an ambassador (minimum 10 characters)"
6999 ) ;
70100 }
71101
72- // Optional fields validation
102+ const xHandle = formData . xHandle ;
73103 if (
74- formData . xHandle &&
75- typeof formData . xHandle === "string" &&
76- formData . xHandle . length > 0 &&
77- ( formData . xHandle . includes ( "@" ) || formData . xHandle . includes ( "http" ) )
104+ xHandle &&
105+ typeof xHandle === "string" &&
106+ xHandle . length > 0 &&
107+ ! isValidXHandle ( xHandle )
78108 ) {
79109 errors . push ( "X handle should not include @ or URLs" ) ;
80110 }
81111
112+ const website = formData . website ;
82113 if (
83- formData . website &&
84- typeof formData . website === "string" &&
85- formData . website . length > 0
114+ website &&
115+ typeof website === "string" &&
116+ website . length > 0 &&
117+ ! isValidURL ( website )
86118 ) {
119+ errors . push ( "Website must be a valid URL" ) ;
120+ }
121+
122+ if ( errors . length > 0 ) {
123+ return { valid : false , errors } ;
124+ }
125+
126+ return {
127+ valid : true ,
128+ data : {
129+ name : String ( name ) . trim ( ) ,
130+ email : String ( email ) . trim ( ) ,
131+ xHandle : xHandle ? String ( xHandle ) . trim ( ) : undefined ,
132+ website : website ? String ( website ) . trim ( ) : undefined ,
133+ whyAmbassador : String ( whyAmbassador ) . trim ( ) ,
134+ experience : formData . experience
135+ ? String ( formData . experience ) . trim ( )
136+ : undefined ,
137+ audience : formData . audience
138+ ? String ( formData . audience ) . trim ( )
139+ : undefined ,
140+ referralSource : formData . referralSource
141+ ? String ( formData . referralSource ) . trim ( )
142+ : undefined ,
143+ } ,
144+ } ;
145+ }
146+
147+ function createSlackField ( label : string , value : string ) {
148+ return {
149+ type : "mrkdwn" as const ,
150+ text : `*${ label } :*\n${ value } ` ,
151+ } ;
152+ }
153+
154+ function buildSlackBlocks ( data : AmbassadorFormData , ip : string ) : unknown [ ] {
155+ const fields = [
156+ createSlackField ( "Name" , data . name ) ,
157+ createSlackField ( "Email" , data . email ) ,
158+ createSlackField ( "X Handle" , data . xHandle || "Not provided" ) ,
159+ createSlackField ( "Website" , data . website || "Not provided" ) ,
160+ ] ;
161+
162+ if ( data . experience ) {
163+ fields . push ( createSlackField ( "Experience" , data . experience ) ) ;
164+ }
165+
166+ if ( data . audience ) {
167+ fields . push ( createSlackField ( "Audience" , data . audience ) ) ;
168+ }
169+
170+ if ( data . referralSource ) {
171+ fields . push ( createSlackField ( "Referral Source" , data . referralSource ) ) ;
172+ }
173+
174+ fields . push ( createSlackField ( "IP" , ip ) ) ;
175+
176+ const blocks : unknown [ ] = [
177+ {
178+ type : "header" ,
179+ text : {
180+ type : "plain_text" ,
181+ text : "🎯 New Ambassador Application" ,
182+ emoji : true ,
183+ } ,
184+ } ,
185+ ] ;
186+
187+ for ( let i = 0 ; i < fields . length ; i += 2 ) {
188+ blocks . push ( {
189+ type : "section" ,
190+ fields : fields . slice ( i , i + 2 ) ,
191+ } ) ;
192+ }
193+
194+ blocks . push ( {
195+ type : "section" ,
196+ text : {
197+ type : "mrkdwn" ,
198+ text : `*Why Ambassador:*\n${ data . whyAmbassador } ` ,
199+ } ,
200+ } ) ;
201+
202+ return blocks ;
203+ }
204+
205+ async function sendToSlack ( data : AmbassadorFormData , ip : string ) : Promise < void > {
206+ if ( ! SLACK_WEBHOOK_URL ) {
207+ logger . warn ( { } , "SLACK_WEBHOOK_URL not configured, skipping Slack notification" ) ;
208+ return ;
209+ }
210+
211+ try {
212+ const blocks = buildSlackBlocks ( data , ip ) ;
213+ const controller = new AbortController ( ) ;
214+ const timeoutId = setTimeout ( ( ) => controller . abort ( ) , SLACK_TIMEOUT_MS ) ;
215+
87216 try {
88- new URL ( formData . website ) ;
89- } catch {
90- errors . push ( "Website must be a valid URL" ) ;
217+ const response = await fetch ( SLACK_WEBHOOK_URL , {
218+ method : "POST" ,
219+ headers : {
220+ "Content-Type" : "application/json" ,
221+ } ,
222+ body : JSON . stringify ( { blocks } ) ,
223+ signal : controller . signal ,
224+ } ) ;
225+
226+ clearTimeout ( timeoutId ) ;
227+
228+ if ( ! response . ok ) {
229+ const responseText = await response . text ( ) . catch ( ( ) => "Unable to read response" ) ;
230+ logger . error (
231+ {
232+ status : response . status ,
233+ statusText : response . statusText ,
234+ response : responseText . slice ( 0 , 200 ) ,
235+ } ,
236+ "Failed to send Slack webhook"
237+ ) ;
238+ }
239+ } catch ( fetchError ) {
240+ clearTimeout ( timeoutId ) ;
241+ if ( fetchError instanceof Error && fetchError . name === "AbortError" ) {
242+ logger . error ( { } , "Slack webhook request timed out after 10 seconds" ) ;
243+ } else {
244+ throw fetchError ;
245+ }
91246 }
247+ } catch ( error ) {
248+ logger . error (
249+ {
250+ error : error instanceof Error ? error . message : String ( error ) ,
251+ stack : error instanceof Error ? error . stack : undefined ,
252+ } ,
253+ "Error sending to Slack webhook"
254+ ) ;
92255 }
93-
94- return { valid : errors . length === 0 , errors } ;
95256}
96257
97258export async function POST ( request : NextRequest ) {
98- try {
99- // Bot protection - DISABLED
100- // const verification = await checkBotId();
101-
102- // if (verification.isBot) {
103- // await logger.warning(
104- // 'Ambassador Form Bot Attempt',
105- // 'Bot detected trying to submit ambassador form',
106- // {
107- // botScore: verification.isBot,
108- // userAgent: request.headers.get('user-agent'),
109- // }
110- // );
111- // return NextResponse.json({ error: 'Access denied' }, { status: 403 });
112- // }
113-
114- // Rate limiting
115- const clientIP = getClientIP ( request ) ;
116- const rateLimitResult = formRateLimit . check ( clientIP ) ;
117-
118- if ( ! rateLimitResult . allowed ) {
119- logger . info (
120- { ip : clientIP , resetTime : rateLimitResult . resetTime } ,
121- `IP ${ clientIP } exceeded rate limit for ambassador form submissions`
122- ) ;
259+ const clientIP = getClientIP ( request ) ;
260+ const userAgent = getUserAgent ( request ) ;
123261
124- return NextResponse . json (
262+ try {
263+ let formData : unknown ;
264+ try {
265+ formData = await request . json ( ) ;
266+ } catch ( jsonError ) {
267+ logger . warn (
125268 {
126- error : "Too many submissions. Please try again later." ,
127- resetTime : rateLimitResult . resetTime ,
269+ ip : clientIP ,
270+ userAgent,
271+ error : jsonError instanceof Error ? jsonError . message : String ( jsonError ) ,
128272 } ,
129- { status : 429 }
273+ "Invalid JSON in request body"
274+ ) ;
275+ return NextResponse . json (
276+ { error : "Invalid JSON format in request body" } ,
277+ { status : 400 }
130278 ) ;
131279 }
132280
133- // Parse and validate form data
134- const formData = await request . json ( ) ;
135281 const validation = validateFormData ( formData ) ;
136282
137283 if ( ! validation . valid ) {
138284 logger . info (
139285 { errors : validation . errors , ip : clientIP } ,
140286 "Form submission failed validation"
141287 ) ;
142-
143288 return NextResponse . json (
144289 { error : "Validation failed" , details : validation . errors } ,
145290 { status : 400 }
146291 ) ;
147292 }
148293
149- const ambassadorData = formData as AmbassadorFormData ;
294+ const ambassadorData = validation . data ;
150295
151296 logger . info (
152297 {
153298 name : ambassadorData . name ,
154299 email : ambassadorData . email ,
155- xHandle : ambassadorData . xHandle || "Not provided" ,
156- website : ambassadorData . website || "Not provided" ,
157- whyAmbassador : ambassadorData . whyAmbassador ,
158- experience : ambassadorData . experience || "Not provided" ,
159- audience : ambassadorData . audience || "Not provided" ,
160- referralSource : ambassadorData . referralSource || "Not provided" ,
161300 ip : clientIP ,
162- userAgent : request . headers . get ( "user-agent" ) ,
301+ userAgent,
163302 } ,
164303 `${ ambassadorData . name } (${ ambassadorData . email } ) submitted an ambassador application`
165304 ) ;
166305
306+ await sendToSlack ( ambassadorData , clientIP ) ;
307+
167308 return NextResponse . json ( {
168309 success : true ,
169310 message : "Ambassador application submitted successfully" ,
170311 } ) ;
171312 } catch ( error ) {
172313 logger . error (
173314 {
174- ip : getClientIP ( request ) ,
175- userAgent : request . headers . get ( "user-agent" ) || "unknown" ,
315+ ip : clientIP ,
316+ userAgent,
176317 error : error instanceof Error ? error . message : String ( error ) ,
318+ stack : error instanceof Error ? error . stack : undefined ,
177319 } ,
178- "Unknown error in ambassador form submission"
320+ "Error processing ambassador form submission"
179321 ) ;
180322
181323 return NextResponse . json (
0 commit comments