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