diff --git a/src/routes/admin/analytics/+page.server.ts b/src/routes/admin/analytics/+page.server.ts new file mode 100644 index 0000000..af3ac7d --- /dev/null +++ b/src/routes/admin/analytics/+page.server.ts @@ -0,0 +1,47 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { pets, applications, donations, fosterAssignments, users, pageViews } from '$lib/server/schema'; +import { eq, gte, count, sum, and } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'dashboard')) error(403, 'Access denied'); + + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const [totalPetsRow] = await db.select({ total: count() }).from(pets); + const [availablePetsRow] = await db.select({ total: count() }).from(pets).where(eq(pets.status, 'available')); + const [fosterPetsRow] = await db.select({ total: count() }).from(pets).where(eq(pets.status, 'foster')); + const [adoptedThisMonthRow] = await db.select({ total: count() }).from(pets).where( + and(eq(pets.status, 'adopted'), gte(pets.adoptedDate, thirtyDaysAgo)) + ); + + const [pendingAppsRow] = await db.select({ total: count() }).from(applications).where(eq(applications.status, 'pending')); + const [newAppsRow] = await db.select({ total: count() }).from(applications).where(gte(applications.createdAt, thirtyDaysAgo)); + + const [donationsRow] = await db.select({ total: sum(donations.amount) }).from(donations).where(gte(donations.date, thirtyDaysAgo)); + const [totalUsersRow] = await db.select({ total: count() }).from(users).where(eq(users.active, true)); + const [activeFostersRow] = await db.select({ total: count() }).from(fosterAssignments).where(eq(fosterAssignments.isCurrent, true)); + + const [pageViewsRow] = await db.select({ total: count() }).from(pageViews).where( + and(gte(pageViews.createdAt, thirtyDaysAgo), eq(pageViews.isBot, false)) + ); + + return { + stats: { + totalPets: totalPetsRow?.total ?? 0, + availablePets: availablePetsRow?.total ?? 0, + fosterPets: fosterPetsRow?.total ?? 0, + adoptedThisMonth: adoptedThisMonthRow?.total ?? 0, + pendingApplications: pendingAppsRow?.total ?? 0, + newApplications: newAppsRow?.total ?? 0, + donationsThisMonth: (donationsRow?.total ?? 0) as number, + activeUsers: totalUsersRow?.total ?? 0, + activeFosters: activeFostersRow?.total ?? 0, + pageViewsThisMonth: pageViewsRow?.total ?? 0 + } + }; +}; diff --git a/src/routes/admin/analytics/+page.svelte b/src/routes/admin/analytics/+page.svelte new file mode 100644 index 0000000..3bbf141 --- /dev/null +++ b/src/routes/admin/analytics/+page.svelte @@ -0,0 +1,58 @@ + + +Analytics — Admin + +
+
+

Analytics

+

Last 30 days unless otherwise noted

+
+ +
+
+

Total Pets

+

{s.totalPets}

+
+
+

Available

+

{s.availablePets}

+
+
+

In Foster

+

{s.fosterPets}

+
+
+

Adopted (30d)

+

{s.adoptedThisMonth}

+
+
+

Active Fosters

+

{s.activeFosters}

+
+
+ +
+
+

Pending Applications

+

{s.pendingApplications}

+

{s.newApplications} new this month

+
+
+

Donations (30d)

+

{fmt.format(s.donationsThisMonth / 100)}

+
+
+

Active Users

+

{s.activeUsers}

+
+
+

Page Views (30d)

+

{s.pageViewsThisMonth.toLocaleString()}

+
+
+
diff --git a/src/routes/admin/foster-dashboard/+page.server.ts b/src/routes/admin/foster-dashboard/+page.server.ts new file mode 100644 index 0000000..9b758ec --- /dev/null +++ b/src/routes/admin/foster-dashboard/+page.server.ts @@ -0,0 +1,57 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { fosterAssignments, supplyRequests, fosterSubmissions, pets, users } from '$lib/server/schema'; +import { eq, desc, count } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied'); + + // Active foster assignments + const activeFosters = await db + .select({ + id: fosterAssignments.id, + startDate: fosterAssignments.startDate, + petId: pets.id, + petName: pets.name, + petSpecies: pets.species, + petBreed: pets.breed, + fostererName: users.name, + fostererEmail: users.email + }) + .from(fosterAssignments) + .leftJoin(pets, eq(fosterAssignments.petId, pets.id)) + .leftJoin(users, eq(fosterAssignments.userId, users.id)) + .where(eq(fosterAssignments.isCurrent, true)) + .orderBy(desc(fosterAssignments.startDate)); + + // Pending supply requests + const pendingSupplyRequests = await db + .select({ + id: supplyRequests.id, + items: supplyRequests.items, + urgency: supplyRequests.urgency, + notes: supplyRequests.notes, + createdAt: supplyRequests.createdAt, + petName: pets.name, + requesterName: users.name + }) + .from(supplyRequests) + .leftJoin(pets, eq(supplyRequests.petId, pets.id)) + .leftJoin(users, eq(supplyRequests.userId, users.id)) + .where(eq(supplyRequests.status, 'pending')) + .orderBy(desc(supplyRequests.createdAt)); + + const [pendingSubmissionsRow] = await db + .select({ total: count() }) + .from(fosterSubmissions) + .where(eq(fosterSubmissions.status, 'pending')); + + return { + activeFosters, + pendingSupplyRequests, + pendingSubmissionsCount: pendingSubmissionsRow?.total ?? 0 + }; +}; diff --git a/src/routes/admin/foster-dashboard/+page.svelte b/src/routes/admin/foster-dashboard/+page.svelte new file mode 100644 index 0000000..a58300e --- /dev/null +++ b/src/routes/admin/foster-dashboard/+page.svelte @@ -0,0 +1,87 @@ + + +Foster Dashboard — Admin + +
+
+

Foster Dashboard

+
+ + +
+
+

Active Fosters

+

{data.activeFosters.length}

+
+
+

Supply Requests

+

{data.pendingSupplyRequests.length}

+
+
+

Pending Posts

+

{data.pendingSubmissionsCount}

+ Review +
+
+ +
+ +
+

Active Foster Assignments

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

No active fosters.

+ {:else} +
+ {#each data.activeFosters as foster} +
+
+

+ {foster.petName ?? '—'} +

+

{foster.fostererName ?? foster.fostererEmail ?? '—'}

+
+ {formatDate(foster.startDate)} +
+ {/each} +
+ {/if} +
+ + +
+

Pending Supply Requests

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

No pending requests.

+ {:else} +
+ {#each data.pendingSupplyRequests as req} +
+
+

{req.requesterName ?? '—'}

+ + {req.urgency} + +
+

{req.items}

+ {#if req.petName} +

For: {req.petName}

+ {/if} +
+ {/each} +
+ {/if} +
+
+
diff --git a/src/routes/admin/fosters/+page.server.ts b/src/routes/admin/fosters/+page.server.ts new file mode 100644 index 0000000..66fbad1 --- /dev/null +++ b/src/routes/admin/fosters/+page.server.ts @@ -0,0 +1,69 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { fosterAssignments, pets, users } from '$lib/server/schema'; +import { eq, desc } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied'); + + const assignments = await db + .select({ + id: fosterAssignments.id, + startDate: fosterAssignments.startDate, + endDate: fosterAssignments.endDate, + isCurrent: fosterAssignments.isCurrent, + notes: fosterAssignments.notes, + createdAt: fosterAssignments.createdAt, + petId: pets.id, + petName: pets.name, + petSpecies: pets.species, + fostererName: users.name, + fostererEmail: users.email, + fostererId: users.id + }) + .from(fosterAssignments) + .leftJoin(pets, eq(fosterAssignments.petId, pets.id)) + .leftJoin(users, eq(fosterAssignments.userId, users.id)) + .orderBy(desc(fosterAssignments.isCurrent), desc(fosterAssignments.startDate)); + + // Active fosters for the form + const fosterUsers = await db + .select({ id: users.id, name: users.name, email: users.email }) + .from(users) + .where(eq(users.role, 'foster')) + .orderBy(users.name); + + const availablePets = await db + .select({ id: pets.id, name: pets.name }) + .from(pets) + .where(eq(pets.status, 'available')) + .orderBy(pets.name); + + return { assignments, fosterUsers, availablePets }; +}; + +export const actions: Actions = { + end: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied'); + const fd = await request.formData(); + const id = parseInt(fd.get('id')?.toString() ?? '', 10); + if (isNaN(id)) return fail(400, { error: 'Invalid ID' }); + + await db.update(fosterAssignments) + .set({ isCurrent: false, endDate: new Date() }) + .where(eq(fosterAssignments.id, id)); + + await logAudit({ + userId: locals.user!.id, + action: 'end_foster_assignment', + entity: 'foster_assignment', + entityId: id + }); + + return { success: true }; + } +}; diff --git a/src/routes/admin/fosters/+page.svelte b/src/routes/admin/fosters/+page.svelte new file mode 100644 index 0000000..5e2c5d5 --- /dev/null +++ b/src/routes/admin/fosters/+page.svelte @@ -0,0 +1,98 @@ + + +Foster Assignments — Admin + +
+
+

Foster Assignments

+ {active.length} active +
+ + {#if form?.error} +
{form.error}
+ {/if} + + {#if active.length > 0} +

Active

+
+ + + + + + + + + + + {#each active as a} + + + + + + + {/each} + +
PetFosterSinceActions
+ {a.petName ?? '—'} + +

{a.fostererName ?? '—'}

+

{a.fostererEmail ?? ''}

+
{formatDate(a.startDate)} +
{ + submitting = `end-${a.id}`; + return async ({ update }) => { submitting = null; await update(); }; + }}> + + +
+
+
+ {/if} + + {#if past.length > 0} +
+ Past Assignments ({past.length}) +
+ + + + + + + + + + {#each past as a} + + + + + + {/each} + +
PetFosterPeriod
{a.petName ?? '—'}{a.fostererName ?? '—'}{formatDate(a.startDate)} – {formatDate(a.endDate)}
+
+
+ {/if} + + {#if data.assignments.length === 0} +
+

No foster assignments yet.

+
+ {/if} +
diff --git a/src/routes/admin/moderation/+page.server.ts b/src/routes/admin/moderation/+page.server.ts new file mode 100644 index 0000000..6d85d79 --- /dev/null +++ b/src/routes/admin/moderation/+page.server.ts @@ -0,0 +1,82 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { fosterSubmissions, pets, users } from '$lib/server/schema'; +import { eq, desc } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied'); + + const submissions = await db + .select({ + id: fosterSubmissions.id, + type: fosterSubmissions.type, + content: fosterSubmissions.content, + photoUrl: fosterSubmissions.photoUrl, + status: fosterSubmissions.status, + createdAt: fosterSubmissions.createdAt, + petName: pets.name, + petId: pets.id, + submittedByName: users.name, + submittedByEmail: users.email + }) + .from(fosterSubmissions) + .leftJoin(pets, eq(fosterSubmissions.petId, pets.id)) + .leftJoin(users, eq(fosterSubmissions.userId, users.id)) + .orderBy(desc(fosterSubmissions.createdAt)) + .limit(100); + + const pending = submissions.filter(s => s.status === 'pending'); + const reviewed = submissions.filter(s => s.status !== 'pending'); + + return { pending, reviewed }; +}; + +export const actions: Actions = { + approve: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied'); + const fd = await request.formData(); + const id = parseInt(fd.get('id')?.toString() ?? '', 10); + if (isNaN(id)) return fail(400, { error: 'Invalid ID' }); + + await db.update(fosterSubmissions).set({ + status: 'approved', + reviewedBy: locals.user!.id, + reviewedAt: new Date() + }).where(eq(fosterSubmissions.id, id)); + + await logAudit({ + userId: locals.user!.id, + action: 'approve_foster_submission', + entity: 'foster_submission', + entityId: id + }); + + return { success: true }; + }, + + reject: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied'); + const fd = await request.formData(); + const id = parseInt(fd.get('id')?.toString() ?? '', 10); + if (isNaN(id)) return fail(400, { error: 'Invalid ID' }); + + await db.update(fosterSubmissions).set({ + status: 'rejected', + reviewedBy: locals.user!.id, + reviewedAt: new Date() + }).where(eq(fosterSubmissions.id, id)); + + await logAudit({ + userId: locals.user!.id, + action: 'reject_foster_submission', + entity: 'foster_submission', + entityId: id + }); + + return { success: true }; + } +}; diff --git a/src/routes/admin/moderation/+page.svelte b/src/routes/admin/moderation/+page.svelte new file mode 100644 index 0000000..9ad2b71 --- /dev/null +++ b/src/routes/admin/moderation/+page.svelte @@ -0,0 +1,91 @@ + + +Moderation — Admin + +
+
+

Foster Submission Moderation

+ {#if data.pending.length > 0} +

{data.pending.length} pending review

+ {/if} +
+ + {#if data.pending.length === 0 && data.reviewed.length === 0} +
+

No submissions yet.

+
+ {/if} + + {#if data.pending.length > 0} +

Pending Review

+
+ {#each data.pending as sub} +
+
+
+
+ {sub.type} + {sub.petName ?? 'Unknown Pet'} + by {sub.submittedByName ?? sub.submittedByEmail ?? 'Unknown'} +
+ {#if sub.content} +

{sub.content}

+ {/if} + {#if sub.photoUrl} + Submission + {/if} +

{formatDate(sub.createdAt)}

+
+
+
{ + submitting = `approve-${sub.id}`; + return async ({ update }) => { submitting = null; await update(); }; + }}> + + +
+
{ + submitting = `reject-${sub.id}`; + return async ({ update }) => { submitting = null; await update(); }; + }}> + + +
+
+
+
+ {/each} +
+ {/if} + + {#if data.reviewed.length > 0} +
+ Reviewed ({data.reviewed.length}) +
+ {#each data.reviewed as sub} +
+
+ {sub.petName ?? '—'} + · + {sub.type} + · + {sub.submittedByName ?? '—'} +
+ + {sub.status} + +
+ {/each} +
+
+ {/if} +
diff --git a/src/routes/admin/system/+page.server.ts b/src/routes/admin/system/+page.server.ts new file mode 100644 index 0000000..5380a25 --- /dev/null +++ b/src/routes/admin/system/+page.server.ts @@ -0,0 +1,92 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { siteSettings, orgConfig, auditLog, users } from '$lib/server/schema'; +import { eq, desc } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!hasPermission(locals.user!.role as Role, 'system')) error(403, 'Access denied'); + + const settings = await db.select().from(siteSettings).orderBy(siteSettings.key); + const configs = await db.select().from(orgConfig).orderBy(orgConfig.key); + + const recentAudit = await db + .select({ + id: auditLog.id, + action: auditLog.action, + entity: auditLog.entity, + entityId: auditLog.entityId, + diff: auditLog.diff, + createdAt: auditLog.createdAt, + userName: users.name, + userEmail: users.email + }) + .from(auditLog) + .leftJoin(users, eq(auditLog.userId, users.id)) + .orderBy(desc(auditLog.createdAt)) + .limit(50); + + return { settings, configs, recentAudit }; +}; + +export const actions: Actions = { + saveSetting: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'system')) error(403, 'Access denied'); + const fd = await request.formData(); + const key = fd.get('key')?.toString().trim() ?? ''; + const value = fd.get('value')?.toString() ?? ''; + + if (!key) return fail(400, { error: 'Key is required' }); + + const existing = await db.select({ id: siteSettings.id }).from(siteSettings).where(eq(siteSettings.key, key)).limit(1); + if (existing.length > 0) { + await db.update(siteSettings).set({ value }).where(eq(siteSettings.key, key)); + } else { + await db.insert(siteSettings).values({ key, value }); + } + + await logAudit({ + userId: locals.user!.id, + action: 'update_site_setting', + entity: 'site_setting', + diff: { key, value } + }); + + return { success: true }; + }, + + saveConfig: async ({ request, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'system')) error(403, 'Access denied'); + const fd = await request.formData(); + const key = fd.get('key')?.toString().trim() ?? ''; + const valueStr = fd.get('value')?.toString() ?? ''; + + if (!key) return fail(400, { error: 'Key is required' }); + + let value: unknown; + try { + value = JSON.parse(valueStr); + } catch { + value = valueStr; + } + + const existing = await db.select({ id: orgConfig.id }).from(orgConfig).where(eq(orgConfig.key, key)).limit(1); + if (existing.length > 0) { + await db.update(orgConfig).set({ value: value as Record }).where(eq(orgConfig.key, key)); + } else { + await db.insert(orgConfig).values({ key, value: value as Record }); + } + + await logAudit({ + userId: locals.user!.id, + action: 'update_org_config', + entity: 'org_config', + diff: { key } + }); + + return { success: true }; + } +}; diff --git a/src/routes/admin/system/+page.svelte b/src/routes/admin/system/+page.svelte new file mode 100644 index 0000000..4409933 --- /dev/null +++ b/src/routes/admin/system/+page.svelte @@ -0,0 +1,169 @@ + + +System Settings — Admin + +
+
+

System Settings

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

Site Settings

+ +
handleEnhance('new-setting')} class="mb-6 p-4 bg-gray-50 rounded-lg"> +

Add / Update Setting

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

No settings configured.

+ {:else} +
+ {#each data.settings as setting} +
+
+ {setting.key} + +
+ {#if editingSetting === setting.key} +
handleEnhance(`setting-${setting.key}`)}> + + + +
+ {:else} +

{setting.value}

+ {/if} +
+ {/each} +
+ {/if} +
+ + +
+

Org Config

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

No config entries.

+ {:else} +
+ {#each data.configs as cfg} +
+
+ {cfg.key} + +
+ {#if editingConfig === cfg.key} +
handleEnhance(`config-${cfg.key}`)}> + + + +
+ {:else} +
{configDisplay(cfg.value)}
+ {/if} +
+ {/each} +
+ {/if} +
+
+ + +
+

Recent Audit Log

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

No audit entries.

+ {:else} +
+ + + + + + + + + + + {#each data.recentAudit as entry} + + + + + + + {/each} + +
TimeUserActionEntity
{formatDate(entry.createdAt)}{entry.userName ?? entry.userEmail ?? 'System'}{entry.action}{entry.entity ?? '—'}{entry.entityId ? ` #${entry.entityId}` : ''}
+
+ {/if} +
+
diff --git a/src/routes/admin/traffic/+page.server.ts b/src/routes/admin/traffic/+page.server.ts new file mode 100644 index 0000000..7bd4039 --- /dev/null +++ b/src/routes/admin/traffic/+page.server.ts @@ -0,0 +1,51 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { pageViews, pets } from '$lib/server/schema'; +import { eq, gte, desc, count, and } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; + +export const load: PageServerLoad = async ({ locals, url }) => { + if (!hasPermission(locals.user!.role as Role, 'system')) error(403, 'Access denied'); + + const days = parseInt(url.searchParams.get('days') ?? '7', 10); + const since = new Date(); + since.setDate(since.getDate() - days); + + // Top paths + const topPaths = await db + .select({ + path: pageViews.path, + views: count() + }) + .from(pageViews) + .where(and(gte(pageViews.createdAt, since), eq(pageViews.isBot, false))) + .groupBy(pageViews.path) + .orderBy(desc(count())) + .limit(25); + + // Top referrers + const topReferrers = await db + .select({ + referrer: pageViews.referrer, + views: count() + }) + .from(pageViews) + .where(and(gte(pageViews.createdAt, since), eq(pageViews.isBot, false))) + .groupBy(pageViews.referrer) + .orderBy(desc(count())) + .limit(10); + + // Total and bot counts + const [totalRow] = await db.select({ total: count() }).from(pageViews).where(gte(pageViews.createdAt, since)); + const [botRow] = await db.select({ total: count() }).from(pageViews).where(and(gte(pageViews.createdAt, since), eq(pageViews.isBot, true))); + + return { + topPaths, + topReferrers: topReferrers.filter(r => r.referrer), + totalViews: totalRow?.total ?? 0, + botViews: botRow?.total ?? 0, + days + }; +}; diff --git a/src/routes/admin/traffic/+page.svelte b/src/routes/admin/traffic/+page.svelte new file mode 100644 index 0000000..ba276aa --- /dev/null +++ b/src/routes/admin/traffic/+page.svelte @@ -0,0 +1,74 @@ + + +Traffic — Admin + +
+
+

Traffic

+ +
+ +
+
+

Human Page Views

+

{(data.totalViews - data.botViews).toLocaleString()}

+
+
+

Bot Requests

+

{data.botViews.toLocaleString()}

+
+
+ +
+
+

Top Pages

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

No data yet.

+ {:else} +
+ {#each data.topPaths as row} +
+ {row.path} + {row.views.toLocaleString()} +
+ {/each} +
+ {/if} +
+ +
+

Top Referrers

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

No referrer data yet.

+ {:else} +
+ {#each data.topReferrers as row} +
+ {row.referrer} + {row.views.toLocaleString()} +
+ {/each} +
+ {/if} +
+
+