diff --git a/src/routes/admin/mentors/+page.server.ts b/src/routes/admin/mentors/+page.server.ts
new file mode 100644
index 0000000..9a26005
--- /dev/null
+++ b/src/routes/admin/mentors/+page.server.ts
@@ -0,0 +1,35 @@
+import type { PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { mentors, users, petMentors } from '$lib/server/schema';
+import { eq, count } from 'drizzle-orm';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+import { error } from '@sveltejs/kit';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied');
+
+ const mentorRows = await db
+ .select({
+ id: mentors.id,
+ userId: mentors.userId,
+ bio: mentors.bio,
+ active: mentors.active,
+ createdAt: mentors.createdAt,
+ userName: users.name,
+ userEmail: users.email
+ })
+ .from(mentors)
+ .innerJoin(users, eq(mentors.userId, users.id))
+ .orderBy(users.name);
+
+ // Count assigned pets for each mentor
+ const mentorList = await Promise.all(
+ mentorRows.map(async (m) => {
+ const [petCountRow] = await db.select({ total: count() }).from(petMentors).where(eq(petMentors.mentorId, m.id));
+ return { ...m, petCount: petCountRow?.total ?? 0 };
+ })
+ );
+
+ return { mentors: mentorList };
+};
diff --git a/src/routes/admin/mentors/+page.svelte b/src/routes/admin/mentors/+page.svelte
new file mode 100644
index 0000000..8b8c180
--- /dev/null
+++ b/src/routes/admin/mentors/+page.svelte
@@ -0,0 +1,54 @@
+
+
+Mentors — Admin
+
+
+
+
Mentor Management
+
{data.mentors.length} mentor{data.mentors.length !== 1 ? 's' : ''}
+
+
+ {#if data.mentors.length === 0}
+
+
+
No mentors yet. Assign a user the mentor role to create one.
+
+ {:else}
+
+
+
+
+ | Name |
+ Email |
+ Pets Mentoring |
+ Status |
+ Actions |
+
+
+
+ {#each data.mentors as mentor}
+
+ | {mentor.userName} |
+
+ {mentor.userEmail}
+ |
+ {mentor.petCount} |
+
+
+ {mentor.active ? 'Active' : 'Inactive'}
+
+ |
+
+ Edit
+ |
+
+ {/each}
+
+
+
+ {/if}
+
diff --git a/src/routes/admin/mentors/[id]/+page.server.ts b/src/routes/admin/mentors/[id]/+page.server.ts
new file mode 100644
index 0000000..8ed0f30
--- /dev/null
+++ b/src/routes/admin/mentors/[id]/+page.server.ts
@@ -0,0 +1,66 @@
+import type { PageServerLoad, Actions } from './$types';
+import { db } from '$lib/server/db';
+import { mentors, users, petMentors, pets } from '$lib/server/schema';
+import { eq } from 'drizzle-orm';
+import { error, fail } from '@sveltejs/kit';
+import { logAudit } from '$lib/server/audit';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+ if (isNaN(id)) error(404, 'Mentor not found');
+
+ const [mentorRow] = await db
+ .select({
+ id: mentors.id,
+ userId: mentors.userId,
+ bio: mentors.bio,
+ active: mentors.active,
+ createdAt: mentors.createdAt,
+ userName: users.name,
+ userEmail: users.email
+ })
+ .from(mentors)
+ .innerJoin(users, eq(mentors.userId, users.id))
+ .where(eq(mentors.id, id));
+
+ if (!mentorRow) error(404, 'Mentor not found');
+
+ // Pets assigned to this mentor
+ const assignedPets = await db
+ .select({
+ id: pets.id,
+ name: pets.name,
+ status: pets.status
+ })
+ .from(petMentors)
+ .innerJoin(pets, eq(petMentors.petId, pets.id))
+ .where(eq(petMentors.mentorId, id));
+
+ return { mentor: mentorRow, assignedPets };
+};
+
+export const actions: Actions = {
+ default: async ({ request, params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+ const fd = await request.formData();
+
+ const bio = fd.get('bio')?.toString().trim() || null;
+ const active = fd.get('active') === 'on';
+
+ await db.update(mentors).set({ bio, active }).where(eq(mentors.id, id));
+
+ await logAudit({
+ userId: locals.user?.id ?? undefined,
+ action: 'update_mentor',
+ entity: 'mentor',
+ entityId: id,
+ diff: { active }
+ });
+
+ return { success: true };
+ }
+};
diff --git a/src/routes/admin/mentors/[id]/+page.svelte b/src/routes/admin/mentors/[id]/+page.svelte
new file mode 100644
index 0000000..4b7017a
--- /dev/null
+++ b/src/routes/admin/mentors/[id]/+page.svelte
@@ -0,0 +1,83 @@
+
+
+{mentor.userName} — Mentor — Admin
+
+
+
+
+
+
+
+
{mentor.userName}
+
{mentor.userEmail}
+
+
+
+ {#if showSuccess}
+
+ {/if}
+
+
+
+
+
+
+
Assigned Pets ({data.assignedPets.length})
+ {#if data.assignedPets.length === 0}
+
No pets assigned.
+ {:else}
+
+ {#each data.assignedPets as pet}
+ -
+ {pet.name}
+
+
+ {/each}
+
+ {/if}
+
+
+
+
diff --git a/src/routes/admin/sponsors/+page.server.ts b/src/routes/admin/sponsors/+page.server.ts
new file mode 100644
index 0000000..79c33b9
--- /dev/null
+++ b/src/routes/admin/sponsors/+page.server.ts
@@ -0,0 +1,44 @@
+import type { PageServerLoad, Actions } from './$types';
+import { db } from '$lib/server/db';
+import { sponsors } from '$lib/server/schema';
+import { eq, asc } from 'drizzle-orm';
+import { error, fail } from '@sveltejs/kit';
+import { logAudit } from '$lib/server/audit';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'sponsors')) error(403, 'Access denied');
+ const allSponsors = await db.select().from(sponsors).orderBy(asc(sponsors.sortOrder));
+
+ const grouped = {
+ top_dog: allSponsors.filter(s => s.tier === 'top_dog'),
+ wagging_tails: allSponsors.filter(s => s.tier === 'wagging_tails'),
+ wet_noses: allSponsors.filter(s => s.tier === 'wet_noses')
+ };
+
+ return { sponsors: allSponsors, grouped };
+};
+
+export const actions: Actions = {
+ toggleActive: async ({ request, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'sponsors')) error(403, 'Access denied');
+ const fd = await request.formData();
+ const sponsorId = parseInt(fd.get('sponsorId') as string, 10);
+ const active = fd.get('active') === 'true';
+
+ if (!sponsorId || isNaN(sponsorId)) return fail(400, { error: 'Invalid sponsor ID' });
+
+ await db.update(sponsors).set({ active }).where(eq(sponsors.id, sponsorId));
+
+ await logAudit({
+ userId: locals.user?.id ?? undefined,
+ action: 'toggle_sponsor_active',
+ entity: 'sponsor',
+ entityId: sponsorId,
+ diff: { active }
+ });
+
+ return { success: true };
+ }
+};
diff --git a/src/routes/admin/sponsors/+page.svelte b/src/routes/admin/sponsors/+page.svelte
new file mode 100644
index 0000000..eb8d4c9
--- /dev/null
+++ b/src/routes/admin/sponsors/+page.svelte
@@ -0,0 +1,73 @@
+
+
+Sponsors — Admin
+
+
+
+
+ {#each ['top_dog', 'wagging_tails', 'wet_noses'] as tier}
+ {@const tierSponsors = grouped[tier as keyof typeof grouped]}
+
+
+
{tierLabels[tier]}
+ {tierSponsors.length}
+
+
+ {#if tierSponsors.length === 0}
+
+
No sponsors in this tier yet.
+
+ {:else}
+
+ {#each tierSponsors as sponsor}
+
+ {/each}
+
+ {/if}
+
+ {/each}
+
diff --git a/src/routes/admin/sponsors/[id]/+page.server.ts b/src/routes/admin/sponsors/[id]/+page.server.ts
new file mode 100644
index 0000000..1d572ee
--- /dev/null
+++ b/src/routes/admin/sponsors/[id]/+page.server.ts
@@ -0,0 +1,59 @@
+import type { PageServerLoad, Actions } from './$types';
+import { db } from '$lib/server/db';
+import { sponsors } from '$lib/server/schema';
+import { eq } from 'drizzle-orm';
+import { error, fail } from '@sveltejs/kit';
+import { logAudit } from '$lib/server/audit';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'sponsors')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+ if (isNaN(id)) error(404, 'Sponsor not found');
+
+ const [sponsor] = await db.select().from(sponsors).where(eq(sponsors.id, id));
+ if (!sponsor) error(404, 'Sponsor not found');
+
+ return { sponsor };
+};
+
+export const actions: Actions = {
+ default: async ({ request, params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'sponsors')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+ const fd = await request.formData();
+
+ const name = fd.get('name')?.toString().trim() ?? '';
+ if (!name) return fail(400, { error: 'Name is required' });
+
+ const tier = fd.get('tier')?.toString() ?? 'wet_noses';
+ if (!['top_dog', 'wagging_tails', 'wet_noses'].includes(tier)) return fail(400, { error: 'Invalid tier' });
+
+ const logoUrl = fd.get('logoUrl')?.toString().trim() || null;
+ const websiteUrl = fd.get('websiteUrl')?.toString().trim() || null;
+ const description = fd.get('description')?.toString().trim() || null;
+ const active = fd.get('active') === 'on';
+ const sortOrder = parseInt(fd.get('sortOrder')?.toString() ?? '0', 10);
+
+ await db.update(sponsors).set({
+ name,
+ tier: tier as typeof sponsors.tier.enumValues[number],
+ logoUrl,
+ websiteUrl,
+ description,
+ active,
+ sortOrder
+ }).where(eq(sponsors.id, id));
+
+ await logAudit({
+ userId: locals.user?.id ?? undefined,
+ action: 'update_sponsor',
+ entity: 'sponsor',
+ entityId: id,
+ diff: { name, tier }
+ });
+
+ return { success: true };
+ }
+};
diff --git a/src/routes/admin/sponsors/[id]/+page.svelte b/src/routes/admin/sponsors/[id]/+page.svelte
new file mode 100644
index 0000000..ebbe5af
--- /dev/null
+++ b/src/routes/admin/sponsors/[id]/+page.svelte
@@ -0,0 +1,87 @@
+
+
+{sponsor.name} — Admin
+
+
+
+
+ {#if showSuccess}
+
+ {/if}
+
+ {#if form?.error}
+
{form.error}
+ {/if}
+
+
+
diff --git a/src/routes/admin/sponsors/new/+page.server.ts b/src/routes/admin/sponsors/new/+page.server.ts
new file mode 100644
index 0000000..06bb731
--- /dev/null
+++ b/src/routes/admin/sponsors/new/+page.server.ts
@@ -0,0 +1,46 @@
+import type { Actions } from './$types';
+import { db } from '$lib/server/db';
+import { sponsors } from '$lib/server/schema';
+import { error, fail, redirect } from '@sveltejs/kit';
+import { logAudit } from '$lib/server/audit';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+
+export const actions: Actions = {
+ default: async ({ request, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'sponsors')) error(403, 'Access denied');
+ const fd = await request.formData();
+
+ const name = fd.get('name')?.toString().trim() ?? '';
+ if (!name) return fail(400, { error: 'Name is required' });
+
+ const tier = fd.get('tier')?.toString() ?? 'wet_noses';
+ if (!['top_dog', 'wagging_tails', 'wet_noses'].includes(tier)) return fail(400, { error: 'Invalid tier' });
+
+ const logoUrl = fd.get('logoUrl')?.toString().trim() || null;
+ const websiteUrl = fd.get('websiteUrl')?.toString().trim() || null;
+ const description = fd.get('description')?.toString().trim() || null;
+ const active = fd.get('active') === 'on';
+ const sortOrder = parseInt(fd.get('sortOrder')?.toString() ?? '0', 10);
+
+ const [inserted] = await db.insert(sponsors).values({
+ name,
+ tier: tier as typeof sponsors.tier.enumValues[number],
+ logoUrl,
+ websiteUrl,
+ description,
+ active,
+ sortOrder
+ }).$returningId();
+
+ await logAudit({
+ userId: locals.user?.id ?? undefined,
+ action: 'create_sponsor',
+ entity: 'sponsor',
+ entityId: inserted.id,
+ diff: { name, tier }
+ });
+
+ redirect(303, '/admin/sponsors');
+ }
+};
diff --git a/src/routes/admin/sponsors/new/+page.svelte b/src/routes/admin/sponsors/new/+page.svelte
new file mode 100644
index 0000000..6bef7db
--- /dev/null
+++ b/src/routes/admin/sponsors/new/+page.svelte
@@ -0,0 +1,65 @@
+
+
+Add Sponsor — Admin
+
+
+
+
+ {#if form?.error}
+
{form.error}
+ {/if}
+
+
+
diff --git a/src/routes/admin/users/+page.server.ts b/src/routes/admin/users/+page.server.ts
new file mode 100644
index 0000000..c8351d5
--- /dev/null
+++ b/src/routes/admin/users/+page.server.ts
@@ -0,0 +1,76 @@
+import type { PageServerLoad, Actions } from './$types';
+import { db } from '$lib/server/db';
+import { users } from '$lib/server/schema';
+import { error, fail } from '@sveltejs/kit';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+import { eq, desc, count } from 'drizzle-orm';
+import { logAudit } from '$lib/server/audit';
+import { sendRegistrationApproved, sendRegistrationDenied } from '$lib/server/email';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'users')) error(403, 'You do not have permission to manage users');
+
+ const userList = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ role: users.role,
+ active: users.active,
+ createdAt: users.createdAt
+ })
+ .from(users)
+ .orderBy(desc(users.createdAt));
+
+ const [pendingRow] = await db.select({ total: count() }).from(users).where(eq(users.active, false));
+ const pendingCount = pendingRow?.total ?? 0;
+
+ return { users: userList, pendingCount };
+};
+
+export const actions: Actions = {
+ approve: async ({ request, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'users')) error(403, 'Access denied');
+ const fd = await request.formData();
+ const userId = parseInt(fd.get('userId')?.toString() ?? '', 10);
+ const role = fd.get('role')?.toString() ?? 'viewer';
+
+ if (isNaN(userId)) return fail(400, { error: 'Invalid user ID' });
+
+ const validRoles = ['sysadmin', 'director', 'foster_coordinator', 'volunteer_manager', 'vet_liaison', 'content_editor', 'applications_manager', 'viewer', 'foster'];
+ if (!validRoles.includes(role)) return fail(400, { error: 'Invalid role' });
+
+ const [user] = await db.select({ id: users.id, name: users.name, email: users.email }).from(users).where(eq(users.id, userId)).limit(1);
+ if (!user) return fail(404, { error: 'User not found' });
+
+ await db.update(users).set({ active: true, role: role as typeof users.role.enumValues[number] }).where(eq(users.id, userId));
+ await logAudit({ userId: locals.user!.id, action: 'approve_user', entity: 'user', entityId: userId, diff: { role } });
+
+ try {
+ await sendRegistrationApproved(process.env.ORG_NAME ?? 'Rescue', user.email, user.name, `${process.env.SITE_URL ?? ''}/login`);
+ } catch { /* noop */ }
+
+ return { success: true, action: 'approve' };
+ },
+
+ deny: async ({ request, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'users')) error(403, 'Access denied');
+ const fd = await request.formData();
+ const userId = parseInt(fd.get('userId')?.toString() ?? '', 10);
+ if (isNaN(userId)) return fail(400, { error: 'Invalid user ID' });
+
+ const [user] = await db.select({ id: users.id, name: users.name, email: users.email, active: users.active }).from(users).where(eq(users.id, userId)).limit(1);
+ if (!user) return fail(404, { error: 'User not found' });
+ if (user.active) return fail(400, { error: 'Cannot deny an active user. Use the edit page to deactivate.' });
+
+ await db.delete(users).where(eq(users.id, userId));
+ await logAudit({ userId: locals.user!.id, action: 'deny_user', entity: 'user', entityId: userId });
+
+ try {
+ await sendRegistrationDenied(process.env.ORG_NAME ?? 'Rescue', user.email, user.name);
+ } catch { /* noop */ }
+
+ return { success: true, action: 'deny' };
+ }
+};
diff --git a/src/routes/admin/users/+page.svelte b/src/routes/admin/users/+page.svelte
new file mode 100644
index 0000000..548fd36
--- /dev/null
+++ b/src/routes/admin/users/+page.svelte
@@ -0,0 +1,123 @@
+
+
+Users — Admin
+
+
+
+
+
Users
+ {#if data.pendingCount > 0}
+
{data.pendingCount} pending approval
+ {/if}
+
+
+ Add User
+
+
+
+ {#if form?.error}
+
{form.error}
+ {/if}
+
+ {#if data.users.length === 0}
+
+ {:else}
+
+
+
+
+ | Name |
+ Email |
+ Role |
+ Status |
+ Joined |
+ Actions |
+
+
+
+ {#each data.users as user}
+
+ | {user.name} |
+ {user.email} |
+ {roleLabel(user.role)} |
+
+ {#if user.active}
+ Active
+ {:else}
+ Pending
+ {/if}
+ |
+ {formatDate(user.createdAt)} |
+
+ {#if !user.active}
+
+
+ {#if confirmDeny === user.id}
+
+
+ {:else}
+
+ {/if}
+
+ {:else}
+ Edit
+ {/if}
+ |
+
+ {/each}
+
+
+
+ {/if}
+
diff --git a/src/routes/admin/users/[id]/+page.server.ts b/src/routes/admin/users/[id]/+page.server.ts
new file mode 100644
index 0000000..0daa2e4
--- /dev/null
+++ b/src/routes/admin/users/[id]/+page.server.ts
@@ -0,0 +1,85 @@
+import type { PageServerLoad, Actions } from './$types';
+import { db } from '$lib/server/db';
+import { users } from '$lib/server/schema';
+import { eq } from 'drizzle-orm';
+import { error, fail } from '@sveltejs/kit';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+import { hashPassword } from '$lib/server/auth-utils';
+import { logAudit } from '$lib/server/audit';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'users')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+ if (isNaN(id)) error(404, 'User not found');
+
+ const [user] = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ role: users.role,
+ active: users.active,
+ mustChangePassword: users.mustChangePassword,
+ createdAt: users.createdAt
+ })
+ .from(users)
+ .where(eq(users.id, id))
+ .limit(1);
+
+ if (!user) error(404, 'User not found');
+ return { user };
+};
+
+export const actions: Actions = {
+ update: async ({ request, params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'users')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+ const fd = await request.formData();
+ const name = fd.get('name')?.toString().trim() ?? '';
+ const role = fd.get('role')?.toString() ?? 'viewer';
+ const active = fd.get('active') === 'on';
+
+ if (!name) return fail(400, { error: 'Name is required' });
+
+ const validRoles = ['sysadmin', 'director', 'foster_coordinator', 'volunteer_manager', 'vet_liaison', 'content_editor', 'applications_manager', 'viewer', 'foster'];
+ if (!validRoles.includes(role)) return fail(400, { error: 'Invalid role' });
+
+ await db.update(users)
+ .set({ name, role: role as typeof users.role.enumValues[number], active })
+ .where(eq(users.id, id));
+
+ await logAudit({
+ userId: locals.user!.id,
+ action: 'update_user',
+ entity: 'user',
+ entityId: id,
+ diff: { name, role, active }
+ });
+
+ return { success: true, action: 'update' };
+ },
+
+ resetPassword: async ({ request, params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'users')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+ const fd = await request.formData();
+ const password = fd.get('password')?.toString() ?? '';
+
+ if (!password || password.length < 8) return fail(400, { error: 'Password must be at least 8 characters' });
+
+ const passwordHash = await hashPassword(password);
+ await db.update(users)
+ .set({ passwordHash, mustChangePassword: true })
+ .where(eq(users.id, id));
+
+ await logAudit({
+ userId: locals.user!.id,
+ action: 'reset_user_password',
+ entity: 'user',
+ entityId: id
+ });
+
+ return { success: true, action: 'resetPassword' };
+ }
+};
diff --git a/src/routes/admin/users/[id]/+page.svelte b/src/routes/admin/users/[id]/+page.svelte
new file mode 100644
index 0000000..13dd3ed
--- /dev/null
+++ b/src/routes/admin/users/[id]/+page.svelte
@@ -0,0 +1,120 @@
+
+
+{user.name} — Admin
+
+
+
+
+ {#if form?.error}
+
{form.error}
+ {/if}
+
+ {#if showSuccess}
+
+ {/if}
+
+
+
+
Edit User
+
+
+
+
+
+
Password
+
+
+ {#if showPasswordForm}
+
+ {:else}
+
+ {user.mustChangePassword ? 'User must change password on next login.' : 'Password is set.'}
+
+ {/if}
+
+
+
diff --git a/src/routes/admin/users/new/+page.server.ts b/src/routes/admin/users/new/+page.server.ts
new file mode 100644
index 0000000..9861a1d
--- /dev/null
+++ b/src/routes/admin/users/new/+page.server.ts
@@ -0,0 +1,69 @@
+import type { PageServerLoad, Actions } from './$types';
+import { db } from '$lib/server/db';
+import { users } from '$lib/server/schema';
+import { error, fail, redirect } from '@sveltejs/kit';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+import { hashPassword } from '$lib/server/auth-utils';
+import { logAudit } from '$lib/server/audit';
+import { sendRegistrationApproved } from '$lib/server/email';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'users')) error(403, 'Access denied');
+ return {};
+};
+
+export const actions: Actions = {
+ default: async ({ request, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'users')) error(403, 'Access denied');
+ const fd = await request.formData();
+ const name = fd.get('name')?.toString().trim() ?? '';
+ const email = fd.get('email')?.toString().trim().toLowerCase() ?? '';
+ const role = fd.get('role')?.toString() ?? 'viewer';
+ const password = fd.get('password')?.toString() ?? '';
+ const sendWelcome = fd.get('sendWelcome') === 'on';
+
+ if (!name) return fail(400, { error: 'Name is required' });
+ if (!email) return fail(400, { error: 'Email is required' });
+ if (!password || password.length < 8) return fail(400, { error: 'Password must be at least 8 characters' });
+
+ const validRoles = ['sysadmin', 'director', 'foster_coordinator', 'volunteer_manager', 'vet_liaison', 'content_editor', 'applications_manager', 'viewer', 'foster'];
+ if (!validRoles.includes(role)) return fail(400, { error: 'Invalid role' });
+
+ const existing = await db.select({ id: users.id }).from(users).where(
+ (await import('drizzle-orm')).eq(users.email, email)
+ ).limit(1);
+ if (existing.length > 0) return fail(400, { error: 'A user with that email already exists' });
+
+ const passwordHash = await hashPassword(password);
+ const [result] = await db.insert(users).values({
+ name,
+ email,
+ passwordHash,
+ role: role as typeof users.role.enumValues[number],
+ active: true,
+ mustChangePassword: true
+ }).$returningId();
+
+ await logAudit({
+ userId: locals.user!.id,
+ action: 'create_user',
+ entity: 'user',
+ entityId: result.id,
+ diff: { name, email, role }
+ });
+
+ if (sendWelcome) {
+ try {
+ await sendRegistrationApproved(
+ process.env.ORG_NAME ?? 'Rescue',
+ email,
+ name,
+ `${process.env.SITE_URL ?? ''}/login`
+ );
+ } catch { /* noop */ }
+ }
+
+ redirect(303, '/admin/users');
+ }
+};
diff --git a/src/routes/admin/users/new/+page.svelte b/src/routes/admin/users/new/+page.svelte
new file mode 100644
index 0000000..74494e2
--- /dev/null
+++ b/src/routes/admin/users/new/+page.svelte
@@ -0,0 +1,77 @@
+
+
+New User — Admin
+
+
+
+
+ {#if form?.error}
+
{form.error}
+ {/if}
+
+
+
+
+
diff --git a/src/routes/admin/vets/+page.server.ts b/src/routes/admin/vets/+page.server.ts
new file mode 100644
index 0000000..2c5049b
--- /dev/null
+++ b/src/routes/admin/vets/+page.server.ts
@@ -0,0 +1,18 @@
+import type { PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { vets } from '$lib/server/schema';
+import { like, or } from 'drizzle-orm';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+import { error } from '@sveltejs/kit';
+
+export const load: PageServerLoad = async ({ url, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'vets')) error(403, 'Access denied');
+ const q = url.searchParams.get('q')?.trim() ?? '';
+
+ const vetList = q
+ ? await db.select().from(vets).where(or(like(vets.name, `%${q}%`), like(vets.clinic, `%${q}%`))).orderBy(vets.name)
+ : await db.select().from(vets).orderBy(vets.name);
+
+ return { vets: vetList, q };
+};
diff --git a/src/routes/admin/vets/+page.svelte b/src/routes/admin/vets/+page.svelte
new file mode 100644
index 0000000..6387136
--- /dev/null
+++ b/src/routes/admin/vets/+page.svelte
@@ -0,0 +1,59 @@
+
+
+Vets — Admin
+
+
+
+
+
+
+ {#if vetList.length === 0}
+
+
+
{data.q ? 'No vets match your search' : 'No vets yet'}
+
+ {:else}
+
+
+
+
+ | Name |
+ Clinic |
+ Phone |
+ Email |
+ Actions |
+
+
+
+ {#each vetList as vet}
+
+ | {vet.name} |
+ {vet.clinic ?? '—'} |
+ {#if vet.phone}{vet.phone}{:else}—{/if} |
+ {#if vet.email}{vet.email}{:else}—{/if} |
+ Edit |
+
+ {/each}
+
+
+
+
{vetList.length} vet{vetList.length !== 1 ? 's' : ''} total
+ {/if}
+
diff --git a/src/routes/admin/vets/[id]/+page.server.ts b/src/routes/admin/vets/[id]/+page.server.ts
new file mode 100644
index 0000000..0af0caf
--- /dev/null
+++ b/src/routes/admin/vets/[id]/+page.server.ts
@@ -0,0 +1,52 @@
+import type { PageServerLoad, Actions } from './$types';
+import { db } from '$lib/server/db';
+import { vets, petMedical, pets } from '$lib/server/schema';
+import { eq, count } from 'drizzle-orm';
+import { error, fail } from '@sveltejs/kit';
+import { logAudit } from '$lib/server/audit';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'vets')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+ if (isNaN(id)) error(404, 'Vet not found');
+
+ const [vet] = await db.select().from(vets).where(eq(vets.id, id));
+ if (!vet) error(404, 'Vet not found');
+
+ const [medicalPetCount] = await db.select({ total: count() }).from(petMedical).where(eq(petMedical.vetId, id));
+ const [clinicPetCount] = await db.select({ total: count() }).from(pets).where(eq(pets.vetId, id));
+ const petCount = (medicalPetCount?.total ?? 0) + (clinicPetCount?.total ?? 0);
+
+ return { vet, petCount };
+};
+
+export const actions: Actions = {
+ default: async ({ request, params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'vets')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+ const fd = await request.formData();
+
+ const name = fd.get('name')?.toString().trim() ?? '';
+ if (!name) return fail(400, { error: 'Name is required' });
+
+ const clinic = fd.get('clinic')?.toString().trim() || null;
+ const phone = fd.get('phone')?.toString().trim() || null;
+ const email = fd.get('email')?.toString().trim() || null;
+ const address = fd.get('address')?.toString().trim() || null;
+ const notes = fd.get('notes')?.toString().trim() || null;
+
+ await db.update(vets).set({ name, clinic, phone, email, address, notes }).where(eq(vets.id, id));
+
+ await logAudit({
+ userId: locals.user?.id ?? undefined,
+ action: 'update_vet',
+ entity: 'vet',
+ entityId: id,
+ diff: { name, clinic }
+ });
+
+ return { success: true };
+ }
+};
diff --git a/src/routes/admin/vets/[id]/+page.svelte b/src/routes/admin/vets/[id]/+page.svelte
new file mode 100644
index 0000000..6006855
--- /dev/null
+++ b/src/routes/admin/vets/[id]/+page.svelte
@@ -0,0 +1,87 @@
+
+
+{vet.name} — Admin
+
+
+
+
+
+
+
+
{vet.name}
+ {#if vet.clinic}
{vet.clinic}
{/if}
+
+
+
+
+
+ {data.petCount} pet record{data.petCount !== 1 ? 's' : ''} assigned to this vet
+
+
+ {#if showSuccess}
+
+
+ Vet updated successfully.
+
+ {/if}
+
+ {#if form?.error}
+
{form.error}
+ {/if}
+
+
+
diff --git a/src/routes/admin/vets/new/+page.server.ts b/src/routes/admin/vets/new/+page.server.ts
new file mode 100644
index 0000000..d47c273
--- /dev/null
+++ b/src/routes/admin/vets/new/+page.server.ts
@@ -0,0 +1,35 @@
+import type { Actions } from './$types';
+import { db } from '$lib/server/db';
+import { vets } from '$lib/server/schema';
+import { error, fail, redirect } from '@sveltejs/kit';
+import { logAudit } from '$lib/server/audit';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+
+export const actions: Actions = {
+ default: async ({ request, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'vets')) error(403, 'Access denied');
+ const fd = await request.formData();
+
+ const name = fd.get('name')?.toString().trim() ?? '';
+ if (!name) return fail(400, { error: 'Name is required' });
+
+ const clinic = fd.get('clinic')?.toString().trim() || null;
+ const phone = fd.get('phone')?.toString().trim() || null;
+ const email = fd.get('email')?.toString().trim() || null;
+ const address = fd.get('address')?.toString().trim() || null;
+ const notes = fd.get('notes')?.toString().trim() || null;
+
+ const [inserted] = await db.insert(vets).values({ name, clinic, phone, email, address, notes }).$returningId();
+
+ await logAudit({
+ userId: locals.user?.id ?? undefined,
+ action: 'create_vet',
+ entity: 'vet',
+ entityId: inserted.id,
+ diff: { name, clinic }
+ });
+
+ redirect(303, '/admin/vets');
+ }
+};
diff --git a/src/routes/admin/vets/new/+page.svelte b/src/routes/admin/vets/new/+page.svelte
new file mode 100644
index 0000000..1bacd60
--- /dev/null
+++ b/src/routes/admin/vets/new/+page.svelte
@@ -0,0 +1,60 @@
+
+
+Add Vet — Admin
+
+
+
+
+ {#if form?.error}
+
{form.error}
+ {/if}
+
+
+
diff --git a/src/routes/admin/volunteers/+page.server.ts b/src/routes/admin/volunteers/+page.server.ts
new file mode 100644
index 0000000..d957aed
--- /dev/null
+++ b/src/routes/admin/volunteers/+page.server.ts
@@ -0,0 +1,43 @@
+import type { PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { applications } from '$lib/server/schema';
+import { eq, and, count, desc } from 'drizzle-orm';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+import { error } from '@sveltejs/kit';
+
+const PER_PAGE = 20;
+
+export const load: PageServerLoad = async ({ url, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'volunteers')) error(403, 'Access denied');
+
+ const status = url.searchParams.get('status') ?? '';
+ const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10));
+ const offset = (page - 1) * PER_PAGE;
+
+ const conditions = [eq(applications.type, 'volunteer') as ReturnType];
+ if (status) conditions.push(eq(applications.status, status as typeof applications.status.enumValues[number]) as ReturnType);
+
+ const where = and(...conditions);
+
+ const [totalRow] = await db.select({ total: count() }).from(applications).where(where);
+ const total = totalRow?.total ?? 0;
+ const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
+
+ const volunteers = await db
+ .select({
+ id: applications.id,
+ applicantName: applications.applicantName,
+ email: applications.applicantEmail,
+ phone: applications.applicantPhone,
+ status: applications.status,
+ createdAt: applications.createdAt
+ })
+ .from(applications)
+ .where(where)
+ .orderBy(desc(applications.createdAt))
+ .limit(PER_PAGE)
+ .offset(offset);
+
+ return { volunteers, total, page, totalPages, status };
+};
diff --git a/src/routes/admin/volunteers/+page.svelte b/src/routes/admin/volunteers/+page.svelte
new file mode 100644
index 0000000..40cfc65
--- /dev/null
+++ b/src/routes/admin/volunteers/+page.svelte
@@ -0,0 +1,89 @@
+
+
+Volunteer Applications — Admin
+
+
+
+
Volunteer Applications
+ {data.total} total
+
+
+
+
+
+
+ {#if data.volunteers.length === 0}
+
+
No volunteer applications found.
+
+ {:else}
+
+
+
+
+ | Name |
+ Email |
+ Phone |
+ Status |
+ Date |
+ Actions |
+
+
+
+ {#each data.volunteers as vol}
+
+ | {vol.applicantName} |
+ {vol.email} |
+ {vol.phone ?? '—'} |
+ |
+ {formatDate(vol.createdAt)} |
+
+ View
+ |
+
+ {/each}
+
+
+
+
+ {/if}
+
diff --git a/src/routes/admin/volunteers/[id]/+page.server.ts b/src/routes/admin/volunteers/[id]/+page.server.ts
new file mode 100644
index 0000000..4a00538
--- /dev/null
+++ b/src/routes/admin/volunteers/[id]/+page.server.ts
@@ -0,0 +1,59 @@
+import type { PageServerLoad, Actions } from './$types';
+import { db } from '$lib/server/db';
+import { applications } from '$lib/server/schema';
+import { eq } from 'drizzle-orm';
+import { error, fail } from '@sveltejs/kit';
+import { logAudit } from '$lib/server/audit';
+import { sendApplicationStatusUpdate } from '$lib/server/email';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'volunteers')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+ if (isNaN(id)) error(404, 'Application not found');
+
+ const [app] = await db.select().from(applications).where(eq(applications.id, id));
+ if (!app || app.type !== 'volunteer') error(404, 'Volunteer application not found');
+
+ return { application: app };
+};
+
+export const actions: Actions = {
+ updateStatus: async ({ request, params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'volunteers')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+ const fd = await request.formData();
+ const status = fd.get('status')?.toString();
+
+ if (!status || !['pending', 'reviewing', 'approved', 'denied', 'withdrawn'].includes(status)) {
+ return fail(400, { error: 'Invalid status' });
+ }
+
+ await db.update(applications).set({ status: status as typeof applications.status.enumValues[number] }).where(eq(applications.id, id));
+
+ await logAudit({
+ userId: locals.user?.id ?? undefined,
+ action: 'update_volunteer_status',
+ entity: 'application',
+ entityId: id,
+ diff: { status }
+ });
+
+ try {
+ const [app] = await db.select({ applicantName: applications.applicantName, email: applications.applicantEmail }).from(applications).where(eq(applications.id, id));
+ if (app) {
+ await sendApplicationStatusUpdate(
+ process.env.ORG_NAME ?? 'Rescue',
+ app.email,
+ app.applicantName,
+ 'volunteer',
+ status,
+ process.env.SITE_URL ?? ''
+ );
+ }
+ } catch { /* noop */ }
+
+ return { success: true };
+ }
+};
diff --git a/src/routes/admin/volunteers/[id]/+page.svelte b/src/routes/admin/volunteers/[id]/+page.svelte
new file mode 100644
index 0000000..4df8f40
--- /dev/null
+++ b/src/routes/admin/volunteers/[id]/+page.svelte
@@ -0,0 +1,124 @@
+
+
+Volunteer #{app.id} — Admin
+
+
+
+
+
+
+
+
{app.applicantName}
+
+
+
+
+ {#if form?.error}
+
{form.error}
+ {/if}
+
+
+
+
+
Applicant Information
+
+
+ {#if app.applicantPhone}
+
+ {/if}
+
+
Submitted
+
{formatDate(app.createdAt)}
+
+
+
+
+ {#if formDataEntries.length > 0}
+
+
Application Details
+
+ {#each formDataEntries as [key, value]}
+
+
- {formatFieldLabel(key)}
+ -
+ {#if typeof value === 'boolean'}{value ? 'Yes' : 'No'}
+ {:else if Array.isArray(value)}{value.join(', ')}
+ {:else}{String(value)}{/if}
+
+
+ {/each}
+
+
+ {/if}
+
+
+
+
+
Update Status
+ {#if showSuccess}
+
+ {/if}
+
+
+
+
+