Files
the-collective-hub/docs/01-architecture-plan.md
T

10 KiB

Technical Architecture Plan

Stack Summary

Layer Choice Notes
Framework SvelteKit File-based routing, server-side rendering, API routes
Language TypeScript Strict mode recommended
Database PostgreSQL Single shared instance for all sites
ORM Drizzle ORM Type-safe, lightweight, SQL-first
Auth Better Auth With Discord OAuth provider
Deployment Coolify Multiple deployments from one Git repo
CDN/Storage Bunny CDN or S3-compatible Single bucket, site-scoped paths
Containerization Docker Dockerfile in repo, built by Coolify

Architecture Overview

flowchart TD
    subgraph Internet
        V[Visitors]
        O[Site Owners/Admins]
    end

    subgraph Coolify
        D1[Deployment: bad-movies-theater]
        D2[Deployment: garbage-day]
        D3[Deployment: future-site-N]
    end

    subgraph Shared Infrastructure
        DB[(PostgreSQL Database)]
        CDN[(CDN / Object Storage)]
    end

    subgraph Auth
        DA[Discord OAuth]
    end

    V --> D1
    V --> D2
    V --> D3
    O --> D1
    O --> D2
    O --> D3
    
    D1 --> DB
    D2 --> DB
    D3 --> DB
    
    D1 --> CDN
    D2 --> CDN
    D3 --> CDN
    
    D1 --> DA
    D2 --> DA
    D3 --> DA

Each Coolify deployment runs the same Docker image from the same Git repo. The only difference between deployments is their environment variables — specifically SITE_SLUG.

Site Resolution Flow

Every request follows this path:

flowchart LR
    R[HTTP Request] --> MW[Site Resolver Middleware]
    MW --> SLUG[Read SITE_SLUG from env]
    SLUG --> DBQ[Query: SELECT FROM sites WHERE slug = ?]
    DBQ --> CTX[Attach site + settings to locals]
    CTX --> APP[App renders with site context]
  1. Request hits the SvelteKit server
  2. A hook or middleware reads SITE_SLUG from environment variables
  3. The site record (and its settings) is loaded from the database
  4. Site context is attached to locals for the lifetime of the request
  5. All downstream code (pages, API routes, auth checks) uses this site context

Data Scoping Rule

Every site-owned record MUST include a siteId column.

This applies to: settings, pages, events, assets, nav links, social links, memberships, and any future content types.

Queries always filter by siteId:

SELECT * FROM events WHERE siteId = $currentSiteId ORDER BY startTime ASC;

This rule means:

  • No cross-site data leaks
  • The database is logically multi-tenant
  • Migrating to a single-deployment/multi-domain model later requires no schema changes

Directory Structure (Proposed)

/
├── src/
│   ├── app.d.ts              # App types, locals augmentation
│   ├── app.html              # HTML shell
│   ├── hooks.server.ts       # Site resolver, auth handling
│   ├── lib/
│   │   ├── server/
│   │   │   ├── db/
│   │   │   │   ├── index.ts          # Drizzle + postgres connection
│   │   │   │   ├── schema.ts         # All table definitions
│   │   │   │   └── seed.ts           # Optional seed data
│   │   │   ├── auth.ts               # Better Auth configuration
│   │   │   ├── site-resolver.ts      # Site loading by SITE_SLUG
│   │   │   └── cdn.ts                # CDN URL helpers
│   │   └── shared/
│   │       └── types.ts              # Shared TypeScript types
│   ├── routes/
│   │   ├── +layout.server.ts         # Root layout, loads site for all pages
│   │   ├── +page.server.ts           # Public homepage data loading
│   │   ├── +page.svelte              # Public homepage
│   │   ├── login/
│   │   │   └── +page.svelte          # Login page (Discord redirect)
│   │   ├── admin/
│   │   │   ├── +layout.server.ts     # Admin auth guard
│   │   │   ├── +layout.svelte        # Admin shell/nav
│   │   │   ├── +page.svelte          # Admin dashboard
│   │   │   ├── settings/
│   │   │   │   └── +page.svelte      # Site settings editor
│   │   │   ├── branding/
│   │   │   │   └── +page.svelte      # Logo, colors, theme
│   │   │   ├── homepage/
│   │   │   │   └── +page.svelte      # Homepage content editor
│   │   │   ├── links/
│   │   │   │   └── +page.svelte      # Nav and social links
│   │   │   └── events/
│   │   │       └── +page.svelte      # Events manager
│   │   └── api/
│   │       └── auth/
│   │           └── [...betterAuth]    # Better Auth API routes
│   └── styles/
│       └── app.css                   # Global styles, CSS custom properties
├── static/
│   └── favicon.png
├── Dockerfile
├── docker-compose.yml                # Optional, for local dev
├── drizzle.config.ts
├── package.json
├── svelte.config.js
├── tsconfig.json
└── vite.config.ts

Auth Flow

sequenceDiagram
    participant U as User
    participant S as SvelteKit App
    participant DA as Discord OAuth
    participant DB as PostgreSQL

    U->>S: Visit /login
    S->>DA: Redirect to Discord OAuth
    DA->>U: Authorize
    U->>S: Callback with OAuth code
    S->>DA: Exchange code for token + user info
    DA-->>S: Discord user (id, username, avatar)
    S->>DB: Upsert user record
    S->>DB: Check if Discord ID matches OWNER_DISCORD_ID
    S->>DB: Check if Discord ID matches SUPER_ADMIN_DISCORD_IDS
    DB-->>S: Match found → assign appropriate role for current site
    S->>S: Create session
    S-->>U: Redirect to /admin (or show access denied)

Ownership Bootstrap

On first login, the app compares the user's Discord ID against the OWNER_DISCORD_ID environment variable. If they match:

  1. A membership record is created (or confirmed) with role owner for the current site
  2. The env var acts as a bootstrap — once the owner exists in the database, the env var could theoretically be removed (though keeping it is fine)

Long-term, existing owners can add other admins/editors through the admin panel, and the database becomes the source of truth for all roles.

Super Admin Access

The app also checks the user's Discord ID against SUPER_ADMIN_DISCORD_IDS (a comma-separated list). If a match is found:

  1. The user is granted cross-site admin access — they bypass site-scoped membership checks
  2. Super admins can access any site's admin panel regardless of OWNER_DISCORD_ID
  3. This is intended for David (system maintainer) to manage all sites
  4. Super admin access is checked on every request, not just at login

Deployment Model

Current: Multiple Coolify Deployments

Git Repo (main branch)
    │
    ├── Coolify Deployment "bad-movies-theater"
    │   └── Env: SITE_SLUG=bad-movies-theater
    │
    ├── Coolify Deployment "garbage-day"
    │   └── Env: SITE_SLUG=garbage-day
    │
    └── Coolify Deployment "future-site"
        └── Env: SITE_SLUG=future-site

Each deployment:

  • Points to the same Git repo + branch
  • Has its own set of environment variables
  • Connects to the same database
  • Uses the same CDN bucket
  • Is completely isolated at the container level

Future: Single Deployment / Multi-Domain (Optional)

If desired later, the system could switch to a single deployment that resolves the site by domain name instead of SITE_SLUG:

  • Add a domains table or JSON field on sites
  • The site resolver checks the request's Host header
  • Falls back to SITE_SLUG for local dev

This is not needed for version 1 but the architecture supports it because all data is already scoped by siteId.

CDN/Asset Architecture

CDN Bucket: collective-hub
├── sites/
│   ├── bad-movies-theater/
│   │   ├── logo.webp
│   │   ├── background.webp
│   │   └── events/
│   │       └── movie-night-june.webp
│   ├── garbage-day/
│   │   ├── logo.webp
│   │   └── background.webp
│   └── future-site/
│       └── logo.webp

Key rules:

  • Database stores asset records with cdnKey (the path within the bucket)
  • Application constructs full CDN URLs using CDN_BASE_URL + cdnKey
  • Never hardcode full CDN URLs in the database
  • Assets are always scoped by siteId in the database
  • Path convention: sites/{siteSlug}/{type}/{filename}
  • All uploaded images are automatically converted to webp and optimized before storage

Migration Safety

Multiple deployments sharing one database means migrations must be handled carefully:

  • Migrations run automatically on startup, but only on the deployment with RUN_MIGRATIONS=true.
  • All other deployments (RUN_MIGRATIONS=false) skip migrations entirely.
  • Exactly one deployment must be designated the migration runner. Choose a stable, low-traffic deployment.
  • The migration runner must be deployed first when schema changes are included in a release.
  • Drizzle's migration tools handle idempotency — running the same migration twice is safe, but concurrent runs from multiple deployments must be avoided.
  • This is enforced by convention (the RUN_MIGRATIONS flag), not by a distributed lock. David must ensure only one deployment has the flag set to true.

Key Architecture Decisions

Decision Choice Rationale
Multi-deploy vs single-deploy Multi-deploy first Simpler initially, no domain-routing complexity
siteId on every table Yes Multi-tenant from day one, no rewrite needed later
JSON vs normalized tables Prefer normalized; JSON for theme settings only Queryable, type-safe, referential integrity
CDN URLs in DB Store keys only, not full URLs CDN migration is trivial, no data changes needed
Image processing Auto webp conversion + optimization on upload Consistent format, smaller files, better performance
Auth library Better Auth First-class Discord support, SvelteKit integration
Super admin SUPER_ADMIN_DISCORD_IDS env var Cross-site access for system maintainer
Migration strategy Automated, gated by RUN_MIGRATIONS flag One deployment runs them; others skip
ORM Drizzle Type-safe, lightweight, good Postgres support