456 lines
14 KiB
TypeScript
456 lines
14 KiB
TypeScript
type Env = {
|
|
TURNSTILE_SECRET_KEY?: string;
|
|
RESEND_API_KEY?: string;
|
|
CONTACT_FROM_EMAIL?: string;
|
|
CONTACT_TO_EMAIL?: string;
|
|
};
|
|
|
|
const jsonResponse = (data: Record<string, unknown>, 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<string, string[]>();
|
|
|
|
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<string, string> = {
|
|
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<Env> = 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<Env> = 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,
|
|
);
|
|
}
|
|
};
|