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 apps.length === 0}
+
+
+
+
+
{data.q || data.type || data.status ? 'No applications match your filters' : 'No applications yet'}
+
+ {:else}
+
+
+
+
+
+ Applicant
+ Email
+ Type
+ Status
+ Pet
+ Date
+ Actions
+
+
+
+ {#each apps as app}
+
+
+
+ {app.applicantName}
+
+
+
+ {app.email}
+
+
+
+ {formatType(app.type)}
+
+
+
+
+
+
+ {#if app.petName}
+ {app.petName}
+ {:else}
+ —
+ {/if}
+
+
+ {formatDate(app.createdAt)}
+
+
+
+ View
+
+
+
+ {/each}
+
+
+
+
+
+
+ {/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}
+
+
+ {#if 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}
+
+ {/if}
+
+ {#if 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}
+
+
+
+
+ Visit Date (optional)
+
+
+
+ Notes
+
+
+
+
+
+ {submittingHomevisitNote ? 'Adding...' : 'Add Homevisit Note'}
+
+
+
+
+
+
+
+
+
+
+
Update Status
+
+ {#if showStatusSuccess}
+
+
+
+
+ Status updated.
+
+ {/if}
+
+
+
+ Status
+
+ {#each statusOptions as s}
+
+ {s.charAt(0).toUpperCase() + s.slice(1)}
+
+ {/each}
+
+
+
+ {submittingStatus ? 'Saving...' : 'Save Status'}
+
+
+
+
+
+ {#if pet}
+
+ {/if}
+
+
+ {#if app.type === 'adoption'}
+
+ {/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
+
+
window.print()}
+ style="padding: 8px 16px; font-size: 14px; font-weight: 500; border-radius: 8px; background: #018184; color: white; border: none; cursor: pointer;"
+ >
+ Print Contract
+
+
+
+{@html data.contractHtml}