Add API routes, SEO endpoints, and friendly error page

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:54:13 -05:00
co-authored by Claude Sonnet 4.6
parent e7681f0b49
commit 8d86f9f3b3
8 changed files with 410 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
import { page } from '$app/stores';
const statusMessages: Record<number, { title: string; description: string }> = {
400: {
title: 'Bad Request',
description: 'Something was wrong with your request. Please try again.'
},
401: {
title: 'Not Logged In',
description: 'You need to log in to access this page.'
},
403: {
title: 'Access Denied',
description: "You don't have permission to view this page."
},
404: {
title: 'Page Not Found',
description: "We couldn't find the page you were looking for. It may have moved or been removed."
},
500: {
title: 'Server Error',
description: 'Something went wrong on our end. Please try again in a moment.'
}
};
const status = $derived($page.status);
const message = $derived(statusMessages[status] ?? {
title: `Error ${status}`,
description: $page.error?.message ?? 'An unexpected error occurred.'
});
function getEmoji(s: number): string {
if (s === 404) return '🐾';
if (s === 403) return '🔒';
if (s === 401) return '🔑';
if (s >= 500) return '🐕';
return '😕';
}
</script>
<svelte:head>
<title>{message.title} — FosterFlow</title>
</svelte:head>
<div class="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div class="max-w-md w-full text-center">
<div class="text-6xl mb-6">{getEmoji(status)}</div>
<div class="mb-2 inline-flex items-center px-3 py-1 rounded-full bg-gray-100 text-gray-600 text-sm font-mono font-medium">
{status}
</div>
<h1 class="text-3xl font-bold text-gray-900 mt-3 mb-3">{message.title}</h1>
<p class="text-gray-500 mb-8 leading-relaxed">{message.description}</p>
{#if $page.error?.message && !statusMessages[status]}
<p class="text-sm text-gray-400 mb-6 font-mono bg-gray-100 rounded-lg p-3 text-left break-all">
{$page.error.message}
</p>
{/if}
<div class="flex flex-col sm:flex-row gap-3 justify-center">
{#if status === 401 || status === 403}
<a
href="/login"
class="inline-flex items-center justify-center px-6 py-3 text-sm font-medium rounded-xl text-white bg-teal-600 hover:bg-teal-700 transition-colors"
>
Log In
</a>
{/if}
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 text-sm font-medium rounded-xl border border-gray-200 text-gray-700 hover:bg-gray-50 transition-colors"
>
Go Home
</a>
<button
type="button"
onclick={() => history.back()}
class="inline-flex items-center justify-center px-6 py-3 text-sm font-medium rounded-xl border border-gray-200 text-gray-700 hover:bg-gray-50 transition-colors cursor-pointer"
>
Go Back
</button>
</div>
</div>
</div>
+6
View File
@@ -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() });
};
+34
View File
@@ -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 });
}
};
+91
View File
@@ -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 });
};
+34
View File
@@ -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 });
};
@@ -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<string, unknown>,
completedAt: new Date()
})
.where(eq(aiJobs.id, id));
}
return json({ success: true });
};
+28
View File
@@ -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'
}
});
};
+80
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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(`
<url>
<loc>${xmlEscape(origin + page.path)}</loc>
<lastmod>${now}</lastmod>
<changefreq>${page.changefreq}</changefreq>
<priority>${page.priority}</priority>
</url>`);
}
// Pet pages
for (const pet of petRows) {
const lastmod = pet.updatedAt
? new Date(pet.updatedAt).toISOString().split('T')[0]
: now;
urls.push(`
<url>
<loc>${xmlEscape(`${origin}/pets/${pet.slug}`)}</loc>
<lastmod>${lastmod}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>`);
}
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join('\n')}
</urlset>`;
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=3600'
}
});
};