From 464f300775ce4e5ad124e5338621470aed18d220 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:14:46 -0500 Subject: [PATCH] feat: add application forms for adopt, foster, volunteer, and surrender Each form checks orgConfig.applications to verify the type is enabled, validates required fields, inserts into the applications table, and sends email notification via sendApplicationNotification. Adoption form supports optional pet pre-selection via ?pet=slug query param. Surrender form auto-creates a pet record from submitted data. Co-Authored-By: Claude Sonnet 4.6 --- .../(public)/apply/adopt/+page.server.ts | 173 +++++ src/routes/(public)/apply/adopt/+page.svelte | 631 ++++++++++++++++++ .../(public)/apply/foster/+page.server.ts | 141 ++++ src/routes/(public)/apply/foster/+page.svelte | 582 ++++++++++++++++ .../(public)/apply/surrender/+page.server.ts | 158 +++++ .../(public)/apply/surrender/+page.svelte | 323 +++++++++ .../(public)/apply/volunteer/+page.server.ts | 96 +++ .../(public)/apply/volunteer/+page.svelte | 226 +++++++ 8 files changed, 2330 insertions(+) create mode 100644 src/routes/(public)/apply/adopt/+page.server.ts create mode 100644 src/routes/(public)/apply/adopt/+page.svelte create mode 100644 src/routes/(public)/apply/foster/+page.server.ts create mode 100644 src/routes/(public)/apply/foster/+page.svelte create mode 100644 src/routes/(public)/apply/surrender/+page.server.ts create mode 100644 src/routes/(public)/apply/surrender/+page.svelte create mode 100644 src/routes/(public)/apply/volunteer/+page.server.ts create mode 100644 src/routes/(public)/apply/volunteer/+page.svelte diff --git a/src/routes/(public)/apply/adopt/+page.server.ts b/src/routes/(public)/apply/adopt/+page.server.ts new file mode 100644 index 0000000..d93ab88 --- /dev/null +++ b/src/routes/(public)/apply/adopt/+page.server.ts @@ -0,0 +1,173 @@ +import type { PageServerLoad, Actions } from './$types'; +import { fail, redirect } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { applications, pets } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { sendApplicationNotification } from '$lib/server/email'; +import { loginLimiter } from '$lib/server/rate-limit'; + +export const load: PageServerLoad = async ({ parent, url }) => { + const parentData = await parent(); + + if (!parentData.orgConfig.applications.adoptionEnabled) { + throw redirect(302, '/'); + } + + const petSlug = url.searchParams.get('pet'); + let pet: { id: number; name: string; slug: string } | null = null; + + if (petSlug) { + const result = await db + .select({ id: pets.id, name: pets.name, slug: pets.slug }) + .from(pets) + .where(eq(pets.slug, petSlug)) + .limit(1); + + if (result.length > 0) { + pet = result[0]; + } + } + + return { ...parentData, pet }; +}; + +export const actions: Actions = { + default: async ({ request, getClientAddress, locals }) => { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress(); + if (!loginLimiter.check(`apply-adopt:${ip}`)) { + return fail(429, { error: 'Too many submissions. Please try again later.' }); + } + + const formData = await request.formData(); + + const firstName = formData.get('firstName')?.toString().trim() ?? ''; + const lastName = formData.get('lastName')?.toString().trim() ?? ''; + const applicantName = `${firstName} ${lastName}`.trim(); + const email = formData.get('email')?.toString().trim() ?? ''; + const phone = formData.get('phone')?.toString().trim() ?? ''; + const petIdRaw = formData.get('petId')?.toString() ?? ''; + const petId = petIdRaw ? parseInt(petIdRaw, 10) : null; + + const data: Record = {}; + const fieldNames = [ + 'petOfInterest', 'firstName', 'lastName', + 'address1', 'address2', 'city', 'state', 'zip', + 'ownRent', 'landlordInfo', + 'householdMembers', 'currentAnimals', 'otherPropertyPets', 'previousPets', + 'petsVaccinated', 'yardFenced', 'fenceHeight', + 'whereDuringDay', 'whereSleepNight', 'hoursAlone', 'activityLevel', + 'whyAdopt', 'howHeard', 'agreeLeash', + 'integratedBefore', 'twoWeekShutdown', + 'felony', 'felonyDetails', 'animalNeglect', + 'reference1Name', 'reference1Phone', 'reference1Relationship', + 'reference2Name', 'reference2Phone', 'reference2Relationship', + 'reference3Name', 'reference3Phone', 'reference3Relationship', + 'vetName', 'vetClinic', 'vetPhone', + 'landlordName', 'landlordPhone', + 'acknowledge', 'signature' + ]; + + for (const name of fieldNames) { + data[name] = formData.get(name)?.toString() ?? ''; + } + + data.dogType = formData.getAll('dogType').map((v) => v.toString()); + data.traits = formData.getAll('traits').map((v) => v.toString()); + + for (let i = 1; i <= 16; i++) { + data[`agreement${i}`] = formData.get(`agreement${i}`)?.toString() ?? ''; + } + + const errors: string[] = []; + if (!firstName) errors.push('First name is required.'); + if (!lastName) errors.push('Last name is required.'); + if (!email) errors.push('Email is required.'); + if (!phone) errors.push('Phone number is required.'); + if (!data.address1) errors.push('Address is required.'); + if (!data.city) errors.push('City is required.'); + if (!data.state) errors.push('State is required.'); + if (!data.zip) errors.push('ZIP code is required.'); + if (!data.ownRent) errors.push('Own/rent is required.'); + if (!data.householdMembers) errors.push('Household members is required.'); + if (!data.currentAnimals) errors.push('Current animals is required.'); + if (!data.previousPets) errors.push('Previous pets is required.'); + if (!data.petsVaccinated) errors.push('Vaccination status is required.'); + if (!data.yardFenced) errors.push('Yard fencing status is required.'); + if (!data.whereDuringDay) errors.push('Where animal stays during the day is required.'); + if (!data.whereSleepNight) errors.push('Where animal sleeps at night is required.'); + if (!data.hoursAlone) errors.push('Hours alone is required.'); + if (!data.activityLevel) errors.push('Activity level is required.'); + if (!data.whyAdopt) errors.push('Why you want to adopt is required.'); + if (!data.howHeard) errors.push('How you heard about us is required.'); + if (!data.agreeLeash) errors.push('Leash agreement is required.'); + if (!data.integratedBefore) errors.push('Integration experience is required.'); + if (!data.twoWeekShutdown) errors.push('2-week shutdown response is required.'); + if (!data.felony) errors.push('Felony question is required.'); + if (!data.animalNeglect) errors.push('Animal neglect question is required.'); + if (!data.reference1Name || !data.reference1Phone) errors.push('Reference 1 is required.'); + if (!data.reference2Name || !data.reference2Phone) errors.push('Reference 2 is required.'); + if (!data.reference3Name || !data.reference3Phone) errors.push('Reference 3 is required.'); + if (!data.vetName || !data.vetClinic || !data.vetPhone) errors.push('Veterinarian information is required.'); + if (!data.landlordName || !data.landlordPhone) errors.push('Landlord information is required (put N/A if you own).'); + if (data.acknowledge !== 'yes') errors.push('You must acknowledge the adoption agreement.'); + if (!data.signature) errors.push('Signature is required.'); + + for (let i = 1; i <= 16; i++) { + if (!data[`agreement${i}`]) { + errors.push(`Agreement item ${i} is required.`); + break; + } + } + + if (errors.length > 0) { + return fail(400, { + error: errors.join(' '), + firstName, + lastName, + applicantName, + email, + phone, + ...data + }); + } + + await db.insert(applications).values({ + type: 'adoption', + petId: petId && !isNaN(petId) ? petId : null, + applicantName, + applicantEmail: email, + applicantPhone: phone || null, + formData: data + }); + + const orgConfig = locals.orgConfig; + if (orgConfig.orgInfo.email) { + let petName: string | undefined; + if (petId && !isNaN(petId)) { + try { + const [petRecord] = await db + .select({ name: pets.name }) + .from(pets) + .where(eq(pets.id, petId)) + .limit(1); + petName = petRecord?.name; + } catch { + // ignore + } + } + try { + sendApplicationNotification( + orgConfig.orgInfo.name, + orgConfig.orgInfo.email, + applicantName, + 'adoption', + petName + ); + } catch { + // Email failure should not break form submission + } + } + + return { success: true }; + } +}; diff --git a/src/routes/(public)/apply/adopt/+page.svelte b/src/routes/(public)/apply/adopt/+page.svelte new file mode 100644 index 0000000..ddf1a06 --- /dev/null +++ b/src/routes/(public)/apply/adopt/+page.svelte @@ -0,0 +1,631 @@ + + + + Adoption Application — {data.orgConfig?.orgInfo?.name ?? 'Pet Rescue'} + + + +
+
+
+

Adoption Application

+

+ Thank you for considering adoption! Please fill out this application completely. + We will review it and get back to you as soon as possible. +

+
+ + {#if form?.success} +
+
+ + + +
+

Application Submitted!

+

+ Thank you for your adoption application. Our team will review it and reach out to you + within a few business days. If you have any questions in the meantime, please don't + hesitate to contact us. +

+ + Browse More Pets + +
+ {:else} +
+ {#if form?.error} +
+
+ {form.error} +
+
+ {/if} + + +
+

Pet of Interest

+ {#if data.pet} + + +

+ Applying for: {data.pet.name} +

+ {:else} +
+ + +
+ {/if} +
+ + +
+

Contact Information

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ Do you own your home or rent? * +
+ + +
+
+ {#if ownRent === 'rent'} +
+ + +
+ {/if} +
+
+ + +
+

Your Family & Home

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ Are your animals spayed/neutered and up to date on vaccines? * +
+ + +
+
+
+ Is your yard completely fenced in? * +
+ + +
+
+ {#if yardFenced === 'yes'} +
+ + +
+ {/if} +
+ + +
+
+ + +
+
+ + +
+
+ How would you describe your level of activity at home? * +
+ + + +
+
+
+
+ + +
+

Additional Information

+
+
+ + +
+
+ + +
+
+ What type of pet are you interested in? * +
+ {#each dogTypeOptions as option} + + {/each} +
+
+
+ What traits should your adopted pet have? +
+ {#each traitOptions as option} + + {/each} +
+
+
+ Do you agree to keep pets leashed at all times when outside of a fenced area? * +
+ + +
+
+
+ Have you integrated a new pet into your home before? * +
+ + +
+
+
+ Are you comfortable doing a "2 week shut down" with your new pet? * +

A 2-week shut down is a decompression period where you limit the new pet's environment and stimulation to help them adjust.

+
+ + +
+
+
+ Have you ever been charged with or convicted of a felony? * +
+ + +
+
+ {#if felony === 'yes'} +
+ + +
+ {/if} +
+ Have you ever been charged with neglect, abandonment, or mistreatment of an animal? * +
+ + +
+
+
+
+ + +
+

References

+

Please provide three personal references (non-relatives).

+
+ {#each [1, 2, 3] as num} +
+

Personal Reference {num} *

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ {/each} + +
+

Veterinarian *

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Landlord

+
+
+ + +
+
+ + +
+
+
+
+
+ + +
+

Adoption Agreement

+

Please read and respond to each item below.

+
+ {#each agreements as text, i} +
+

{text}

+
+ + +
+
+ {/each} + +
+

I acknowledge that my responses above indicate that I have read and understood each item in this adoption agreement.

+
+ + +
+
+
+
+ + +
+

Signature

+

+ My signature indicates that I have read and filled out this application truthfully and completely. Please sign below using your mouse or finger. +

+ +
+ +
+ +
+ + +
+ +
+
+ {/if} +
+
diff --git a/src/routes/(public)/apply/foster/+page.server.ts b/src/routes/(public)/apply/foster/+page.server.ts new file mode 100644 index 0000000..2bd1984 --- /dev/null +++ b/src/routes/(public)/apply/foster/+page.server.ts @@ -0,0 +1,141 @@ +import type { PageServerLoad, Actions } from './$types'; +import { fail, redirect } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { applications } from '$lib/server/schema'; +import { sendApplicationNotification } from '$lib/server/email'; +import { loginLimiter } from '$lib/server/rate-limit'; + +export const load: PageServerLoad = async ({ parent }) => { + const parentData = await parent(); + + if (!parentData.orgConfig.applications.fosterEnabled) { + throw redirect(302, '/'); + } + + return parentData; +}; + +export const actions: Actions = { + default: async ({ request, getClientAddress, locals }) => { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress(); + if (!loginLimiter.check(`apply-foster:${ip}`)) { + return fail(429, { error: 'Too many submissions. Please try again later.' }); + } + + const formData = await request.formData(); + + const firstName = formData.get('firstName')?.toString().trim() ?? ''; + const lastName = formData.get('lastName')?.toString().trim() ?? ''; + const applicantName = `${firstName} ${lastName}`.trim(); + const email = formData.get('email')?.toString().trim() ?? ''; + const phone = formData.get('phone')?.toString().trim() ?? ''; + + const data: Record = {}; + const fieldNames = [ + 'firstName', 'lastName', + 'address1', 'address2', 'city', 'state', 'zip', + 'ownRent', 'landlordInfo', + 'householdMembers', 'currentAnimals', 'otherPropertyPets', 'previousPets', + 'petsVaccinated', 'yardFenced', 'fenceHeight', + 'whereDuringDay', 'whereSleepNight', 'hoursAlone', 'activityLevel', + 'whyFoster', 'howHeard', 'agreeLeash', + 'integratedBefore', 'twoWeekShutdown', + 'felony', 'felonyDetails', 'animalNeglect', + 'reference1Name', 'reference1Phone', 'reference1Relationship', + 'reference2Name', 'reference2Phone', 'reference2Relationship', + 'reference3Name', 'reference3Phone', 'reference3Relationship', + 'vetName', 'vetClinic', 'vetPhone', + 'landlordName', 'landlordPhone', + 'acknowledge', 'signature' + ]; + + for (const name of fieldNames) { + data[name] = formData.get(name)?.toString() ?? ''; + } + + data.petType = formData.getAll('petType').map((v) => v.toString()); + data.traits = formData.getAll('traits').map((v) => v.toString()); + + for (let i = 1; i <= 20; i++) { + data[`agreement${i}`] = formData.get(`agreement${i}`)?.toString() ?? ''; + } + + const errors: string[] = []; + if (!firstName) errors.push('First name is required.'); + if (!lastName) errors.push('Last name is required.'); + if (!email) errors.push('Email is required.'); + if (!phone) errors.push('Phone number is required.'); + if (!data.address1) errors.push('Address is required.'); + if (!data.city) errors.push('City is required.'); + if (!data.state) errors.push('State is required.'); + if (!data.zip) errors.push('ZIP code is required.'); + if (!data.ownRent) errors.push('Own/rent is required.'); + if (!data.householdMembers) errors.push('Household members is required.'); + if (!data.currentAnimals) errors.push('Current animals is required.'); + if (!data.previousPets) errors.push('Previous pets is required.'); + if (!data.petsVaccinated) errors.push('Vaccination status is required.'); + if (!data.yardFenced) errors.push('Yard fencing status is required.'); + if (!data.whereDuringDay) errors.push('Where animal stays during the day is required.'); + if (!data.whereSleepNight) errors.push('Where animal sleeps at night is required.'); + if (!data.hoursAlone) errors.push('Hours alone is required.'); + if (!data.activityLevel) errors.push('Activity level is required.'); + if (!data.whyFoster) errors.push('Why you want to foster is required.'); + if (!data.howHeard) errors.push('How you heard about us is required.'); + if (!data.agreeLeash) errors.push('Leash agreement is required.'); + if (!data.integratedBefore) errors.push('Integration experience is required.'); + if (!data.twoWeekShutdown) errors.push('2-week shutdown response is required.'); + if (!data.felony) errors.push('Felony question is required.'); + if (!data.animalNeglect) errors.push('Animal neglect question is required.'); + if (!data.reference1Name || !data.reference1Phone) errors.push('Reference 1 is required.'); + if (!data.reference2Name || !data.reference2Phone) errors.push('Reference 2 is required.'); + if (!data.reference3Name || !data.reference3Phone) errors.push('Reference 3 is required.'); + if (!data.vetName || !data.vetClinic || !data.vetPhone) errors.push('Veterinarian information is required.'); + if (!data.landlordName || !data.landlordPhone) errors.push('Landlord information is required (put N/A if you own).'); + if (data.acknowledge !== 'yes') errors.push('You must acknowledge the foster agreement.'); + if (!data.signature) errors.push('Signature is required.'); + + for (let i = 1; i <= 20; i++) { + if (!data[`agreement${i}`]) { + errors.push(`Agreement item ${i} is required.`); + break; + } + } + + if (errors.length > 0) { + return fail(400, { + error: errors.join(' '), + firstName, + lastName, + applicantName, + email, + phone, + ...data + }); + } + + await db.insert(applications).values({ + type: 'foster', + petId: null, + applicantName, + applicantEmail: email, + applicantPhone: phone || null, + formData: data + }); + + const orgConfig = locals.orgConfig; + if (orgConfig.orgInfo.email) { + try { + sendApplicationNotification( + orgConfig.orgInfo.name, + orgConfig.orgInfo.email, + applicantName, + 'foster' + ); + } catch { + // Email failure should not break form submission + } + } + + return { success: true }; + } +}; diff --git a/src/routes/(public)/apply/foster/+page.svelte b/src/routes/(public)/apply/foster/+page.svelte new file mode 100644 index 0000000..5d9268e --- /dev/null +++ b/src/routes/(public)/apply/foster/+page.svelte @@ -0,0 +1,582 @@ + + + + Foster Application — {data.orgConfig?.orgInfo?.name ?? 'Pet Rescue'} + + + +
+
+
+

Foster Application

+

+ Fostering saves lives! We provide all supplies and cover vet expenses. + Please fill out this application completely and we will get back to you as soon as possible. +

+
+ + {#if form?.success} +
+
+ + + +
+

Application Submitted!

+

+ Thank you for your interest in fostering! Our foster coordinator will review your + application and reach out to you soon. In the meantime, feel free to browse our + pets who need foster homes. +

+ + Meet Our Pets + +
+ {:else} +
+ {#if form?.error} +
+
+ {form.error} +
+
+ {/if} + + +
+

Contact Information

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ Do you own your home or rent? * +
+ + +
+
+ {#if ownRent === 'rent'} +
+ + +
+ {/if} +
+
+ + +
+

Your Family & Home

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ Are your animals spayed/neutered and up to date on vaccines? * +
+ + +
+
+
+ Is your yard completely fenced in? * +
+ + +
+
+ {#if yardFenced === 'yes'} +
+ + +
+ {/if} +
+ + +
+
+ + +
+
+ + +
+
+ How would you describe your level of activity at home? * +
+ + + +
+
+
+
+ + +
+

Additional Information

+
+
+ + +
+
+ + +
+
+ What type of pet are you interested in fostering? * +
+ {#each petTypeOptions as option} + + {/each} +
+
+
+ What traits should your foster pet have? +
+ {#each traitOptions as option} + + {/each} +
+
+
+ Do you agree to keep pets leashed at all times when outside of a fenced area? * +
+ + +
+
+
+ Have you integrated a new pet into your home before? * +
+ + +
+
+
+ Are you comfortable doing a "2 week shut down" with your new foster pet? * +

A 2-week shut down is a decompression period where you limit the new pet's environment and stimulation to help them adjust.

+
+ + +
+
+
+ Have you ever been charged with or convicted of a felony? * +
+ + +
+
+ {#if felony === 'yes'} +
+ + +
+ {/if} +
+ Have you ever been charged with neglect, abandonment, or mistreatment of an animal? * +
+ + +
+
+
+
+ + +
+

References

+

Please provide three personal references (non-relatives).

+
+ {#each [1, 2, 3] as num} +
+

Personal Reference {num} *

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ {/each} + +
+

Veterinarian *

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Landlord

+
+
+ + +
+
+ + +
+
+
+
+
+ + +
+

Foster Agreement

+

Please read and respond to each item below.

+
+ {#each agreements as text, i} +
+

{text}

+
+ + +
+
+ {/each} + +
+

I acknowledge that my responses above indicate that I have read and understood each item in this foster agreement.

+
+ + +
+
+
+
+ + +
+

Signature

+

+ My signature indicates that I have read and filled out this application truthfully and completely. Please sign below using your mouse or finger. +

+ +
+ +
+ +
+ + +
+ +
+
+ {/if} +
+
diff --git a/src/routes/(public)/apply/surrender/+page.server.ts b/src/routes/(public)/apply/surrender/+page.server.ts new file mode 100644 index 0000000..fa73003 --- /dev/null +++ b/src/routes/(public)/apply/surrender/+page.server.ts @@ -0,0 +1,158 @@ +import type { PageServerLoad, Actions } from './$types'; +import { fail, redirect } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { applications, pets } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { sendApplicationNotification } from '$lib/server/email'; +import { loginLimiter } from '$lib/server/rate-limit'; +import { slugify } from '$lib/slugify'; + +export const load: PageServerLoad = async ({ parent }) => { + const parentData = await parent(); + + if (!parentData.orgConfig.applications.surrenderEnabled) { + throw redirect(302, '/'); + } + + return parentData; +}; + +export const actions: Actions = { + default: async ({ request, getClientAddress, locals }) => { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress(); + if (!loginLimiter.check(`apply-surrender:${ip}`)) { + return fail(429, { error: 'Too many submissions. Please try again later.' }); + } + + const formData = await request.formData(); + + const applicantName = formData.get('applicantName')?.toString().trim() ?? ''; + const email = formData.get('email')?.toString().trim() ?? ''; + const phone = formData.get('phone')?.toString().trim() ?? ''; + + const data: Record = {}; + const fieldNames = [ + 'street', 'city', 'state', 'zip', + 'petName', 'species', 'petBreed', 'petAge', 'petSex', 'petWeight', + 'spayedNeutered', 'microchipped', + 'vaccinationsCurrent', 'medicalConditions', 'medications', 'vetName', + 'goodWithKids', 'goodWithDogs', 'goodWithCats', + 'houseTrained', 'crateTrained', 'leashTrained', + 'aggressionOrAnxiety', + 'reasonForSurrender', 'timeline' + ]; + + for (const name of fieldNames) { + data[name] = formData.get(name)?.toString() ?? ''; + } + + const errors: string[] = []; + if (!applicantName) errors.push('Your name is required.'); + if (!email) errors.push('Email is required.'); + if (!phone) errors.push('Phone number is required.'); + if (!data.petName) errors.push('Pet name is required.'); + if (!data.species) errors.push('Species is required.'); + if (!data.petBreed) errors.push('Breed is required.'); + if (!data.petAge) errors.push('Pet age is required.'); + if (!data.petSex) errors.push('Pet sex is required.'); + if (!data.reasonForSurrender) errors.push('Reason for surrender is required.'); + if (!data.timeline) errors.push('Timeline is required.'); + + if (errors.length > 0) { + return fail(400, { + error: errors.join(' '), + applicantName, + email, + phone, + ...data + }); + } + + // Insert the surrender application first + const [appResult] = await db.insert(applications).values({ + type: 'surrender', + petId: null, + applicantName, + applicantEmail: email, + applicantPhone: phone || null, + formData: data + }).$returningId(); + + // Auto-create a pet record + const petName = String(data.petName).trim(); + let slug = slugify(petName); + + const [existing] = await db + .select({ id: pets.id }) + .from(pets) + .where(eq(pets.slug, slug)) + .limit(1); + if (existing) { + slug = `${slug}-${Date.now()}`; + } + + const rawSex = String(data.petSex).toLowerCase().trim(); + const sex: 'male' | 'female' | 'unknown' = + rawSex === 'male' || rawSex === 'm' ? 'male' : + rawSex === 'female' || rawSex === 'f' ? 'female' : + 'unknown'; + + const rawSpecies = String(data.species).toLowerCase().trim(); + const species: 'dog' | 'cat' | 'other' = + rawSpecies === 'dog' ? 'dog' : + rawSpecies === 'cat' ? 'cat' : + 'other'; + + const notesParts: string[] = []; + if (data.medicalConditions) notesParts.push(`Medical conditions: ${data.medicalConditions}`); + if (data.medications) notesParts.push(`Medications: ${data.medications}`); + if (data.vaccinationsCurrent) notesParts.push(`Vaccinations current: ${data.vaccinationsCurrent}`); + if (data.spayedNeutered) notesParts.push(`Spayed/Neutered: ${data.spayedNeutered}`); + if (data.aggressionOrAnxiety) notesParts.push(`Aggression/Anxiety: ${data.aggressionOrAnxiety}`); + if (data.goodWithKids) notesParts.push(`Good with kids: ${data.goodWithKids}`); + if (data.goodWithDogs) notesParts.push(`Good with dogs: ${data.goodWithDogs}`); + if (data.goodWithCats) notesParts.push(`Good with cats: ${data.goodWithCats}`); + if (data.houseTrained) notesParts.push(`House trained: ${data.houseTrained}`); + if (data.crateTrained) notesParts.push(`Crate trained: ${data.crateTrained}`); + if (data.leashTrained) notesParts.push(`Leash trained: ${data.leashTrained}`); + if (data.reasonForSurrender) notesParts.push(`Reason for surrender: ${data.reasonForSurrender}`); + if (data.vetName) notesParts.push(`Previous vet: ${data.vetName}`); + const medicalNotes = notesParts.length > 0 ? notesParts.join('\n') : null; + + const [petResult] = await db.insert(pets).values({ + name: petName, + species, + breed: String(data.petBreed || '') || null, + age: String(data.petAge || '') || null, + sex, + weight: String(data.petWeight || '') || null, + status: 'pending', + slug, + intakeDate: new Date(), + adopterName: applicantName, + medicalNotes + }).$returningId(); + + // Link the pet to the application + await db + .update(applications) + .set({ petId: petResult.id }) + .where(eq(applications.id, appResult.id)); + + const orgConfig = locals.orgConfig; + if (orgConfig.orgInfo.email) { + try { + sendApplicationNotification( + orgConfig.orgInfo.name, + orgConfig.orgInfo.email, + applicantName, + 'surrender' + ); + } catch { + // Email failure should not break form submission + } + } + + return { success: true }; + } +}; diff --git a/src/routes/(public)/apply/surrender/+page.svelte b/src/routes/(public)/apply/surrender/+page.svelte new file mode 100644 index 0000000..bcedc88 --- /dev/null +++ b/src/routes/(public)/apply/surrender/+page.svelte @@ -0,0 +1,323 @@ + + + + Surrender a Pet — {data.orgConfig?.orgInfo?.name ?? 'Pet Rescue'} + + + +
+
+
+

Surrender a Pet

+

+ We understand that sometimes circumstances change and you may no longer be able to + care for your pet. Please complete this form so we can learn about your pet and + determine if we can help. +

+
+ + {#if form?.success} +
+
+ + + +
+

Form Submitted

+

+ Thank you for reaching out. We know this is a difficult decision. Our team will + review your submission and contact you as soon as possible. Please note that due + to limited capacity, we may not be able to accept every surrender request. +

+ + Back to Home + +
+ {:else} +
+ {#if form?.error} +
+
+ {form.error} +
+
+ {/if} + + +
+

Your Information

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+

Pet Information

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

Medical History

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

Behavior

+
+ {#each [ + { id: 'goodWithKids', label: 'Good with kids?' }, + { id: 'goodWithDogs', label: 'Good with dogs?' }, + { id: 'goodWithCats', label: 'Good with cats?' } + ] as field} +
+ + +
+ {/each} +
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+

Reason for Surrender

+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ {/if} +
+
diff --git a/src/routes/(public)/apply/volunteer/+page.server.ts b/src/routes/(public)/apply/volunteer/+page.server.ts new file mode 100644 index 0000000..5374fa7 --- /dev/null +++ b/src/routes/(public)/apply/volunteer/+page.server.ts @@ -0,0 +1,96 @@ +import type { PageServerLoad, Actions } from './$types'; +import { fail, redirect } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { applications } from '$lib/server/schema'; +import { sendApplicationNotification } from '$lib/server/email'; +import { loginLimiter } from '$lib/server/rate-limit'; + +export const load: PageServerLoad = async ({ parent }) => { + const parentData = await parent(); + + if (!parentData.orgConfig.applications.volunteerEnabled) { + throw redirect(302, '/'); + } + + return parentData; +}; + +export const actions: Actions = { + default: async ({ request, getClientAddress, locals }) => { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress(); + if (!loginLimiter.check(`apply-volunteer:${ip}`)) { + return fail(429, { error: 'Too many submissions. Please try again later.' }); + } + + const formData = await request.formData(); + + const applicantName = formData.get('applicantName')?.toString().trim() ?? ''; + const email = formData.get('email')?.toString().trim() ?? ''; + const phone = formData.get('phone')?.toString().trim() ?? ''; + + const data: Record = {}; + + const fieldNames = [ + 'availabilityDays', 'hoursPerWeek', + 'dogExperience', 'rescueExperience', 'whyInterested' + ]; + + for (const name of fieldNames) { + data[name] = formData.get(name)?.toString() ?? ''; + } + + const interestOptions = [ + 'eventHelp', 'transport', 'photography', 'socialMedia', + 'adminDataEntry', 'fundraising', 'fosterMentoring' + ]; + const interests: string[] = []; + for (const opt of interestOptions) { + if (formData.get(opt)?.toString() === 'on') { + interests.push(opt); + } + } + data.interests = interests; + + const errors: string[] = []; + if (!applicantName) errors.push('Full name is required.'); + if (!email) errors.push('Email is required.'); + if (!phone) errors.push('Phone number is required.'); + if (!data.whyInterested) errors.push('Please tell us why you are interested in volunteering.'); + + if (errors.length > 0) { + return fail(400, { + error: errors.join(' '), + applicantName, + email, + phone, + ...data, + interests + }); + } + + await db.insert(applications).values({ + type: 'volunteer', + petId: null, + applicantName, + applicantEmail: email, + applicantPhone: phone || null, + formData: data + }); + + const orgConfig = locals.orgConfig; + if (orgConfig.orgInfo.email) { + try { + sendApplicationNotification( + orgConfig.orgInfo.name, + orgConfig.orgInfo.email, + applicantName, + 'volunteer' + ); + } catch { + // Email failure should not break form submission + } + } + + return { success: true }; + } +}; diff --git a/src/routes/(public)/apply/volunteer/+page.svelte b/src/routes/(public)/apply/volunteer/+page.svelte new file mode 100644 index 0000000..04ef7a5 --- /dev/null +++ b/src/routes/(public)/apply/volunteer/+page.svelte @@ -0,0 +1,226 @@ + + + + Volunteer Application — {data.orgConfig?.orgInfo?.name ?? 'Pet Rescue'} + + + +
+
+
+

Volunteer Application

+

+ Our volunteers are the backbone of our rescue. Whether you have a few hours a week + or can help at events, we would love to have you on our team! +

+
+ + {#if form?.success} +
+
+ + + +
+

Thank You for Volunteering!

+

+ We appreciate your willingness to help! Our volunteer manager will review your + application and be in touch soon with next steps. +

+ + Back to Home + +
+ {:else} +
+ {#if form?.error} +
+
+ {form.error} +
+
+ {/if} + + +
+

Personal Information

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

Availability

+
+
+ + +
+
+ + +
+
+
+ + +
+

Areas of Interest

+

Check all that apply.

+
+ {#each [ + { name: 'eventHelp', label: 'Event help' }, + { name: 'transport', label: 'Transport (vet visits, meetups)' }, + { name: 'photography', label: 'Photography' }, + { name: 'socialMedia', label: 'Social media' }, + { name: 'adminDataEntry', label: 'Admin / data entry' }, + { name: 'fundraising', label: 'Fundraising' }, + { name: 'fosterMentoring', label: 'Foster mentoring' } + ] as interest} + + {/each} +
+
+ + +
+

Experience

+
+
+ + +
+
+ + +
+
+
+ + +
+

Why Are You Interested?

+
+ + +
+
+ + +
+ +
+
+ {/if} +
+