type Env = { TURNSTILE_SECRET_KEY?: string; RESEND_API_KEY?: string; CONTACT_FROM_EMAIL?: string; CONTACT_TO_EMAIL?: string; }; const jsonResponse = (data: Record, status = 200) => new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" }, }); const fetchWithTimeout = async ( input: RequestInfo | URL, init: RequestInit, timeoutMs: number, label: string, ) => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(input, { ...init, signal: controller.signal }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; throw new Error(`${label} request failed: ${message}`); } finally { clearTimeout(timeout); } }; const readTextField = (value: string | null) => (value ?? "").trim(); const toTitleCase = (value: string) => value .replace(/_/g, " ") .replace(/\b\w/g, (char) => char.toUpperCase()); const parseFormFields = async (request: Request) => { const contentType = request.headers.get("content-type") ?? ""; const data = new Map(); const appendValue = (key: string, value: string | null) => { const normalized = readTextField(value); if (!normalized) return; data.set(key, [...(data.get(key) ?? []), normalized]); }; if (contentType.includes("application/x-www-form-urlencoded")) { const bodyText = await request.text(); const params = new URLSearchParams(bodyText); for (const [key, value] of params.entries()) { appendValue(key, value); } return data; } const formData = await request.formData(); for (const [key, value] of formData.entries()) { appendValue(key, typeof value === "string" ? value : value.name); } return data; }; const formatValue = (values: string[] | undefined) => (values ?? []).join(", "); const labelMap: Record = { filled_by: "Who is filling in the form", participant_first_names: "Participant first name(s)", participant_last_name: "Participant last name", preferred_name: "Preferred name or nickname", preferred_pronouns: "Preferred pronouns", date_of_birth: "Date of birth", sex: "Sex", languages_spoken: "Languages used every day", address_line_1: "Address line 1", address_line_2: "Address line 2", postcode: "Postcode", telephone: "Telephone", mobile: "Mobile", email: "Email", activity_experience: "Previous activity experience", allergies: "Allergies", triggers: "Known triggers", simple_instructions: "Would prefer simple instructions", learning_disability: "Learning disability", neurodiverse: "Neurodiverse", sensitive_to_crowds: "Sensitive to strangers or crowds", sensory_sensitivities: "Sensitive to light, temperature, noise or clothing", anxiety_low_mood: "Anxiety, depression or low mood", panic_attacks: "Panic attacks", speech_difficulties: "Difficulty with speech or being understood", communication_system: "Uses a communication system", hearing_aid: "Hearing aid", cochlear_implant: "Cochlear implant", bsl: "Uses BSL", blind: "Blind", partially_sighted: "Partially sighted", dexterity_difficulties: "Dexterity difficulties", balance_problems: "Problems with balance", weight_through_feet: "Can take weight through feet", help_walking: "Needs help with walking", steps_mounting_block: "Can walk up a few steps", wheelchair_user: "Wheelchair user", walking_aids: "Uses walking aids or supports", prosthetic_limb: "Wears a prosthetic limb", long_term_pain: "Long term pain", breathing_stamina: "Breathing or stamina difficulties", epilepsy: "Epilepsy or controlled by medication", condition_details: "Disability or medical condition details", participant_height: "Height", height_unit: "Height unit", participant_weight: "Weight", weight_unit: "Weight unit", interested_in_competing: "Interested in competing", been_classified: "Has been classified", classification_details: "Classification", classification_date: "Classification date", emergency_contact_name: "Emergency contact name and relationship", emergency_contact_phone: "Emergency contact number", emergency_contact_consent: "Emergency contact consent", declaration_confirm: "Declaration confirmation", provide_medical_details: "Agrees to provide further medical details if requested", notify_changes: "Will notify RDA of changes", risk_acknowledgement: "Accepts horse-related risk acknowledgement", vaulting_acknowledgement: "Vaulting risk acknowledgement", photos_consent: "Photo and video consent", newsletter_consent: "Newsletter consent", declaration_signature: "Signature name", declaration_date: "Declaration date", guardian_name: "Parent or legal guardian name", guardian_relationship: "Relationship to applicant", guardian_address_line_1: "Guardian address line 1", guardian_address_line_2: "Guardian address line 2", guardian_postcode: "Guardian postcode", guardian_telephone: "Guardian telephone", guardian_mobile: "Guardian mobile", }; const requiredFields = [ "filled_by", "participant_first_names", "participant_last_name", "date_of_birth", "address_line_1", "postcode", "email", "emergency_contact_name", "emergency_contact_phone", "declaration_confirm", "photos_consent", ]; const sectionOrder = [ { title: "Applicant", fields: [ "filled_by", "participant_first_names", "participant_last_name", "preferred_name", "preferred_pronouns", "date_of_birth", "sex", "languages_spoken", "address_line_1", "address_line_2", "postcode", "telephone", "mobile", "email", "activity_experience", ], }, { title: "Specific Information", fields: [ "allergies", "triggers", "simple_instructions", "learning_disability", "neurodiverse", "sensitive_to_crowds", "sensory_sensitivities", "anxiety_low_mood", "panic_attacks", "speech_difficulties", "communication_system", "hearing_aid", "cochlear_implant", "bsl", "blind", "partially_sighted", "dexterity_difficulties", "balance_problems", "weight_through_feet", "help_walking", "steps_mounting_block", "wheelchair_user", "walking_aids", "prosthetic_limb", "long_term_pain", "breathing_stamina", "epilepsy", "condition_details", "participant_height", "height_unit", "participant_weight", "weight_unit", ], }, { title: "Additional Information", fields: [ "interested_in_competing", "been_classified", "classification_details", "classification_date", ], }, { title: "Emergency Contact", fields: [ "emergency_contact_name", "emergency_contact_phone", "emergency_contact_consent", ], }, { title: "Declaration", fields: [ "declaration_confirm", "provide_medical_details", "notify_changes", "risk_acknowledgement", "vaulting_acknowledgement", "photos_consent", "newsletter_consent", "declaration_signature", "declaration_date", ], }, { title: "Parent or Legal Guardian", fields: [ "guardian_name", "guardian_relationship", "guardian_address_line_1", "guardian_address_line_2", "guardian_postcode", "guardian_telephone", "guardian_mobile", ], }, ]; const handlePost: PagesFunction = async ({ request, env }) => { const fields = await parseFormFields(request); const missingRequired = requiredFields.filter( (field) => !formatValue(fields.get(field)), ); if (missingRequired.length > 0) { return jsonResponse( { ok: false, error: "Missing required fields.", details: missingRequired.map((field) => labelMap[field] ?? field).join(", "), }, 400, ); } if (!env.TURNSTILE_SECRET_KEY) { return jsonResponse( { ok: false, error: "Turnstile secret key not configured." }, 500, ); } const turnstileToken = formatValue(fields.get("cf-turnstile-response")); if (!turnstileToken) { return jsonResponse( { ok: false, error: "Turnstile verification failed." }, 400, ); } const verifyBody = new URLSearchParams({ secret: env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: request.headers.get("CF-Connecting-IP") ?? "", }); const verifyResponse = await fetchWithTimeout( "https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: verifyBody, }, 8000, "Turnstile", ); const verifyResult = (await verifyResponse.json()) as { success?: boolean; "error-codes"?: string[]; messages?: string[]; hostname?: string; action?: string; }; if (!verifyResult.success) { const details = [ verifyResult["error-codes"]?.length ? `codes=${verifyResult["error-codes"].join(",")}` : "", verifyResult.messages?.length ? `messages=${verifyResult.messages.join(",")}` : "", verifyResult.hostname ? `hostname=${verifyResult.hostname}` : "", verifyResult.action ? `action=${verifyResult.action}` : "", ] .filter(Boolean) .join(" | ") .slice(0, 500); return jsonResponse( { ok: false, error: "Turnstile verification failed.", details: details || undefined, }, 400, ); } if (!env.RESEND_API_KEY) { return jsonResponse( { ok: false, error: "Email provider not configured." }, 500, ); } const fromEmail = env.CONTACT_FROM_EMAIL; if (!fromEmail) { return jsonResponse( { ok: false, error: "Sender email not configured." }, 500, ); } const toEmail = env.CONTACT_TO_EMAIL ?? "calum@muir.in"; const applicantName = [ formatValue(fields.get("participant_first_names")), formatValue(fields.get("participant_last_name")), ] .filter(Boolean) .join(" "); const bodyText = sectionOrder .map(({ title, fields: sectionFields }) => { const lines = sectionFields .map((field) => { const value = formatValue(fields.get(field)); if (!value) return null; return `${labelMap[field] ?? toTitleCase(field)}: ${value}`; }) .filter(Boolean) .join("\n"); return lines ? `${title}\n${lines}` : ""; }) .filter(Boolean) .join("\n\n"); const extraFields = [...fields.keys()].filter( (field) => field !== "cf-turnstile-response" && !sectionOrder.some((section) => section.fields.includes(field)), ); const extraText = extraFields .map((field) => { const value = formatValue(fields.get(field)); return value ? `${labelMap[field] ?? toTitleCase(field)}: ${value}` : ""; }) .filter(Boolean) .join("\n"); const emailPayload = { from: fromEmail, to: [toEmail], reply_to: formatValue(fields.get("email")) || undefined, subject: `New participant application${applicantName ? `: ${applicantName}` : ""}`, text: [bodyText, extraText ? `Additional Fields\n${extraText}` : ""] .filter(Boolean) .join("\n\n"), }; const response = await fetchWithTimeout( "https://api.resend.com/emails", { method: "POST", headers: { Authorization: `Bearer ${env.RESEND_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(emailPayload), }, 8000, "Resend", ); if (!response.ok) { const contentType = response.headers.get("content-type") ?? ""; const errorText = contentType.includes("application/json") ? JSON.stringify(await response.json().catch(() => ({}))) : await response.text(); const errorCode = response.headers.get("x-ms-error-code"); const details = [ `${response.status} ${response.statusText}`.trim(), errorCode ? `code=${errorCode}` : "", errorText.trim(), ] .filter(Boolean) .join(" | ") .slice(0, 500); return jsonResponse( { ok: false, error: "Failed to send email.", details, }, 502, ); } return jsonResponse({ ok: true }); }; export const onRequest: PagesFunction = async (context) => { try { if (context.request.method !== "POST") { return jsonResponse( { ok: false, error: "Method not allowed." }, 405, ); } return await handlePost(context); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return jsonResponse( { ok: false, error: "Unhandled exception.", details: message }, 500, ); } };