From 8d86f9f3b34d948e4c51a0a69dc42a4b9145e606 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:54:13 -0500 Subject: [PATCH] Add API routes, SEO endpoints, and friendly error page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/health — liveness probe returning status + timestamp - POST /api/newsletter — subscribe with onDuplicateKeyUpdate - POST /api/stripe-webhook — handles checkout.session.completed for adoption fees and donations; verifies signature via constructWebhookEvent - GET /api/worker/jobs — fetch next pending AI job (Bearer token auth) - POST /api/worker/jobs/[id] — report job completion or failure - GET /robots.txt — disallows admin/foster/api/login/setup paths - GET /sitemap.xml — static pages + all published available pet pages - +error.svelte — friendly error page with status-appropriate messaging Co-Authored-By: Claude Sonnet 4.6 --- src/routes/+error.svelte | 89 +++++++++++++++++++++ src/routes/api/health/+server.ts | 6 ++ src/routes/api/newsletter/+server.ts | 34 ++++++++ src/routes/api/stripe-webhook/+server.ts | 91 ++++++++++++++++++++++ src/routes/api/worker/jobs/+server.ts | 34 ++++++++ src/routes/api/worker/jobs/[id]/+server.ts | 48 ++++++++++++ src/routes/robots.txt/+server.ts | 28 +++++++ src/routes/sitemap.xml/+server.ts | 80 +++++++++++++++++++ 8 files changed, 410 insertions(+) create mode 100644 src/routes/+error.svelte create mode 100644 src/routes/api/health/+server.ts create mode 100644 src/routes/api/newsletter/+server.ts create mode 100644 src/routes/api/stripe-webhook/+server.ts create mode 100644 src/routes/api/worker/jobs/+server.ts create mode 100644 src/routes/api/worker/jobs/[id]/+server.ts create mode 100644 src/routes/robots.txt/+server.ts create mode 100644 src/routes/sitemap.xml/+server.ts diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte new file mode 100644 index 0000000..af6f574 --- /dev/null +++ b/src/routes/+error.svelte @@ -0,0 +1,89 @@ + + + + {message.title} — FosterFlow + + +
+
+
{getEmoji(status)}
+ +
+ {status} +
+ +

{message.title}

+

{message.description}

+ + {#if $page.error?.message && !statusMessages[status]} +

+ {$page.error.message} +

+ {/if} + +
+ {#if status === 401 || status === 403} + + Log In + + {/if} + + + Go Home + + + +
+
+
diff --git a/src/routes/api/health/+server.ts b/src/routes/api/health/+server.ts new file mode 100644 index 0000000..2e53030 --- /dev/null +++ b/src/routes/api/health/+server.ts @@ -0,0 +1,6 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async () => { + return json({ status: 'ok', timestamp: new Date().toISOString() }); +}; diff --git a/src/routes/api/newsletter/+server.ts b/src/routes/api/newsletter/+server.ts new file mode 100644 index 0000000..fd3ed26 --- /dev/null +++ b/src/routes/api/newsletter/+server.ts @@ -0,0 +1,34 @@ +import { json } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { newsletterSubscribers } from '$lib/server/schema'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ request }) => { + try { + const body = await request.json(); + const email = body?.email; + + if (!email || typeof email !== 'string') { + return json({ error: 'Email is required.' }, { status: 400 }); + } + + const trimmed = email.trim().toLowerCase(); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (!emailRegex.test(trimmed)) { + return json({ error: 'Please enter a valid email address.' }, { status: 400 }); + } + + const name = typeof body?.name === 'string' ? body.name.trim() || null : null; + + await db + .insert(newsletterSubscribers) + .values({ email: trimmed, name }) + .onDuplicateKeyUpdate({ set: { email: trimmed } }); + + return json({ message: 'Thanks for subscribing!' }); + } catch (err) { + console.error('Newsletter signup error:', err); + return json({ error: 'Something went wrong. Please try again.' }, { status: 500 }); + } +}; diff --git a/src/routes/api/stripe-webhook/+server.ts b/src/routes/api/stripe-webhook/+server.ts new file mode 100644 index 0000000..938c645 --- /dev/null +++ b/src/routes/api/stripe-webhook/+server.ts @@ -0,0 +1,91 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import type Stripe from 'stripe'; +import { db } from '$lib/server/db'; +import { donations, pets } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { constructWebhookEvent } from '$lib/server/stripe'; + +export const POST: RequestHandler = async ({ request }) => { + if (!process.env.STRIPE_SECRET_KEY) { + return json({ error: 'Stripe not configured' }, { status: 503 }); + } + + if (!process.env.STRIPE_WEBHOOK_SECRET) { + return json({ error: 'Webhook secret not configured' }, { status: 503 }); + } + + const body = await request.text(); + const sig = request.headers.get('stripe-signature'); + + if (!sig) { + return json({ error: 'Missing stripe-signature header' }, { status: 400 }); + } + + let event: Stripe.Event; + try { + const result = constructWebhookEvent(body, sig); + if (!result) { + return json({ error: 'Webhook verification failed' }, { status: 400 }); + } + event = result; + } catch (err) { + console.error('Stripe webhook signature verification failed:', err); + return json({ error: 'Invalid signature' }, { status: 400 }); + } + + if (event.type === 'checkout.session.completed') { + const session = event.data.object as Stripe.Checkout.Session; + const metadata = session.metadata ?? {}; + const amountCents = session.amount_total ?? 0; + const amountDollars = Math.round(amountCents); // store as cents (int) + + if (metadata.type === 'adoption_fee') { + const petId = metadata.pet_id ? parseInt(metadata.pet_id, 10) : null; + + await db.insert(donations).values({ + source: 'stripe', + amount: amountDollars, + donorEmail: session.customer_email ?? null, + petId: petId ?? null, + stripePaymentId: typeof session.payment_intent === 'string' ? session.payment_intent : null, + isRecurring: false, + purpose: 'adoption_fee', + notes: `Adoption fee payment${petId ? ` for pet #${petId}` : ''}` + }); + + // Mark adoption fee as collected on the pet + if (petId) { + await db + .update(pets) + .set({ adoptionFeeCollected: true }) + .where(eq(pets.id, petId)); + } + } else if (metadata.type === 'donation') { + const petId = metadata.pet_id ? parseInt(metadata.pet_id, 10) : null; + + await db.insert(donations).values({ + source: 'stripe', + amount: amountDollars, + donorEmail: session.customer_email ?? null, + petId: petId ?? null, + stripePaymentId: typeof session.payment_intent === 'string' ? session.payment_intent : null, + isRecurring: false, + purpose: 'donation', + notes: 'Online donation via Stripe' + }); + } else { + // Generic payment — record as donation + await db.insert(donations).values({ + source: 'stripe', + amount: amountDollars, + donorEmail: session.customer_email ?? null, + stripePaymentId: typeof session.payment_intent === 'string' ? session.payment_intent : null, + isRecurring: false, + notes: `Stripe checkout session: ${session.id}` + }); + } + } + + return json({ received: true }); +}; diff --git a/src/routes/api/worker/jobs/+server.ts b/src/routes/api/worker/jobs/+server.ts new file mode 100644 index 0000000..025f191 --- /dev/null +++ b/src/routes/api/worker/jobs/+server.ts @@ -0,0 +1,34 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { aiJobs } from '$lib/server/schema'; +import { eq, asc } from 'drizzle-orm'; + +export const GET: RequestHandler = async ({ request }) => { + const auth = request.headers.get('authorization'); + const key = process.env.WORKER_API_KEY; + + if (!key || auth !== `Bearer ${key}`) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Get oldest pending job + const [job] = await db + .select() + .from(aiJobs) + .where(eq(aiJobs.status, 'pending')) + .orderBy(asc(aiJobs.createdAt)) + .limit(1); + + if (!job) { + return json({ job: null }); + } + + // Mark as processing atomically + await db + .update(aiJobs) + .set({ status: 'processing' }) + .where(eq(aiJobs.id, job.id)); + + return json({ job }); +}; diff --git a/src/routes/api/worker/jobs/[id]/+server.ts b/src/routes/api/worker/jobs/[id]/+server.ts new file mode 100644 index 0000000..46985e0 --- /dev/null +++ b/src/routes/api/worker/jobs/[id]/+server.ts @@ -0,0 +1,48 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { aiJobs } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; + +export const POST: RequestHandler = async ({ request, params }) => { + const auth = request.headers.get('authorization'); + const key = process.env.WORKER_API_KEY; + + if (!key || auth !== `Bearer ${key}`) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + + const id = parseInt(params.id, 10); + if (!id || isNaN(id)) { + return json({ error: 'Invalid job ID' }, { status: 400 }); + } + + let body: { error?: string; output?: unknown }; + try { + body = await request.json(); + } catch { + return json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + if (body.error) { + await db + .update(aiJobs) + .set({ + status: 'failed', + error: body.error, + completedAt: new Date() + }) + .where(eq(aiJobs.id, id)); + } else { + await db + .update(aiJobs) + .set({ + status: 'completed', + output: body.output as Record, + completedAt: new Date() + }) + .where(eq(aiJobs.id, id)); + } + + return json({ success: true }); +}; diff --git a/src/routes/robots.txt/+server.ts b/src/routes/robots.txt/+server.ts new file mode 100644 index 0000000..c57cdbd --- /dev/null +++ b/src/routes/robots.txt/+server.ts @@ -0,0 +1,28 @@ +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async () => { + const disallowedPaths = [ + '/admin', + '/foster', + '/api', + '/login', + '/logout', + '/register', + '/change-password', + '/setup' + ]; + + const lines = [ + 'User-agent: *', + ...disallowedPaths.map((p) => `Disallow: ${p}/`), + '', + 'Sitemap: /sitemap.xml' + ]; + + return new Response(lines.join('\n'), { + headers: { + 'Content-Type': 'text/plain', + 'Cache-Control': 'public, max-age=3600' + } + }); +}; diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts new file mode 100644 index 0000000..7c27d78 --- /dev/null +++ b/src/routes/sitemap.xml/+server.ts @@ -0,0 +1,80 @@ +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { pets } from '$lib/server/schema'; +import { eq, and } from 'drizzle-orm'; + +const STATIC_PAGES = [ + { path: '/', changefreq: 'daily', priority: '1.0' }, + { path: '/pets', changefreq: 'daily', priority: '0.9' }, + { path: '/about', changefreq: 'monthly', priority: '0.7' }, + { path: '/how-to-help', changefreq: 'monthly', priority: '0.7' }, + { path: '/why-adopt', changefreq: 'monthly', priority: '0.6' }, + { path: '/become-a-foster', changefreq: 'monthly', priority: '0.6' }, + { path: '/adoption-process', changefreq: 'monthly', priority: '0.6' }, + { path: '/foster-resources', changefreq: 'monthly', priority: '0.5' }, + { path: '/volunteer', changefreq: 'monthly', priority: '0.5' }, + { path: '/events', changefreq: 'weekly', priority: '0.6' }, + { path: '/happy-tails', changefreq: 'weekly', priority: '0.6' }, + { path: '/donate', changefreq: 'monthly', priority: '0.7' }, + { path: '/sponsors', changefreq: 'monthly', priority: '0.5' }, + { path: '/contact', changefreq: 'monthly', priority: '0.5' } +]; + +function xmlEscape(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export const GET: RequestHandler = async ({ url }) => { + const origin = url.origin; + const now = new Date().toISOString().split('T')[0]; + + // Load all published, available pets for individual pages + const petRows = await db + .select({ slug: pets.slug, updatedAt: pets.updatedAt }) + .from(pets) + .where(and(eq(pets.published, true), eq(pets.status, 'available'))); + + const urls: string[] = []; + + // Static pages + for (const page of STATIC_PAGES) { + urls.push(` + + ${xmlEscape(origin + page.path)} + ${now} + ${page.changefreq} + ${page.priority} + `); + } + + // Pet pages + for (const pet of petRows) { + const lastmod = pet.updatedAt + ? new Date(pet.updatedAt).toISOString().split('T')[0] + : now; + urls.push(` + + ${xmlEscape(`${origin}/pets/${pet.slug}`)} + ${lastmod} + weekly + 0.8 + `); + } + + const xml = ` + +${urls.join('\n')} +`; + + return new Response(xml, { + headers: { + 'Content-Type': 'application/xml', + 'Cache-Control': 'public, max-age=3600' + } + }); +};