274 lines
10 KiB
Markdown
274 lines
10 KiB
Markdown
# 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
|
|
|
|
```mermaid
|
|
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:
|
|
|
|
```mermaid
|
|
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`:
|
|
|
|
```sql
|
|
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)
|
|
|
|
```text
|
|
/
|
|
├── 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
|
|
|
|
```mermaid
|
|
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 |
|