From b5cb5ba96a5accab0aaa7f7d7607cfa1502eb925 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 11:50:11 -0500 Subject: [PATCH 01/38] feat: scaffold FosterFlow SvelteKit project with config Initializes SvelteKit 2 + Svelte 5 project with adapter-node, Tailwind CSS 4, Drizzle ORM (MySQL), Stripe, Nodemailer, and argon2 dependencies. Configures app.html with PWA meta tags, app.css with brand color tokens, and manifest.json. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 33 + .gitignore | 27 + .npmrc | 1 + .vscode/extensions.json | 3 + drizzle.config.ts | 10 + package-lock.json | 4163 ++++++++++++++++++++++++++++++++++++ package.json | 35 + src/app.css | 20 + src/app.d.ts | 13 + src/app.html | 15 + src/lib/assets/favicon.svg | 1 + src/lib/index.ts | 1 + src/routes/+layout.svelte | 11 + src/routes/+page.svelte | 2 + static/manifest.json | 9 + static/robots.txt | 3 + svelte.config.js | 14 + tsconfig.json | 20 + vite.config.ts | 7 + 19 files changed, 4388 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .vscode/extensions.json create mode 100644 drizzle.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/app.css create mode 100644 src/app.d.ts create mode 100644 src/app.html create mode 100644 src/lib/assets/favicon.svg create mode 100644 src/lib/index.ts create mode 100644 src/routes/+layout.svelte create mode 100644 src/routes/+page.svelte create mode 100644 static/manifest.json create mode 100644 static/robots.txt create mode 100644 svelte.config.js create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d100334 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# Database +DATABASE_URL=mysql://fosterflow:password@localhost:3306/fosterflow + +# Auth +SESSION_SECRET=change-me-to-a-random-64-char-string + +# Stripe (optional) +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= + +# Email (optional) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM=noreply@example.com + +# File uploads +UPLOAD_DIR=./uploads + +# Public URL +PUBLIC_SITE_URL=http://localhost:5173 + +# AI name generator (optional) +OLLAMA_URL=http://localhost:11434 +WORKER_API_KEY=change-me + +# Facebook integration (optional) +FACEBOOK_APP_ID= +FACEBOOK_APP_SECRET= + +# Foster resources page password (optional) +FOSTER_RESOURCES_PASSWORD= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97be634 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +node_modules/ +build/ +.svelte-kit/ +uploads/ + +# Output +.output +.vercel +.netlify +.wrangler + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# Drizzle +drizzle/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..28d1e67 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..5653130 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/lib/server/schema.ts', + out: './drizzle', + dialect: 'mysql', + dbCredentials: { + url: process.env.DATABASE_URL! + } +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4f47c74 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4163 @@ +{ + "name": "pawprint", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pawprint", + "version": "0.0.1", + "dependencies": { + "@node-rs/argon2": "^2.0.2", + "drizzle-orm": "^0.45.1", + "mysql2": "^3.20.0", + "nodemailer": "^8.0.4", + "stripe": "^21.0.0" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/adapter-node": "^5.5.4", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.2", + "@types/nodemailer": "^7.0.11", + "drizzle-kit": "^0.31.10", + "svelte": "^5.54.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.2.2", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } + }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@node-rs/argon2": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.0.2.tgz", + "integrity": "sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@node-rs/argon2-android-arm-eabi": "2.0.2", + "@node-rs/argon2-android-arm64": "2.0.2", + "@node-rs/argon2-darwin-arm64": "2.0.2", + "@node-rs/argon2-darwin-x64": "2.0.2", + "@node-rs/argon2-freebsd-x64": "2.0.2", + "@node-rs/argon2-linux-arm-gnueabihf": "2.0.2", + "@node-rs/argon2-linux-arm64-gnu": "2.0.2", + "@node-rs/argon2-linux-arm64-musl": "2.0.2", + "@node-rs/argon2-linux-x64-gnu": "2.0.2", + "@node-rs/argon2-linux-x64-musl": "2.0.2", + "@node-rs/argon2-wasm32-wasi": "2.0.2", + "@node-rs/argon2-win32-arm64-msvc": "2.0.2", + "@node-rs/argon2-win32-ia32-msvc": "2.0.2", + "@node-rs/argon2-win32-x64-msvc": "2.0.2" + } + }, + "node_modules/@node-rs/argon2-android-arm-eabi": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.0.2.tgz", + "integrity": "sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-android-arm64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.0.2.tgz", + "integrity": "sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-arm64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.0.2.tgz", + "integrity": "sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-x64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.0.2.tgz", + "integrity": "sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-freebsd-x64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.0.2.tgz", + "integrity": "sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm-gnueabihf": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.0.2.tgz", + "integrity": "sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-gnu": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.0.2.tgz", + "integrity": "sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-musl": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.0.2.tgz", + "integrity": "sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-gnu": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.0.2.tgz", + "integrity": "sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-musl": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.0.2.tgz", + "integrity": "sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-wasm32-wasi": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-2.0.2.tgz", + "integrity": "sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@node-rs/argon2-win32-arm64-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.0.2.tgz", + "integrity": "sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-ia32-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.0.2.tgz", + "integrity": "sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-x64-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.0.2.tgz", + "integrity": "sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz", + "integrity": "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.4.tgz", + "integrity": "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "dev": true, + "license": "MIT" + }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", + "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/mysql2": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz", + "integrity": "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nodemailer": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", + "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/stripe": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-21.0.0.tgz", + "integrity": "sha512-VlQ1QhjQAhmCgY3g3BgfZFLo8AkJw1r9zTrG4hdjSDX2AcwS5sPz4eHJG0zfzCO8zO/md0Xe+zrkxRqg604ZWA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz", + "integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", + "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c8f9a9c --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "pawprint", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/adapter-node": "^5.5.4", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.2", + "@types/nodemailer": "^7.0.11", + "drizzle-kit": "^0.31.10", + "svelte": "^5.54.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.2.2", + "typescript": "^5.9.3", + "vite": "^7.3.1" + }, + "dependencies": { + "@node-rs/argon2": "^2.0.2", + "drizzle-orm": "^0.45.1", + "mysql2": "^3.20.0", + "nodemailer": "^8.0.4", + "stripe": "^21.0.0" + } +} diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..3ba6152 --- /dev/null +++ b/src/app.css @@ -0,0 +1,20 @@ +@import 'tailwindcss'; + +:root { + --color-primary: #0d9488; + --color-primary-light: #14b8a6; + --color-primary-dark: #0f766e; + --color-accent: #f59e0b; + --color-accent-light: #fbbf24; + --color-accent-dark: #d97706; +} + +@theme { + --color-primary: var(--color-primary); + --color-primary-light: var(--color-primary-light); + --color-primary-dark: var(--color-primary-dark); + --color-accent: var(--color-accent); + --color-accent-light: var(--color-accent-light); + --color-accent-dark: var(--color-accent-dark); + --color-warm-bg: #faf8f5; +} diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..d99688f --- /dev/null +++ b/src/app.html @@ -0,0 +1,15 @@ + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/lib/assets/favicon.svg b/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..9cebde5 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,11 @@ + + + + + + +{@render children()} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..cc88df0 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,2 @@ +

Welcome to SvelteKit

+

Visit svelte.dev/docs/kit to read the documentation

diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..524b110 --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "FosterFlow", + "short_name": "FosterFlow", + "start_url": "/", + "display": "standalone", + "theme_color": "#0d9488", + "background_color": "#faf8f5", + "icons": [] +} diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..33d5b50 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,14 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +const config = { + kit: { + adapter: adapter(), + csrf: { + checkOrigin: true + } + }, + preprocess: vitePreprocess() +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..2d35c4f --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,7 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()] +}); From 22f0ded88f829ad7e9f04c16d4d7567989daa991 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 11:53:30 -0500 Subject: [PATCH 02/38] feat: add database schema and connection pool Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/db.ts | 11 + src/lib/server/schema.ts | 423 ++++++++++++++++++++++++++++++++++++++ tests/unit/schema.test.ts | 19 ++ vitest.config.ts | 14 ++ 4 files changed, 467 insertions(+) create mode 100644 src/lib/server/db.ts create mode 100644 src/lib/server/schema.ts create mode 100644 tests/unit/schema.test.ts create mode 100644 vitest.config.ts diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts new file mode 100644 index 0000000..24d88a3 --- /dev/null +++ b/src/lib/server/db.ts @@ -0,0 +1,11 @@ +import { drizzle } from 'drizzle-orm/mysql2'; +import mysql from 'mysql2/promise'; +import * as schema from './schema'; + +const pool = mysql.createPool({ + uri: process.env.DATABASE_URL, + waitForConnections: true, + connectionLimit: 10 +}); + +export const db = drizzle(pool, { schema, mode: 'default' }); diff --git a/src/lib/server/schema.ts b/src/lib/server/schema.ts new file mode 100644 index 0000000..c941c49 --- /dev/null +++ b/src/lib/server/schema.ts @@ -0,0 +1,423 @@ +import { + mysqlTable, varchar, int, text, boolean, timestamp, json, + mysqlEnum, uniqueIndex, index, serial +} from 'drizzle-orm/mysql-core'; +import { relations } from 'drizzle-orm'; + +// ── Auth ────────────────────────────────────────── + +export const users = mysqlTable('users', { + id: serial().primaryKey(), + email: varchar({ length: 255 }).notNull().unique(), + passwordHash: varchar('password_hash', { length: 255 }).notNull(), + name: varchar({ length: 255 }).notNull(), + role: mysqlEnum(['sysadmin', 'director', 'foster_coordinator', 'volunteer_manager', 'vet_liaison', 'content_editor', 'applications_manager', 'viewer', 'foster']).notNull().default('viewer'), + active: boolean().notNull().default(false), + mustChangePassword: boolean('must_change_password').notNull().default(false), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow() +}); + +export const sessions = mysqlTable('sessions', { + sessionId: varchar('session_id', { length: 255 }).primaryKey(), + userId: int('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + expiresAt: timestamp('expires_at').notNull() +}); + +// ── Pets ────────────────────────────────────────── + +export const intakeSources = mysqlTable('intake_sources', { + id: serial().primaryKey(), + name: varchar({ length: 255 }).notNull(), + active: boolean().notNull().default(true) +}); + +export const vets = mysqlTable('vets', { + id: serial().primaryKey(), + name: varchar({ length: 255 }).notNull(), + clinic: varchar({ length: 255 }), + phone: varchar({ length: 50 }), + email: varchar({ length: 255 }), + address: text(), + notes: text(), + active: boolean().notNull().default(true), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const pets = mysqlTable('pets', { + id: serial().primaryKey(), + name: varchar({ length: 255 }).notNull(), + slug: varchar({ length: 255 }).notNull().unique(), + species: mysqlEnum(['dog', 'cat', 'other']).notNull().default('dog'), + breed: varchar({ length: 255 }), + color: varchar({ length: 255 }), + markings: text(), + sex: mysqlEnum(['male', 'female', 'unknown']).notNull().default('unknown'), + weight: varchar({ length: 50 }), + age: varchar({ length: 100 }), + birthday: timestamp(), + status: mysqlEnum([ + 'available', 'adopted', 'foster', 'medical_hold', 'behavioral_hold', + 'pending', 'trial', 'hospice', 'nursing', 'whelping', 'deceased', 'transferred' + ]).notNull().default('available'), + description: text(), + medicalNotes: text('medical_notes'), + behavioralNotes: text('behavioral_notes'), + specialNeeds: text('special_needs'), + goodWithDogs: boolean('good_with_dogs'), + goodWithCats: boolean('good_with_cats'), + goodWithKids: boolean('good_with_kids'), + houseTrained: boolean('house_trained'), + spayedNeutered: boolean('spayed_neutered'), + microchipped: boolean(), + microchipId: varchar('microchip_id', { length: 100 }), + adoptionFee: int('adoption_fee'), + adoptionFeeCollected: boolean('adoption_fee_collected').default(false), + intakeDate: timestamp('intake_date'), + intakeSourceId: int('intake_source_id').references(() => intakeSources.id), + adoptedDate: timestamp('adopted_date'), + adopterName: varchar('adopter_name', { length: 255 }), + adopterEmail: varchar('adopter_email', { length: 255 }), + adopterPhone: varchar('adopter_phone', { length: 50 }), + vetId: int('vet_id').references(() => vets.id), + featured: boolean().notNull().default(false), + published: boolean().notNull().default(true), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow() +}, (table) => [ + index('status_idx').on(table.status), + index('species_idx').on(table.species), + index('featured_idx').on(table.featured) +]); + +export const petPhotos = mysqlTable('pet_photos', { + id: serial().primaryKey(), + petId: int('pet_id').notNull().references(() => pets.id, { onDelete: 'cascade' }), + url: varchar({ length: 500 }).notNull(), + alt: varchar({ length: 255 }), + isPrimary: boolean('is_primary').notNull().default(false), + sortOrder: int('sort_order').notNull().default(0), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const petMedical = mysqlTable('pet_medical', { + id: serial().primaryKey(), + petId: int('pet_id').notNull().references(() => pets.id, { onDelete: 'cascade' }), + type: mysqlEnum([ + 'vaccine', 'surgery', 'checkup', 'medication', 'deworming', + 'flea_tick', 'heartworm_test', 'heartworm_treatment', + 'dental', 'spay_neuter', 'microchip', 'other' + ]).notNull(), + name: varchar({ length: 255 }).notNull(), + date: timestamp(), + vetId: int('vet_id').references(() => vets.id), + cost: int(), + notes: text(), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const fosterAssignments = mysqlTable('foster_assignments', { + id: serial().primaryKey(), + petId: int('pet_id').notNull().references(() => pets.id, { onDelete: 'cascade' }), + userId: int('user_id').notNull().references(() => users.id), + startDate: timestamp('start_date').notNull().defaultNow(), + endDate: timestamp('end_date'), + isCurrent: boolean('is_current').notNull().default(true), + notes: text(), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const mentors = mysqlTable('mentors', { + id: serial().primaryKey(), + userId: int('user_id').notNull().references(() => users.id).unique(), + bio: text(), + active: boolean().notNull().default(true), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const petMentors = mysqlTable('pet_mentors', { + id: serial().primaryKey(), + petId: int('pet_id').notNull().references(() => pets.id, { onDelete: 'cascade' }), + mentorId: int('mentor_id').notNull().references(() => mentors.id, { onDelete: 'cascade' }), + assignedAt: timestamp('assigned_at').notNull().defaultNow() +}); + +// ── Applications ────────────────────────────────── + +export const applications = mysqlTable('applications', { + id: serial().primaryKey(), + type: mysqlEnum(['adoption', 'foster', 'volunteer', 'surrender']).notNull(), + status: mysqlEnum(['pending', 'reviewing', 'approved', 'denied', 'withdrawn']).notNull().default('pending'), + applicantName: varchar('applicant_name', { length: 255 }).notNull(), + applicantEmail: varchar('applicant_email', { length: 255 }).notNull(), + applicantPhone: varchar('applicant_phone', { length: 50 }), + petId: int('pet_id').references(() => pets.id), + formData: json('form_data').notNull(), + reviewedBy: int('reviewed_by').references(() => users.id), + reviewedAt: timestamp('reviewed_at'), + reviewNotes: text('review_notes'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow() +}); + +export const applicationHomevisitNotes = mysqlTable('application_homevisit_notes', { + id: serial().primaryKey(), + applicationId: int('application_id').notNull().references(() => applications.id, { onDelete: 'cascade' }), + userId: int('user_id').notNull().references(() => users.id), + notes: text().notNull(), + visitDate: timestamp('visit_date'), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +// ── Finance ─────────────────────────────────────── + +export const sponsors = mysqlTable('sponsors', { + id: serial().primaryKey(), + name: varchar({ length: 255 }).notNull(), + tier: mysqlEnum(['top_dog', 'wagging_tails', 'wet_noses']).notNull().default('wet_noses'), + logoUrl: varchar('logo_url', { length: 500 }), + websiteUrl: varchar('website_url', { length: 500 }), + description: text(), + active: boolean().notNull().default(true), + sortOrder: int('sort_order').notNull().default(0), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const donations = mysqlTable('donations', { + id: serial().primaryKey(), + donorName: varchar('donor_name', { length: 255 }), + donorEmail: varchar('donor_email', { length: 255 }), + amount: int().notNull(), + source: mysqlEnum([ + 'stripe', 'paypal', 'venmo', 'givebutter', 'cash', + 'check', 'zelle', 'google_pay', 'square', 'other' + ]).notNull().default('other'), + petId: int('pet_id').references(() => pets.id), + stripePaymentId: varchar('stripe_payment_id', { length: 255 }), + isRecurring: boolean('is_recurring').notNull().default(false), + purpose: varchar({ length: 255 }), + notes: text(), + date: timestamp().notNull().defaultNow(), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const expenses = mysqlTable('expenses', { + id: serial().primaryKey(), + description: varchar({ length: 255 }).notNull(), + amount: int().notNull(), + category: varchar({ length: 100 }), + petId: int('pet_id').references(() => pets.id), + vetId: int('vet_id').references(() => vets.id), + receiptUrl: varchar('receipt_url', { length: 500 }), + date: timestamp().notNull().defaultNow(), + notes: text(), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +// ── Content ─────────────────────────────────────── + +export const events = mysqlTable('events', { + id: serial().primaryKey(), + title: varchar({ length: 255 }).notNull(), + description: text(), + location: varchar({ length: 255 }), + startDate: timestamp('start_date').notNull(), + endDate: timestamp('end_date'), + imageUrl: varchar('image_url', { length: 500 }), + published: boolean().notNull().default(true), + facebookEventId: varchar('facebook_event_id', { length: 100 }), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const happyTails = mysqlTable('happy_tails', { + id: serial().primaryKey(), + petId: int('pet_id').references(() => pets.id), + title: varchar({ length: 255 }).notNull(), + story: text().notNull(), + imageUrl: varchar('image_url', { length: 500 }), + adopterName: varchar('adopter_name', { length: 255 }), + featured: boolean().notNull().default(false), + published: boolean().notNull().default(false), + submittedBy: varchar('submitted_by', { length: 255 }), + submittedEmail: varchar('submitted_email', { length: 255 }), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const contentBlocks = mysqlTable('content_blocks', { + id: serial().primaryKey(), + page: varchar({ length: 100 }).notNull(), + sectionKey: varchar('section_key', { length: 100 }).notNull(), + content: json().notNull(), + updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow() +}, (table) => [ + uniqueIndex('page_section_idx').on(table.page, table.sectionKey) +]); + +export const newsletterSubscribers = mysqlTable('newsletter_subscribers', { + id: serial().primaryKey(), + email: varchar({ length: 255 }).notNull().unique(), + name: varchar({ length: 255 }), + subscribedAt: timestamp('subscribed_at').notNull().defaultNow(), + unsubscribedAt: timestamp('unsubscribed_at') +}); + +export const contactMessages = mysqlTable('contact_messages', { + id: serial().primaryKey(), + name: varchar({ length: 255 }).notNull(), + email: varchar({ length: 255 }).notNull(), + subject: varchar({ length: 255 }), + message: text().notNull(), + read: boolean().notNull().default(false), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +// ── Foster Portal ───────────────────────────────── + +export const fosterSubmissions = mysqlTable('foster_submissions', { + id: serial().primaryKey(), + userId: int('user_id').notNull().references(() => users.id), + petId: int('pet_id').notNull().references(() => pets.id, { onDelete: 'cascade' }), + type: mysqlEnum(['note', 'photo', 'update']).notNull().default('update'), + content: text(), + photoUrl: varchar('photo_url', { length: 500 }), + status: mysqlEnum(['pending', 'approved', 'rejected']).notNull().default('pending'), + reviewedBy: int('reviewed_by').references(() => users.id), + reviewedAt: timestamp('reviewed_at'), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const supplyRequests = mysqlTable('supply_requests', { + id: serial().primaryKey(), + userId: int('user_id').notNull().references(() => users.id), + petId: int('pet_id').references(() => pets.id), + items: text().notNull(), + urgency: mysqlEnum(['low', 'medium', 'high']).notNull().default('medium'), + status: mysqlEnum(['pending', 'approved', 'fulfilled', 'denied']).notNull().default('pending'), + notes: text(), + reviewedBy: int('reviewed_by').references(() => users.id), + reviewedAt: timestamp('reviewed_at'), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +// ── Config & System ─────────────────────────────── + +export const siteSettings = mysqlTable('site_settings', { + id: serial().primaryKey(), + key: varchar({ length: 100 }).notNull().unique(), + value: text().notNull(), + updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow() +}); + +export const orgConfig = mysqlTable('org_config', { + id: serial().primaryKey(), + key: varchar({ length: 100 }).notNull().unique(), + value: json().notNull(), + updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow() +}); + +export const auditLog = mysqlTable('audit_log', { + id: serial().primaryKey(), + userId: int('user_id').references(() => users.id), + action: varchar({ length: 100 }).notNull(), + entity: varchar({ length: 100 }), + entityId: int('entity_id'), + diff: json(), + ipAddress: varchar('ip_address', { length: 45 }), + createdAt: timestamp('created_at').notNull().defaultNow() +}, (table) => [ + index('action_idx').on(table.action), + index('entity_idx').on(table.entity, table.entityId) +]); + +export const pageViews = mysqlTable('page_views', { + id: serial().primaryKey(), + path: varchar({ length: 500 }).notNull(), + petId: int('pet_id').references(() => pets.id), + ipAddress: varchar('ip_address', { length: 45 }), + userAgent: text('user_agent'), + referrer: varchar({ length: 500 }), + isBot: boolean('is_bot').notNull().default(false), + createdAt: timestamp('created_at').notNull().defaultNow() +}, (table) => [ + index('path_idx').on(table.path), + index('created_idx').on(table.createdAt) +]); + +export const aiJobs = mysqlTable('ai_jobs', { + id: serial().primaryKey(), + type: varchar({ length: 50 }).notNull(), + input: json().notNull(), + status: mysqlEnum(['pending', 'processing', 'completed', 'failed']).notNull().default('pending'), + output: json(), + error: text(), + completedAt: timestamp('completed_at'), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +// ── Relations ───────────────────────────────────── + +export const usersRelations = relations(users, ({ many }) => ({ + sessions: many(sessions), + fosterAssignments: many(fosterAssignments), + fosterSubmissions: many(fosterSubmissions), + supplyRequests: many(supplyRequests) +})); + +export const sessionsRelations = relations(sessions, ({ one }) => ({ + user: one(users, { fields: [sessions.userId], references: [users.id] }) +})); + +export const petsRelations = relations(pets, ({ one, many }) => ({ + photos: many(petPhotos), + medical: many(petMedical), + fosterAssignments: many(fosterAssignments), + applications: many(applications), + intakeSource: one(intakeSources, { fields: [pets.intakeSourceId], references: [intakeSources.id] }), + vet: one(vets, { fields: [pets.vetId], references: [vets.id] }), + donations: many(donations), + expenses: many(expenses) +})); + +export const petPhotosRelations = relations(petPhotos, ({ one }) => ({ + pet: one(pets, { fields: [petPhotos.petId], references: [pets.id] }) +})); + +export const petMedicalRelations = relations(petMedical, ({ one }) => ({ + pet: one(pets, { fields: [petMedical.petId], references: [pets.id] }), + vet: one(vets, { fields: [petMedical.vetId], references: [vets.id] }) +})); + +export const fosterAssignmentsRelations = relations(fosterAssignments, ({ one }) => ({ + pet: one(pets, { fields: [fosterAssignments.petId], references: [pets.id] }), + user: one(users, { fields: [fosterAssignments.userId], references: [users.id] }) +})); + +export const applicationsRelations = relations(applications, ({ one, many }) => ({ + pet: one(pets, { fields: [applications.petId], references: [pets.id] }), + reviewer: one(users, { fields: [applications.reviewedBy], references: [users.id] }), + homevisitNotes: many(applicationHomevisitNotes) +})); + +export const applicationHomevisitNotesRelations = relations(applicationHomevisitNotes, ({ one }) => ({ + application: one(applications, { fields: [applicationHomevisitNotes.applicationId], references: [applications.id] }), + user: one(users, { fields: [applicationHomevisitNotes.userId], references: [users.id] }) +})); + +export const donationsRelations = relations(donations, ({ one }) => ({ + pet: one(pets, { fields: [donations.petId], references: [pets.id] }) +})); + +export const expensesRelations = relations(expenses, ({ one }) => ({ + pet: one(pets, { fields: [expenses.petId], references: [pets.id] }), + vet: one(vets, { fields: [expenses.vetId], references: [vets.id] }) +})); + +export const fosterSubmissionsRelations = relations(fosterSubmissions, ({ one }) => ({ + user: one(users, { fields: [fosterSubmissions.userId], references: [users.id] }), + pet: one(pets, { fields: [fosterSubmissions.petId], references: [pets.id] }) +})); + +export const supplyRequestsRelations = relations(supplyRequests, ({ one }) => ({ + user: one(users, { fields: [supplyRequests.userId], references: [users.id] }), + pet: one(pets, { fields: [supplyRequests.petId], references: [pets.id] }) +})); diff --git a/tests/unit/schema.test.ts b/tests/unit/schema.test.ts new file mode 100644 index 0000000..99ab3c6 --- /dev/null +++ b/tests/unit/schema.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import * as schema from '$lib/server/schema'; + +describe('schema exports', () => { + it('exports all core tables', () => { + const tables = [ + 'users', 'sessions', 'pets', 'petPhotos', 'petMedical', + 'fosterAssignments', 'mentors', 'petMentors', 'applications', + 'applicationHomevisitNotes', 'vets', 'intakeSources', 'sponsors', + 'donations', 'expenses', 'events', 'happyTails', 'fosterSubmissions', + 'supplyRequests', 'contentBlocks', 'newsletterSubscribers', + 'contactMessages', 'siteSettings', 'orgConfig', 'auditLog', + 'pageViews', 'aiJobs' + ]; + for (const table of tables) { + expect(schema).toHaveProperty(table); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fe3d8a0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + include: ['tests/unit/**/*.test.ts'], + exclude: ['tests/e2e/**'], + }, + resolve: { + alias: { + $lib: path.resolve('./src/lib') + } + } +}); From fdc27584c93aec562cedd0bfae26bd912e7ec3d0 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 11:54:24 -0500 Subject: [PATCH 03/38] feat: add role-based permission system --- src/lib/roles.ts | 56 ++++++++++++++++++++++++++++++++++++++++ tests/unit/roles.test.ts | 43 ++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/lib/roles.ts create mode 100644 tests/unit/roles.test.ts diff --git a/src/lib/roles.ts b/src/lib/roles.ts new file mode 100644 index 0000000..1f9c269 --- /dev/null +++ b/src/lib/roles.ts @@ -0,0 +1,56 @@ +export type Role = + | 'sysadmin' + | 'director' + | 'foster_coordinator' + | 'volunteer_manager' + | 'vet_liaison' + | 'content_editor' + | 'applications_manager' + | 'viewer' + | 'foster'; + +export type Permission = + | 'dashboard' + | 'pets' + | 'pets_view' + | 'applications' + | 'sponsors' + | 'donations' + | 'expenses' + | 'vets' + | 'medical' + | 'events' + | 'content' + | 'volunteers' + | 'users' + | 'shop' + | 'import' + | 'system'; + +const ROLE_PERMISSIONS: Record = { + sysadmin: [ + 'dashboard', 'pets', 'pets_view', 'applications', 'sponsors', + 'donations', 'expenses', 'vets', 'medical', 'events', 'content', + 'volunteers', 'users', 'shop', 'import', 'system' + ], + director: [ + 'dashboard', 'pets', 'pets_view', 'applications', 'sponsors', + 'donations', 'expenses', 'vets', 'medical', 'events', 'content', + 'volunteers', 'users', 'shop', 'import' + ], + foster_coordinator: ['dashboard', 'pets', 'pets_view', 'applications'], + volunteer_manager: ['dashboard', 'volunteers', 'events'], + vet_liaison: ['dashboard', 'pets', 'pets_view', 'vets', 'medical', 'expenses'], + content_editor: ['dashboard', 'sponsors', 'content', 'events'], + applications_manager: ['dashboard', 'applications', 'pets_view'], + viewer: ['dashboard'], + foster: ['dashboard'] +}; + +export function hasPermission(role: Role, permission: Permission): boolean { + return ROLE_PERMISSIONS[role]?.includes(permission) ?? false; +} + +export function canViewPets(role: Role): boolean { + return hasPermission(role, 'pets') || hasPermission(role, 'pets_view'); +} diff --git a/tests/unit/roles.test.ts b/tests/unit/roles.test.ts new file mode 100644 index 0000000..74025ef --- /dev/null +++ b/tests/unit/roles.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { hasPermission, canViewPets, type Role, type Permission } from '$lib/roles'; + +describe('roles', () => { + it('sysadmin has all permissions', () => { + const perms: Permission[] = [ + 'dashboard', 'pets', 'pets_view', 'applications', 'sponsors', + 'donations', 'expenses', 'vets', 'medical', 'events', 'content', + 'volunteers', 'users', 'shop', 'import', 'system' + ]; + for (const perm of perms) { + expect(hasPermission('sysadmin', perm)).toBe(true); + } + }); + + it('director has all except system', () => { + expect(hasPermission('director', 'pets')).toBe(true); + expect(hasPermission('director', 'system')).toBe(false); + }); + + it('foster has only dashboard', () => { + expect(hasPermission('foster', 'dashboard')).toBe(true); + expect(hasPermission('foster', 'pets')).toBe(false); + expect(hasPermission('foster', 'users')).toBe(false); + }); + + it('viewer has only dashboard', () => { + expect(hasPermission('viewer', 'dashboard')).toBe(true); + expect(hasPermission('viewer', 'pets')).toBe(false); + }); + + it('applications_manager can view pets but not edit', () => { + expect(hasPermission('applications_manager', 'applications')).toBe(true); + expect(hasPermission('applications_manager', 'pets_view')).toBe(true); + expect(hasPermission('applications_manager', 'pets')).toBe(false); + }); + + it('canViewPets returns true for roles with pets or pets_view', () => { + expect(canViewPets('sysadmin')).toBe(true); + expect(canViewPets('applications_manager')).toBe(true); + expect(canViewPets('viewer')).toBe(false); + }); +}); From 86c1389d675eead5f17550cb7aad8d727a8a2e95 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 11:54:35 -0500 Subject: [PATCH 04/38] feat: add auth utilities (Argon2, session tokens) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/server/auth-utils.ts | 22 ++++++++++++++++++++++ tests/unit/auth.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/lib/server/auth-utils.ts create mode 100644 tests/unit/auth.test.ts diff --git a/src/lib/server/auth-utils.ts b/src/lib/server/auth-utils.ts new file mode 100644 index 0000000..53123f0 --- /dev/null +++ b/src/lib/server/auth-utils.ts @@ -0,0 +1,22 @@ +import { hash, verify } from '@node-rs/argon2'; +import crypto from 'crypto'; + +export async function hashPassword(password: string): Promise { + return hash(password); +} + +export async function verifyPassword(hashedPassword: string, password: string): Promise { + try { + return await verify(hashedPassword, password); + } catch { + return false; + } +} + +export function generateSessionToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +export function hashSessionToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts new file mode 100644 index 0000000..6f1bac0 --- /dev/null +++ b/tests/unit/auth.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { + hashPassword, verifyPassword, generateSessionToken, hashSessionToken +} from '$lib/server/auth-utils'; + +describe('auth-utils', () => { + it('hashes and verifies a password', async () => { + const hash = await hashPassword('testpassword'); + expect(hash).not.toBe('testpassword'); + expect(await verifyPassword(hash, 'testpassword')).toBe(true); + expect(await verifyPassword(hash, 'wrongpassword')).toBe(false); + }); + + it('generates unique session tokens', () => { + const token1 = generateSessionToken(); + const token2 = generateSessionToken(); + expect(token1).toHaveLength(64); + expect(token2).toHaveLength(64); + expect(token1).not.toBe(token2); + }); + + it('hashes session tokens deterministically', () => { + const token = generateSessionToken(); + const hash1 = hashSessionToken(token); + const hash2 = hashSessionToken(token); + expect(hash1).toBe(hash2); + expect(hash1).not.toBe(token); + }); +}); From af2e48347fa747957099b53e8286e60b3db63976 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 11:54:42 -0500 Subject: [PATCH 05/38] feat: add slugify utility --- src/lib/slugify.ts | 8 ++++++++ tests/unit/slugify.test.ts | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 src/lib/slugify.ts create mode 100644 tests/unit/slugify.test.ts diff --git a/src/lib/slugify.ts b/src/lib/slugify.ts new file mode 100644 index 0000000..8f3d2c4 --- /dev/null +++ b/src/lib/slugify.ts @@ -0,0 +1,8 @@ +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/[\s]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} diff --git a/tests/unit/slugify.test.ts b/tests/unit/slugify.test.ts new file mode 100644 index 0000000..f80a027 --- /dev/null +++ b/tests/unit/slugify.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { slugify } from '$lib/slugify'; + +describe('slugify', () => { + it('converts to lowercase with hyphens', () => { + expect(slugify('Hello World')).toBe('hello-world'); + }); + + it('removes special characters', () => { + expect(slugify("Buddy's Adventure!")).toBe('buddys-adventure'); + }); + + it('collapses multiple hyphens', () => { + expect(slugify('foo bar---baz')).toBe('foo-bar-baz'); + }); + + it('trims leading and trailing hyphens', () => { + expect(slugify(' hello ')).toBe('hello'); + }); +}); From c5f1d2c993dea5fdbc4def52790a61f66eaf2ee1 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 11:54:51 -0500 Subject: [PATCH 06/38] feat: add in-memory sliding window rate limiter --- src/lib/server/rate-limit.ts | 33 +++++++++++++++++++++++++++++++++ tests/unit/rate-limit.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/lib/server/rate-limit.ts create mode 100644 tests/unit/rate-limit.test.ts diff --git a/src/lib/server/rate-limit.ts b/src/lib/server/rate-limit.ts new file mode 100644 index 0000000..3eb7ed0 --- /dev/null +++ b/src/lib/server/rate-limit.ts @@ -0,0 +1,33 @@ +export class RateLimiter { + private windows = new Map(); + + constructor( + private maxRequests: number, + private windowMs: number + ) {} + + check(key: string): boolean { + const now = Date.now(); + const timestamps = this.windows.get(key) ?? []; + + const valid = timestamps.filter((t) => now - t < this.windowMs); + + if (valid.length >= this.maxRequests) { + this.windows.set(key, valid); + return false; + } + + valid.push(now); + this.windows.set(key, valid); + return true; + } + + reset(key: string): void { + this.windows.delete(key); + } +} + +export const loginLimiter = new RateLimiter(5, 15 * 60 * 1000); +export const registrationLimiter = new RateLimiter(3, 60 * 60 * 1000); +export const apiLimiter = new RateLimiter(60, 60 * 1000); +export const nameGeneratorLimiter = new RateLimiter(10, 5 * 60 * 1000); diff --git a/tests/unit/rate-limit.test.ts b/tests/unit/rate-limit.test.ts new file mode 100644 index 0000000..6ea59e2 --- /dev/null +++ b/tests/unit/rate-limit.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { RateLimiter } from '$lib/server/rate-limit'; + +describe('rate-limit', () => { + it('allows requests under the limit', () => { + const limiter = new RateLimiter(3, 60000); + expect(limiter.check('ip1')).toBe(true); + expect(limiter.check('ip1')).toBe(true); + expect(limiter.check('ip1')).toBe(true); + }); + + it('blocks requests over the limit', () => { + const limiter = new RateLimiter(2, 60000); + expect(limiter.check('ip1')).toBe(true); + expect(limiter.check('ip1')).toBe(true); + expect(limiter.check('ip1')).toBe(false); + }); + + it('tracks different keys independently', () => { + const limiter = new RateLimiter(1, 60000); + expect(limiter.check('ip1')).toBe(true); + expect(limiter.check('ip2')).toBe(true); + expect(limiter.check('ip1')).toBe(false); + }); +}); From c06a78b5fc15867ad8b329aaef704e58f3bf179b Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 11:55:47 -0500 Subject: [PATCH 07/38] feat: add session-based auth system --- src/lib/server/auth.ts | 71 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/lib/server/auth.ts diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts new file mode 100644 index 0000000..316e8fb --- /dev/null +++ b/src/lib/server/auth.ts @@ -0,0 +1,71 @@ +import { db } from './db'; +import { users, sessions } from './schema'; +import { eq, and, gt } from 'drizzle-orm'; +import { generateSessionToken, hashSessionToken, verifyPassword } from './auth-utils'; + +const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +export async function createSession(userId: number): Promise { + const token = generateSessionToken(); + const sessionId = hashSessionToken(token); + const expiresAt = new Date(Date.now() + SESSION_DURATION_MS); + + await db.insert(sessions).values({ sessionId, userId, expiresAt }); + return token; +} + +export async function validateSession(token: string) { + const sessionId = hashSessionToken(token); + + const result = await db + .select({ + session: sessions, + user: { + id: users.id, + email: users.email, + name: users.name, + role: users.role, + active: users.active, + mustChangePassword: users.mustChangePassword + } + }) + .from(sessions) + .innerJoin(users, eq(sessions.userId, users.id)) + .where( + and( + eq(sessions.sessionId, sessionId), + gt(sessions.expiresAt, new Date()) + ) + ) + .limit(1); + + if (result.length === 0) return null; + + const { session, user } = result[0]; + if (!user.active) return null; + + return { session, user }; +} + +export async function destroySession(token: string): Promise { + const sessionId = hashSessionToken(token); + await db.delete(sessions).where(eq(sessions.sessionId, sessionId)); +} + +export async function authenticateUser(email: string, password: string) { + const result = await db + .select() + .from(users) + .where(eq(users.email, email.toLowerCase())) + .limit(1); + + if (result.length === 0) return null; + + const user = result[0]; + if (!user.active) return null; + + const valid = await verifyPassword(user.passwordHash, password); + if (!valid) return null; + + return user; +} From 0367886ac6a3a0ad77f932f1664c01562395e5e7 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 11:56:48 -0500 Subject: [PATCH 08/38] feat: add i18n system with English locale Co-Authored-By: Claude Sonnet 4.6 --- src/lib/i18n/index.ts | 46 ++++++++++ src/lib/i18n/locales/en.json | 163 +++++++++++++++++++++++++++++++++++ tests/unit/i18n.test.ts | 26 ++++++ 3 files changed, 235 insertions(+) create mode 100644 src/lib/i18n/index.ts create mode 100644 src/lib/i18n/locales/en.json create mode 100644 tests/unit/i18n.test.ts diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts new file mode 100644 index 0000000..a9af22d --- /dev/null +++ b/src/lib/i18n/index.ts @@ -0,0 +1,46 @@ +type Translations = Record; + +export function createI18n(translations: Translations) { + function t(key: string, vars?: Record): string { + let value = translations[key]; + if (value === undefined) return key; + + if (vars) { + for (const [varName, varValue] of Object.entries(vars)) { + value = value.replaceAll(`{${varName}}`, varValue); + } + } + + return value; + } + + return { t }; +} + +function flattenObject(obj: Record, prefix = ''): Translations { + const result: Translations = {}; + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + Object.assign(result, flattenObject(value as Record, fullKey)); + } else { + result[fullKey] = String(value); + } + } + return result; +} + +let cachedI18n: ReturnType | null = null; + +export async function loadI18n(locale = 'en'): Promise> { + if (cachedI18n) return cachedI18n; + + const localeData = await import(`./locales/${locale}.json`); + const flat = flattenObject(localeData.default || localeData); + cachedI18n = createI18n(flat); + return cachedI18n; +} + +export function resetI18nCache(): void { + cachedI18n = null; +} diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json new file mode 100644 index 0000000..8c772e7 --- /dev/null +++ b/src/lib/i18n/locales/en.json @@ -0,0 +1,163 @@ +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "create": "Create", + "back": "Back", + "search": "Search", + "filter": "Filter", + "loading": "Loading...", + "noResults": "No results found", + "confirm": "Are you sure?", + "yes": "Yes", + "no": "No", + "submit": "Submit", + "required": "Required", + "optional": "Optional", + "actions": "Actions", + "status": "Status", + "date": "Date", + "name": "Name", + "email": "Email", + "phone": "Phone", + "notes": "Notes", + "description": "Description" + }, + "auth": { + "login": "Log In", + "logout": "Log Out", + "register": "Register", + "email": "Email", + "password": "Password", + "currentPassword": "Current Password", + "newPassword": "New Password", + "confirmPassword": "Confirm Password", + "changePassword": "Change Password", + "invalidCredentials": "Invalid email or password", + "accountInactive": "Account is not active", + "passwordMinLength": "Password must be at least 8 characters", + "passwordsDoNotMatch": "Passwords do not match" + }, + "nav": { + "home": "Home", + "pets": "Our Pets", + "about": "About", + "events": "Events", + "donate": "Donate", + "contact": "Contact", + "apply": "Apply", + "adoptionProcess": "Adoption Process", + "becomeAFoster": "Become a Foster", + "howToHelp": "How to Help", + "volunteer": "Volunteer", + "whyAdopt": "Why Adopt", + "happyTails": "Happy Tails", + "sponsors": "Sponsors", + "fosterResources": "Foster Resources", + "nameGenerator": "Name Generator" + }, + "pets": { + "title": "Our Pets", + "available": "Available for Adoption", + "featured": "Featured Pets", + "noFeatured": "No featured pets at the moment", + "noPets": "No pets available at the moment", + "adoptionFee": "Adoption Fee", + "learnMore": "Learn More", + "applyToAdopt": "Apply to Adopt", + "applyToFoster": "Apply to Foster", + "filterSpecies": "Species", + "filterAge": "Age", + "filterSex": "Sex", + "all": "All" + }, + "applications": { + "adoption": "Adoption Application", + "foster": "Foster Application", + "volunteer": "Volunteer Application", + "surrender": "Surrender Application", + "thankYou": "Thank you for your application!", + "weWillReview": "We will review your application and get back to you soon." + }, + "foster": { + "portal": "Foster Portal", + "myPets": "My Foster Pets", + "submitUpdate": "Submit Update", + "requestSupplies": "Request Supplies", + "noPets": "You don't have any foster pets assigned yet." + }, + "admin": { + "dashboard": "Dashboard", + "pets": "Pets", + "applications": "Applications", + "sponsors": "Sponsors", + "donations": "Donations", + "expenses": "Expenses", + "vets": "Vets", + "events": "Events", + "content": "Content", + "newsletter": "Newsletter", + "happyTails": "Happy Tails", + "users": "Users", + "volunteers": "Volunteers", + "fosters": "Fosters", + "mentors": "Mentors", + "intake": "Intake", + "moderation": "Moderation", + "reports": "Reports", + "analytics": "Analytics", + "traffic": "Live Traffic", + "messages": "Messages", + "import": "Import", + "system": "System", + "fosterDashboard": "My Fosters" + }, + "setup": { + "welcome": "Welcome to FosterFlow!", + "letsGetStarted": "Let's get your rescue set up.", + "step1Title": "Organization Info", + "step2Title": "Branding", + "step3Title": "Application Forms", + "step4Title": "Integrations", + "step5Title": "Import Data", + "next": "Next", + "previous": "Previous", + "finish": "Finish Setup" + }, + "home": { + "hero": "Find Your Forever Friend", + "heroSubtitle": "We rescue, rehabilitate, and rehome animals in need.", + "viewPets": "View Our Pets", + "howToHelp": "How to Help", + "stats": { + "adopted": "Pets Adopted", + "inCare": "Currently in Care", + "fosters": "Active Fosters" + }, + "upcomingEvents": "Upcoming Events", + "recentHappyTails": "Recent Happy Tails" + }, + "contact": { + "title": "Contact Us", + "send": "Send Message", + "sent": "Message sent! We'll get back to you soon." + }, + "newsletter": { + "subscribe": "Subscribe to Our Newsletter", + "emailPlaceholder": "Your email address", + "subscribed": "Thanks for subscribing!" + }, + "donate": { + "title": "Donate", + "support": "Support Our Mission", + "oneTime": "One-Time Donation", + "monthly": "Monthly Donation" + }, + "footer": { + "quickLinks": "Quick Links", + "connect": "Connect With Us", + "copyright": "All rights reserved." + } +} diff --git a/tests/unit/i18n.test.ts b/tests/unit/i18n.test.ts new file mode 100644 index 0000000..d8fc7a0 --- /dev/null +++ b/tests/unit/i18n.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { createI18n } from '$lib/i18n'; + +describe('i18n', () => { + const i18n = createI18n({ + 'common.save': 'Save', + 'common.cancel': 'Cancel', + 'pets.title': 'Our Pets', + 'pets.count': '{count} pets available', + 'greeting': 'Hello, {name}!' + }); + + it('returns translated string by key', () => { + expect(i18n.t('common.save')).toBe('Save'); + expect(i18n.t('pets.title')).toBe('Our Pets'); + }); + + it('returns key when translation is missing', () => { + expect(i18n.t('missing.key')).toBe('missing.key'); + }); + + it('interpolates variables', () => { + expect(i18n.t('greeting', { name: 'Justin' })).toBe('Hello, Justin!'); + expect(i18n.t('pets.count', { count: '5' })).toBe('5 pets available'); + }); +}); From 649c324f37c53e5ec1744f1397489b152f5c5c32 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 11:56:51 -0500 Subject: [PATCH 09/38] feat: add org_config loader with caching and defaults Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/config.ts | 128 ++++++++++++++++++++++++++++++++++++++ tests/unit/config.test.ts | 24 +++++++ 2 files changed, 152 insertions(+) create mode 100644 src/lib/server/config.ts create mode 100644 tests/unit/config.test.ts diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts new file mode 100644 index 0000000..bff30ca --- /dev/null +++ b/src/lib/server/config.ts @@ -0,0 +1,128 @@ +import { db } from './db'; +import { orgConfig } from './schema'; + +export interface OrgBranding { + primaryColor: string; + primaryColorLight: string; + primaryColorDark: string; + accentColor: string; + accentColorLight: string; + accentColorDark: string; + logoUrl: string; +} + +export interface OrgInfo { + name: string; + tagline: string; + city: string; + state: string; + phone: string; + email: string; + facebookUrl: string; + instagramUrl: string; +} + +export interface OrgLocale { + language: string; + currency: string; + dateFormat: string; +} + +export interface OrgApplications { + adoptionEnabled: boolean; + fosterEnabled: boolean; + volunteerEnabled: boolean; + surrenderEnabled: boolean; + adoptionFee: number; +} + +export interface OrgConfig { + branding: OrgBranding; + orgInfo: OrgInfo; + locale: OrgLocale; + applications: OrgApplications; + setupComplete: boolean; +} + +const DEFAULTS: OrgConfig = { + branding: { + primaryColor: '#0d9488', + primaryColorLight: '#14b8a6', + primaryColorDark: '#0f766e', + accentColor: '#f59e0b', + accentColorLight: '#fbbf24', + accentColorDark: '#d97706', + logoUrl: '' + }, + orgInfo: { + name: 'My Rescue', + tagline: '', + city: '', + state: '', + phone: '', + email: '', + facebookUrl: '', + instagramUrl: '' + }, + locale: { + language: 'en', + currency: 'USD', + dateFormat: 'MM/DD/YYYY' + }, + applications: { + adoptionEnabled: true, + fosterEnabled: true, + volunteerEnabled: true, + surrenderEnabled: true, + adoptionFee: 250 + }, + setupComplete: false +}; + +const KEY_MAP: Record = { + branding: 'branding', + org_info: 'orgInfo', + locale: 'locale', + applications: 'applications', + setup_complete: 'setupComplete' +}; + +export function parseOrgConfig(rows: { key: string; value: unknown }[]): OrgConfig { + const config = structuredClone(DEFAULTS); + + for (const row of rows) { + const configKey = KEY_MAP[row.key]; + if (!configKey) continue; + + if (configKey === 'setupComplete') { + config.setupComplete = Boolean(row.value); + } else { + config[configKey] = { + ...config[configKey], + ...(row.value as Record) + } as any; + } + } + + return config; +} + +let cachedConfig: OrgConfig | null = null; +let cacheTime = 0; +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +export async function getOrgConfig(): Promise { + if (cachedConfig && Date.now() - cacheTime < CACHE_TTL) { + return cachedConfig; + } + + const rows = await db.select().from(orgConfig); + cachedConfig = parseOrgConfig(rows); + cacheTime = Date.now(); + return cachedConfig; +} + +export function invalidateConfigCache(): void { + cachedConfig = null; + cacheTime = 0; +} diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts new file mode 100644 index 0000000..2e989d7 --- /dev/null +++ b/tests/unit/config.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { parseOrgConfig, type OrgConfig } from '$lib/server/config'; + +describe('config', () => { + it('parses org config rows into structured object', () => { + const rows = [ + { key: 'branding', value: { primaryColor: '#0d9488', accentColor: '#f59e0b', logoUrl: '' } }, + { key: 'org_info', value: { name: 'Test Rescue', city: 'Sioux Falls', state: 'SD' } }, + { key: 'locale', value: { language: 'en', currency: 'USD', dateFormat: 'MM/DD/YYYY' } } + ]; + + const config = parseOrgConfig(rows); + expect(config.branding.primaryColor).toBe('#0d9488'); + expect(config.orgInfo.name).toBe('Test Rescue'); + expect(config.locale.language).toBe('en'); + }); + + it('provides defaults for missing keys', () => { + const config = parseOrgConfig([]); + expect(config.branding.primaryColor).toBe('#0d9488'); + expect(config.orgInfo.name).toBe('My Rescue'); + expect(config.locale.language).toBe('en'); + }); +}); From 3669c0d4f8d014d6120061a119da51f6b4d8f665 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 11:59:00 -0500 Subject: [PATCH 10/38] feat: add server modules (audit, upload, email, content, tracking) Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/audit.ts | 20 ++++ src/lib/server/content.ts | 37 +++++++ src/lib/server/email.ts | 212 ++++++++++++++++++++++++++++++++++++++ src/lib/server/track.ts | 36 +++++++ src/lib/server/upload.ts | 66 ++++++++++++ 5 files changed, 371 insertions(+) create mode 100644 src/lib/server/audit.ts create mode 100644 src/lib/server/content.ts create mode 100644 src/lib/server/email.ts create mode 100644 src/lib/server/track.ts create mode 100644 src/lib/server/upload.ts diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts new file mode 100644 index 0000000..aa18d48 --- /dev/null +++ b/src/lib/server/audit.ts @@ -0,0 +1,20 @@ +import { db } from './db'; +import { auditLog } from './schema'; + +export async function logAudit(params: { + userId?: number; + action: string; + entity?: string; + entityId?: number; + diff?: Record; + ipAddress?: string; +}): Promise { + await db.insert(auditLog).values({ + userId: params.userId ?? null, + action: params.action, + entity: params.entity ?? null, + entityId: params.entityId ?? null, + diff: params.diff ?? null, + ipAddress: params.ipAddress ?? null + }); +} diff --git a/src/lib/server/content.ts b/src/lib/server/content.ts new file mode 100644 index 0000000..3ac7e30 --- /dev/null +++ b/src/lib/server/content.ts @@ -0,0 +1,37 @@ +import { db } from './db'; +import { contentBlocks } from './schema'; +import { eq, and } from 'drizzle-orm'; + +export async function getContentBlock(page: string, sectionKey: string): Promise { + const result = await db + .select() + .from(contentBlocks) + .where(and(eq(contentBlocks.page, page), eq(contentBlocks.sectionKey, sectionKey))) + .limit(1); + + if (result.length === 0) return null; + + const content = result[0].content; + if (typeof content === 'string') { + try { return JSON.parse(content); } catch { return content; } + } + return content; +} + +export async function getPageBlocks(page: string): Promise> { + const rows = await db + .select() + .from(contentBlocks) + .where(eq(contentBlocks.page, page)); + + const blocks: Record = {}; + for (const row of rows) { + const content = row.content; + if (typeof content === 'string') { + try { blocks[row.sectionKey] = JSON.parse(content); } catch { blocks[row.sectionKey] = content; } + } else { + blocks[row.sectionKey] = content; + } + } + return blocks; +} diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts new file mode 100644 index 0000000..ff327a5 --- /dev/null +++ b/src/lib/server/email.ts @@ -0,0 +1,212 @@ +import nodemailer from 'nodemailer'; + +function getTransporter() { + if (!process.env.SMTP_USER) return null; + + return nodemailer.createTransport({ + host: process.env.SMTP_HOST || 'smtp.gmail.com', + port: parseInt(process.env.SMTP_PORT || '587'), + secure: false, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS + } + }); +} + +const from = process.env.SMTP_FROM || 'noreply@example.com'; + +async function sendEmail(to: string, subject: string, html: string): Promise { + const transporter = getTransporter(); + if (!transporter) return; + + await transporter.sendMail({ from, to, subject, html }); +} + +export async function sendApplicationNotification( + orgName: string, + staffEmail: string, + applicantName: string, + type: string, + petName?: string +): Promise { + const subject = `[${orgName}] New ${type} application from ${applicantName}`; + const html = ` +

New ${type} Application

+

Applicant: ${applicantName}

+ ${petName ? `

Pet: ${petName}

` : ''} +

Log in to your admin dashboard to review this application.

+ `; + await sendEmail(staffEmail, subject, html); +} + +export async function sendApplicationStatusUpdate( + orgName: string, + to: string, + applicantName: string, + type: string, + status: string, + siteUrl: string +): Promise { + const subject = `[${orgName}] Your ${type} application has been ${status}`; + const html = ` +

Application Update

+

Hi ${applicantName},

+

Your ${type} application has been ${status}.

+

Visit ${siteUrl} for more information.

+ `; + await sendEmail(to, subject, html); +} + +export async function sendFosterWelcomeEmail( + orgName: string, + to: string, + name: string, + tempPassword: string, + loginUrl: string +): Promise { + const subject = `[${orgName}] Welcome to the foster team!`; + const html = ` +

Welcome, ${name}!

+

Your foster account has been created.

+

Login URL: ${loginUrl}

+

Email: ${to}

+

Temporary Password: ${tempPassword}

+

You will be asked to change your password on first login.

+ `; + await sendEmail(to, subject, html); +} + +export async function sendRegistrationNotification( + orgName: string, + staffEmail: string, + name: string, + email: string, + reason: string +): Promise { + const subject = `[${orgName}] New registration request from ${name}`; + const html = ` +

New Registration Request

+

Name: ${name}

+

Email: ${email}

+

Reason: ${reason}

+

Log in to your admin dashboard to approve or deny this request.

+ `; + await sendEmail(staffEmail, subject, html); +} + +export async function sendRegistrationApproved( + orgName: string, + to: string, + name: string, + loginUrl: string +): Promise { + const subject = `[${orgName}] Your account has been approved!`; + const html = ` +

Account Approved

+

Hi ${name}, your account at ${orgName} has been approved.

+

Log in at: ${loginUrl}

+ `; + await sendEmail(to, subject, html); +} + +export async function sendRegistrationDenied( + orgName: string, + to: string, + name: string +): Promise { + const subject = `[${orgName}] Registration update`; + const html = ` +

Registration Update

+

Hi ${name}, unfortunately your registration request at ${orgName} was not approved at this time.

+

If you believe this is an error, please contact us.

+ `; + await sendEmail(to, subject, html); +} + +export async function sendFosterSubmissionNotification( + orgName: string, + staffEmail: string, + fosterName: string, + petName: string, + type: string +): Promise { + const subject = `[${orgName}] New foster ${type} for ${petName}`; + const html = ` +

New Foster Submission

+

Foster: ${fosterName}

+

Pet: ${petName}

+

Type: ${type}

+

Log in to your admin dashboard to review.

+ `; + await sendEmail(staffEmail, subject, html); +} + +export async function sendFosterReviewNotification( + orgName: string, + to: string, + fosterName: string, + petName: string, + approved: boolean +): Promise { + const status = approved ? 'approved' : 'not approved'; + const subject = `[${orgName}] Your submission for ${petName} was ${status}`; + const html = ` +

Submission Review

+

Hi ${fosterName}, your submission for ${petName} has been ${status}.

+ `; + await sendEmail(to, subject, html); +} + +export async function sendSupplyRequestNotification( + orgName: string, + staffEmail: string, + fosterName: string, + items: string, + urgency: string +): Promise { + const urgentTag = urgency === 'high' ? '[URGENT] ' : ''; + const subject = `${urgentTag}[${orgName}] Supply request from ${fosterName}`; + const html = ` +

Supply Request

+

Foster: ${fosterName}

+

Items: ${items}

+

Urgency: ${urgency}

+ `; + await sendEmail(staffEmail, subject, html); +} + +export async function sendNewsletter( + orgName: string, + to: string, + subject: string, + body: string, + siteUrl: string +): Promise { + const html = ` + ${body} +
+

+ Sent by ${orgName}. Visit our website. +

+ `; + await sendEmail(to, `[${orgName}] ${subject}`, html); +} + +export async function sendContactNotification( + orgName: string, + staffEmail: string, + senderName: string, + senderEmail: string, + messageSubject: string, + message: string +): Promise { + const subject = `[${orgName}] Contact form: ${messageSubject || 'New message'}`; + const html = ` +

Contact Form Message

+

From: ${senderName} (${senderEmail})

+

Subject: ${messageSubject || 'N/A'}

+

${message}

+ `; + await sendEmail(staffEmail, subject, html); +} diff --git a/src/lib/server/track.ts b/src/lib/server/track.ts new file mode 100644 index 0000000..0731731 --- /dev/null +++ b/src/lib/server/track.ts @@ -0,0 +1,36 @@ +import { db } from './db'; +import { pageViews } from './schema'; + +const BOT_PATTERNS = [ + /bot/i, /crawl/i, /spider/i, /slurp/i, /mediapartners/i, + /googlebot/i, /bingbot/i, /yandex/i, /baidu/i, /duckduckbot/i, + /facebookexternalhit/i, /twitterbot/i, /linkedinbot/i, + /whatsapp/i, /telegrambot/i, /discordbot/i, /slack/i, + /semrush/i, /ahref/i, /mj12bot/i, /dotbot/i, /petalbot/i, + /bytespider/i, /gptbot/i, /claudebot/i, /anthropic/i, + /headlesschrome/i, /phantomjs/i, /selenium/i, /puppeteer/i, + /curl/i, /wget/i, /python-requests/i, /httpx/i, /axios/i, /node-fetch/i +]; + +function isBot(userAgent: string): boolean { + return BOT_PATTERNS.some((pattern) => pattern.test(userAgent)); +} + +export async function trackPageView(params: { + path: string; + petId?: number; + ipAddress?: string; + userAgent?: string; + referrer?: string; +}): Promise { + const bot = params.userAgent ? isBot(params.userAgent) : false; + + await db.insert(pageViews).values({ + path: params.path, + petId: params.petId ?? null, + ipAddress: params.ipAddress ?? null, + userAgent: params.userAgent ?? null, + referrer: params.referrer ?? null, + isBot: bot + }); +} diff --git a/src/lib/server/upload.ts b/src/lib/server/upload.ts new file mode 100644 index 0000000..ede3387 --- /dev/null +++ b/src/lib/server/upload.ts @@ -0,0 +1,66 @@ +import fs from 'fs/promises'; +import path from 'path'; +import crypto from 'crypto'; + +const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'; +const MAX_SIZE = 10 * 1024 * 1024; // 10MB +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + +const MAGIC_BYTES: Record = { + 'image/jpeg': [0xff, 0xd8, 0xff], + 'image/png': [0x89, 0x50, 0x4e, 0x47], + 'image/gif': [0x47, 0x49, 0x46], + 'image/webp': [0x52, 0x49, 0x46, 0x46] +}; + +function validateMagicBytes(buffer: Buffer, mimeType: string): boolean { + const expected = MAGIC_BYTES[mimeType]; + if (!expected) return false; + for (let i = 0; i < expected.length; i++) { + if (buffer[i] !== expected[i]) return false; + } + return true; +} + +export async function saveUpload( + file: File, + subdir = 'general' +): Promise<{ url: string; path: string }> { + if (file.size > MAX_SIZE) { + throw new Error('File too large (max 10MB)'); + } + + if (!ALLOWED_TYPES.includes(file.type)) { + throw new Error('File type not allowed'); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + + if (!validateMagicBytes(buffer, file.type)) { + throw new Error('File content does not match declared type'); + } + + const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg'; + const filename = `${crypto.randomUUID()}.${ext}`; + const dir = path.join(UPLOAD_DIR, subdir); + + await fs.mkdir(dir, { recursive: true }); + + const filePath = path.join(dir, filename); + await fs.writeFile(filePath, buffer); + + return { + url: `/uploads/${subdir}/${filename}`, + path: filePath + }; +} + +export async function deleteUpload(url: string): Promise { + if (!url.startsWith('/uploads/')) return; + const filePath = path.join(UPLOAD_DIR, url.replace('/uploads/', '')); + try { + await fs.unlink(filePath); + } catch { + // File already gone + } +} From 9dac6f9f8b5226367cea0dd6579fde6bbeb0d71f Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 11:59:31 -0500 Subject: [PATCH 11/38] feat: add server modules (stripe, contract, ai, facebook) Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/ai.ts | 32 ++++++++++++ src/lib/server/contract.ts | 66 ++++++++++++++++++++++++ src/lib/server/facebook.ts | 102 +++++++++++++++++++++++++++++++++++++ src/lib/server/stripe.ts | 94 ++++++++++++++++++++++++++++++++++ 4 files changed, 294 insertions(+) create mode 100644 src/lib/server/ai.ts create mode 100644 src/lib/server/contract.ts create mode 100644 src/lib/server/facebook.ts create mode 100644 src/lib/server/stripe.ts diff --git a/src/lib/server/ai.ts b/src/lib/server/ai.ts new file mode 100644 index 0000000..0006f2d --- /dev/null +++ b/src/lib/server/ai.ts @@ -0,0 +1,32 @@ +import { db } from './db'; +import { aiJobs } from './schema'; +import { eq } from 'drizzle-orm'; + +export async function queueNameGenerationJob(params: { + species: string; + theme?: string; + count?: number; +}): Promise { + const result = await db.insert(aiJobs).values({ + type: 'pet-names', + input: { + species: params.species, + theme: params.theme || 'classic', + count: params.count || 10 + }, + status: 'pending' + }); + + return Number(result[0].insertId); +} + +export async function getJobStatus(jobId: number) { + const result = await db + .select() + .from(aiJobs) + .where(eq(aiJobs.id, jobId)) + .limit(1); + + if (result.length === 0) return null; + return result[0]; +} diff --git a/src/lib/server/contract.ts b/src/lib/server/contract.ts new file mode 100644 index 0000000..3262e16 --- /dev/null +++ b/src/lib/server/contract.ts @@ -0,0 +1,66 @@ +export function generateAdoptionContract(params: { + orgName: string; + orgCity: string; + orgState: string; + petName: string; + species: string; + breed: string; + sex: string; + age: string; + adopterName: string; + adopterEmail: string; + adoptionFee: number; + agreementItems: string[]; + date: string; +}): string { + const items = params.agreementItems + .map((item, i) => `
  • ${i + 1}. ${item}
  • `) + .join('\n'); + + return ` + +Adoption Contract — ${params.petName} + + + +

    ${params.orgName}

    +

    Adoption Contract

    + +
    +
    Pet Name:
    ${params.petName}
    +
    Species:
    ${params.species}
    +
    Breed:
    ${params.breed || 'Unknown'}
    +
    Sex:
    ${params.sex}
    +
    Age:
    ${params.age || 'Unknown'}
    +
    + +
    +
    Adopter:
    ${params.adopterName}
    +
    Email:
    ${params.adopterEmail}
    +
    Adoption Fee:
    $${(params.adoptionFee / 100).toFixed(2)}
    +
    Date:
    ${params.date}
    +
    + +

    Terms of Adoption

    +
      ${items}
    + +
    +
    +
    Adopter Signature
    +
    +
    +
    ${params.orgName} Representative
    +
    +
    + +`; +} diff --git a/src/lib/server/facebook.ts b/src/lib/server/facebook.ts new file mode 100644 index 0000000..6221b72 --- /dev/null +++ b/src/lib/server/facebook.ts @@ -0,0 +1,102 @@ +import { db } from './db'; +import { siteSettings } from './schema'; +import { eq } from 'drizzle-orm'; + +function getFacebookConfig() { + const appId = process.env.FACEBOOK_APP_ID; + const appSecret = process.env.FACEBOOK_APP_SECRET; + if (!appId || !appSecret) return null; + return { appId, appSecret }; +} + +export function isFacebookConfigured(): boolean { + return getFacebookConfig() !== null; +} + +export function getFacebookAuthUrl(redirectUri: string): string | null { + const config = getFacebookConfig(); + if (!config) return null; + + const params = new URLSearchParams({ + client_id: config.appId, + redirect_uri: redirectUri, + scope: 'pages_manage_posts,pages_read_engagement' + }); + + return `https://www.facebook.com/v19.0/dialog/oauth?${params}`; +} + +export async function exchangeCodeForToken(code: string, redirectUri: string): Promise { + const config = getFacebookConfig(); + if (!config) return null; + + const params = new URLSearchParams({ + client_id: config.appId, + client_secret: config.appSecret, + redirect_uri: redirectUri, + code + }); + + const res = await fetch(`https://graph.facebook.com/v19.0/oauth/access_token?${params}`); + if (!res.ok) return null; + + const data = await res.json(); + return data.access_token || null; +} + +export async function getLongLivedToken(shortToken: string): Promise { + const config = getFacebookConfig(); + if (!config) return null; + + const params = new URLSearchParams({ + grant_type: 'fb_exchange_token', + client_id: config.appId, + client_secret: config.appSecret, + fb_exchange_token: shortToken + }); + + const res = await fetch(`https://graph.facebook.com/v19.0/oauth/access_token?${params}`); + if (!res.ok) return null; + + const data = await res.json(); + return data.access_token || null; +} + +export async function getPages(userToken: string): Promise> { + const res = await fetch(`https://graph.facebook.com/v19.0/me/accounts?access_token=${userToken}`); + if (!res.ok) return []; + + const data = await res.json(); + return data.data || []; +} + +export async function postToPage(pageId: string, pageToken: string, message: string, link?: string): Promise { + const body: Record = { message, access_token: pageToken }; + if (link) body.link = link; + + const res = await fetch(`https://graph.facebook.com/v19.0/${pageId}/feed`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + return res.ok; +} + +export async function getFacebookSettings(): Promise<{ pageId: string; pageToken: string; pageName: string } | null> { + const result = await db + .select() + .from(siteSettings) + .where(eq(siteSettings.key, 'facebook')) + .limit(1); + + if (result.length === 0) return null; + + try { + const val = JSON.parse(result[0].value); + if (!val.pageId || !val.pageToken) return null; + return val; + } catch { + return null; + } +} diff --git a/src/lib/server/stripe.ts b/src/lib/server/stripe.ts new file mode 100644 index 0000000..3b329dc --- /dev/null +++ b/src/lib/server/stripe.ts @@ -0,0 +1,94 @@ +import Stripe from 'stripe'; + +function getStripe(): Stripe | null { + if (!process.env.STRIPE_SECRET_KEY) return null; + return new Stripe(process.env.STRIPE_SECRET_KEY); +} + +export async function createDonationCheckout(params: { + amount: number; + donorEmail?: string; + petId?: number; + petName?: string; + successUrl: string; + cancelUrl: string; +}): Promise { + const stripe = getStripe(); + if (!stripe) return null; + + const session = await stripe.checkout.sessions.create({ + payment_method_types: ['card'], + mode: 'payment', + customer_email: params.donorEmail, + line_items: [{ + price_data: { + currency: 'usd', + product_data: { + name: params.petName + ? `Donation for ${params.petName}` + : 'Donation' + }, + unit_amount: params.amount + }, + quantity: 1 + }], + metadata: { + type: 'donation', + petId: params.petId?.toString() || '' + }, + success_url: params.successUrl, + cancel_url: params.cancelUrl + }); + + return session.url; +} + +export async function createAdoptionFeeCheckout(params: { + amount: number; + petId: number; + petName: string; + adopterEmail: string; + successUrl: string; + cancelUrl: string; +}): Promise { + const stripe = getStripe(); + if (!stripe) return null; + + const session = await stripe.checkout.sessions.create({ + payment_method_types: ['card'], + mode: 'payment', + customer_email: params.adopterEmail, + line_items: [{ + price_data: { + currency: 'usd', + product_data: { + name: `Adoption Fee — ${params.petName}` + }, + unit_amount: params.amount + }, + quantity: 1 + }], + metadata: { + type: 'adoption_fee', + petId: params.petId.toString() + }, + success_url: params.successUrl, + cancel_url: params.cancelUrl + }); + + return session.url; +} + +export function constructWebhookEvent( + body: string, + signature: string +): Stripe.Event | null { + const stripe = getStripe(); + if (!stripe || !process.env.STRIPE_WEBHOOK_SECRET) return null; + + return stripe.webhooks.constructEvent( + body, + signature, + process.env.STRIPE_WEBHOOK_SECRET + ); +} From ff6dc03a06cb2cc9e9330dee7178a49f73c0a94c Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:00:30 -0500 Subject: [PATCH 12/38] feat: add toast state, server hooks (auth, guards, tracking, security headers) --- src/app.d.ts | 21 +++++++---- src/hooks.server.ts | 80 +++++++++++++++++++++++++++++++++++++++++ src/lib/toast.svelte.ts | 22 ++++++++++++ 3 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 src/hooks.server.ts create mode 100644 src/lib/toast.svelte.ts diff --git a/src/app.d.ts b/src/app.d.ts index da08e6d..7811f18 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,12 +1,19 @@ -// See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces +import type { OrgConfig } from '$lib/server/config'; + declare global { namespace App { - // interface Error {} - // interface Locals {} - // interface PageData {} - // interface PageState {} - // interface Platform {} + interface Locals { + user?: { + id: number; + email: string; + name: string; + role: string; + active: boolean; + mustChangePassword: boolean; + }; + sessionToken?: string; + orgConfig: OrgConfig; + } } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..5153feb --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,80 @@ +import type { Handle } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; +import { validateSession } from '$lib/server/auth'; +import { getOrgConfig } from '$lib/server/config'; +import { trackPageView } from '$lib/server/track'; + +export const handle: Handle = async ({ event, resolve }) => { + // Load org config (cached) + event.locals.orgConfig = await getOrgConfig(); + + // Session validation + const sessionToken = event.cookies.get('session'); + if (sessionToken) { + const result = await validateSession(sessionToken); + if (result) { + event.locals.user = result.user; + event.locals.sessionToken = sessionToken; + } else { + event.cookies.delete('session', { path: '/' }); + } + } + + // Force password change + if (event.locals.user?.mustChangePassword) { + const path = event.url.pathname; + if (path !== '/change-password' && path !== '/logout' && !path.startsWith('/api/')) { + throw redirect(302, '/change-password'); + } + } + + // Redirect to setup wizard if not complete + if (event.locals.user && !event.locals.orgConfig.setupComplete) { + const path = event.url.pathname; + if ( + path.startsWith('/admin') && + path !== '/admin' && + !path.startsWith('/setup') && + !path.startsWith('/api/') && + path !== '/logout' + ) { + throw redirect(302, '/setup'); + } + } + + // Auth guards + if (event.url.pathname.startsWith('/admin')) { + if (!event.locals.user) { + throw redirect(302, '/login'); + } + } + + if (event.url.pathname.startsWith('/foster')) { + if (!event.locals.user) { + throw redirect(302, '/login'); + } + } + + // Page view tracking (async, don't await) + const path = event.url.pathname; + if (!path.startsWith('/admin') && !path.startsWith('/api/') && !path.startsWith('/foster')) { + const ipAddress = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() + || event.getClientAddress(); + trackPageView({ + path, + ipAddress, + userAgent: event.request.headers.get('user-agent') || undefined, + referrer: event.request.headers.get('referer') || undefined + }); + } + + // Resolve with security headers + const response = await resolve(event); + + response.headers.set('X-Frame-Options', 'SAMEORIGIN'); + response.headers.set('X-Content-Type-Options', 'nosniff'); + response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); + + return response; +}; diff --git a/src/lib/toast.svelte.ts b/src/lib/toast.svelte.ts new file mode 100644 index 0000000..f5a60df --- /dev/null +++ b/src/lib/toast.svelte.ts @@ -0,0 +1,22 @@ +interface Toast { + id: number; + message: string; + type: 'success' | 'error' | 'info'; +} + +let toasts = $state([]); +let nextId = 0; + +export function addToast(message: string, type: Toast['type'] = 'info'): void { + const id = nextId++; + toasts.push({ id, message, type }); + setTimeout(() => removeToast(id), 5000); +} + +export function removeToast(id: number): void { + toasts = toasts.filter((t) => t.id !== id); +} + +export function getToasts(): Toast[] { + return toasts; +} From 7f7d4870d08b143cb91701e1b4da89c2a37fd404 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:02:19 -0500 Subject: [PATCH 13/38] feat: add UI component library (toast, search, badges, modals, upload, table) Co-Authored-By: Claude Sonnet 4.6 --- src/lib/components/admin/DataTable.svelte | 23 ++++++++++ src/lib/components/ui/ConfirmModal.svelte | 29 ++++++++++++ src/lib/components/ui/FileUpload.svelte | 40 ++++++++++++++++ src/lib/components/ui/Pagination.svelte | 27 +++++++++++ src/lib/components/ui/PetSearchSelect.svelte | 48 ++++++++++++++++++++ src/lib/components/ui/SearchBar.svelte | 22 +++++++++ src/lib/components/ui/StatusBadge.svelte | 28 ++++++++++++ src/lib/components/ui/Toast.svelte | 22 +++++++++ 8 files changed, 239 insertions(+) create mode 100644 src/lib/components/admin/DataTable.svelte create mode 100644 src/lib/components/ui/ConfirmModal.svelte create mode 100644 src/lib/components/ui/FileUpload.svelte create mode 100644 src/lib/components/ui/Pagination.svelte create mode 100644 src/lib/components/ui/PetSearchSelect.svelte create mode 100644 src/lib/components/ui/SearchBar.svelte create mode 100644 src/lib/components/ui/StatusBadge.svelte create mode 100644 src/lib/components/ui/Toast.svelte diff --git a/src/lib/components/admin/DataTable.svelte b/src/lib/components/admin/DataTable.svelte new file mode 100644 index 0000000..a39a6ac --- /dev/null +++ b/src/lib/components/admin/DataTable.svelte @@ -0,0 +1,23 @@ + + +
    + + + + {#each headers as header} + + {/each} + + + + {@render children()} + +
    {header}
    +
    diff --git a/src/lib/components/ui/ConfirmModal.svelte b/src/lib/components/ui/ConfirmModal.svelte new file mode 100644 index 0000000..bfeab08 --- /dev/null +++ b/src/lib/components/ui/ConfirmModal.svelte @@ -0,0 +1,29 @@ + + +{#if open} +
    +
    { open = false; oncancel?.(); }} role="button" tabindex="-1" onkeydown={() => {}}>
    +
    +

    {title}

    +

    {message}

    +
    + + +
    +
    +
    +{/if} diff --git a/src/lib/components/ui/FileUpload.svelte b/src/lib/components/ui/FileUpload.svelte new file mode 100644 index 0000000..0e3d425 --- /dev/null +++ b/src/lib/components/ui/FileUpload.svelte @@ -0,0 +1,40 @@ + + +
    { e.preventDefault(); dragging = true; }} + ondragleave={() => dragging = false} + ondrop={handleDrop} + onclick={() => input.click()} + role="button" + tabindex="0" + onkeydown={(e) => { if (e.key === 'Enter') input.click(); }} +> + +

    Drop files here or click to browse

    +
    diff --git a/src/lib/components/ui/Pagination.svelte b/src/lib/components/ui/Pagination.svelte new file mode 100644 index 0000000..58ecf4c --- /dev/null +++ b/src/lib/components/ui/Pagination.svelte @@ -0,0 +1,27 @@ + + +{#if totalPages > 1} + +{/if} diff --git a/src/lib/components/ui/PetSearchSelect.svelte b/src/lib/components/ui/PetSearchSelect.svelte new file mode 100644 index 0000000..8de7226 --- /dev/null +++ b/src/lib/components/ui/PetSearchSelect.svelte @@ -0,0 +1,48 @@ + + +
    + open = true} + class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary" + /> + {#if open && results.length > 0} +
      + {#each results as pet} +
    • + +
    • + {/each} +
    + {/if} +
    diff --git a/src/lib/components/ui/SearchBar.svelte b/src/lib/components/ui/SearchBar.svelte new file mode 100644 index 0000000..cd391a0 --- /dev/null +++ b/src/lib/components/ui/SearchBar.svelte @@ -0,0 +1,22 @@ + + +
    { e.preventDefault(); onsubmit?.(value); }} +> + + + + +
    diff --git a/src/lib/components/ui/StatusBadge.svelte b/src/lib/components/ui/StatusBadge.svelte new file mode 100644 index 0000000..376d418 --- /dev/null +++ b/src/lib/components/ui/StatusBadge.svelte @@ -0,0 +1,28 @@ + + + + {label} + diff --git a/src/lib/components/ui/Toast.svelte b/src/lib/components/ui/Toast.svelte new file mode 100644 index 0000000..21a69c1 --- /dev/null +++ b/src/lib/components/ui/Toast.svelte @@ -0,0 +1,22 @@ + + +{#if toasts.length > 0} +
    + {#each toasts as toast (toast.id)} +
    + {toast.message} + +
    + {/each} +
    +{/if} From a30205fa285fded984f1ab39f6a05c8ce2f4fc5b Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:03:11 -0500 Subject: [PATCH 14/38] feat: add public site components (header, footer, pet card, sponsors, etc.) --- src/lib/components/public/DonateButton.svelte | 10 +++ src/lib/components/public/Footer.svelte | 54 ++++++++++++++ src/lib/components/public/Header.svelte | 72 +++++++++++++++++++ .../components/public/NewsletterSignup.svelte | 34 +++++++++ src/lib/components/public/PetCard.svelte | 32 +++++++++ src/lib/components/public/SponsorStrip.svelte | 19 +++++ src/lib/components/public/StatsCounter.svelte | 14 ++++ 7 files changed, 235 insertions(+) create mode 100644 src/lib/components/public/DonateButton.svelte create mode 100644 src/lib/components/public/Footer.svelte create mode 100644 src/lib/components/public/Header.svelte create mode 100644 src/lib/components/public/NewsletterSignup.svelte create mode 100644 src/lib/components/public/PetCard.svelte create mode 100644 src/lib/components/public/SponsorStrip.svelte create mode 100644 src/lib/components/public/StatsCounter.svelte diff --git a/src/lib/components/public/DonateButton.svelte b/src/lib/components/public/DonateButton.svelte new file mode 100644 index 0000000..4e549fb --- /dev/null +++ b/src/lib/components/public/DonateButton.svelte @@ -0,0 +1,10 @@ + + + + {label} + diff --git a/src/lib/components/public/Footer.svelte b/src/lib/components/public/Footer.svelte new file mode 100644 index 0000000..3ed7376 --- /dev/null +++ b/src/lib/components/public/Footer.svelte @@ -0,0 +1,54 @@ + + + diff --git a/src/lib/components/public/Header.svelte b/src/lib/components/public/Header.svelte new file mode 100644 index 0000000..3c0c14e --- /dev/null +++ b/src/lib/components/public/Header.svelte @@ -0,0 +1,72 @@ + + +
    +
    +
    + + {#if logoUrl} + {orgName} + {:else} + {orgName} + {/if} + + + + + +
    + + {#if mobileOpen} + + {/if} +
    +
    diff --git a/src/lib/components/public/NewsletterSignup.svelte b/src/lib/components/public/NewsletterSignup.svelte new file mode 100644 index 0000000..8044385 --- /dev/null +++ b/src/lib/components/public/NewsletterSignup.svelte @@ -0,0 +1,34 @@ + + +{#if submitted} +

    Thanks for subscribing!

    +{:else} +
    + + +
    +{/if} diff --git a/src/lib/components/public/PetCard.svelte b/src/lib/components/public/PetCard.svelte new file mode 100644 index 0000000..d6eebca --- /dev/null +++ b/src/lib/components/public/PetCard.svelte @@ -0,0 +1,32 @@ + + + +
    + {#if pet.photoUrl} + {pet.name} + {:else} +
    🐾
    + {/if} +
    +
    +

    {pet.name}

    +

    + {pet.breed || pet.species} + {#if pet.age} · {pet.age}{/if} + · {pet.sex} +

    +
    +
    diff --git a/src/lib/components/public/SponsorStrip.svelte b/src/lib/components/public/SponsorStrip.svelte new file mode 100644 index 0000000..e991815 --- /dev/null +++ b/src/lib/components/public/SponsorStrip.svelte @@ -0,0 +1,19 @@ + + +
    +
    + {#each sponsors as sponsor} + {#if sponsor.logoUrl} + + {sponsor.name} + + {:else} + {sponsor.name} + {/if} + {/each} +
    +
    diff --git a/src/lib/components/public/StatsCounter.svelte b/src/lib/components/public/StatsCounter.svelte new file mode 100644 index 0000000..70fe77a --- /dev/null +++ b/src/lib/components/public/StatsCounter.svelte @@ -0,0 +1,14 @@ + + +
    + {#each stats as stat} +
    +
    {stat.value.toLocaleString()}
    +
    {stat.label}
    +
    + {/each} +
    From 2fa3d4ec67d8e7e8cec73c04d48ade3c13a0147f Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:03:31 -0500 Subject: [PATCH 15/38] feat: add root and public layouts with config-driven theming --- src/routes/(public)/+layout.server.ts | 16 +++++++++++++++ src/routes/(public)/+layout.svelte | 29 +++++++++++++++++++++++++++ src/routes/+layout.server.ts | 8 ++++++++ src/routes/+layout.svelte | 8 +++----- 4 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 src/routes/(public)/+layout.server.ts create mode 100644 src/routes/(public)/+layout.svelte create mode 100644 src/routes/+layout.server.ts diff --git a/src/routes/(public)/+layout.server.ts b/src/routes/(public)/+layout.server.ts new file mode 100644 index 0000000..3babdc5 --- /dev/null +++ b/src/routes/(public)/+layout.server.ts @@ -0,0 +1,16 @@ +import { db } from '$lib/server/db'; +import { sponsors } from '$lib/server/schema'; +import { eq, asc } from 'drizzle-orm'; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ parent }) => { + const parentData = await parent(); + + const allSponsors = await db + .select() + .from(sponsors) + .where(eq(sponsors.active, true)) + .orderBy(asc(sponsors.sortOrder)); + + return { ...parentData, sponsors: allSponsors }; +}; diff --git a/src/routes/(public)/+layout.svelte b/src/routes/(public)/+layout.svelte new file mode 100644 index 0000000..b49d936 --- /dev/null +++ b/src/routes/(public)/+layout.svelte @@ -0,0 +1,29 @@ + + + + + + + {@html ``} + + +
    +
    +
    + {@render children()} +
    +
    +
    diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..21de368 --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,8 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals }) => { + return { + user: locals.user, + orgConfig: locals.orgConfig + }; +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9cebde5..aa71a3c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,11 +1,9 @@ - - - - {@render children()} + From f9002898f834541c96529de4d684e7befde62a93 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:10:39 -0500 Subject: [PATCH 16/38] Add home page, pets listing, pet detail, and API endpoint - Home page loads featured pets, upcoming events, content blocks, and stats - Pets listing page with search/filter by species and sex, infinite scroll - Pet detail page with photo gallery/lightbox, compatibility badges, and related pets - /api/pets GET endpoint for infinite scroll pagination - All schema adapted for pawprint (url, startDate, published, etc.) - All org name references driven by data.orgConfig.orgInfo.name Co-Authored-By: Claude Sonnet 4.6 --- src/routes/(public)/+page.server.ts | 87 ++++ src/routes/(public)/+page.svelte | 298 ++++++++++++ src/routes/(public)/pets/+page.server.ts | 63 +++ src/routes/(public)/pets/+page.svelte | 247 ++++++++++ .../(public)/pets/[slug]/+page.server.ts | 79 ++++ src/routes/(public)/pets/[slug]/+page.svelte | 426 ++++++++++++++++++ src/routes/api/pets/+server.ts | 64 +++ 7 files changed, 1264 insertions(+) create mode 100644 src/routes/(public)/+page.server.ts create mode 100644 src/routes/(public)/+page.svelte create mode 100644 src/routes/(public)/pets/+page.server.ts create mode 100644 src/routes/(public)/pets/+page.svelte create mode 100644 src/routes/(public)/pets/[slug]/+page.server.ts create mode 100644 src/routes/(public)/pets/[slug]/+page.svelte create mode 100644 src/routes/api/pets/+server.ts diff --git a/src/routes/(public)/+page.server.ts b/src/routes/(public)/+page.server.ts new file mode 100644 index 0000000..6702df4 --- /dev/null +++ b/src/routes/(public)/+page.server.ts @@ -0,0 +1,87 @@ +import { db } from '$lib/server/db'; +import { pets, petPhotos, events, fosterAssignments } from '$lib/server/schema'; +import { eq, and, gt, asc, or, isNotNull, desc, count } from 'drizzle-orm'; +import { getPageBlocks } from '$lib/server/content'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const parentData = await parent(); + + // Featured pets with primary photos + const rows = await db + .select({ + id: pets.id, + name: pets.name, + slug: pets.slug, + breed: pets.breed, + age: pets.age, + sex: pets.sex, + species: pets.species, + photoUrl: petPhotos.url + }) + .from(pets) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where(and( + eq(pets.status, 'available'), + eq(pets.published, true), + or(eq(pets.featured, true), isNotNull(petPhotos.url)) + )) + .orderBy(desc(pets.featured), pets.name) + .limit(8); + + // Upcoming events (next 3) + const now = new Date(); + const upcomingEvents = await db + .select() + .from(events) + .where(and(eq(events.published, true), gt(events.startDate, now))) + .orderBy(asc(events.startDate)) + .limit(3); + + const content = await getPageBlocks('home'); + + // Stats + const [{ totalRescued }] = await db + .select({ totalRescued: count() }) + .from(pets); + + const [{ totalAdopted }] = await db + .select({ totalAdopted: count() }) + .from(pets) + .where(eq(pets.status, 'adopted')); + + const [{ currentFosters }] = await db + .select({ currentFosters: count() }) + .from(fosterAssignments) + .where(eq(fosterAssignments.isCurrent, true)); + + const [{ availableNow }] = await db + .select({ availableNow: count() }) + .from(pets) + .where(and(eq(pets.status, 'available'), eq(pets.published, true))); + + const foundedYear = (parentData.orgConfig as { foundedYear?: number }).foundedYear; + const yearsRescuing = new Date().getFullYear() - (foundedYear ?? new Date().getFullYear()); + + return { + ...parentData, + featuredPets: rows.map((row) => ({ + name: row.name, + slug: row.slug, + breed: row.breed, + age: row.age, + sex: row.sex, + species: row.species, + primaryPhoto: row.photoUrl ?? null + })), + upcomingEvents, + content, + stats: { + totalRescued: totalRescued ?? 0, + totalAdopted: totalAdopted ?? 0, + currentFosters: currentFosters ?? 0, + availableNow: availableNow ?? 0, + yearsRescuing + } + }; +}; diff --git a/src/routes/(public)/+page.svelte b/src/routes/(public)/+page.svelte new file mode 100644 index 0000000..54e0714 --- /dev/null +++ b/src/routes/(public)/+page.svelte @@ -0,0 +1,298 @@ + + + + {orgName} — {tagline} + + + + + + + + + +
    +
    +
    +

    + {(data.content as Record>)?.hero?.heading ?? tagline} +

    +

    + {(data.content as Record>)?.hero?.subtext ?? 'A foster-based rescue committed to giving every animal a second chance. Through compassion, community, and dedicated foster families, we help pets find the loving homes they deserve.'} +

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

    Pets Looking for Homes

    +

    + Meet some of our wonderful animals waiting for their forever families. +

    +
    + + {#if data.featuredPets.length > 0} +
    + {#each data.featuredPets as pet} + + {/each} +
    + {:else} +
    +

    No pets available at this time. Check back soon!

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

    How You Can Help

    +

    + There are many ways to make a difference in the life of a rescue pet. +

    +
    + +
    +
    +
    + + + +
    +

    Adopt

    +

    + Open your heart and home to a rescue pet. Our adoption process is thorough, and we will help you find the perfect match for your family. +

    + + Browse Pets + + + + +
    + +
    +
    + + + +
    +

    Foster

    +

    + Provide a temporary safe haven for a pet in need. Fostering saves lives and helps pets prepare for their forever homes. +

    + + Learn More + + + + +
    + +
    +
    + + + +
    +

    Donate

    +

    + Your generous donations help cover veterinary care, food, and supplies for our rescue pets. Every dollar makes a difference. +

    + + Give Now + + + + +
    +
    +
    +
    + + +
    +
    +
    +
    +

    Stay in the Loop

    +

    + Get rescue updates, adoption stories, and event news delivered to your inbox. +

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

    Upcoming Events

    +

    + Join us at an upcoming event to meet our pets and support our mission. +

    +
    + + {#if data.upcomingEvents.length > 0} +
    + {#each data.upcomingEvents as event} +
    + {#if event.imageUrl} +
    + {event.title} +
    + {:else} +
    + + + +
    + {/if} +
    +
    + + + + {new Date(event.startDate).toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric' + })} +
    +

    {event.title}

    + {#if event.location} +
    + + + + + {event.location} +
    + {/if} + {#if event.description} +

    {event.description}

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

    No upcoming events at this time.

    + {#if facebookUrl} +

    Check back soon or follow us on Facebook for updates.

    + {/if} +
    + {/if} +
    +
    + + +{#if facebookUrl} +
    +
    +

    Follow Us

    +

    + Stay up to date with our latest rescue stories, adoption news, and event announcements. +

    +
    + {#if facebookUrl} + + + + + Follow on Facebook + + {/if} +
    +
    +
    +{/if} diff --git a/src/routes/(public)/pets/+page.server.ts b/src/routes/(public)/pets/+page.server.ts new file mode 100644 index 0000000..13966b9 --- /dev/null +++ b/src/routes/(public)/pets/+page.server.ts @@ -0,0 +1,63 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { pets, petPhotos } from '$lib/server/schema'; +import { eq, and, like, count, or, isNotNull, desc } from 'drizzle-orm'; +import type { SQL } from 'drizzle-orm'; + +const PER_PAGE = 12; + +export const load: PageServerLoad = async ({ url, parent }) => { + const parentData = await parent(); + + const q = url.searchParams.get('q')?.trim() || ''; + const species = url.searchParams.get('species') || ''; + const sex = url.searchParams.get('sex') || ''; + + const conditions: SQL[] = [eq(pets.status, 'available'), eq(pets.published, true)]; + + if (q) { + conditions.push(like(pets.name, `%${q}%`)); + } + + if (species === 'dog' || species === 'cat') { + conditions.push(eq(pets.species, species)); + } + + if (sex === 'male' || sex === 'female') { + conditions.push(eq(pets.sex, sex)); + } + + const whereClause = and(...conditions)!; + + const [{ total }] = await db + .select({ total: count() }) + .from(pets) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where(and(whereClause, or(eq(pets.featured, true), isNotNull(petPhotos.url)))); + + const rows = await db + .select({ + id: pets.id, + name: pets.name, + slug: pets.slug, + species: pets.species, + breed: pets.breed, + age: pets.age, + sex: pets.sex, + primaryPhoto: petPhotos.url + }) + .from(pets) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where(and(whereClause, or(eq(pets.featured, true), isNotNull(petPhotos.url)))) + .orderBy(desc(pets.featured), desc(pets.createdAt)) + .limit(PER_PAGE) + .offset(0); + + return { + ...parentData, + pets: rows, + total, + hasMore: rows.length < total, + filters: { q, species, sex } + }; +}; diff --git a/src/routes/(public)/pets/+page.svelte b/src/routes/(public)/pets/+page.svelte new file mode 100644 index 0000000..fedb3e1 --- /dev/null +++ b/src/routes/(public)/pets/+page.svelte @@ -0,0 +1,247 @@ + + + + Available Pets | {orgName} + + + + + + + + + +
    +
    +

    Available Pets

    +

    + Every one of these pets is looking for a loving forever home. Could you be the one? +

    +
    +
    + + +
    +
    +
    +
    + + + + +
    + +
    + + + + + +
    +
    + + {#if hasActiveFilters} +
    + Showing {totalCount} result{totalCount === 1 ? '' : 's'} + | + +
    + {/if} +
    +
    + + +
    + {#if allPets.length === 0 && !loading} +
    +
    + + + +
    +

    No pets found

    +

    + No pets match your current criteria. Try adjusting your filters or check back soon — new pets arrive regularly! +

    + + Clear Filters + +
    + {:else} +
    + {#each allPets as pet (pet.id)} +
    + +
    + {/each} +
    + +
    + + {#if loading} +
    + + + + + Loading more pets... +
    + {/if} + + {#if !hasMore && allPets.length > 0 && !loading} +

    That's everyone!

    + {/if} + {/if} +
    diff --git a/src/routes/(public)/pets/[slug]/+page.server.ts b/src/routes/(public)/pets/[slug]/+page.server.ts new file mode 100644 index 0000000..5a055c8 --- /dev/null +++ b/src/routes/(public)/pets/[slug]/+page.server.ts @@ -0,0 +1,79 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { pets, petPhotos } from '$lib/server/schema'; +import { eq, and, ne, asc } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import { trackPageView } from '$lib/server/track'; + +export const load: PageServerLoad = async ({ params, request, getClientAddress, url, parent }) => { + const parentData = await parent(); + + const [pet] = await db + .select() + .from(pets) + .where(eq(pets.slug, params.slug)) + .limit(1); + + if (!pet) { + throw error(404, 'Pet not found'); + } + + // Track page view (fire and forget) + const ipAddress = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + getClientAddress(); + trackPageView({ + path: `/pets/${params.slug}`, + petId: pet.id, + ipAddress, + userAgent: request.headers.get('user-agent') ?? undefined, + referrer: request.headers.get('referer') ?? undefined + }); + + const photos = await db + .select() + .from(petPhotos) + .where(eq(petPhotos.petId, pet.id)) + .orderBy(asc(petPhotos.sortOrder)); + + const primaryPhoto = photos.find((p) => p.isPrimary) ?? photos[0] ?? null; + + // Related pets: same species, available, not this pet, limit 4 + const relatedRows = await db + .select({ + id: pets.id, + name: pets.name, + slug: pets.slug, + species: pets.species, + breed: pets.breed, + age: pets.age, + sex: pets.sex, + primaryPhoto: petPhotos.url + }) + .from(pets) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where( + and( + eq(pets.species, pet.species), + eq(pets.status, 'available'), + eq(pets.published, true), + ne(pets.id, pet.id) + ) + ) + .orderBy(pets.name) + .limit(4); + + const origin = url.origin; + const pageUrl = `${origin}/pets/${params.slug}`; + const ogImageUrl = primaryPhoto ? `${origin}${primaryPhoto.url}` : null; + + return { + ...parentData, + pet, + photos, + primaryPhoto, + relatedPets: relatedRows, + pageUrl, + ogImageUrl + }; +}; diff --git a/src/routes/(public)/pets/[slug]/+page.svelte b/src/routes/(public)/pets/[slug]/+page.svelte new file mode 100644 index 0000000..8109090 --- /dev/null +++ b/src/routes/(public)/pets/[slug]/+page.svelte @@ -0,0 +1,426 @@ + + + + {pet.name} | {orgName} + + + + + {#if ogImageUrl} + + {/if} + + + + + + + +
    +
    + +
    +
    + +
    +
    + +
    +
    + {#if currentPhoto} + {pet.name} - Photo {selectedPhotoIndex + 1} openLightbox(selectedPhotoIndex)} + role="button" + tabindex="0" + onkeydown={(e) => { if (e.key === 'Enter') openLightbox(selectedPhotoIndex); }} + /> + {:else} +
    + + + +

    Photo coming soon

    +
    + {/if} +
    + + {#if photos.length > 1} +
    + {#each photos as photo, i} + + {/each} +
    + {/if} +
    + + +
    +
    + + {statusLabels[pet.status] ?? pet.status} + +
    + +

    {pet.name}

    + + {#if pet.breed} +

    {pet.breed}

    + {/if} + + +
    + Share: + + + + + Share + + +
    + + +
    + {#if pet.species} +
    +

    Species

    +

    {pet.species}

    +
    + {/if} + {#if pet.age} +
    +

    Age

    +

    {pet.age}

    +
    + {/if} + {#if pet.sex && pet.sex !== 'unknown'} +
    +

    Sex

    +

    {pet.sex}

    +
    + {/if} + {#if pet.weight} +
    +

    Weight

    +

    {pet.weight}

    +
    + {/if} +
    + + + {#if pet.goodWithDogs !== null || pet.goodWithCats !== null || pet.goodWithKids !== null} +
    + {#if pet.goodWithDogs === true} + Good with dogs + {:else if pet.goodWithDogs === false} + Not good with dogs + {/if} + {#if pet.goodWithCats === true} + Good with cats + {:else if pet.goodWithCats === false} + Not good with cats + {/if} + {#if pet.goodWithKids === true} + Good with kids + {:else if pet.goodWithKids === false} + Not good with kids + {/if} + {#if pet.houseTrained} + House trained + {/if} + {#if pet.spayedNeutered} + Spayed/Neutered + {/if} + {#if pet.microchipped} + Microchipped + {/if} +
    + {/if} + + + {#if pet.description} +
    +

    About {pet.name}

    +
    + {pet.description} +
    +
    + {/if} + + + {#if pet.status === 'available'} + + {:else if pet.status === 'pending'} +
    +

    + An adoption application for {pet.name} is currently being reviewed. You may still + submit an application + to be considered. +

    +
    + {/if} + + {#if pet.adoptionFee} +

    + Adoption fee: ${(pet.adoptionFee / 100).toFixed(2)} +

    + {/if} +
    +
    + + + {#if pet.intakeDate || pet.microchipId || pet.birthday} +
    +

    Details

    +
    + {#if pet.intakeDate} +
    +
    Intake Date
    +
    {formatDate(pet.intakeDate)}
    +
    + {/if} + {#if pet.birthday} +
    +
    Date of Birth
    +
    {formatDate(pet.birthday)}
    +
    + {/if} + {#if pet.microchipId} +
    +
    Microchip
    +
    {pet.microchipId}
    +
    + {/if} +
    +
    + {/if} + + + {#if pet.medicalNotes} +
    +

    Medical Notes

    +

    {pet.medicalNotes}

    +
    + {/if} + + + {#if relatedPets.length > 0} +
    +

    You Might Also Like

    +
    + {#each relatedPets as rp (rp.id)} + + {/each} +
    +
    + {/if} +
    + + +{#if lightboxOpen && photos.length > 0} + +{/if} + + diff --git a/src/routes/api/pets/+server.ts b/src/routes/api/pets/+server.ts new file mode 100644 index 0000000..979cb9b --- /dev/null +++ b/src/routes/api/pets/+server.ts @@ -0,0 +1,64 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { db } from '$lib/server/db'; +import { pets, petPhotos } from '$lib/server/schema'; +import { eq, and, like, count, or, isNotNull, desc } from 'drizzle-orm'; +import type { SQL } from 'drizzle-orm'; + +const DEFAULT_LIMIT = 12; + +export const GET: RequestHandler = async ({ url }) => { + const q = url.searchParams.get('q')?.trim() || ''; + const species = url.searchParams.get('species') || ''; + const sex = url.searchParams.get('sex') || ''; + const offset = Math.max(0, parseInt(url.searchParams.get('offset') || '0', 10)); + const limit = Math.min(48, Math.max(1, parseInt(url.searchParams.get('limit') || String(DEFAULT_LIMIT), 10))); + + const conditions: SQL[] = [eq(pets.status, 'available'), eq(pets.published, true)]; + + if (q) { + conditions.push(like(pets.name, `%${q}%`)); + } + + if (species === 'dog' || species === 'cat') { + conditions.push(eq(pets.species, species)); + } + + if (sex === 'male' || sex === 'female') { + conditions.push(eq(pets.sex, sex)); + } + + const whereClause = and(...conditions)!; + const photoFilter = and(whereClause, or(eq(pets.featured, true), isNotNull(petPhotos.url))); + + const [{ total }] = await db + .select({ total: count() }) + .from(pets) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where(photoFilter); + + const rows = await db + .select({ + id: pets.id, + name: pets.name, + slug: pets.slug, + species: pets.species, + breed: pets.breed, + age: pets.age, + sex: pets.sex, + status: pets.status, + primaryPhoto: petPhotos.url + }) + .from(pets) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where(photoFilter) + .orderBy(desc(pets.featured), desc(pets.createdAt)) + .limit(limit) + .offset(offset); + + return json({ + pets: rows, + hasMore: offset + rows.length < total, + total + }); +}; From 708a434032ef1e74a909995c92f5c225b4e9231d Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:14:17 -0500 Subject: [PATCH 17/38] Add static content pages (about, adoption-process, become-a-foster, etc.) - About, adoption process, become a foster, how to help, volunteer, why adopt pages - All org name/email/contact info driven by data.orgConfig - Foster resources page with optional FOSTER_RESOURCES_PASSWORD password gate - Content blocks loaded from DB with sensible generic fallback text - Application links conditionally shown based on orgConfig.applications flags Co-Authored-By: Claude Sonnet 4.6 --- src/routes/(public)/about/+page.server.ts | 8 + src/routes/(public)/about/+page.svelte | 144 +++++++++++++++ .../(public)/adoption-process/+page.server.ts | 8 + .../(public)/adoption-process/+page.svelte | 138 +++++++++++++++ .../(public)/become-a-foster/+page.server.ts | 8 + .../(public)/become-a-foster/+page.svelte | 89 ++++++++++ .../(public)/foster-resources/+page.server.ts | 41 +++++ .../(public)/foster-resources/+page.svelte | 72 ++++++++ .../(public)/how-to-help/+page.server.ts | 8 + src/routes/(public)/how-to-help/+page.svelte | 165 ++++++++++++++++++ src/routes/(public)/volunteer/+page.server.ts | 8 + src/routes/(public)/volunteer/+page.svelte | 82 +++++++++ src/routes/(public)/why-adopt/+page.server.ts | 8 + src/routes/(public)/why-adopt/+page.svelte | 64 +++++++ 14 files changed, 843 insertions(+) create mode 100644 src/routes/(public)/about/+page.server.ts create mode 100644 src/routes/(public)/about/+page.svelte create mode 100644 src/routes/(public)/adoption-process/+page.server.ts create mode 100644 src/routes/(public)/adoption-process/+page.svelte create mode 100644 src/routes/(public)/become-a-foster/+page.server.ts create mode 100644 src/routes/(public)/become-a-foster/+page.svelte create mode 100644 src/routes/(public)/foster-resources/+page.server.ts create mode 100644 src/routes/(public)/foster-resources/+page.svelte create mode 100644 src/routes/(public)/how-to-help/+page.server.ts create mode 100644 src/routes/(public)/how-to-help/+page.svelte create mode 100644 src/routes/(public)/volunteer/+page.server.ts create mode 100644 src/routes/(public)/volunteer/+page.svelte create mode 100644 src/routes/(public)/why-adopt/+page.server.ts create mode 100644 src/routes/(public)/why-adopt/+page.svelte diff --git a/src/routes/(public)/about/+page.server.ts b/src/routes/(public)/about/+page.server.ts new file mode 100644 index 0000000..0ec062a --- /dev/null +++ b/src/routes/(public)/about/+page.server.ts @@ -0,0 +1,8 @@ +import { getPageBlocks } from '$lib/server/content'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const parentData = await parent(); + const blocks = await getPageBlocks('about'); + return { ...parentData, blocks }; +}; diff --git a/src/routes/(public)/about/+page.svelte b/src/routes/(public)/about/+page.svelte new file mode 100644 index 0000000..85e7d46 --- /dev/null +++ b/src/routes/(public)/about/+page.svelte @@ -0,0 +1,144 @@ + + + + About Us | {orgName} + + + + +
    +
    +

    {blocks?.intro?.heading ?? `About ${orgName}`}

    +

    + {blocks?.intro?.text ?? 'Every pet deserves a second chance at a happy life.'} +

    +
    +
    + + +
    +
    +
    +

    {blocks?.mission?.heading ?? 'Our Mission'}

    +
    +

    + {blocks?.mission?.text ?? `${orgName} is a foster-based rescue dedicated to saving animals in need and finding them loving forever homes. We believe every pet deserves safety, compassion, and a family to call their own.`} +

    +
    +
    +
    + + +
    +
    +

    {blocks?.how_we_work?.heading ?? 'How We Work'}

    +
    + +
    +
    +
    + + + +
    +

    Foster-Based Model

    +

    + Every pet lives in a loving foster home where they receive personalized attention, socialization, and care while waiting for their forever family. +

    +
    + +
    +
    + + + +
    +

    Complete Vetting

    +

    + Every pet in our care receives a full veterinary workup, vaccinations, spay/neuter surgery, microchipping, and any additional medical treatment they need. +

    +
    + +
    +
    + + + +
    +

    Thoughtful Matching

    +

    + We carefully match each pet with the right family through our application and screening process — for a fit that lasts a lifetime. +

    +
    +
    +
    +
    + + +
    +
    +
    +

    Our Story

    +
    + {#if blocks?.story?.text} +
    {@html blocks.story.text}
    + {:else} +
    +

    + {orgName} was born from a simple belief: no pet should be without a safe place to call home. What started as a small group of passionate animal lovers has grown into a dedicated organization serving our community. +

    +

    + Through our network of incredible foster families, we've been able to rehabilitate, socialize, and prepare pets for life in their forever homes. +

    +

    + Every pet that comes through our program receives the medical care, training support, and love they need to thrive. +

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

    Our Team & Volunteers

    +
    +

    + {orgName} runs on the dedication of our volunteers — from foster families opening their homes to transport drivers, event coordinators, and behind-the-scenes helpers. +

    +
    +
    +

    100%

    +

    Volunteer Run

    +
    +
    +

    Foster

    +

    Based Network

    +
    +
    +

    Hundreds

    +

    Of Pets Saved

    +
    +
    +
    +
    + + +
    +
    +

    Want to Help?

    +

    + See how you can make a difference in the lives of pets who need it most. +

    + + How to Help + +
    +
    diff --git a/src/routes/(public)/adoption-process/+page.server.ts b/src/routes/(public)/adoption-process/+page.server.ts new file mode 100644 index 0000000..7640e33 --- /dev/null +++ b/src/routes/(public)/adoption-process/+page.server.ts @@ -0,0 +1,8 @@ +import { getPageBlocks } from '$lib/server/content'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const parentData = await parent(); + const blocks = await getPageBlocks('adoption-process'); + return { ...parentData, blocks }; +}; diff --git a/src/routes/(public)/adoption-process/+page.svelte b/src/routes/(public)/adoption-process/+page.svelte new file mode 100644 index 0000000..6556945 --- /dev/null +++ b/src/routes/(public)/adoption-process/+page.svelte @@ -0,0 +1,138 @@ + + + + Adoption Process | {orgName} + + + + +
    +
    +

    Adoption Process

    +

    Everything you need to know about adopting from us.

    +
    +
    + + +
    +
    +

    + Thank you for your interest in adopting a pet from {orgName}! + {#if orgEmail} + Please email us at + {orgEmail} + with any questions or concerns. + {/if} +

    +
    +

    + Please read the following information before applying! +

    +
    +
    +
    + + +
    +
    +

    Frequently Asked Questions

    +
    + +
    +
    + + What is the adoption process? + + + + +
    +

    Submit an adoption application and complete each question thoroughly. Once submitted, the following steps will take place:

    +
      +
    • Reference Check — We may contact your vet, landlord, and personal references.
    • +
    • Pre-Adoption Home Visit — We will schedule a visit to ensure the home is safe for the pet.
    • +
    • Meet & Greet — Once approved, we coordinate a time for you to meet the pet.
    • +
    • Adoption is Final — Adoption fees are non-refundable if the pet needs to be returned.
    • +
    +
    +
    + +
    + + What are the adoption fees? + + + + +
    + {#if adoptionFee} +

    The standard adoption fee is ${adoptionFee}. Fees help cover veterinary care, vaccinations, spay/neuter, and microchipping.

    + {:else} +

    + Adoption fees vary. Contact us + {#if orgEmail} + at {orgEmail} + {/if} + for current fee information. +

    + {/if} +
    +
    + +
    + + What happens after my application is approved? + + + + +
    +

    Our adoption team will reach out to coordinate meeting the pet. They will work with both you and the foster family to find a time and place that works for everyone.

    +
    +
    + +
    + + Have more questions? + + + + +
    +

    + Contact us + {#if orgEmail} + at {orgEmail} + {/if} + and we will be happy to answer any questions. +

    +
    +
    +
    +
    +
    + + +{#if data.orgConfig.applications.adoptionEnabled} +
    +
    +

    Ready to Apply?

    +

    + Start the process today and take the first step toward welcoming a rescue pet into your family. +

    + +
    +
    +{/if} diff --git a/src/routes/(public)/become-a-foster/+page.server.ts b/src/routes/(public)/become-a-foster/+page.server.ts new file mode 100644 index 0000000..eecd1e2 --- /dev/null +++ b/src/routes/(public)/become-a-foster/+page.server.ts @@ -0,0 +1,8 @@ +import { getPageBlocks } from '$lib/server/content'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const parentData = await parent(); + const blocks = await getPageBlocks('become-a-foster'); + return { ...parentData, blocks }; +}; diff --git a/src/routes/(public)/become-a-foster/+page.svelte b/src/routes/(public)/become-a-foster/+page.svelte new file mode 100644 index 0000000..c6b7ec9 --- /dev/null +++ b/src/routes/(public)/become-a-foster/+page.svelte @@ -0,0 +1,89 @@ + + + + Become a Foster | {orgName} + + + + +
    +
    +

    Become a Foster

    +

    Open your heart and home to a pet in need.

    +
    +
    + + +
    +
    +

    + Have you ever thought about fostering a pet? All of our animals reside in foster homes so they can have a normal life while awaiting a forever family. Fostering is the backbone of what we do, and we are always in need of caring individuals willing to open their homes. +

    +
    +
    + + +
    +
    +
    +

    What Does Fostering Entail?

    +
    +
    +

    + Fostering means taking in a pet and providing them with a safe, loving environment while they wait for their forever home. You are giving them the gift of a normal home life. +

    +

    + {orgName} provides all necessary supplies including food, crates, leashes, and toys. All vetting is paid for by the rescue, including vaccinations, spay/neuter, and any medical treatment needed. +

    +

    + We also need temporary fosters! Whether you can commit for a few weeks or a few months, every bit of help matters. +

    +
    +
    +
    +
    + + +
    +
    +
    +

    What If I Want to Adopt My Foster Pet?

    +
    +
    +

    + We completely understand that you may fall in love with your foster pet — it happens more often than you might think! Foster families always get first consideration for adopting the animal in their care. +

    +

    + As a foster, you also play an important role in helping choose the right adopter for your foster pet. Your input is invaluable in making sure each animal ends up in the best possible home. +

    +
    +
    +
    +
    + + +
    +
    +

    Ready to Foster?

    +

    + Fill out our foster application or reach out with any questions. +

    +
    + {#if fosterEnabled} + + Apply to Foster + + {/if} + {#if orgEmail} + + Email Us + + {/if} +
    +
    +
    diff --git a/src/routes/(public)/foster-resources/+page.server.ts b/src/routes/(public)/foster-resources/+page.server.ts new file mode 100644 index 0000000..3a18bf7 --- /dev/null +++ b/src/routes/(public)/foster-resources/+page.server.ts @@ -0,0 +1,41 @@ +import type { Actions, PageServerLoad } from './$types'; +import { fail } from '@sveltejs/kit'; +import { getPageBlocks } from '$lib/server/content'; + +const FOSTER_PASSWORD = process.env.FOSTER_RESOURCES_PASSWORD ?? ''; + +export const load: PageServerLoad = async ({ cookies, parent }) => { + const parentData = await parent(); + + // If no password is configured, show content freely + const passwordRequired = FOSTER_PASSWORD.length > 0; + const authed = !passwordRequired || cookies.get('foster_resources_auth') === 'true'; + + let blocks = {}; + if (authed) { + blocks = await getPageBlocks('foster-resources'); + } + + return { ...parentData, authed, passwordRequired, blocks }; +}; + +export const actions: Actions = { + default: async ({ request, cookies }) => { + const data = await request.formData(); + const password = data.get('password')?.toString(); + + if (!FOSTER_PASSWORD || password !== FOSTER_PASSWORD) { + return fail(401, { error: 'Incorrect password' }); + } + + cookies.set('foster_resources_auth', 'true', { + path: '/foster-resources', + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30 // 30 days + }); + + return { success: true }; + } +}; diff --git a/src/routes/(public)/foster-resources/+page.svelte b/src/routes/(public)/foster-resources/+page.svelte new file mode 100644 index 0000000..f18a725 --- /dev/null +++ b/src/routes/(public)/foster-resources/+page.svelte @@ -0,0 +1,72 @@ + + + + Foster Resources | {orgName} + + + + +
    +
    +

    Foster Resources

    +

    Resources and documents for our foster families.

    +
    +
    + +
    +
    + {#if !data.authed && !form?.success} + +
    +

    This page is password-protected

    +

    Enter the foster resources password to view this content.

    + + {#if form?.error} +
    {form.error}
    + {/if} + +
    + + + +
    +
    + {:else} + +
    + {#if blocks && Object.keys(blocks).length > 0} + {#each Object.entries(blocks) as [key, content]} +
    +

    {key.replace(/-/g, ' ')}

    +
    +
    {@html content}
    +
    + {/each} + {:else} +
    + + + +

    Resources Coming Soon

    +

    Foster resource documents will be published here. Contact us for current resources.

    +
    + {/if} +
    + {/if} +
    +
    diff --git a/src/routes/(public)/how-to-help/+page.server.ts b/src/routes/(public)/how-to-help/+page.server.ts new file mode 100644 index 0000000..dbaaf86 --- /dev/null +++ b/src/routes/(public)/how-to-help/+page.server.ts @@ -0,0 +1,8 @@ +import { getPageBlocks } from '$lib/server/content'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const parentData = await parent(); + const blocks = await getPageBlocks('how-to-help'); + return { ...parentData, blocks }; +}; diff --git a/src/routes/(public)/how-to-help/+page.svelte b/src/routes/(public)/how-to-help/+page.svelte new file mode 100644 index 0000000..1ad0654 --- /dev/null +++ b/src/routes/(public)/how-to-help/+page.svelte @@ -0,0 +1,165 @@ + + + + How to Help | {orgName} + + + + +
    +
    +

    Make a Difference

    +

    + There are so many ways to support our rescue pets on their journey home. +

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

    Adopt

    +
    +
    +

    + Give a rescue pet their forever home. Our adoption process includes an application, meet-and-greet, and a trial period to ensure a great match. All pets come fully vetted, spayed/neutered, and microchipped. +

    +
    + + View Available Pets + + {#if data.orgConfig.applications.adoptionEnabled} + + Apply to Adopt + + {/if} +
    +
    +
    + + +
    +
    + + + +

    Foster

    +
    +
    +

    + Open your home and heart to a pet in need. Fostering saves lives by making room to take in more pets. We provide all supplies, food, and medical care. You provide the love. +

    +
    + {#if data.orgConfig.applications.fosterEnabled} + + Apply to Foster + + {:else} + + Learn More + + {/if} +
    +
    +
    + + +
    +
    + + + +

    Volunteer

    +
    +
    +

    + Lend your time and talents to help our rescue. We need help with events, transport, photography, social media, and more. Every skill set has a place here. +

    +
    + {#if data.orgConfig.applications.volunteerEnabled} + + Apply to Volunteer + + {:else} + + Learn More + + {/if} +
    +
    +
    + + +
    +
    + + + +

    Donate

    +
    +
    +

    + Your financial support directly funds veterinary care, food, and supplies for our pets. Every dollar makes a difference. +

    + +
    +
    + + +
    +
    + + + +

    Sponsor

    +
    +
    +

    + Become a business sponsor and support our mission while gaining visibility in the community. We offer multiple sponsorship tiers. +

    +
    + + View Sponsors + + {#if orgEmail} + + Contact Us + + {/if} +
    +
    +
    +
    +
    +
    + + +
    +
    +

    Not Sure Where to Start?

    +

    Reach out and we'll help you find the best way to get involved.

    + {#if orgEmail} + + Email Us + + {:else} + + Contact Us + + {/if} +
    +
    diff --git a/src/routes/(public)/volunteer/+page.server.ts b/src/routes/(public)/volunteer/+page.server.ts new file mode 100644 index 0000000..9bd290e --- /dev/null +++ b/src/routes/(public)/volunteer/+page.server.ts @@ -0,0 +1,8 @@ +import { getPageBlocks } from '$lib/server/content'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const parentData = await parent(); + const blocks = await getPageBlocks('volunteer'); + return { ...parentData, blocks }; +}; diff --git a/src/routes/(public)/volunteer/+page.svelte b/src/routes/(public)/volunteer/+page.svelte new file mode 100644 index 0000000..755d408 --- /dev/null +++ b/src/routes/(public)/volunteer/+page.svelte @@ -0,0 +1,82 @@ + + + + Volunteer Opportunities | {orgName} + + + + +
    +
    +

    Volunteer Opportunities

    +

    Every skill set has a place here. Find out how you can help.

    +
    +
    + + +
    +
    +

    + {orgName} runs entirely on volunteers, and we could not do what we do without the incredible people who donate their time and talents. Whether you have a few hours a month or want to get deeply involved, there is a role for you. +

    +
    +
    + + +
    +
    +

    Ways to Volunteer

    +
    + +
    + {#each [ + { icon: 'calendar', title: 'Event Help', desc: 'Help plan and run adoption events, fundraisers, and community outreach activities.' }, + { icon: 'truck', title: 'Transport', desc: 'Drive pets to vet appointments, foster homes, or adoption events. Reliable transport volunteers are critical.' }, + { icon: 'camera', title: 'Photography', desc: 'Great photos help animals find homes faster. Volunteer for adoption photos, event shots, or social content.' }, + { icon: 'chat', title: 'Social Media', desc: 'Help share stories, create engaging posts, and grow our online community.' }, + { icon: 'doc', title: 'Administrative Support', desc: 'Assist with data entry, application processing, email communication, and other behind-the-scenes tasks.' }, + { icon: 'star', title: 'Fundraising & Mentoring', desc: 'Organize fundraising campaigns or mentor new foster families as they learn the ropes.' } + ] as role} +
    +
    + + {#if role.icon === 'calendar'} + + {:else if role.icon === 'truck'} + + {:else if role.icon === 'camera'} + + {:else if role.icon === 'chat'} + + {:else if role.icon === 'doc'} + + {:else} + + {/if} + +
    +

    {role.title}

    +

    {role.desc}

    +
    + {/each} +
    +
    +
    + + +{#if data.orgConfig.applications.volunteerEnabled} +
    +
    +

    Ready to Get Involved?

    +

    + Fill out our volunteer application and we will find the perfect role for you. +

    + + Apply to Volunteer + +
    +
    +{/if} diff --git a/src/routes/(public)/why-adopt/+page.server.ts b/src/routes/(public)/why-adopt/+page.server.ts new file mode 100644 index 0000000..41d3c86 --- /dev/null +++ b/src/routes/(public)/why-adopt/+page.server.ts @@ -0,0 +1,8 @@ +import { getPageBlocks } from '$lib/server/content'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const parentData = await parent(); + const blocks = await getPageBlocks('why-adopt'); + return { ...parentData, blocks }; +}; diff --git a/src/routes/(public)/why-adopt/+page.svelte b/src/routes/(public)/why-adopt/+page.svelte new file mode 100644 index 0000000..acdf865 --- /dev/null +++ b/src/routes/(public)/why-adopt/+page.svelte @@ -0,0 +1,64 @@ + + + + Why Adopt | {orgName} + + + + +
    +
    +

    {blocks?.intro?.heading ?? 'Why Adopt?'}

    +

    + {blocks?.intro?.text ?? 'Adopting a rescue pet is one of the most rewarding decisions you can make.'} +

    +
    +
    + +
    +
    + {#if blocks?.main?.text} +
    {@html blocks.main.text}
    + {:else} +
    + {#each [ + { title: 'Save a Life', desc: 'When you adopt from a rescue, you are giving a pet a second chance they might not otherwise have. Every adoption makes room for another animal in need.' }, + { title: 'Fully Vetted Pets', desc: 'All of our animals come spayed or neutered, vaccinated, and microchipped. We cover all veterinary costs so you can welcome a healthy pet into your home.' }, + { title: 'Know What You Are Getting', desc: 'Foster families observe our pets in a home environment, so we can tell you about their personality, habits, and compatibility with other animals and children.' }, + { title: 'Ongoing Support', desc: 'Adoption is not just a transaction. We are here to help you with questions and guidance throughout your pet\'s life.' }, + { title: 'Feel Good About It', desc: 'Adoption means you are not supporting puppy mills or irresponsible breeding. You are changing the world, one pet at a time.' } + ] as reason} +
    +
    + + + +
    +
    +

    {reason.title}

    +

    {reason.desc}

    +
    +
    + {/each} +
    + {/if} +
    +
    + + +{#if data.orgConfig.applications.adoptionEnabled} +
    +
    +

    Ready to Meet Your New Best Friend?

    +

    Browse our available pets and start your adoption journey today.

    + + Browse Available Pets + +
    +
    +{/if} From 464f300775ce4e5ad124e5338621470aed18d220 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:14:46 -0500 Subject: [PATCH 18/38] 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} +
    +
    From fccc89630e47ab9b660c9927bd9e769a49f97db1 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:14:52 -0500 Subject: [PATCH 19/38] feat: add AI pet name generator page and API endpoint Client-side generator page with species (dog/cat) and theme selectors submits a job to /api/pet-names POST, then polls GET until completed. API uses nameGeneratorLimiter for rate limiting and delegates to queueNameGenerationJob/getJobStatus from lib/server/ai. Co-Authored-By: Claude Sonnet 4.6 --- .../(public)/name-generator/+page.svelte | 233 ++++++++++++++++++ src/routes/api/pet-names/+server.ts | 54 ++++ 2 files changed, 287 insertions(+) create mode 100644 src/routes/(public)/name-generator/+page.svelte create mode 100644 src/routes/api/pet-names/+server.ts diff --git a/src/routes/(public)/name-generator/+page.svelte b/src/routes/(public)/name-generator/+page.svelte new file mode 100644 index 0000000..044bade --- /dev/null +++ b/src/routes/(public)/name-generator/+page.svelte @@ -0,0 +1,233 @@ + + + + Pet Name Generator + + + +
    +
    +

    Pet Name Generator

    +

    + Can't decide on a name? Let our AI help! Powered by local AI — no data leaves our servers. +

    +
    +
    + +
    +
    + +
    +
    + +
    + +
    + + +
    +
    + + +
    + +
    + {#each themes as t} + + {/each} +
    +
    + + + +
    +
    + + + {#if error} +
    + {error} +
    + {/if} + + + {#if names.length > 0} +
    +

    + Here are your {species} names! +

    +
    + {#each names as name} + + {/each} +
    +

    + Generated by AI running locally +

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

    + Looking to adopt? Check out our + available pets + — they already have great names! +

    +
    +
    +
    diff --git a/src/routes/api/pet-names/+server.ts b/src/routes/api/pet-names/+server.ts new file mode 100644 index 0000000..c7de790 --- /dev/null +++ b/src/routes/api/pet-names/+server.ts @@ -0,0 +1,54 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { queueNameGenerationJob, getJobStatus } from '$lib/server/ai'; +import { nameGeneratorLimiter } from '$lib/server/rate-limit'; + +export const POST: RequestHandler = async ({ request, getClientAddress }) => { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress(); + + // Rate limit: 10 requests per 5 minutes per IP + if (!nameGeneratorLimiter.check(ip)) { + return json({ error: 'Too many requests. Please slow down.' }, { status: 429 }); + } + + const { species, theme, count } = await request.json(); + + const nameCount = Math.min(Math.max(parseInt(count) || 10, 1), 20); + const animalType = species === 'cat' ? 'cat' : 'dog'; + + const jobId = await queueNameGenerationJob({ + species: animalType, + theme: theme || 'classic', + count: nameCount + }); + + return json({ jobId }); +}; + +export const GET: RequestHandler = async ({ url }) => { + const jobId = parseInt(url.searchParams.get('jobId') || '0'); + if (!jobId || isNaN(jobId)) { + return json({ error: 'Missing or invalid jobId' }, { status: 400 }); + } + + const job = await getJobStatus(jobId); + if (!job) { + return json({ error: 'Job not found' }, { status: 404 }); + } + + if (job.status === 'completed') { + let output = job.output as { names: string[] } | string | null; + if (typeof output === 'string') { + try { output = JSON.parse(output); } catch { output = null; } + } + const names = (output as { names: string[] } | null)?.names || []; + return json({ status: 'completed', names }); + } + + if (job.status === 'failed') { + return json({ status: 'failed', error: job.error || 'Unknown error' }); + } + + // pending or processing + return json({ status: job.status }); +}; From c83f24084c715a97d6101503ccf3f64d57e83a01 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:14:56 -0500 Subject: [PATCH 20/38] feat: add auth pages (login, logout, register, change-password) Login uses loginLimiter, authenticates via authenticateUser, creates session cookie, and redirects foster role to /foster, others to /admin. Logout destroys session and redirects to /. Register uses registrationLimiter, validates input, creates inactive user, and sends registration notification. Change-password verifies current password, enforces 8-char minimum, updates hash, clears mustChangePassword flag. Co-Authored-By: Claude Sonnet 4.6 --- src/routes/change-password/+page.server.ts | 53 ++++++++ src/routes/change-password/+page.svelte | 82 ++++++++++++ src/routes/login/+page.server.ts | 62 ++++++++++ src/routes/login/+page.svelte | 67 ++++++++++ src/routes/logout/+page.server.ts | 14 +++ src/routes/register/+page.server.ts | 81 ++++++++++++ src/routes/register/+page.svelte | 137 +++++++++++++++++++++ 7 files changed, 496 insertions(+) create mode 100644 src/routes/change-password/+page.server.ts create mode 100644 src/routes/change-password/+page.svelte create mode 100644 src/routes/login/+page.server.ts create mode 100644 src/routes/login/+page.svelte create mode 100644 src/routes/logout/+page.server.ts create mode 100644 src/routes/register/+page.server.ts create mode 100644 src/routes/register/+page.svelte diff --git a/src/routes/change-password/+page.server.ts b/src/routes/change-password/+page.server.ts new file mode 100644 index 0000000..96d7411 --- /dev/null +++ b/src/routes/change-password/+page.server.ts @@ -0,0 +1,53 @@ +import type { PageServerLoad, Actions } from './$types'; +import { fail, redirect } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { users } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { hashPassword, verifyPassword } from '$lib/server/auth-utils'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!locals.user) throw redirect(303, '/login'); + if (!locals.user.mustChangePassword) { + throw redirect(303, locals.user.role === 'foster' ? '/foster' : '/admin'); + } + return {}; +}; + +export const actions: Actions = { + default: async ({ request, locals }) => { + if (!locals.user) throw redirect(303, '/login'); + + const fd = await request.formData(); + const currentPassword = fd.get('currentPassword')?.toString() ?? ''; + const newPassword = fd.get('newPassword')?.toString() ?? ''; + const confirmPassword = fd.get('confirmPassword')?.toString() ?? ''; + + if (!currentPassword || !newPassword || !confirmPassword) { + return fail(400, { error: 'All fields are required' }); + } + + if (newPassword.length < 8) { + return fail(400, { error: 'Password must be at least 8 characters' }); + } + + if (newPassword !== confirmPassword) { + return fail(400, { error: 'New passwords do not match' }); + } + + const [user] = await db.select().from(users).where(eq(users.id, locals.user.id)); + if (!user) throw redirect(303, '/login'); + + const valid = await verifyPassword(user.passwordHash, currentPassword); + if (!valid) { + return fail(400, { error: 'Current password is incorrect' }); + } + + const newHash = await hashPassword(newPassword); + await db.update(users).set({ + passwordHash: newHash, + mustChangePassword: false + }).where(eq(users.id, locals.user.id)); + + throw redirect(303, locals.user.role === 'foster' ? '/foster' : '/admin'); + } +}; diff --git a/src/routes/change-password/+page.svelte b/src/routes/change-password/+page.svelte new file mode 100644 index 0000000..17b92d5 --- /dev/null +++ b/src/routes/change-password/+page.svelte @@ -0,0 +1,82 @@ + + + + Change Password — {data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'} + + +
    +
    +
    +

    {data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'}

    +
    + +
    +
    +

    Change Your Password

    +

    You must change your password before continuing.

    +
    + + {#if form?.error} +
    + {form.error} +
    + {/if} + +
    + + +
    + +
    + + +

    Must be at least 8 characters

    +
    + +
    + + +
    + + +
    +
    +
    diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts new file mode 100644 index 0000000..04116e3 --- /dev/null +++ b/src/routes/login/+page.server.ts @@ -0,0 +1,62 @@ +import type { Actions, PageServerLoad } from './$types'; +import { fail, redirect } from '@sveltejs/kit'; +import { authenticateUser, createSession } from '$lib/server/auth'; +import { db } from '$lib/server/db'; +import { users } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { loginLimiter } from '$lib/server/rate-limit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (locals.user) { + throw redirect(303, locals.user.role === 'foster' ? '/foster' : '/admin'); + } +}; + +export const actions: Actions = { + default: async ({ request, cookies, getClientAddress }) => { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress(); + + if (!loginLimiter.check(ip)) { + return fail(429, { error: 'Too many login attempts. Please try again later.' }); + } + + const data = await request.formData(); + const email = data.get('email')?.toString().trim(); + const password = data.get('password')?.toString(); + + if (!email || !password) { + return fail(400, { error: 'Email and password are required', email }); + } + + const user = await authenticateUser(email, password); + if (!user) { + // Check if account exists but is inactive (pending approval) + if (email) { + const [existing] = await db + .select({ id: users.id, active: users.active }) + .from(users) + .where(eq(users.email, email.toLowerCase())) + .limit(1); + if (existing && !existing.active) { + return fail(401, { + error: "Your account is pending approval. You'll receive an email when it's been reviewed.", + email + }); + } + } + return fail(401, { error: 'Invalid email or password', email }); + } + + const token = await createSession(user.id); + + cookies.set('session', token, { + path: '/', + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 30 * 24 * 60 * 60 // 30 days + }); + + throw redirect(303, user.role === 'foster' ? '/foster' : '/admin'); + } +}; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..b6ab5f5 --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,67 @@ + + + + Login — {data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'} + + +
    +
    +
    +

    {data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'}

    +

    Staff & Foster Portal

    +
    + +
    + {#if form?.error} +
    + {form.error} +
    + {/if} + +
    + + +
    + +
    + + +
    + + + +

    + Need an account? + Request access +

    +
    +
    +
    diff --git a/src/routes/logout/+page.server.ts b/src/routes/logout/+page.server.ts new file mode 100644 index 0000000..86799df --- /dev/null +++ b/src/routes/logout/+page.server.ts @@ -0,0 +1,14 @@ +import type { Actions } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { destroySession } from '$lib/server/auth'; + +export const actions: Actions = { + default: async ({ cookies, locals }) => { + const token = locals.sessionToken; + if (token) { + await destroySession(token); + } + cookies.delete('session', { path: '/' }); + throw redirect(303, '/'); + } +}; diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts new file mode 100644 index 0000000..0548109 --- /dev/null +++ b/src/routes/register/+page.server.ts @@ -0,0 +1,81 @@ +import type { Actions, PageServerLoad } from './$types'; +import { fail, redirect } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { users } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { hashPassword } from '$lib/server/auth-utils'; +import { sendRegistrationNotification } from '$lib/server/email'; +import { registrationLimiter } from '$lib/server/rate-limit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (locals.user) { + throw redirect(303, locals.user.role === 'foster' ? '/foster' : '/admin'); + } +}; + +export const actions: Actions = { + default: async ({ request, getClientAddress, locals }) => { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress(); + + if (!registrationLimiter.check(ip)) { + return fail(429, { error: 'Too many registration attempts. Please try again later.' }); + } + + const data = await request.formData(); + const name = data.get('name')?.toString().trim(); + const email = data.get('email')?.toString().trim().toLowerCase(); + const password = data.get('password')?.toString(); + const confirmPassword = data.get('confirmPassword')?.toString(); + const reason = data.get('reason')?.toString().trim(); + + if (!name || !email || !password || !confirmPassword || !reason) { + return fail(400, { error: 'All fields are required', name, email, reason }); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return fail(400, { error: 'Please enter a valid email address', name, email, reason }); + } + + if (password.length < 8) { + return fail(400, { error: 'Password must be at least 8 characters', name, email, reason }); + } + + if (password !== confirmPassword) { + return fail(400, { error: 'Passwords do not match', name, email, reason }); + } + + const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, email)).limit(1); + if (existing) { + return fail(400, { error: 'An account with this email already exists', name, email, reason }); + } + + const passwordHash = await hashPassword(password); + + await db.insert(users).values({ + name, + email, + passwordHash, + role: 'viewer', + active: false, + mustChangePassword: false + }); + + const orgConfig = locals.orgConfig; + if (orgConfig.orgInfo.email) { + try { + await sendRegistrationNotification( + orgConfig.orgInfo.name, + orgConfig.orgInfo.email, + name, + email, + reason + ); + } catch { + // Email failure shouldn't block registration + } + } + + return { success: true }; + } +}; diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte new file mode 100644 index 0000000..897f80a --- /dev/null +++ b/src/routes/register/+page.svelte @@ -0,0 +1,137 @@ + + + + Request Access — {data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'} + + +
    +
    +
    +

    {data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'}

    +

    Staff & Foster Portal

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

    Your registration request has been submitted. You'll receive an email when your account is approved.

    +
    + +
    + {:else} +
    +
    +

    Request Access

    +

    Submit a request to create an account. Your request will be reviewed by our team.

    +
    + + {#if form?.error} +
    + {form.error} +
    + {/if} + +
    + + +
    + +
    + + +
    + +
    + + +

    Minimum 8 characters

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

    + Already have an account? + Sign in +

    +
    + {/if} +
    +
    From c1efb85c1435881d707bce6cc783110aa5014a06 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:21:17 -0500 Subject: [PATCH 21/38] Add content pages (contact, events, happy tails, sponsors, donate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All pages use config-driven org info via data.orgConfig — no hardcoded org names, emails, or external URLs. Stripe donate page gracefully falls back to a contact CTA when STRIPE_SECRET_KEY is not configured. RateLimiter applied to contact form and happy tails submission. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/(public)/contact/+page.server.ts | 60 +++ src/routes/(public)/contact/+page.svelte | 216 ++++++++++ src/routes/(public)/donate/+page.server.ts | 56 +++ src/routes/(public)/donate/+page.svelte | 401 ++++++++++++++++++ src/routes/(public)/events/+page.server.ts | 20 + src/routes/(public)/events/+page.svelte | 155 +++++++ .../(public)/happy-tails/+page.server.ts | 28 ++ src/routes/(public)/happy-tails/+page.svelte | 149 +++++++ .../happy-tails/submit/+page.server.ts | 44 ++ .../(public)/happy-tails/submit/+page.svelte | 105 +++++ src/routes/(public)/sponsors/+page.server.ts | 22 + src/routes/(public)/sponsors/+page.svelte | 183 ++++++++ 12 files changed, 1439 insertions(+) create mode 100644 src/routes/(public)/contact/+page.server.ts create mode 100644 src/routes/(public)/contact/+page.svelte create mode 100644 src/routes/(public)/donate/+page.server.ts create mode 100644 src/routes/(public)/donate/+page.svelte create mode 100644 src/routes/(public)/events/+page.server.ts create mode 100644 src/routes/(public)/events/+page.svelte create mode 100644 src/routes/(public)/happy-tails/+page.server.ts create mode 100644 src/routes/(public)/happy-tails/+page.svelte create mode 100644 src/routes/(public)/happy-tails/submit/+page.server.ts create mode 100644 src/routes/(public)/happy-tails/submit/+page.svelte create mode 100644 src/routes/(public)/sponsors/+page.server.ts create mode 100644 src/routes/(public)/sponsors/+page.svelte diff --git a/src/routes/(public)/contact/+page.server.ts b/src/routes/(public)/contact/+page.server.ts new file mode 100644 index 0000000..3d0f3d5 --- /dev/null +++ b/src/routes/(public)/contact/+page.server.ts @@ -0,0 +1,60 @@ +import { getPageBlocks } from '$lib/server/content'; +import { db } from '$lib/server/db'; +import { contactMessages } from '$lib/server/schema'; +import { fail } from '@sveltejs/kit'; +import { RateLimiter } from '$lib/server/rate-limit'; +import type { PageServerLoad, Actions } from './$types'; + +const contactLimiter = new RateLimiter(3, 60 * 60 * 1000); + +export const load: PageServerLoad = async ({ parent }) => { + const parentData = await parent(); + const blocks = await getPageBlocks('contact'); + return { ...parentData, blocks }; +}; + +export const actions: Actions = { + default: async ({ request, getClientAddress }) => { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress(); + + if (!contactLimiter.check(ip)) { + return fail(429, { error: 'Too many submissions. Please try again later.' }); + } + + const formData = await request.formData(); + + // Honeypot — bots fill this hidden field + const website = formData.get('website')?.toString() ?? ''; + if (website) { + return { success: true }; + } + + // Timing check — reject submissions under 2 seconds + const loadedAt = formData.get('_t')?.toString() ?? ''; + if (loadedAt) { + const elapsed = Date.now() - parseInt(loadedAt, 10); + if (elapsed < 2000) { + return { success: true }; + } + } + + const name = formData.get('name')?.toString().trim() ?? ''; + const email = formData.get('email')?.toString().trim() ?? ''; + const subject = formData.get('subject')?.toString().trim() || null; + const message = formData.get('message')?.toString().trim() ?? ''; + + const errors: Record = {}; + if (!name) errors.name = 'Name is required.'; + if (!email) errors.email = 'Email is required.'; + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Please enter a valid email.'; + if (!message) errors.message = 'Message is required.'; + + if (Object.keys(errors).length > 0) { + return fail(400, { errors, values: { name, email, subject, message } }); + } + + await db.insert(contactMessages).values({ name, email, subject, message }); + + return { success: true }; + } +}; diff --git a/src/routes/(public)/contact/+page.svelte b/src/routes/(public)/contact/+page.svelte new file mode 100644 index 0000000..bedf8af --- /dev/null +++ b/src/routes/(public)/contact/+page.svelte @@ -0,0 +1,216 @@ + + + + Contact Us | {orgName} + + + + +
    +
    +

    {blocks?.intro?.heading ?? 'Contact Us'}

    +

    + {blocks?.intro?.text ?? 'We would love to hear from you.'} +

    +
    +
    + + +
    +
    +

    + {orgName} is 100% volunteer-based and we will respond to your message as soon as possible. +

    + +
    + {#if orgEmail} +
    +
    + + + +
    +

    Email

    + + {orgEmail} + +
    + {/if} + + {#if blocks?.info?.address} +
    +
    + + + + +
    +

    Mailing Address

    +

    {blocks.info.address}

    +
    + {/if} +
    + + + {#if facebookUrl || instagramUrl} +
    +

    Follow Us

    +
    +
    + {#if facebookUrl} + + + + + Facebook + + {/if} + {#if instagramUrl} + + + + + Instagram + + {/if} +
    +
    + {/if} +
    +
    + + +
    +
    +

    Send Us a Message

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

    Thank you! We'll get back to you soon.

    +

    Our volunteers typically respond within 24-48 hours.

    +
    + {:else} + {#if form?.error} +
    {form.error}
    + {/if} + +
    +
    +
    + + + {#if form?.errors?.name} +

    {form.errors.name}

    + {/if} +
    + +
    + + + {#if form?.errors?.email} +

    {form.errors.email}

    + {/if} +
    +
    + +
    + + +
    + +
    + + + {#if form?.errors?.message} +

    {form.errors.message}

    + {/if} +
    + + + + + +
    + +
    +
    + {/if} +
    +
    + + +{#if data.orgConfig.applications.adoptionEnabled} +
    +
    +
    +

    Interested in Adopting?

    +

    + If you are inquiring about an adoptable pet, please fill out our adoption application first. +

    + +
    +
    +
    +{/if} diff --git a/src/routes/(public)/donate/+page.server.ts b/src/routes/(public)/donate/+page.server.ts new file mode 100644 index 0000000..47c12a9 --- /dev/null +++ b/src/routes/(public)/donate/+page.server.ts @@ -0,0 +1,56 @@ +import { getPageBlocks } from '$lib/server/content'; +import { createDonationCheckout } from '$lib/server/stripe'; +import { fail, redirect } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; + +export const load: PageServerLoad = async ({ url, parent }) => { + const parentData = await parent(); + const content = await getPageBlocks('donate'); + const paymentStatus = url.searchParams.get('payment'); + const stripeEnabled = Boolean(process.env.STRIPE_SECRET_KEY); + + return { ...parentData, content, stripeEnabled, paymentStatus }; +}; + +export const actions: Actions = { + donate: async ({ request, url }) => { + if (!process.env.STRIPE_SECRET_KEY) { + return fail(503, { donateError: 'Online payment is not available at this time.' }); + } + + const fd = await request.formData(); + const amountStr = fd.get('amount')?.toString().trim(); + const email = fd.get('email')?.toString().trim() || undefined; + + if (!amountStr) { + return fail(400, { donateError: 'Please enter a donation amount.' }); + } + + const amount = parseFloat(amountStr); + if (isNaN(amount) || amount < 1) { + return fail(400, { donateError: 'Please enter a valid amount of at least $1.00.' }); + } + + if (amount > 99999) { + return fail(400, { + donateError: 'Please enter a smaller amount or contact us directly.' + }); + } + + const baseUrl = url.origin; + const checkoutUrl = await createDonationCheckout({ + amount: Math.round(amount * 100), // convert to cents + donorEmail: email, + successUrl: `${baseUrl}/donate?payment=success`, + cancelUrl: `${baseUrl}/donate?payment=cancelled` + }); + + if (!checkoutUrl) { + return fail(503, { + donateError: 'Unable to create payment session. Please try again later.' + }); + } + + redirect(303, checkoutUrl); + } +}; diff --git a/src/routes/(public)/donate/+page.svelte b/src/routes/(public)/donate/+page.svelte new file mode 100644 index 0000000..cc49ffa --- /dev/null +++ b/src/routes/(public)/donate/+page.svelte @@ -0,0 +1,401 @@ + + + + Donate | {orgName} + + + + +
    +
    +

    + {(data.content as Record>)?.intro?.heading ?? 'Support Our Mission'} +

    +

    + {(data.content as Record>)?.intro?.text ?? + 'Your generosity saves lives. Every donation directly supports the animals in our care.'} +

    +
    +
    + + +{#if data.paymentStatus === 'success'} +
    +
    +
    + + + +

    Thank you for your generous donation!

    +
    +

    + Your payment has been received. You will get a receipt via email. +

    +
    +
    +{/if} + +{#if data.paymentStatus === 'cancelled'} +
    +
    +

    + Your donation was cancelled. No payment was processed. You can try again below. +

    +
    +
    +{/if} + + +{#if data.stripeEnabled} +
    +
    +
    +

    Donate with Card

    +
    +

    Make a secure one-time donation with your credit or debit card.

    +
    + +
    +
    +
    + +
    + +
    + {#each presetAmounts as amount} + + {/each} +
    +
    + + +
    + +
    + $ + +
    +
    + + +
    + + +
    + + {#if form?.donateError} +
    + {form.donateError} +
    + {/if} + + +
    +

    + Secure payment powered by Stripe. You will be redirected to complete your donation. +

    +
    +
    +
    +
    +{:else} + +
    +
    +

    Make a Donation

    +
    +

    + Ready to make a difference? Get in touch with us to learn how you can donate. +

    + {#if orgEmail} + + Contact Us to Donate + + {:else} + + Contact Us to Donate + + {/if} +
    +
    +{/if} + + +
    +
    +

    Other Ways to Give

    +
    + +
    + +
    +
    + + + +
    +

    Volunteer Your Time

    +

    + Your time is just as valuable as money. Help with fostering, events, transport, and more. +

    + + Learn How to Help + +
    + + +
    +
    + + + +
    +

    Become a Foster

    +

    + Open your home to an animal in need. Fosters provide the care and love that makes all the + difference. +

    + + Learn About Fostering + +
    + + +
    +
    + {#if facebookUrl} + + + + {:else} + + + + {/if} +
    +

    Spread the Word

    +

    + Share our animals on social media, tell friends and family, and help us find forever homes. +

    + {#if facebookUrl} + + Follow on Facebook + + {:else} + + Share Our Animals + + {/if} +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + + + +
    +
    +

    Donate by Mail

    +

    + Prefer to send a check? Make it payable to {orgName} and contact us for + mailing details. +

    + {#if orgEmail} + + {orgEmail} + + {/if} +
    +
    +
    +
    +
    + + +
    +
    +
    +

    + Tax-Deductible Giving: + {orgName} is a registered 501(c)(3) nonprofit organization. All donations are tax-deductible to + the extent allowed by law. You will receive a receipt for your records. +

    +
    +
    +
    + + +
    +
    +

    Questions About Donating?

    +

    We're happy to help. Reach out to us anytime.

    + {#if orgEmail} + + Contact Us + + {:else} + + Contact Us + + {/if} +
    +
    diff --git a/src/routes/(public)/events/+page.server.ts b/src/routes/(public)/events/+page.server.ts new file mode 100644 index 0000000..a4ec34c --- /dev/null +++ b/src/routes/(public)/events/+page.server.ts @@ -0,0 +1,20 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { events } from '$lib/server/schema'; +import { eq, asc } from 'drizzle-orm'; + +export const load: PageServerLoad = async ({ parent }) => { + const parentData = await parent(); + const now = new Date(); + + const allEvents = await db + .select() + .from(events) + .where(eq(events.published, true)) + .orderBy(asc(events.startDate)); + + const upcomingEvents = allEvents.filter((e) => new Date(e.startDate) >= now); + const pastEvents = allEvents.filter((e) => new Date(e.startDate) < now).reverse().slice(0, 6); + + return { ...parentData, upcomingEvents, pastEvents }; +}; diff --git a/src/routes/(public)/events/+page.svelte b/src/routes/(public)/events/+page.svelte new file mode 100644 index 0000000..a36b4d3 --- /dev/null +++ b/src/routes/(public)/events/+page.svelte @@ -0,0 +1,155 @@ + + + + Events | {orgName} + + + + +
    +
    +

    Events

    +

    + Join us at adoption events, fundraisers, and community gatherings. +

    +
    +
    + + +
    +
    +

    Upcoming Events

    +
    + + {#if data.upcomingEvents.length > 0} +
    + {#each data.upcomingEvents as event} +
    + {#if event.imageUrl} +
    + {event.title} +
    + {:else} +
    + + + +
    + {/if} +
    +
    + + + + {formatDate(event.startDate)} +
    +
    + + + + {formatTime(event.startDate)} +
    +

    {event.title}

    + {#if event.location} +
    + + + + + {event.location} +
    + {/if} + {#if event.description} +

    {event.description}

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

    No Upcoming Events

    +

    + Check back soon! {#if facebookUrl}Follow us on Facebook for the latest updates.{/if} +

    + {#if facebookUrl} + + Follow Us on Facebook + + {/if} +
    + {/if} +
    +
    + + +{#if data.pastEvents.length > 0} +
    +
    +

    Past Events

    +
    + +
    + {#each data.pastEvents as event} +
    + {#if event.imageUrl} +
    + {event.title} +
    + {:else} +
    + + + +
    + {/if} +
    +

    {formatShortDate(event.startDate)}

    +

    {event.title}

    + {#if event.location} +

    {event.location}

    + {/if} +
    +
    + {/each} +
    +
    +
    +{/if} diff --git a/src/routes/(public)/happy-tails/+page.server.ts b/src/routes/(public)/happy-tails/+page.server.ts new file mode 100644 index 0000000..da40553 --- /dev/null +++ b/src/routes/(public)/happy-tails/+page.server.ts @@ -0,0 +1,28 @@ +import { db } from '$lib/server/db'; +import { happyTails, pets, petPhotos } from '$lib/server/schema'; +import { eq, desc, and } from 'drizzle-orm'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const parentData = await parent(); + + const stories = await db + .select({ + id: happyTails.id, + title: happyTails.title, + story: happyTails.story, + adopterName: happyTails.adopterName, + imageUrl: happyTails.imageUrl, + featured: happyTails.featured, + createdAt: happyTails.createdAt, + petName: pets.name, + petPhotoUrl: petPhotos.url + }) + .from(happyTails) + .leftJoin(pets, eq(happyTails.petId, pets.id)) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where(eq(happyTails.published, true)) + .orderBy(desc(happyTails.featured), desc(happyTails.createdAt)); + + return { ...parentData, stories }; +}; diff --git a/src/routes/(public)/happy-tails/+page.svelte b/src/routes/(public)/happy-tails/+page.svelte new file mode 100644 index 0000000..e672e6e --- /dev/null +++ b/src/routes/(public)/happy-tails/+page.svelte @@ -0,0 +1,149 @@ + + + + Happy Tails — Success Stories | {orgName} + + + + +
    +
    +

    Happy Tails

    +

    + Every adoption is a success story. Read about the families and pets who found their perfect match. +

    +
    +
    + +
    +
    + {#if data.stories.length === 0} +
    + + + +

    No stories yet

    +

    Check back soon for heartwarming adoption stories!

    + Share Your Story +
    + {:else} + + {#if featuredStories.length > 0} +
    +

    Featured Stories

    +
    + {#each featuredStories as story} +
    + {#if getPhoto(story)} +
    + {story.title} +
    + {:else} +
    + + + +
    + {/if} +
    +

    {story.title}

    + {#if story.adopterName} +

    The {story.adopterName} Family

    + {/if} + {#if story.petName} +

    Pet: {story.petName}

    + {/if} + {#if expandedId === story.id} +

    {story.story}

    + + {:else} +

    {truncate(story.story, 250)}

    + {#if story.story.length > 250} + + {/if} + {/if} +
    +
    + {/each} +
    +
    + {/if} + + + {#if regularStories.length > 0} +
    + {#each regularStories as story} +
    + {#if getPhoto(story)} +
    + {story.title} +
    + {:else} +
    + + + +
    + {/if} +
    +

    {story.title}

    + {#if story.adopterName} +

    The {story.adopterName} Family

    + {/if} + {#if story.petName} +

    Pet: {story.petName}

    + {/if} + {#if expandedId === story.id} +

    {story.story}

    + + {:else} +

    {truncate(story.story)}

    + {#if story.story.length > 150} + + {/if} + {/if} +
    +
    + {/each} +
    + {/if} + {/if} + + + +
    +
    diff --git a/src/routes/(public)/happy-tails/submit/+page.server.ts b/src/routes/(public)/happy-tails/submit/+page.server.ts new file mode 100644 index 0000000..f1b7411 --- /dev/null +++ b/src/routes/(public)/happy-tails/submit/+page.server.ts @@ -0,0 +1,44 @@ +import type { Actions, PageServerLoad } from './$types'; +import { fail } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { happyTails } from '$lib/server/schema'; +import { RateLimiter } from '$lib/server/rate-limit'; + +const submitLimiter = new RateLimiter(3, 60 * 60 * 1000); + +export const load: PageServerLoad = async ({ parent }) => { + const parentData = await parent(); + return { ...parentData }; +}; + +export const actions: Actions = { + default: async ({ request, getClientAddress }) => { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress(); + + if (!submitLimiter.check(ip)) { + return fail(429, { error: 'Too many submissions. Please try again later.' }); + } + + const fd = await request.formData(); + const title = fd.get('title')?.toString().trim(); + const story = fd.get('story')?.toString().trim(); + const adopterName = fd.get('adopterName')?.toString().trim(); + const submittedEmail = fd.get('email')?.toString().trim() || null; + + if (!title || !story) { + return fail(400, { error: 'Please fill in all required fields.', title, story, adopterName }); + } + + await db.insert(happyTails).values({ + title, + story, + adopterName: adopterName || null, + submittedBy: adopterName || null, + submittedEmail, + published: false, + featured: false + }); + + return { success: true }; + } +}; diff --git a/src/routes/(public)/happy-tails/submit/+page.svelte b/src/routes/(public)/happy-tails/submit/+page.svelte new file mode 100644 index 0000000..e07e114 --- /dev/null +++ b/src/routes/(public)/happy-tails/submit/+page.svelte @@ -0,0 +1,105 @@ + + + + Share Your Happy Tail | {orgName} + + + +
    +
    +

    Share Your Happy Tail

    +

    + Adopted from {orgName}? We'd love to hear how your new family member is doing! +

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

    Thank You!

    +

    Your story has been submitted and will be reviewed by our team. Once approved, it'll appear on our Happy Tails page!

    + + ← Back to Happy Tails + +
    + {:else} +
    + {#if form?.error} +
    {form.error}
    + {/if} + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + + + +

    + Your story will be reviewed by our team before being published. +

    +
    +
    + {/if} +
    +
    diff --git a/src/routes/(public)/sponsors/+page.server.ts b/src/routes/(public)/sponsors/+page.server.ts new file mode 100644 index 0000000..cfd522f --- /dev/null +++ b/src/routes/(public)/sponsors/+page.server.ts @@ -0,0 +1,22 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { sponsors } from '$lib/server/schema'; +import { eq, asc } from 'drizzle-orm'; + +export const load: PageServerLoad = async ({ parent }) => { + const parentData = await parent(); + + const allSponsors = await db + .select() + .from(sponsors) + .where(eq(sponsors.active, true)) + .orderBy(asc(sponsors.sortOrder)); + + const grouped = { + top_dog: allSponsors.filter((s) => s.tier === 'top_dog'), + wagging_tails: allSponsors.filter((s) => s.tier === 'wagging_tails'), + wet_noses: allSponsors.filter((s) => s.tier === 'wet_noses') + }; + + return { ...parentData, sponsorsByTier: grouped }; +}; diff --git a/src/routes/(public)/sponsors/+page.svelte b/src/routes/(public)/sponsors/+page.svelte new file mode 100644 index 0000000..dd9b3b5 --- /dev/null +++ b/src/routes/(public)/sponsors/+page.svelte @@ -0,0 +1,183 @@ + + + + Our Sponsors | {orgName} + + + + +
    +
    +

    Our Sponsors

    +

    + We're grateful for the businesses and organizations that support our mission. +

    +
    +
    + + +
    +
    + {#each Object.entries(tierConfig) as [tier, config]} + {@const tierSponsors = data.sponsorsByTier[tier as keyof typeof data.sponsorsByTier]} + {#if tierSponsors && tierSponsors.length > 0} +
    +
    +

    {config.label}

    +
    +

    {config.description}

    +
    + +
    + {/if} + {/each} + + {#if !data.sponsorsByTier.top_dog?.length && !data.sponsorsByTier.wagging_tails?.length && !data.sponsorsByTier.wet_noses?.length} +
    +

    + We're looking for sponsors to support our mission. Interested? +

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

    Become a Sponsor

    +
    +

    + Support our rescue while gaining visibility in the community. Your sponsorship directly funds the care of animals in our program. +

    + +
    +
    +
    + + + +
    +

    Top Tier

    +
      +
    • + + Proud Sponsor certificate for your business +
    • +
    • + + Logo on our sponsor page +
    • +
    • + + Social media shout-out +
    • +
    • + + Logo displayed at events +
    • +
    +
    + +
    +
    + + + +
    +

    Premier

    +
      +
    • + + Proud Sponsor certificate for your business +
    • +
    • + + Logo on our sponsor page +
    • +
    • + + Social media shout-out +
    • +
    +
    + +
    +
    + + + +
    +

    Supporting

    +
      +
    • + + Sponsor recognition at your business +
    • +
    • + + Logo on our sponsor page +
    • +
    +
    +
    + +
    +

    Interested in becoming a sponsor? We'd love to hear from you.

    + {#if orgEmail} + + Contact Us About Sponsorship + + {:else} + + Contact Us About Sponsorship + + {/if} +
    +
    +
    From f06e85e6f8dac6f3a837400a2a9e834b79236a76 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:30:51 -0500 Subject: [PATCH 22/38] =?UTF-8?q?feat(admin):=20Task=2033=20=E2=80=94=20ap?= =?UTF-8?q?plications=20list,=20detail,=20status=20actions,=20homevisit=20?= =?UTF-8?q?notes,=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/routes/admin/applications/+page.server.ts | 51 +++ src/routes/admin/applications/+page.svelte | 166 ++++++++ .../admin/applications/[id]/+page.server.ts | 179 +++++++++ .../admin/applications/[id]/+page.svelte | 354 ++++++++++++++++++ .../[id]/contract/+page.server.ts | 64 ++++ .../applications/[id]/contract/+page.svelte | 29 ++ 6 files changed, 843 insertions(+) create mode 100644 src/routes/admin/applications/+page.server.ts create mode 100644 src/routes/admin/applications/+page.svelte create mode 100644 src/routes/admin/applications/[id]/+page.server.ts create mode 100644 src/routes/admin/applications/[id]/+page.svelte create mode 100644 src/routes/admin/applications/[id]/contract/+page.server.ts create mode 100644 src/routes/admin/applications/[id]/contract/+page.svelte diff --git a/src/routes/admin/applications/+page.server.ts b/src/routes/admin/applications/+page.server.ts new file mode 100644 index 0000000..9d6bc08 --- /dev/null +++ b/src/routes/admin/applications/+page.server.ts @@ -0,0 +1,51 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { applications, pets } from '$lib/server/schema'; +import { eq, and, like, count, desc } from 'drizzle-orm'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { error } from '@sveltejs/kit'; + +const PER_PAGE = 20; + +export const load: PageServerLoad = async ({ url, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'applications')) error(403, 'Access denied'); + + const q = url.searchParams.get('q')?.trim() ?? ''; + const type = url.searchParams.get('type') ?? ''; + const status = url.searchParams.get('status') ?? ''; + const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10)); + const offset = (page - 1) * PER_PAGE; + + const conditions = []; + if (q) conditions.push(like(applications.applicantName, `%${q}%`)); + if (type) conditions.push(eq(applications.type, type as typeof applications.type.enumValues[number])); + if (status) conditions.push(eq(applications.status, status as typeof applications.status.enumValues[number])); + + const where = conditions.length > 0 ? and(...conditions) : undefined; + + const [totalRow] = await db.select({ total: count() }).from(applications).where(where); + const total = totalRow?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); + + const rows = await db + .select({ + id: applications.id, + type: applications.type, + petId: applications.petId, + applicantName: applications.applicantName, + email: applications.applicantEmail, + phone: applications.applicantPhone, + status: applications.status, + createdAt: applications.createdAt, + petName: pets.name + }) + .from(applications) + .leftJoin(pets, eq(applications.petId, pets.id)) + .where(where) + .orderBy(desc(applications.createdAt)) + .limit(PER_PAGE) + .offset(offset); + + return { applications: rows, total, page, totalPages, q, type, status }; +}; diff --git a/src/routes/admin/applications/+page.svelte b/src/routes/admin/applications/+page.svelte new file mode 100644 index 0000000..c7a6241 --- /dev/null +++ b/src/routes/admin/applications/+page.svelte @@ -0,0 +1,166 @@ + + + + Applications — Admin + + +
    +
    +

    Applications

    + {data.total} total +
    + +
    +
    +
    + +
    +
    + +
    +
    + +
    + + {#if data.q || data.type || data.status} + + Clear + + {/if} +
    +
    + + {#if apps.length === 0} +
    + + + +

    {data.q || data.type || data.status ? 'No applications match your filters' : 'No applications yet'}

    +
    + {:else} +
    +
    + + + + + + + + + + + + + + {#each apps as app} + + + + + + + + + + {/each} + +
    ApplicantEmailTypeStatusPetDateActions
    + + {app.applicantName} + + + {app.email} + + + {formatType(app.type)} + + + + + {#if app.petName} + {app.petName} + {:else} + — + {/if} + + {formatDate(app.createdAt)} + + + View + +
    +
    +
    + + + {/if} +
    diff --git a/src/routes/admin/applications/[id]/+page.server.ts b/src/routes/admin/applications/[id]/+page.server.ts new file mode 100644 index 0000000..94f6c6b --- /dev/null +++ b/src/routes/admin/applications/[id]/+page.server.ts @@ -0,0 +1,179 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { applications, pets, users, applicationHomevisitNotes } from '$lib/server/schema'; +import { eq, asc } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { logAudit } from '$lib/server/audit'; +import { sendApplicationStatusUpdate, sendFosterWelcomeEmail } from '$lib/server/email'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { hashPassword } from '$lib/server/auth-utils'; +import crypto from 'node:crypto'; + +export const load: PageServerLoad = async ({ params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'applications')) error(403, 'Access denied'); + + const id = parseInt(params.id, 10); + if (isNaN(id)) error(404, 'Application not found'); + + const [app] = await db.select().from(applications).where(eq(applications.id, id)); + if (!app) error(404, 'Application not found'); + + let pet = null; + if (app.petId) { + const [petRecord] = await db.select().from(pets).where(eq(pets.id, app.petId)); + pet = petRecord ?? null; + } + + const homevisitNotes = await db + .select({ + id: applicationHomevisitNotes.id, + notes: applicationHomevisitNotes.notes, + visitDate: applicationHomevisitNotes.visitDate, + createdAt: applicationHomevisitNotes.createdAt, + userName: users.name + }) + .from(applicationHomevisitNotes) + .innerJoin(users, eq(applicationHomevisitNotes.userId, users.id)) + .where(eq(applicationHomevisitNotes.applicationId, id)) + .orderBy(asc(applicationHomevisitNotes.createdAt)); + + return { application: app, pet, homevisitNotes }; +}; + +export const actions: Actions = { + updateStatus: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'applications')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + const fd = await request.formData(); + const status = fd.get('status')?.toString(); + + if (!status || !['pending', 'reviewing', 'approved', 'denied', 'withdrawn'].includes(status)) { + return fail(400, { error: 'Invalid status' }); + } + + await db + .update(applications) + .set({ status: status as typeof applications.status.enumValues[number] }) + .where(eq(applications.id, id)); + + await logAudit({ + userId: locals.user?.id ?? undefined, + action: 'update_application_status', + entity: 'application', + entityId: id, + diff: { status } + }); + + // Send status update email + try { + const [app] = await db + .select({ applicantName: applications.applicantName, email: applications.applicantEmail, type: applications.type }) + .from(applications) + .where(eq(applications.id, id)); + + if (app) { + await sendApplicationStatusUpdate( + process.env.ORG_NAME ?? 'Rescue', + app.email, + app.applicantName, + app.type, + status, + process.env.SITE_URL ?? '' + ); + + // Auto-create foster account on approval + if (status === 'approved' && app.type === 'foster') { + const [existingUser] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, app.email)) + .limit(1); + + if (!existingUser) { + const tempPassword = crypto.randomBytes(4).toString('hex'); + const passwordHash = await hashPassword(tempPassword); + await db.insert(users).values({ + email: app.email, + name: app.applicantName, + role: 'foster', + passwordHash, + mustChangePassword: true, + active: true + }); + await sendFosterWelcomeEmail( + process.env.ORG_NAME ?? 'Rescue', + app.email, + app.applicantName, + tempPassword, + `${process.env.SITE_URL ?? ''}/login` + ); + } + } + } + } catch { + // Email failure should not break status update + } + + return { success: true, action: 'updateStatus' }; + }, + + addNote: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'applications')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + const fd = await request.formData(); + const note = fd.get('note')?.toString().trim(); + + if (!note) return fail(400, { error: 'Note cannot be empty' }); + + const [app] = await db + .select({ reviewNotes: applications.reviewNotes }) + .from(applications) + .where(eq(applications.id, id)); + + const timestamp = new Date().toLocaleString('en-US', { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit' + }); + const userName = locals.user?.name ?? 'Unknown'; + const newNote = `[${timestamp} — ${userName}] ${note}`; + const updatedNotes = app?.reviewNotes ? `${app.reviewNotes}\n\n${newNote}` : newNote; + + await db.update(applications).set({ reviewNotes: updatedNotes }).where(eq(applications.id, id)); + + await logAudit({ + userId: locals.user?.id ?? undefined, + action: 'add_application_note', + entity: 'application', + entityId: id + }); + + return { success: true, action: 'addNote' }; + }, + + addHomevisitNote: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'applications')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + const fd = await request.formData(); + const notes = fd.get('notes')?.toString().trim(); + const visitDate = fd.get('visitDate')?.toString() || null; + + if (!notes) return fail(400, { error: 'Note cannot be empty' }); + + await db.insert(applicationHomevisitNotes).values({ + applicationId: id, + userId: locals.user!.id, + notes, + visitDate: visitDate ? new Date(visitDate) : null + }); + + await logAudit({ + userId: locals.user?.id ?? undefined, + action: 'add_homevisit_note', + entity: 'application', + entityId: id + }); + + return { success: true, action: 'addHomevisitNote' }; + } +}; diff --git a/src/routes/admin/applications/[id]/+page.svelte b/src/routes/admin/applications/[id]/+page.svelte new file mode 100644 index 0000000..25171dd --- /dev/null +++ b/src/routes/admin/applications/[id]/+page.svelte @@ -0,0 +1,354 @@ + + + + Application #{app.id} — Admin + + +
    +
    + + + + + +
    +

    Application #{app.id}

    + + {formatType(app.type)} + + +
    +
    + + {#if form?.error} +
    {form.error}
    + {/if} + +
    + +
    + +
    +

    Applicant Information

    +
    +
    +

    Name

    +

    {app.applicantName}

    +
    +
    +

    Email

    +

    + {app.applicantEmail} +

    +
    + {#if app.applicantPhone} +
    +

    Phone

    +

    + {app.applicantPhone} +

    +
    + {/if} +
    +

    Submitted

    +

    {formatDate(app.createdAt)}

    +
    +
    +
    + + +
    +

    Application Details

    + {#if formDataEntries.length === 0} +

    No form data available.

    + {:else} +
    + {#each formDataEntries as [key, value]} +
    +
    {formatFieldLabel(key)}
    +
    + {#if typeof value === 'boolean'} + {value ? 'Yes' : 'No'} + {:else if Array.isArray(value)} + {value.join(', ')} + {:else} + {String(value)} + {/if} +
    +
    + {/each} +
    + + {@const fd = typeof app.formData === 'string' ? JSON.parse(app.formData as string) : app.formData} + {#if (fd as Record)?.signature} +
    +

    Signature

    +
    + ).signature as string} alt="Applicant signature" class="max-h-24" /> +
    +
    + {/if} + {/if} +
    + + +
    +

    Review Notes

    + + {#if showNoteSuccess} +
    + + + + Note added. +
    + {/if} + + {#if app.reviewNotes} +
    +
    {app.reviewNotes}
    +
    + {:else} +

    No notes yet.

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

    Homevisit Notes

    + + {#if showHomevisitNoteSuccess} +
    + + + + Homevisit note added. +
    + {/if} + + {#if homevisitNotes.length > 0} +
    + {#each homevisitNotes as note} +
    +
    + {note.userName} + {formatDate(note.createdAt)} + {#if note.visitDate} + Visit: {formatDate(note.visitDate)} + {/if} +
    +

    {note.notes}

    +
    + {/each} +
    + {:else} +

    No homevisit notes yet.

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

    Update Status

    + + {#if showStatusSuccess} +
    + + + + Status updated. +
    + {/if} + +
    +
    + + +
    + +
    +
    + + + {#if pet} + + {/if} + + + {#if app.type === 'adoption'} +
    +

    Adoption Contract

    + + Generate Contract + +
    + {/if} +
    +
    +
    diff --git a/src/routes/admin/applications/[id]/contract/+page.server.ts b/src/routes/admin/applications/[id]/contract/+page.server.ts new file mode 100644 index 0000000..acdb463 --- /dev/null +++ b/src/routes/admin/applications/[id]/contract/+page.server.ts @@ -0,0 +1,64 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { applications, pets } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import { generateAdoptionContract } from '$lib/server/contract'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'applications')) error(403, 'Access denied'); + + const id = parseInt(params.id, 10); + if (isNaN(id)) error(404, 'Application not found'); + + const [app] = await db.select().from(applications).where(eq(applications.id, id)); + if (!app) error(404, 'Application not found'); + + if (app.type !== 'adoption') { + error(400, 'Contracts can only be generated for adoption applications'); + } + + let pet = null; + if (app.petId) { + const [petRecord] = await db.select().from(pets).where(eq(pets.id, app.petId)); + pet = petRecord ?? null; + } + + if (!pet) { + error(400, 'No pet linked to this application. Link a pet first before generating a contract.'); + } + + const fd = app.formData as Record ?? {}; + const address = (fd.address as string) ?? ''; + + const contractHtml = generateAdoptionContract({ + orgName: process.env.ORG_NAME ?? 'Rescue Organization', + orgCity: process.env.ORG_CITY ?? '', + orgState: process.env.ORG_STATE ?? '', + petName: pet.name, + species: pet.species, + breed: pet.breed ?? '', + sex: pet.sex, + age: pet.age ?? '', + adopterName: app.applicantName, + adopterEmail: app.applicantEmail, + adoptionFee: pet.adoptionFee ?? 0, + agreementItems: [ + 'The adopter agrees to provide proper food, water, shelter, and veterinary care.', + 'The adopter agrees not to re-home, sell, or transfer ownership without notifying the rescue.', + 'The adopter agrees to keep the pet as an indoor pet (where applicable).', + 'The adopter agrees to have the pet spayed/neutered if not already done.', + 'The rescue reserves the right to reclaim the animal if terms are not met.' + ], + date: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) + }); + + return { + contractHtml, + applicationId: app.id, + petName: pet.name, + applicantName: app.applicantName + }; +}; diff --git a/src/routes/admin/applications/[id]/contract/+page.svelte b/src/routes/admin/applications/[id]/contract/+page.svelte new file mode 100644 index 0000000..0eade24 --- /dev/null +++ b/src/routes/admin/applications/[id]/contract/+page.svelte @@ -0,0 +1,29 @@ + + + + Adoption Contract — {data.petName} + + + +
    + + Back to Application + + +
    + +{@html data.contractHtml} From d1e7a52eecf33134f8f0bf7b5e9c65ffc990ad92 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:30:55 -0500 Subject: [PATCH 23/38] =?UTF-8?q?feat(admin):=20Task=2034=20=E2=80=94=20do?= =?UTF-8?q?nations,=20expenses,=20and=20financial=20reports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/routes/admin/donations/+page.server.ts | 80 ++++++++ src/routes/admin/donations/+page.svelte | 135 +++++++++++++ .../admin/donations/[id]/+page.server.ts | 74 +++++++ src/routes/admin/donations/[id]/+page.svelte | 151 +++++++++++++++ .../admin/donations/new/+page.server.ts | 62 ++++++ src/routes/admin/donations/new/+page.svelte | 115 +++++++++++ src/routes/admin/expenses/+page.server.ts | 76 ++++++++ src/routes/admin/expenses/+page.svelte | 117 +++++++++++ .../admin/expenses/[id]/+page.server.ts | 77 ++++++++ src/routes/admin/expenses/[id]/+page.svelte | 146 ++++++++++++++ src/routes/admin/expenses/new/+page.server.ts | 64 ++++++ src/routes/admin/expenses/new/+page.svelte | 94 +++++++++ src/routes/admin/reports/+page.server.ts | 84 ++++++++ src/routes/admin/reports/+page.svelte | 183 ++++++++++++++++++ 14 files changed, 1458 insertions(+) create mode 100644 src/routes/admin/donations/+page.server.ts create mode 100644 src/routes/admin/donations/+page.svelte create mode 100644 src/routes/admin/donations/[id]/+page.server.ts create mode 100644 src/routes/admin/donations/[id]/+page.svelte create mode 100644 src/routes/admin/donations/new/+page.server.ts create mode 100644 src/routes/admin/donations/new/+page.svelte create mode 100644 src/routes/admin/expenses/+page.server.ts create mode 100644 src/routes/admin/expenses/+page.svelte create mode 100644 src/routes/admin/expenses/[id]/+page.server.ts create mode 100644 src/routes/admin/expenses/[id]/+page.svelte create mode 100644 src/routes/admin/expenses/new/+page.server.ts create mode 100644 src/routes/admin/expenses/new/+page.svelte create mode 100644 src/routes/admin/reports/+page.server.ts create mode 100644 src/routes/admin/reports/+page.svelte diff --git a/src/routes/admin/donations/+page.server.ts b/src/routes/admin/donations/+page.server.ts new file mode 100644 index 0000000..e627ee5 --- /dev/null +++ b/src/routes/admin/donations/+page.server.ts @@ -0,0 +1,80 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { donations, pets } from '$lib/server/schema'; +import { eq, and, gte, lte, count, sum } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +const PER_PAGE = 20; + +export const load: PageServerLoad = async ({ url, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'donations')) error(403, 'Access denied'); + + const source = url.searchParams.get('source') ?? ''; + const startDate = url.searchParams.get('startDate') ?? ''; + const endDate = url.searchParams.get('endDate') ?? ''; + const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10)); + const offset = (page - 1) * PER_PAGE; + + const conditions = []; + if (source) conditions.push(eq(donations.source, source as typeof donations.source.enumValues[number])); + if (startDate) conditions.push(gte(donations.date, new Date(startDate))); + if (endDate) { + const end = new Date(endDate); + end.setHours(23, 59, 59, 999); + conditions.push(lte(donations.date, end)); + } + + const where = conditions.length > 0 ? and(...conditions) : undefined; + + const [totalRow] = await db.select({ total: count() }).from(donations).where(where); + const total = totalRow?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); + + const [filteredSumRow] = await db.select({ total: sum(donations.amount) }).from(donations).where(where); + const filteredTotal = filteredSumRow?.total ?? 0; + + const now = new Date(); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const [monthSumRow] = await db.select({ total: sum(donations.amount) }).from(donations).where(gte(donations.date, monthStart)); + const monthTotal = monthSumRow?.total ?? 0; + + const yearStart = new Date(now.getFullYear(), 0, 1); + const [yearSumRow] = await db.select({ total: sum(donations.amount) }).from(donations).where(gte(donations.date, yearStart)); + const yearTotal = yearSumRow?.total ?? 0; + + const rows = await db + .select({ + id: donations.id, + source: donations.source, + amount: donations.amount, + donorName: donations.donorName, + donorEmail: donations.donorEmail, + petId: donations.petId, + petName: pets.name, + isRecurring: donations.isRecurring, + notes: donations.notes, + date: donations.date, + createdAt: donations.createdAt + }) + .from(donations) + .leftJoin(pets, eq(donations.petId, pets.id)) + .where(where) + .orderBy(donations.createdAt) + .limit(PER_PAGE) + .offset(offset); + + return { + donations: rows, + total, + page, + totalPages, + filteredTotal, + monthTotal, + yearTotal, + source, + startDate, + endDate + }; +}; diff --git a/src/routes/admin/donations/+page.svelte b/src/routes/admin/donations/+page.svelte new file mode 100644 index 0000000..ab078c3 --- /dev/null +++ b/src/routes/admin/donations/+page.svelte @@ -0,0 +1,135 @@ + + + + Donations — Admin + + + + + +
    +
    +

    This Month

    +

    {fmt.format(data.monthTotal / 100)}

    +
    +
    +

    This Year

    +

    {fmt.format(data.yearTotal / 100)}

    +
    +
    +

    Filtered Total

    +

    {fmt.format(data.filteredTotal / 100)}

    +
    +
    + + +
    +
    + +
    + + handleFilterChange('startDate', e.currentTarget.value)} class="px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-brand" /> +
    +
    + + handleFilterChange('endDate', e.currentTarget.value)} class="px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-brand" /> +
    +
    +
    + +

    {data.total} donation{data.total !== 1 ? 's' : ''} found

    + + + {#snippet row(donation)} + + + {donation.donorName ?? 'Anonymous'} + + {#if donation.donorEmail} +

    {donation.donorEmail}

    + {/if} + + {fmt.format(donation.amount / 100)} + + + {donation.source.replace(/_/g, ' ')} + + + {donation.petName ?? '—'} + {formatDate(donation.date)} + + {#if donation.isRecurring} + + Yes + + {:else} + One-time + {/if} + + + Edit + + {/snippet} +
    + + diff --git a/src/routes/admin/donations/[id]/+page.server.ts b/src/routes/admin/donations/[id]/+page.server.ts new file mode 100644 index 0000000..f5d1390 --- /dev/null +++ b/src/routes/admin/donations/[id]/+page.server.ts @@ -0,0 +1,74 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { donations, pets } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { logAudit } from '$lib/server/audit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'donations')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + if (isNaN(id)) error(404, 'Donation not found'); + + const [donation] = await db.select().from(donations).where(eq(donations.id, id)); + if (!donation) error(404, 'Donation not found'); + + const petList = await db.select({ id: pets.id, name: pets.name }).from(pets).orderBy(pets.name); + return { donation, pets: petList }; +}; + +export const actions: Actions = { + update: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'donations')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + const fd = await request.formData(); + + const source = fd.get('source')?.toString() ?? ''; + const amountStr = fd.get('amount')?.toString().trim() ?? ''; + const donorName = fd.get('donorName')?.toString().trim() || null; + const donorEmail = fd.get('donorEmail')?.toString().trim() || null; + const petId = fd.get('petId')?.toString() ? parseInt(fd.get('petId') as string, 10) : null; + const isRecurring = fd.get('isRecurring') === 'on'; + const notes = fd.get('notes')?.toString().trim() || null; + const purpose = fd.get('purpose')?.toString().trim() || null; + + const validSources = ['stripe', 'paypal', 'venmo', 'givebutter', 'cash', 'check', 'zelle', 'google_pay', 'square', 'other']; + if (!validSources.includes(source)) return fail(400, { error: 'Please select a valid source' }); + + const parsedAmount = parseFloat(amountStr); + if (!amountStr || isNaN(parsedAmount) || parsedAmount <= 0) return fail(400, { error: 'Amount must be a positive number' }); + + await db.update(donations).set({ + source: source as typeof donations.source.enumValues[number], + amount: Math.round(parsedAmount * 100), + donorName, + donorEmail, + petId, + isRecurring, + notes, + purpose + }).where(eq(donations.id, id)); + + await logAudit({ + userId: locals.user!.id, + action: 'update_donation', + entity: 'donation', + entityId: id, + diff: { source, amount: parsedAmount, donorName } + }); + + return { success: true }; + }, + + delete: async ({ params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'donations')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + + await db.delete(donations).where(eq(donations.id, id)); + await logAudit({ userId: locals.user!.id, action: 'delete_donation', entity: 'donation', entityId: id }); + + redirect(303, '/admin/donations'); + } +}; diff --git a/src/routes/admin/donations/[id]/+page.svelte b/src/routes/admin/donations/[id]/+page.svelte new file mode 100644 index 0000000..0774f46 --- /dev/null +++ b/src/routes/admin/donations/[id]/+page.svelte @@ -0,0 +1,151 @@ + + + + Edit Donation — Admin + + +
    +
    + + + + + +

    Edit Donation

    +
    + + {#if showSuccess} +
    + + + + Donation updated successfully. +
    + {/if} + + {#if form?.error} +
    {form.error}
    + {/if} + +
    +
    +

    Donation Details

    +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    + +
    + + +
    +
    +
    + +
    + +
    + Cancel + +
    +
    +
    + + + + deleteFormEl?.requestSubmit()} + oncancel={() => confirmDelete = false} + /> +
    diff --git a/src/routes/admin/donations/new/+page.server.ts b/src/routes/admin/donations/new/+page.server.ts new file mode 100644 index 0000000..bff7991 --- /dev/null +++ b/src/routes/admin/donations/new/+page.server.ts @@ -0,0 +1,62 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { donations, pets } from '$lib/server/schema'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { logAudit } from '$lib/server/audit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'donations')) error(403, 'Access denied'); + const petList = await db.select({ id: pets.id, name: pets.name }).from(pets).orderBy(pets.name); + return { pets: petList }; +}; + +export const actions: Actions = { + default: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'donations')) error(403, 'Access denied'); + const fd = await request.formData(); + + const source = fd.get('source')?.toString() ?? ''; + const amountStr = fd.get('amount')?.toString().trim() ?? ''; + const donorName = fd.get('donorName')?.toString().trim() || null; + const donorEmail = fd.get('donorEmail')?.toString().trim() || null; + const petId = fd.get('petId')?.toString() ? parseInt(fd.get('petId') as string, 10) : null; + const isRecurring = fd.get('isRecurring') === 'on'; + const notes = fd.get('notes')?.toString().trim() || null; + const purpose = fd.get('purpose')?.toString().trim() || null; + + const validSources = ['stripe', 'paypal', 'venmo', 'givebutter', 'cash', 'check', 'zelle', 'google_pay', 'square', 'other']; + if (!validSources.includes(source)) { + return fail(400, { error: 'Please select a valid source', values: Object.fromEntries(fd) }); + } + + const parsedAmount = parseFloat(amountStr); + if (!amountStr || isNaN(parsedAmount) || parsedAmount <= 0) { + return fail(400, { error: 'Amount must be a positive number', values: Object.fromEntries(fd) }); + } + + const amountCents = Math.round(parsedAmount * 100); + + const [result] = await db.insert(donations).values({ + source: source as typeof donations.source.enumValues[number], + amount: amountCents, + donorName, + donorEmail, + petId, + isRecurring, + notes, + purpose + }); + + await logAudit({ + userId: locals.user!.id, + action: 'create_donation', + entity: 'donation', + entityId: result.insertId, + diff: { source, amount: parsedAmount, donorName } + }); + + redirect(303, '/admin/donations'); + } +}; diff --git a/src/routes/admin/donations/new/+page.svelte b/src/routes/admin/donations/new/+page.svelte new file mode 100644 index 0000000..f1ccf18 --- /dev/null +++ b/src/routes/admin/donations/new/+page.svelte @@ -0,0 +1,115 @@ + + + + Add Donation — Admin + + +
    +
    + + + + + +

    Add Donation

    +
    + + {#if form?.error} +
    {form.error}
    + {/if} + +
    { + submitting = true; + return async ({ update }) => { submitting = false; await update(); }; + }} + class="space-y-6" + > +
    +

    Donation Details

    +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    + +
    + + +
    +
    +
    + +
    + Cancel + +
    +
    +
    diff --git a/src/routes/admin/expenses/+page.server.ts b/src/routes/admin/expenses/+page.server.ts new file mode 100644 index 0000000..62c1d5e --- /dev/null +++ b/src/routes/admin/expenses/+page.server.ts @@ -0,0 +1,76 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { expenses, pets, vets } from '$lib/server/schema'; +import { eq, and, gte, lte, like, count, sum, desc } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +const PER_PAGE = 20; + +export const load: PageServerLoad = async ({ url, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'expenses')) error(403, 'Access denied'); + + const category = url.searchParams.get('category') ?? ''; + const startDate = url.searchParams.get('startDate') ?? ''; + const endDate = url.searchParams.get('endDate') ?? ''; + const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10)); + const offset = (page - 1) * PER_PAGE; + + const conditions = []; + if (category) conditions.push(like(expenses.category, `%${category}%`)); + if (startDate) conditions.push(gte(expenses.date, new Date(startDate))); + if (endDate) conditions.push(lte(expenses.date, new Date(endDate))); + + const where = conditions.length > 0 ? and(...conditions) : undefined; + + const [totalRow] = await db.select({ total: count() }).from(expenses).where(where); + const total = totalRow?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); + + const [filteredSumRow] = await db.select({ total: sum(expenses.amount) }).from(expenses).where(where); + const filteredTotal = filteredSumRow?.total ?? 0; + + const now = new Date(); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const [monthSumRow] = await db.select({ total: sum(expenses.amount) }).from(expenses).where(gte(expenses.date, monthStart)); + const monthTotal = monthSumRow?.total ?? 0; + + const yearStart = new Date(now.getFullYear(), 0, 1); + const [yearSumRow] = await db.select({ total: sum(expenses.amount) }).from(expenses).where(gte(expenses.date, yearStart)); + const yearTotal = yearSumRow?.total ?? 0; + + const rows = await db + .select({ + id: expenses.id, + category: expenses.category, + amount: expenses.amount, + description: expenses.description, + date: expenses.date, + receiptUrl: expenses.receiptUrl, + petId: expenses.petId, + petName: pets.name, + vetId: expenses.vetId, + vetName: vets.name + }) + .from(expenses) + .leftJoin(pets, eq(expenses.petId, pets.id)) + .leftJoin(vets, eq(expenses.vetId, vets.id)) + .where(where) + .orderBy(desc(expenses.date)) + .limit(PER_PAGE) + .offset(offset); + + return { + expenses: rows, + total, + page, + totalPages, + filteredTotal, + monthTotal, + yearTotal, + category, + startDate, + endDate + }; +}; diff --git a/src/routes/admin/expenses/+page.svelte b/src/routes/admin/expenses/+page.svelte new file mode 100644 index 0000000..aa7a5ce --- /dev/null +++ b/src/routes/admin/expenses/+page.svelte @@ -0,0 +1,117 @@ + + + + Expenses — Admin + + + + +
    +
    +

    This Month

    +

    {fmt.format((data.monthTotal ?? 0) / 100)}

    +
    +
    +

    This Year

    +

    {fmt.format((data.yearTotal ?? 0) / 100)}

    +
    +
    +

    Filtered Total

    +

    {fmt.format((data.filteredTotal ?? 0) / 100)}

    +
    +
    + +
    +
    +
    + handleFilterChange('category', e.currentTarget.value)} + class="w-full px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand" + /> +
    +
    + + handleFilterChange('startDate', e.currentTarget.value)} class="px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-brand" /> +
    +
    + + handleFilterChange('endDate', e.currentTarget.value)} class="px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-brand" /> +
    +
    +
    + +

    {data.total} expense{data.total !== 1 ? 's' : ''} found

    + + + {#snippet row(expense)} + + + {expense.description || 'No description'} + + + {fmt.format(expense.amount / 100)} + + {#if expense.category} + + {expense.category} + + {:else} + — + {/if} + + {expense.petName ?? '—'} + {expense.vetName ?? '—'} + {formatDate(expense.date)} + + Edit + + {/snippet} + + + diff --git a/src/routes/admin/expenses/[id]/+page.server.ts b/src/routes/admin/expenses/[id]/+page.server.ts new file mode 100644 index 0000000..2af4e06 --- /dev/null +++ b/src/routes/admin/expenses/[id]/+page.server.ts @@ -0,0 +1,77 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { expenses, pets, vets } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { logAudit } from '$lib/server/audit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'expenses')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + if (isNaN(id)) error(404, 'Expense not found'); + + const [expense] = await db.select().from(expenses).where(eq(expenses.id, id)); + if (!expense) error(404, 'Expense not found'); + + const [petList, vetList] = await Promise.all([ + db.select({ id: pets.id, name: pets.name }).from(pets).orderBy(pets.name), + db.select({ id: vets.id, name: vets.name, clinic: vets.clinic }).from(vets).orderBy(vets.name) + ]); + + return { expense, pets: petList, vets: vetList }; +}; + +export const actions: Actions = { + update: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'expenses')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + const fd = await request.formData(); + + const description = fd.get('description')?.toString().trim() ?? ''; + const category = fd.get('category')?.toString().trim() || null; + const amountStr = fd.get('amount')?.toString().trim() ?? ''; + const dateStr = fd.get('date')?.toString() ?? ''; + const petId = fd.get('petId')?.toString() ? parseInt(fd.get('petId') as string, 10) : null; + const vetId = fd.get('vetId')?.toString() ? parseInt(fd.get('vetId') as string, 10) : null; + const notes = fd.get('notes')?.toString().trim() || null; + + if (!description) return fail(400, { error: 'Description is required' }); + + const parsedAmount = parseFloat(amountStr); + if (!amountStr || isNaN(parsedAmount) || parsedAmount <= 0) return fail(400, { error: 'Amount must be a positive number' }); + + if (!dateStr) return fail(400, { error: 'Date is required' }); + + await db.update(expenses).set({ + description, + category, + amount: Math.round(parsedAmount * 100), + date: new Date(dateStr), + petId, + vetId, + notes + }).where(eq(expenses.id, id)); + + await logAudit({ + userId: locals.user!.id, + action: 'update_expense', + entity: 'expense', + entityId: id, + diff: { description, category, amount: parsedAmount } + }); + + return { success: true }; + }, + + delete: async ({ params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'expenses')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + + await db.delete(expenses).where(eq(expenses.id, id)); + await logAudit({ userId: locals.user!.id, action: 'delete_expense', entity: 'expense', entityId: id }); + + redirect(303, '/admin/expenses'); + } +}; diff --git a/src/routes/admin/expenses/[id]/+page.svelte b/src/routes/admin/expenses/[id]/+page.svelte new file mode 100644 index 0000000..212bb8f --- /dev/null +++ b/src/routes/admin/expenses/[id]/+page.svelte @@ -0,0 +1,146 @@ + + + + Edit Expense — Admin + + +
    +
    + + + + + +

    Edit Expense

    +
    + + {#if showSuccess} +
    + + + + Expense updated successfully. +
    + {/if} + + {#if form?.error} +
    {form.error}
    + {/if} + +
    +
    +

    Expense Details

    +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + + {#if expense.receiptUrl} +
    +

    Receipt

    + View receipt +
    + {/if} + +
    + + +
    +
    +
    + +
    + +
    + Cancel + +
    +
    +
    + + + + deleteFormEl?.requestSubmit()} + oncancel={() => confirmDelete = false} + /> +
    diff --git a/src/routes/admin/expenses/new/+page.server.ts b/src/routes/admin/expenses/new/+page.server.ts new file mode 100644 index 0000000..bddef8f --- /dev/null +++ b/src/routes/admin/expenses/new/+page.server.ts @@ -0,0 +1,64 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { expenses, pets, vets } from '$lib/server/schema'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { logAudit } from '$lib/server/audit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'expenses')) error(403, 'Access denied'); + const [petList, vetList] = await Promise.all([ + db.select({ id: pets.id, name: pets.name }).from(pets).orderBy(pets.name), + db.select({ id: vets.id, name: vets.name, clinic: vets.clinic }).from(vets).orderBy(vets.name) + ]); + return { pets: petList, vets: vetList }; +}; + +export const actions: Actions = { + default: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'expenses')) error(403, 'Access denied'); + const fd = await request.formData(); + + const description = fd.get('description')?.toString().trim() ?? ''; + const category = fd.get('category')?.toString().trim() || null; + const amountStr = fd.get('amount')?.toString().trim() ?? ''; + const dateStr = fd.get('date')?.toString() ?? ''; + const petId = fd.get('petId')?.toString() ? parseInt(fd.get('petId') as string, 10) : null; + const vetId = fd.get('vetId')?.toString() ? parseInt(fd.get('vetId') as string, 10) : null; + const notes = fd.get('notes')?.toString().trim() || null; + + if (!description) { + return fail(400, { error: 'Description is required', values: Object.fromEntries(fd) }); + } + + const parsedAmount = parseFloat(amountStr); + if (!amountStr || isNaN(parsedAmount) || parsedAmount <= 0) { + return fail(400, { error: 'Amount must be a positive number', values: Object.fromEntries(fd) }); + } + + if (!dateStr) { + return fail(400, { error: 'Date is required', values: Object.fromEntries(fd) }); + } + + const [result] = await db.insert(expenses).values({ + description, + category, + amount: Math.round(parsedAmount * 100), + date: new Date(dateStr), + petId, + vetId, + notes + }); + + await logAudit({ + userId: locals.user!.id, + action: 'create_expense', + entity: 'expense', + entityId: result.insertId, + diff: { description, category, amount: parsedAmount } + }); + + redirect(303, '/admin/expenses'); + } +}; diff --git a/src/routes/admin/expenses/new/+page.svelte b/src/routes/admin/expenses/new/+page.svelte new file mode 100644 index 0000000..af12db0 --- /dev/null +++ b/src/routes/admin/expenses/new/+page.svelte @@ -0,0 +1,94 @@ + + + + Add Expense — Admin + + +
    +
    + + + + + +

    Add Expense

    +
    + + {#if form?.error} +
    {form.error}
    + {/if} + +
    { submitting = true; return async ({ update }) => { submitting = false; await update(); }; }} + class="space-y-6" + > +
    +

    Expense Details

    +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    +
    + +
    + Cancel + +
    +
    +
    diff --git a/src/routes/admin/reports/+page.server.ts b/src/routes/admin/reports/+page.server.ts new file mode 100644 index 0000000..e14a8a2 --- /dev/null +++ b/src/routes/admin/reports/+page.server.ts @@ -0,0 +1,84 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { donations, expenses } from '$lib/server/schema'; +import { sql, and, gte, lt, sum } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ url, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'donations')) error(403, 'Access denied'); + + const year = parseInt(url.searchParams.get('year') ?? String(new Date().getFullYear()), 10); + const yearStart = new Date(year, 0, 1); + const yearEnd = new Date(year + 1, 0, 1); + + // Donations by month + const donationsByMonth = await db + .select({ + month: sql`MONTH(${donations.date})`.as('month'), + total: sql`COALESCE(SUM(${donations.amount}), 0)`.as('total') + }) + .from(donations) + .where(and(gte(donations.date, yearStart), lt(donations.date, yearEnd))) + .groupBy(sql`MONTH(${donations.date})`) + .orderBy(sql`MONTH(${donations.date})`); + + // Donations by source + const donationsBySource = await db + .select({ + source: donations.source, + total: sql`COALESCE(SUM(${donations.amount}), 0)`.as('total') + }) + .from(donations) + .where(and(gte(donations.date, yearStart), lt(donations.date, yearEnd))) + .groupBy(donations.source) + .orderBy(sql`SUM(${donations.amount}) DESC`); + + // Expenses by month + const expensesByMonth = await db + .select({ + month: sql`MONTH(${expenses.date})`.as('month'), + total: sql`COALESCE(SUM(${expenses.amount}), 0)`.as('total') + }) + .from(expenses) + .where(and(gte(expenses.date, yearStart), lt(expenses.date, yearEnd))) + .groupBy(sql`MONTH(${expenses.date})`) + .orderBy(sql`MONTH(${expenses.date})`); + + // Expenses by category + const expensesByCategory = await db + .select({ + category: expenses.category, + total: sql`COALESCE(SUM(${expenses.amount}), 0)`.as('total') + }) + .from(expenses) + .where(and(gte(expenses.date, yearStart), lt(expenses.date, yearEnd))) + .groupBy(expenses.category) + .orderBy(sql`SUM(${expenses.amount}) DESC`); + + const donMonthMap = new Map(donationsByMonth.map((r) => [r.month, r.total])); + const expMonthMap = new Map(expensesByMonth.map((r) => [r.month, r.total])); + + const monthlyBreakdown = Array.from({ length: 12 }, (_, i) => { + const m = i + 1; + const d = donMonthMap.get(m) ?? 0; + const e = expMonthMap.get(m) ?? 0; + return { month: m, donations: d, expenses: e, net: d - e }; + }); + + const totalDonations = monthlyBreakdown.reduce((s, r) => s + r.donations, 0); + const totalExpenses = monthlyBreakdown.reduce((s, r) => s + r.expenses, 0); + + return { + year, + donationsByMonth, + expensesByMonth, + donationsBySource: donationsBySource.map(r => ({ source: r.source, total: r.total })), + expensesByCategory: expensesByCategory.map(r => ({ category: r.category ?? 'Uncategorized', total: r.total })), + totalDonations, + totalExpenses, + net: totalDonations - totalExpenses, + monthlyBreakdown + }; +}; diff --git a/src/routes/admin/reports/+page.svelte b/src/routes/admin/reports/+page.svelte new file mode 100644 index 0000000..b861e04 --- /dev/null +++ b/src/routes/admin/reports/+page.svelte @@ -0,0 +1,183 @@ + + + + Financial Reports — Admin + + +
    +
    +

    Financial Reports

    +
    + + + +
    +
    + + +
    +
    +

    Total Donations

    +

    {fmt(data.totalDonations)}

    +
    +
    +

    Total Expenses

    +

    {fmt(data.totalExpenses)}

    +
    +
    +

    Net Income

    +

    = 0} class:text-red-600={data.net < 0}>{fmt(data.net)}

    +
    +
    + + +
    +

    Monthly Breakdown — {data.year}

    +
    + + + + + + + + + + + {#each data.monthlyBreakdown as row} + + + + + + + {/each} + + + + + + + + + +
    MonthDonationsExpensesNet
    {MONTH_NAMES[row.month - 1]}{fmt(row.donations)}{fmt(row.expenses)}= 0} class:text-red-600={row.net < 0}>{fmt(row.net)}
    Total{fmt(data.totalDonations)}{fmt(data.totalExpenses)}= 0} class:text-red-600={data.net < 0}>{fmt(data.net)}
    +
    +
    + +
    + +
    +

    Donations by Source

    + {#if data.donationsBySource.length === 0} +

    No donation data for {data.year}.

    + {:else} +
    + {#each data.donationsBySource as row} + {@const pct = (row.total / maxOf(data.donationsBySource)) * 100} +
    +
    + {row.source.replace(/_/g, ' ')} + {fmt(row.total)} +
    +
    +
    +
    +
    + {/each} +
    + {/if} +
    + + +
    +

    Expenses by Category

    + {#if data.expensesByCategory.length === 0} +

    No expense data for {data.year}.

    + {:else} +
    + {#each data.expensesByCategory as row, i} + {@const pct = (row.total / maxOf(data.expensesByCategory)) * 100} +
    +
    + {row.category} + {fmt(row.total)} +
    +
    +
    +
    +
    + {/each} +
    + {/if} +
    +
    +
    From 6b6dab6cf867b927bd5b7f38a5386c83ac9d72d2 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:33:05 -0500 Subject: [PATCH 24/38] Add admin layout and dashboard (Task 31) Sidebar with permission-filtered nav, mobile hamburger, org name from config, logout button. Dashboard loads pet/application/foster/donation stats and recent audit log activity. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/admin/+layout.server.ts | 40 ++++++++++ src/routes/admin/+layout.svelte | 92 +++++++++++++++++++++++ src/routes/admin/+page.server.ts | 78 +++++++++++++++++++ src/routes/admin/+page.svelte | 115 +++++++++++++++++++++++++++++ 4 files changed, 325 insertions(+) create mode 100644 src/routes/admin/+layout.server.ts create mode 100644 src/routes/admin/+layout.svelte create mode 100644 src/routes/admin/+page.server.ts create mode 100644 src/routes/admin/+page.svelte diff --git a/src/routes/admin/+layout.server.ts b/src/routes/admin/+layout.server.ts new file mode 100644 index 0000000..e69a77f --- /dev/null +++ b/src/routes/admin/+layout.server.ts @@ -0,0 +1,40 @@ +import type { LayoutServerLoad } from './$types'; +import { hasPermission, canViewPets } from '$lib/roles'; +import { db } from '$lib/server/db'; +import { fosterSubmissions, supplyRequests } from '$lib/server/schema'; +import { eq, count } from 'drizzle-orm'; + +// Note: auth redirect is handled by hooks.server.ts — no need to duplicate here. + +export const load: LayoutServerLoad = async ({ locals }) => { + const hasPets = hasPermission(locals.user!.role, 'pets'); + + let pendingModeration = 0; + if (hasPets) { + const [[subRow], [reqRow]] = await Promise.all([ + db.select({ total: count() }).from(fosterSubmissions).where(eq(fosterSubmissions.status, 'pending')), + db.select({ total: count() }).from(supplyRequests).where(eq(supplyRequests.status, 'pending')) + ]); + pendingModeration = (subRow?.total ?? 0) + (reqRow?.total ?? 0); + } + + return { + user: locals.user!, + pendingModeration, + canEditPets: hasPets, + permissions: { + pets: hasPets || canViewPets(locals.user!.role), + applications: hasPermission(locals.user!.role, 'applications'), + donations: hasPermission(locals.user!.role, 'donations'), + expenses: hasPermission(locals.user!.role, 'expenses'), + vets: hasPermission(locals.user!.role, 'vets'), + medical: hasPermission(locals.user!.role, 'medical'), + events: hasPermission(locals.user!.role, 'events'), + content: hasPermission(locals.user!.role, 'content'), + volunteers: hasPermission(locals.user!.role, 'volunteers'), + users: hasPermission(locals.user!.role, 'users'), + system: hasPermission(locals.user!.role, 'system'), + import: hasPermission(locals.user!.role, 'import') + } + }; +}; diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte new file mode 100644 index 0000000..e9c7652 --- /dev/null +++ b/src/routes/admin/+layout.svelte @@ -0,0 +1,92 @@ + + +
    + +
    +

    {data.orgConfig.orgInfo.name} Admin

    + +
    + +
    + + + + + {#if sidebarOpen} +
    sidebarOpen = false} + role="button" + tabindex="-1" + onkeydown={() => {}} + >
    + {/if} + + +
    + {@render children()} +
    +
    +
    diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts new file mode 100644 index 0000000..3a9796b --- /dev/null +++ b/src/routes/admin/+page.server.ts @@ -0,0 +1,78 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { pets, applications, fosterAssignments, donations, auditLog, users, fosterSubmissions, supplyRequests } from '$lib/server/schema'; +import { eq, gte, sql, count, sum } from 'drizzle-orm'; +import { hasPermission } from '$lib/roles'; +import { error } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role, 'dashboard')) error(403, 'Access denied'); + + // Available pets count + const [availableRow] = await db + .select({ total: count() }) + .from(pets) + .where(eq(pets.status, 'available')); + const availablePets = availableRow?.total ?? 0; + + // Pending applications count + const [pendingRow] = await db + .select({ total: count() }) + .from(applications) + .where(eq(applications.status, 'pending')); + const pendingApplications = pendingRow?.total ?? 0; + + // Active fosters count + const [fosterRow] = await db + .select({ total: count() }) + .from(fosterAssignments) + .where(eq(fosterAssignments.isCurrent, true)); + const activeFosters = fosterRow?.total ?? 0; + + // This month's donations sum (amount stored as int cents) + const now = new Date(); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const [donationRow] = await db + .select({ total: sum(donations.amount) }) + .from(donations) + .where(gte(donations.date, monthStart)); + const monthlyDonationsCents = parseInt(donationRow?.total ?? '0', 10); + const monthlyDonations = monthlyDonationsCents / 100; + + // Pending moderation counts (for users with 'pets' permission) + let pendingSubmissions = 0; + let pendingRequests = 0; + if (hasPermission(locals.user!.role, 'pets')) { + const [[subRow], [reqRow]] = await Promise.all([ + db.select({ total: count() }).from(fosterSubmissions).where(eq(fosterSubmissions.status, 'pending')), + db.select({ total: count() }).from(supplyRequests).where(eq(supplyRequests.status, 'pending')) + ]); + pendingSubmissions = subRow?.total ?? 0; + pendingRequests = reqRow?.total ?? 0; + } + + // Last 10 audit log entries with user name + const recentActivity = await db + .select({ + id: auditLog.id, + action: auditLog.action, + entity: auditLog.entity, + entityId: auditLog.entityId, + createdAt: auditLog.createdAt, + userName: users.name + }) + .from(auditLog) + .leftJoin(users, eq(auditLog.userId, users.id)) + .orderBy(sql`${auditLog.createdAt} DESC`) + .limit(10); + + return { + availablePets, + pendingApplications, + activeFosters, + monthlyDonations, + pendingSubmissions, + pendingRequests, + recentActivity + }; +}; diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte new file mode 100644 index 0000000..6e4d4c5 --- /dev/null +++ b/src/routes/admin/+page.svelte @@ -0,0 +1,115 @@ + + + + Dashboard — {data.orgConfig.orgInfo.name} Admin + + +

    Dashboard

    + +{#if data.pendingSubmissions > 0 || data.pendingRequests > 0} +
    +
    +

    Moderation Queue

    +

    + You have + {#if data.pendingSubmissions > 0} + {data.pendingSubmissions} foster submission{data.pendingSubmissions === 1 ? '' : 's'} + {/if} + {#if data.pendingSubmissions > 0 && data.pendingRequests > 0} + and + {/if} + {#if data.pendingRequests > 0} + {data.pendingRequests} supply request{data.pendingRequests === 1 ? '' : 's'} + {/if} + pending review. +

    +
    + + Review Now + +
    +{/if} + +
    +
    +

    Available Pets

    +

    {data.availablePets}

    +
    +
    +

    Pending Applications

    +

    {data.pendingApplications}

    +
    +
    +

    Active Fosters

    +

    {data.activeFosters}

    +
    +
    +

    This Month's Donations

    +

    {fmt.format(data.monthlyDonations)}

    +
    +
    + + +
    +

    Recent Activity

    +
    + {#if data.recentActivity.length === 0} +
    + No recent activity recorded. +
    + {:else} +
      + {#each data.recentActivity as entry} +
    • + + {formatAction(entry.action)} + +
      +

      + {entry.userName ?? 'System'} + {entry.action === 'delete' ? 'deleted' : entry.action === 'create' ? 'created' : 'updated'} + {formatEntity(entry.entity)} + {#if entry.entityId} + #{entry.entityId} + {/if} +

      +
      + {formatDate(entry.createdAt)} +
    • + {/each} +
    + {/if} +
    +
    + +

    Welcome, {data.user.name}.

    From 55c1eb7a9fce686a1d4d7884371102690993f9a3 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:33:15 -0500 Subject: [PATCH 25/38] Add admin pet list, new pet form, and quick intake (Task 32a) Pet list: search by name/breed/microchip, filter by status/species, sort columns, pagination, delete with confirm modal. New pet form: full fields including photos with drag-and-drop preview. Quick intake: mobile-optimized form with camera/gallery photo capture, toggles for species/sex, sets intake date to today. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/admin/intake/+page.server.ts | 89 ++++++ src/routes/admin/intake/+page.svelte | 252 +++++++++++++++++ src/routes/admin/pets/+page.server.ts | 109 ++++++++ src/routes/admin/pets/+page.svelte | 270 ++++++++++++++++++ src/routes/admin/pets/new/+page.server.ts | 129 +++++++++ src/routes/admin/pets/new/+page.svelte | 326 ++++++++++++++++++++++ 6 files changed, 1175 insertions(+) create mode 100644 src/routes/admin/intake/+page.server.ts create mode 100644 src/routes/admin/intake/+page.svelte create mode 100644 src/routes/admin/pets/+page.server.ts create mode 100644 src/routes/admin/pets/+page.svelte create mode 100644 src/routes/admin/pets/new/+page.server.ts create mode 100644 src/routes/admin/pets/new/+page.svelte diff --git a/src/routes/admin/intake/+page.server.ts b/src/routes/admin/intake/+page.server.ts new file mode 100644 index 0000000..60afd85 --- /dev/null +++ b/src/routes/admin/intake/+page.server.ts @@ -0,0 +1,89 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { pets, petPhotos, intakeSources } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { slugify } from '$lib/slugify'; +import { hasPermission } from '$lib/roles'; +import { saveUpload } from '$lib/server/upload'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + const sources = await db.select().from(intakeSources).where(eq(intakeSources.active, true)); + + return { intakeSources: sources }; +}; + +export const actions: Actions = { + default: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + const fd = await request.formData(); + + const name = fd.get('name')?.toString().trim() ?? ''; + const species = fd.get('species')?.toString() ?? 'dog'; + + if (!name) return fail(400, { error: 'Name is required' }); + if (!['dog', 'cat', 'other'].includes(species)) return fail(400, { error: 'Invalid species' }); + + // Generate unique slug + let slug = slugify(name); + const existing = await db.select({ id: pets.id }).from(pets).where(eq(pets.slug, slug)); + if (existing.length > 0) slug = `${slug}-${Date.now()}`; + + const breed = fd.get('breed')?.toString().trim() || null; + const sex = fd.get('sex')?.toString() || 'unknown'; + const age = fd.get('age')?.toString().trim() || null; + const intakeSourceId = fd.get('intakeSourceId')?.toString() + ? parseInt(fd.get('intakeSourceId') as string, 10) + : null; + const notes = fd.get('notes')?.toString().trim() || null; + const intakeDate = new Date(); + + const [result] = await db.insert(pets).values({ + name, + species: species as 'dog' | 'cat' | 'other', + breed, + sex: sex as 'male' | 'female' | 'unknown', + age, + description: notes, + slug, + intakeDate, + intakeSourceId, + status: 'available', + featured: false, + published: true + }); + + const petId = result.insertId; + + await logAudit({ + userId: locals.user!.id, + action: 'create', + entity: 'pet', + entityId: Number(petId), + diff: { name, species, source: 'intake' } + }); + + // Handle photo uploads + const photoFiles = fd.getAll('photos'); + let photoIndex = 0; + for (const file of photoFiles) { + if (!(file instanceof File) || file.size === 0) continue; + try { + const { url } = await saveUpload(file, 'pets'); + await db.insert(petPhotos).values({ + petId: Number(petId), + url, + isPrimary: photoIndex === 0, + sortOrder: photoIndex + }); + photoIndex++; + } catch { + // Skip invalid files silently + } + } + + redirect(303, `/admin/pets/${petId}`); + } +}; diff --git a/src/routes/admin/intake/+page.svelte b/src/routes/admin/intake/+page.svelte new file mode 100644 index 0000000..94011b4 --- /dev/null +++ b/src/routes/admin/intake/+page.svelte @@ -0,0 +1,252 @@ + + + + Quick Intake — {data.orgConfig.orgInfo.name} Admin + + + +
    + +
    + + + + + +

    Quick Intake

    +
    + + {#if form?.error} +
    + {form.error} +
    + {/if} + +
    { + submitting = true; + formData.delete('photos'); + for (const file of photoFiles) { + formData.append('photos', file); + } + return async ({ update }) => { + submitting = false; + await update(); + }; + }} + class="space-y-4" + > + +
    +

    Quick ID

    + +
    +
    + + +
    + + +
    + + +
    + {#each [{ value: 'dog', label: 'Dog', icon: '🐕' }, { value: 'cat', label: 'Cat', icon: '🐈' }, { value: 'other', label: 'Other', icon: '🐾' }] as s} + + {/each} +
    +
    + + +
    + + +
    + {#each [{ value: 'male', label: 'Male' }, { value: 'female', label: 'Female' }, { value: 'unknown', label: 'Unknown' }] as s} + + {/each} +
    +
    +
    +
    + + +
    +

    Details

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

    Photos

    + + + + +
    + + +
    + + {#if photoPreviews.length > 0} +
    + {#each photoPreviews as preview, i} +
    + Photo {i + 1} + {#if i === 0} +
    + + + +
    + {/if} + +
    + {/each} +
    +

    First photo is the primary photo

    + {:else} +

    No photos yet.

    + {/if} +
    + + +
    +

    Intake Info

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

    Notes

    + +
    + + + +
    +
    diff --git a/src/routes/admin/pets/+page.server.ts b/src/routes/admin/pets/+page.server.ts new file mode 100644 index 0000000..4d6624a --- /dev/null +++ b/src/routes/admin/pets/+page.server.ts @@ -0,0 +1,109 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { pets, fosterAssignments, vets } from '$lib/server/schema'; +import { eq, like, and, or, sql, count, desc, asc } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { hasPermission, canViewPets } from '$lib/roles'; + +const PER_PAGE = 50; + +export const load: PageServerLoad = async ({ url, locals }) => { + if (!canViewPets(locals.user!.role)) error(403, 'Access denied'); + + const q = url.searchParams.get('q')?.trim() ?? ''; + const status = url.searchParams.get('status') ?? ''; + const species = url.searchParams.get('species') ?? ''; + const sort = url.searchParams.get('sort') ?? 'intake_newest'; + const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10)); + const offset = (page - 1) * PER_PAGE; + + const conditions = []; + if (q) { + conditions.push(or( + like(pets.name, `%${q}%`), + like(pets.breed, `%${q}%`), + like(pets.microchipId, `%${q}%`) + )); + } + if (status) { + conditions.push(eq(pets.status, status as typeof pets.status.enumValues[number])); + } else { + conditions.push(sql`${pets.status} NOT IN ('deceased', 'transferred')`); + } + if (species) { + conditions.push(eq(pets.species, species as typeof pets.species.enumValues[number])); + } + + const where = conditions.length > 0 ? and(...conditions) : undefined; + + const sortMap: Record> = { + newest: desc(pets.createdAt), + oldest: asc(pets.createdAt), + name_asc: asc(pets.name), + name_desc: desc(pets.name), + intake_newest: desc(pets.intakeDate), + intake_oldest: asc(pets.intakeDate), + status: asc(pets.status) + }; + const orderBy = sortMap[sort] ?? desc(pets.createdAt); + + const [totalRow, petRows] = await Promise.all([ + db.select({ total: count() }).from(pets).where(where).then((r) => r[0]), + + db.select({ + id: pets.id, + name: pets.name, + species: pets.species, + breed: pets.breed, + status: pets.status, + intakeDate: pets.intakeDate, + featured: pets.featured, + createdAt: pets.createdAt, + vetName: vets.name, + currentFoster: sql`( + SELECT u.name + FROM ${fosterAssignments} fa + JOIN users u ON fa.user_id = u.id + WHERE fa.pet_id = ${pets.id} + AND fa.is_current = true + LIMIT 1 + )`.as('current_foster') + }) + .from(pets) + .leftJoin(vets, eq(pets.vetId, vets.id)) + .where(where) + .orderBy(orderBy) + .limit(PER_PAGE) + .offset(offset) + ]); + + const total = totalRow?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); + + return { + pets: petRows, + total, + page, + totalPages, + q, + status, + species, + sort + }; +}; + +export const actions: Actions = { + delete: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + const formData = await request.formData(); + const id = parseInt(formData.get('id') as string, 10); + + if (!id || isNaN(id)) { + return fail(400, { error: 'Invalid pet ID' }); + } + + await db.delete(pets).where(eq(pets.id, id)); + + return { success: true }; + } +}; diff --git a/src/routes/admin/pets/+page.svelte b/src/routes/admin/pets/+page.svelte new file mode 100644 index 0000000..d5b2ba6 --- /dev/null +++ b/src/routes/admin/pets/+page.svelte @@ -0,0 +1,270 @@ + + + + Pets — {data.orgConfig.orgInfo.name} Admin + + +
    +

    Pets

    + {#if canEdit} + + {/if} +
    + + +
    +
    +
    + + + + handleSearchInput(e.currentTarget.value)} + placeholder="Search by name, breed, or microchip..." + class="w-full pl-9 pr-4 py-2.5 rounded-lg border border-gray-200 bg-white text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-600/40 focus:border-teal-600 transition" + /> +
    +
    + + + +
    +
    +
    + + +

    + {data.total} pet{data.total !== 1 ? 's' : ''} found +

    + + +
    +
    + + + + + + + + + + + {#if canEdit}{/if} + + + + {#each data.pets as pet} + + + + + + + + + {#if canEdit} + + {/if} + + {:else} + + + + {/each} + +
    + + SpeciesBreed + + + + VetFoster
    + + {pet.name} + + {#if pet.featured} + ★ + {/if} + {pet.species}{pet.breed ?? '—'}{formatDate(pet.intakeDate)}{pet.vetName ?? '—'}{pet.currentFoster ?? '—'} + +
    + No pets found. +
    +
    +
    + + + + +{#if canEdit} + +
    { + return async ({ update }) => { + deleteTarget = null; + await update(); + }; + }} + class="hidden" + > + +
    + + deleteFormEl?.requestSubmit()} + oncancel={() => deleteTarget = null} + /> +{/if} diff --git a/src/routes/admin/pets/new/+page.server.ts b/src/routes/admin/pets/new/+page.server.ts new file mode 100644 index 0000000..bc1d6a3 --- /dev/null +++ b/src/routes/admin/pets/new/+page.server.ts @@ -0,0 +1,129 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { pets, petPhotos, intakeSources, vets } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { slugify } from '$lib/slugify'; +import { hasPermission } from '$lib/roles'; +import { saveUpload } from '$lib/server/upload'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + const [sources, vetList] = await Promise.all([ + db.select().from(intakeSources).where(eq(intakeSources.active, true)), + db.select().from(vets).where(eq(vets.active, true)) + ]); + + return { + intakeSources: sources, + vets: vetList + }; +}; + +export const actions: Actions = { + default: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + const fd = await request.formData(); + + const name = fd.get('name')?.toString().trim() ?? ''; + const species = fd.get('species')?.toString() ?? 'dog'; + + if (!name) { + return fail(400, { error: 'Name is required', values: Object.fromEntries(fd) }); + } + if (!['dog', 'cat', 'other'].includes(species)) { + return fail(400, { error: 'Invalid species', values: Object.fromEntries(fd) }); + } + + // Generate unique slug + let slug = slugify(name); + const existing = await db.select({ id: pets.id }).from(pets).where(eq(pets.slug, slug)); + if (existing.length > 0) { + slug = `${slug}-${Date.now()}`; + } + + const breed = fd.get('breed')?.toString().trim() || null; + const sex = fd.get('sex')?.toString() || 'unknown'; + const age = fd.get('age')?.toString().trim() || null; + const birthdayStr = fd.get('birthday')?.toString() || null; + const birthday = birthdayStr ? new Date(birthdayStr) : null; + const weight = fd.get('weight')?.toString().trim() || null; + const color = fd.get('color')?.toString().trim() || null; + const description = fd.get('description')?.toString().trim() || null; + const intakeDateStr = fd.get('intakeDate')?.toString() || null; + const intakeDate = intakeDateStr ? new Date(intakeDateStr) : null; + const intakeSourceId = fd.get('intakeSourceId')?.toString() + ? parseInt(fd.get('intakeSourceId') as string, 10) + : null; + const vetIdStr = fd.get('vetId')?.toString(); + const vetId = vetIdStr ? parseInt(vetIdStr, 10) : null; + const status = fd.get('status')?.toString() || 'available'; + const featured = fd.get('featured') === 'on'; + const published = fd.get('published') !== 'off'; + const goodWithDogs = fd.get('goodWithDogs') === 'on' ? true : fd.get('goodWithDogs') === 'off' ? false : null; + const goodWithCats = fd.get('goodWithCats') === 'on' ? true : fd.get('goodWithCats') === 'off' ? false : null; + const goodWithKids = fd.get('goodWithKids') === 'on' ? true : fd.get('goodWithKids') === 'off' ? false : null; + const houseTrained = fd.get('houseTrained') === 'on' ? true : null; + const spayedNeutered = fd.get('spayedNeutered') === 'on' ? true : null; + const microchipped = fd.get('microchipped') === 'on' ? true : null; + const microchipId = fd.get('microchipId')?.toString().trim() || null; + + const [result] = await db.insert(pets).values({ + name, + species: species as 'dog' | 'cat' | 'other', + breed, + sex: sex as 'male' | 'female' | 'unknown', + age, + birthday, + weight, + color, + description, + slug, + intakeDate, + intakeSourceId, + vetId: vetId && !isNaN(vetId) ? vetId : null, + status: status as typeof pets.status.enumValues[number], + featured, + published, + goodWithDogs, + goodWithCats, + goodWithKids, + houseTrained, + spayedNeutered, + microchipped, + microchipId + }); + + const newPetId = result.insertId; + + await logAudit({ + userId: locals.user!.id, + action: 'create', + entity: 'pet', + entityId: Number(newPetId), + diff: { name, species, status } + }); + + // Handle photo uploads + const photoEntries = fd.getAll('photos').filter( + (entry): entry is File => entry instanceof File && entry.size > 0 + ); + + for (let i = 0; i < photoEntries.length; i++) { + try { + const { url } = await saveUpload(photoEntries[i], 'pets'); + await db.insert(petPhotos).values({ + petId: Number(newPetId), + url, + isPrimary: i === 0, + sortOrder: i + }); + } catch { + // Skip invalid files — pet is already created + } + } + + redirect(303, `/admin/pets/${newPetId}`); + } +}; diff --git a/src/routes/admin/pets/new/+page.svelte b/src/routes/admin/pets/new/+page.svelte new file mode 100644 index 0000000..96c646f --- /dev/null +++ b/src/routes/admin/pets/new/+page.svelte @@ -0,0 +1,326 @@ + + + + Add Pet — {data.orgConfig.orgInfo.name} Admin + + +
    +
    + + + + + +

    Add New Pet

    +
    + + {#if form?.error} +
    + {form.error} +
    + {/if} + +
    { + submitting = true; + for (const file of photoFiles) { + formData.append('photos', file); + } + return async ({ update }) => { + submitting = false; + await update(); + }; + }} + class="space-y-6" + > + +
    +

    Basic Information

    +
    +
    + + +
    + +
    + +
    + {#each ['dog', 'cat', 'other'] as s} + + {/each} +
    +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    +
    + + +
    +

    Compatibility & Care

    +
    + {#each [ + { name: 'goodWithDogs', label: 'Good with dogs' }, + { name: 'goodWithCats', label: 'Good with cats' }, + { name: 'goodWithKids', label: 'Good with kids' }, + { name: 'houseTrained', label: 'House trained' }, + { name: 'spayedNeutered', label: 'Spayed/Neutered' }, + { name: 'microchipped', label: 'Microchipped' } + ] as item} + + {/each} +
    +
    + + +
    +
    + + +
    +

    Intake Information

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

    Settings

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

    Photos

    + + + + +
    photoInputEl.click()} + ondrop={handlePhotoDrop} + ondragover={(e) => { e.preventDefault(); photoDragover = true; }} + ondragleave={() => photoDragover = false} + class="relative border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors + {photoDragover ? 'border-teal-600 bg-teal-50' : 'border-gray-200 bg-white hover:border-teal-600 hover:bg-teal-50/50'}" + role="button" + tabindex="0" + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); photoInputEl.click(); } }} + > + + + +

    + Drop photos here or browse +

    +

    JPG, PNG, WebP, GIF up to 10MB each

    +
    + + {#if photoFiles.length > 0} +
    + {#each photoFiles as file, i} +
    + Preview of {file.name} + {#if i === 0} + Primary + {/if} + +
    + {/each} +
    +

    {photoFiles.length} photo{photoFiles.length === 1 ? '' : 's'} selected. The first photo will be the primary image.

    + {/if} +
    + + +
    + + Cancel + + +
    +
    +
    From 63f127dfca82e6895f786b75538fae1b2fd8e22b Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:33:24 -0500 Subject: [PATCH 26/38] Add admin pet detail pages: profile, photos, medical, fosters (Task 32b) Pet layout: tabbed nav with counts for photos/medical/fosters, status badge in header. Detail overview: summary cards and compatibility grid. Profile: full edit form including adoption info conditional on status, audit logged. Photos: upload, set primary (star toggle), delete, reorder with prev/next arrows. Medical: add/delete records grouped by date, cost stored as cents. Fosters: assign by user account (linked to foster-role users), set current, delete, audit logged throughout. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/admin/pets/[id]/+layout.server.ts | 31 ++ src/routes/admin/pets/[id]/+layout.svelte | 63 ++++ src/routes/admin/pets/[id]/+page.server.ts | 9 + src/routes/admin/pets/[id]/+page.svelte | 114 +++++++ .../admin/pets/[id]/fosters/+page.server.ts | 137 +++++++++ .../admin/pets/[id]/fosters/+page.svelte | 223 ++++++++++++++ .../admin/pets/[id]/medical/+page.server.ts | 99 ++++++ .../admin/pets/[id]/medical/+page.svelte | 257 ++++++++++++++++ .../admin/pets/[id]/photos/+page.server.ts | 149 +++++++++ .../admin/pets/[id]/photos/+page.svelte | 209 +++++++++++++ .../admin/pets/[id]/profile/+page.server.ts | 132 ++++++++ .../admin/pets/[id]/profile/+page.svelte | 284 ++++++++++++++++++ 12 files changed, 1707 insertions(+) create mode 100644 src/routes/admin/pets/[id]/+layout.server.ts create mode 100644 src/routes/admin/pets/[id]/+layout.svelte create mode 100644 src/routes/admin/pets/[id]/+page.server.ts create mode 100644 src/routes/admin/pets/[id]/+page.svelte create mode 100644 src/routes/admin/pets/[id]/fosters/+page.server.ts create mode 100644 src/routes/admin/pets/[id]/fosters/+page.svelte create mode 100644 src/routes/admin/pets/[id]/medical/+page.server.ts create mode 100644 src/routes/admin/pets/[id]/medical/+page.svelte create mode 100644 src/routes/admin/pets/[id]/photos/+page.server.ts create mode 100644 src/routes/admin/pets/[id]/photos/+page.svelte create mode 100644 src/routes/admin/pets/[id]/profile/+page.server.ts create mode 100644 src/routes/admin/pets/[id]/profile/+page.svelte diff --git a/src/routes/admin/pets/[id]/+layout.server.ts b/src/routes/admin/pets/[id]/+layout.server.ts new file mode 100644 index 0000000..34e2e94 --- /dev/null +++ b/src/routes/admin/pets/[id]/+layout.server.ts @@ -0,0 +1,31 @@ +import type { LayoutServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { pets, petPhotos, petMedical, fosterAssignments } from '$lib/server/schema'; +import { eq, count } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import { hasPermission, canViewPets } from '$lib/roles'; + +export const load: LayoutServerLoad = async ({ params, locals }) => { + if (!canViewPets(locals.user!.role)) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + if (isNaN(id)) error(404, 'Pet not found'); + + const [pet] = await db.select().from(pets).where(eq(pets.id, id)); + if (!pet) error(404, 'Pet not found'); + + const [photosCount, medicalCount, fostersCount] = await Promise.all([ + db.select({ total: count() }).from(petPhotos).where(eq(petPhotos.petId, id)), + db.select({ total: count() }).from(petMedical).where(eq(petMedical.petId, id)), + db.select({ total: count() }).from(fosterAssignments).where(eq(fosterAssignments.petId, id)) + ]); + + return { + pet, + canEditPets: hasPermission(locals.user!.role, 'pets'), + counts: { + photos: photosCount[0]?.total ?? 0, + medical: medicalCount[0]?.total ?? 0, + fosters: fostersCount[0]?.total ?? 0 + } + }; +}; diff --git a/src/routes/admin/pets/[id]/+layout.svelte b/src/routes/admin/pets/[id]/+layout.svelte new file mode 100644 index 0000000..14fbdb3 --- /dev/null +++ b/src/routes/admin/pets/[id]/+layout.svelte @@ -0,0 +1,63 @@ + + + + {data.pet.name} — {data.orgConfig.orgInfo.name} Admin + + +
    + +
    + + + + + +
    +

    {data.pet.name}

    + +
    +
    + + + + + {@render children()} +
    diff --git a/src/routes/admin/pets/[id]/+page.server.ts b/src/routes/admin/pets/[id]/+page.server.ts new file mode 100644 index 0000000..e14237e --- /dev/null +++ b/src/routes/admin/pets/[id]/+page.server.ts @@ -0,0 +1,9 @@ +import type { PageServerLoad } from './$types'; +import { canViewPets } from '$lib/roles'; +import { error } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!canViewPets(locals.user!.role)) error(403, 'Access denied'); + // Pet data comes from layout. This page just shows an overview. + return {}; +}; diff --git a/src/routes/admin/pets/[id]/+page.svelte b/src/routes/admin/pets/[id]/+page.svelte new file mode 100644 index 0000000..3e6b29d --- /dev/null +++ b/src/routes/admin/pets/[id]/+page.svelte @@ -0,0 +1,114 @@ + + +
    + +
    +
    +

    Species

    +

    {pet.species}

    +
    +
    +

    Sex

    +

    {pet.sex}

    +
    +
    +

    Breed

    +

    {pet.breed ?? '—'}

    +
    +
    +

    Age

    +

    {pet.age ?? '—'}

    +
    +
    + + +
    +

    Pet Details

    +
    +
    +
    Intake Date
    +
    {formatDate(pet.intakeDate)}
    +
    +
    +
    Weight
    +
    {pet.weight ?? '—'}
    +
    +
    +
    Color
    +
    {pet.color ?? '—'}
    +
    +
    +
    Microchip ID
    +
    {pet.microchipId ?? '—'}
    +
    +
    +
    Featured
    +
    {pet.featured ? 'Yes' : 'No'}
    +
    +
    +
    Published
    +
    {pet.published ? 'Yes' : 'No'}
    +
    +
    +
    + + {#if pet.description} +
    +

    Description

    +

    {pet.description}

    +
    + {/if} + + +
    +

    Compatibility & Care

    +
    + {#each [ + { label: 'Good with dogs', value: pet.goodWithDogs }, + { label: 'Good with cats', value: pet.goodWithCats }, + { label: 'Good with kids', value: pet.goodWithKids }, + { label: 'House trained', value: pet.houseTrained }, + { label: 'Spayed/Neutered', value: pet.spayedNeutered }, + { label: 'Microchipped', value: pet.microchipped } + ] as item} +
    + {#if item.value === true} + ✓ + {:else if item.value === false} + ✗ + {:else} + — + {/if} + {item.label} +
    + {/each} +
    +
    + + + +
    diff --git a/src/routes/admin/pets/[id]/fosters/+page.server.ts b/src/routes/admin/pets/[id]/fosters/+page.server.ts new file mode 100644 index 0000000..2dc8989 --- /dev/null +++ b/src/routes/admin/pets/[id]/fosters/+page.server.ts @@ -0,0 +1,137 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { fosterAssignments, users } from '$lib/server/schema'; +import { eq, and, asc } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { logAudit } from '$lib/server/audit'; +import { hasPermission, canViewPets } from '$lib/roles'; + +export const load: PageServerLoad = async ({ parent, locals }) => { + if (!canViewPets(locals.user!.role)) error(403, 'Access denied'); + const { pet } = await parent(); + + const assignments = await db + .select({ + id: fosterAssignments.id, + petId: fosterAssignments.petId, + userId: fosterAssignments.userId, + startDate: fosterAssignments.startDate, + endDate: fosterAssignments.endDate, + isCurrent: fosterAssignments.isCurrent, + notes: fosterAssignments.notes, + createdAt: fosterAssignments.createdAt, + fosterName: users.name, + fosterEmail: users.email + }) + .from(fosterAssignments) + .leftJoin(users, eq(fosterAssignments.userId, users.id)) + .where(eq(fosterAssignments.petId, pet.id)) + .orderBy(asc(fosterAssignments.startDate)); + + // Load active foster-role users for the dropdown + const fosterUsers = await db + .select({ id: users.id, name: users.name, email: users.email }) + .from(users) + .where(and(eq(users.role, 'foster'), eq(users.active, true))) + .orderBy(asc(users.name)); + + return { assignments, fosterUsers }; +}; + +export const actions: Actions = { + add: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + const petId = parseInt(params.id, 10); + const fd = await request.formData(); + + const userIdStr = fd.get('userId')?.toString(); + const userId = userIdStr ? parseInt(userIdStr, 10) : null; + + if (!userId || isNaN(userId)) { + return fail(400, { error: 'Foster user is required' }); + } + + const startDateStr = fd.get('startDate')?.toString() || null; + const startDate = startDateStr ? new Date(startDateStr) : new Date(); + const endDateStr = fd.get('endDate')?.toString() || null; + const endDate = endDateStr ? new Date(endDateStr) : null; + const notes = fd.get('notes')?.toString().trim() || null; + const isCurrent = fd.get('isCurrent') === 'on'; + + // If marking as current, unset all others + if (isCurrent) { + await db.update(fosterAssignments).set({ isCurrent: false }).where(eq(fosterAssignments.petId, petId)); + } + + const [inserted] = await db.insert(fosterAssignments).values({ + petId, + userId, + startDate, + endDate, + notes, + isCurrent + }).$returningId(); + + await logAudit({ + userId: locals.user!.id, + action: 'create', + entity: 'foster_assignment', + entityId: inserted.id, + diff: { petId, userId, isCurrent } + }); + + return { success: true }; + }, + + setCurrent: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + const petId = parseInt(params.id, 10); + const fd = await request.formData(); + const assignmentId = parseInt(fd.get('assignmentId') as string, 10); + + if (!assignmentId || isNaN(assignmentId)) { + return fail(400, { error: 'Invalid assignment ID' }); + } + + const isCurrent = fd.get('isCurrent') === 'on'; + + if (isCurrent) { + await db.update(fosterAssignments).set({ isCurrent: false }).where(eq(fosterAssignments.petId, petId)); + } + + await db.update(fosterAssignments).set({ isCurrent }).where(eq(fosterAssignments.id, assignmentId)); + + await logAudit({ + userId: locals.user!.id, + action: 'update', + entity: 'foster_assignment', + entityId: assignmentId, + diff: { petId, isCurrent } + }); + + return { success: true }; + }, + + delete: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + const petId = parseInt(params.id, 10); + const fd = await request.formData(); + const assignmentId = parseInt(fd.get('assignmentId') as string, 10); + + if (!assignmentId || isNaN(assignmentId)) { + return fail(400, { error: 'Invalid assignment ID' }); + } + + await db.delete(fosterAssignments).where(eq(fosterAssignments.id, assignmentId)); + + await logAudit({ + userId: locals.user!.id, + action: 'delete', + entity: 'foster_assignment', + entityId: assignmentId, + diff: { petId } + }); + + return { success: true }; + } +}; diff --git a/src/routes/admin/pets/[id]/fosters/+page.svelte b/src/routes/admin/pets/[id]/fosters/+page.svelte new file mode 100644 index 0000000..3de8926 --- /dev/null +++ b/src/routes/admin/pets/[id]/fosters/+page.svelte @@ -0,0 +1,223 @@ + + +{#if showSuccess} +
    + + + + Foster assignment saved successfully. +
    +{/if} + +{#if form?.error} +
    + {form.error} +
    +{/if} + + +
    +

    Add Foster Placement

    + +
    +
    +
    + + + {#if fosterUsers.length === 0} +

    No foster-role users found. Add users with the "foster" role in User Management.

    + {/if} +
    + +
    + + +
    + +
    + + +
    +
    + +
    + + +
    + +
    + + + +
    +
    +
    + + +{#if assignments.length === 0} +
    + + + +

    No foster placements yet

    +

    Add the first placement using the form above.

    +
    +{:else} +
    + {#each assignments as assignment} +
    +
    +
    +
    +

    {assignment.fosterName ?? 'Unknown User'}

    + {#if assignment.isCurrent} + + Current + + {/if} + {#if assignment.fosterEmail} + {assignment.fosterEmail} + {/if} +
    + +
    + + + + + {formatDate(assignment.startDate)} + {#if assignment.endDate} + to {formatDate(assignment.endDate)} + {:else} + — ongoing + {/if} + +
    + + {#if assignment.notes} +

    {assignment.notes}

    + {/if} +
    + +
    + +
    + + + +
    + + + +
    +
    +
    + {/each} +
    +{/if} + + { + if (deleteId) { + const frm = document.createElement('form'); + frm.method = 'POST'; + frm.action = '?/delete'; + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'assignmentId'; + input.value = String(deleteId); + frm.appendChild(input); + document.body.appendChild(frm); + frm.submit(); + } + showDeleteConfirm = false; + }} + oncancel={() => { showDeleteConfirm = false; }} +/> diff --git a/src/routes/admin/pets/[id]/medical/+page.server.ts b/src/routes/admin/pets/[id]/medical/+page.server.ts new file mode 100644 index 0000000..72321cf --- /dev/null +++ b/src/routes/admin/pets/[id]/medical/+page.server.ts @@ -0,0 +1,99 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { petMedical, vets } from '$lib/server/schema'; +import { eq, desc } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { logAudit } from '$lib/server/audit'; +import { hasPermission, canViewPets } from '$lib/roles'; + +export const load: PageServerLoad = async ({ parent, locals }) => { + if (!canViewPets(locals.user!.role)) error(403, 'Access denied'); + const { pet } = await parent(); + + const [records, vetList] = await Promise.all([ + db + .select({ + id: petMedical.id, + petId: petMedical.petId, + vetId: petMedical.vetId, + date: petMedical.date, + type: petMedical.type, + name: petMedical.name, + cost: petMedical.cost, + notes: petMedical.notes, + createdAt: petMedical.createdAt, + vetName: vets.name, + vetClinic: vets.clinic + }) + .from(petMedical) + .leftJoin(vets, eq(petMedical.vetId, vets.id)) + .where(eq(petMedical.petId, pet.id)) + .orderBy(desc(petMedical.date), desc(petMedical.createdAt)), + db.select().from(vets).where(eq(vets.active, true)) + ]); + + return { records, vets: vetList }; +}; + +export const actions: Actions = { + add: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role, 'medical')) error(403, 'Access denied'); + const petId = parseInt(params.id, 10); + const fd = await request.formData(); + + const type = fd.get('type')?.toString(); + const name = fd.get('name')?.toString().trim(); + + if (!type) return fail(400, { error: 'Type is required' }); + if (!name) return fail(400, { error: 'Name is required' }); + + const dateStr = fd.get('date')?.toString(); + const date = dateStr ? new Date(dateStr) : null; + const vetIdStr = fd.get('vetId')?.toString(); + const vetId = vetIdStr ? parseInt(vetIdStr, 10) : null; + const costStr = fd.get('cost')?.toString().trim(); + const cost = costStr ? Math.round(parseFloat(costStr) * 100) : null; + const notes = fd.get('notes')?.toString().trim() || null; + + const [inserted] = await db.insert(petMedical).values({ + petId, + vetId: vetId && !isNaN(vetId) ? vetId : null, + date, + type: type as typeof petMedical.type.enumValues[number], + name, + cost, + notes + }).$returningId(); + + await logAudit({ + userId: locals.user!.id, + action: 'create', + entity: 'pet_medical', + entityId: inserted.id, + diff: { petId, type, name, date: dateStr } + }); + + return { success: true }; + }, + + delete: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role, 'medical')) error(403, 'Access denied'); + const petId = parseInt(params.id, 10); + const fd = await request.formData(); + const recordId = parseInt(fd.get('recordId') as string, 10); + + if (!recordId || isNaN(recordId)) return fail(400, { error: 'Invalid record ID' }); + + await db.delete(petMedical).where(eq(petMedical.id, recordId)); + + await logAudit({ + userId: locals.user!.id, + action: 'delete', + entity: 'pet_medical', + entityId: recordId, + diff: { petId } + }); + + return { success: true }; + } +}; diff --git a/src/routes/admin/pets/[id]/medical/+page.svelte b/src/routes/admin/pets/[id]/medical/+page.svelte new file mode 100644 index 0000000..509ab37 --- /dev/null +++ b/src/routes/admin/pets/[id]/medical/+page.svelte @@ -0,0 +1,257 @@ + + +{#if showSuccess} +
    + + + + Medical record added successfully. +
    +{/if} + +{#if form?.error} +
    + {form.error} +
    +{/if} + + +
    +

    Add Medical Record

    + +
    +
    +
    + + +
    + +
    + + +
    + +
    + + +
    +
    + +
    +
    + + +
    + +
    + + +
    +
    + +
    + + +
    + +
    + +
    +
    +
    + + +{#if records.length === 0} +
    + + + +

    No medical records yet

    +

    Add the first record using the form above.

    +
    +{:else} +
    + {#each [...groupedRecords().entries()] as [dateKey, dateRecords]} +
    +

    + {formatDate(dateKey === 'unknown' ? null : dateKey)} +

    +
    + {#each dateRecords as record} +
    +
    +
    + + {formatType(record.type)} + + {record.name} +
    + {#if record.notes} +

    {record.notes}

    + {/if} +
    + {#if record.vetName} + + + + + {record.vetName}{record.vetClinic ? ` (${record.vetClinic})` : ''} + + {/if} + {#if record.cost} + + + + + {formatCost(record.cost)} + + {/if} +
    +
    + +
    + {/each} +
    +
    + {/each} +
    +{/if} + + { + if (deleteId) { + const frm = document.createElement('form'); + frm.method = 'POST'; + frm.action = '?/delete'; + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'recordId'; + input.value = String(deleteId); + frm.appendChild(input); + document.body.appendChild(frm); + frm.submit(); + } + showDeleteConfirm = false; + }} + oncancel={() => { showDeleteConfirm = false; }} +/> diff --git a/src/routes/admin/pets/[id]/photos/+page.server.ts b/src/routes/admin/pets/[id]/photos/+page.server.ts new file mode 100644 index 0000000..5c0f7e9 --- /dev/null +++ b/src/routes/admin/pets/[id]/photos/+page.server.ts @@ -0,0 +1,149 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { petPhotos } from '$lib/server/schema'; +import { eq, asc } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { saveUpload, deleteUpload } from '$lib/server/upload'; +import { logAudit } from '$lib/server/audit'; +import { hasPermission, canViewPets } from '$lib/roles'; + +export const load: PageServerLoad = async ({ parent, locals }) => { + if (!canViewPets(locals.user!.role)) error(403, 'Access denied'); + const { pet } = await parent(); + + const photos = await db + .select() + .from(petPhotos) + .where(eq(petPhotos.petId, pet.id)) + .orderBy(asc(petPhotos.sortOrder)); + + return { photos }; +}; + +export const actions: Actions = { + upload: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + const petId = parseInt(params.id, 10); + const fd = await request.formData(); + const file = fd.get('photo') as File | null; + + if (!file || file.size === 0) { + return fail(400, { error: 'No file selected' }); + } + + let url: string; + try { + const result = await saveUpload(file, 'pets'); + url = result.url; + } catch (err) { + return fail(400, { error: (err as Error).message }); + } + + const existing = await db + .select({ sortOrder: petPhotos.sortOrder }) + .from(petPhotos) + .where(eq(petPhotos.petId, petId)) + .orderBy(asc(petPhotos.sortOrder)); + + const nextSort = existing.length > 0 ? Math.max(...existing.map((e) => e.sortOrder)) + 1 : 0; + const isPrimary = existing.length === 0; + + const [inserted] = await db.insert(petPhotos).values({ + petId, + url, + isPrimary, + sortOrder: nextSort + }).$returningId(); + + await logAudit({ + userId: locals.user!.id, + action: 'create', + entity: 'pet_photo', + entityId: inserted.id, + diff: { petId, url } + }); + + return { success: true }; + }, + + setPrimary: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + const petId = parseInt(params.id, 10); + const fd = await request.formData(); + const photoId = parseInt(fd.get('photoId') as string, 10); + + if (!photoId || isNaN(photoId)) return fail(400, { error: 'Invalid photo ID' }); + + await db.update(petPhotos).set({ isPrimary: false }).where(eq(petPhotos.petId, petId)); + await db.update(petPhotos).set({ isPrimary: true }).where(eq(petPhotos.id, photoId)); + + await logAudit({ + userId: locals.user!.id, + action: 'update', + entity: 'pet_photo', + entityId: photoId, + diff: { petId, action: 'set_primary' } + }); + + return { success: true }; + }, + + delete: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + const petId = parseInt(params.id, 10); + const fd = await request.formData(); + const photoId = parseInt(fd.get('photoId') as string, 10); + + if (!photoId || isNaN(photoId)) return fail(400, { error: 'Invalid photo ID' }); + + const [photo] = await db.select().from(petPhotos).where(eq(petPhotos.id, photoId)); + if (!photo) return fail(404, { error: 'Photo not found' }); + + await db.delete(petPhotos).where(eq(petPhotos.id, photoId)); + + try { + await deleteUpload(photo.url); + } catch { + // File may not exist on disk + } + + await logAudit({ + userId: locals.user!.id, + action: 'delete', + entity: 'pet_photo', + entityId: photoId, + diff: { petId, url: photo.url } + }); + + return { success: true }; + }, + + reorder: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + const petId = parseInt(params.id, 10); + const fd = await request.formData(); + const orderJson = fd.get('order')?.toString(); + + if (!orderJson) return fail(400, { error: 'No order data provided' }); + + let items: { id: number; sortOrder: number }[]; + try { + items = JSON.parse(orderJson); + } catch { + return fail(400, { error: 'Invalid order data' }); + } + + for (const item of items) { + await db.update(petPhotos).set({ sortOrder: item.sortOrder }).where(eq(petPhotos.id, item.id)); + } + + await logAudit({ + userId: locals.user!.id, + action: 'update', + entity: 'pet_photo', + diff: { petId, action: 'reorder', items } + }); + + return { success: true }; + } +}; diff --git a/src/routes/admin/pets/[id]/photos/+page.svelte b/src/routes/admin/pets/[id]/photos/+page.svelte new file mode 100644 index 0000000..65eaf03 --- /dev/null +++ b/src/routes/admin/pets/[id]/photos/+page.svelte @@ -0,0 +1,209 @@ + + +{#if showSuccess} +
    + + + + Photo uploaded successfully. +
    +{/if} + +{#if form?.error} +
    + {form.error} +
    +{/if} + +{#if canEdit} +
    +

    Upload Photo

    +
    + +
    + +
    + +
    +{/if} + + +{#if photos.length === 0} +
    + + + +

    No photos yet

    + {#if canEdit}

    Upload the first photo using the form above.

    {/if} +
    +{:else} +
    + {#each photos as photo, index} +
    +
    + {photo.alt +
    + + {#if canEdit} + +
    + +
    + + +
    + + + +
    + + +
    +
    + + +
    + {#if photo.isPrimary} + Primary + {:else} + #{index + 1} + {/if} +
    + {/if} +
    + {/each} +
    +{/if} + +{#if canEdit} + { + if (deleteId) { + const frm = document.createElement('form'); + frm.method = 'POST'; + frm.action = '?/delete'; + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'photoId'; + input.value = String(deleteId); + frm.appendChild(input); + document.body.appendChild(frm); + frm.submit(); + } + showDeleteConfirm = false; + }} + oncancel={() => { showDeleteConfirm = false; }} + /> +{/if} diff --git a/src/routes/admin/pets/[id]/profile/+page.server.ts b/src/routes/admin/pets/[id]/profile/+page.server.ts new file mode 100644 index 0000000..e68a410 --- /dev/null +++ b/src/routes/admin/pets/[id]/profile/+page.server.ts @@ -0,0 +1,132 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { pets, intakeSources, vets } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { slugify } from '$lib/slugify'; +import { hasPermission, canViewPets } from '$lib/roles'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ parent, locals }) => { + if (!canViewPets(locals.user!.role)) error(403, 'Access denied'); + await parent(); + + const [sources, vetList] = await Promise.all([ + db.select().from(intakeSources).where(eq(intakeSources.active, true)), + db.select().from(vets).where(eq(vets.active, true)) + ]); + + return { + intakeSources: sources, + vets: vetList + }; +}; + +export const actions: Actions = { + default: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role, 'pets')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + const fd = await request.formData(); + + const name = fd.get('name')?.toString().trim() ?? ''; + const species = fd.get('species')?.toString() ?? 'dog'; + + if (!name) return fail(400, { error: 'Name is required' }); + if (!['dog', 'cat', 'other'].includes(species)) return fail(400, { error: 'Invalid species' }); + + // Regenerate slug only if name changed + const [existing] = await db.select({ name: pets.name, slug: pets.slug }).from(pets).where(eq(pets.id, id)); + let slug = existing?.slug ?? slugify(name); + if (existing && existing.name !== name) { + const newSlug = slugify(name); + const [conflict] = await db.select({ id: pets.id }).from(pets).where(eq(pets.slug, newSlug)); + if (conflict && conflict.id !== id) { + slug = `${newSlug}-${Date.now()}`; + } else { + slug = newSlug; + } + } + + const breed = fd.get('breed')?.toString().trim() || null; + const sex = fd.get('sex')?.toString() || 'unknown'; + const age = fd.get('age')?.toString().trim() || null; + const birthdayStr = fd.get('birthday')?.toString() || null; + const birthday = birthdayStr ? new Date(birthdayStr) : null; + const weight = fd.get('weight')?.toString().trim() || null; + const color = fd.get('color')?.toString().trim() || null; + const description = fd.get('description')?.toString().trim() || null; + const medicalNotes = fd.get('medicalNotes')?.toString().trim() || null; + const behavioralNotes = fd.get('behavioralNotes')?.toString().trim() || null; + const specialNeeds = fd.get('specialNeeds')?.toString().trim() || null; + const intakeDateStr = fd.get('intakeDate')?.toString() || null; + const intakeDate = intakeDateStr ? new Date(intakeDateStr) : null; + const intakeSourceId = fd.get('intakeSourceId')?.toString() + ? parseInt(fd.get('intakeSourceId') as string, 10) + : null; + const vetIdStr = fd.get('vetId')?.toString(); + const vetId = vetIdStr ? parseInt(vetIdStr, 10) : null; + const status = fd.get('status')?.toString() || 'available'; + const featured = fd.get('featured') === 'on'; + const published = fd.get('published') === 'on'; + const goodWithDogs = fd.has('goodWithDogs') ? fd.get('goodWithDogs') === 'on' : null; + const goodWithCats = fd.has('goodWithCats') ? fd.get('goodWithCats') === 'on' : null; + const goodWithKids = fd.has('goodWithKids') ? fd.get('goodWithKids') === 'on' : null; + const houseTrained = fd.get('houseTrained') === 'on' ? true : null; + const spayedNeutered = fd.get('spayedNeutered') === 'on' ? true : null; + const microchipped = fd.get('microchipped') === 'on' ? true : null; + const microchipId = fd.get('microchipId')?.toString().trim() || null; + const adoptedDateStr = fd.get('adoptedDate')?.toString() || null; + const adoptedDate = adoptedDateStr ? new Date(adoptedDateStr) : null; + const adopterName = fd.get('adopterName')?.toString().trim() || null; + const adopterEmail = fd.get('adopterEmail')?.toString().trim() || null; + const adopterPhone = fd.get('adopterPhone')?.toString().trim() || null; + const adoptionFeeStr = fd.get('adoptionFee')?.toString().trim(); + const adoptionFee = adoptionFeeStr ? Math.round(parseFloat(adoptionFeeStr) * 100) : null; + const adoptionFeeCollected = fd.get('adoptionFeeCollected') === 'on'; + + await db.update(pets).set({ + name, + species: species as 'dog' | 'cat' | 'other', + breed, + sex: sex as 'male' | 'female' | 'unknown', + age, + birthday, + weight, + color, + description, + medicalNotes, + behavioralNotes, + specialNeeds, + slug, + intakeDate, + intakeSourceId, + vetId: vetId && !isNaN(vetId) ? vetId : null, + status: status as typeof pets.status.enumValues[number], + featured, + published, + goodWithDogs, + goodWithCats, + goodWithKids, + houseTrained, + spayedNeutered, + microchipped, + microchipId, + adoptedDate, + adopterName, + adopterEmail, + adopterPhone, + adoptionFee, + adoptionFeeCollected + }).where(eq(pets.id, id)); + + await logAudit({ + userId: locals.user!.id, + action: 'update', + entity: 'pet', + entityId: id, + diff: { name, status } + }); + + return { success: true }; + } +}; diff --git a/src/routes/admin/pets/[id]/profile/+page.svelte b/src/routes/admin/pets/[id]/profile/+page.svelte new file mode 100644 index 0000000..381d3d1 --- /dev/null +++ b/src/routes/admin/pets/[id]/profile/+page.svelte @@ -0,0 +1,284 @@ + + +{#if showSuccess} +
    + + + + Pet updated successfully. +
    +{/if} + +{#if form?.error} +
    + {form.error} +
    +{/if} + +
    +
    + +
    +

    Basic Information

    +
    +
    + + +
    + +
    + +
    + {#each ['dog', 'cat', 'other'] as s} + + {/each} +
    +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    +
    + + +
    +

    Compatibility & Care

    +
    + {#each [ + { name: 'goodWithDogs', label: 'Good with dogs', value: pet.goodWithDogs }, + { name: 'goodWithCats', label: 'Good with cats', value: pet.goodWithCats }, + { name: 'goodWithKids', label: 'Good with kids', value: pet.goodWithKids }, + { name: 'houseTrained', label: 'House trained', value: pet.houseTrained }, + { name: 'spayedNeutered', label: 'Spayed/Neutered', value: pet.spayedNeutered }, + { name: 'microchipped', label: 'Microchipped', value: pet.microchipped } + ] as item} + + {/each} +
    +
    + + +
    +
    + + +
    +

    Notes

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

    Intake Information

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

    Settings

    +
    +
    + + +
    + +
    + + +
    +
    +
    + + + {#if showAdoption} +
    +

    Adoption Information

    +
    +
    + + +
    + +
    + + +
    + +
    + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    +
    + {/if} +
    + + {#if canEdit} +
    + + Cancel + + +
    + {:else} + + {/if} +
    From 416b25fca945b441c3385ac0daa79cbdab206490 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:38:48 -0500 Subject: [PATCH 27/38] =?UTF-8?q?feat(task-35):=20admin=20People=20section?= =?UTF-8?q?=20=E2=80=94=20vets,=20sponsors,=20mentors,=20volunteers,=20use?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full CRUD for vets, sponsors, mentors, volunteer applications, and users. Includes approve/deny flows for pending users, role management, and password reset. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/admin/mentors/+page.server.ts | 35 +++++ src/routes/admin/mentors/+page.svelte | 54 ++++++++ src/routes/admin/mentors/[id]/+page.server.ts | 66 ++++++++++ src/routes/admin/mentors/[id]/+page.svelte | 83 ++++++++++++ src/routes/admin/sponsors/+page.server.ts | 44 +++++++ src/routes/admin/sponsors/+page.svelte | 73 +++++++++++ .../admin/sponsors/[id]/+page.server.ts | 59 +++++++++ src/routes/admin/sponsors/[id]/+page.svelte | 87 ++++++++++++ src/routes/admin/sponsors/new/+page.server.ts | 46 +++++++ src/routes/admin/sponsors/new/+page.svelte | 65 +++++++++ src/routes/admin/users/+page.server.ts | 76 +++++++++++ src/routes/admin/users/+page.svelte | 123 +++++++++++++++++ src/routes/admin/users/[id]/+page.server.ts | 85 ++++++++++++ src/routes/admin/users/[id]/+page.svelte | 120 +++++++++++++++++ src/routes/admin/users/new/+page.server.ts | 69 ++++++++++ src/routes/admin/users/new/+page.svelte | 77 +++++++++++ src/routes/admin/vets/+page.server.ts | 18 +++ src/routes/admin/vets/+page.svelte | 59 +++++++++ src/routes/admin/vets/[id]/+page.server.ts | 52 ++++++++ src/routes/admin/vets/[id]/+page.svelte | 87 ++++++++++++ src/routes/admin/vets/new/+page.server.ts | 35 +++++ src/routes/admin/vets/new/+page.svelte | 60 +++++++++ src/routes/admin/volunteers/+page.server.ts | 43 ++++++ src/routes/admin/volunteers/+page.svelte | 89 +++++++++++++ .../admin/volunteers/[id]/+page.server.ts | 59 +++++++++ src/routes/admin/volunteers/[id]/+page.svelte | 124 ++++++++++++++++++ 26 files changed, 1788 insertions(+) create mode 100644 src/routes/admin/mentors/+page.server.ts create mode 100644 src/routes/admin/mentors/+page.svelte create mode 100644 src/routes/admin/mentors/[id]/+page.server.ts create mode 100644 src/routes/admin/mentors/[id]/+page.svelte create mode 100644 src/routes/admin/sponsors/+page.server.ts create mode 100644 src/routes/admin/sponsors/+page.svelte create mode 100644 src/routes/admin/sponsors/[id]/+page.server.ts create mode 100644 src/routes/admin/sponsors/[id]/+page.svelte create mode 100644 src/routes/admin/sponsors/new/+page.server.ts create mode 100644 src/routes/admin/sponsors/new/+page.svelte create mode 100644 src/routes/admin/users/+page.server.ts create mode 100644 src/routes/admin/users/+page.svelte create mode 100644 src/routes/admin/users/[id]/+page.server.ts create mode 100644 src/routes/admin/users/[id]/+page.svelte create mode 100644 src/routes/admin/users/new/+page.server.ts create mode 100644 src/routes/admin/users/new/+page.svelte create mode 100644 src/routes/admin/vets/+page.server.ts create mode 100644 src/routes/admin/vets/+page.svelte create mode 100644 src/routes/admin/vets/[id]/+page.server.ts create mode 100644 src/routes/admin/vets/[id]/+page.svelte create mode 100644 src/routes/admin/vets/new/+page.server.ts create mode 100644 src/routes/admin/vets/new/+page.svelte create mode 100644 src/routes/admin/volunteers/+page.server.ts create mode 100644 src/routes/admin/volunteers/+page.svelte create mode 100644 src/routes/admin/volunteers/[id]/+page.server.ts create mode 100644 src/routes/admin/volunteers/[id]/+page.svelte diff --git a/src/routes/admin/mentors/+page.server.ts b/src/routes/admin/mentors/+page.server.ts new file mode 100644 index 0000000..9a26005 --- /dev/null +++ b/src/routes/admin/mentors/+page.server.ts @@ -0,0 +1,35 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { mentors, users, petMentors } from '$lib/server/schema'; +import { eq, count } from 'drizzle-orm'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { error } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied'); + + const mentorRows = await db + .select({ + id: mentors.id, + userId: mentors.userId, + bio: mentors.bio, + active: mentors.active, + createdAt: mentors.createdAt, + userName: users.name, + userEmail: users.email + }) + .from(mentors) + .innerJoin(users, eq(mentors.userId, users.id)) + .orderBy(users.name); + + // Count assigned pets for each mentor + const mentorList = await Promise.all( + mentorRows.map(async (m) => { + const [petCountRow] = await db.select({ total: count() }).from(petMentors).where(eq(petMentors.mentorId, m.id)); + return { ...m, petCount: petCountRow?.total ?? 0 }; + }) + ); + + return { mentors: mentorList }; +}; diff --git a/src/routes/admin/mentors/+page.svelte b/src/routes/admin/mentors/+page.svelte new file mode 100644 index 0000000..8b8c180 --- /dev/null +++ b/src/routes/admin/mentors/+page.svelte @@ -0,0 +1,54 @@ + + +Mentors — Admin + +
    +
    +

    Mentor Management

    +

    {data.mentors.length} mentor{data.mentors.length !== 1 ? 's' : ''}

    +
    + + {#if data.mentors.length === 0} +
    + + + +

    No mentors yet. Assign a user the mentor role to create one.

    +
    + {:else} +
    + + + + + + + + + + + + {#each data.mentors as mentor} + + + + + + + + {/each} + +
    NameEmailPets MentoringStatusActions
    {mentor.userName} + {mentor.userEmail} + {mentor.petCount} + + {mentor.active ? 'Active' : 'Inactive'} + + + Edit +
    +
    + {/if} +
    diff --git a/src/routes/admin/mentors/[id]/+page.server.ts b/src/routes/admin/mentors/[id]/+page.server.ts new file mode 100644 index 0000000..8ed0f30 --- /dev/null +++ b/src/routes/admin/mentors/[id]/+page.server.ts @@ -0,0 +1,66 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { mentors, users, petMentors, pets } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { logAudit } from '$lib/server/audit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + if (isNaN(id)) error(404, 'Mentor not found'); + + const [mentorRow] = await db + .select({ + id: mentors.id, + userId: mentors.userId, + bio: mentors.bio, + active: mentors.active, + createdAt: mentors.createdAt, + userName: users.name, + userEmail: users.email + }) + .from(mentors) + .innerJoin(users, eq(mentors.userId, users.id)) + .where(eq(mentors.id, id)); + + if (!mentorRow) error(404, 'Mentor not found'); + + // Pets assigned to this mentor + const assignedPets = await db + .select({ + id: pets.id, + name: pets.name, + status: pets.status + }) + .from(petMentors) + .innerJoin(pets, eq(petMentors.petId, pets.id)) + .where(eq(petMentors.mentorId, id)); + + return { mentor: mentorRow, assignedPets }; +}; + +export const actions: Actions = { + default: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + const fd = await request.formData(); + + const bio = fd.get('bio')?.toString().trim() || null; + const active = fd.get('active') === 'on'; + + await db.update(mentors).set({ bio, active }).where(eq(mentors.id, id)); + + await logAudit({ + userId: locals.user?.id ?? undefined, + action: 'update_mentor', + entity: 'mentor', + entityId: id, + diff: { active } + }); + + return { success: true }; + } +}; diff --git a/src/routes/admin/mentors/[id]/+page.svelte b/src/routes/admin/mentors/[id]/+page.svelte new file mode 100644 index 0000000..4b7017a --- /dev/null +++ b/src/routes/admin/mentors/[id]/+page.svelte @@ -0,0 +1,83 @@ + + +{mentor.userName} — Mentor — Admin + +
    +
    + + + +
    +

    {mentor.userName}

    +

    {mentor.userEmail}

    +
    +
    + + {#if showSuccess} +
    + + Mentor updated. +
    + {/if} + +
    +
    +
    +
    +

    Mentor Details

    +
    + + +
    +
    + + +
    +
    +
    + Cancel + +
    +
    +
    + +
    +
    +

    Assigned Pets ({data.assignedPets.length})

    + {#if data.assignedPets.length === 0} +

    No pets assigned.

    + {:else} +
      + {#each data.assignedPets as pet} +
    • + {pet.name} + +
    • + {/each} +
    + {/if} +
    +
    +
    +
    diff --git a/src/routes/admin/sponsors/+page.server.ts b/src/routes/admin/sponsors/+page.server.ts new file mode 100644 index 0000000..79c33b9 --- /dev/null +++ b/src/routes/admin/sponsors/+page.server.ts @@ -0,0 +1,44 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { sponsors } from '$lib/server/schema'; +import { eq, asc } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { logAudit } from '$lib/server/audit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'sponsors')) error(403, 'Access denied'); + const allSponsors = await db.select().from(sponsors).orderBy(asc(sponsors.sortOrder)); + + const grouped = { + top_dog: allSponsors.filter(s => s.tier === 'top_dog'), + wagging_tails: allSponsors.filter(s => s.tier === 'wagging_tails'), + wet_noses: allSponsors.filter(s => s.tier === 'wet_noses') + }; + + return { sponsors: allSponsors, grouped }; +}; + +export const actions: Actions = { + toggleActive: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'sponsors')) error(403, 'Access denied'); + const fd = await request.formData(); + const sponsorId = parseInt(fd.get('sponsorId') as string, 10); + const active = fd.get('active') === 'true'; + + if (!sponsorId || isNaN(sponsorId)) return fail(400, { error: 'Invalid sponsor ID' }); + + await db.update(sponsors).set({ active }).where(eq(sponsors.id, sponsorId)); + + await logAudit({ + userId: locals.user?.id ?? undefined, + action: 'toggle_sponsor_active', + entity: 'sponsor', + entityId: sponsorId, + diff: { active } + }); + + return { success: true }; + } +}; diff --git a/src/routes/admin/sponsors/+page.svelte b/src/routes/admin/sponsors/+page.svelte new file mode 100644 index 0000000..eb8d4c9 --- /dev/null +++ b/src/routes/admin/sponsors/+page.svelte @@ -0,0 +1,73 @@ + + +Sponsors — Admin + +
    +
    +

    Sponsor Management

    + + Add Sponsor +
    + + {#each ['top_dog', 'wagging_tails', 'wet_noses'] as tier} + {@const tierSponsors = grouped[tier as keyof typeof grouped]} +
    +
    +

    {tierLabels[tier]}

    + {tierSponsors.length} +
    + + {#if tierSponsors.length === 0} +
    +

    No sponsors in this tier yet.

    +
    + {:else} +
    + {#each tierSponsors as sponsor} +
    +
    + {#if sponsor.logoUrl} + {sponsor.name} logo + {:else} +
    + + + +
    + {/if} +
    +

    {sponsor.name}

    + {#if sponsor.websiteUrl} + {sponsor.websiteUrl} + {/if} +
    +
    + +
    +
    async ({ update }) => { await update({ reset: false }); }}> + + + +
    + Edit +
    +
    + {/each} +
    + {/if} +
    + {/each} +
    diff --git a/src/routes/admin/sponsors/[id]/+page.server.ts b/src/routes/admin/sponsors/[id]/+page.server.ts new file mode 100644 index 0000000..1d572ee --- /dev/null +++ b/src/routes/admin/sponsors/[id]/+page.server.ts @@ -0,0 +1,59 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { sponsors } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { logAudit } from '$lib/server/audit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'sponsors')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + if (isNaN(id)) error(404, 'Sponsor not found'); + + const [sponsor] = await db.select().from(sponsors).where(eq(sponsors.id, id)); + if (!sponsor) error(404, 'Sponsor not found'); + + return { sponsor }; +}; + +export const actions: Actions = { + default: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'sponsors')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + const fd = await request.formData(); + + const name = fd.get('name')?.toString().trim() ?? ''; + if (!name) return fail(400, { error: 'Name is required' }); + + const tier = fd.get('tier')?.toString() ?? 'wet_noses'; + if (!['top_dog', 'wagging_tails', 'wet_noses'].includes(tier)) return fail(400, { error: 'Invalid tier' }); + + const logoUrl = fd.get('logoUrl')?.toString().trim() || null; + const websiteUrl = fd.get('websiteUrl')?.toString().trim() || null; + const description = fd.get('description')?.toString().trim() || null; + const active = fd.get('active') === 'on'; + const sortOrder = parseInt(fd.get('sortOrder')?.toString() ?? '0', 10); + + await db.update(sponsors).set({ + name, + tier: tier as typeof sponsors.tier.enumValues[number], + logoUrl, + websiteUrl, + description, + active, + sortOrder + }).where(eq(sponsors.id, id)); + + await logAudit({ + userId: locals.user?.id ?? undefined, + action: 'update_sponsor', + entity: 'sponsor', + entityId: id, + diff: { name, tier } + }); + + return { success: true }; + } +}; diff --git a/src/routes/admin/sponsors/[id]/+page.svelte b/src/routes/admin/sponsors/[id]/+page.svelte new file mode 100644 index 0000000..ebbe5af --- /dev/null +++ b/src/routes/admin/sponsors/[id]/+page.svelte @@ -0,0 +1,87 @@ + + +{sponsor.name} — Admin + +
    +
    + + + +

    {sponsor.name}

    +
    + + {#if showSuccess} +
    + + Sponsor updated. +
    + {/if} + + {#if form?.error} +
    {form.error}
    + {/if} + +
    +
    +
    + + +
    +
    + + +
    +
    + + + {#if sponsor.logoUrl} + Current logo + {/if} +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + Cancel + +
    +
    +
    diff --git a/src/routes/admin/sponsors/new/+page.server.ts b/src/routes/admin/sponsors/new/+page.server.ts new file mode 100644 index 0000000..06bb731 --- /dev/null +++ b/src/routes/admin/sponsors/new/+page.server.ts @@ -0,0 +1,46 @@ +import type { Actions } from './$types'; +import { db } from '$lib/server/db'; +import { sponsors } from '$lib/server/schema'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { logAudit } from '$lib/server/audit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const actions: Actions = { + default: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'sponsors')) error(403, 'Access denied'); + const fd = await request.formData(); + + const name = fd.get('name')?.toString().trim() ?? ''; + if (!name) return fail(400, { error: 'Name is required' }); + + const tier = fd.get('tier')?.toString() ?? 'wet_noses'; + if (!['top_dog', 'wagging_tails', 'wet_noses'].includes(tier)) return fail(400, { error: 'Invalid tier' }); + + const logoUrl = fd.get('logoUrl')?.toString().trim() || null; + const websiteUrl = fd.get('websiteUrl')?.toString().trim() || null; + const description = fd.get('description')?.toString().trim() || null; + const active = fd.get('active') === 'on'; + const sortOrder = parseInt(fd.get('sortOrder')?.toString() ?? '0', 10); + + const [inserted] = await db.insert(sponsors).values({ + name, + tier: tier as typeof sponsors.tier.enumValues[number], + logoUrl, + websiteUrl, + description, + active, + sortOrder + }).$returningId(); + + await logAudit({ + userId: locals.user?.id ?? undefined, + action: 'create_sponsor', + entity: 'sponsor', + entityId: inserted.id, + diff: { name, tier } + }); + + redirect(303, '/admin/sponsors'); + } +}; diff --git a/src/routes/admin/sponsors/new/+page.svelte b/src/routes/admin/sponsors/new/+page.svelte new file mode 100644 index 0000000..6bef7db --- /dev/null +++ b/src/routes/admin/sponsors/new/+page.svelte @@ -0,0 +1,65 @@ + + +Add Sponsor — Admin + +
    +
    + + + +

    Add Sponsor

    +
    + + {#if form?.error} +
    {form.error}
    + {/if} + +
    { submitting = true; return async ({ update }) => { submitting = false; await update(); }; }} class="space-y-6"> +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + Cancel + +
    +
    +
    diff --git a/src/routes/admin/users/+page.server.ts b/src/routes/admin/users/+page.server.ts new file mode 100644 index 0000000..c8351d5 --- /dev/null +++ b/src/routes/admin/users/+page.server.ts @@ -0,0 +1,76 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { users } from '$lib/server/schema'; +import { error, fail } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { eq, desc, count } from 'drizzle-orm'; +import { logAudit } from '$lib/server/audit'; +import { sendRegistrationApproved, sendRegistrationDenied } from '$lib/server/email'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'users')) error(403, 'You do not have permission to manage users'); + + const userList = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + role: users.role, + active: users.active, + createdAt: users.createdAt + }) + .from(users) + .orderBy(desc(users.createdAt)); + + const [pendingRow] = await db.select({ total: count() }).from(users).where(eq(users.active, false)); + const pendingCount = pendingRow?.total ?? 0; + + return { users: userList, pendingCount }; +}; + +export const actions: Actions = { + approve: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'users')) error(403, 'Access denied'); + const fd = await request.formData(); + const userId = parseInt(fd.get('userId')?.toString() ?? '', 10); + const role = fd.get('role')?.toString() ?? 'viewer'; + + if (isNaN(userId)) return fail(400, { error: 'Invalid user ID' }); + + const validRoles = ['sysadmin', 'director', 'foster_coordinator', 'volunteer_manager', 'vet_liaison', 'content_editor', 'applications_manager', 'viewer', 'foster']; + if (!validRoles.includes(role)) return fail(400, { error: 'Invalid role' }); + + const [user] = await db.select({ id: users.id, name: users.name, email: users.email }).from(users).where(eq(users.id, userId)).limit(1); + if (!user) return fail(404, { error: 'User not found' }); + + await db.update(users).set({ active: true, role: role as typeof users.role.enumValues[number] }).where(eq(users.id, userId)); + await logAudit({ userId: locals.user!.id, action: 'approve_user', entity: 'user', entityId: userId, diff: { role } }); + + try { + await sendRegistrationApproved(process.env.ORG_NAME ?? 'Rescue', user.email, user.name, `${process.env.SITE_URL ?? ''}/login`); + } catch { /* noop */ } + + return { success: true, action: 'approve' }; + }, + + deny: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'users')) error(403, 'Access denied'); + const fd = await request.formData(); + const userId = parseInt(fd.get('userId')?.toString() ?? '', 10); + if (isNaN(userId)) return fail(400, { error: 'Invalid user ID' }); + + const [user] = await db.select({ id: users.id, name: users.name, email: users.email, active: users.active }).from(users).where(eq(users.id, userId)).limit(1); + if (!user) return fail(404, { error: 'User not found' }); + if (user.active) return fail(400, { error: 'Cannot deny an active user. Use the edit page to deactivate.' }); + + await db.delete(users).where(eq(users.id, userId)); + await logAudit({ userId: locals.user!.id, action: 'deny_user', entity: 'user', entityId: userId }); + + try { + await sendRegistrationDenied(process.env.ORG_NAME ?? 'Rescue', user.email, user.name); + } catch { /* noop */ } + + return { success: true, action: 'deny' }; + } +}; diff --git a/src/routes/admin/users/+page.svelte b/src/routes/admin/users/+page.svelte new file mode 100644 index 0000000..548fd36 --- /dev/null +++ b/src/routes/admin/users/+page.svelte @@ -0,0 +1,123 @@ + + +Users — Admin + +
    +
    +
    +

    Users

    + {#if data.pendingCount > 0} +

    {data.pendingCount} pending approval

    + {/if} +
    + + Add User + +
    + + {#if form?.error} +
    {form.error}
    + {/if} + + {#if data.users.length === 0} +
    +

    No users found.

    +
    + {:else} +
    + + + + + + + + + + + + + {#each data.users as user} + + + + + + + + + {/each} + +
    NameEmailRoleStatusJoinedActions
    {user.name}{user.email}{roleLabel(user.role)} + {#if user.active} + Active + {:else} + Pending + {/if} + {formatDate(user.createdAt)} + {#if !user.active} +
    +
    { + submitting = user.id; + return async ({ update }) => { submitting = null; await update(); }; + }}> + + + +
    + {#if confirmDeny === user.id} +
    { + submitting = user.id; + return async ({ update }) => { submitting = null; confirmDeny = null; await update(); }; + }}> + + +
    + + {:else} + + {/if} +
    + {:else} + Edit + {/if} +
    +
    + {/if} +
    diff --git a/src/routes/admin/users/[id]/+page.server.ts b/src/routes/admin/users/[id]/+page.server.ts new file mode 100644 index 0000000..0daa2e4 --- /dev/null +++ b/src/routes/admin/users/[id]/+page.server.ts @@ -0,0 +1,85 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { users } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { hashPassword } from '$lib/server/auth-utils'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'users')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + if (isNaN(id)) error(404, 'User not found'); + + const [user] = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + role: users.role, + active: users.active, + mustChangePassword: users.mustChangePassword, + createdAt: users.createdAt + }) + .from(users) + .where(eq(users.id, id)) + .limit(1); + + if (!user) error(404, 'User not found'); + return { user }; +}; + +export const actions: Actions = { + update: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'users')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + const fd = await request.formData(); + const name = fd.get('name')?.toString().trim() ?? ''; + const role = fd.get('role')?.toString() ?? 'viewer'; + const active = fd.get('active') === 'on'; + + if (!name) return fail(400, { error: 'Name is required' }); + + const validRoles = ['sysadmin', 'director', 'foster_coordinator', 'volunteer_manager', 'vet_liaison', 'content_editor', 'applications_manager', 'viewer', 'foster']; + if (!validRoles.includes(role)) return fail(400, { error: 'Invalid role' }); + + await db.update(users) + .set({ name, role: role as typeof users.role.enumValues[number], active }) + .where(eq(users.id, id)); + + await logAudit({ + userId: locals.user!.id, + action: 'update_user', + entity: 'user', + entityId: id, + diff: { name, role, active } + }); + + return { success: true, action: 'update' }; + }, + + resetPassword: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'users')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + const fd = await request.formData(); + const password = fd.get('password')?.toString() ?? ''; + + if (!password || password.length < 8) return fail(400, { error: 'Password must be at least 8 characters' }); + + const passwordHash = await hashPassword(password); + await db.update(users) + .set({ passwordHash, mustChangePassword: true }) + .where(eq(users.id, id)); + + await logAudit({ + userId: locals.user!.id, + action: 'reset_user_password', + entity: 'user', + entityId: id + }); + + return { success: true, action: 'resetPassword' }; + } +}; diff --git a/src/routes/admin/users/[id]/+page.svelte b/src/routes/admin/users/[id]/+page.svelte new file mode 100644 index 0000000..13dd3ed --- /dev/null +++ b/src/routes/admin/users/[id]/+page.svelte @@ -0,0 +1,120 @@ + + +{user.name} — Admin + +
    +
    + + + +

    {user.name}

    +
    + + {#if form?.error} +
    {form.error}
    + {/if} + + {#if showSuccess} +
    + + Changes saved. +
    + {/if} + +
    +
    +

    Edit User

    +
    handleEnhance('update')}> +
    +
    + + +
    +
    +

    Email

    +

    {user.email}

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

    Password

    + +
    + {#if showPasswordForm} +
    handleEnhance('resetPassword')}> +
    +
    + + +

    User will be prompted to change this on next login.

    +
    +
    +
    + +
    +
    + {:else} +

    + {user.mustChangePassword ? 'User must change password on next login.' : 'Password is set.'} +

    + {/if} +
    +
    +
    diff --git a/src/routes/admin/users/new/+page.server.ts b/src/routes/admin/users/new/+page.server.ts new file mode 100644 index 0000000..9861a1d --- /dev/null +++ b/src/routes/admin/users/new/+page.server.ts @@ -0,0 +1,69 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { users } from '$lib/server/schema'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { hashPassword } from '$lib/server/auth-utils'; +import { logAudit } from '$lib/server/audit'; +import { sendRegistrationApproved } from '$lib/server/email'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'users')) error(403, 'Access denied'); + return {}; +}; + +export const actions: Actions = { + default: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'users')) error(403, 'Access denied'); + const fd = await request.formData(); + const name = fd.get('name')?.toString().trim() ?? ''; + const email = fd.get('email')?.toString().trim().toLowerCase() ?? ''; + const role = fd.get('role')?.toString() ?? 'viewer'; + const password = fd.get('password')?.toString() ?? ''; + const sendWelcome = fd.get('sendWelcome') === 'on'; + + if (!name) return fail(400, { error: 'Name is required' }); + if (!email) return fail(400, { error: 'Email is required' }); + if (!password || password.length < 8) return fail(400, { error: 'Password must be at least 8 characters' }); + + const validRoles = ['sysadmin', 'director', 'foster_coordinator', 'volunteer_manager', 'vet_liaison', 'content_editor', 'applications_manager', 'viewer', 'foster']; + if (!validRoles.includes(role)) return fail(400, { error: 'Invalid role' }); + + const existing = await db.select({ id: users.id }).from(users).where( + (await import('drizzle-orm')).eq(users.email, email) + ).limit(1); + if (existing.length > 0) return fail(400, { error: 'A user with that email already exists' }); + + const passwordHash = await hashPassword(password); + const [result] = await db.insert(users).values({ + name, + email, + passwordHash, + role: role as typeof users.role.enumValues[number], + active: true, + mustChangePassword: true + }).$returningId(); + + await logAudit({ + userId: locals.user!.id, + action: 'create_user', + entity: 'user', + entityId: result.id, + diff: { name, email, role } + }); + + if (sendWelcome) { + try { + await sendRegistrationApproved( + process.env.ORG_NAME ?? 'Rescue', + email, + name, + `${process.env.SITE_URL ?? ''}/login` + ); + } catch { /* noop */ } + } + + redirect(303, '/admin/users'); + } +}; diff --git a/src/routes/admin/users/new/+page.svelte b/src/routes/admin/users/new/+page.svelte new file mode 100644 index 0000000..74494e2 --- /dev/null +++ b/src/routes/admin/users/new/+page.svelte @@ -0,0 +1,77 @@ + + +New User — Admin + +
    +
    + + + +

    New User

    +
    + + {#if form?.error} +
    {form.error}
    + {/if} + +
    +
    { + submitting = true; + return async ({ update }) => { submitting = false; await update(); }; + }}> +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +

    User will be prompted to change this on first login.

    +
    +
    + + +
    +
    +
    + + Cancel +
    +
    +
    +
    diff --git a/src/routes/admin/vets/+page.server.ts b/src/routes/admin/vets/+page.server.ts new file mode 100644 index 0000000..2c5049b --- /dev/null +++ b/src/routes/admin/vets/+page.server.ts @@ -0,0 +1,18 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { vets } from '$lib/server/schema'; +import { like, or } from 'drizzle-orm'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { error } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ url, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'vets')) error(403, 'Access denied'); + const q = url.searchParams.get('q')?.trim() ?? ''; + + const vetList = q + ? await db.select().from(vets).where(or(like(vets.name, `%${q}%`), like(vets.clinic, `%${q}%`))).orderBy(vets.name) + : await db.select().from(vets).orderBy(vets.name); + + return { vets: vetList, q }; +}; diff --git a/src/routes/admin/vets/+page.svelte b/src/routes/admin/vets/+page.svelte new file mode 100644 index 0000000..6387136 --- /dev/null +++ b/src/routes/admin/vets/+page.svelte @@ -0,0 +1,59 @@ + + +Vets — Admin + +
    +
    +

    Vet Management

    + + Add Vet +
    + +
    +
    + + + {#if data.q} + Clear + {/if} +
    +
    + + {#if vetList.length === 0} +
    + + + +

    {data.q ? 'No vets match your search' : 'No vets yet'}

    +
    + {:else} +
    + + + + + + + + + + + + {#each vetList as vet} + + + + + + + + {/each} + +
    NameClinicPhoneEmailActions
    {vet.name}{vet.clinic ?? '—'}{#if vet.phone}{vet.phone}{:else}—{/if}{#if vet.email}{vet.email}{:else}—{/if}Edit
    +
    +

    {vetList.length} vet{vetList.length !== 1 ? 's' : ''} total

    + {/if} +
    diff --git a/src/routes/admin/vets/[id]/+page.server.ts b/src/routes/admin/vets/[id]/+page.server.ts new file mode 100644 index 0000000..0af0caf --- /dev/null +++ b/src/routes/admin/vets/[id]/+page.server.ts @@ -0,0 +1,52 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { vets, petMedical, pets } from '$lib/server/schema'; +import { eq, count } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { logAudit } from '$lib/server/audit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'vets')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + if (isNaN(id)) error(404, 'Vet not found'); + + const [vet] = await db.select().from(vets).where(eq(vets.id, id)); + if (!vet) error(404, 'Vet not found'); + + const [medicalPetCount] = await db.select({ total: count() }).from(petMedical).where(eq(petMedical.vetId, id)); + const [clinicPetCount] = await db.select({ total: count() }).from(pets).where(eq(pets.vetId, id)); + const petCount = (medicalPetCount?.total ?? 0) + (clinicPetCount?.total ?? 0); + + return { vet, petCount }; +}; + +export const actions: Actions = { + default: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'vets')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + const fd = await request.formData(); + + const name = fd.get('name')?.toString().trim() ?? ''; + if (!name) return fail(400, { error: 'Name is required' }); + + const clinic = fd.get('clinic')?.toString().trim() || null; + const phone = fd.get('phone')?.toString().trim() || null; + const email = fd.get('email')?.toString().trim() || null; + const address = fd.get('address')?.toString().trim() || null; + const notes = fd.get('notes')?.toString().trim() || null; + + await db.update(vets).set({ name, clinic, phone, email, address, notes }).where(eq(vets.id, id)); + + await logAudit({ + userId: locals.user?.id ?? undefined, + action: 'update_vet', + entity: 'vet', + entityId: id, + diff: { name, clinic } + }); + + return { success: true }; + } +}; diff --git a/src/routes/admin/vets/[id]/+page.svelte b/src/routes/admin/vets/[id]/+page.svelte new file mode 100644 index 0000000..6006855 --- /dev/null +++ b/src/routes/admin/vets/[id]/+page.svelte @@ -0,0 +1,87 @@ + + +{vet.name} — Admin + +
    +
    + + + +
    +

    {vet.name}

    + {#if vet.clinic}

    {vet.clinic}

    {/if} +
    +
    + +
    + + {data.petCount} pet record{data.petCount !== 1 ? 's' : ''} assigned to this vet +
    + + {#if showSuccess} +
    + + Vet updated successfully. +
    + {/if} + + {#if form?.error} +
    {form.error}
    + {/if} + +
    +
    +

    Vet Information

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + Cancel + +
    +
    +
    diff --git a/src/routes/admin/vets/new/+page.server.ts b/src/routes/admin/vets/new/+page.server.ts new file mode 100644 index 0000000..d47c273 --- /dev/null +++ b/src/routes/admin/vets/new/+page.server.ts @@ -0,0 +1,35 @@ +import type { Actions } from './$types'; +import { db } from '$lib/server/db'; +import { vets } from '$lib/server/schema'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { logAudit } from '$lib/server/audit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const actions: Actions = { + default: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'vets')) error(403, 'Access denied'); + const fd = await request.formData(); + + const name = fd.get('name')?.toString().trim() ?? ''; + if (!name) return fail(400, { error: 'Name is required' }); + + const clinic = fd.get('clinic')?.toString().trim() || null; + const phone = fd.get('phone')?.toString().trim() || null; + const email = fd.get('email')?.toString().trim() || null; + const address = fd.get('address')?.toString().trim() || null; + const notes = fd.get('notes')?.toString().trim() || null; + + const [inserted] = await db.insert(vets).values({ name, clinic, phone, email, address, notes }).$returningId(); + + await logAudit({ + userId: locals.user?.id ?? undefined, + action: 'create_vet', + entity: 'vet', + entityId: inserted.id, + diff: { name, clinic } + }); + + redirect(303, '/admin/vets'); + } +}; diff --git a/src/routes/admin/vets/new/+page.svelte b/src/routes/admin/vets/new/+page.svelte new file mode 100644 index 0000000..1bacd60 --- /dev/null +++ b/src/routes/admin/vets/new/+page.svelte @@ -0,0 +1,60 @@ + + +Add Vet — Admin + +
    +
    + + + +

    Add Vet

    +
    + + {#if form?.error} +
    {form.error}
    + {/if} + +
    { submitting = true; return async ({ update }) => { submitting = false; await update(); }; }} class="space-y-6"> +
    +

    Vet Information

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + Cancel + +
    +
    +
    diff --git a/src/routes/admin/volunteers/+page.server.ts b/src/routes/admin/volunteers/+page.server.ts new file mode 100644 index 0000000..d957aed --- /dev/null +++ b/src/routes/admin/volunteers/+page.server.ts @@ -0,0 +1,43 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { applications } from '$lib/server/schema'; +import { eq, and, count, desc } from 'drizzle-orm'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { error } from '@sveltejs/kit'; + +const PER_PAGE = 20; + +export const load: PageServerLoad = async ({ url, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'volunteers')) error(403, 'Access denied'); + + const status = url.searchParams.get('status') ?? ''; + const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10)); + const offset = (page - 1) * PER_PAGE; + + const conditions = [eq(applications.type, 'volunteer') as ReturnType]; + if (status) conditions.push(eq(applications.status, status as typeof applications.status.enumValues[number]) as ReturnType); + + const where = and(...conditions); + + const [totalRow] = await db.select({ total: count() }).from(applications).where(where); + const total = totalRow?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / PER_PAGE)); + + const volunteers = await db + .select({ + id: applications.id, + applicantName: applications.applicantName, + email: applications.applicantEmail, + phone: applications.applicantPhone, + status: applications.status, + createdAt: applications.createdAt + }) + .from(applications) + .where(where) + .orderBy(desc(applications.createdAt)) + .limit(PER_PAGE) + .offset(offset); + + return { volunteers, total, page, totalPages, status }; +}; diff --git a/src/routes/admin/volunteers/+page.svelte b/src/routes/admin/volunteers/+page.svelte new file mode 100644 index 0000000..40cfc65 --- /dev/null +++ b/src/routes/admin/volunteers/+page.svelte @@ -0,0 +1,89 @@ + + +Volunteer Applications — Admin + +
    +
    +

    Volunteer Applications

    + {data.total} total +
    + +
    + +
    + + {#if data.volunteers.length === 0} +
    +

    No volunteer applications found.

    +
    + {:else} +
    + + + + + + + + + + + + + {#each data.volunteers as vol} + + + + + + + + + {/each} + +
    NameEmailPhoneStatusDateActions
    {vol.applicantName}{vol.email}{vol.phone ?? '—'}{formatDate(vol.createdAt)} + View +
    +
    + + {/if} +
    diff --git a/src/routes/admin/volunteers/[id]/+page.server.ts b/src/routes/admin/volunteers/[id]/+page.server.ts new file mode 100644 index 0000000..4a00538 --- /dev/null +++ b/src/routes/admin/volunteers/[id]/+page.server.ts @@ -0,0 +1,59 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { applications } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { logAudit } from '$lib/server/audit'; +import { sendApplicationStatusUpdate } from '$lib/server/email'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'volunteers')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + if (isNaN(id)) error(404, 'Application not found'); + + const [app] = await db.select().from(applications).where(eq(applications.id, id)); + if (!app || app.type !== 'volunteer') error(404, 'Volunteer application not found'); + + return { application: app }; +}; + +export const actions: Actions = { + updateStatus: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'volunteers')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + const fd = await request.formData(); + const status = fd.get('status')?.toString(); + + if (!status || !['pending', 'reviewing', 'approved', 'denied', 'withdrawn'].includes(status)) { + return fail(400, { error: 'Invalid status' }); + } + + await db.update(applications).set({ status: status as typeof applications.status.enumValues[number] }).where(eq(applications.id, id)); + + await logAudit({ + userId: locals.user?.id ?? undefined, + action: 'update_volunteer_status', + entity: 'application', + entityId: id, + diff: { status } + }); + + try { + const [app] = await db.select({ applicantName: applications.applicantName, email: applications.applicantEmail }).from(applications).where(eq(applications.id, id)); + if (app) { + await sendApplicationStatusUpdate( + process.env.ORG_NAME ?? 'Rescue', + app.email, + app.applicantName, + 'volunteer', + status, + process.env.SITE_URL ?? '' + ); + } + } catch { /* noop */ } + + return { success: true }; + } +}; diff --git a/src/routes/admin/volunteers/[id]/+page.svelte b/src/routes/admin/volunteers/[id]/+page.svelte new file mode 100644 index 0000000..4df8f40 --- /dev/null +++ b/src/routes/admin/volunteers/[id]/+page.svelte @@ -0,0 +1,124 @@ + + +Volunteer #{app.id} — Admin + +
    +
    + + + +
    +

    {app.applicantName}

    + +
    +
    + + {#if form?.error} +
    {form.error}
    + {/if} + +
    +
    +
    +

    Applicant Information

    +
    +
    +

    Email

    +

    {app.applicantEmail}

    +
    + {#if app.applicantPhone} +
    +

    Phone

    +

    {app.applicantPhone}

    +
    + {/if} +
    +

    Submitted

    +

    {formatDate(app.createdAt)}

    +
    +
    +
    + + {#if formDataEntries.length > 0} +
    +

    Application Details

    +
    + {#each formDataEntries as [key, value]} +
    +
    {formatFieldLabel(key)}
    +
    + {#if typeof value === 'boolean'}{value ? 'Yes' : 'No'} + {:else if Array.isArray(value)}{value.join(', ')} + {:else}{String(value)}{/if} +
    +
    + {/each} +
    +
    + {/if} +
    + +
    +
    +

    Update Status

    + {#if showSuccess} +
    + + Status updated. +
    + {/if} +
    +
    + + +
    + +
    +
    +
    +
    +
    From 1ae4c1be2cf23127f4a29ba154a2b447adfadaaf Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:42:50 -0500 Subject: [PATCH 28/38] =?UTF-8?q?feat(task-36):=20admin=20Content=20sectio?= =?UTF-8?q?n=20=E2=80=94=20content=20blocks,=20newsletter,=20happy=20tails?= =?UTF-8?q?,=20events,=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CMS content block editor grouped by page, newsletter subscriber management, happy tails CRUD, events CRUD, and contact message inbox with read/unread tracking. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/admin/content/+page.server.ts | 60 +++++++++ src/routes/admin/content/+page.svelte | 126 ++++++++++++++++++ src/routes/admin/events/+page.server.ts | 26 ++++ src/routes/admin/events/+page.svelte | 57 ++++++++ src/routes/admin/events/[id]/+page.server.ts | 74 ++++++++++ src/routes/admin/events/[id]/+page.svelte | 118 ++++++++++++++++ src/routes/admin/events/new/+page.server.ts | 54 ++++++++ src/routes/admin/events/new/+page.svelte | 74 ++++++++++ src/routes/admin/happy-tails/+page.server.ts | 28 ++++ src/routes/admin/happy-tails/+page.svelte | 64 +++++++++ .../admin/happy-tails/[id]/+page.server.ts | 78 +++++++++++ .../admin/happy-tails/[id]/+page.svelte | 114 ++++++++++++++++ .../admin/happy-tails/new/+page.server.ts | 61 +++++++++ src/routes/admin/happy-tails/new/+page.svelte | 87 ++++++++++++ src/routes/admin/messages/+page.server.ts | 53 ++++++++ src/routes/admin/messages/+page.svelte | 94 +++++++++++++ src/routes/admin/newsletter/+page.server.ts | 58 ++++++++ src/routes/admin/newsletter/+page.svelte | 113 ++++++++++++++++ 18 files changed, 1339 insertions(+) create mode 100644 src/routes/admin/content/+page.server.ts create mode 100644 src/routes/admin/content/+page.svelte create mode 100644 src/routes/admin/events/+page.server.ts create mode 100644 src/routes/admin/events/+page.svelte create mode 100644 src/routes/admin/events/[id]/+page.server.ts create mode 100644 src/routes/admin/events/[id]/+page.svelte create mode 100644 src/routes/admin/events/new/+page.server.ts create mode 100644 src/routes/admin/events/new/+page.svelte create mode 100644 src/routes/admin/happy-tails/+page.server.ts create mode 100644 src/routes/admin/happy-tails/+page.svelte create mode 100644 src/routes/admin/happy-tails/[id]/+page.server.ts create mode 100644 src/routes/admin/happy-tails/[id]/+page.svelte create mode 100644 src/routes/admin/happy-tails/new/+page.server.ts create mode 100644 src/routes/admin/happy-tails/new/+page.svelte create mode 100644 src/routes/admin/messages/+page.server.ts create mode 100644 src/routes/admin/messages/+page.svelte create mode 100644 src/routes/admin/newsletter/+page.server.ts create mode 100644 src/routes/admin/newsletter/+page.svelte diff --git a/src/routes/admin/content/+page.server.ts b/src/routes/admin/content/+page.server.ts new file mode 100644 index 0000000..7cd5010 --- /dev/null +++ b/src/routes/admin/content/+page.server.ts @@ -0,0 +1,60 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { contentBlocks } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied'); + + const blocks = await db.select().from(contentBlocks).orderBy(contentBlocks.page, contentBlocks.sectionKey); + + return { blocks }; +}; + +export const actions: Actions = { + save: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied'); + const fd = await request.formData(); + const page = fd.get('page')?.toString().trim() ?? ''; + const sectionKey = fd.get('sectionKey')?.toString().trim() ?? ''; + const content = fd.get('content')?.toString() ?? ''; + + if (!page || !sectionKey) return fail(400, { error: 'Page and section key are required' }); + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + // Treat as plain text content object + parsed = { text: content }; + } + + const existing = await db.select({ id: contentBlocks.id }).from(contentBlocks) + .where((await import('drizzle-orm')).and( + eq(contentBlocks.page, page), + eq(contentBlocks.sectionKey, sectionKey) + )) + .limit(1); + + if (existing.length > 0) { + await db.update(contentBlocks) + .set({ content: parsed as Record }) + .where(eq(contentBlocks.id, existing[0].id)); + } else { + await db.insert(contentBlocks).values({ page, sectionKey, content: parsed as Record }); + } + + await logAudit({ + userId: locals.user!.id, + action: 'update_content_block', + entity: 'content_block', + diff: { page, sectionKey } + }); + + return { success: true }; + } +}; diff --git a/src/routes/admin/content/+page.svelte b/src/routes/admin/content/+page.svelte new file mode 100644 index 0000000..88d93d9 --- /dev/null +++ b/src/routes/admin/content/+page.svelte @@ -0,0 +1,126 @@ + + +Content Blocks — Admin + +
    +
    +

    Content Blocks

    +
    + + {#if form?.error} +
    {form.error}
    + {/if} + + {#if showSuccess} +
    + + Content saved. +
    + {/if} + +
    + +
    +

    Add / Update Content Block

    +
    handleEnhance('new')}> +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + +
    +
    + + + {#each [...blocksByPage.entries()] as [pageName, blocks]} +
    +

    Page: {pageName}

    +
    + {#each blocks as block} + {@const key = `${block.page}:${block.sectionKey}`} +
    +
    + {block.sectionKey} + +
    + {#if editingKey === key} +
    handleEnhance(key)}> + + + + +
    + {:else} +
    {getContentDisplay(block.content)}
    + {/if} +
    + {/each} +
    +
    + {/each} + + {#if data.blocks.length === 0} +
    +

    No content blocks yet. Add one above.

    +
    + {/if} +
    +
    diff --git a/src/routes/admin/events/+page.server.ts b/src/routes/admin/events/+page.server.ts new file mode 100644 index 0000000..a0794a6 --- /dev/null +++ b/src/routes/admin/events/+page.server.ts @@ -0,0 +1,26 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { events } from '$lib/server/schema'; +import { desc } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'events')) error(403, 'Access denied'); + + const eventList = await db + .select({ + id: events.id, + title: events.title, + location: events.location, + startDate: events.startDate, + endDate: events.endDate, + published: events.published, + createdAt: events.createdAt + }) + .from(events) + .orderBy(desc(events.startDate)); + + return { events: eventList }; +}; diff --git a/src/routes/admin/events/+page.svelte b/src/routes/admin/events/+page.svelte new file mode 100644 index 0000000..7d0b7e4 --- /dev/null +++ b/src/routes/admin/events/+page.svelte @@ -0,0 +1,57 @@ + + +Events — Admin + +
    +
    +

    Events

    + + Add Event + +
    + + {#if data.events.length === 0} +
    +

    No events yet.

    +
    + {:else} +
    + + + + + + + + + + + + {#each data.events as event} + + + + + + + + {/each} + +
    TitleLocationDateStatusActions
    {event.title}{event.location ?? '—'}{formatDate(event.startDate)} + {#if event.published} + Published + {:else} + Draft + {/if} + + Edit +
    +
    + {/if} +
    diff --git a/src/routes/admin/events/[id]/+page.server.ts b/src/routes/admin/events/[id]/+page.server.ts new file mode 100644 index 0000000..02c3436 --- /dev/null +++ b/src/routes/admin/events/[id]/+page.server.ts @@ -0,0 +1,74 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { events } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'events')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + if (isNaN(id)) error(404, 'Event not found'); + + const [event] = await db.select().from(events).where(eq(events.id, id)).limit(1); + if (!event) error(404, 'Event not found'); + + return { event }; +}; + +export const actions: Actions = { + update: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'events')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + const fd = await request.formData(); + const title = fd.get('title')?.toString().trim() ?? ''; + const description = fd.get('description')?.toString().trim() || null; + const location = fd.get('location')?.toString().trim() || null; + const startDateStr = fd.get('startDate')?.toString() ?? ''; + const endDateStr = fd.get('endDate')?.toString() || null; + const imageUrl = fd.get('imageUrl')?.toString().trim() || null; + const facebookEventId = fd.get('facebookEventId')?.toString().trim() || null; + const published = fd.get('published') === 'on'; + + if (!title) return fail(400, { error: 'Title is required' }); + if (!startDateStr) return fail(400, { error: 'Start date is required' }); + + await db.update(events).set({ + title, + description, + location, + startDate: new Date(startDateStr), + endDate: endDateStr ? new Date(endDateStr) : null, + imageUrl, + facebookEventId, + published + }).where(eq(events.id, id)); + + await logAudit({ + userId: locals.user!.id, + action: 'update_event', + entity: 'event', + entityId: id, + diff: { title, published } + }); + + return { success: true }; + }, + + delete: async ({ params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'events')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + + await db.delete(events).where(eq(events.id, id)); + await logAudit({ + userId: locals.user!.id, + action: 'delete_event', + entity: 'event', + entityId: id + }); + + return { success: true, deleted: true }; + } +}; diff --git a/src/routes/admin/events/[id]/+page.svelte b/src/routes/admin/events/[id]/+page.svelte new file mode 100644 index 0000000..b03b99b --- /dev/null +++ b/src/routes/admin/events/[id]/+page.svelte @@ -0,0 +1,118 @@ + + +{event.title} — Admin + +
    +
    + + + +

    {event.title}

    +
    + + {#if form?.error} +
    {form.error}
    + {/if} + {#if showSuccess} +
    + + Event saved. +
    + {/if} + +
    +
    handleEnhance('update')}> +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    + +
    +
    + +
    +
    +
    + +
    +

    Danger Zone

    + {#if confirmDelete} +

    Are you sure? This cannot be undone.

    +
    handleEnhance('delete')}> +
    + + +
    +
    + {:else} + + {/if} +
    +
    diff --git a/src/routes/admin/events/new/+page.server.ts b/src/routes/admin/events/new/+page.server.ts new file mode 100644 index 0000000..31b907e --- /dev/null +++ b/src/routes/admin/events/new/+page.server.ts @@ -0,0 +1,54 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { events } from '$lib/server/schema'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'events')) error(403, 'Access denied'); + return {}; +}; + +export const actions: Actions = { + default: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'events')) error(403, 'Access denied'); + const fd = await request.formData(); + const title = fd.get('title')?.toString().trim() ?? ''; + const description = fd.get('description')?.toString().trim() || null; + const location = fd.get('location')?.toString().trim() || null; + const startDateStr = fd.get('startDate')?.toString() ?? ''; + const endDateStr = fd.get('endDate')?.toString() || null; + const imageUrl = fd.get('imageUrl')?.toString().trim() || null; + const facebookEventId = fd.get('facebookEventId')?.toString().trim() || null; + const published = fd.get('published') === 'on'; + + if (!title) return fail(400, { error: 'Title is required' }); + if (!startDateStr) return fail(400, { error: 'Start date is required' }); + + const startDate = new Date(startDateStr); + const endDate = endDateStr ? new Date(endDateStr) : null; + + const [result] = await db.insert(events).values({ + title, + description, + location, + startDate, + endDate, + imageUrl, + facebookEventId, + published + }).$returningId(); + + await logAudit({ + userId: locals.user!.id, + action: 'create_event', + entity: 'event', + entityId: result.id, + diff: { title, published } + }); + + redirect(303, `/admin/events/${result.id}`); + } +}; diff --git a/src/routes/admin/events/new/+page.svelte b/src/routes/admin/events/new/+page.svelte new file mode 100644 index 0000000..1d2c62d --- /dev/null +++ b/src/routes/admin/events/new/+page.svelte @@ -0,0 +1,74 @@ + + +New Event — Admin + +
    +
    + + + +

    New Event

    +
    + + {#if form?.error} +
    {form.error}
    + {/if} + +
    +
    { + submitting = true; + return async ({ update }) => { submitting = false; await update(); }; + }}> +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    + +
    +
    + + Cancel +
    +
    +
    +
    diff --git a/src/routes/admin/happy-tails/+page.server.ts b/src/routes/admin/happy-tails/+page.server.ts new file mode 100644 index 0000000..24e48e0 --- /dev/null +++ b/src/routes/admin/happy-tails/+page.server.ts @@ -0,0 +1,28 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { happyTails, pets } from '$lib/server/schema'; +import { eq, desc } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied'); + + const stories = await db + .select({ + id: happyTails.id, + title: happyTails.title, + adopterName: happyTails.adopterName, + petId: happyTails.petId, + petName: pets.name, + featured: happyTails.featured, + published: happyTails.published, + createdAt: happyTails.createdAt + }) + .from(happyTails) + .leftJoin(pets, eq(happyTails.petId, pets.id)) + .orderBy(desc(happyTails.createdAt)); + + return { stories }; +}; diff --git a/src/routes/admin/happy-tails/+page.svelte b/src/routes/admin/happy-tails/+page.svelte new file mode 100644 index 0000000..5e39b24 --- /dev/null +++ b/src/routes/admin/happy-tails/+page.svelte @@ -0,0 +1,64 @@ + + +Happy Tails — Admin + +
    +
    +

    Happy Tails

    + + Add Story + +
    + + {#if data.stories.length === 0} +
    +

    No happy tails stories yet.

    +
    + {:else} +
    + + + + + + + + + + + + + {#each data.stories as story} + + + + + + + + + {/each} + +
    TitleAdopterPetStatusDateActions
    {story.title}{story.adopterName ?? '—'}{story.petName ?? '—'} +
    + {#if story.published} + Published + {:else} + Draft + {/if} + {#if story.featured} + Featured + {/if} +
    +
    {formatDate(story.createdAt)} + Edit +
    +
    + {/if} +
    diff --git a/src/routes/admin/happy-tails/[id]/+page.server.ts b/src/routes/admin/happy-tails/[id]/+page.server.ts new file mode 100644 index 0000000..9a80540 --- /dev/null +++ b/src/routes/admin/happy-tails/[id]/+page.server.ts @@ -0,0 +1,78 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { happyTails, pets } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + if (isNaN(id)) error(404, 'Story not found'); + + const [story] = await db.select().from(happyTails).where(eq(happyTails.id, id)).limit(1); + if (!story) error(404, 'Story not found'); + + const petList = await db + .select({ id: pets.id, name: pets.name }) + .from(pets) + .where(eq(pets.status, 'adopted')) + .orderBy(pets.name); + + return { story, pets: petList }; +}; + +export const actions: Actions = { + update: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + const fd = await request.formData(); + const title = fd.get('title')?.toString().trim() ?? ''; + const story = fd.get('story')?.toString().trim() ?? ''; + const adopterName = fd.get('adopterName')?.toString().trim() || null; + const petId = fd.get('petId')?.toString() ? parseInt(fd.get('petId')!.toString(), 10) : null; + const imageUrl = fd.get('imageUrl')?.toString().trim() || null; + const featured = fd.get('featured') === 'on'; + const published = fd.get('published') === 'on'; + + if (!title) return fail(400, { error: 'Title is required' }); + if (!story) return fail(400, { error: 'Story is required' }); + + await db.update(happyTails).set({ + title, + story, + adopterName, + petId: petId && !isNaN(petId) ? petId : null, + imageUrl, + featured, + published + }).where(eq(happyTails.id, id)); + + await logAudit({ + userId: locals.user!.id, + action: 'update_happy_tail', + entity: 'happy_tail', + entityId: id, + diff: { title, published } + }); + + return { success: true }; + }, + + delete: async ({ params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + + await db.delete(happyTails).where(eq(happyTails.id, id)); + await logAudit({ + userId: locals.user!.id, + action: 'delete_happy_tail', + entity: 'happy_tail', + entityId: id + }); + + return { success: true, deleted: true }; + } +}; diff --git a/src/routes/admin/happy-tails/[id]/+page.svelte b/src/routes/admin/happy-tails/[id]/+page.svelte new file mode 100644 index 0000000..a5ec368 --- /dev/null +++ b/src/routes/admin/happy-tails/[id]/+page.svelte @@ -0,0 +1,114 @@ + + +{story.title} — Admin + +
    +
    + + + +

    {story.title}

    +
    + + {#if form?.error} +
    {form.error}
    + {/if} + {#if showSuccess} +
    + + Story saved. +
    + {/if} + +
    +
    handleEnhance('update')}> +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + +
    +
    +
    + +
    +

    Danger Zone

    + {#if confirmDelete} +

    Are you sure? This cannot be undone.

    +
    handleEnhance('delete')}> +
    + + +
    +
    + {:else} + + {/if} +
    +
    diff --git a/src/routes/admin/happy-tails/new/+page.server.ts b/src/routes/admin/happy-tails/new/+page.server.ts new file mode 100644 index 0000000..2c8c66b --- /dev/null +++ b/src/routes/admin/happy-tails/new/+page.server.ts @@ -0,0 +1,61 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { happyTails, pets } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied'); + + const petList = await db + .select({ id: pets.id, name: pets.name }) + .from(pets) + .where(eq(pets.status, 'adopted')) + .orderBy(pets.name); + + return { pets: petList }; +}; + +export const actions: Actions = { + default: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied'); + const fd = await request.formData(); + const title = fd.get('title')?.toString().trim() ?? ''; + const story = fd.get('story')?.toString().trim() ?? ''; + const adopterName = fd.get('adopterName')?.toString().trim() ?? ''; + const petId = fd.get('petId')?.toString() ? parseInt(fd.get('petId')!.toString(), 10) : null; + const imageUrl = fd.get('imageUrl')?.toString().trim() || null; + const featured = fd.get('featured') === 'on'; + const published = fd.get('published') === 'on'; + const submittedBy = fd.get('submittedBy')?.toString().trim() || null; + const submittedEmail = fd.get('submittedEmail')?.toString().trim() || null; + + if (!title) return fail(400, { error: 'Title is required' }); + if (!story) return fail(400, { error: 'Story is required' }); + + const [result] = await db.insert(happyTails).values({ + title, + story, + adopterName: adopterName || null, + petId: petId && !isNaN(petId) ? petId : null, + imageUrl, + featured, + published, + submittedBy, + submittedEmail + }).$returningId(); + + await logAudit({ + userId: locals.user!.id, + action: 'create_happy_tail', + entity: 'happy_tail', + entityId: result.id, + diff: { title, published } + }); + + redirect(303, `/admin/happy-tails/${result.id}`); + } +}; diff --git a/src/routes/admin/happy-tails/new/+page.svelte b/src/routes/admin/happy-tails/new/+page.svelte new file mode 100644 index 0000000..25d2663 --- /dev/null +++ b/src/routes/admin/happy-tails/new/+page.svelte @@ -0,0 +1,87 @@ + + +New Happy Tail — Admin + +
    +
    + + + +

    New Happy Tail

    +
    + + {#if form?.error} +
    {form.error}
    + {/if} + +
    +
    { + submitting = true; + return async ({ update }) => { submitting = false; await update(); }; + }}> +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + Cancel +
    +
    +
    +
    diff --git a/src/routes/admin/messages/+page.server.ts b/src/routes/admin/messages/+page.server.ts new file mode 100644 index 0000000..3050e5d --- /dev/null +++ b/src/routes/admin/messages/+page.server.ts @@ -0,0 +1,53 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { contactMessages } from '$lib/server/schema'; +import { eq, desc, count } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied'); + + const messages = await db + .select() + .from(contactMessages) + .orderBy(desc(contactMessages.createdAt)); + + const [unreadRow] = await db.select({ total: count() }).from(contactMessages).where(eq(contactMessages.read, false)); + const unreadCount = unreadRow?.total ?? 0; + + return { messages, unreadCount }; +}; + +export const actions: Actions = { + markRead: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied'); + const fd = await request.formData(); + const id = parseInt(fd.get('id')?.toString() ?? '', 10); + if (isNaN(id)) return fail(400, { error: 'Invalid ID' }); + + await db.update(contactMessages).set({ read: true }).where(eq(contactMessages.id, id)); + return { success: true }; + }, + + markUnread: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied'); + const fd = await request.formData(); + const id = parseInt(fd.get('id')?.toString() ?? '', 10); + if (isNaN(id)) return fail(400, { error: 'Invalid ID' }); + + await db.update(contactMessages).set({ read: false }).where(eq(contactMessages.id, id)); + return { success: true }; + }, + + delete: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied'); + const fd = await request.formData(); + const id = parseInt(fd.get('id')?.toString() ?? '', 10); + if (isNaN(id)) return fail(400, { error: 'Invalid ID' }); + + await db.delete(contactMessages).where(eq(contactMessages.id, id)); + return { success: true }; + } +}; diff --git a/src/routes/admin/messages/+page.svelte b/src/routes/admin/messages/+page.svelte new file mode 100644 index 0000000..3091628 --- /dev/null +++ b/src/routes/admin/messages/+page.svelte @@ -0,0 +1,94 @@ + + +Messages — Admin + +
    +
    +
    +

    Contact Messages

    + {#if data.unreadCount > 0} +

    {data.unreadCount} unread

    + {/if} +
    +
    + + {#if data.messages.length === 0} +
    +

    No messages yet.

    +
    + {:else} +
    + {#each data.messages as msg} +
    +
    toggleExpand(msg.id)} role="button" tabindex="0" onkeydown={(e) => e.key === 'Enter' && toggleExpand(msg.id)}> +
    + {#if !msg.read} + + {:else} + + {/if} +
    +

    {msg.name} <{msg.email}>

    +

    {msg.subject ?? '(no subject)'}

    +
    +
    +
    + {formatDate(msg.createdAt)} + +
    +
    + + {#if expanded.has(msg.id)} +
    +
    {msg.message}
    +
    + Reply + {#if msg.read} +
    { + submitting = `unread-${msg.id}`; + return async ({ update }) => { submitting = null; await update(); }; + }}> + + +
    + {:else} +
    { + submitting = `read-${msg.id}`; + return async ({ update }) => { submitting = null; await update(); }; + }}> + + +
    + {/if} +
    { + submitting = `delete-${msg.id}`; + return async ({ update }) => { submitting = null; await update(); }; + }}> + + +
    +
    +
    + {/if} +
    + {/each} +
    + {/if} +
    diff --git a/src/routes/admin/newsletter/+page.server.ts b/src/routes/admin/newsletter/+page.server.ts new file mode 100644 index 0000000..bdf82f8 --- /dev/null +++ b/src/routes/admin/newsletter/+page.server.ts @@ -0,0 +1,58 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { newsletterSubscribers } from '$lib/server/schema'; +import { isNull, isNotNull, desc, count } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied'); + + const subscribers = await db + .select({ + id: newsletterSubscribers.id, + email: newsletterSubscribers.email, + name: newsletterSubscribers.name, + subscribedAt: newsletterSubscribers.subscribedAt, + unsubscribedAt: newsletterSubscribers.unsubscribedAt + }) + .from(newsletterSubscribers) + .orderBy(desc(newsletterSubscribers.subscribedAt)); + + const [activeRow] = await db.select({ total: count() }).from(newsletterSubscribers).where(isNull(newsletterSubscribers.unsubscribedAt)); + const activeCount = activeRow?.total ?? 0; + + return { subscribers, activeCount }; +}; + +export const actions: Actions = { + add: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied'); + const fd = await request.formData(); + const email = fd.get('email')?.toString().trim().toLowerCase() ?? ''; + const name = fd.get('name')?.toString().trim() ?? ''; + + if (!email) return fail(400, { error: 'Email is required' }); + + await db.insert(newsletterSubscribers) + .values({ email, name: name || null }) + .onDuplicateKeyUpdate({ set: { unsubscribedAt: null, name: name || null } }); + + return { success: true }; + }, + + remove: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'content')) error(403, 'Access denied'); + const fd = await request.formData(); + const id = parseInt(fd.get('id')?.toString() ?? '', 10); + if (isNaN(id)) return fail(400, { error: 'Invalid ID' }); + + const { eq } = await import('drizzle-orm'); + await db.update(newsletterSubscribers) + .set({ unsubscribedAt: new Date() }) + .where(eq(newsletterSubscribers.id, id)); + + return { success: true }; + } +}; diff --git a/src/routes/admin/newsletter/+page.svelte b/src/routes/admin/newsletter/+page.svelte new file mode 100644 index 0000000..8e790f9 --- /dev/null +++ b/src/routes/admin/newsletter/+page.svelte @@ -0,0 +1,113 @@ + + +Newsletter Subscribers — Admin + +
    +
    +
    +

    Newsletter Subscribers

    +

    {data.activeCount} active subscribers

    +
    + +
    + + {#if form?.error} +
    {form.error}
    + {/if} + + {#if showAddForm} +
    +
    { + submitting = 'add'; + return async ({ update, result }) => { + submitting = null; + await update(); + if (result.type === 'success') showAddForm = false; + }; + }}> +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + {/if} + + {#if activeSubscribers.length === 0} +
    +

    No active subscribers.

    +
    + {:else} +
    + + + + + + + + + + + {#each activeSubscribers as sub} + + + + + + + {/each} + +
    EmailNameSubscribedActions
    {sub.email}{sub.name ?? '—'}{formatDate(sub.subscribedAt)} +
    { + submitting = `remove-${sub.id}`; + return async ({ update }) => { submitting = null; await update(); }; + }}> + + +
    +
    +
    + {/if} + + {#if unsubscribed.length > 0} +
    + Unsubscribed ({unsubscribed.length}) +
    + {#each unsubscribed as sub} +
    + {sub.email} + {sub.unsubscribedAt ? formatDate(sub.unsubscribedAt) : ''} +
    + {/each} +
    +
    + {/if} +
    From 6ca598b45cd3904709ba69d3268410a8b8a1a24d Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:45:42 -0500 Subject: [PATCH 29/38] =?UTF-8?q?feat(task-37):=20admin=20Analytics=20&=20?= =?UTF-8?q?System=20=E2=80=94=20analytics,=20traffic,=20moderation,=20fost?= =?UTF-8?q?er=20dashboard,=20fosters,=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key metrics dashboard, page view traffic analysis, foster submission moderation, foster assignment management, active foster dashboard with supply requests, and system settings/org config editor with full audit log. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/admin/analytics/+page.server.ts | 47 +++++ src/routes/admin/analytics/+page.svelte | 58 ++++++ .../admin/foster-dashboard/+page.server.ts | 57 ++++++ .../admin/foster-dashboard/+page.svelte | 87 +++++++++ src/routes/admin/fosters/+page.server.ts | 69 +++++++ src/routes/admin/fosters/+page.svelte | 98 ++++++++++ src/routes/admin/moderation/+page.server.ts | 82 +++++++++ src/routes/admin/moderation/+page.svelte | 91 ++++++++++ src/routes/admin/system/+page.server.ts | 92 ++++++++++ src/routes/admin/system/+page.svelte | 169 ++++++++++++++++++ src/routes/admin/traffic/+page.server.ts | 51 ++++++ src/routes/admin/traffic/+page.svelte | 74 ++++++++ 12 files changed, 975 insertions(+) create mode 100644 src/routes/admin/analytics/+page.server.ts create mode 100644 src/routes/admin/analytics/+page.svelte create mode 100644 src/routes/admin/foster-dashboard/+page.server.ts create mode 100644 src/routes/admin/foster-dashboard/+page.svelte create mode 100644 src/routes/admin/fosters/+page.server.ts create mode 100644 src/routes/admin/fosters/+page.svelte create mode 100644 src/routes/admin/moderation/+page.server.ts create mode 100644 src/routes/admin/moderation/+page.svelte create mode 100644 src/routes/admin/system/+page.server.ts create mode 100644 src/routes/admin/system/+page.svelte create mode 100644 src/routes/admin/traffic/+page.server.ts create mode 100644 src/routes/admin/traffic/+page.svelte diff --git a/src/routes/admin/analytics/+page.server.ts b/src/routes/admin/analytics/+page.server.ts new file mode 100644 index 0000000..af3ac7d --- /dev/null +++ b/src/routes/admin/analytics/+page.server.ts @@ -0,0 +1,47 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { pets, applications, donations, fosterAssignments, users, pageViews } from '$lib/server/schema'; +import { eq, gte, count, sum, and } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'dashboard')) error(403, 'Access denied'); + + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const [totalPetsRow] = await db.select({ total: count() }).from(pets); + const [availablePetsRow] = await db.select({ total: count() }).from(pets).where(eq(pets.status, 'available')); + const [fosterPetsRow] = await db.select({ total: count() }).from(pets).where(eq(pets.status, 'foster')); + const [adoptedThisMonthRow] = await db.select({ total: count() }).from(pets).where( + and(eq(pets.status, 'adopted'), gte(pets.adoptedDate, thirtyDaysAgo)) + ); + + const [pendingAppsRow] = await db.select({ total: count() }).from(applications).where(eq(applications.status, 'pending')); + const [newAppsRow] = await db.select({ total: count() }).from(applications).where(gte(applications.createdAt, thirtyDaysAgo)); + + const [donationsRow] = await db.select({ total: sum(donations.amount) }).from(donations).where(gte(donations.date, thirtyDaysAgo)); + const [totalUsersRow] = await db.select({ total: count() }).from(users).where(eq(users.active, true)); + const [activeFostersRow] = await db.select({ total: count() }).from(fosterAssignments).where(eq(fosterAssignments.isCurrent, true)); + + const [pageViewsRow] = await db.select({ total: count() }).from(pageViews).where( + and(gte(pageViews.createdAt, thirtyDaysAgo), eq(pageViews.isBot, false)) + ); + + return { + stats: { + totalPets: totalPetsRow?.total ?? 0, + availablePets: availablePetsRow?.total ?? 0, + fosterPets: fosterPetsRow?.total ?? 0, + adoptedThisMonth: adoptedThisMonthRow?.total ?? 0, + pendingApplications: pendingAppsRow?.total ?? 0, + newApplications: newAppsRow?.total ?? 0, + donationsThisMonth: (donationsRow?.total ?? 0) as number, + activeUsers: totalUsersRow?.total ?? 0, + activeFosters: activeFostersRow?.total ?? 0, + pageViewsThisMonth: pageViewsRow?.total ?? 0 + } + }; +}; diff --git a/src/routes/admin/analytics/+page.svelte b/src/routes/admin/analytics/+page.svelte new file mode 100644 index 0000000..3bbf141 --- /dev/null +++ b/src/routes/admin/analytics/+page.svelte @@ -0,0 +1,58 @@ + + +Analytics — Admin + +
    +
    +

    Analytics

    +

    Last 30 days unless otherwise noted

    +
    + +
    +
    +

    Total Pets

    +

    {s.totalPets}

    +
    +
    +

    Available

    +

    {s.availablePets}

    +
    +
    +

    In Foster

    +

    {s.fosterPets}

    +
    +
    +

    Adopted (30d)

    +

    {s.adoptedThisMonth}

    +
    +
    +

    Active Fosters

    +

    {s.activeFosters}

    +
    +
    + +
    +
    +

    Pending Applications

    +

    {s.pendingApplications}

    +

    {s.newApplications} new this month

    +
    +
    +

    Donations (30d)

    +

    {fmt.format(s.donationsThisMonth / 100)}

    +
    +
    +

    Active Users

    +

    {s.activeUsers}

    +
    +
    +

    Page Views (30d)

    +

    {s.pageViewsThisMonth.toLocaleString()}

    +
    +
    +
    diff --git a/src/routes/admin/foster-dashboard/+page.server.ts b/src/routes/admin/foster-dashboard/+page.server.ts new file mode 100644 index 0000000..9b758ec --- /dev/null +++ b/src/routes/admin/foster-dashboard/+page.server.ts @@ -0,0 +1,57 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { fosterAssignments, supplyRequests, fosterSubmissions, pets, users } from '$lib/server/schema'; +import { eq, desc, count } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied'); + + // Active foster assignments + const activeFosters = await db + .select({ + id: fosterAssignments.id, + startDate: fosterAssignments.startDate, + petId: pets.id, + petName: pets.name, + petSpecies: pets.species, + petBreed: pets.breed, + fostererName: users.name, + fostererEmail: users.email + }) + .from(fosterAssignments) + .leftJoin(pets, eq(fosterAssignments.petId, pets.id)) + .leftJoin(users, eq(fosterAssignments.userId, users.id)) + .where(eq(fosterAssignments.isCurrent, true)) + .orderBy(desc(fosterAssignments.startDate)); + + // Pending supply requests + const pendingSupplyRequests = await db + .select({ + id: supplyRequests.id, + items: supplyRequests.items, + urgency: supplyRequests.urgency, + notes: supplyRequests.notes, + createdAt: supplyRequests.createdAt, + petName: pets.name, + requesterName: users.name + }) + .from(supplyRequests) + .leftJoin(pets, eq(supplyRequests.petId, pets.id)) + .leftJoin(users, eq(supplyRequests.userId, users.id)) + .where(eq(supplyRequests.status, 'pending')) + .orderBy(desc(supplyRequests.createdAt)); + + const [pendingSubmissionsRow] = await db + .select({ total: count() }) + .from(fosterSubmissions) + .where(eq(fosterSubmissions.status, 'pending')); + + return { + activeFosters, + pendingSupplyRequests, + pendingSubmissionsCount: pendingSubmissionsRow?.total ?? 0 + }; +}; diff --git a/src/routes/admin/foster-dashboard/+page.svelte b/src/routes/admin/foster-dashboard/+page.svelte new file mode 100644 index 0000000..a58300e --- /dev/null +++ b/src/routes/admin/foster-dashboard/+page.svelte @@ -0,0 +1,87 @@ + + +Foster Dashboard — Admin + +
    +
    +

    Foster Dashboard

    +
    + + +
    +
    +

    Active Fosters

    +

    {data.activeFosters.length}

    +
    +
    +

    Supply Requests

    +

    {data.pendingSupplyRequests.length}

    +
    +
    +

    Pending Posts

    +

    {data.pendingSubmissionsCount}

    + Review +
    +
    + +
    + +
    +

    Active Foster Assignments

    + {#if data.activeFosters.length === 0} +

    No active fosters.

    + {:else} +
    + {#each data.activeFosters as foster} +
    +
    +

    + {foster.petName ?? '—'} +

    +

    {foster.fostererName ?? foster.fostererEmail ?? '—'}

    +
    + {formatDate(foster.startDate)} +
    + {/each} +
    + {/if} +
    + + +
    +

    Pending Supply Requests

    + {#if data.pendingSupplyRequests.length === 0} +

    No pending requests.

    + {:else} +
    + {#each data.pendingSupplyRequests as req} +
    +
    +

    {req.requesterName ?? '—'}

    + + {req.urgency} + +
    +

    {req.items}

    + {#if req.petName} +

    For: {req.petName}

    + {/if} +
    + {/each} +
    + {/if} +
    +
    +
    diff --git a/src/routes/admin/fosters/+page.server.ts b/src/routes/admin/fosters/+page.server.ts new file mode 100644 index 0000000..66fbad1 --- /dev/null +++ b/src/routes/admin/fosters/+page.server.ts @@ -0,0 +1,69 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { fosterAssignments, pets, users } from '$lib/server/schema'; +import { eq, desc } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied'); + + const assignments = await db + .select({ + id: fosterAssignments.id, + startDate: fosterAssignments.startDate, + endDate: fosterAssignments.endDate, + isCurrent: fosterAssignments.isCurrent, + notes: fosterAssignments.notes, + createdAt: fosterAssignments.createdAt, + petId: pets.id, + petName: pets.name, + petSpecies: pets.species, + fostererName: users.name, + fostererEmail: users.email, + fostererId: users.id + }) + .from(fosterAssignments) + .leftJoin(pets, eq(fosterAssignments.petId, pets.id)) + .leftJoin(users, eq(fosterAssignments.userId, users.id)) + .orderBy(desc(fosterAssignments.isCurrent), desc(fosterAssignments.startDate)); + + // Active fosters for the form + const fosterUsers = await db + .select({ id: users.id, name: users.name, email: users.email }) + .from(users) + .where(eq(users.role, 'foster')) + .orderBy(users.name); + + const availablePets = await db + .select({ id: pets.id, name: pets.name }) + .from(pets) + .where(eq(pets.status, 'available')) + .orderBy(pets.name); + + return { assignments, fosterUsers, availablePets }; +}; + +export const actions: Actions = { + end: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied'); + const fd = await request.formData(); + const id = parseInt(fd.get('id')?.toString() ?? '', 10); + if (isNaN(id)) return fail(400, { error: 'Invalid ID' }); + + await db.update(fosterAssignments) + .set({ isCurrent: false, endDate: new Date() }) + .where(eq(fosterAssignments.id, id)); + + await logAudit({ + userId: locals.user!.id, + action: 'end_foster_assignment', + entity: 'foster_assignment', + entityId: id + }); + + return { success: true }; + } +}; diff --git a/src/routes/admin/fosters/+page.svelte b/src/routes/admin/fosters/+page.svelte new file mode 100644 index 0000000..5e2c5d5 --- /dev/null +++ b/src/routes/admin/fosters/+page.svelte @@ -0,0 +1,98 @@ + + +Foster Assignments — Admin + +
    +
    +

    Foster Assignments

    + {active.length} active +
    + + {#if form?.error} +
    {form.error}
    + {/if} + + {#if active.length > 0} +

    Active

    +
    + + + + + + + + + + + {#each active as a} + + + + + + + {/each} + +
    PetFosterSinceActions
    + {a.petName ?? '—'} + +

    {a.fostererName ?? '—'}

    +

    {a.fostererEmail ?? ''}

    +
    {formatDate(a.startDate)} +
    { + submitting = `end-${a.id}`; + return async ({ update }) => { submitting = null; await update(); }; + }}> + + +
    +
    +
    + {/if} + + {#if past.length > 0} +
    + Past Assignments ({past.length}) +
    + + + + + + + + + + {#each past as a} + + + + + + {/each} + +
    PetFosterPeriod
    {a.petName ?? '—'}{a.fostererName ?? '—'}{formatDate(a.startDate)} – {formatDate(a.endDate)}
    +
    +
    + {/if} + + {#if data.assignments.length === 0} +
    +

    No foster assignments yet.

    +
    + {/if} +
    diff --git a/src/routes/admin/moderation/+page.server.ts b/src/routes/admin/moderation/+page.server.ts new file mode 100644 index 0000000..6d85d79 --- /dev/null +++ b/src/routes/admin/moderation/+page.server.ts @@ -0,0 +1,82 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { fosterSubmissions, pets, users } from '$lib/server/schema'; +import { eq, desc } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied'); + + const submissions = await db + .select({ + id: fosterSubmissions.id, + type: fosterSubmissions.type, + content: fosterSubmissions.content, + photoUrl: fosterSubmissions.photoUrl, + status: fosterSubmissions.status, + createdAt: fosterSubmissions.createdAt, + petName: pets.name, + petId: pets.id, + submittedByName: users.name, + submittedByEmail: users.email + }) + .from(fosterSubmissions) + .leftJoin(pets, eq(fosterSubmissions.petId, pets.id)) + .leftJoin(users, eq(fosterSubmissions.userId, users.id)) + .orderBy(desc(fosterSubmissions.createdAt)) + .limit(100); + + const pending = submissions.filter(s => s.status === 'pending'); + const reviewed = submissions.filter(s => s.status !== 'pending'); + + return { pending, reviewed }; +}; + +export const actions: Actions = { + approve: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied'); + const fd = await request.formData(); + const id = parseInt(fd.get('id')?.toString() ?? '', 10); + if (isNaN(id)) return fail(400, { error: 'Invalid ID' }); + + await db.update(fosterSubmissions).set({ + status: 'approved', + reviewedBy: locals.user!.id, + reviewedAt: new Date() + }).where(eq(fosterSubmissions.id, id)); + + await logAudit({ + userId: locals.user!.id, + action: 'approve_foster_submission', + entity: 'foster_submission', + entityId: id + }); + + return { success: true }; + }, + + reject: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied'); + const fd = await request.formData(); + const id = parseInt(fd.get('id')?.toString() ?? '', 10); + if (isNaN(id)) return fail(400, { error: 'Invalid ID' }); + + await db.update(fosterSubmissions).set({ + status: 'rejected', + reviewedBy: locals.user!.id, + reviewedAt: new Date() + }).where(eq(fosterSubmissions.id, id)); + + await logAudit({ + userId: locals.user!.id, + action: 'reject_foster_submission', + entity: 'foster_submission', + entityId: id + }); + + return { success: true }; + } +}; diff --git a/src/routes/admin/moderation/+page.svelte b/src/routes/admin/moderation/+page.svelte new file mode 100644 index 0000000..9ad2b71 --- /dev/null +++ b/src/routes/admin/moderation/+page.svelte @@ -0,0 +1,91 @@ + + +Moderation — Admin + +
    +
    +

    Foster Submission Moderation

    + {#if data.pending.length > 0} +

    {data.pending.length} pending review

    + {/if} +
    + + {#if data.pending.length === 0 && data.reviewed.length === 0} +
    +

    No submissions yet.

    +
    + {/if} + + {#if data.pending.length > 0} +

    Pending Review

    +
    + {#each data.pending as sub} +
    +
    +
    +
    + {sub.type} + {sub.petName ?? 'Unknown Pet'} + by {sub.submittedByName ?? sub.submittedByEmail ?? 'Unknown'} +
    + {#if sub.content} +

    {sub.content}

    + {/if} + {#if sub.photoUrl} + Submission + {/if} +

    {formatDate(sub.createdAt)}

    +
    +
    +
    { + submitting = `approve-${sub.id}`; + return async ({ update }) => { submitting = null; await update(); }; + }}> + + +
    +
    { + submitting = `reject-${sub.id}`; + return async ({ update }) => { submitting = null; await update(); }; + }}> + + +
    +
    +
    +
    + {/each} +
    + {/if} + + {#if data.reviewed.length > 0} +
    + Reviewed ({data.reviewed.length}) +
    + {#each data.reviewed as sub} +
    +
    + {sub.petName ?? '—'} + · + {sub.type} + · + {sub.submittedByName ?? '—'} +
    + + {sub.status} + +
    + {/each} +
    +
    + {/if} +
    diff --git a/src/routes/admin/system/+page.server.ts b/src/routes/admin/system/+page.server.ts new file mode 100644 index 0000000..5380a25 --- /dev/null +++ b/src/routes/admin/system/+page.server.ts @@ -0,0 +1,92 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { siteSettings, orgConfig, auditLog, users } from '$lib/server/schema'; +import { eq, desc } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'system')) error(403, 'Access denied'); + + const settings = await db.select().from(siteSettings).orderBy(siteSettings.key); + const configs = await db.select().from(orgConfig).orderBy(orgConfig.key); + + const recentAudit = await db + .select({ + id: auditLog.id, + action: auditLog.action, + entity: auditLog.entity, + entityId: auditLog.entityId, + diff: auditLog.diff, + createdAt: auditLog.createdAt, + userName: users.name, + userEmail: users.email + }) + .from(auditLog) + .leftJoin(users, eq(auditLog.userId, users.id)) + .orderBy(desc(auditLog.createdAt)) + .limit(50); + + return { settings, configs, recentAudit }; +}; + +export const actions: Actions = { + saveSetting: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'system')) error(403, 'Access denied'); + const fd = await request.formData(); + const key = fd.get('key')?.toString().trim() ?? ''; + const value = fd.get('value')?.toString() ?? ''; + + if (!key) return fail(400, { error: 'Key is required' }); + + const existing = await db.select({ id: siteSettings.id }).from(siteSettings).where(eq(siteSettings.key, key)).limit(1); + if (existing.length > 0) { + await db.update(siteSettings).set({ value }).where(eq(siteSettings.key, key)); + } else { + await db.insert(siteSettings).values({ key, value }); + } + + await logAudit({ + userId: locals.user!.id, + action: 'update_site_setting', + entity: 'site_setting', + diff: { key, value } + }); + + return { success: true }; + }, + + saveConfig: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'system')) error(403, 'Access denied'); + const fd = await request.formData(); + const key = fd.get('key')?.toString().trim() ?? ''; + const valueStr = fd.get('value')?.toString() ?? ''; + + if (!key) return fail(400, { error: 'Key is required' }); + + let value: unknown; + try { + value = JSON.parse(valueStr); + } catch { + value = valueStr; + } + + const existing = await db.select({ id: orgConfig.id }).from(orgConfig).where(eq(orgConfig.key, key)).limit(1); + if (existing.length > 0) { + await db.update(orgConfig).set({ value: value as Record }).where(eq(orgConfig.key, key)); + } else { + await db.insert(orgConfig).values({ key, value: value as Record }); + } + + await logAudit({ + userId: locals.user!.id, + action: 'update_org_config', + entity: 'org_config', + diff: { key } + }); + + return { success: true }; + } +}; diff --git a/src/routes/admin/system/+page.svelte b/src/routes/admin/system/+page.svelte new file mode 100644 index 0000000..4409933 --- /dev/null +++ b/src/routes/admin/system/+page.svelte @@ -0,0 +1,169 @@ + + +System Settings — Admin + +
    +
    +

    System Settings

    +
    + + {#if form?.error} +
    {form.error}
    + {/if} + + {#if showSuccess} +
    + + Settings saved. +
    + {/if} + +
    + +
    +

    Site Settings

    + +
    handleEnhance('new-setting')} class="mb-6 p-4 bg-gray-50 rounded-lg"> +

    Add / Update Setting

    +
    +
    + + +
    +
    + + +
    +
    + +
    + + {#if data.settings.length === 0} +

    No settings configured.

    + {:else} +
    + {#each data.settings as setting} +
    +
    + {setting.key} + +
    + {#if editingSetting === setting.key} +
    handleEnhance(`setting-${setting.key}`)}> + + + +
    + {:else} +

    {setting.value}

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

    Org Config

    + + {#if data.configs.length === 0} +

    No config entries.

    + {:else} +
    + {#each data.configs as cfg} +
    +
    + {cfg.key} + +
    + {#if editingConfig === cfg.key} +
    handleEnhance(`config-${cfg.key}`)}> + + + +
    + {:else} +
    {configDisplay(cfg.value)}
    + {/if} +
    + {/each} +
    + {/if} +
    +
    + + +
    +

    Recent Audit Log

    + {#if data.recentAudit.length === 0} +

    No audit entries.

    + {:else} +
    + + + + + + + + + + + {#each data.recentAudit as entry} + + + + + + + {/each} + +
    TimeUserActionEntity
    {formatDate(entry.createdAt)}{entry.userName ?? entry.userEmail ?? 'System'}{entry.action}{entry.entity ?? '—'}{entry.entityId ? ` #${entry.entityId}` : ''}
    +
    + {/if} +
    +
    diff --git a/src/routes/admin/traffic/+page.server.ts b/src/routes/admin/traffic/+page.server.ts new file mode 100644 index 0000000..7bd4039 --- /dev/null +++ b/src/routes/admin/traffic/+page.server.ts @@ -0,0 +1,51 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { pageViews, pets } from '$lib/server/schema'; +import { eq, gte, desc, count, and } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ locals, url }) => { + if (!hasPermission(locals.user!.role as Role, 'system')) error(403, 'Access denied'); + + const days = parseInt(url.searchParams.get('days') ?? '7', 10); + const since = new Date(); + since.setDate(since.getDate() - days); + + // Top paths + const topPaths = await db + .select({ + path: pageViews.path, + views: count() + }) + .from(pageViews) + .where(and(gte(pageViews.createdAt, since), eq(pageViews.isBot, false))) + .groupBy(pageViews.path) + .orderBy(desc(count())) + .limit(25); + + // Top referrers + const topReferrers = await db + .select({ + referrer: pageViews.referrer, + views: count() + }) + .from(pageViews) + .where(and(gte(pageViews.createdAt, since), eq(pageViews.isBot, false))) + .groupBy(pageViews.referrer) + .orderBy(desc(count())) + .limit(10); + + // Total and bot counts + const [totalRow] = await db.select({ total: count() }).from(pageViews).where(gte(pageViews.createdAt, since)); + const [botRow] = await db.select({ total: count() }).from(pageViews).where(and(gte(pageViews.createdAt, since), eq(pageViews.isBot, true))); + + return { + topPaths, + topReferrers: topReferrers.filter(r => r.referrer), + totalViews: totalRow?.total ?? 0, + botViews: botRow?.total ?? 0, + days + }; +}; diff --git a/src/routes/admin/traffic/+page.svelte b/src/routes/admin/traffic/+page.svelte new file mode 100644 index 0000000..ba276aa --- /dev/null +++ b/src/routes/admin/traffic/+page.svelte @@ -0,0 +1,74 @@ + + +Traffic — Admin + +
    +
    +

    Traffic

    + +
    + +
    +
    +

    Human Page Views

    +

    {(data.totalViews - data.botViews).toLocaleString()}

    +
    +
    +

    Bot Requests

    +

    {data.botViews.toLocaleString()}

    +
    +
    + +
    +
    +

    Top Pages

    + {#if data.topPaths.length === 0} +

    No data yet.

    + {:else} +
    + {#each data.topPaths as row} +
    + {row.path} + {row.views.toLocaleString()} +
    + {/each} +
    + {/if} +
    + +
    +

    Top Referrers

    + {#if data.topReferrers.length === 0} +

    No referrer data yet.

    + {:else} +
    + {#each data.topReferrers as row} +
    + {row.referrer} + {row.views.toLocaleString()} +
    + {/each} +
    + {/if} +
    +
    +
    From c6b7d4d4ebdef730531867dec01dcbd1d0d16a13 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:49:37 -0500 Subject: [PATCH 30/38] feat: add seed scripts and AI worker Adds scripts/seed.ts for fresh self-hosted installs, scripts/seed-tenant.ts for cloud provisioner use, and scripts/ai-worker.ts adapted from AHCR with improved theme prompts, name filtering, and WORKER_API_KEY guard. Installs tsx as a dev dependency. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 407 ++++++++++++++++++++++++++++++++++++++++- package.json | 5 +- scripts/ai-worker.ts | 139 ++++++++++++++ scripts/seed-tenant.ts | 52 ++++++ scripts/seed.ts | 63 +++++++ 5 files changed, 664 insertions(+), 2 deletions(-) create mode 100644 scripts/ai-worker.ts create mode 100644 scripts/seed-tenant.ts create mode 100644 scripts/seed.ts diff --git a/package-lock.json b/package-lock.json index 4f47c74..cbec35b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "stripe": "^21.0.0" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.50.2", @@ -25,8 +26,10 @@ "svelte": "^5.54.0", "svelte-check": "^4.4.2", "tailwindcss": "^4.2.2", + "tsx": "^4.21.0", "typescript": "^5.9.3", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.1.2" } }, "node_modules/@drizzle-team/brocli": { @@ -1268,6 +1271,22 @@ "node": ">= 10" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -2188,6 +2207,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -2195,6 +2225,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2249,6 +2286,129 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2272,6 +2432,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -2298,6 +2468,16 @@ "dev": true, "license": "MIT" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2331,6 +2511,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -3016,6 +3203,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -3083,6 +3277,16 @@ "dev": true, "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3654,6 +3858,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3674,6 +3885,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -3819,6 +4077,13 @@ "dev": true, "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -3880,6 +4145,20 @@ "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stripe": { "version": "21.0.0", "resolved": "https://registry.npmjs.org/stripe/-/stripe-21.0.0.tgz", @@ -3983,6 +4262,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4000,6 +4296,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -4152,6 +4458,105 @@ } } }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/package.json b/package.json index c8f9a9c..ef1ad7c 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.50.2", @@ -22,8 +23,10 @@ "svelte": "^5.54.0", "svelte-check": "^4.4.2", "tailwindcss": "^4.2.2", + "tsx": "^4.21.0", "typescript": "^5.9.3", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.1.2" }, "dependencies": { "@node-rs/argon2": "^2.0.2", diff --git a/scripts/ai-worker.ts b/scripts/ai-worker.ts new file mode 100644 index 0000000..aab3afb --- /dev/null +++ b/scripts/ai-worker.ts @@ -0,0 +1,139 @@ +/** + * FosterFlow AI Worker — runs on a machine with local GPU/Ollama access. + * Polls the FosterFlow API for pending AI jobs, processes them through local Ollama, + * and posts results back. + * + * Usage: + * PUBLIC_SITE_URL=https://yourrescue.fosterflow.app WORKER_API_KEY=your-key npx tsx scripts/ai-worker.ts + * + * Environment variables: + * PUBLIC_SITE_URL — FosterFlow base URL (default: http://localhost:5173) + * WORKER_API_KEY — Shared secret for authenticating with the FosterFlow API + * OLLAMA_URL — Ollama API URL (default: http://localhost:11434) + * POLL_INTERVAL — Milliseconds between polls (default: 10000) + * + * NOTE: Old completed/failed jobs should be cleaned up periodically. + * Consider a cron job: DELETE FROM ai_jobs WHERE status IN ('completed','failed') AND completed_at < NOW() - INTERVAL 7 DAY; + */ + +const API_URL = process.env.PUBLIC_SITE_URL || 'http://localhost:5173'; +const WORKER_KEY = process.env.WORKER_API_KEY || ''; +const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434'; +const POLL_INTERVAL = parseInt(process.env.POLL_INTERVAL || '10000', 10); + +if (!WORKER_KEY) { + console.error('ERROR: WORKER_API_KEY environment variable is required'); + process.exit(1); +} + +async function fetchNextJob() { + const res = await fetch(`${API_URL}/api/worker/jobs`, { + headers: { Authorization: `Bearer ${WORKER_KEY}` } + }); + if (!res.ok) return null; + return res.json(); +} + +async function reportResult(jobId: number, output: unknown) { + await fetch(`${API_URL}/api/worker/jobs/${jobId}`, { + method: 'POST', + headers: { Authorization: `Bearer ${WORKER_KEY}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'completed', output }) + }); +} + +async function reportError(jobId: number, error: string) { + await fetch(`${API_URL}/api/worker/jobs/${jobId}`, { + method: 'POST', + headers: { Authorization: `Bearer ${WORKER_KEY}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'failed', error }) + }); +} + +async function generateNames(input: { species: string; theme: string; count: number; existingNames?: string }) { + const themes: Record = { + food: 'food and cooking themed names (like Biscuit, Waffles, Chimichurri, Mochi, Pancake)', + disney: 'Disney character themed names (like Simba, Nala, Elsa, Olaf, Stitch)', + celebrity: 'celebrity or pop culture themed names (like Bowie, Dolly, Elvis, Beyonce)', + nature: 'nature themed names (like Willow, Storm, River, Daisy, Bear)', + classic: 'classic pet names (like Max, Bella, Charlie, Luna, Cooper)', + funny: 'funny and punny names (like Sir Barks-a-Lot, Bark Twain, Chewbarka, Purrlock Holmes)', + random: 'creative and unique names' + }; + + const themePrompt = themes[input.theme] || themes.random; + const count = input.count || 10; + + const prompt = `Generate exactly ${count} creative ${input.species || 'dog'} names. Theme: ${themePrompt}. + +IMPORTANT: These names are ALREADY TAKEN and must NOT be reused: ${input.existingNames || 'none'} + +Rules: +- Just the names, one per line +- No numbers, no explanations, no extra text +- Be creative and fun +- Mix single names and two-word names +- Do NOT repeat any name from the taken list above`; + + const res = await fetch(`${OLLAMA_URL}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gemma2:9b', + prompt, + stream: false, + options: { + temperature: 1.2, + top_p: 0.95, + num_predict: 200 + } + }) + }); + + if (!res.ok) throw new Error(`Ollama error: ${res.status}: ${await res.text()}`); + + const data = await res.json(); + const names = (data.response || '') + .split('\n') + .map((n: string) => n.replace(/^\d+[\.\)]\s*/, '').replace(/^[-*•]\s*/, '').trim()) + .filter((name: string) => name && name.length > 1 && name.length < 40 && !name.includes(':')) + .filter((name: string, index: number, self: string[]) => self.indexOf(name) === index) + .slice(0, count); + + return { names }; +} + +async function poll() { + console.log('FosterFlow AI Worker started'); + console.log(` Polling: ${API_URL} every ${POLL_INTERVAL}ms`); + console.log(` Ollama: ${OLLAMA_URL}`); + + // Run immediately on start, then on interval + const tick = async () => { + try { + const job = await fetchNextJob(); + if (job && job.id) { + console.log(`Processing job ${job.id} (${job.type})`); + try { + if (job.type === 'pet-names') { + const result = await generateNames(job.input); + await reportResult(job.id, result); + console.log(`Job ${job.id} completed`); + } else { + await reportError(job.id, `Unknown job type: ${job.type}`); + } + } catch (err: any) { + await reportError(job.id, err.message); + console.error(`Job ${job.id} failed:`, err.message); + } + } + } catch (err) { + // Connection error — will retry on next interval + } + }; + + tick(); + setInterval(tick, POLL_INTERVAL); +} + +poll(); diff --git a/scripts/seed-tenant.ts b/scripts/seed-tenant.ts new file mode 100644 index 0000000..55ef53f --- /dev/null +++ b/scripts/seed-tenant.ts @@ -0,0 +1,52 @@ +import { drizzle } from 'drizzle-orm/mysql2'; +import mysql from 'mysql2/promise'; +import { hash } from '@node-rs/argon2'; +import * as schema from '../src/lib/server/schema'; + +const args = process.argv.slice(2); +const flags: Record = {}; +for (let i = 0; i < args.length; i += 2) { + flags[args[i].replace('--', '')] = args[i + 1]; +} + +async function seedTenant() { + const { email, name, password, orgName } = flags; + if (!email || !name || !password || !orgName) { + console.error('Usage: npx tsx scripts/seed-tenant.ts --email X --name X --password X --orgName X'); + process.exit(1); + } + + const pool = mysql.createPool({ uri: process.env.DATABASE_URL }); + const db = drizzle(pool, { schema, mode: 'default' }); + + const passwordHash = await hash(password); + await db.insert(schema.users).values({ + email, + passwordHash, + name, + role: 'sysadmin', + active: true, + mustChangePassword: true + }); + + const orgConfigDefaults = [ + { key: 'branding', value: { primaryColor: '#0d9488', primaryColorLight: '#14b8a6', primaryColorDark: '#0f766e', accentColor: '#f59e0b', accentColorLight: '#fbbf24', accentColorDark: '#d97706', logoUrl: '' } }, + { key: 'org_info', value: { name: orgName, tagline: '', city: '', state: '', phone: '', email, facebookUrl: '', instagramUrl: '' } }, + { key: 'locale', value: { language: 'en', currency: 'USD', dateFormat: 'MM/DD/YYYY' } }, + { key: 'applications', value: { adoptionEnabled: true, fosterEnabled: true, volunteerEnabled: true, surrenderEnabled: true, adoptionFee: 250 } }, + { key: 'setup_complete', value: false } + ]; + for (const row of orgConfigDefaults) { + await db.insert(schema.orgConfig).values({ key: row.key, value: row.value }); + } + + const intakeSources = ['Owner Surrender', 'Stray', 'Transfer', 'Born in Care', 'Other']; + for (const sourceName of intakeSources) { + await db.insert(schema.intakeSources).values({ name: sourceName }); + } + + console.log(`Tenant seeded: ${email} / [password provided] for ${orgName}`); + await pool.end(); +} + +seedTenant().catch(console.error); diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..a501d32 --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,63 @@ +import { drizzle } from 'drizzle-orm/mysql2'; +import mysql from 'mysql2/promise'; +import { hash } from '@node-rs/argon2'; +import * as schema from '../src/lib/server/schema'; + +async function seed() { + const pool = mysql.createPool({ uri: process.env.DATABASE_URL }); + const db = drizzle(pool, { schema, mode: 'default' }); + + console.log('Seeding FosterFlow database...'); + + // 1. Create admin user + const passwordHash = await hash('changeme'); + await db.insert(schema.users).values({ + email: 'admin@example.com', + passwordHash, + name: 'Admin', + role: 'sysadmin', + active: true, + mustChangePassword: true + }); + console.log('Created admin user (admin@example.com / changeme)'); + + // 2. Default org config + const orgConfigDefaults = [ + { key: 'branding', value: { primaryColor: '#0d9488', primaryColorLight: '#14b8a6', primaryColorDark: '#0f766e', accentColor: '#f59e0b', accentColorLight: '#fbbf24', accentColorDark: '#d97706', logoUrl: '' } }, + { key: 'org_info', value: { name: 'My Rescue', tagline: '', city: '', state: '', phone: '', email: '', facebookUrl: '', instagramUrl: '' } }, + { key: 'locale', value: { language: 'en', currency: 'USD', dateFormat: 'MM/DD/YYYY' } }, + { key: 'applications', value: { adoptionEnabled: true, fosterEnabled: true, volunteerEnabled: true, surrenderEnabled: true, adoptionFee: 250 } }, + { key: 'setup_complete', value: false } + ]; + for (const row of orgConfigDefaults) { + await db.insert(schema.orgConfig).values({ key: row.key, value: row.value }); + } + console.log('Created default org config'); + + // 3. Default intake sources + const intakeSources = ['Owner Surrender', 'Stray', 'Transfer', 'Born in Care', 'Other']; + for (const name of intakeSources) { + await db.insert(schema.intakeSources).values({ name }); + } + console.log('Created default intake sources'); + + // 4. Default content blocks (placeholder) + const contentDefaults = [ + { page: 'home', sectionKey: 'hero', content: { title: 'Find Your Forever Friend', subtitle: 'We rescue, rehabilitate, and rehome animals in need.' } }, + { page: 'about', sectionKey: 'intro', content: { text: 'Welcome to our rescue! We are dedicated to saving animals in need.' } }, + { page: 'adoption-process', sectionKey: 'intro', content: { text: 'Our adoption process is designed to find the best match for both you and our animals.' } }, + { page: 'become-a-foster', sectionKey: 'intro', content: { text: 'Fostering saves lives! Learn how you can help by opening your home to an animal in need.' } }, + { page: 'how-to-help', sectionKey: 'intro', content: { text: 'There are many ways you can help our rescue mission.' } }, + { page: 'volunteer', sectionKey: 'intro', content: { text: 'Volunteers are the backbone of our organization.' } }, + { page: 'why-adopt', sectionKey: 'intro', content: { text: 'Adopting a pet changes two lives — yours and theirs.' } } + ]; + for (const block of contentDefaults) { + await db.insert(schema.contentBlocks).values(block); + } + console.log('Created default content blocks'); + + console.log('\nDone! Log in at /login with admin@example.com / changeme'); + await pool.end(); +} + +seed().catch(console.error); From b0bd8277a83af35f241754ed7d83e49d7f4d1d09 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:49:42 -0500 Subject: [PATCH 31/38] feat: add Docker deployment (Dockerfile, compose, Caddyfile) Multi-stage Dockerfile builds SvelteKit then runs a lean production image. docker-compose.yml wires up MariaDB with health checks, the app, and Caddy for TLS termination. Caddyfile uses SITE_DOMAIN env var with localhost fallback. Co-Authored-By: Claude Sonnet 4.6 --- Caddyfile | 3 +++ Dockerfile | 18 ++++++++++++++++ docker-compose.yml | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 Caddyfile create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..4679d77 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,3 @@ +{$SITE_DOMAIN:localhost} { + reverse_proxy app:3000 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6d727b7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +COPY --from=builder /app/build ./build +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/drizzle ./drizzle +COPY --from=builder /app/scripts ./scripts + +ENV NODE_ENV=production +EXPOSE 3000 +CMD ["node", "build"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5175749 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +services: + db: + image: mariadb:11 + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-fosterflow} + MYSQL_DATABASE: fosterflow + MYSQL_USER: fosterflow + MYSQL_PASSWORD: ${DB_PASSWORD:-fosterflow} + volumes: + - db_data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 + + app: + build: . + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: mysql://fosterflow:${DB_PASSWORD:-fosterflow}@db:3306/fosterflow + SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production} + PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-http://localhost:3000} + UPLOAD_DIR: /app/uploads + PORT: 3000 + env_file: + - .env + ports: + - "3000:3000" + volumes: + - uploads:/app/uploads + + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - caddy_data:/data + - caddy_config:/config + +volumes: + db_data: + uploads: + caddy_data: + caddy_config: From c129eecb158e105bf9da844ec3e256e2580ec8cc Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:49:46 -0500 Subject: [PATCH 32/38] feat: add Playwright E2E test config and basic tests Adds playwright.config.ts pointing at tests/e2e/ with a 15s timeout and dev server reuse. Stubs two test files: public-pages (home, pets, login load checks) and admin-guard (unauthenticated /admin and /foster redirect to /login). Installs @playwright/test as a dev dependency. Co-Authored-By: Claude Sonnet 4.6 --- playwright.config.ts | 14 ++++++++++++++ tests/e2e/admin-guard.test.ts | 11 +++++++++++ tests/e2e/public-pages.test.ts | 16 ++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/admin-guard.test.ts create mode 100644 tests/e2e/public-pages.test.ts diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..02deef3 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: 'tests/e2e', + timeout: 15000, + webServer: { + command: 'npm run dev', + port: 5173, + reuseExistingServer: true + }, + use: { + baseURL: 'http://localhost:5173' + } +}); diff --git a/tests/e2e/admin-guard.test.ts b/tests/e2e/admin-guard.test.ts new file mode 100644 index 0000000..d6f5dfe --- /dev/null +++ b/tests/e2e/admin-guard.test.ts @@ -0,0 +1,11 @@ +import { test, expect } from '@playwright/test'; + +test('admin redirects to login when not authenticated', async ({ page }) => { + await page.goto('/admin'); + await expect(page).toHaveURL(/\/login/); +}); + +test('foster redirects to login when not authenticated', async ({ page }) => { + await page.goto('/foster'); + await expect(page).toHaveURL(/\/login/); +}); diff --git a/tests/e2e/public-pages.test.ts b/tests/e2e/public-pages.test.ts new file mode 100644 index 0000000..41e0880 --- /dev/null +++ b/tests/e2e/public-pages.test.ts @@ -0,0 +1,16 @@ +import { test, expect } from '@playwright/test'; + +test('home page loads', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/./); +}); + +test('pets page loads', async ({ page }) => { + await page.goto('/pets'); + await expect(page.locator('h1')).toBeVisible(); +}); + +test('login page loads', async ({ page }) => { + await page.goto('/login'); + await expect(page.locator('form')).toBeVisible(); +}); From 19665c4dd600e4bf5c7b80f2818dab8ae307bdc9 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:49:50 -0500 Subject: [PATCH 33/38] docs: update README for FosterFlow branding Replaces PawPrint branding with FosterFlow throughout. Adds Docker quick start (docker compose up + seed commands), manual quick start, and a self-hosted vs FosterFlow Cloud comparison table. Removes emoji from feature list for cleaner rendering. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 77 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 51 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 645a0bb..c98bfd6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# 🐾 paw❤️print +# FosterFlow -**Open-source rescue management platform — self-host free or use PawPrint Cloud.** +**Open-source rescue management platform — self-host free or use FosterFlow Cloud.** Modern, fast, mobile-first website and management system built for animal rescues. Everything a foster-based rescue needs, nothing it doesn't. @@ -8,16 +8,16 @@ Modern, fast, mobile-first website and management system built for animal rescue ## What It Does -- 🐕 **Pet Management** — Profiles, photos, medical records, foster assignments, status tracking -- 📋 **Applications** — Adoption, foster, volunteer, surrender forms with configurable questions -- 🏠 **Foster Portal** — Dedicated dashboard for fosters to submit updates, photos, supply requests -- 💰 **Donations & Expenses** — Stripe payments, multi-source tracking, financial reports -- 📅 **Events** — Listings with optional Facebook auto-posting -- ✏️ **Content CMS** — Editable page sections, newsletter, happy tails / success stories -- 📊 **Analytics** — Human traffic, bot detection, honeypot, AI job tracking -- 🔐 **Role-Based Access** — 9 roles from sysadmin to foster with granular permissions -- 🤖 **Bot Protection** — Dynamic robots.txt, honeypot tarpit, managed blocklist/whitelist -- 🧠 **AI Features** — Pet name generator (Ollama), expandable job queue +- **Pet Management** — Profiles, photos, medical records, foster assignments, status tracking +- **Applications** — Adoption, foster, volunteer, surrender forms with configurable questions +- **Foster Portal** — Dedicated dashboard for fosters to submit updates, photos, supply requests +- **Donations & Expenses** — Stripe payments, multi-source tracking, financial reports +- **Events** — Listings with optional Facebook auto-posting +- **Content CMS** — Editable page sections, newsletter, happy tails / success stories +- **Analytics** — Human traffic, bot detection, honeypot, AI job tracking +- **Role-Based Access** — 9 roles from sysadmin to foster with granular permissions +- **Bot Protection** — Dynamic robots.txt, honeypot tarpit, managed blocklist/whitelist +- **AI Features** — Pet name generator (Ollama), expandable job queue ## Tech Stack @@ -31,34 +31,59 @@ Modern, fast, mobile-first website and management system built for animal rescue ## SaaS Architecture -PawPrint supports multi-tenant deployment: +FosterFlow supports multi-tenant deployment: - **Self-hosted** — One rescue, one server, free forever -- **PawPrint Cloud** — Automated onboarding, subdomain provisioning, managed hosting +- **FosterFlow Cloud** — Automated onboarding, subdomain provisioning, managed hosting See [docs/SAAS.md](docs/SAAS.md) for the multi-tenant architecture. See [docs/ONBOARDING.md](docs/ONBOARDING.md) for automated provisioning. -## Quick Start (Self-Hosted) +## Quick Start (Docker) + +The fastest way to get running: ```bash -git clone https://github.com/yourusername/pawprint -cd pawprint +git clone https://github.com/yourusername/fosterflow +cd fosterflow cp .env.example .env -# Edit .env with your database URL, SMTP, etc. +# Edit .env — set SESSION_SECRET and SITE_DOMAIN at minimum +docker compose up +``` + +On first run, migrate and seed the database: + +```bash +docker compose exec app npx drizzle-kit push +docker compose exec app npx tsx scripts/seed.ts +``` + +Then open `http://localhost:3000` and log in with `admin@example.com` / `changeme`. + +## Quick Start (Manual) + +```bash +git clone https://github.com/yourusername/fosterflow +cd fosterflow +cp .env.example .env +# Edit .env with your database URL, SMTP settings, etc. npm install npx drizzle-kit push -npm run seed +npx tsx scripts/seed.ts npm run dev ``` -## PawPrint Cloud Pricing +## Self-Hosted vs FosterFlow Cloud -| Plan | Price | What You Get | -|------|-------|-------------| -| **Self-Hosted** | Free | Full source, you manage everything | -| **PawPrint Cloud** | $19/mo | Managed hosting, subdomain, SSL, backups, updates | -| **Setup Assist** | $50 one-time | We migrate your data from your old site | +| | Self-Hosted | FosterFlow Cloud | +|---|---|---| +| **Cost** | Free | $19/mo | +| **Hosting** | You manage | Fully managed | +| **SSL** | You configure | Automatic | +| **Backups** | You configure | Daily, included | +| **Updates** | Manual | Automatic | +| **Subdomain** | Your domain | yourrescue.fosterflow.app | +| **Setup assist** | Community support | $50 one-time migration | ## License @@ -66,4 +91,4 @@ MIT — use it, fork it, save some dogs. --- -Built with ❤️ by [JRei](https://reiners.dev) — started as Almost Home Canine Rescue's website, now open for every rescue. +Built with love by [JRei](https://reiners.dev) — started as Almost Home Canine Rescue's website, now open for every rescue. From 6e3ca2e496bad78c1657e50284328940b017734c Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:53:57 -0500 Subject: [PATCH 34/38] Add foster portal with dashboard, submit update, and request supplies Fosters can view their current animals (with photos), submit updates (note/photo/update types with optional photo upload), and request supplies with urgency levels. Staff is notified via email on each submission. Layout uses orgConfig branding and CSS custom properties. Co-Authored-By: Claude Sonnet 4.6 --- src/routes/foster/+layout.server.ts | 40 ++++ src/routes/foster/+layout.svelte | 91 +++++++++ src/routes/foster/+page.server.ts | 70 +++++++ src/routes/foster/+page.svelte | 159 +++++++++++++++ src/routes/foster/submit/+page.server.ts | 111 +++++++++++ src/routes/foster/submit/+page.svelte | 142 ++++++++++++++ src/routes/foster/supplies/+page.server.ts | 110 +++++++++++ src/routes/foster/supplies/+page.svelte | 214 +++++++++++++++++++++ 8 files changed, 937 insertions(+) create mode 100644 src/routes/foster/+layout.server.ts create mode 100644 src/routes/foster/+layout.svelte create mode 100644 src/routes/foster/+page.server.ts create mode 100644 src/routes/foster/+page.svelte create mode 100644 src/routes/foster/submit/+page.server.ts create mode 100644 src/routes/foster/submit/+page.svelte create mode 100644 src/routes/foster/supplies/+page.server.ts create mode 100644 src/routes/foster/supplies/+page.svelte diff --git a/src/routes/foster/+layout.server.ts b/src/routes/foster/+layout.server.ts new file mode 100644 index 0000000..82a7ffd --- /dev/null +++ b/src/routes/foster/+layout.server.ts @@ -0,0 +1,40 @@ +import type { LayoutServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { fosterAssignments, pets, petPhotos } from '$lib/server/schema'; +import { eq, and } from 'drizzle-orm'; + +export const load: LayoutServerLoad = async ({ locals }) => { + if (!locals.user) { + throw redirect(303, '/login'); + } + + if (locals.user.role !== 'foster') { + throw redirect(303, locals.user.role === 'sysadmin' || locals.user.role === 'director' ? '/admin' : '/login'); + } + + const userId = locals.user.id; + + // Load current foster assignments with pet details for sidebar/context + const assignments = await db + .select({ + assignmentId: fosterAssignments.id, + petId: pets.id, + petName: pets.name, + petSpecies: pets.species, + petBreed: pets.breed, + petAge: pets.age, + petStatus: pets.status, + photoUrl: petPhotos.url + }) + .from(fosterAssignments) + .innerJoin(pets, eq(fosterAssignments.petId, pets.id)) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where(and(eq(fosterAssignments.userId, userId), eq(fosterAssignments.isCurrent, true))); + + return { + user: locals.user, + orgConfig: locals.orgConfig, + assignments + }; +}; diff --git a/src/routes/foster/+layout.svelte b/src/routes/foster/+layout.svelte new file mode 100644 index 0000000..ce96b66 --- /dev/null +++ b/src/routes/foster/+layout.svelte @@ -0,0 +1,91 @@ + + + + {@html ``} + + +
    + +
    +
    + {#if data.orgConfig.branding.logoUrl} + {orgName} + {/if} +

    {orgName} Foster Portal

    +
    + +
    + +
    + + + + + {#if sidebarOpen} +
    sidebarOpen = false} + role="button" + tabindex="-1" + onkeydown={() => {}} + >
    + {/if} + + +
    + {@render children()} +
    +
    +
    diff --git a/src/routes/foster/+page.server.ts b/src/routes/foster/+page.server.ts new file mode 100644 index 0000000..e381097 --- /dev/null +++ b/src/routes/foster/+page.server.ts @@ -0,0 +1,70 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { fosterAssignments, fosterSubmissions, supplyRequests, pets, petPhotos } from '$lib/server/schema'; +import { eq, and, desc, count } from 'drizzle-orm'; + +export const load: PageServerLoad = async ({ locals }) => { + const userId = locals.user!.id; + + // Load current foster assignments with pet info and primary photo + const assignments = await db + .select({ + assignmentId: fosterAssignments.id, + startDate: fosterAssignments.startDate, + notes: fosterAssignments.notes, + petId: pets.id, + petName: pets.name, + petBreed: pets.breed, + petAge: pets.age, + petStatus: pets.status, + petSpecies: pets.species, + photoUrl: petPhotos.url + }) + .from(fosterAssignments) + .innerJoin(pets, eq(fosterAssignments.petId, pets.id)) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where(and(eq(fosterAssignments.userId, userId), eq(fosterAssignments.isCurrent, true))); + + // Recent submissions (last 10) + const recentSubmissions = await db + .select({ + id: fosterSubmissions.id, + type: fosterSubmissions.type, + content: fosterSubmissions.content, + status: fosterSubmissions.status, + createdAt: fosterSubmissions.createdAt, + petName: pets.name + }) + .from(fosterSubmissions) + .innerJoin(pets, eq(fosterSubmissions.petId, pets.id)) + .where(eq(fosterSubmissions.userId, userId)) + .orderBy(desc(fosterSubmissions.createdAt)) + .limit(10); + + // Pending supply requests + const pendingRequests = await db + .select({ + id: supplyRequests.id, + items: supplyRequests.items, + urgency: supplyRequests.urgency, + status: supplyRequests.status, + createdAt: supplyRequests.createdAt + }) + .from(supplyRequests) + .where(and(eq(supplyRequests.userId, userId), eq(supplyRequests.status, 'pending'))) + .orderBy(desc(supplyRequests.createdAt)); + + // Count pending submissions + const [pendingSubRow] = await db + .select({ total: count() }) + .from(fosterSubmissions) + .where(and(eq(fosterSubmissions.userId, userId), eq(fosterSubmissions.status, 'pending'))); + const pendingSubmissionCount = pendingSubRow?.total ?? 0; + + return { + assignments, + recentSubmissions, + pendingRequests, + pendingSubmissionCount + }; +}; diff --git a/src/routes/foster/+page.svelte b/src/routes/foster/+page.svelte new file mode 100644 index 0000000..45539b2 --- /dev/null +++ b/src/routes/foster/+page.svelte @@ -0,0 +1,159 @@ + + + + Foster Dashboard — {orgName} + + +

    Welcome, {data.user.name}

    +

    Here is an overview of your foster animals and activity.

    + + +
    +
    +

    Active Fosters

    +

    {data.assignments.length}

    +
    +
    +

    Pending Submissions

    +

    {data.pendingSubmissionCount}

    +
    +
    +

    Pending Supply Requests

    +

    {data.pendingRequests.length}

    +
    +
    + + +
    +

    My Animals

    + {#if data.assignments.length > 0} + + {/if} +
    + +{#if data.assignments.length === 0} +
    +

    No animals currently assigned to you.

    +

    Your foster coordinator will assign animals when they are ready.

    +
    +{:else} +
    + {#each data.assignments as a} +
    + {#if a.photoUrl} + {a.petName} + {:else} +
    + + + +
    + {/if} +
    +

    {a.petName}

    +

    + {a.petBreed ?? 'Unknown breed'} + {#if a.petAge} + · {a.petAge} + {/if} +

    +

    In foster since {formatDate(a.startDate)}

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

    Recent Submissions

    +
    + {#if data.recentSubmissions.length === 0} +
    + No recent submissions. + {#if data.assignments.length > 0} + + Submit your first update + + {/if} +
    + {:else} +
      + {#each data.recentSubmissions as sub} +
    • + + {sub.status} + +
      +

      + {typeLabel(sub.type)} + for {sub.petName} + {#if sub.content} + — {sub.content.length > 60 ? sub.content.slice(0, 60) + '...' : sub.content} + {/if} +

      +
      + {formatDate(sub.createdAt)} +
    • + {/each} +
    + {/if} +
    diff --git a/src/routes/foster/submit/+page.server.ts b/src/routes/foster/submit/+page.server.ts new file mode 100644 index 0000000..e46c815 --- /dev/null +++ b/src/routes/foster/submit/+page.server.ts @@ -0,0 +1,111 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { fosterSubmissions, fosterAssignments, pets } from '$lib/server/schema'; +import { eq, and } from 'drizzle-orm'; +import { fail } from '@sveltejs/kit'; +import { saveUpload } from '$lib/server/upload'; +import { getOrgConfig } from '$lib/server/config'; +import { sendFosterSubmissionNotification } from '$lib/server/email'; + +export const load: PageServerLoad = async ({ locals, url }) => { + const userId = locals.user!.id; + const preselectedPetId = url.searchParams.get('petId') ? parseInt(url.searchParams.get('petId')!) : null; + + const assignments = await db + .select({ + petId: pets.id, + petName: pets.name + }) + .from(fosterAssignments) + .innerJoin(pets, eq(fosterAssignments.petId, pets.id)) + .where(and(eq(fosterAssignments.userId, userId), eq(fosterAssignments.isCurrent, true))); + + return { + assignments, + preselectedPetId + }; +}; + +export const actions: Actions = { + default: async ({ request, locals }) => { + const userId = locals.user!.id; + const fd = await request.formData(); + + const petId = parseInt(fd.get('petId')?.toString() ?? '0', 10); + const type = fd.get('type')?.toString() ?? 'update'; + const content = fd.get('content')?.toString().trim() || null; + const photoFile = fd.get('photo') as File | null; + + if (!petId) { + return fail(400, { error: 'Please select a pet.' }); + } + + if (!['note', 'photo', 'update'].includes(type)) { + return fail(400, { error: 'Invalid submission type.' }); + } + + if (!content && !photoFile?.size) { + return fail(400, { error: 'Please add a note or upload a photo.' }); + } + + // Verify pet is assigned to this foster + const [assignment] = await db + .select({ id: fosterAssignments.id }) + .from(fosterAssignments) + .where( + and( + eq(fosterAssignments.petId, petId), + eq(fosterAssignments.userId, userId), + eq(fosterAssignments.isCurrent, true) + ) + ); + + if (!assignment) { + return fail(403, { error: 'This pet is not assigned to you.' }); + } + + // Handle photo upload + let photoUrl: string | null = null; + if (photoFile && photoFile.size > 0) { + try { + const uploaded = await saveUpload(photoFile, 'foster-submissions'); + photoUrl = uploaded.url; + } catch (err) { + return fail(400, { error: err instanceof Error ? err.message : 'Photo upload failed.' }); + } + } + + // Get pet name for notification + const [pet] = await db + .select({ name: pets.name }) + .from(pets) + .where(eq(pets.id, petId)); + + await db.insert(fosterSubmissions).values({ + userId, + petId, + type: type as 'note' | 'photo' | 'update', + content, + photoUrl, + status: 'pending' + }); + + // Send notification email + try { + const config = await getOrgConfig(); + if (config.orgInfo.email) { + await sendFosterSubmissionNotification( + config.orgInfo.name, + config.orgInfo.email, + locals.user!.name, + pet?.name ?? 'Unknown', + type + ); + } + } catch { + // Email notification not critical + } + + return { success: true }; + } +}; diff --git a/src/routes/foster/submit/+page.svelte b/src/routes/foster/submit/+page.svelte new file mode 100644 index 0000000..cc47626 --- /dev/null +++ b/src/routes/foster/submit/+page.svelte @@ -0,0 +1,142 @@ + + + + Submit Update — {orgName} Foster Portal + + +← Back to Dashboard + +

    Submit an Update

    + +{#if showSuccess} +
    + + + + Your update has been submitted and is pending review. +
    +{/if} + +{#if form?.error} +
    + {form.error} +
    +{/if} + +
    + {#if data.assignments.length === 0} +
    +

    No animals are currently assigned to you.

    +

    Contact your foster coordinator to be assigned a pet.

    +
    + {:else} +
    +
    + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +

    JPG, PNG, or WebP. Max 10MB.

    +
    +
    + +
    + +
    +
    + {/if} +
    diff --git a/src/routes/foster/supplies/+page.server.ts b/src/routes/foster/supplies/+page.server.ts new file mode 100644 index 0000000..3cf313f --- /dev/null +++ b/src/routes/foster/supplies/+page.server.ts @@ -0,0 +1,110 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { supplyRequests, fosterAssignments, pets } from '$lib/server/schema'; +import { eq, and, desc } from 'drizzle-orm'; +import { fail } from '@sveltejs/kit'; +import { getOrgConfig } from '$lib/server/config'; +import { sendSupplyRequestNotification } from '$lib/server/email'; + +export const load: PageServerLoad = async ({ locals, url }) => { + const userId = locals.user!.id; + const preselectedPetId = url.searchParams.get('petId') ? parseInt(url.searchParams.get('petId')!) : null; + + // All supply requests for this user + const requests = await db + .select({ + id: supplyRequests.id, + items: supplyRequests.items, + urgency: supplyRequests.urgency, + notes: supplyRequests.notes, + status: supplyRequests.status, + createdAt: supplyRequests.createdAt, + petId: supplyRequests.petId, + petName: pets.name + }) + .from(supplyRequests) + .leftJoin(pets, eq(supplyRequests.petId, pets.id)) + .where(eq(supplyRequests.userId, userId)) + .orderBy(desc(supplyRequests.createdAt)); + + // Current foster assignments for pet dropdown + const assignments = await db + .select({ + petId: pets.id, + petName: pets.name + }) + .from(fosterAssignments) + .innerJoin(pets, eq(fosterAssignments.petId, pets.id)) + .where(and(eq(fosterAssignments.userId, userId), eq(fosterAssignments.isCurrent, true))); + + return { + requests, + assignments, + preselectedPetId + }; +}; + +export const actions: Actions = { + create: async ({ request, locals }) => { + const userId = locals.user!.id; + const fd = await request.formData(); + + const petId = fd.get('petId')?.toString() ? parseInt(fd.get('petId')!.toString(), 10) : null; + const items = fd.get('items')?.toString().trim() ?? ''; + const urgency = fd.get('urgency')?.toString() ?? 'medium'; + const notes = fd.get('notes')?.toString().trim() || null; + + if (!items) { + return fail(400, { error: 'Please list the supplies you need.' }); + } + + if (!['low', 'medium', 'high'].includes(urgency)) { + return fail(400, { error: 'Invalid urgency level.' }); + } + + // If petId provided, verify pet is assigned to this foster + if (petId) { + const [assignment] = await db + .select({ id: fosterAssignments.id }) + .from(fosterAssignments) + .where( + and( + eq(fosterAssignments.petId, petId), + eq(fosterAssignments.userId, userId), + eq(fosterAssignments.isCurrent, true) + ) + ); + + if (!assignment) { + return fail(403, { error: 'This pet is not assigned to you.' }); + } + } + + await db.insert(supplyRequests).values({ + userId, + petId: petId || null, + items, + urgency: urgency as 'low' | 'medium' | 'high', + notes, + status: 'pending' + }); + + // Send notification email + try { + const config = await getOrgConfig(); + if (config.orgInfo.email) { + await sendSupplyRequestNotification( + config.orgInfo.name, + config.orgInfo.email, + locals.user!.name, + items, + urgency + ); + } + } catch { + // Email notification not critical + } + + return { success: true }; + } +}; diff --git a/src/routes/foster/supplies/+page.svelte b/src/routes/foster/supplies/+page.svelte new file mode 100644 index 0000000..cbe0e23 --- /dev/null +++ b/src/routes/foster/supplies/+page.svelte @@ -0,0 +1,214 @@ + + + + Request Supplies — {orgName} Foster Portal + + +← Back to Dashboard + +

    Request Supplies

    + +{#if showSuccess} +
    + + + + Supply request submitted. Your coordinator will be notified. +
    +{/if} + +{#if form?.error} +
    + {form.error} +
    +{/if} + + +
    +

    New Request

    + +
    +
    + +
    + + +

    List everything you need, one item per line or separated by commas.

    +
    + + + {#if data.assignments.length > 0} +
    + + +
    + {/if} + + +
    + +
    + + + +
    +
    + + +
    + + +
    +
    + +
    + +
    +
    +
    + + +

    Your Requests

    +
    + {#if data.requests.length === 0} +
    No supply requests yet.
    + {:else} + +
      + {#each data.requests as req} +
    • +
      + {req.items.length > 50 ? req.items.slice(0, 50) + '...' : req.items} + {req.status} +
      +
      + {#if req.petName} + {req.petName} + · + {/if} + {req.urgency} + · + {formatDate(req.createdAt)} +
      +
    • + {/each} +
    + + + + {/if} +
    From 8e24f5637df95ccf0450cc5fb2e49fee1643c128 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:54:02 -0500 Subject: [PATCH 35/38] Add 5-step setup wizard for initial org configuration Accessible only by sysadmin when setupComplete is false. Steps cover org info, branding (color pickers + logo upload), application form toggles, Stripe integration keys, and a link to CSV import. Each step saves to org_config via upsert and calls invalidateConfigCache(). Completing the wizard sets setup_complete = true and redirects to /admin. Co-Authored-By: Claude Sonnet 4.6 --- src/routes/setup/+page.server.ts | 186 ++++++++++++ src/routes/setup/+page.svelte | 488 +++++++++++++++++++++++++++++++ 2 files changed, 674 insertions(+) create mode 100644 src/routes/setup/+page.server.ts create mode 100644 src/routes/setup/+page.svelte diff --git a/src/routes/setup/+page.server.ts b/src/routes/setup/+page.server.ts new file mode 100644 index 0000000..e4132aa --- /dev/null +++ b/src/routes/setup/+page.server.ts @@ -0,0 +1,186 @@ +import type { PageServerLoad, Actions } from './$types'; +import { redirect, fail, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { orgConfig, siteSettings } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { invalidateConfigCache } from '$lib/server/config'; +import { saveUpload } from '$lib/server/upload'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals, url }) => { + if (!locals.user) { + throw redirect(303, '/login'); + } + + if (locals.user.role !== 'sysadmin') { + throw error(403, 'Setup wizard is only accessible by system administrators.'); + } + + // If setup is already complete, redirect to admin unless explicitly revisiting + if (locals.orgConfig.setupComplete && !url.searchParams.has('revisit')) { + throw redirect(303, '/admin'); + } + + const step = parseInt(url.searchParams.get('step') ?? '1', 10) || 1; + + return { + step: Math.min(Math.max(step, 1), 5), + orgConfig: locals.orgConfig + }; +}; + +async function upsertOrgConfig(key: string, value: unknown): Promise { + const existing = await db.select({ id: orgConfig.id }).from(orgConfig).where(eq(orgConfig.key, key)).limit(1); + if (existing.length > 0) { + await db.update(orgConfig).set({ value: value as Record }).where(eq(orgConfig.key, key)); + } else { + await db.insert(orgConfig).values({ key, value: value as Record }); + } +} + +async function upsertSiteSetting(key: string, value: string): Promise { + const existing = await db.select({ id: siteSettings.id }).from(siteSettings).where(eq(siteSettings.key, key)).limit(1); + if (existing.length > 0) { + await db.update(siteSettings).set({ value }).where(eq(siteSettings.key, key)); + } else { + await db.insert(siteSettings).values({ key, value }); + } +} + +export const actions: Actions = { + saveStep1: async ({ request, locals }) => { + if (locals.user?.role !== 'sysadmin') return fail(403, { error: 'Access denied.' }); + + const fd = await request.formData(); + + const orgInfo = { + name: fd.get('name')?.toString().trim() ?? '', + tagline: fd.get('tagline')?.toString().trim() ?? '', + city: fd.get('city')?.toString().trim() ?? '', + state: fd.get('state')?.toString().trim() ?? '', + phone: fd.get('phone')?.toString().trim() ?? '', + email: fd.get('email')?.toString().trim() ?? '', + facebookUrl: fd.get('facebookUrl')?.toString().trim() ?? '', + instagramUrl: fd.get('instagramUrl')?.toString().trim() ?? '' + }; + + if (!orgInfo.name) { + return fail(400, { error: 'Organization name is required.', step: 1 }); + } + + await upsertOrgConfig('org_info', orgInfo); + invalidateConfigCache(); + + await logAudit({ + userId: locals.user!.id, + action: 'setup_step1', + entity: 'org_config', + diff: { key: 'org_info' } + }); + + return { success: true, nextStep: 2 }; + }, + + saveStep2: async ({ request, locals }) => { + if (locals.user?.role !== 'sysadmin') return fail(403, { error: 'Access denied.' }); + + const fd = await request.formData(); + + const branding: Record = { + primaryColor: fd.get('primaryColor')?.toString() ?? '#0d9488', + primaryColorLight: fd.get('primaryColorLight')?.toString() ?? '#14b8a6', + primaryColorDark: fd.get('primaryColorDark')?.toString() ?? '#0f766e', + accentColor: fd.get('accentColor')?.toString() ?? '#f59e0b', + accentColorLight: fd.get('accentColorLight')?.toString() ?? '#fbbf24', + accentColorDark: fd.get('accentColorDark')?.toString() ?? '#d97706', + logoUrl: fd.get('existingLogoUrl')?.toString() ?? '' + }; + + // Handle logo upload + const logoFile = fd.get('logo') as File | null; + if (logoFile && logoFile.size > 0) { + try { + const uploaded = await saveUpload(logoFile, 'branding'); + branding.logoUrl = uploaded.url; + } catch (err) { + return fail(400, { error: err instanceof Error ? err.message : 'Logo upload failed.', step: 2 }); + } + } + + await upsertOrgConfig('branding', branding); + invalidateConfigCache(); + + await logAudit({ + userId: locals.user!.id, + action: 'setup_step2', + entity: 'org_config', + diff: { key: 'branding' } + }); + + return { success: true, nextStep: 3 }; + }, + + saveStep3: async ({ request, locals }) => { + if (locals.user?.role !== 'sysadmin') return fail(403, { error: 'Access denied.' }); + + const fd = await request.formData(); + + const applications = { + adoptionEnabled: fd.get('adoptionEnabled') === 'true', + fosterEnabled: fd.get('fosterEnabled') === 'true', + volunteerEnabled: fd.get('volunteerEnabled') === 'true', + surrenderEnabled: fd.get('surrenderEnabled') === 'true', + adoptionFee: parseInt(fd.get('adoptionFee')?.toString() ?? '250', 10) || 250 + }; + + await upsertOrgConfig('applications', applications); + invalidateConfigCache(); + + await logAudit({ + userId: locals.user!.id, + action: 'setup_step3', + entity: 'org_config', + diff: { key: 'applications' } + }); + + return { success: true, nextStep: 4 }; + }, + + saveStep4: async ({ request, locals }) => { + if (locals.user?.role !== 'sysadmin') return fail(403, { error: 'Access denied.' }); + + const fd = await request.formData(); + + // Save Stripe keys to site_settings + const stripePublishable = fd.get('stripePublishableKey')?.toString().trim() ?? ''; + const stripeSecret = fd.get('stripeSecretKey')?.toString().trim() ?? ''; + + if (stripePublishable) await upsertSiteSetting('stripe_publishable_key', stripePublishable); + if (stripeSecret) await upsertSiteSetting('stripe_secret_key', stripeSecret); + + await logAudit({ + userId: locals.user!.id, + action: 'setup_step4', + entity: 'site_settings', + diff: { keys: ['stripe_publishable_key', 'stripe_secret_key'] } + }); + + return { success: true, nextStep: 5 }; + }, + + complete: async ({ locals }) => { + if (locals.user?.role !== 'sysadmin') return fail(403, { error: 'Access denied.' }); + + await upsertOrgConfig('setup_complete', true); + invalidateConfigCache(); + + await logAudit({ + userId: locals.user!.id, + action: 'setup_complete', + entity: 'org_config', + diff: { key: 'setup_complete', value: true } + }); + + throw redirect(303, '/admin'); + } +}; diff --git a/src/routes/setup/+page.svelte b/src/routes/setup/+page.svelte new file mode 100644 index 0000000..9c6bada --- /dev/null +++ b/src/routes/setup/+page.svelte @@ -0,0 +1,488 @@ + + + + Setup Wizard — FosterFlow + + +
    + +
    +
    +

    FosterFlow Setup

    + Step {currentStep} of 5 +
    +
    + + +
    +
    +
    + {#each steps as s} +
    + + {s.num} +
    + {/each} +
    +
    +
    + +
    + {#if form?.error} +
    + {form.error} +
    + {/if} + + + {#if currentStep === 1} +
    +

    Organization Information

    +

    Tell us about your rescue organization.

    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    +
    +
    + + + {:else if currentStep === 2} +
    +

    Branding

    +

    Customize colors and your logo.

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

    Used for navigation, buttons, and accents.

    +
    +
    + +
    + + +
    +

    Used for highlights and call-to-action elements.

    +
    + + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + + {#if data.orgConfig.branding.logoUrl} +
    + Current logo + Current logo +
    + {/if} + +

    Upload a new logo to replace the current one. Max 10MB.

    +
    +
    + +
    + + +
    +
    +
    + + + {:else if currentStep === 3} +
    +

    Application Forms

    +

    Configure which application forms are enabled on your public site.

    + +
    +
    +
    +
    +

    Adoption applications

    +

    Allow the public to submit adoption applications online.

    +
    + +
    + +
    +
    +

    Foster applications

    +

    Allow the public to apply to become foster families.

    +
    + +
    + +
    +
    +

    Volunteer applications

    +

    Allow the public to apply to volunteer.

    +
    + +
    + +
    +
    +

    Surrender requests

    +

    Allow the public to submit pet surrender requests.

    +
    + +
    + +
    + +
    + $ + +
    +

    Can be overridden per pet.

    +
    +
    + +
    + + +
    +
    +
    + + + {:else if currentStep === 4} +
    +

    Integrations

    +

    Configure optional third-party integrations. This step can be skipped and completed later.

    + +
    +
    + +
    +

    + S + Stripe (Online Payments) +

    +
    +
    + + +
    +
    + + +

    Stored securely in site settings.

    +
    +
    +
    + + +
    +

    Email (SMTP)

    +

    + Email settings are configured via environment variables in your .env file: +

    +
      +
    • SMTP_HOST, SMTP_PORT
    • +
    • SMTP_USER, SMTP_PASS
    • +
    • SMTP_FROM
    • +
    +
    + + +
    +

    Facebook Integration

    +

    + Connect your Facebook page to automatically post new animals and events. +

    + + Configure Facebook in System Settings → + +
    +
    + +
    + +
    + + +
    +
    +
    +
    + + + {:else if currentStep === 5} +
    +

    Import Data

    +

    + Optionally import existing pet records from a CSV file. You can also do this later from the admin panel. +

    + +
    +

    + Use the CSV import tool to bulk-upload pet records from a spreadsheet. The importer will auto-detect columns and let you map them to FosterFlow fields. +

    + + Open CSV Import Tool → + +
    + +
    + + +
    + +
    +
    +
    + {/if} + + +

    Step {currentStep} of 5 — You can always change these settings later in the admin panel.

    +
    +
    From e7681f0b49322a5a07685dc0b4ca040a2dab97a7 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:54:06 -0500 Subject: [PATCH 36/38] Add generic CSV import tool for bulk pet ingestion Two-step flow: upload CSV, auto-detect and map columns, preview first 5 rows, then import. Auto-detects common column names (name, species, breed, age, sex, etc.). Generates unique slugs from pet names, handles duplicates within the batch and against existing records. Reports imported count, skipped rows, and per-row errors. Requires 'import' permission. Co-Authored-By: Claude Sonnet 4.6 --- src/routes/admin/import/+page.server.ts | 282 ++++++++++++++++++++++++ src/routes/admin/import/+page.svelte | 272 +++++++++++++++++++++++ 2 files changed, 554 insertions(+) create mode 100644 src/routes/admin/import/+page.server.ts create mode 100644 src/routes/admin/import/+page.svelte diff --git a/src/routes/admin/import/+page.server.ts b/src/routes/admin/import/+page.server.ts new file mode 100644 index 0000000..0dbfdcb --- /dev/null +++ b/src/routes/admin/import/+page.server.ts @@ -0,0 +1,282 @@ +import type { PageServerLoad, Actions } from './$types'; +import { fail, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { pets } from '$lib/server/schema'; +import { slugify } from '$lib/slugify'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { logAudit } from '$lib/server/audit'; + +// Pet fields that can be mapped from CSV +export const PET_FIELDS = [ + { key: 'name', label: 'Name', required: true }, + { key: 'species', label: 'Species (dog/cat/other)' }, + { key: 'breed', label: 'Breed' }, + { key: 'age', label: 'Age' }, + { key: 'sex', label: 'Sex (male/female/unknown)' }, + { key: 'color', label: 'Color' }, + { key: 'weight', label: 'Weight' }, + { key: 'status', label: 'Status' }, + { key: 'description', label: 'Description' }, + { key: 'medicalNotes', label: 'Medical Notes' }, + { key: 'behavioralNotes', label: 'Behavioral Notes' }, + { key: 'specialNeeds', label: 'Special Needs' }, + { key: 'microchipId', label: 'Microchip ID' }, + { key: 'adoptionFee', label: 'Adoption Fee' }, + { key: 'skip', label: '— Skip this column —' } +] as const; + +type PetFieldKey = (typeof PET_FIELDS)[number]['key']; + +function parseCSV(text: string): string[][] { + const rows: string[][] = []; + const lines = text.split(/\r?\n/); + + for (const line of lines) { + if (!line.trim()) continue; + + const cells: string[] = []; + let inQuotes = false; + let cell = ''; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + + if (ch === '"') { + if (inQuotes && line[i + 1] === '"') { + cell += '"'; + i++; // skip next quote + } else { + inQuotes = !inQuotes; + } + } else if (ch === ',' && !inQuotes) { + cells.push(cell.trim()); + cell = ''; + } else { + cell += ch; + } + } + cells.push(cell.trim()); + rows.push(cells); + } + + return rows; +} + +function normalizeSpecies(val: string): 'dog' | 'cat' | 'other' { + const v = val.toLowerCase().trim(); + if (v === 'dog' || v === 'canine') return 'dog'; + if (v === 'cat' || v === 'feline') return 'cat'; + return 'other'; +} + +function normalizeSex(val: string): 'male' | 'female' | 'unknown' { + const v = val.toLowerCase().trim(); + if (v === 'male' || v === 'm') return 'male'; + if (v === 'female' || v === 'f') return 'female'; + return 'unknown'; +} + +function normalizeStatus(val: string): string { + const v = val.toLowerCase().trim(); + const validStatuses = [ + 'available', 'adopted', 'foster', 'medical_hold', 'behavioral_hold', + 'pending', 'trial', 'hospice', 'nursing', 'whelping', 'deceased', 'transferred' + ]; + return validStatuses.includes(v) ? v : 'available'; +} + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'import')) { + error(403, 'Access denied: import permission required.'); + } + + return {}; +}; + +export const actions: Actions = { + // Parse uploaded CSV and return preview data + parse: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'import')) { + return fail(403, { error: 'Access denied.' }); + } + + const fd = await request.formData(); + const file = fd.get('csv') as File | null; + + if (!file || file.size === 0) { + return fail(400, { error: 'Please upload a CSV file.' }); + } + + if (!file.name.endsWith('.csv') && file.type !== 'text/csv') { + return fail(400, { error: 'File must be a CSV.' }); + } + + if (file.size > 5 * 1024 * 1024) { + return fail(400, { error: 'File too large (max 5MB).' }); + } + + const text = await file.text(); + const rows = parseCSV(text); + + if (rows.length < 2) { + return fail(400, { error: 'CSV must have a header row and at least one data row.' }); + } + + const headers = rows[0]; + const previewRows = rows.slice(1, 6); // First 5 data rows + const totalRows = rows.length - 1; + + // Auto-detect column mappings based on header names + const autoMappings: Record = {}; + for (let i = 0; i < headers.length; i++) { + const h = headers[i].toLowerCase().trim(); + if (h === 'name' || h === 'pet name' || h === 'animal name') autoMappings[i] = 'name'; + else if (h === 'species' || h === 'type' || h === 'animal type') autoMappings[i] = 'species'; + else if (h === 'breed') autoMappings[i] = 'breed'; + else if (h === 'age' || h === 'age estimate') autoMappings[i] = 'age'; + else if (h === 'sex' || h === 'gender') autoMappings[i] = 'sex'; + else if (h === 'color' || h === 'colour') autoMappings[i] = 'color'; + else if (h === 'weight') autoMappings[i] = 'weight'; + else if (h === 'status') autoMappings[i] = 'status'; + else if (h === 'description' || h === 'bio' || h === 'about') autoMappings[i] = 'description'; + else if (h === 'medical notes' || h === 'medical') autoMappings[i] = 'medicalNotes'; + else if (h === 'behavioral notes' || h === 'behavior' || h === 'behavioural') autoMappings[i] = 'behavioralNotes'; + else if (h === 'special needs') autoMappings[i] = 'specialNeeds'; + else if (h === 'microchip' || h === 'microchip id' || h === 'chip') autoMappings[i] = 'microchipId'; + else if (h === 'adoption fee' || h === 'fee') autoMappings[i] = 'adoptionFee'; + else autoMappings[i] = 'skip'; + } + + // Store the CSV data as a JSON string so we can pass it in the next form step + const csvData = JSON.stringify({ headers, rows: rows.slice(1) }); + + return { + parsed: true, + headers, + previewRows, + totalRows, + autoMappings, + csvData, + petFields: PET_FIELDS + }; + }, + + // Import mapped CSV data into the pets table + import: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'import')) { + return fail(403, { error: 'Access denied.' }); + } + + const fd = await request.formData(); + const csvDataStr = fd.get('csvData')?.toString() ?? ''; + + if (!csvDataStr) { + return fail(400, { error: 'CSV data missing. Please re-upload.' }); + } + + let csvData: { headers: string[]; rows: string[][] }; + try { + csvData = JSON.parse(csvDataStr); + } catch { + return fail(400, { error: 'Invalid CSV data.' }); + } + + const { headers, rows } = csvData; + + // Build column mapping from form: mapping_0, mapping_1, ... + const columnMap: Record = {}; + for (let i = 0; i < headers.length; i++) { + const val = fd.get(`mapping_${i}`)?.toString() as PetFieldKey | undefined; + if (val) columnMap[i] = val; + } + + // Verify 'name' is mapped + const hasName = Object.values(columnMap).includes('name'); + if (!hasName) { + return fail(400, { error: 'You must map a column to "Name".' }); + } + + let imported = 0; + let skipped = 0; + const errors: string[] = []; + + // Track slugs within this batch to avoid duplicates + const batchSlugs = new Set(); + + // Get existing slugs from db + const existingSlugRows = await db.select({ slug: pets.slug }).from(pets); + const existingSlugs = new Set(existingSlugRows.map(r => r.slug)); + + for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { + const row = rows[rowIdx]; + const rowNum = rowIdx + 2; // 1-indexed, +1 for header + + // Extract mapped values + const mapped: Record = {}; + for (let colIdx = 0; colIdx < headers.length; colIdx++) { + const field = columnMap[colIdx]; + if (field && field !== 'skip' && row[colIdx] !== undefined) { + mapped[field] = row[colIdx].trim(); + } + } + + const name = mapped['name']; + if (!name) { + errors.push(`Row ${rowNum}: skipped — name is empty.`); + skipped++; + continue; + } + + // Generate unique slug + let baseSlug = slugify(name); + let slug = baseSlug; + let counter = 1; + while (existingSlugs.has(slug) || batchSlugs.has(slug)) { + slug = `${baseSlug}-${counter}`; + counter++; + } + batchSlugs.add(slug); + existingSlugs.add(slug); + + try { + await db.insert(pets).values({ + name, + slug, + species: mapped['species'] ? normalizeSpecies(mapped['species']) : 'dog', + breed: mapped['breed'] || null, + age: mapped['age'] || null, + sex: mapped['sex'] ? normalizeSex(mapped['sex']) : 'unknown', + color: mapped['color'] || null, + weight: mapped['weight'] || null, + status: mapped['status'] ? normalizeStatus(mapped['status']) as any : 'available', + description: mapped['description'] || null, + medicalNotes: mapped['medicalNotes'] || null, + behavioralNotes: mapped['behavioralNotes'] || null, + specialNeeds: mapped['specialNeeds'] || null, + microchipId: mapped['microchipId'] || null, + adoptionFee: mapped['adoptionFee'] ? parseInt(mapped['adoptionFee'], 10) || null : null, + published: true + }); + imported++; + } catch (err) { + errors.push(`Row ${rowNum} (${name}): ${err instanceof Error ? err.message : 'Insert failed.'}`); + skipped++; + } + } + + await logAudit({ + userId: locals.user!.id, + action: 'csv_import', + entity: 'pets', + diff: { imported, skipped, errorCount: errors.length } + }); + + return { + importComplete: true, + imported, + skipped, + errors: errors.slice(0, 20) // Cap error output + }; + } +}; diff --git a/src/routes/admin/import/+page.svelte b/src/routes/admin/import/+page.svelte new file mode 100644 index 0000000..e0686e6 --- /dev/null +++ b/src/routes/admin/import/+page.svelte @@ -0,0 +1,272 @@ + + + + CSV Import — Admin + + +
    +

    CSV Import

    +

    Bulk import pet records from a CSV file.

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

    Import Complete

    + +
    +
    +

    {form.imported}

    +

    Imported

    +
    +
    +

    {form.skipped}

    +

    Skipped / Errors

    +
    +
    + + {#if form.errors && form.errors.length > 0} +
    +

    Issues ({form.errors.length}):

    +
      + {#each form.errors as err} +
    • {err}
    • + {/each} +
    +
    + {/if} + +
    + + View Pets + + +
    +
    + + +{:else if form?.parsed} +
    +
    +

    Map Columns

    + {form.totalRows} row{form.totalRows !== 1 ? 's' : ''} detected +
    + +
    + + + +
    + + + + + + + + + + {#each form.headers as header, colIdx} + + + + + + {/each} + +
    CSV ColumnMap to FieldPreview (first 5 rows)
    + {header} + + + +
    + {#each form.previewRows as previewRow} + {#if previewRow[colIdx] !== undefined} + + {previewRow[colIdx] || '—'} + + {/if} + {/each} +
    +
    +
    + +
    + Required: At least one column must be mapped to Name. + Species defaults to "dog" and sex defaults to "unknown" if not mapped. +
    + +
    + + +
    +
    +
    + + +{:else} + {#if form?.error} +
    + {form.error} +
    + {/if} + +
    +

    Upload CSV File

    + +
    +
    { e.preventDefault(); dragOver = true; }} + ondragleave={() => dragOver = false} + ondrop={(e) => { e.preventDefault(); dragOver = false; }} + role="region" + aria-label="File upload area" + > + + + +

    Drop your CSV file here

    +

    or click to browse

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

    How it works

    +
      +
    1. Upload a CSV file with a header row and one pet per row.
    2. +
    3. The importer auto-detects common column names.
    4. +
    5. Review the preview and adjust column mappings as needed.
    6. +
    7. Click "Import" to insert all records. Slugs are generated automatically from pet names.
    8. +
    + +
    +

    Supported columns:

    +

    name, species, breed, age, sex, color, weight, status, description, medical notes, behavioral notes, special needs, microchip ID, adoption fee

    +
    + +
    +

    Valid values:

    +
      +
    • species: dog, cat, other
    • +
    • sex: male, female, unknown
    • +
    • status: available, adopted, foster, medical_hold, behavioral_hold, pending, trial, hospice, nursing, whelping, deceased, transferred
    • +
    +
    +
    +{/if} From 8d86f9f3b34d948e4c51a0a69dc42a4b9145e606 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:54:13 -0500 Subject: [PATCH 37/38] 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' + } + }); +}; From 74bfbeff46b88b0299f6c6f6b43aca0a3c28ba77 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:55:48 -0500 Subject: [PATCH 38/38] fix: remove conflicting root page and fix CSV import export - Remove scaffolded +page.svelte that conflicted with (public) route group - Change PET_FIELDS from export to local const (SvelteKit disallows) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/+page.svelte | 2 -- src/routes/admin/import/+page.server.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 src/routes/+page.svelte diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte deleted file mode 100644 index cc88df0..0000000 --- a/src/routes/+page.svelte +++ /dev/null @@ -1,2 +0,0 @@ -

    Welcome to SvelteKit

    -

    Visit svelte.dev/docs/kit to read the documentation

    diff --git a/src/routes/admin/import/+page.server.ts b/src/routes/admin/import/+page.server.ts index 0dbfdcb..2c914bd 100644 --- a/src/routes/admin/import/+page.server.ts +++ b/src/routes/admin/import/+page.server.ts @@ -8,7 +8,7 @@ import type { Role } from '$lib/roles'; import { logAudit } from '$lib/server/audit'; // Pet fields that can be mapped from CSV -export const PET_FIELDS = [ +const PET_FIELDS = [ { key: 'name', label: 'Name', required: true }, { key: 'species', label: 'Species (dog/cat/other)' }, { key: 'breed', label: 'Breed' },