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:
@@ -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>
|
||||||
@@ -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() });
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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, '"')
|
||||||
|
.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(`
|
||||||
|
<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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user