Skip to content

Commit 7734695

Browse files
committed
rewrite ambassadors
1 parent e5e80b3 commit 7734695

File tree

5 files changed

+626
-304
lines changed

5 files changed

+626
-304
lines changed

apps/docs/app/api/ambassador/submit/route.ts

Lines changed: 221 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { createLogger } from "@databuddy/shared/logger";
22
import { type NextRequest, NextResponse } from "next/server";
3-
import { formRateLimit } from "@/lib/rate-limit";
43

54
const 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

711
type 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

1826
function 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

97258
export 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

Comments
 (0)