45 tasks across 9 phases covering foundation, server modules, UI, public routes, admin dashboard, foster portal, setup wizard, API, deployment, and testing. Full TDD approach with AHCR as reference. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3950 lines
114 KiB
Markdown
3950 lines
114 KiB
Markdown
# FosterFlow Tenant App — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build the FosterFlow tenant app — a complete, config-driven rescue management site with public pages, admin dashboard, foster portal, i18n-ready architecture, and theming support.
|
|
|
|
**Architecture:** SvelteKit 2 app with Svelte 5 runes, Drizzle ORM over MariaDB, config-driven via `org_config` table and `.env`. All user-facing strings go through i18n `t()` function. Theming via CSS custom properties loaded from `org_config`. Clean room rebuild using AHCR as reference.
|
|
|
|
**Tech Stack:** SvelteKit 2, Svelte 5, Tailwind CSS 4, Drizzle ORM, MariaDB, Stripe, Nodemailer, Argon2, Vitest, Playwright
|
|
|
|
**Reference codebase:** `../almosthomecaninerescue.com-2026/ahcr/` — use for patterns, not copy-paste.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
```
|
|
fosterflow/
|
|
├── src/
|
|
│ ├── lib/
|
|
│ │ ├── server/
|
|
│ │ │ ├── db.ts # Drizzle + MariaDB connection pool
|
|
│ │ │ ├── schema.ts # All tenant DB tables (Drizzle)
|
|
│ │ │ ├── auth.ts # Session create/validate/destroy, authenticateUser
|
|
│ │ │ ├── auth-utils.ts # Argon2 hash/verify, token generation, SHA256
|
|
│ │ │ ├── email.ts # Nodemailer setup + all email templates
|
|
│ │ │ ├── stripe.ts # Stripe client + checkout helpers
|
|
│ │ │ ├── upload.ts # File upload validation + storage
|
|
│ │ │ ├── audit.ts # Audit log writer
|
|
│ │ │ ├── rate-limit.ts # In-memory sliding window rate limiter
|
|
│ │ │ ├── content.ts # Content block reader (CMS)
|
|
│ │ │ ├── config.ts # org_config loader with caching
|
|
│ │ │ ├── contract.ts # Adoption contract HTML generator
|
|
│ │ │ ├── track.ts # Page view tracking + bot detection
|
|
│ │ │ ├── facebook.ts # Facebook OAuth + auto-posting
|
|
│ │ │ └── ai.ts # Ollama client for name generator
|
|
│ │ ├── components/
|
|
│ │ │ ├── ui/
|
|
│ │ │ │ ├── Toast.svelte # Toast notification display
|
|
│ │ │ │ ├── SearchBar.svelte # Reusable search input
|
|
│ │ │ │ ├── StatusBadge.svelte # Colored status pills
|
|
│ │ │ │ ├── ConfirmModal.svelte # Confirm dialog
|
|
│ │ │ │ ├── FileUpload.svelte # Drag-and-drop file upload
|
|
│ │ │ │ ├── Pagination.svelte # Page navigation
|
|
│ │ │ │ └── PetSearchSelect.svelte # Autocomplete pet picker
|
|
│ │ │ ├── admin/
|
|
│ │ │ │ └── DataTable.svelte # Sortable/filterable data table
|
|
│ │ │ └── public/
|
|
│ │ │ ├── Header.svelte # Public site header/nav
|
|
│ │ │ ├── Footer.svelte # Public site footer with sponsors
|
|
│ │ │ ├── PetCard.svelte # Pet listing card
|
|
│ │ │ ├── SponsorStrip.svelte # Sponsor logos row
|
|
│ │ │ ├── StatsCounter.svelte # Animated stat counters
|
|
│ │ │ ├── NewsletterSignup.svelte # Email signup form
|
|
│ │ │ └── DonateButton.svelte # Stripe donate button
|
|
│ │ ├── i18n/
|
|
│ │ │ ├── index.ts # t() function, locale loader, context setup
|
|
│ │ │ └── locales/
|
|
│ │ │ └── en.json # All English strings
|
|
│ │ ├── roles.ts # Role/permission definitions + helpers
|
|
│ │ ├── slugify.ts # URL slug generator
|
|
│ │ └── toast.svelte.ts # Toast state (Svelte 5 runes)
|
|
│ ├── routes/
|
|
│ │ ├── +layout.svelte # Root layout (imports app.css)
|
|
│ │ ├── +layout.server.ts # Root loader (user + orgConfig)
|
|
│ │ ├── +error.svelte # Error page
|
|
│ │ ├── (public)/
|
|
│ │ │ ├── +layout.svelte # Public shell (Header + Footer)
|
|
│ │ │ ├── +layout.server.ts # Load sponsors
|
|
│ │ │ ├── +page.svelte # Home page
|
|
│ │ │ ├── +page.server.ts # Home data (featured pets, events, stats)
|
|
│ │ │ ├── about/ # About page
|
|
│ │ │ ├── pets/ # Pet listings + [slug] detail
|
|
│ │ │ ├── events/ # Events page
|
|
│ │ │ ├── happy-tails/ # Success stories + submit form
|
|
│ │ │ ├── sponsors/ # Sponsors page
|
|
│ │ │ ├── donate/ # Donate page
|
|
│ │ │ ├── contact/ # Contact form
|
|
│ │ │ ├── adoption-process/ # Adoption info page
|
|
│ │ │ ├── become-a-foster/ # Foster info page
|
|
│ │ │ ├── how-to-help/ # Ways to help page
|
|
│ │ │ ├── volunteer/ # Volunteer info page
|
|
│ │ │ ├── why-adopt/ # Why adopt page
|
|
│ │ │ ├── foster-resources/ # Foster resources (password-protected)
|
|
│ │ │ ├── name-generator/ # AI name generator
|
|
│ │ │ └── apply/
|
|
│ │ │ ├── adopt/ # Adoption application
|
|
│ │ │ ├── foster/ # Foster application
|
|
│ │ │ ├── volunteer/ # Volunteer application
|
|
│ │ │ └── surrender/ # Surrender application
|
|
│ │ ├── admin/
|
|
│ │ │ ├── +layout.svelte # Admin shell (sidebar + nav)
|
|
│ │ │ ├── +layout.server.ts # Permission loading
|
|
│ │ │ ├── +page.svelte # Dashboard
|
|
│ │ │ ├── +page.server.ts # Dashboard stats
|
|
│ │ │ ├── pets/ # Pet CRUD + [id]/(photos, medical, fosters, profile)
|
|
│ │ │ ├── pets/new/ # New pet form
|
|
│ │ │ ├── applications/ # Application list + [id] detail + [id]/contract
|
|
│ │ │ ├── intake/ # Quick intake form
|
|
│ │ │ ├── fosters/ # Foster management
|
|
│ │ │ ├── mentors/ # Mentor management + [id]
|
|
│ │ │ ├── sponsors/ # Sponsor CRUD + [id] + new
|
|
│ │ │ ├── donations/ # Donation CRUD + [id] + new
|
|
│ │ │ ├── expenses/ # Expense CRUD + [id] + new
|
|
│ │ │ ├── vets/ # Vet CRUD + [id] + new
|
|
│ │ │ ├── events/ # Event CRUD + [id] + new
|
|
│ │ │ ├── content/ # CMS content editor
|
|
│ │ │ ├── newsletter/ # Newsletter management + compose
|
|
│ │ │ ├── happy-tails/ # Happy tails CRUD + [id] + new
|
|
│ │ │ ├── volunteers/ # Volunteer management + [id]
|
|
│ │ │ ├── users/ # User CRUD + [id] + new
|
|
│ │ │ ├── messages/ # Contact message inbox
|
|
│ │ │ ├── moderation/ # Foster submission moderation
|
|
│ │ │ ├── foster-dashboard/ # Admin view of foster activity
|
|
│ │ │ ├── reports/ # Financial reports
|
|
│ │ │ ├── analytics/ # Traffic analytics
|
|
│ │ │ ├── traffic/ # Live traffic view
|
|
│ │ │ ├── import/ # CSV import tool
|
|
│ │ │ └── system/ # System settings
|
|
│ │ ├── foster/
|
|
│ │ │ ├── +layout.svelte # Foster portal shell
|
|
│ │ │ ├── +layout.server.ts # Foster auth check
|
|
│ │ │ ├── +page.svelte # Foster dashboard
|
|
│ │ │ ├── +page.server.ts # Foster data
|
|
│ │ │ ├── submit/ # Submit update/photo
|
|
│ │ │ └── supplies/ # Request supplies
|
|
│ │ ├── setup/
|
|
│ │ │ ├── +page.svelte # Setup wizard (multi-step)
|
|
│ │ │ └── +page.server.ts # Setup wizard actions
|
|
│ │ ├── api/
|
|
│ │ │ ├── health/+server.ts # Health check endpoint
|
|
│ │ │ ├── pets/+server.ts # Public pet search API
|
|
│ │ │ ├── newsletter/+server.ts # Newsletter subscribe
|
|
│ │ │ ├── pet-names/+server.ts # AI name generator
|
|
│ │ │ ├── stripe-webhook/+server.ts # Stripe webhooks
|
|
│ │ │ └── worker/ # AI worker job endpoints
|
|
│ │ ├── login/ # Login page
|
|
│ │ ├── logout/ # Logout action
|
|
│ │ ├── register/ # Registration request
|
|
│ │ ├── change-password/ # Password change
|
|
│ │ ├── pay/ # Stripe payment flows
|
|
│ │ ├── robots.txt/+server.ts # Dynamic robots.txt
|
|
│ │ └── sitemap.xml/+server.ts # Dynamic sitemap
|
|
│ ├── hooks.server.ts # Auth, security headers, page tracking
|
|
│ ├── app.html # HTML shell
|
|
│ └── app.css # Tailwind imports + custom properties
|
|
├── tests/
|
|
│ ├── unit/
|
|
│ │ ├── auth.test.ts # Auth utility tests
|
|
│ │ ├── roles.test.ts # Permission matrix tests
|
|
│ │ ├── config.test.ts # org_config loader tests
|
|
│ │ ├── i18n.test.ts # i18n function tests
|
|
│ │ ├── slugify.test.ts # Slug generation tests
|
|
│ │ └── rate-limit.test.ts # Rate limiter tests
|
|
│ └── e2e/
|
|
│ ├── login.test.ts # Login flow
|
|
│ ├── admin-guard.test.ts # Admin route protection
|
|
│ ├── setup-wizard.test.ts # Setup wizard flow
|
|
│ └── public-pages.test.ts # Public page rendering
|
|
├── scripts/
|
|
│ ├── seed.ts # Seed placeholder data (self-hosted)
|
|
│ ├── seed-tenant.ts # Seed admin user (used by cloud provisioner)
|
|
│ └── ai-worker.ts # Ollama worker for name generation
|
|
├── drizzle/ # Generated migrations
|
|
├── static/
|
|
│ ├── favicon.ico # Default paw favicon
|
|
│ └── manifest.json # PWA manifest
|
|
├── docker-compose.yml # MariaDB + app + Caddy
|
|
├── Dockerfile # Node.js production image
|
|
├── Caddyfile # Reverse proxy config
|
|
├── .env.example # Documented env template
|
|
├── package.json
|
|
├── svelte.config.js
|
|
├── drizzle.config.ts
|
|
├── vite.config.ts
|
|
├── vitest.config.ts
|
|
├── playwright.config.ts
|
|
└── tsconfig.json
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 1: Foundation
|
|
|
|
### Task 1: Project Scaffolding
|
|
|
|
**Files:**
|
|
- Create: `package.json`
|
|
- Create: `svelte.config.js`
|
|
- Create: `vite.config.ts`
|
|
- Create: `tsconfig.json`
|
|
- Create: `drizzle.config.ts`
|
|
- Create: `.env.example`
|
|
- Create: `.gitignore`
|
|
- Create: `src/app.html`
|
|
- Create: `src/app.css`
|
|
|
|
- [ ] **Step 1: Initialize SvelteKit project**
|
|
|
|
```bash
|
|
cd /home/justin/code/pawprint
|
|
npm create svelte@latest . -- --template minimal --types typescript
|
|
```
|
|
|
|
Select: Skeleton project, TypeScript, no additional options.
|
|
|
|
- [ ] **Step 2: Install core dependencies**
|
|
|
|
```bash
|
|
npm install drizzle-orm mysql2 @node-rs/argon2 stripe nodemailer
|
|
npm install -D drizzle-kit @sveltejs/adapter-node @tailwindcss/vite tailwindcss @types/nodemailer
|
|
```
|
|
|
|
- [ ] **Step 3: Configure svelte.config.js**
|
|
|
|
```javascript
|
|
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;
|
|
```
|
|
|
|
- [ ] **Step 4: Configure vite.config.ts**
|
|
|
|
```typescript
|
|
import tailwindcss from '@tailwindcss/vite';
|
|
import { sveltekit } from '@sveltejs/kit/vite';
|
|
import { defineConfig } from 'vite';
|
|
|
|
export default defineConfig({
|
|
plugins: [tailwindcss(), sveltekit()]
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Configure drizzle.config.ts**
|
|
|
|
```typescript
|
|
import { defineConfig } from 'drizzle-kit';
|
|
|
|
export default defineConfig({
|
|
schema: './src/lib/server/schema.ts',
|
|
out: './drizzle',
|
|
dialect: 'mysql',
|
|
dbCredentials: {
|
|
url: process.env.DATABASE_URL!
|
|
}
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 6: Create .env.example**
|
|
|
|
```bash
|
|
# Database
|
|
DATABASE_URL=mysql://fosterflow:password@localhost:3306/fosterflow
|
|
|
|
# Auth
|
|
SESSION_SECRET=change-me-to-a-random-64-char-string
|
|
|
|
# Stripe (optional — payments disabled without these)
|
|
STRIPE_SECRET_KEY=
|
|
STRIPE_WEBHOOK_SECRET=
|
|
|
|
# Email (optional — emails disabled without these)
|
|
SMTP_HOST=smtp.gmail.com
|
|
SMTP_PORT=587
|
|
SMTP_USER=
|
|
SMTP_PASS=
|
|
SMTP_FROM=noreply@example.com
|
|
|
|
# File uploads
|
|
UPLOAD_DIR=./uploads
|
|
|
|
# Public URL (used in emails, sitemap, etc.)
|
|
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=
|
|
```
|
|
|
|
- [ ] **Step 7: Create src/app.html**
|
|
|
|
```html
|
|
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
|
<meta name="theme-color" content="#0d9488" />
|
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
%sveltekit.head%
|
|
</head>
|
|
<body data-sveltekit-preload-data="hover">
|
|
<div style="display: contents">%sveltekit.body%</div>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
- [ ] **Step 8: Create src/app.css**
|
|
|
|
```css
|
|
@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;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 9: Update .gitignore**
|
|
|
|
Add to the generated .gitignore:
|
|
```
|
|
uploads/
|
|
.env
|
|
```
|
|
|
|
- [ ] **Step 10: Create static/manifest.json**
|
|
|
|
```json
|
|
{
|
|
"name": "FosterFlow",
|
|
"short_name": "FosterFlow",
|
|
"start_url": "/",
|
|
"display": "standalone",
|
|
"theme_color": "#0d9488",
|
|
"background_color": "#faf8f5",
|
|
"icons": []
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 11: Verify the app builds and runs**
|
|
|
|
```bash
|
|
npm run dev
|
|
```
|
|
|
|
Expected: SvelteKit dev server starts on port 5173.
|
|
|
|
- [ ] **Step 12: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: scaffold FosterFlow SvelteKit project with config"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Database Schema
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/schema.ts`
|
|
- Create: `src/lib/server/db.ts`
|
|
|
|
- [ ] **Step 1: Write schema tests**
|
|
|
|
Create `tests/unit/schema.test.ts`:
|
|
|
|
```typescript
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
npx vitest run tests/unit/schema.test.ts
|
|
```
|
|
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 3: Create vitest.config.ts**
|
|
|
|
```typescript
|
|
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')
|
|
}
|
|
}
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Write the database schema**
|
|
|
|
Create `src/lib/server/schema.ts`:
|
|
|
|
```typescript
|
|
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] })
|
|
}));
|
|
```
|
|
|
|
- [ ] **Step 5: Create database connection module**
|
|
|
|
Create `src/lib/server/db.ts`:
|
|
|
|
```typescript
|
|
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' });
|
|
```
|
|
|
|
- [ ] **Step 6: Run schema test**
|
|
|
|
```bash
|
|
npx vitest run tests/unit/schema.test.ts
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/schema.ts src/lib/server/db.ts tests/unit/schema.test.ts vitest.config.ts
|
|
git commit -m "feat: add database schema and connection pool"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Roles & Permissions
|
|
|
|
**Files:**
|
|
- Create: `src/lib/roles.ts`
|
|
- Create: `tests/unit/roles.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `tests/unit/roles.test.ts`:
|
|
|
|
```typescript
|
|
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);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
npx vitest run tests/unit/roles.test.ts
|
|
```
|
|
|
|
Expected: FAIL
|
|
|
|
- [ ] **Step 3: Implement roles**
|
|
|
|
Create `src/lib/roles.ts`:
|
|
|
|
```typescript
|
|
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<Role, Permission[]> = {
|
|
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');
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
npx vitest run tests/unit/roles.test.ts
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/roles.ts tests/unit/roles.test.ts
|
|
git commit -m "feat: add role-based permission system"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Auth Utilities
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/auth-utils.ts`
|
|
- Create: `tests/unit/auth.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Create `tests/unit/auth.test.ts`:
|
|
|
|
```typescript
|
|
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);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
npx vitest run tests/unit/auth.test.ts
|
|
```
|
|
|
|
Expected: FAIL
|
|
|
|
- [ ] **Step 3: Implement auth utilities**
|
|
|
|
Create `src/lib/server/auth-utils.ts`:
|
|
|
|
```typescript
|
|
import { hash, verify } from '@node-rs/argon2';
|
|
import crypto from 'crypto';
|
|
|
|
export async function hashPassword(password: string): Promise<string> {
|
|
return hash(password);
|
|
}
|
|
|
|
export async function verifyPassword(hashedPassword: string, password: string): Promise<boolean> {
|
|
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');
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
npx vitest run tests/unit/auth.test.ts
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/auth-utils.ts tests/unit/auth.test.ts
|
|
git commit -m "feat: add auth utilities (Argon2, session tokens)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Auth System (Sessions)
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/auth.ts`
|
|
|
|
- [ ] **Step 1: Implement session management**
|
|
|
|
Create `src/lib/server/auth.ts`:
|
|
|
|
```typescript
|
|
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<string> {
|
|
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<void> {
|
|
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;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/auth.ts
|
|
git commit -m "feat: add session-based auth system"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: i18n System
|
|
|
|
**Files:**
|
|
- Create: `src/lib/i18n/index.ts`
|
|
- Create: `src/lib/i18n/locales/en.json`
|
|
- Create: `tests/unit/i18n.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Create `tests/unit/i18n.test.ts`:
|
|
|
|
```typescript
|
|
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');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
npx vitest run tests/unit/i18n.test.ts
|
|
```
|
|
|
|
Expected: FAIL
|
|
|
|
- [ ] **Step 3: Implement i18n module**
|
|
|
|
Create `src/lib/i18n/index.ts`:
|
|
|
|
```typescript
|
|
type Translations = Record<string, string>;
|
|
|
|
export function createI18n(translations: Translations) {
|
|
function t(key: string, vars?: Record<string, string>): 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<string, unknown>, 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<string, unknown>, fullKey));
|
|
} else {
|
|
result[fullKey] = String(value);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
let cachedI18n: ReturnType<typeof createI18n> | null = null;
|
|
|
|
export async function loadI18n(locale = 'en'): Promise<ReturnType<typeof createI18n>> {
|
|
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;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Create initial English locale file**
|
|
|
|
Create `src/lib/i18n/locales/en.json`:
|
|
|
|
```json
|
|
{
|
|
"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."
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests**
|
|
|
|
```bash
|
|
npx vitest run tests/unit/i18n.test.ts
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/lib/i18n/ tests/unit/i18n.test.ts
|
|
git commit -m "feat: add i18n system with English locale"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: org_config Loader
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/config.ts`
|
|
- Create: `tests/unit/config.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Create `tests/unit/config.test.ts`:
|
|
|
|
```typescript
|
|
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');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
npx vitest run tests/unit/config.test.ts
|
|
```
|
|
|
|
Expected: FAIL
|
|
|
|
- [ ] **Step 3: Implement config loader**
|
|
|
|
Create `src/lib/server/config.ts`:
|
|
|
|
```typescript
|
|
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<string, keyof OrgConfig> = {
|
|
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<string, unknown>)
|
|
} 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<OrgConfig> {
|
|
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;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
npx vitest run tests/unit/config.test.ts
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/config.ts tests/unit/config.test.ts
|
|
git commit -m "feat: add org_config loader with caching and defaults"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Slugify Utility
|
|
|
|
**Files:**
|
|
- Create: `src/lib/slugify.ts`
|
|
- Create: `tests/unit/slugify.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Create `tests/unit/slugify.test.ts`:
|
|
|
|
```typescript
|
|
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');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
npx vitest run tests/unit/slugify.test.ts
|
|
```
|
|
|
|
Expected: FAIL
|
|
|
|
- [ ] **Step 3: Implement slugify**
|
|
|
|
Create `src/lib/slugify.ts`:
|
|
|
|
```typescript
|
|
export function slugify(text: string): string {
|
|
return text
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/g, '')
|
|
.replace(/[\s]+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
npx vitest run tests/unit/slugify.test.ts
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/slugify.ts tests/unit/slugify.test.ts
|
|
git commit -m "feat: add slugify utility"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Rate Limiter
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/rate-limit.ts`
|
|
- Create: `tests/unit/rate-limit.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Create `tests/unit/rate-limit.test.ts`:
|
|
|
|
```typescript
|
|
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);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
npx vitest run tests/unit/rate-limit.test.ts
|
|
```
|
|
|
|
Expected: FAIL
|
|
|
|
- [ ] **Step 3: Implement rate limiter**
|
|
|
|
Create `src/lib/server/rate-limit.ts`:
|
|
|
|
```typescript
|
|
export class RateLimiter {
|
|
private windows = new Map<string, number[]>();
|
|
|
|
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); // 5 per 15 min
|
|
export const registrationLimiter = new RateLimiter(3, 60 * 60 * 1000); // 3 per hour
|
|
export const apiLimiter = new RateLimiter(60, 60 * 1000); // 60 per min
|
|
export const nameGeneratorLimiter = new RateLimiter(10, 5 * 60 * 1000); // 10 per 5 min
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
npx vitest run tests/unit/rate-limit.test.ts
|
|
```
|
|
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/rate-limit.ts tests/unit/rate-limit.test.ts
|
|
git commit -m "feat: add in-memory sliding window rate limiter"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2: Server Modules
|
|
|
|
### Task 10: Audit Logger
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/audit.ts`
|
|
|
|
- [ ] **Step 1: Implement audit logger**
|
|
|
|
Create `src/lib/server/audit.ts`:
|
|
|
|
```typescript
|
|
import { db } from './db';
|
|
import { auditLog } from './schema';
|
|
|
|
export async function logAudit(params: {
|
|
userId?: number;
|
|
action: string;
|
|
entity?: string;
|
|
entityId?: number;
|
|
diff?: Record<string, unknown>;
|
|
ipAddress?: string;
|
|
}): Promise<void> {
|
|
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
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/audit.ts
|
|
git commit -m "feat: add audit logger"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: File Upload Handler
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/upload.ts`
|
|
|
|
- [ ] **Step 1: Implement upload handler**
|
|
|
|
Create `src/lib/server/upload.ts`:
|
|
|
|
```typescript
|
|
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<string, number[]> = {
|
|
'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<void> {
|
|
if (!url.startsWith('/uploads/')) return;
|
|
const filePath = path.join(UPLOAD_DIR, url.replace('/uploads/', ''));
|
|
try {
|
|
await fs.unlink(filePath);
|
|
} catch {
|
|
// File already gone — that's fine
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/upload.ts
|
|
git commit -m "feat: add file upload handler with magic byte validation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Email Service
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/email.ts`
|
|
|
|
- [ ] **Step 1: Implement email service**
|
|
|
|
Create `src/lib/server/email.ts`:
|
|
|
|
```typescript
|
|
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<void> {
|
|
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<void> {
|
|
const subject = `[${orgName}] New ${type} application from ${applicantName}`;
|
|
const html = `
|
|
<h2>New ${type} Application</h2>
|
|
<p><strong>Applicant:</strong> ${applicantName}</p>
|
|
${petName ? `<p><strong>Pet:</strong> ${petName}</p>` : ''}
|
|
<p>Log in to your admin dashboard to review this application.</p>
|
|
`;
|
|
await sendEmail(staffEmail, subject, html);
|
|
}
|
|
|
|
export async function sendApplicationStatusUpdate(
|
|
orgName: string,
|
|
to: string,
|
|
applicantName: string,
|
|
type: string,
|
|
status: string,
|
|
siteUrl: string
|
|
): Promise<void> {
|
|
const subject = `[${orgName}] Your ${type} application has been ${status}`;
|
|
const html = `
|
|
<h2>Application Update</h2>
|
|
<p>Hi ${applicantName},</p>
|
|
<p>Your ${type} application has been <strong>${status}</strong>.</p>
|
|
<p>Visit <a href="${siteUrl}">${siteUrl}</a> for more information.</p>
|
|
`;
|
|
await sendEmail(to, subject, html);
|
|
}
|
|
|
|
export async function sendFosterWelcomeEmail(
|
|
orgName: string,
|
|
to: string,
|
|
name: string,
|
|
tempPassword: string,
|
|
loginUrl: string
|
|
): Promise<void> {
|
|
const subject = `[${orgName}] Welcome to the foster team!`;
|
|
const html = `
|
|
<h2>Welcome, ${name}!</h2>
|
|
<p>Your foster account has been created.</p>
|
|
<p><strong>Login URL:</strong> <a href="${loginUrl}">${loginUrl}</a></p>
|
|
<p><strong>Email:</strong> ${to}</p>
|
|
<p><strong>Temporary Password:</strong> ${tempPassword}</p>
|
|
<p>You will be asked to change your password on first login.</p>
|
|
`;
|
|
await sendEmail(to, subject, html);
|
|
}
|
|
|
|
export async function sendRegistrationNotification(
|
|
orgName: string,
|
|
staffEmail: string,
|
|
name: string,
|
|
email: string,
|
|
reason: string
|
|
): Promise<void> {
|
|
const subject = `[${orgName}] New registration request from ${name}`;
|
|
const html = `
|
|
<h2>New Registration Request</h2>
|
|
<p><strong>Name:</strong> ${name}</p>
|
|
<p><strong>Email:</strong> ${email}</p>
|
|
<p><strong>Reason:</strong> ${reason}</p>
|
|
<p>Log in to your admin dashboard to approve or deny this request.</p>
|
|
`;
|
|
await sendEmail(staffEmail, subject, html);
|
|
}
|
|
|
|
export async function sendRegistrationApproved(
|
|
orgName: string,
|
|
to: string,
|
|
name: string,
|
|
loginUrl: string
|
|
): Promise<void> {
|
|
const subject = `[${orgName}] Your account has been approved!`;
|
|
const html = `
|
|
<h2>Account Approved</h2>
|
|
<p>Hi ${name}, your account at ${orgName} has been approved.</p>
|
|
<p>Log in at: <a href="${loginUrl}">${loginUrl}</a></p>
|
|
`;
|
|
await sendEmail(to, subject, html);
|
|
}
|
|
|
|
export async function sendRegistrationDenied(
|
|
orgName: string,
|
|
to: string,
|
|
name: string
|
|
): Promise<void> {
|
|
const subject = `[${orgName}] Registration update`;
|
|
const html = `
|
|
<h2>Registration Update</h2>
|
|
<p>Hi ${name}, unfortunately your registration request at ${orgName} was not approved at this time.</p>
|
|
<p>If you believe this is an error, please contact us.</p>
|
|
`;
|
|
await sendEmail(to, subject, html);
|
|
}
|
|
|
|
export async function sendFosterSubmissionNotification(
|
|
orgName: string,
|
|
staffEmail: string,
|
|
fosterName: string,
|
|
petName: string,
|
|
type: string
|
|
): Promise<void> {
|
|
const subject = `[${orgName}] New foster ${type} for ${petName}`;
|
|
const html = `
|
|
<h2>New Foster Submission</h2>
|
|
<p><strong>Foster:</strong> ${fosterName}</p>
|
|
<p><strong>Pet:</strong> ${petName}</p>
|
|
<p><strong>Type:</strong> ${type}</p>
|
|
<p>Log in to your admin dashboard to review.</p>
|
|
`;
|
|
await sendEmail(staffEmail, subject, html);
|
|
}
|
|
|
|
export async function sendFosterReviewNotification(
|
|
orgName: string,
|
|
to: string,
|
|
fosterName: string,
|
|
petName: string,
|
|
approved: boolean
|
|
): Promise<void> {
|
|
const status = approved ? 'approved' : 'not approved';
|
|
const subject = `[${orgName}] Your submission for ${petName} was ${status}`;
|
|
const html = `
|
|
<h2>Submission Review</h2>
|
|
<p>Hi ${fosterName}, your submission for ${petName} has been <strong>${status}</strong>.</p>
|
|
`;
|
|
await sendEmail(to, subject, html);
|
|
}
|
|
|
|
export async function sendSupplyRequestNotification(
|
|
orgName: string,
|
|
staffEmail: string,
|
|
fosterName: string,
|
|
items: string,
|
|
urgency: string
|
|
): Promise<void> {
|
|
const urgentTag = urgency === 'high' ? '[URGENT] ' : '';
|
|
const subject = `${urgentTag}[${orgName}] Supply request from ${fosterName}`;
|
|
const html = `
|
|
<h2>Supply Request</h2>
|
|
<p><strong>Foster:</strong> ${fosterName}</p>
|
|
<p><strong>Items:</strong> ${items}</p>
|
|
<p><strong>Urgency:</strong> ${urgency}</p>
|
|
`;
|
|
await sendEmail(staffEmail, subject, html);
|
|
}
|
|
|
|
export async function sendNewsletter(
|
|
orgName: string,
|
|
to: string,
|
|
subject: string,
|
|
body: string,
|
|
siteUrl: string
|
|
): Promise<void> {
|
|
const html = `
|
|
${body}
|
|
<hr />
|
|
<p style="font-size: 12px; color: #999;">
|
|
Sent by ${orgName}. <a href="${siteUrl}">Visit our website</a>.
|
|
</p>
|
|
`;
|
|
await sendEmail(to, `[${orgName}] ${subject}`, html);
|
|
}
|
|
|
|
export async function sendContactNotification(
|
|
orgName: string,
|
|
staffEmail: string,
|
|
senderName: string,
|
|
senderEmail: string,
|
|
messageSubject: string,
|
|
message: string
|
|
): Promise<void> {
|
|
const subject = `[${orgName}] Contact form: ${messageSubject || 'New message'}`;
|
|
const html = `
|
|
<h2>Contact Form Message</h2>
|
|
<p><strong>From:</strong> ${senderName} (${senderEmail})</p>
|
|
<p><strong>Subject:</strong> ${messageSubject || 'N/A'}</p>
|
|
<p>${message}</p>
|
|
`;
|
|
await sendEmail(staffEmail, subject, html);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/email.ts
|
|
git commit -m "feat: add email service with all notification templates"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: Content Block Reader
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/content.ts`
|
|
|
|
- [ ] **Step 1: Implement content block reader**
|
|
|
|
Create `src/lib/server/content.ts`:
|
|
|
|
```typescript
|
|
import { db } from './db';
|
|
import { contentBlocks } from './schema';
|
|
import { eq, and } from 'drizzle-orm';
|
|
|
|
export async function getContentBlock(page: string, sectionKey: string): Promise<unknown | null> {
|
|
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<Record<string, unknown>> {
|
|
const rows = await db
|
|
.select()
|
|
.from(contentBlocks)
|
|
.where(eq(contentBlocks.page, page));
|
|
|
|
const blocks: Record<string, unknown> = {};
|
|
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;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/content.ts
|
|
git commit -m "feat: add content block reader (CMS)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: Page View Tracking
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/track.ts`
|
|
|
|
- [ ] **Step 1: Implement tracker with bot detection**
|
|
|
|
Create `src/lib/server/track.ts`:
|
|
|
|
```typescript
|
|
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<void> {
|
|
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
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/track.ts
|
|
git commit -m "feat: add page view tracking with bot detection"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: Stripe Integration
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/stripe.ts`
|
|
|
|
- [ ] **Step 1: Implement Stripe helpers**
|
|
|
|
Create `src/lib/server/stripe.ts`:
|
|
|
|
```typescript
|
|
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<string | null> {
|
|
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<string | null> {
|
|
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
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/stripe.ts
|
|
git commit -m "feat: add Stripe checkout and webhook helpers"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 16: Contract Generator
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/contract.ts`
|
|
|
|
- [ ] **Step 1: Implement contract generator**
|
|
|
|
Create `src/lib/server/contract.ts`:
|
|
|
|
```typescript
|
|
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) => `<li>${i + 1}. ${item}</li>`)
|
|
.join('\n');
|
|
|
|
return `<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Adoption Contract — ${params.petName}</title>
|
|
<style>
|
|
body { font-family: Georgia, serif; max-width: 800px; margin: 0 auto; padding: 40px; }
|
|
h1 { text-align: center; }
|
|
.info { margin: 20px 0; }
|
|
.info dt { font-weight: bold; display: inline; }
|
|
.info dd { display: inline; margin: 0 20px 0 5px; }
|
|
ol { line-height: 1.8; }
|
|
.signatures { margin-top: 60px; display: flex; justify-content: space-between; }
|
|
.sig-line { border-top: 1px solid #000; width: 200px; padding-top: 5px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>${params.orgName}</h1>
|
|
<h2 style="text-align: center;">Adoption Contract</h2>
|
|
|
|
<dl class="info">
|
|
<dt>Pet Name:</dt><dd>${params.petName}</dd>
|
|
<dt>Species:</dt><dd>${params.species}</dd>
|
|
<dt>Breed:</dt><dd>${params.breed || 'Unknown'}</dd>
|
|
<dt>Sex:</dt><dd>${params.sex}</dd>
|
|
<dt>Age:</dt><dd>${params.age || 'Unknown'}</dd>
|
|
</dl>
|
|
|
|
<dl class="info">
|
|
<dt>Adopter:</dt><dd>${params.adopterName}</dd>
|
|
<dt>Email:</dt><dd>${params.adopterEmail}</dd>
|
|
<dt>Adoption Fee:</dt><dd>$${(params.adoptionFee / 100).toFixed(2)}</dd>
|
|
<dt>Date:</dt><dd>${params.date}</dd>
|
|
</dl>
|
|
|
|
<h3>Terms of Adoption</h3>
|
|
<ol>${items}</ol>
|
|
|
|
<div class="signatures">
|
|
<div>
|
|
<div class="sig-line">Adopter Signature</div>
|
|
</div>
|
|
<div>
|
|
<div class="sig-line">${params.orgName} Representative</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/contract.ts
|
|
git commit -m "feat: add adoption contract HTML generator"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 17: AI Client
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/ai.ts`
|
|
|
|
- [ ] **Step 1: Implement Ollama client**
|
|
|
|
Create `src/lib/server/ai.ts`:
|
|
|
|
```typescript
|
|
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<number> {
|
|
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];
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/ai.ts
|
|
git commit -m "feat: add AI job queue client for name generator"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 18: Facebook Integration
|
|
|
|
**Files:**
|
|
- Create: `src/lib/server/facebook.ts`
|
|
|
|
- [ ] **Step 1: Implement Facebook helpers**
|
|
|
|
Create `src/lib/server/facebook.ts`:
|
|
|
|
```typescript
|
|
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<string | null> {
|
|
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<string | null> {
|
|
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<Array<{ id: string; name: string; access_token: string }>> {
|
|
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<boolean> {
|
|
const body: Record<string, string> = { 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;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/lib/server/facebook.ts
|
|
git commit -m "feat: add Facebook OAuth and auto-posting integration"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 19: Toast State + Lib Index
|
|
|
|
**Files:**
|
|
- Create: `src/lib/toast.svelte.ts`
|
|
- Create: `src/lib/index.ts`
|
|
|
|
- [ ] **Step 1: Implement toast state**
|
|
|
|
Create `src/lib/toast.svelte.ts`:
|
|
|
|
```typescript
|
|
interface Toast {
|
|
id: number;
|
|
message: string;
|
|
type: 'success' | 'error' | 'info';
|
|
}
|
|
|
|
let toasts = $state<Toast[]>([]);
|
|
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;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create lib index**
|
|
|
|
Create `src/lib/index.ts`:
|
|
|
|
```typescript
|
|
// Re-exports for $lib convenience
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/lib/toast.svelte.ts src/lib/index.ts
|
|
git commit -m "feat: add toast notification state"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 20: Server Hooks
|
|
|
|
**Files:**
|
|
- Create: `src/hooks.server.ts`
|
|
|
|
- [ ] **Step 1: Implement hooks**
|
|
|
|
Create `src/hooks.server.ts`:
|
|
|
|
```typescript
|
|
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;
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Create app.d.ts for type declarations**
|
|
|
|
Create `src/app.d.ts`:
|
|
|
|
```typescript
|
|
import type { OrgConfig } from '$lib/server/config';
|
|
|
|
declare global {
|
|
namespace App {
|
|
interface Locals {
|
|
user?: {
|
|
id: number;
|
|
email: string;
|
|
name: string;
|
|
role: string;
|
|
active: boolean;
|
|
mustChangePassword: boolean;
|
|
};
|
|
sessionToken?: string;
|
|
orgConfig: OrgConfig;
|
|
}
|
|
}
|
|
}
|
|
|
|
export {};
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/hooks.server.ts src/app.d.ts
|
|
git commit -m "feat: add server hooks (auth, guards, tracking, security headers)"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 3: UI Foundation
|
|
|
|
### Task 21: UI Components
|
|
|
|
**Files:**
|
|
- Create: `src/lib/components/ui/Toast.svelte`
|
|
- Create: `src/lib/components/ui/SearchBar.svelte`
|
|
- Create: `src/lib/components/ui/StatusBadge.svelte`
|
|
- Create: `src/lib/components/ui/ConfirmModal.svelte`
|
|
- Create: `src/lib/components/ui/FileUpload.svelte`
|
|
- Create: `src/lib/components/ui/Pagination.svelte`
|
|
- Create: `src/lib/components/ui/PetSearchSelect.svelte`
|
|
- Create: `src/lib/components/admin/DataTable.svelte`
|
|
|
|
- [ ] **Step 1: Create Toast component**
|
|
|
|
Create `src/lib/components/ui/Toast.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import { getToasts, removeToast } from '$lib/toast.svelte';
|
|
|
|
const toasts = $derived(getToasts());
|
|
|
|
const colors = {
|
|
success: 'bg-green-500',
|
|
error: 'bg-red-500',
|
|
info: 'bg-primary'
|
|
};
|
|
</script>
|
|
|
|
{#if toasts.length > 0}
|
|
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
|
{#each toasts as toast (toast.id)}
|
|
<div class="{colors[toast.type]} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 max-w-sm">
|
|
<span class="flex-1">{toast.message}</span>
|
|
<button onclick={() => removeToast(toast.id)} class="text-white/80 hover:text-white">×</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
```
|
|
|
|
- [ ] **Step 2: Create SearchBar component**
|
|
|
|
Create `src/lib/components/ui/SearchBar.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
let { value = $bindable(''), placeholder = 'Search...', onsubmit }: {
|
|
value?: string;
|
|
placeholder?: string;
|
|
onsubmit?: (value: string) => void;
|
|
} = $props();
|
|
</script>
|
|
|
|
<form
|
|
class="relative"
|
|
onsubmit={(e) => { e.preventDefault(); onsubmit?.(value); }}
|
|
>
|
|
<input
|
|
type="text"
|
|
bind:value
|
|
{placeholder}
|
|
class="w-full px-4 py-2 pl-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
|
|
/>
|
|
<svg class="absolute left-3 top-2.5 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
</form>
|
|
```
|
|
|
|
- [ ] **Step 3: Create StatusBadge component**
|
|
|
|
Create `src/lib/components/ui/StatusBadge.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
let { status }: { status: string } = $props();
|
|
|
|
const colorMap: Record<string, string> = {
|
|
available: 'bg-green-100 text-green-800',
|
|
adopted: 'bg-blue-100 text-blue-800',
|
|
foster: 'bg-purple-100 text-purple-800',
|
|
pending: 'bg-yellow-100 text-yellow-800',
|
|
medical_hold: 'bg-red-100 text-red-800',
|
|
behavioral_hold: 'bg-orange-100 text-orange-800',
|
|
trial: 'bg-teal-100 text-teal-800',
|
|
hospice: 'bg-pink-100 text-pink-800',
|
|
deceased: 'bg-gray-100 text-gray-800',
|
|
transferred: 'bg-gray-100 text-gray-800',
|
|
approved: 'bg-green-100 text-green-800',
|
|
denied: 'bg-red-100 text-red-800',
|
|
reviewing: 'bg-yellow-100 text-yellow-800',
|
|
active: 'bg-green-100 text-green-800',
|
|
suspended: 'bg-red-100 text-red-800'
|
|
};
|
|
|
|
const colors = $derived(colorMap[status] || 'bg-gray-100 text-gray-800');
|
|
const label = $derived(status.replace(/_/g, ' '));
|
|
</script>
|
|
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize {colors}">
|
|
{label}
|
|
</span>
|
|
```
|
|
|
|
- [ ] **Step 4: Create ConfirmModal component**
|
|
|
|
Create `src/lib/components/ui/ConfirmModal.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
let { open = $bindable(false), title = 'Confirm', message = 'Are you sure?', onconfirm, oncancel }: {
|
|
open?: boolean;
|
|
title?: string;
|
|
message?: string;
|
|
onconfirm?: () => void;
|
|
oncancel?: () => void;
|
|
} = $props();
|
|
</script>
|
|
|
|
{#if open}
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div class="fixed inset-0 bg-black/50" onclick={() => { open = false; oncancel?.(); }} role="button" tabindex="-1" onkeydown={() => {}}></div>
|
|
<div class="relative bg-white rounded-xl shadow-xl p-6 max-w-md w-full mx-4">
|
|
<h3 class="text-lg font-bold mb-2">{title}</h3>
|
|
<p class="text-gray-600 mb-6">{message}</p>
|
|
<div class="flex justify-end gap-3">
|
|
<button
|
|
onclick={() => { open = false; oncancel?.(); }}
|
|
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
|
>Cancel</button>
|
|
<button
|
|
onclick={() => { open = false; onconfirm?.(); }}
|
|
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
|
>Confirm</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
```
|
|
|
|
- [ ] **Step 5: Create FileUpload component**
|
|
|
|
Create `src/lib/components/ui/FileUpload.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
let { accept = 'image/*', multiple = false, onfiles }: {
|
|
accept?: string;
|
|
multiple?: boolean;
|
|
onfiles?: (files: FileList) => void;
|
|
} = $props();
|
|
|
|
let dragging = $state(false);
|
|
let input: HTMLInputElement;
|
|
|
|
function handleDrop(e: DragEvent) {
|
|
e.preventDefault();
|
|
dragging = false;
|
|
if (e.dataTransfer?.files.length) {
|
|
onfiles?.(e.dataTransfer.files);
|
|
}
|
|
}
|
|
|
|
function handleChange(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
if (target.files?.length) {
|
|
onfiles?.(target.files);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors
|
|
{dragging ? 'border-primary bg-primary/5' : 'border-gray-300 hover:border-primary'}"
|
|
ondragover={(e) => { e.preventDefault(); dragging = true; }}
|
|
ondragleave={() => dragging = false}
|
|
ondrop={handleDrop}
|
|
onclick={() => input.click()}
|
|
role="button"
|
|
tabindex="0"
|
|
onkeydown={(e) => { if (e.key === 'Enter') input.click(); }}
|
|
>
|
|
<input bind:this={input} type="file" {accept} {multiple} class="hidden" onchange={handleChange} />
|
|
<p class="text-gray-500">Drop files here or click to browse</p>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 6: Create Pagination component**
|
|
|
|
Create `src/lib/components/ui/Pagination.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
let { page = 1, totalPages = 1, onpage }: {
|
|
page?: number;
|
|
totalPages?: number;
|
|
onpage?: (page: number) => void;
|
|
} = $props();
|
|
</script>
|
|
|
|
{#if totalPages > 1}
|
|
<nav class="flex items-center justify-center gap-2">
|
|
<button
|
|
onclick={() => onpage?.(page - 1)}
|
|
disabled={page <= 1}
|
|
class="px-3 py-1 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
|
>←</button>
|
|
|
|
<span class="px-3 py-1 text-sm text-gray-600">
|
|
Page {page} of {totalPages}
|
|
</span>
|
|
|
|
<button
|
|
onclick={() => onpage?.(page + 1)}
|
|
disabled={page >= totalPages}
|
|
class="px-3 py-1 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
|
>→</button>
|
|
</nav>
|
|
{/if}
|
|
```
|
|
|
|
- [ ] **Step 7: Create PetSearchSelect component**
|
|
|
|
Create `src/lib/components/ui/PetSearchSelect.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
let { value = $bindable<number | null>(null), placeholder = 'Search for a pet...' }: {
|
|
value?: number | null;
|
|
placeholder?: string;
|
|
} = $props();
|
|
|
|
let query = $state('');
|
|
let results = $state<Array<{ id: number; name: string }>>([]);
|
|
let open = $state(false);
|
|
let selected = $state<string>('');
|
|
|
|
async function search() {
|
|
if (query.length < 2) { results = []; return; }
|
|
const res = await fetch(`/api/pets?q=${encodeURIComponent(query)}&limit=10`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
results = data.pets || [];
|
|
}
|
|
}
|
|
|
|
function select(pet: { id: number; name: string }) {
|
|
value = pet.id;
|
|
selected = pet.name;
|
|
query = pet.name;
|
|
open = false;
|
|
}
|
|
</script>
|
|
|
|
<div class="relative">
|
|
<input
|
|
type="text"
|
|
bind:value={query}
|
|
{placeholder}
|
|
oninput={search}
|
|
onfocus={() => 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}
|
|
<ul class="absolute z-10 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
|
{#each results as pet}
|
|
<li>
|
|
<button
|
|
class="w-full px-4 py-2 text-left hover:bg-gray-50"
|
|
onclick={() => select(pet)}
|
|
>{pet.name}</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 8: Create DataTable component**
|
|
|
|
Create `src/lib/components/admin/DataTable.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import type { Snippet } from 'svelte';
|
|
|
|
let { headers, children }: {
|
|
headers: string[];
|
|
children: Snippet;
|
|
} = $props();
|
|
</script>
|
|
|
|
<div class="overflow-x-auto bg-white rounded-xl shadow">
|
|
<table class="w-full">
|
|
<thead>
|
|
<tr class="border-b bg-gray-50">
|
|
{#each headers as header}
|
|
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">{header}</th>
|
|
{/each}
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-100">
|
|
{@render children()}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add src/lib/components/
|
|
git commit -m "feat: add UI component library (toast, search, badges, modals, upload, table)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 22: Public Layout Components
|
|
|
|
**Files:**
|
|
- Create: `src/lib/components/public/Header.svelte`
|
|
- Create: `src/lib/components/public/Footer.svelte`
|
|
- Create: `src/lib/components/public/PetCard.svelte`
|
|
- Create: `src/lib/components/public/SponsorStrip.svelte`
|
|
- Create: `src/lib/components/public/StatsCounter.svelte`
|
|
- Create: `src/lib/components/public/NewsletterSignup.svelte`
|
|
- Create: `src/lib/components/public/DonateButton.svelte`
|
|
|
|
- [ ] **Step 1: Create Header component**
|
|
|
|
Create `src/lib/components/public/Header.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
let { orgName = 'FosterFlow', logoUrl = '' }: {
|
|
orgName?: string;
|
|
logoUrl?: string;
|
|
} = $props();
|
|
|
|
let mobileOpen = $state(false);
|
|
|
|
const navItems = [
|
|
{ href: '/pets', label: 'Our Pets' },
|
|
{ href: '/about', label: 'About' },
|
|
{ href: '/events', label: 'Events' },
|
|
{ href: '/happy-tails', label: 'Happy Tails' },
|
|
{ href: '/how-to-help', label: 'How to Help', children: [
|
|
{ href: '/become-a-foster', label: 'Become a Foster' },
|
|
{ href: '/volunteer', label: 'Volunteer' },
|
|
{ href: '/donate', label: 'Donate' },
|
|
{ href: '/sponsors', label: 'Sponsors' }
|
|
]},
|
|
{ href: '/contact', label: 'Contact' }
|
|
];
|
|
</script>
|
|
|
|
<header class="bg-white shadow-sm sticky top-0 z-50">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div class="flex items-center justify-between h-16">
|
|
<a href="/" class="flex items-center gap-2">
|
|
{#if logoUrl}
|
|
<img src={logoUrl} alt={orgName} class="h-10 w-auto" />
|
|
{:else}
|
|
<span class="text-xl font-bold text-primary">{orgName}</span>
|
|
{/if}
|
|
</a>
|
|
|
|
<!-- Desktop nav -->
|
|
<nav class="hidden lg:flex items-center gap-6">
|
|
{#each navItems as item}
|
|
{#if item.children}
|
|
<div class="relative group">
|
|
<button class="text-gray-700 hover:text-primary transition">{item.label}</button>
|
|
<div class="absolute top-full left-0 hidden group-hover:block bg-white shadow-lg rounded-lg py-2 min-w-[180px]">
|
|
{#each item.children as child}
|
|
<a href={child.href} class="block px-4 py-2 text-gray-700 hover:bg-gray-50 hover:text-primary">{child.label}</a>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<a href={item.href} class="text-gray-700 hover:text-primary transition">{item.label}</a>
|
|
{/if}
|
|
{/each}
|
|
<a href="/donate" class="px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent-dark transition font-medium">Donate</a>
|
|
</nav>
|
|
|
|
<!-- Mobile toggle -->
|
|
<button class="lg:hidden p-2" onclick={() => mobileOpen = !mobileOpen}>
|
|
<span class="text-2xl">{mobileOpen ? '✕' : '☰'}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Mobile nav -->
|
|
{#if mobileOpen}
|
|
<nav class="lg:hidden pb-4 border-t">
|
|
{#each navItems as item}
|
|
<a href={item.href} class="block py-2 text-gray-700" onclick={() => mobileOpen = false}>{item.label}</a>
|
|
{#if item.children}
|
|
{#each item.children as child}
|
|
<a href={child.href} class="block py-2 pl-4 text-gray-500 text-sm" onclick={() => mobileOpen = false}>{child.label}</a>
|
|
{/each}
|
|
{/if}
|
|
{/each}
|
|
<a href="/donate" class="block mt-2 px-4 py-2 bg-accent text-white rounded-lg text-center font-medium">Donate</a>
|
|
</nav>
|
|
{/if}
|
|
</div>
|
|
</header>
|
|
```
|
|
|
|
- [ ] **Step 2: Create Footer component**
|
|
|
|
Create `src/lib/components/public/Footer.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import SponsorStrip from './SponsorStrip.svelte';
|
|
import NewsletterSignup from './NewsletterSignup.svelte';
|
|
|
|
let { orgName = 'FosterFlow', sponsors = [], facebookUrl = '', instagramUrl = '' }: {
|
|
orgName?: string;
|
|
sponsors?: Array<{ id: number; name: string; logoUrl: string | null; websiteUrl: string | null; tier: string }>;
|
|
facebookUrl?: string;
|
|
instagramUrl?: string;
|
|
} = $props();
|
|
</script>
|
|
|
|
<footer class="bg-gray-900 text-white">
|
|
{#if sponsors.length > 0}
|
|
<SponsorStrip {sponsors} />
|
|
{/if}
|
|
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
|
<div>
|
|
<h3 class="font-bold text-lg mb-4">{orgName}</h3>
|
|
<p class="text-gray-400">Saving lives, one paw at a time.</p>
|
|
</div>
|
|
<div>
|
|
<h4 class="font-bold mb-4">Quick Links</h4>
|
|
<ul class="space-y-2 text-gray-400">
|
|
<li><a href="/pets" class="hover:text-white">Our Pets</a></li>
|
|
<li><a href="/about" class="hover:text-white">About</a></li>
|
|
<li><a href="/donate" class="hover:text-white">Donate</a></li>
|
|
<li><a href="/contact" class="hover:text-white">Contact</a></li>
|
|
<li><a href="/apply/adopt" class="hover:text-white">Apply to Adopt</a></li>
|
|
<li><a href="/apply/foster" class="hover:text-white">Apply to Foster</a></li>
|
|
</ul>
|
|
</div>
|
|
<div>
|
|
<h4 class="font-bold mb-4">Stay Connected</h4>
|
|
<NewsletterSignup />
|
|
<div class="flex gap-4 mt-4">
|
|
{#if facebookUrl}
|
|
<a href={facebookUrl} target="_blank" rel="noopener" class="text-gray-400 hover:text-white">Facebook</a>
|
|
{/if}
|
|
{#if instagramUrl}
|
|
<a href={instagramUrl} target="_blank" rel="noopener" class="text-gray-400 hover:text-white">Instagram</a>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border-t border-gray-800 mt-8 pt-8 text-center text-gray-500 text-sm">
|
|
<p>© {new Date().getFullYear()} {orgName}. All rights reserved.</p>
|
|
<p class="mt-1">Powered by <a href="https://fosterflow.app" class="text-primary hover:underline">FosterFlow</a></p>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
```
|
|
|
|
- [ ] **Step 3: Create PetCard component**
|
|
|
|
Create `src/lib/components/public/PetCard.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
let { pet }: {
|
|
pet: {
|
|
id: number;
|
|
name: string;
|
|
slug: string;
|
|
species: string;
|
|
breed: string | null;
|
|
age: string | null;
|
|
sex: string;
|
|
photoUrl: string | null;
|
|
};
|
|
} = $props();
|
|
</script>
|
|
|
|
<a href="/pets/{pet.slug}" class="group block bg-white rounded-xl shadow-sm overflow-hidden hover:shadow-md transition">
|
|
<div class="aspect-[4/3] bg-gray-200 overflow-hidden">
|
|
{#if pet.photoUrl}
|
|
<img src={pet.photoUrl} alt={pet.name} class="w-full h-full object-cover group-hover:scale-105 transition duration-300" />
|
|
{:else}
|
|
<div class="w-full h-full flex items-center justify-center text-gray-400 text-4xl">🐾</div>
|
|
{/if}
|
|
</div>
|
|
<div class="p-4">
|
|
<h3 class="font-bold text-lg text-gray-900">{pet.name}</h3>
|
|
<p class="text-sm text-gray-500">
|
|
{pet.breed || pet.species}
|
|
{#if pet.age} · {pet.age}{/if}
|
|
· {pet.sex}
|
|
</p>
|
|
</div>
|
|
</a>
|
|
```
|
|
|
|
- [ ] **Step 4: Create SponsorStrip component**
|
|
|
|
Create `src/lib/components/public/SponsorStrip.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
let { sponsors }: {
|
|
sponsors: Array<{ id: number; name: string; logoUrl: string | null; websiteUrl: string | null; tier: string }>;
|
|
} = $props();
|
|
</script>
|
|
|
|
<div class="bg-gray-800 py-6">
|
|
<div class="max-w-7xl mx-auto px-4 flex items-center justify-center gap-8 flex-wrap">
|
|
{#each sponsors as sponsor}
|
|
{#if sponsor.logoUrl}
|
|
<a href={sponsor.websiteUrl || '#'} target="_blank" rel="noopener" class="opacity-70 hover:opacity-100 transition">
|
|
<img src={sponsor.logoUrl} alt={sponsor.name} class="h-10 w-auto" />
|
|
</a>
|
|
{:else}
|
|
<span class="text-gray-400 text-sm">{sponsor.name}</span>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 5: Create StatsCounter component**
|
|
|
|
Create `src/lib/components/public/StatsCounter.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
let { stats }: {
|
|
stats: Array<{ label: string; value: number }>;
|
|
} = $props();
|
|
</script>
|
|
|
|
<div class="grid grid-cols-2 md:grid-cols-{stats.length} gap-6">
|
|
{#each stats as stat}
|
|
<div class="text-center">
|
|
<div class="text-3xl md:text-4xl font-bold text-primary">{stat.value.toLocaleString()}</div>
|
|
<div class="text-sm text-gray-600 mt-1">{stat.label}</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
```
|
|
|
|
- [ ] **Step 6: Create NewsletterSignup component**
|
|
|
|
Create `src/lib/components/public/NewsletterSignup.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
let email = $state('');
|
|
let submitted = $state(false);
|
|
|
|
async function subscribe(e: Event) {
|
|
e.preventDefault();
|
|
const res = await fetch('/api/newsletter', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email })
|
|
});
|
|
if (res.ok) {
|
|
submitted = true;
|
|
email = '';
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if submitted}
|
|
<p class="text-green-400 text-sm">Thanks for subscribing!</p>
|
|
{:else}
|
|
<form onsubmit={subscribe} class="flex gap-2">
|
|
<input
|
|
type="email"
|
|
bind:value={email}
|
|
placeholder="Your email"
|
|
required
|
|
class="flex-1 px-3 py-2 rounded-lg text-gray-900 text-sm"
|
|
/>
|
|
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm">
|
|
Subscribe
|
|
</button>
|
|
</form>
|
|
{/if}
|
|
```
|
|
|
|
- [ ] **Step 7: Create DonateButton component**
|
|
|
|
Create `src/lib/components/public/DonateButton.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
let { label = 'Donate Now', href = '/donate' }: {
|
|
label?: string;
|
|
href?: string;
|
|
} = $props();
|
|
</script>
|
|
|
|
<a {href} class="inline-block px-6 py-3 bg-accent text-white font-bold rounded-lg hover:bg-accent-dark transition shadow-sm">
|
|
{label}
|
|
</a>
|
|
```
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add src/lib/components/public/
|
|
git commit -m "feat: add public site components (header, footer, pet card, sponsors, etc.)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 23: Root Layout + Public Layout
|
|
|
|
**Files:**
|
|
- Create: `src/routes/+layout.svelte`
|
|
- Create: `src/routes/+layout.server.ts`
|
|
- Create: `src/routes/(public)/+layout.svelte`
|
|
- Create: `src/routes/(public)/+layout.server.ts`
|
|
|
|
- [ ] **Step 1: Create root layout**
|
|
|
|
Create `src/routes/+layout.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import '../app.css';
|
|
import Toast from '$lib/components/ui/Toast.svelte';
|
|
|
|
let { children } = $props();
|
|
</script>
|
|
|
|
{@render children()}
|
|
<Toast />
|
|
```
|
|
|
|
- [ ] **Step 2: Create root layout server**
|
|
|
|
Create `src/routes/+layout.server.ts`:
|
|
|
|
```typescript
|
|
import type { LayoutServerLoad } from './$types';
|
|
|
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
|
return {
|
|
user: locals.user,
|
|
orgConfig: locals.orgConfig
|
|
};
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 3: Create public layout**
|
|
|
|
Create `src/routes/(public)/+layout.svelte`:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import Header from '$lib/components/public/Header.svelte';
|
|
import Footer from '$lib/components/public/Footer.svelte';
|
|
|
|
let { data, children } = $props();
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<meta name="description" content="{data.orgConfig.orgInfo.name} — {data.orgConfig.orgInfo.tagline || 'A foster-based animal rescue'}" />
|
|
<meta property="og:site_name" content={data.orgConfig.orgInfo.name} />
|
|
<meta property="og:type" content="website" />
|
|
</svelte:head>
|
|
|
|
<div class="min-h-screen flex flex-col bg-warm-bg">
|
|
<Header
|
|
orgName={data.orgConfig.orgInfo.name}
|
|
logoUrl={data.orgConfig.branding.logoUrl}
|
|
/>
|
|
<main class="flex-1">
|
|
{@render children()}
|
|
</main>
|
|
<Footer
|
|
orgName={data.orgConfig.orgInfo.name}
|
|
sponsors={data.sponsors}
|
|
facebookUrl={data.orgConfig.orgInfo.facebookUrl}
|
|
instagramUrl={data.orgConfig.orgInfo.instagramUrl}
|
|
/>
|
|
</div>
|
|
|
|
<svelte:head>
|
|
{@html `<style>:root{--color-primary:${data.orgConfig.branding.primaryColor};--color-primary-light:${data.orgConfig.branding.primaryColorLight};--color-primary-dark:${data.orgConfig.branding.primaryColorDark};--color-accent:${data.orgConfig.branding.accentColor};--color-accent-light:${data.orgConfig.branding.accentColorLight};--color-accent-dark:${data.orgConfig.branding.accentColorDark}}</style>`}
|
|
</svelte:head>
|
|
```
|
|
|
|
- [ ] **Step 4: Create public layout server**
|
|
|
|
Create `src/routes/(public)/+layout.server.ts`:
|
|
|
|
```typescript
|
|
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 };
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/routes/+layout.svelte src/routes/+layout.server.ts src/routes/'(public)'/
|
|
git commit -m "feat: add root and public layouts with config-driven theming"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4-9: Remaining Implementation
|
|
|
|
The remaining phases follow the same TDD pattern. Due to the size of this plan, they are outlined at task level with file lists. Each task follows the pattern: write failing test → verify failure → implement → verify pass → commit.
|
|
|
|
### Task 24: Home Page
|
|
|
|
**Files:**
|
|
- Create: `src/routes/(public)/+page.svelte`
|
|
- Create: `src/routes/(public)/+page.server.ts`
|
|
|
|
Server loads: featured pets (with primary photos), upcoming events, content blocks for home page, org stats (pet counts by status).
|
|
|
|
Page renders: hero section with org name/tagline from orgConfig, featured pet grid using PetCard, stats counter, upcoming events list, newsletter signup.
|
|
|
|
---
|
|
|
|
### Task 25: Public Pets Page
|
|
|
|
**Files:**
|
|
- Create: `src/routes/(public)/pets/+page.svelte`
|
|
- Create: `src/routes/(public)/pets/+page.server.ts`
|
|
- Create: `src/routes/(public)/pets/[slug]/+page.svelte`
|
|
- Create: `src/routes/(public)/pets/[slug]/+page.server.ts`
|
|
- Create: `src/routes/api/pets/+server.ts`
|
|
|
|
Pets listing with filters (species, age, sex, search). Pet detail page with photo gallery, description, medical info, adoption fee, apply button. API endpoint for infinite scroll / search.
|
|
|
|
---
|
|
|
|
### Task 26: Static Content Pages
|
|
|
|
**Files:**
|
|
- Create: `src/routes/(public)/about/+page.svelte` + server
|
|
- Create: `src/routes/(public)/adoption-process/+page.svelte` + server
|
|
- Create: `src/routes/(public)/become-a-foster/+page.svelte` + server
|
|
- Create: `src/routes/(public)/how-to-help/+page.svelte` + server
|
|
- Create: `src/routes/(public)/volunteer/+page.svelte` + server
|
|
- Create: `src/routes/(public)/why-adopt/+page.svelte` + server
|
|
- Create: `src/routes/(public)/foster-resources/+page.svelte` + server
|
|
|
|
All content-driven pages. Server loads content blocks for each page from DB. Pages render content blocks with fallback placeholder text. Foster resources page has optional password protection via env var.
|
|
|
|
---
|
|
|
|
### Task 27: Contact, Events, Happy Tails, Sponsors, Donate Public Pages
|
|
|
|
**Files:**
|
|
- Create: `src/routes/(public)/contact/+page.svelte` + server
|
|
- Create: `src/routes/(public)/events/+page.svelte` + server
|
|
- Create: `src/routes/(public)/happy-tails/+page.svelte` + server
|
|
- Create: `src/routes/(public)/happy-tails/submit/+page.svelte` + server
|
|
- Create: `src/routes/(public)/sponsors/+page.svelte` + server
|
|
- Create: `src/routes/(public)/donate/+page.svelte` + server
|
|
|
|
Contact form with rate limiting and email notification. Events list from DB. Happy tails stories with public submit form. Sponsor page with tiered display. Donate page with Stripe checkout integration.
|
|
|
|
---
|
|
|
|
### Task 28: Application Forms
|
|
|
|
**Files:**
|
|
- Create: `src/routes/(public)/apply/adopt/+page.svelte` + server
|
|
- Create: `src/routes/(public)/apply/foster/+page.svelte` + server
|
|
- Create: `src/routes/(public)/apply/volunteer/+page.svelte` + server
|
|
- Create: `src/routes/(public)/apply/surrender/+page.svelte` + server
|
|
|
|
Each form type renders configurable questions from orgConfig. Server validates, stores JSON formData, sends email notification to staff. Shows conditional fields based on orgConfig.applications (enabled/disabled per type).
|
|
|
|
---
|
|
|
|
### Task 29: AI Name Generator
|
|
|
|
**Files:**
|
|
- Create: `src/routes/(public)/name-generator/+page.svelte` + server
|
|
- Create: `src/routes/api/pet-names/+server.ts`
|
|
- Create: `scripts/ai-worker.ts`
|
|
|
|
Public page with species selector and theme picker (classic, food, disney, nature, funny). POST to API queues job. GET polls status. Worker script fetches jobs from API, calls Ollama, returns results.
|
|
|
|
---
|
|
|
|
### Task 30: Auth Pages
|
|
|
|
**Files:**
|
|
- Create: `src/routes/login/+page.svelte` + server
|
|
- Create: `src/routes/logout/+page.server.ts`
|
|
- Create: `src/routes/register/+page.svelte` + server
|
|
- Create: `src/routes/change-password/+page.svelte` + server
|
|
|
|
Login with rate limiting and role-based redirect (foster → /foster, admin → /admin). Logout destroys session. Register creates inactive user, sends notification. Change password with current password verification.
|
|
|
|
---
|
|
|
|
### Task 31: Admin Layout + Dashboard
|
|
|
|
**Files:**
|
|
- Create: `src/routes/admin/+layout.svelte`
|
|
- Create: `src/routes/admin/+layout.server.ts`
|
|
- Create: `src/routes/admin/+page.svelte`
|
|
- Create: `src/routes/admin/+page.server.ts`
|
|
|
|
Admin layout: sidebar with permission-filtered navigation (same navItems pattern as AHCR), mobile hamburger, user info display. Dashboard: stat cards (pet counts, pending apps, recent donations), recent audit log, pending moderation badge.
|
|
|
|
---
|
|
|
|
### Task 32: Admin Pet CRUD
|
|
|
|
**Files:**
|
|
- Create: `src/routes/admin/pets/+page.svelte` + server
|
|
- Create: `src/routes/admin/pets/new/+page.svelte` + server
|
|
- Create: `src/routes/admin/pets/[id]/+page.svelte` + server
|
|
- Create: `src/routes/admin/pets/[id]/photos/+page.svelte` + server
|
|
- Create: `src/routes/admin/pets/[id]/medical/+page.svelte` + server
|
|
- Create: `src/routes/admin/pets/[id]/fosters/+page.svelte` + server
|
|
- Create: `src/routes/admin/pets/[id]/profile/+page.svelte` + server
|
|
- Create: `src/routes/admin/intake/+page.svelte` + server
|
|
|
|
Pet list with search/filter/sort. New pet form (generates slug from name). Pet detail tabs: profile edit, photo management (upload, reorder, set primary), medical records CRUD, foster assignment history. Quick intake form for fast pet creation.
|
|
|
|
---
|
|
|
|
### Task 33: Admin Applications
|
|
|
|
**Files:**
|
|
- Create: `src/routes/admin/applications/+page.svelte` + server
|
|
- Create: `src/routes/admin/applications/[id]/+page.svelte` + server
|
|
- Create: `src/routes/admin/applications/[id]/contract/+page.svelte` + server
|
|
|
|
Application list filtered by type/status. Detail view with full formData display, status change actions (approve, deny), homevisit notes CRUD. Contract page renders HTML adoption contract via contract.ts.
|
|
|
|
---
|
|
|
|
### Task 34: Admin Finance (Donations, Expenses, Reports)
|
|
|
|
**Files:**
|
|
- Create: `src/routes/admin/donations/+page.svelte` + server (+[id], +new)
|
|
- Create: `src/routes/admin/expenses/+page.svelte` + server (+[id], +new)
|
|
- Create: `src/routes/admin/reports/+page.svelte` + server
|
|
|
|
Donation and expense CRUD. Reports page: income vs expenses by month, per-pet cost summary, donation source breakdown.
|
|
|
|
---
|
|
|
|
### Task 35: Admin People (Vets, Sponsors, Mentors, Volunteers, Users)
|
|
|
|
**Files:**
|
|
- Create: routes for vets, sponsors, mentors, volunteers, users (CRUD pattern for each)
|
|
|
|
Each follows the same list → new → [id] detail pattern. Permission-gated per role matrix.
|
|
|
|
---
|
|
|
|
### Task 36: Admin Content (CMS, Newsletter, Happy Tails, Events, Messages)
|
|
|
|
**Files:**
|
|
- Create: `src/routes/admin/content/+page.svelte` + server
|
|
- Create: `src/routes/admin/newsletter/+page.svelte` + server (+compose)
|
|
- Create: `src/routes/admin/happy-tails/+page.svelte` + server (+[id], +new)
|
|
- Create: `src/routes/admin/events/+page.svelte` + server (+[id], +new)
|
|
- Create: `src/routes/admin/messages/+page.svelte` + server
|
|
|
|
Content editor: per-page section editing with JSON content. Newsletter: compose and send to all subscribers. Happy tails CRUD with publish/feature toggles. Event CRUD. Message inbox with read/unread.
|
|
|
|
---
|
|
|
|
### Task 37: Admin Analytics, Traffic, Moderation, System
|
|
|
|
**Files:**
|
|
- Create: `src/routes/admin/analytics/+page.svelte` + server
|
|
- Create: `src/routes/admin/traffic/+page.svelte` + server
|
|
- Create: `src/routes/admin/moderation/+page.svelte` + server
|
|
- Create: `src/routes/admin/foster-dashboard/+page.svelte` + server
|
|
- Create: `src/routes/admin/system/+page.svelte` + server
|
|
|
|
Analytics: page view charts, top pages, bot vs human traffic. Live traffic: recent page views stream. Moderation: pending foster submissions and supply requests with approve/reject. Foster dashboard: admin view of all active fosters and their pets. System: site settings, org config, blocked bots, content block management.
|
|
|
|
---
|
|
|
|
### Task 38: Admin CSV Import
|
|
|
|
**Files:**
|
|
- Create: `src/routes/admin/import/+page.svelte` + server
|
|
|
|
Generic CSV import: upload file → parse → show column mapping UI → preview → confirm → insert into DB. Map CSV columns to pet fields (name, breed, age, status, etc.). Validate and show errors for bad rows. Usable from setup wizard step 5 and admin import page.
|
|
|
|
---
|
|
|
|
### Task 39: Foster Portal
|
|
|
|
**Files:**
|
|
- Create: `src/routes/foster/+layout.svelte`
|
|
- Create: `src/routes/foster/+layout.server.ts`
|
|
- Create: `src/routes/foster/+page.svelte` + server
|
|
- Create: `src/routes/foster/submit/+page.svelte` + server
|
|
- Create: `src/routes/foster/supplies/+page.svelte` + server
|
|
|
|
Foster layout: simple sidebar (Dashboard, Submit Update, Request Supplies, Logout). Dashboard: list of assigned pets with status. Submit: photo/note/update form for a specific pet. Supplies: request form with items and urgency.
|
|
|
|
---
|
|
|
|
### Task 40: Setup Wizard
|
|
|
|
**Files:**
|
|
- Create: `src/routes/setup/+page.svelte`
|
|
- Create: `src/routes/setup/+page.server.ts`
|
|
|
|
Multi-step wizard (5 steps as defined in spec): org info, branding (color pickers + logo upload), application form config (toggle types, set fee), integrations (Stripe keys, SMTP, Facebook), optional CSV import. Each step saves to org_config table. Final step sets setupComplete = true. Only accessible by sysadmin when setupComplete is false.
|
|
|
|
---
|
|
|
|
### Task 41: API Routes + SEO
|
|
|
|
**Files:**
|
|
- Create: `src/routes/api/health/+server.ts`
|
|
- Create: `src/routes/api/newsletter/+server.ts`
|
|
- Create: `src/routes/api/stripe-webhook/+server.ts`
|
|
- Create: `src/routes/api/worker/jobs/+server.ts`
|
|
- Create: `src/routes/api/worker/jobs/[id]/+server.ts`
|
|
- Create: `src/routes/robots.txt/+server.ts`
|
|
- Create: `src/routes/sitemap.xml/+server.ts`
|
|
- Create: `src/routes/pay/+page.svelte` + server
|
|
|
|
Health endpoint returns { status: 'ok', timestamp }. Newsletter subscribe API. Stripe webhook handler (checkout.session.completed). Worker job API (get next job, report completion). Dynamic robots.txt with blocked bot support. Dynamic sitemap with all pages + pet pages. Pay routes for Stripe redirect flows.
|
|
|
|
---
|
|
|
|
### Task 42: Seed Scripts
|
|
|
|
**Files:**
|
|
- Create: `scripts/seed.ts`
|
|
- Create: `scripts/seed-tenant.ts`
|
|
|
|
`seed.ts`: creates default org_config rows, default content blocks with placeholder text, default intake sources, seed an admin user. For self-hosted fresh install.
|
|
|
|
`seed-tenant.ts`: accepts CLI args (email, name, password, orgName), creates admin user with mustChangePassword=true, creates minimal org_config. Used by cloud provisioner.
|
|
|
|
---
|
|
|
|
### Task 43: Docker + Deployment
|
|
|
|
**Files:**
|
|
- Create: `Dockerfile`
|
|
- Create: `docker-compose.yml`
|
|
- Create: `Caddyfile`
|
|
|
|
Dockerfile: multi-stage Node.js build (install → build → production). Docker compose: MariaDB (with healthcheck), app (depends on DB), Caddy (ports 80/443). Caddyfile: reverse proxy to app, serve uploads directory.
|
|
|
|
---
|
|
|
|
### Task 44: E2E Tests
|
|
|
|
**Files:**
|
|
- Create: `playwright.config.ts`
|
|
- Create: `tests/e2e/login.test.ts`
|
|
- Create: `tests/e2e/admin-guard.test.ts`
|
|
- Create: `tests/e2e/setup-wizard.test.ts`
|
|
- Create: `tests/e2e/public-pages.test.ts`
|
|
|
|
Playwright config with dev server. Tests: login form validation, invalid/valid credentials, admin route redirect for unauthenticated users, setup wizard flow completion, public pages render correctly.
|
|
|
|
---
|
|
|
|
### Task 45: Error Page + Final Polish
|
|
|
|
**Files:**
|
|
- Create: `src/routes/+error.svelte`
|
|
|
|
Error page with friendly message, back button, and org branding. Final verification: run all unit tests, run all e2e tests, verify Docker build, verify `npm run build` succeeds.
|
|
|
|
---
|
|
|
|
## Execution Notes
|
|
|
|
- **Tasks 1-23** are fully detailed with code — start here
|
|
- **Tasks 24-45** follow the same TDD pattern with file lists and descriptions — expand code during execution
|
|
- Each task is independent enough to be a single commit
|
|
- The AHCR codebase at `../almosthomecaninerescue.com-2026/ahcr/` is the reference for all route implementations
|
|
- When implementing Tasks 24-45, read the corresponding AHCR file first, then rebuild it clean with config-driven strings instead of hardcoded values
|