rda-v3/functions/api/volunteer-application.ts
2026-04-25 20:37:20 +00:00

435 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> = {
group_name: "RDA Group Name",
charity_number: "Charity Number",
group_contact_name: "Group Contact Name",
group_contact_address: "Group Contact Address",
group_contact_email: "Group Contact Email",
group_contact_phone: "Group Contact Telephone",
volunteer_first_names: "Volunteer first name(s)",
volunteer_last_name: "Volunteer 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",
equine_experience: "Previous experience with equines",
disability_experience: "Experience volunteering or working with disabled people",
skills_qualifications: "Other skills and professional qualifications",
placement_considerations: "Placement considerations or support needs",
emergency_contact_name: "Emergency contact full name",
emergency_contact_relationship: "Emergency contact relationship",
emergency_contact_phone: "Emergency contact telephone number",
emergency_contact_consent: "Emergency contact consent",
reference_1_name: "Reference 1 full name",
reference_1_address: "Reference 1 address",
reference_1_email: "Reference 1 email",
reference_1_phone: "Reference 1 phone",
reference_2_name: "Reference 2 full name",
reference_2_address: "Reference 2 address",
reference_2_email: "Reference 2 email",
reference_2_phone: "Reference 2 phone",
declaration_truth: "Declaration of truth and accuracy",
declaration_notify_changes: "Will notify RDA of any changes",
declaration_risk: "Accepts activity risk and precautions",
declaration_codes: "Will adhere to the RDA Codes of Conduct",
declaration_horses: "Understands horses and ponies are unpredictable",
declaration_liability: "Accepts liability statement",
disclosure_consent: "Enhanced disclosure and safeguarding consent",
authority_checks: "Accepts authority and police record checks",
conviction_reporting: "Understands duty to report convictions involving children",
photos_consent: "Photo and video consent",
declaration_signature: "Signature name",
declaration_role: "Signer role",
declaration_date: "Declaration date",
guardian_name: "Parent or guardian name",
guardian_relationship: "Relationship to volunteer",
guardian_address_line_1: "Parent or guardian address line 1",
guardian_address_line_2: "Parent or guardian address line 2",
guardian_postcode: "Parent or guardian postcode",
guardian_telephone: "Parent or guardian telephone",
guardian_mobile: "Parent or guardian mobile",
};
const requiredFields = [
"volunteer_first_names",
"volunteer_last_name",
"date_of_birth",
"address_line_1",
"postcode",
"email",
"emergency_contact_name",
"emergency_contact_relationship",
"emergency_contact_phone",
"reference_1_name",
"reference_1_email",
"reference_2_name",
"reference_2_email",
"declaration_truth",
"photos_consent",
];
const sectionOrder = [
{
title: "Group Details",
fields: [
"group_name",
"charity_number",
"group_contact_name",
"group_contact_address",
"group_contact_email",
"group_contact_phone",
],
},
{
title: "Volunteer Details",
fields: [
"volunteer_first_names",
"volunteer_last_name",
"preferred_name",
"preferred_pronouns",
"date_of_birth",
"sex",
"languages_spoken",
"address_line_1",
"address_line_2",
"postcode",
"telephone",
"mobile",
"email",
],
},
{
title: "Specific Information",
fields: [
"equine_experience",
"disability_experience",
"skills_qualifications",
"placement_considerations",
],
},
{
title: "Emergency Contact",
fields: [
"emergency_contact_name",
"emergency_contact_relationship",
"emergency_contact_phone",
"emergency_contact_consent",
],
},
{
title: "References",
fields: [
"reference_1_name",
"reference_1_address",
"reference_1_email",
"reference_1_phone",
"reference_2_name",
"reference_2_address",
"reference_2_email",
"reference_2_phone",
],
},
{
title: "Declaration",
fields: [
"declaration_truth",
"declaration_notify_changes",
"declaration_risk",
"declaration_codes",
"declaration_horses",
"declaration_liability",
"disclosure_consent",
"authority_checks",
"conviction_reporting",
"photos_consent",
"declaration_signature",
"declaration_role",
"declaration_date",
],
},
{
title: "Parent or 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 volunteerName = [
formatValue(fields.get("volunteer_first_names")),
formatValue(fields.get("volunteer_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 volunteer application${volunteerName ? `: ${volunteerName}` : ""}`,
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,
);
}
};