From 22f0ded88f829ad7e9f04c16d4d7567989daa991 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 11:53:30 -0500 Subject: [PATCH] feat: add database schema and connection pool Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/db.ts | 11 + src/lib/server/schema.ts | 423 ++++++++++++++++++++++++++++++++++++++ tests/unit/schema.test.ts | 19 ++ vitest.config.ts | 14 ++ 4 files changed, 467 insertions(+) create mode 100644 src/lib/server/db.ts create mode 100644 src/lib/server/schema.ts create mode 100644 tests/unit/schema.test.ts create mode 100644 vitest.config.ts diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts new file mode 100644 index 0000000..24d88a3 --- /dev/null +++ b/src/lib/server/db.ts @@ -0,0 +1,11 @@ +import { drizzle } from 'drizzle-orm/mysql2'; +import mysql from 'mysql2/promise'; +import * as schema from './schema'; + +const pool = mysql.createPool({ + uri: process.env.DATABASE_URL, + waitForConnections: true, + connectionLimit: 10 +}); + +export const db = drizzle(pool, { schema, mode: 'default' }); diff --git a/src/lib/server/schema.ts b/src/lib/server/schema.ts new file mode 100644 index 0000000..c941c49 --- /dev/null +++ b/src/lib/server/schema.ts @@ -0,0 +1,423 @@ +import { + mysqlTable, varchar, int, text, boolean, timestamp, json, + mysqlEnum, uniqueIndex, index, serial +} from 'drizzle-orm/mysql-core'; +import { relations } from 'drizzle-orm'; + +// ── Auth ────────────────────────────────────────── + +export const users = mysqlTable('users', { + id: serial().primaryKey(), + email: varchar({ length: 255 }).notNull().unique(), + passwordHash: varchar('password_hash', { length: 255 }).notNull(), + name: varchar({ length: 255 }).notNull(), + role: mysqlEnum(['sysadmin', 'director', 'foster_coordinator', 'volunteer_manager', 'vet_liaison', 'content_editor', 'applications_manager', 'viewer', 'foster']).notNull().default('viewer'), + active: boolean().notNull().default(false), + mustChangePassword: boolean('must_change_password').notNull().default(false), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow() +}); + +export const sessions = mysqlTable('sessions', { + sessionId: varchar('session_id', { length: 255 }).primaryKey(), + userId: int('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + expiresAt: timestamp('expires_at').notNull() +}); + +// ── Pets ────────────────────────────────────────── + +export const intakeSources = mysqlTable('intake_sources', { + id: serial().primaryKey(), + name: varchar({ length: 255 }).notNull(), + active: boolean().notNull().default(true) +}); + +export const vets = mysqlTable('vets', { + id: serial().primaryKey(), + name: varchar({ length: 255 }).notNull(), + clinic: varchar({ length: 255 }), + phone: varchar({ length: 50 }), + email: varchar({ length: 255 }), + address: text(), + notes: text(), + active: boolean().notNull().default(true), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const pets = mysqlTable('pets', { + id: serial().primaryKey(), + name: varchar({ length: 255 }).notNull(), + slug: varchar({ length: 255 }).notNull().unique(), + species: mysqlEnum(['dog', 'cat', 'other']).notNull().default('dog'), + breed: varchar({ length: 255 }), + color: varchar({ length: 255 }), + markings: text(), + sex: mysqlEnum(['male', 'female', 'unknown']).notNull().default('unknown'), + weight: varchar({ length: 50 }), + age: varchar({ length: 100 }), + birthday: timestamp(), + status: mysqlEnum([ + 'available', 'adopted', 'foster', 'medical_hold', 'behavioral_hold', + 'pending', 'trial', 'hospice', 'nursing', 'whelping', 'deceased', 'transferred' + ]).notNull().default('available'), + description: text(), + medicalNotes: text('medical_notes'), + behavioralNotes: text('behavioral_notes'), + specialNeeds: text('special_needs'), + goodWithDogs: boolean('good_with_dogs'), + goodWithCats: boolean('good_with_cats'), + goodWithKids: boolean('good_with_kids'), + houseTrained: boolean('house_trained'), + spayedNeutered: boolean('spayed_neutered'), + microchipped: boolean(), + microchipId: varchar('microchip_id', { length: 100 }), + adoptionFee: int('adoption_fee'), + adoptionFeeCollected: boolean('adoption_fee_collected').default(false), + intakeDate: timestamp('intake_date'), + intakeSourceId: int('intake_source_id').references(() => intakeSources.id), + adoptedDate: timestamp('adopted_date'), + adopterName: varchar('adopter_name', { length: 255 }), + adopterEmail: varchar('adopter_email', { length: 255 }), + adopterPhone: varchar('adopter_phone', { length: 50 }), + vetId: int('vet_id').references(() => vets.id), + featured: boolean().notNull().default(false), + published: boolean().notNull().default(true), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow() +}, (table) => [ + index('status_idx').on(table.status), + index('species_idx').on(table.species), + index('featured_idx').on(table.featured) +]); + +export const petPhotos = mysqlTable('pet_photos', { + id: serial().primaryKey(), + petId: int('pet_id').notNull().references(() => pets.id, { onDelete: 'cascade' }), + url: varchar({ length: 500 }).notNull(), + alt: varchar({ length: 255 }), + isPrimary: boolean('is_primary').notNull().default(false), + sortOrder: int('sort_order').notNull().default(0), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const petMedical = mysqlTable('pet_medical', { + id: serial().primaryKey(), + petId: int('pet_id').notNull().references(() => pets.id, { onDelete: 'cascade' }), + type: mysqlEnum([ + 'vaccine', 'surgery', 'checkup', 'medication', 'deworming', + 'flea_tick', 'heartworm_test', 'heartworm_treatment', + 'dental', 'spay_neuter', 'microchip', 'other' + ]).notNull(), + name: varchar({ length: 255 }).notNull(), + date: timestamp(), + vetId: int('vet_id').references(() => vets.id), + cost: int(), + notes: text(), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const fosterAssignments = mysqlTable('foster_assignments', { + id: serial().primaryKey(), + petId: int('pet_id').notNull().references(() => pets.id, { onDelete: 'cascade' }), + userId: int('user_id').notNull().references(() => users.id), + startDate: timestamp('start_date').notNull().defaultNow(), + endDate: timestamp('end_date'), + isCurrent: boolean('is_current').notNull().default(true), + notes: text(), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const mentors = mysqlTable('mentors', { + id: serial().primaryKey(), + userId: int('user_id').notNull().references(() => users.id).unique(), + bio: text(), + active: boolean().notNull().default(true), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const petMentors = mysqlTable('pet_mentors', { + id: serial().primaryKey(), + petId: int('pet_id').notNull().references(() => pets.id, { onDelete: 'cascade' }), + mentorId: int('mentor_id').notNull().references(() => mentors.id, { onDelete: 'cascade' }), + assignedAt: timestamp('assigned_at').notNull().defaultNow() +}); + +// ── Applications ────────────────────────────────── + +export const applications = mysqlTable('applications', { + id: serial().primaryKey(), + type: mysqlEnum(['adoption', 'foster', 'volunteer', 'surrender']).notNull(), + status: mysqlEnum(['pending', 'reviewing', 'approved', 'denied', 'withdrawn']).notNull().default('pending'), + applicantName: varchar('applicant_name', { length: 255 }).notNull(), + applicantEmail: varchar('applicant_email', { length: 255 }).notNull(), + applicantPhone: varchar('applicant_phone', { length: 50 }), + petId: int('pet_id').references(() => pets.id), + formData: json('form_data').notNull(), + reviewedBy: int('reviewed_by').references(() => users.id), + reviewedAt: timestamp('reviewed_at'), + reviewNotes: text('review_notes'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow() +}); + +export const applicationHomevisitNotes = mysqlTable('application_homevisit_notes', { + id: serial().primaryKey(), + applicationId: int('application_id').notNull().references(() => applications.id, { onDelete: 'cascade' }), + userId: int('user_id').notNull().references(() => users.id), + notes: text().notNull(), + visitDate: timestamp('visit_date'), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +// ── Finance ─────────────────────────────────────── + +export const sponsors = mysqlTable('sponsors', { + id: serial().primaryKey(), + name: varchar({ length: 255 }).notNull(), + tier: mysqlEnum(['top_dog', 'wagging_tails', 'wet_noses']).notNull().default('wet_noses'), + logoUrl: varchar('logo_url', { length: 500 }), + websiteUrl: varchar('website_url', { length: 500 }), + description: text(), + active: boolean().notNull().default(true), + sortOrder: int('sort_order').notNull().default(0), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const donations = mysqlTable('donations', { + id: serial().primaryKey(), + donorName: varchar('donor_name', { length: 255 }), + donorEmail: varchar('donor_email', { length: 255 }), + amount: int().notNull(), + source: mysqlEnum([ + 'stripe', 'paypal', 'venmo', 'givebutter', 'cash', + 'check', 'zelle', 'google_pay', 'square', 'other' + ]).notNull().default('other'), + petId: int('pet_id').references(() => pets.id), + stripePaymentId: varchar('stripe_payment_id', { length: 255 }), + isRecurring: boolean('is_recurring').notNull().default(false), + purpose: varchar({ length: 255 }), + notes: text(), + date: timestamp().notNull().defaultNow(), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const expenses = mysqlTable('expenses', { + id: serial().primaryKey(), + description: varchar({ length: 255 }).notNull(), + amount: int().notNull(), + category: varchar({ length: 100 }), + petId: int('pet_id').references(() => pets.id), + vetId: int('vet_id').references(() => vets.id), + receiptUrl: varchar('receipt_url', { length: 500 }), + date: timestamp().notNull().defaultNow(), + notes: text(), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +// ── Content ─────────────────────────────────────── + +export const events = mysqlTable('events', { + id: serial().primaryKey(), + title: varchar({ length: 255 }).notNull(), + description: text(), + location: varchar({ length: 255 }), + startDate: timestamp('start_date').notNull(), + endDate: timestamp('end_date'), + imageUrl: varchar('image_url', { length: 500 }), + published: boolean().notNull().default(true), + facebookEventId: varchar('facebook_event_id', { length: 100 }), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const happyTails = mysqlTable('happy_tails', { + id: serial().primaryKey(), + petId: int('pet_id').references(() => pets.id), + title: varchar({ length: 255 }).notNull(), + story: text().notNull(), + imageUrl: varchar('image_url', { length: 500 }), + adopterName: varchar('adopter_name', { length: 255 }), + featured: boolean().notNull().default(false), + published: boolean().notNull().default(false), + submittedBy: varchar('submitted_by', { length: 255 }), + submittedEmail: varchar('submitted_email', { length: 255 }), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const contentBlocks = mysqlTable('content_blocks', { + id: serial().primaryKey(), + page: varchar({ length: 100 }).notNull(), + sectionKey: varchar('section_key', { length: 100 }).notNull(), + content: json().notNull(), + updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow() +}, (table) => [ + uniqueIndex('page_section_idx').on(table.page, table.sectionKey) +]); + +export const newsletterSubscribers = mysqlTable('newsletter_subscribers', { + id: serial().primaryKey(), + email: varchar({ length: 255 }).notNull().unique(), + name: varchar({ length: 255 }), + subscribedAt: timestamp('subscribed_at').notNull().defaultNow(), + unsubscribedAt: timestamp('unsubscribed_at') +}); + +export const contactMessages = mysqlTable('contact_messages', { + id: serial().primaryKey(), + name: varchar({ length: 255 }).notNull(), + email: varchar({ length: 255 }).notNull(), + subject: varchar({ length: 255 }), + message: text().notNull(), + read: boolean().notNull().default(false), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +// ── Foster Portal ───────────────────────────────── + +export const fosterSubmissions = mysqlTable('foster_submissions', { + id: serial().primaryKey(), + userId: int('user_id').notNull().references(() => users.id), + petId: int('pet_id').notNull().references(() => pets.id, { onDelete: 'cascade' }), + type: mysqlEnum(['note', 'photo', 'update']).notNull().default('update'), + content: text(), + photoUrl: varchar('photo_url', { length: 500 }), + status: mysqlEnum(['pending', 'approved', 'rejected']).notNull().default('pending'), + reviewedBy: int('reviewed_by').references(() => users.id), + reviewedAt: timestamp('reviewed_at'), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +export const supplyRequests = mysqlTable('supply_requests', { + id: serial().primaryKey(), + userId: int('user_id').notNull().references(() => users.id), + petId: int('pet_id').references(() => pets.id), + items: text().notNull(), + urgency: mysqlEnum(['low', 'medium', 'high']).notNull().default('medium'), + status: mysqlEnum(['pending', 'approved', 'fulfilled', 'denied']).notNull().default('pending'), + notes: text(), + reviewedBy: int('reviewed_by').references(() => users.id), + reviewedAt: timestamp('reviewed_at'), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +// ── Config & System ─────────────────────────────── + +export const siteSettings = mysqlTable('site_settings', { + id: serial().primaryKey(), + key: varchar({ length: 100 }).notNull().unique(), + value: text().notNull(), + updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow() +}); + +export const orgConfig = mysqlTable('org_config', { + id: serial().primaryKey(), + key: varchar({ length: 100 }).notNull().unique(), + value: json().notNull(), + updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow() +}); + +export const auditLog = mysqlTable('audit_log', { + id: serial().primaryKey(), + userId: int('user_id').references(() => users.id), + action: varchar({ length: 100 }).notNull(), + entity: varchar({ length: 100 }), + entityId: int('entity_id'), + diff: json(), + ipAddress: varchar('ip_address', { length: 45 }), + createdAt: timestamp('created_at').notNull().defaultNow() +}, (table) => [ + index('action_idx').on(table.action), + index('entity_idx').on(table.entity, table.entityId) +]); + +export const pageViews = mysqlTable('page_views', { + id: serial().primaryKey(), + path: varchar({ length: 500 }).notNull(), + petId: int('pet_id').references(() => pets.id), + ipAddress: varchar('ip_address', { length: 45 }), + userAgent: text('user_agent'), + referrer: varchar({ length: 500 }), + isBot: boolean('is_bot').notNull().default(false), + createdAt: timestamp('created_at').notNull().defaultNow() +}, (table) => [ + index('path_idx').on(table.path), + index('created_idx').on(table.createdAt) +]); + +export const aiJobs = mysqlTable('ai_jobs', { + id: serial().primaryKey(), + type: varchar({ length: 50 }).notNull(), + input: json().notNull(), + status: mysqlEnum(['pending', 'processing', 'completed', 'failed']).notNull().default('pending'), + output: json(), + error: text(), + completedAt: timestamp('completed_at'), + createdAt: timestamp('created_at').notNull().defaultNow() +}); + +// ── Relations ───────────────────────────────────── + +export const usersRelations = relations(users, ({ many }) => ({ + sessions: many(sessions), + fosterAssignments: many(fosterAssignments), + fosterSubmissions: many(fosterSubmissions), + supplyRequests: many(supplyRequests) +})); + +export const sessionsRelations = relations(sessions, ({ one }) => ({ + user: one(users, { fields: [sessions.userId], references: [users.id] }) +})); + +export const petsRelations = relations(pets, ({ one, many }) => ({ + photos: many(petPhotos), + medical: many(petMedical), + fosterAssignments: many(fosterAssignments), + applications: many(applications), + intakeSource: one(intakeSources, { fields: [pets.intakeSourceId], references: [intakeSources.id] }), + vet: one(vets, { fields: [pets.vetId], references: [vets.id] }), + donations: many(donations), + expenses: many(expenses) +})); + +export const petPhotosRelations = relations(petPhotos, ({ one }) => ({ + pet: one(pets, { fields: [petPhotos.petId], references: [pets.id] }) +})); + +export const petMedicalRelations = relations(petMedical, ({ one }) => ({ + pet: one(pets, { fields: [petMedical.petId], references: [pets.id] }), + vet: one(vets, { fields: [petMedical.vetId], references: [vets.id] }) +})); + +export const fosterAssignmentsRelations = relations(fosterAssignments, ({ one }) => ({ + pet: one(pets, { fields: [fosterAssignments.petId], references: [pets.id] }), + user: one(users, { fields: [fosterAssignments.userId], references: [users.id] }) +})); + +export const applicationsRelations = relations(applications, ({ one, many }) => ({ + pet: one(pets, { fields: [applications.petId], references: [pets.id] }), + reviewer: one(users, { fields: [applications.reviewedBy], references: [users.id] }), + homevisitNotes: many(applicationHomevisitNotes) +})); + +export const applicationHomevisitNotesRelations = relations(applicationHomevisitNotes, ({ one }) => ({ + application: one(applications, { fields: [applicationHomevisitNotes.applicationId], references: [applications.id] }), + user: one(users, { fields: [applicationHomevisitNotes.userId], references: [users.id] }) +})); + +export const donationsRelations = relations(donations, ({ one }) => ({ + pet: one(pets, { fields: [donations.petId], references: [pets.id] }) +})); + +export const expensesRelations = relations(expenses, ({ one }) => ({ + pet: one(pets, { fields: [expenses.petId], references: [pets.id] }), + vet: one(vets, { fields: [expenses.vetId], references: [vets.id] }) +})); + +export const fosterSubmissionsRelations = relations(fosterSubmissions, ({ one }) => ({ + user: one(users, { fields: [fosterSubmissions.userId], references: [users.id] }), + pet: one(pets, { fields: [fosterSubmissions.petId], references: [pets.id] }) +})); + +export const supplyRequestsRelations = relations(supplyRequests, ({ one }) => ({ + user: one(users, { fields: [supplyRequests.userId], references: [users.id] }), + pet: one(pets, { fields: [supplyRequests.petId], references: [pets.id] }) +})); diff --git a/tests/unit/schema.test.ts b/tests/unit/schema.test.ts new file mode 100644 index 0000000..99ab3c6 --- /dev/null +++ b/tests/unit/schema.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import * as schema from '$lib/server/schema'; + +describe('schema exports', () => { + it('exports all core tables', () => { + const tables = [ + 'users', 'sessions', 'pets', 'petPhotos', 'petMedical', + 'fosterAssignments', 'mentors', 'petMentors', 'applications', + 'applicationHomevisitNotes', 'vets', 'intakeSources', 'sponsors', + 'donations', 'expenses', 'events', 'happyTails', 'fosterSubmissions', + 'supplyRequests', 'contentBlocks', 'newsletterSubscribers', + 'contactMessages', 'siteSettings', 'orgConfig', 'auditLog', + 'pageViews', 'aiJobs' + ]; + for (const table of tables) { + expect(schema).toHaveProperty(table); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fe3d8a0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + include: ['tests/unit/**/*.test.ts'], + exclude: ['tests/e2e/**'], + }, + resolve: { + alias: { + $lib: path.resolve('./src/lib') + } + } +});