diff --git a/src/routes/admin/mentors/+page.server.ts b/src/routes/admin/mentors/+page.server.ts new file mode 100644 index 0000000..9a26005 --- /dev/null +++ b/src/routes/admin/mentors/+page.server.ts @@ -0,0 +1,35 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { mentors, users, petMentors } from '$lib/server/schema'; +import { eq, count } from 'drizzle-orm'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { error } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied'); + + const mentorRows = await db + .select({ + id: mentors.id, + userId: mentors.userId, + bio: mentors.bio, + active: mentors.active, + createdAt: mentors.createdAt, + userName: users.name, + userEmail: users.email + }) + .from(mentors) + .innerJoin(users, eq(mentors.userId, users.id)) + .orderBy(users.name); + + // Count assigned pets for each mentor + const mentorList = await Promise.all( + mentorRows.map(async (m) => { + const [petCountRow] = await db.select({ total: count() }).from(petMentors).where(eq(petMentors.mentorId, m.id)); + return { ...m, petCount: petCountRow?.total ?? 0 }; + }) + ); + + return { mentors: mentorList }; +}; diff --git a/src/routes/admin/mentors/+page.svelte b/src/routes/admin/mentors/+page.svelte new file mode 100644 index 0000000..8b8c180 --- /dev/null +++ b/src/routes/admin/mentors/+page.svelte @@ -0,0 +1,54 @@ + + +Mentors — Admin + +
+
+

Mentor Management

+

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

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

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

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

{mentor.userName}

+

{mentor.userEmail}

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

Mentor Details

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

Assigned Pets ({data.assignedPets.length})

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

No pets assigned.

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

Sponsor Management

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

{tierLabels[tier]}

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

No sponsors in this tier yet.

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

{sponsor.name}

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

{sponsor.name}

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

Add Sponsor

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

Users

+ {#if data.pendingCount > 0} +

{data.pendingCount} pending approval

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

No users found.

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

{user.name}

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

Edit User

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

Email

+

{user.email}

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

Password

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

User will be prompted to change this on next login.

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

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

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

New User

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

User will be prompted to change this on first login.

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

Vet Management

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

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

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

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

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

{vet.name}

+ {#if vet.clinic}

{vet.clinic}

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

Vet Information

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

Add Vet

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

Vet Information

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

Volunteer Applications

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

No volunteer applications found.

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

{app.applicantName}

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

Applicant Information

+
+
+

Email

+

{app.applicantEmail}

+
+ {#if app.applicantPhone} +
+

Phone

+

{app.applicantPhone}

+
+ {/if} +
+

Submitted

+

{formatDate(app.createdAt)}

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

Application Details

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

Update Status

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