From 8e24f5637df95ccf0450cc5fb2e49fee1643c128 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:54:02 -0500 Subject: [PATCH] Add 5-step setup wizard for initial org configuration Accessible only by sysadmin when setupComplete is false. Steps cover org info, branding (color pickers + logo upload), application form toggles, Stripe integration keys, and a link to CSV import. Each step saves to org_config via upsert and calls invalidateConfigCache(). Completing the wizard sets setup_complete = true and redirects to /admin. Co-Authored-By: Claude Sonnet 4.6 --- src/routes/setup/+page.server.ts | 186 ++++++++++++ src/routes/setup/+page.svelte | 488 +++++++++++++++++++++++++++++++ 2 files changed, 674 insertions(+) create mode 100644 src/routes/setup/+page.server.ts create mode 100644 src/routes/setup/+page.svelte diff --git a/src/routes/setup/+page.server.ts b/src/routes/setup/+page.server.ts new file mode 100644 index 0000000..e4132aa --- /dev/null +++ b/src/routes/setup/+page.server.ts @@ -0,0 +1,186 @@ +import type { PageServerLoad, Actions } from './$types'; +import { redirect, fail, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { orgConfig, siteSettings } from '$lib/server/schema'; +import { eq } from 'drizzle-orm'; +import { invalidateConfigCache } from '$lib/server/config'; +import { saveUpload } from '$lib/server/upload'; +import { logAudit } from '$lib/server/audit'; + +export const load: PageServerLoad = async ({ locals, url }) => { + if (!locals.user) { + throw redirect(303, '/login'); + } + + if (locals.user.role !== 'sysadmin') { + throw error(403, 'Setup wizard is only accessible by system administrators.'); + } + + // If setup is already complete, redirect to admin unless explicitly revisiting + if (locals.orgConfig.setupComplete && !url.searchParams.has('revisit')) { + throw redirect(303, '/admin'); + } + + const step = parseInt(url.searchParams.get('step') ?? '1', 10) || 1; + + return { + step: Math.min(Math.max(step, 1), 5), + orgConfig: locals.orgConfig + }; +}; + +async function upsertOrgConfig(key: string, value: unknown): Promise { + 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 }); + } +} + +async function upsertSiteSetting(key: string, value: string): Promise { + 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 }); + } +} + +export const actions: Actions = { + saveStep1: async ({ request, locals }) => { + if (locals.user?.role !== 'sysadmin') return fail(403, { error: 'Access denied.' }); + + const fd = await request.formData(); + + const orgInfo = { + name: fd.get('name')?.toString().trim() ?? '', + tagline: fd.get('tagline')?.toString().trim() ?? '', + city: fd.get('city')?.toString().trim() ?? '', + state: fd.get('state')?.toString().trim() ?? '', + phone: fd.get('phone')?.toString().trim() ?? '', + email: fd.get('email')?.toString().trim() ?? '', + facebookUrl: fd.get('facebookUrl')?.toString().trim() ?? '', + instagramUrl: fd.get('instagramUrl')?.toString().trim() ?? '' + }; + + if (!orgInfo.name) { + return fail(400, { error: 'Organization name is required.', step: 1 }); + } + + await upsertOrgConfig('org_info', orgInfo); + invalidateConfigCache(); + + await logAudit({ + userId: locals.user!.id, + action: 'setup_step1', + entity: 'org_config', + diff: { key: 'org_info' } + }); + + return { success: true, nextStep: 2 }; + }, + + saveStep2: async ({ request, locals }) => { + if (locals.user?.role !== 'sysadmin') return fail(403, { error: 'Access denied.' }); + + const fd = await request.formData(); + + const branding: Record = { + primaryColor: fd.get('primaryColor')?.toString() ?? '#0d9488', + primaryColorLight: fd.get('primaryColorLight')?.toString() ?? '#14b8a6', + primaryColorDark: fd.get('primaryColorDark')?.toString() ?? '#0f766e', + accentColor: fd.get('accentColor')?.toString() ?? '#f59e0b', + accentColorLight: fd.get('accentColorLight')?.toString() ?? '#fbbf24', + accentColorDark: fd.get('accentColorDark')?.toString() ?? '#d97706', + logoUrl: fd.get('existingLogoUrl')?.toString() ?? '' + }; + + // Handle logo upload + const logoFile = fd.get('logo') as File | null; + if (logoFile && logoFile.size > 0) { + try { + const uploaded = await saveUpload(logoFile, 'branding'); + branding.logoUrl = uploaded.url; + } catch (err) { + return fail(400, { error: err instanceof Error ? err.message : 'Logo upload failed.', step: 2 }); + } + } + + await upsertOrgConfig('branding', branding); + invalidateConfigCache(); + + await logAudit({ + userId: locals.user!.id, + action: 'setup_step2', + entity: 'org_config', + diff: { key: 'branding' } + }); + + return { success: true, nextStep: 3 }; + }, + + saveStep3: async ({ request, locals }) => { + if (locals.user?.role !== 'sysadmin') return fail(403, { error: 'Access denied.' }); + + const fd = await request.formData(); + + const applications = { + adoptionEnabled: fd.get('adoptionEnabled') === 'true', + fosterEnabled: fd.get('fosterEnabled') === 'true', + volunteerEnabled: fd.get('volunteerEnabled') === 'true', + surrenderEnabled: fd.get('surrenderEnabled') === 'true', + adoptionFee: parseInt(fd.get('adoptionFee')?.toString() ?? '250', 10) || 250 + }; + + await upsertOrgConfig('applications', applications); + invalidateConfigCache(); + + await logAudit({ + userId: locals.user!.id, + action: 'setup_step3', + entity: 'org_config', + diff: { key: 'applications' } + }); + + return { success: true, nextStep: 4 }; + }, + + saveStep4: async ({ request, locals }) => { + if (locals.user?.role !== 'sysadmin') return fail(403, { error: 'Access denied.' }); + + const fd = await request.formData(); + + // Save Stripe keys to site_settings + const stripePublishable = fd.get('stripePublishableKey')?.toString().trim() ?? ''; + const stripeSecret = fd.get('stripeSecretKey')?.toString().trim() ?? ''; + + if (stripePublishable) await upsertSiteSetting('stripe_publishable_key', stripePublishable); + if (stripeSecret) await upsertSiteSetting('stripe_secret_key', stripeSecret); + + await logAudit({ + userId: locals.user!.id, + action: 'setup_step4', + entity: 'site_settings', + diff: { keys: ['stripe_publishable_key', 'stripe_secret_key'] } + }); + + return { success: true, nextStep: 5 }; + }, + + complete: async ({ locals }) => { + if (locals.user?.role !== 'sysadmin') return fail(403, { error: 'Access denied.' }); + + await upsertOrgConfig('setup_complete', true); + invalidateConfigCache(); + + await logAudit({ + userId: locals.user!.id, + action: 'setup_complete', + entity: 'org_config', + diff: { key: 'setup_complete', value: true } + }); + + throw redirect(303, '/admin'); + } +}; diff --git a/src/routes/setup/+page.svelte b/src/routes/setup/+page.svelte new file mode 100644 index 0000000..9c6bada --- /dev/null +++ b/src/routes/setup/+page.svelte @@ -0,0 +1,488 @@ + + + + Setup Wizard — FosterFlow + + +
+ +
+
+

FosterFlow Setup

+ Step {currentStep} of 5 +
+
+ + +
+
+
+ {#each steps as s} +
+ + {s.num} +
+ {/each} +
+
+
+ +
+ {#if form?.error} +
+ {form.error} +
+ {/if} + + + {#if currentStep === 1} +
+

Organization Information

+

Tell us about your rescue organization.

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ + + {:else if currentStep === 2} +
+

Branding

+

Customize colors and your logo.

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

Used for navigation, buttons, and accents.

+
+
+ +
+ + +
+

Used for highlights and call-to-action elements.

+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + {#if data.orgConfig.branding.logoUrl} +
+ Current logo + Current logo +
+ {/if} + +

Upload a new logo to replace the current one. Max 10MB.

+
+
+ +
+ + +
+
+
+ + + {:else if currentStep === 3} +
+

Application Forms

+

Configure which application forms are enabled on your public site.

+ +
+
+
+
+

Adoption applications

+

Allow the public to submit adoption applications online.

+
+ +
+ +
+
+

Foster applications

+

Allow the public to apply to become foster families.

+
+ +
+ +
+
+

Volunteer applications

+

Allow the public to apply to volunteer.

+
+ +
+ +
+
+

Surrender requests

+

Allow the public to submit pet surrender requests.

+
+ +
+ +
+ +
+ $ + +
+

Can be overridden per pet.

+
+
+ +
+ + +
+
+
+ + + {:else if currentStep === 4} +
+

Integrations

+

Configure optional third-party integrations. This step can be skipped and completed later.

+ +
+
+ +
+

+ S + Stripe (Online Payments) +

+
+
+ + +
+
+ + +

Stored securely in site settings.

+
+
+
+ + +
+

Email (SMTP)

+

+ Email settings are configured via environment variables in your .env file: +

+
    +
  • SMTP_HOST, SMTP_PORT
  • +
  • SMTP_USER, SMTP_PASS
  • +
  • SMTP_FROM
  • +
+
+ + +
+

Facebook Integration

+

+ Connect your Facebook page to automatically post new animals and events. +

+ + Configure Facebook in System Settings → + +
+
+ +
+ +
+ + +
+
+
+
+ + + {:else if currentStep === 5} +
+

Import Data

+

+ Optionally import existing pet records from a CSV file. You can also do this later from the admin panel. +

+ +
+

+ Use the CSV import tool to bulk-upload pet records from a spreadsheet. The importer will auto-detect columns and let you map them to FosterFlow fields. +

+ + Open CSV Import Tool → + +
+ +
+ + +
+ +
+
+
+ {/if} + + +

Step {currentStep} of 5 — You can always change these settings later in the admin panel.

+
+