From 6e3ca2e496bad78c1657e50284328940b017734c Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:53:57 -0500 Subject: [PATCH] Add foster portal with dashboard, submit update, and request supplies Fosters can view their current animals (with photos), submit updates (note/photo/update types with optional photo upload), and request supplies with urgency levels. Staff is notified via email on each submission. Layout uses orgConfig branding and CSS custom properties. Co-Authored-By: Claude Sonnet 4.6 --- src/routes/foster/+layout.server.ts | 40 ++++ src/routes/foster/+layout.svelte | 91 +++++++++ src/routes/foster/+page.server.ts | 70 +++++++ src/routes/foster/+page.svelte | 159 +++++++++++++++ src/routes/foster/submit/+page.server.ts | 111 +++++++++++ src/routes/foster/submit/+page.svelte | 142 ++++++++++++++ src/routes/foster/supplies/+page.server.ts | 110 +++++++++++ src/routes/foster/supplies/+page.svelte | 214 +++++++++++++++++++++ 8 files changed, 937 insertions(+) create mode 100644 src/routes/foster/+layout.server.ts create mode 100644 src/routes/foster/+layout.svelte create mode 100644 src/routes/foster/+page.server.ts create mode 100644 src/routes/foster/+page.svelte create mode 100644 src/routes/foster/submit/+page.server.ts create mode 100644 src/routes/foster/submit/+page.svelte create mode 100644 src/routes/foster/supplies/+page.server.ts create mode 100644 src/routes/foster/supplies/+page.svelte diff --git a/src/routes/foster/+layout.server.ts b/src/routes/foster/+layout.server.ts new file mode 100644 index 0000000..82a7ffd --- /dev/null +++ b/src/routes/foster/+layout.server.ts @@ -0,0 +1,40 @@ +import type { LayoutServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { fosterAssignments, pets, petPhotos } from '$lib/server/schema'; +import { eq, and } from 'drizzle-orm'; + +export const load: LayoutServerLoad = async ({ locals }) => { + if (!locals.user) { + throw redirect(303, '/login'); + } + + if (locals.user.role !== 'foster') { + throw redirect(303, locals.user.role === 'sysadmin' || locals.user.role === 'director' ? '/admin' : '/login'); + } + + const userId = locals.user.id; + + // Load current foster assignments with pet details for sidebar/context + const assignments = await db + .select({ + assignmentId: fosterAssignments.id, + petId: pets.id, + petName: pets.name, + petSpecies: pets.species, + petBreed: pets.breed, + petAge: pets.age, + petStatus: pets.status, + photoUrl: petPhotos.url + }) + .from(fosterAssignments) + .innerJoin(pets, eq(fosterAssignments.petId, pets.id)) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where(and(eq(fosterAssignments.userId, userId), eq(fosterAssignments.isCurrent, true))); + + return { + user: locals.user, + orgConfig: locals.orgConfig, + assignments + }; +}; diff --git a/src/routes/foster/+layout.svelte b/src/routes/foster/+layout.svelte new file mode 100644 index 0000000..ce96b66 --- /dev/null +++ b/src/routes/foster/+layout.svelte @@ -0,0 +1,91 @@ + + + + {@html ``} + + +
+ +
+
+ {#if data.orgConfig.branding.logoUrl} + {orgName} + {/if} +

{orgName} Foster Portal

+
+ +
+ +
+ + + + + {#if sidebarOpen} +
sidebarOpen = false} + role="button" + tabindex="-1" + onkeydown={() => {}} + >
+ {/if} + + +
+ {@render children()} +
+
+
diff --git a/src/routes/foster/+page.server.ts b/src/routes/foster/+page.server.ts new file mode 100644 index 0000000..e381097 --- /dev/null +++ b/src/routes/foster/+page.server.ts @@ -0,0 +1,70 @@ +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/db'; +import { fosterAssignments, fosterSubmissions, supplyRequests, pets, petPhotos } from '$lib/server/schema'; +import { eq, and, desc, count } from 'drizzle-orm'; + +export const load: PageServerLoad = async ({ locals }) => { + const userId = locals.user!.id; + + // Load current foster assignments with pet info and primary photo + const assignments = await db + .select({ + assignmentId: fosterAssignments.id, + startDate: fosterAssignments.startDate, + notes: fosterAssignments.notes, + petId: pets.id, + petName: pets.name, + petBreed: pets.breed, + petAge: pets.age, + petStatus: pets.status, + petSpecies: pets.species, + photoUrl: petPhotos.url + }) + .from(fosterAssignments) + .innerJoin(pets, eq(fosterAssignments.petId, pets.id)) + .leftJoin(petPhotos, and(eq(petPhotos.petId, pets.id), eq(petPhotos.isPrimary, true))) + .where(and(eq(fosterAssignments.userId, userId), eq(fosterAssignments.isCurrent, true))); + + // Recent submissions (last 10) + const recentSubmissions = await db + .select({ + id: fosterSubmissions.id, + type: fosterSubmissions.type, + content: fosterSubmissions.content, + status: fosterSubmissions.status, + createdAt: fosterSubmissions.createdAt, + petName: pets.name + }) + .from(fosterSubmissions) + .innerJoin(pets, eq(fosterSubmissions.petId, pets.id)) + .where(eq(fosterSubmissions.userId, userId)) + .orderBy(desc(fosterSubmissions.createdAt)) + .limit(10); + + // Pending supply requests + const pendingRequests = await db + .select({ + id: supplyRequests.id, + items: supplyRequests.items, + urgency: supplyRequests.urgency, + status: supplyRequests.status, + createdAt: supplyRequests.createdAt + }) + .from(supplyRequests) + .where(and(eq(supplyRequests.userId, userId), eq(supplyRequests.status, 'pending'))) + .orderBy(desc(supplyRequests.createdAt)); + + // Count pending submissions + const [pendingSubRow] = await db + .select({ total: count() }) + .from(fosterSubmissions) + .where(and(eq(fosterSubmissions.userId, userId), eq(fosterSubmissions.status, 'pending'))); + const pendingSubmissionCount = pendingSubRow?.total ?? 0; + + return { + assignments, + recentSubmissions, + pendingRequests, + pendingSubmissionCount + }; +}; diff --git a/src/routes/foster/+page.svelte b/src/routes/foster/+page.svelte new file mode 100644 index 0000000..45539b2 --- /dev/null +++ b/src/routes/foster/+page.svelte @@ -0,0 +1,159 @@ + + + + Foster Dashboard — {orgName} + + +

Welcome, {data.user.name}

+

Here is an overview of your foster animals and activity.

+ + +
+
+

Active Fosters

+

{data.assignments.length}

+
+
+

Pending Submissions

+

{data.pendingSubmissionCount}

+
+
+

Pending Supply Requests

+

{data.pendingRequests.length}

+
+
+ + +
+

My Animals

+ {#if data.assignments.length > 0} + + {/if} +
+ +{#if data.assignments.length === 0} +
+

No animals currently assigned to you.

+

Your foster coordinator will assign animals when they are ready.

+
+{:else} +
+ {#each data.assignments as a} +
+ {#if a.photoUrl} + {a.petName} + {:else} +
+ + + +
+ {/if} +
+

{a.petName}

+

+ {a.petBreed ?? 'Unknown breed'} + {#if a.petAge} + · {a.petAge} + {/if} +

+

In foster since {formatDate(a.startDate)}

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

Recent Submissions

+
+ {#if data.recentSubmissions.length === 0} +
+ No recent submissions. + {#if data.assignments.length > 0} + + Submit your first update + + {/if} +
+ {:else} +
    + {#each data.recentSubmissions as sub} +
  • + + {sub.status} + +
    +

    + {typeLabel(sub.type)} + for {sub.petName} + {#if sub.content} + — {sub.content.length > 60 ? sub.content.slice(0, 60) + '...' : sub.content} + {/if} +

    +
    + {formatDate(sub.createdAt)} +
  • + {/each} +
+ {/if} +
diff --git a/src/routes/foster/submit/+page.server.ts b/src/routes/foster/submit/+page.server.ts new file mode 100644 index 0000000..e46c815 --- /dev/null +++ b/src/routes/foster/submit/+page.server.ts @@ -0,0 +1,111 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { fosterSubmissions, fosterAssignments, pets } from '$lib/server/schema'; +import { eq, and } from 'drizzle-orm'; +import { fail } from '@sveltejs/kit'; +import { saveUpload } from '$lib/server/upload'; +import { getOrgConfig } from '$lib/server/config'; +import { sendFosterSubmissionNotification } from '$lib/server/email'; + +export const load: PageServerLoad = async ({ locals, url }) => { + const userId = locals.user!.id; + const preselectedPetId = url.searchParams.get('petId') ? parseInt(url.searchParams.get('petId')!) : null; + + const assignments = await db + .select({ + petId: pets.id, + petName: pets.name + }) + .from(fosterAssignments) + .innerJoin(pets, eq(fosterAssignments.petId, pets.id)) + .where(and(eq(fosterAssignments.userId, userId), eq(fosterAssignments.isCurrent, true))); + + return { + assignments, + preselectedPetId + }; +}; + +export const actions: Actions = { + default: async ({ request, locals }) => { + const userId = locals.user!.id; + const fd = await request.formData(); + + const petId = parseInt(fd.get('petId')?.toString() ?? '0', 10); + const type = fd.get('type')?.toString() ?? 'update'; + const content = fd.get('content')?.toString().trim() || null; + const photoFile = fd.get('photo') as File | null; + + if (!petId) { + return fail(400, { error: 'Please select a pet.' }); + } + + if (!['note', 'photo', 'update'].includes(type)) { + return fail(400, { error: 'Invalid submission type.' }); + } + + if (!content && !photoFile?.size) { + return fail(400, { error: 'Please add a note or upload a photo.' }); + } + + // Verify pet is assigned to this foster + const [assignment] = await db + .select({ id: fosterAssignments.id }) + .from(fosterAssignments) + .where( + and( + eq(fosterAssignments.petId, petId), + eq(fosterAssignments.userId, userId), + eq(fosterAssignments.isCurrent, true) + ) + ); + + if (!assignment) { + return fail(403, { error: 'This pet is not assigned to you.' }); + } + + // Handle photo upload + let photoUrl: string | null = null; + if (photoFile && photoFile.size > 0) { + try { + const uploaded = await saveUpload(photoFile, 'foster-submissions'); + photoUrl = uploaded.url; + } catch (err) { + return fail(400, { error: err instanceof Error ? err.message : 'Photo upload failed.' }); + } + } + + // Get pet name for notification + const [pet] = await db + .select({ name: pets.name }) + .from(pets) + .where(eq(pets.id, petId)); + + await db.insert(fosterSubmissions).values({ + userId, + petId, + type: type as 'note' | 'photo' | 'update', + content, + photoUrl, + status: 'pending' + }); + + // Send notification email + try { + const config = await getOrgConfig(); + if (config.orgInfo.email) { + await sendFosterSubmissionNotification( + config.orgInfo.name, + config.orgInfo.email, + locals.user!.name, + pet?.name ?? 'Unknown', + type + ); + } + } catch { + // Email notification not critical + } + + return { success: true }; + } +}; diff --git a/src/routes/foster/submit/+page.svelte b/src/routes/foster/submit/+page.svelte new file mode 100644 index 0000000..cc47626 --- /dev/null +++ b/src/routes/foster/submit/+page.svelte @@ -0,0 +1,142 @@ + + + + Submit Update — {orgName} Foster Portal + + +← Back to Dashboard + +

Submit an Update

+ +{#if showSuccess} +
+ + + + Your update has been submitted and is pending review. +
+{/if} + +{#if form?.error} +
+ {form.error} +
+{/if} + +
+ {#if data.assignments.length === 0} +
+

No animals are currently assigned to you.

+

Contact your foster coordinator to be assigned a pet.

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

JPG, PNG, or WebP. Max 10MB.

+
+
+ +
+ +
+
+ {/if} +
diff --git a/src/routes/foster/supplies/+page.server.ts b/src/routes/foster/supplies/+page.server.ts new file mode 100644 index 0000000..3cf313f --- /dev/null +++ b/src/routes/foster/supplies/+page.server.ts @@ -0,0 +1,110 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { supplyRequests, fosterAssignments, pets } from '$lib/server/schema'; +import { eq, and, desc } from 'drizzle-orm'; +import { fail } from '@sveltejs/kit'; +import { getOrgConfig } from '$lib/server/config'; +import { sendSupplyRequestNotification } from '$lib/server/email'; + +export const load: PageServerLoad = async ({ locals, url }) => { + const userId = locals.user!.id; + const preselectedPetId = url.searchParams.get('petId') ? parseInt(url.searchParams.get('petId')!) : null; + + // All supply requests for this user + const requests = await db + .select({ + id: supplyRequests.id, + items: supplyRequests.items, + urgency: supplyRequests.urgency, + notes: supplyRequests.notes, + status: supplyRequests.status, + createdAt: supplyRequests.createdAt, + petId: supplyRequests.petId, + petName: pets.name + }) + .from(supplyRequests) + .leftJoin(pets, eq(supplyRequests.petId, pets.id)) + .where(eq(supplyRequests.userId, userId)) + .orderBy(desc(supplyRequests.createdAt)); + + // Current foster assignments for pet dropdown + const assignments = await db + .select({ + petId: pets.id, + petName: pets.name + }) + .from(fosterAssignments) + .innerJoin(pets, eq(fosterAssignments.petId, pets.id)) + .where(and(eq(fosterAssignments.userId, userId), eq(fosterAssignments.isCurrent, true))); + + return { + requests, + assignments, + preselectedPetId + }; +}; + +export const actions: Actions = { + create: async ({ request, locals }) => { + const userId = locals.user!.id; + const fd = await request.formData(); + + const petId = fd.get('petId')?.toString() ? parseInt(fd.get('petId')!.toString(), 10) : null; + const items = fd.get('items')?.toString().trim() ?? ''; + const urgency = fd.get('urgency')?.toString() ?? 'medium'; + const notes = fd.get('notes')?.toString().trim() || null; + + if (!items) { + return fail(400, { error: 'Please list the supplies you need.' }); + } + + if (!['low', 'medium', 'high'].includes(urgency)) { + return fail(400, { error: 'Invalid urgency level.' }); + } + + // If petId provided, verify pet is assigned to this foster + if (petId) { + const [assignment] = await db + .select({ id: fosterAssignments.id }) + .from(fosterAssignments) + .where( + and( + eq(fosterAssignments.petId, petId), + eq(fosterAssignments.userId, userId), + eq(fosterAssignments.isCurrent, true) + ) + ); + + if (!assignment) { + return fail(403, { error: 'This pet is not assigned to you.' }); + } + } + + await db.insert(supplyRequests).values({ + userId, + petId: petId || null, + items, + urgency: urgency as 'low' | 'medium' | 'high', + notes, + status: 'pending' + }); + + // Send notification email + try { + const config = await getOrgConfig(); + if (config.orgInfo.email) { + await sendSupplyRequestNotification( + config.orgInfo.name, + config.orgInfo.email, + locals.user!.name, + items, + urgency + ); + } + } catch { + // Email notification not critical + } + + return { success: true }; + } +}; diff --git a/src/routes/foster/supplies/+page.svelte b/src/routes/foster/supplies/+page.svelte new file mode 100644 index 0000000..cbe0e23 --- /dev/null +++ b/src/routes/foster/supplies/+page.svelte @@ -0,0 +1,214 @@ + + + + Request Supplies — {orgName} Foster Portal + + +← Back to Dashboard + +

Request Supplies

+ +{#if showSuccess} +
+ + + + Supply request submitted. Your coordinator will be notified. +
+{/if} + +{#if form?.error} +
+ {form.error} +
+{/if} + + +
+

New Request

+ +
+
+ +
+ + +

List everything you need, one item per line or separated by commas.

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

Your Requests

+
+ {#if data.requests.length === 0} +
No supply requests yet.
+ {:else} + +
    + {#each data.requests as req} +
  • +
    + {req.items.length > 50 ? req.items.slice(0, 50) + '...' : req.items} + {req.status} +
    +
    + {#if req.petName} + {req.petName} + · + {/if} + {req.urgency} + · + {formatDate(req.createdAt)} +
    +
  • + {/each} +
+ + + + {/if} +