diff --git a/src/routes/admin/applications/+page.server.ts b/src/routes/admin/applications/+page.server.ts new file mode 100644 index 0000000..9d6bc08 --- /dev/null +++ b/src/routes/admin/applications/+page.server.ts @@ -0,0 +1,51 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { applications, pets } from '$lib/server/schema'; +import { eq, and, like, 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, 'applications')) error(403, 'Access denied'); + + const q = url.searchParams.get('q')?.trim() ?? ''; + const type = url.searchParams.get('type') ?? ''; + 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 = []; + if (q) conditions.push(like(applications.applicantName, `%${q}%`)); + if (type) conditions.push(eq(applications.type, type as typeof applications.type.enumValues[number])); + if (status) conditions.push(eq(applications.status, status as typeof applications.status.enumValues[number])); + + const where = conditions.length > 0 ? and(...conditions) : undefined; + + 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 rows = await db + .select({ + id: applications.id, + type: applications.type, + petId: applications.petId, + applicantName: applications.applicantName, + email: applications.applicantEmail, + phone: applications.applicantPhone, + status: applications.status, + createdAt: applications.createdAt, + petName: pets.name + }) + .from(applications) + .leftJoin(pets, eq(applications.petId, pets.id)) + .where(where) + .orderBy(desc(applications.createdAt)) + .limit(PER_PAGE) + .offset(offset); + + return { applications: rows, total, page, totalPages, q, type, status }; +}; diff --git a/src/routes/admin/applications/+page.svelte b/src/routes/admin/applications/+page.svelte new file mode 100644 index 0000000..c7a6241 --- /dev/null +++ b/src/routes/admin/applications/+page.svelte @@ -0,0 +1,166 @@ + + + + Applications — Admin + + +
+
+

Applications

+ {data.total} total +
+ +
+
+
+ +
+
+ +
+
+ +
+ + {#if data.q || data.type || data.status} + + Clear + + {/if} +
+
+ + {#if apps.length === 0} +
+ + + +

{data.q || data.type || data.status ? 'No applications match your filters' : 'No applications yet'}

+
+ {:else} +
+
+ + + + + + + + + + + + + + {#each apps as app} + + + + + + + + + + {/each} + +
ApplicantEmailTypeStatusPetDateActions
+ + {app.applicantName} + + + {app.email} + + + {formatType(app.type)} + + + + + {#if app.petName} + {app.petName} + {:else} + — + {/if} + + {formatDate(app.createdAt)} + + + View + +
+
+
+ + + {/if} +
diff --git a/src/routes/admin/applications/[id]/+page.server.ts b/src/routes/admin/applications/[id]/+page.server.ts new file mode 100644 index 0000000..94f6c6b --- /dev/null +++ b/src/routes/admin/applications/[id]/+page.server.ts @@ -0,0 +1,179 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { applications, pets, users, applicationHomevisitNotes } from '$lib/server/schema'; +import { eq, asc } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { logAudit } from '$lib/server/audit'; +import { sendApplicationStatusUpdate, sendFosterWelcomeEmail } from '$lib/server/email'; +import { hasPermission } from '$lib/roles'; +import type { Role } from '$lib/roles'; +import { hashPassword } from '$lib/server/auth-utils'; +import crypto from 'node:crypto'; + +export const load: PageServerLoad = async ({ params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'applications')) 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) error(404, 'Application not found'); + + let pet = null; + if (app.petId) { + const [petRecord] = await db.select().from(pets).where(eq(pets.id, app.petId)); + pet = petRecord ?? null; + } + + const homevisitNotes = await db + .select({ + id: applicationHomevisitNotes.id, + notes: applicationHomevisitNotes.notes, + visitDate: applicationHomevisitNotes.visitDate, + createdAt: applicationHomevisitNotes.createdAt, + userName: users.name + }) + .from(applicationHomevisitNotes) + .innerJoin(users, eq(applicationHomevisitNotes.userId, users.id)) + .where(eq(applicationHomevisitNotes.applicationId, id)) + .orderBy(asc(applicationHomevisitNotes.createdAt)); + + return { application: app, pet, homevisitNotes }; +}; + +export const actions: Actions = { + updateStatus: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'applications')) 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_application_status', + entity: 'application', + entityId: id, + diff: { status } + }); + + // Send status update email + try { + const [app] = await db + .select({ applicantName: applications.applicantName, email: applications.applicantEmail, type: applications.type }) + .from(applications) + .where(eq(applications.id, id)); + + if (app) { + await sendApplicationStatusUpdate( + process.env.ORG_NAME ?? 'Rescue', + app.email, + app.applicantName, + app.type, + status, + process.env.SITE_URL ?? '' + ); + + // Auto-create foster account on approval + if (status === 'approved' && app.type === 'foster') { + const [existingUser] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, app.email)) + .limit(1); + + if (!existingUser) { + const tempPassword = crypto.randomBytes(4).toString('hex'); + const passwordHash = await hashPassword(tempPassword); + await db.insert(users).values({ + email: app.email, + name: app.applicantName, + role: 'foster', + passwordHash, + mustChangePassword: true, + active: true + }); + await sendFosterWelcomeEmail( + process.env.ORG_NAME ?? 'Rescue', + app.email, + app.applicantName, + tempPassword, + `${process.env.SITE_URL ?? ''}/login` + ); + } + } + } + } catch { + // Email failure should not break status update + } + + return { success: true, action: 'updateStatus' }; + }, + + addNote: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'applications')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + const fd = await request.formData(); + const note = fd.get('note')?.toString().trim(); + + if (!note) return fail(400, { error: 'Note cannot be empty' }); + + const [app] = await db + .select({ reviewNotes: applications.reviewNotes }) + .from(applications) + .where(eq(applications.id, id)); + + const timestamp = new Date().toLocaleString('en-US', { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit' + }); + const userName = locals.user?.name ?? 'Unknown'; + const newNote = `[${timestamp} — ${userName}] ${note}`; + const updatedNotes = app?.reviewNotes ? `${app.reviewNotes}\n\n${newNote}` : newNote; + + await db.update(applications).set({ reviewNotes: updatedNotes }).where(eq(applications.id, id)); + + await logAudit({ + userId: locals.user?.id ?? undefined, + action: 'add_application_note', + entity: 'application', + entityId: id + }); + + return { success: true, action: 'addNote' }; + }, + + addHomevisitNote: async ({ request, params, locals }) => { + if (!hasPermission(locals.user!.role as Role, 'applications')) error(403, 'Access denied'); + const id = parseInt(params.id, 10); + const fd = await request.formData(); + const notes = fd.get('notes')?.toString().trim(); + const visitDate = fd.get('visitDate')?.toString() || null; + + if (!notes) return fail(400, { error: 'Note cannot be empty' }); + + await db.insert(applicationHomevisitNotes).values({ + applicationId: id, + userId: locals.user!.id, + notes, + visitDate: visitDate ? new Date(visitDate) : null + }); + + await logAudit({ + userId: locals.user?.id ?? undefined, + action: 'add_homevisit_note', + entity: 'application', + entityId: id + }); + + return { success: true, action: 'addHomevisitNote' }; + } +}; diff --git a/src/routes/admin/applications/[id]/+page.svelte b/src/routes/admin/applications/[id]/+page.svelte new file mode 100644 index 0000000..25171dd --- /dev/null +++ b/src/routes/admin/applications/[id]/+page.svelte @@ -0,0 +1,354 @@ + + + + Application #{app.id} — Admin + + +
+
+ + + + + +
+

Application #{app.id}

+ + {formatType(app.type)} + + +
+
+ + {#if form?.error} +
{form.error}
+ {/if} + +
+ +
+ +
+

Applicant Information

+
+
+

Name

+

{app.applicantName}

+
+
+

Email

+

+ {app.applicantEmail} +

+
+ {#if app.applicantPhone} +
+

Phone

+

+ {app.applicantPhone} +

+
+ {/if} +
+

Submitted

+

{formatDate(app.createdAt)}

+
+
+
+ + +
+

Application Details

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

No form data available.

+ {:else} +
+ {#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} +
+ + {@const fd = typeof app.formData === 'string' ? JSON.parse(app.formData as string) : app.formData} + {#if (fd as Record)?.signature} +
+

Signature

+
+ ).signature as string} alt="Applicant signature" class="max-h-24" /> +
+
+ {/if} + {/if} +
+ + +
+

Review Notes

+ + {#if showNoteSuccess} +
+ + + + Note added. +
+ {/if} + + {#if app.reviewNotes} +
+
{app.reviewNotes}
+
+ {:else} +

No notes yet.

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

Homevisit Notes

+ + {#if showHomevisitNoteSuccess} +
+ + + + Homevisit note added. +
+ {/if} + + {#if homevisitNotes.length > 0} +
+ {#each homevisitNotes as note} +
+
+ {note.userName} + {formatDate(note.createdAt)} + {#if note.visitDate} + Visit: {formatDate(note.visitDate)} + {/if} +
+

{note.notes}

+
+ {/each} +
+ {:else} +

No homevisit notes yet.

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

Update Status

+ + {#if showStatusSuccess} +
+ + + + Status updated. +
+ {/if} + +
+
+ + +
+ +
+
+ + + {#if pet} + + {/if} + + + {#if app.type === 'adoption'} +
+

Adoption Contract

+ + Generate Contract + +
+ {/if} +
+
+
diff --git a/src/routes/admin/applications/[id]/contract/+page.server.ts b/src/routes/admin/applications/[id]/contract/+page.server.ts new file mode 100644 index 0000000..acdb463 --- /dev/null +++ b/src/routes/admin/applications/[id]/contract/+page.server.ts @@ -0,0 +1,64 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { applications, pets } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; +import { generateAdoptionContract } from '$lib/server/contract'; +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, 'applications')) 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) error(404, 'Application not found'); + + if (app.type !== 'adoption') { + error(400, 'Contracts can only be generated for adoption applications'); + } + + let pet = null; + if (app.petId) { + const [petRecord] = await db.select().from(pets).where(eq(pets.id, app.petId)); + pet = petRecord ?? null; + } + + if (!pet) { + error(400, 'No pet linked to this application. Link a pet first before generating a contract.'); + } + + const fd = app.formData as Record ?? {}; + const address = (fd.address as string) ?? ''; + + const contractHtml = generateAdoptionContract({ + orgName: process.env.ORG_NAME ?? 'Rescue Organization', + orgCity: process.env.ORG_CITY ?? '', + orgState: process.env.ORG_STATE ?? '', + petName: pet.name, + species: pet.species, + breed: pet.breed ?? '', + sex: pet.sex, + age: pet.age ?? '', + adopterName: app.applicantName, + adopterEmail: app.applicantEmail, + adoptionFee: pet.adoptionFee ?? 0, + agreementItems: [ + 'The adopter agrees to provide proper food, water, shelter, and veterinary care.', + 'The adopter agrees not to re-home, sell, or transfer ownership without notifying the rescue.', + 'The adopter agrees to keep the pet as an indoor pet (where applicable).', + 'The adopter agrees to have the pet spayed/neutered if not already done.', + 'The rescue reserves the right to reclaim the animal if terms are not met.' + ], + date: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) + }); + + return { + contractHtml, + applicationId: app.id, + petName: pet.name, + applicantName: app.applicantName + }; +}; diff --git a/src/routes/admin/applications/[id]/contract/+page.svelte b/src/routes/admin/applications/[id]/contract/+page.svelte new file mode 100644 index 0000000..0eade24 --- /dev/null +++ b/src/routes/admin/applications/[id]/contract/+page.svelte @@ -0,0 +1,29 @@ + + + + Adoption Contract — {data.petName} + + + +
+ + Back to Application + + +
+ +{@html data.contractHtml}