feat: add database schema and connection pool

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 11:53:30 -05:00
co-authored by Claude Sonnet 4.6
parent b5cb5ba96a
commit 22f0ded88f
4 changed files with 467 additions and 0 deletions
+11
View File
@@ -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' });
+423
View File
@@ -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] })
}));
+19
View File
@@ -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);
}
});
});
+14
View File
@@ -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')
}
}
});