From f4245a996a47606d68239bf78346eaabb13613d8 Mon Sep 17 00:00:00 2001 From: dallensmith Date: Sat, 6 Jun 2026 02:02:49 -0400 Subject: [PATCH] feat(hooks): add migration automation, deactivated site guard, and super admin flag - Run database migrations on startup if `RUN_MIGRATIONS=true` - Block public access to deactivated sites with a 503 page, allowing admin/login paths - Add `isSuperAdmin` boolean to session user type in app.d.ts --- docker-compose.yml | 16 + src/app.d.ts | 1 + src/hooks.server.ts | 87 +- src/lib/server/db/migrate.ts | 29 + src/routes/+layout.server.ts | 34 +- src/routes/+layout.svelte | 46 +- src/routes/+page.server.ts | 11 +- src/routes/+page.svelte | 76 +- src/routes/admin/+layout.server.ts | 33 +- src/routes/admin/+layout.svelte | 41 +- src/routes/admin/+page.svelte | 108 ++- src/routes/admin/branding/+page.server.ts | 153 ++++ src/routes/admin/branding/+page.svelte | 958 ++++++++++++++++++++++ src/routes/login/+page.svelte | 44 +- 14 files changed, 1596 insertions(+), 41 deletions(-) create mode 100644 docker-compose.yml create mode 100644 src/lib/server/db/migrate.ts create mode 100644 src/routes/admin/branding/+page.server.ts create mode 100644 src/routes/admin/branding/+page.svelte diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6abd13a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' +services: + postgres: + image: postgres:16-alpine + container_name: collective-hub-db + environment: + POSTGRES_USER: hub_dev + POSTGRES_PASSWORD: hub_dev_password + POSTGRES_DB: collective_hub + ports: + - '5432:5432' + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/src/app.d.ts b/src/app.d.ts index 44d2070..9a0c5e5 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -18,6 +18,7 @@ declare global { email: string | null; } | null; membership: Membership | null; + isSuperAdmin: boolean; } // interface PageData {} // interface PageState {} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index a811bdb..31f7cdd 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -4,13 +4,40 @@ import { env } from '$env/dynamic/private'; import { svelteKitHandler } from 'better-auth/svelte-kit'; import { auth } from '$lib/server/auth'; import { getSiteBySlug } from '$lib/server/site-resolver'; +import { runMigrations } from '$lib/server/db/migrate'; + +// ─── Migration Automation ──────────────────────────────────────────────────── +// +// Runs database migrations on startup if RUN_MIGRATIONS=true. +// Only the designated migration-runner deployment sets this to true. +// All other deployments (RUN_MIGRATIONS=false or unset) skip migrations. +// +// This runs as a module-level initialization before the server accepts +// any requests. Migrations are skipped entirely during the Vite build +// phase (`building` is true). + +if (!building) { + const shouldRun = env.RUN_MIGRATIONS === 'true'; + + if (shouldRun) { + console.log('🚀 RUN_MIGRATIONS=true — running database migrations…'); + // Top-level await is fine here — Node.js supports it in ES modules, + // and SvelteKit's server entry is an ES module. + await runMigrations(); + } else { + console.log('⏭️ RUN_MIGRATIONS is not "true" — skipping migrations.'); + } +} + +// ─── Request Handler ───────────────────────────────────────────────────────── /** * Root server hook — runs on every request. * * Order of operations: - * 1. Resolve the current site from SITE_SLUG env var → attach to event.locals - * 2. Delegate to Better Auth's svelteKitHandler (handles /api/auth/* routes, + * 1. (Startup only) Run DB migrations if RUN_MIGRATIONS=true + * 2. Resolve the current site from SITE_SLUG env var → attach to event.locals + * 3. Delegate to Better Auth's svelteKitHandler (handles /api/auth/* routes, * passes through for all other routes) */ export const handle: Handle = async ({ event, resolve }) => { @@ -27,6 +54,62 @@ export const handle: Handle = async ({ event, resolve }) => { event.locals.siteSlug = slug; event.locals.siteSettings = siteContext.settings; + // --- Deactivated Site Guard (503) --- + // If the site is deactivated (isActive=false), block public access with a 503. + // Admin paths (/admin/*) and the login page (/login) are allowed through so + // that site owners/admins can still log in and reactivate the site. + if (event.locals.site && !event.locals.site.isActive) { + const pathname = event.url.pathname; + const isAdminPath = pathname.startsWith('/admin'); + const isLoginPath = pathname.startsWith('/login'); + + if (!isAdminPath && !isLoginPath) { + const bg = event.locals.siteSettings?.theme?.backgroundColor ?? '#1a1a2e'; + const fg = event.locals.siteSettings?.theme?.textColor ?? '#eaeaea'; + const name = event.locals.site.name; + + return new Response( + ` + + + + +Site Unavailable — ${name} + + + +
+

Site Unavailable

+

This site is currently deactivated. Please check back later.

+
+ +`, + { + status: 503, + headers: { 'Content-Type': 'text/html; charset=utf-8' } + } + ); + } + } + // --- Auth (Better Auth SvelteKit handler) --- // svelteKitHandler intercepts /api/auth/* and handles OAuth flows. // For all other routes it calls resolve(event) transparently. diff --git a/src/lib/server/db/migrate.ts b/src/lib/server/db/migrate.ts new file mode 100644 index 0000000..9c04ce0 --- /dev/null +++ b/src/lib/server/db/migrate.ts @@ -0,0 +1,29 @@ +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import { db } from './index'; +import { resolve } from 'path'; + +/** + * Run database migrations from the `./drizzle` folder. + * + * Uses drizzle-orm's migrator which reads the SQL migration files + * generated by `drizzle-kit generate` and applies them in order. + * + * This should only be called on the deployment with RUN_MIGRATIONS=true. + * All other deployments skip this entire function. + * + * Logs detailed status for observability in production. + */ +export async function runMigrations(): Promise { + const migrationsFolder = resolve(process.cwd(), 'drizzle'); + + console.log('🔄 Running database migrations…'); + console.log(` Migrations folder: ${migrationsFolder}`); + + try { + await migrate(db, { migrationsFolder }); + console.log('✅ Migrations complete.'); + } catch (err) { + console.error('❌ Migration failed:', err); + throw err; + } +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index c7f64e8..0de2f49 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -9,16 +9,29 @@ import { env } from '$env/dynamic/private'; * Root layout server load — runs on every page navigation. * * Responsibilities: - * 1. Load the Better Auth session (if any) - * 2. Sync the authenticated user to our application `users` table - * 3. Perform owner bootstrap: if user's Discord ID matches OWNER_DISCORD_ID, + * 1. Parse SUPER_ADMIN_DISCORD_IDS env var for super admin access + * 2. Load the Better Auth session (if any) + * 3. Sync the authenticated user to our application `users` table + * 4. Perform owner bootstrap: if user's Discord ID matches OWNER_DISCORD_ID, * upsert a membership with role 'owner' for the current site - * 4. Load the user's membership for the current site - * 5. Return site, user, and membership data to all pages + * 5. Check if user is a super admin (bypasses site-scoped membership) + * 6. Load the user's membership for the current site + * 7. Return site, user, membership, and isSuperAdmin data to all pages */ export const load: LayoutServerLoad = async (event) => { const { site, siteSlug, siteSettings } = event.locals; + // ─── Super Admin IDs ──────────────────────────────────────────────────── + // Parse SUPER_ADMIN_DISCORD_IDS env var (comma-separated list of Discord IDs). + // Users whose Discord ID is in this list bypass all site-scoped membership + // checks and get full admin access to any site. + const superAdminIds = (env.SUPER_ADMIN_DISCORD_IDS ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + + let isSuperAdmin = false; + // Get session from Better Auth const session = await auth.api.getSession({ headers: event.request.headers @@ -70,6 +83,13 @@ export const load: LayoutServerLoad = async (event) => { appUser = fetchedUser ?? null; + // --- Super Admin Resolution --- + // Check BEFORE owner bootstrap so super admins always get the flag + // regardless of whether they also happen to be the owner. + if (appUser && superAdminIds.includes(discordAccount.accountId)) { + isSuperAdmin = true; + } + // --- Owner Bootstrap --- // If this user's Discord ID matches OWNER_DISCORD_ID, ensure they // have an 'owner' membership for the current site. @@ -119,12 +139,14 @@ export const load: LayoutServerLoad = async (event) => { } : null; event.locals.membership = membership; + event.locals.isSuperAdmin = isSuperAdmin; return { site, siteSlug, siteSettings, user: event.locals.user, - membership + membership, + isSuperAdmin }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 4ed109f..f5136df 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -19,22 +19,62 @@ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); } + // ── Theme preset defaults ────────────────────────────────────────────────── + const DARK_PRESET = { + accentColor: '#e63946', + backgroundColor: '#1a1a2e', + textColor: '#eaeaea' + } as const; + + const LIGHT_PRESET = { + accentColor: '#e63946', + backgroundColor: '#ffffff', + textColor: '#1a1a2e' + } as const; + // ── Theme values (reactive via $derived — updates if data.siteSettings changes) ── - let accentColor = $derived(data.siteSettings?.theme?.accentColor || '#e63946'); - let backgroundColor = $derived(data.siteSettings?.theme?.backgroundColor || '#1a1a2e'); - let textColor = $derived(data.siteSettings?.theme?.textColor || '#eaeaea'); + const theme = $derived(data.siteSettings?.theme); + const preset = $derived(theme?.preset ?? 'dark'); + + // Resolve colors based on preset + any stored overrides + let accentColor = $derived( + theme?.accentColor || + (preset === 'light' ? LIGHT_PRESET.accentColor : DARK_PRESET.accentColor) + ); + let backgroundColor = $derived( + theme?.backgroundColor || + (preset === 'light' ? LIGHT_PRESET.backgroundColor : DARK_PRESET.backgroundColor) + ); + let textColor = $derived( + theme?.textColor || + (preset === 'light' ? LIGHT_PRESET.textColor : DARK_PRESET.textColor) + ); // Secondary text: muted version based on background luminance let textSecondary = $derived( hexLuminance(backgroundColor) < 0.5 ? '#b0b0b0' : '#555555' ); + // Derived card/border colors for a polished look + let cardBackground = $derived( + preset === 'light' + ? 'rgba(0, 0, 0, 0.03)' + : 'rgba(255, 255, 255, 0.05)' + ); + let borderColor = $derived( + preset === 'light' + ? 'rgba(0, 0, 0, 0.08)' + : 'rgba(255, 255, 255, 0.08)' + ); + let cssVars = $derived( [ `--color-accent: ${accentColor}`, `--color-background: ${backgroundColor}`, `--color-text: ${textColor}`, `--color-text-secondary: ${textSecondary}`, + `--color-card-background: ${cardBackground}`, + `--color-border: ${borderColor}`, `--font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif` ].join('; ') ); diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 5aa00b9..2d3c7bf 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,4 +1,5 @@ import type { PageServerLoad } from './$types'; +import { getCdnUrl } from '$lib/server/cdn'; /** * Homepage server load — flattens site settings into simple props. @@ -34,6 +35,11 @@ export const load: PageServerLoad = async (event) => { const showNextEvent = homepage?.showNextEvent ?? false; const showSchedule = homepage?.showSchedule ?? false; + // Resolve branding asset CDN keys to full URLs + const logoUrl = branding?.logoCdnKey ? getCdnUrl(branding.logoCdnKey) : null; + const backgroundUrl = branding?.backgroundCdnKey ? getCdnUrl(branding.backgroundCdnKey) : null; + const faviconUrl = branding?.faviconCdnKey ? getCdnUrl(branding.faviconCdnKey) : null; + return { site, heroTitle, @@ -44,6 +50,9 @@ export const load: PageServerLoad = async (event) => { showNextEvent, showSchedule, user, - membership + membership, + logoUrl, + backgroundUrl, + faviconUrl }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 233ae95..1ba21a8 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -14,6 +14,9 @@ {data.heroTitle} + {#if data.faviconUrl} + + {/if} @@ -24,11 +27,23 @@

Run npm run db:seed to create the default "local-dev" site.

{:else} - - - -
+ +
+ {#if data.logoUrl} + + {/if}

{data.heroTitle}

{#if data.heroSubtitle} @@ -49,9 +64,7 @@
- - - + {#if data.aboutText}
@@ -61,9 +74,7 @@
{/if} - - - +