Version: 0.1.0-draft
sinau-lms/
├── docs/ # Planning documents
│ ├── PRD.md # Product Requirements Document
│ ├── ERD.md # Entity Relationship Diagram
│ ├── ROADMAP.md # Development roadmap & sprints
│ ├── ARCHITECTURE.md # This file
│ └── images/ # Diagrams and visuals
│
├── server/ # Rust backend (Axum 0.8)
│ ├── Cargo.toml # Rust dependencies
│ ├── sqlx-data.json # sqlx query data (auto-generated)
│ ├── .sqlx/ # Offline mode query metadata
│ ├── migrations/ # sqlx migrations (SQLite first)
│ │ ├── 001_create_users.sql
│ │ ├── 002_create_organizations.sql
│ │ ├── 003_create_courses.sql
│ │ ├── 004_create_chapters.sql
│ │ ├── 005_create_activities.sql
│ │ ├── 006_create_media.sql
│ │ ├── 007_create_api_tokens.sql
│ │ └── 008_create_webhooks.sql
│ └── src/
│ ├── main.rs # Entry point: server startup
│ ├── config.rs # ENV-based configuration
│ ├── db.rs # sqlx pool abstraction
│ ├── errors.rs # Unified AppError enum
│ ├── routes/
│ │ ├── mod.rs # Route aggregation
│ │ ├── auth.rs # OAuth login/callback/logout/me
│ │ ├── users.rs # User CRUD
│ │ ├── courses.rs # Course CRUD + meta + clone
│ │ ├── chapters.rs # Chapter CRUD + reorder
│ │ ├── activities.rs # Activity CRUD
│ │ ├── organizations.rs # Org CRUD
│ │ ├── media.rs # File upload
│ │ ├── api_tokens.rs # Token management
│ │ ├── webhooks.rs # Webhook management
│ │ └── health.rs # GET /api/health
│ ├── services/
│ │ ├── mod.rs
│ │ ├── auth/
│ │ │ ├── mod.rs
│ │ │ ├── config.rs # ProviderConfig, AuthConfig
│ │ │ ├── oauth.rs # exchange_code, fetch_identity
│ │ │ ├── session.rs # JWT issue/validate
│ │ │ └── middleware.rs # Axum auth extractors
│ │ ├── users.rs # find_or_create_user
│ │ ├── courses.rs # Course business logic
│ │ ├── media.rs # Storage trait impl (local + S3)
│ │ └── webhooks.rs # Event dispatch
│ └── models/ # sqlx row structs (not ORM)
│ ├── mod.rs
│ ├── user.rs
│ ├── organization.rs
│ ├── course.rs
│ ├── chapter.rs
│ ├── activity.rs
│ ├── media.rs
│ ├── api_token.rs
│ └── webhook.rs
│
├── web/ # SvelteKit 5 frontend
│ ├── package.json
│ ├── svelte.config.js
│ ├── tailwind.config.ts
│ ├── vite.config.ts
│ ├── src/
│ │ ├── app.html
│ │ ├── app.d.ts
│ │ ├── lib/
│ │ │ ├── components/ # Shared UI components
│ │ │ │ ├── ui/ # shadcn-svelte components
│ │ │ │ ├── Editor.svelte # TipTap wrapper
│ │ │ │ ├── CourseCard.svelte
│ │ │ │ ├── ChapterList.svelte
│ │ │ │ └── ActivityView.svelte
│ │ │ ├── api/ # API client functions
│ │ │ │ ├── client.ts # Base fetch wrapper
│ │ │ │ ├── auth.ts
│ │ │ │ ├── courses.ts
│ │ │ │ ├── chapters.ts
│ │ │ │ ├── activities.ts
│ │ │ │ └── media.ts
│ │ │ ├── stores/
│ │ │ │ └── auth.ts # User session store
│ │ │ └── utils/
│ │ │ └── cn.ts # Tailwind class merge
│ │ └── routes/
│ │ ├── +layout.ts # Root layout (auth check)
│ │ ├── +page.svelte # Home / redirect
│ │ ├── login/
│ │ │ └── +page.svelte
│ │ ├── (dashboard)/
│ │ │ ├── +layout.svelte # Auth required
│ │ │ ├── courses/
│ │ │ │ ├── +page.svelte # Course list
│ │ │ │ ├── new/+page.svelte # Create course
│ │ │ │ └── [uuid]/
│ │ │ │ ├── +page.svelte # Edit course
│ │ │ │ └── chapters/
│ │ │ │ └── [id]/
│ │ │ │ └── +page.svelte # Edit chapter
│ │ │ ├── users/
│ │ │ │ └── +page.svelte # User management
│ │ │ ├── settings/
│ │ │ │ └── +page.svelte # Org settings, webhooks
│ │ │ └── tokens/
│ │ │ └── +page.svelte # API token management
│ │ └── course/
│ │ └── [uuid]/
│ │ ├── +page.svelte # Public course view
│ │ └── chapter/
│ │ └── [id]/
│ │ ├── +page.svelte # Chapter view
│ │ └── activity/
│ │ └── [activityId]/
│ │ └── +page.svelte # Activity view
│ └── static/
│ └── favicon.png
│
├── docker-compose.yml # Dev environment
├── docker-compose.prod.yml # Production environment
├── Dockerfile.server # Multi-stage Rust build
├── Dockerfile.web # Multi-stage Node build
├── .env.example # Environment variables template
├── .gitignore
├── CHANGELOG.md
├── CONTRIBUTING.md
├── README.md
└── LICENSE
- Compile-time SQL validation — sqlx checks queries at compile time with
sqlx::query!(). No runtime ORM surprises. - Explicit SQL — Full control over queries. No hidden N+1, no magic.
- Lightweight — Less abstraction = less magic = less debugging.
- SQLite + Postgres — sqlx supports both via feature flags. SeaORM too, but sqlx is simpler.
- Full ownership — No upstream merge conflicts, no tracking upstream changes.
- Custom JWT claims — Different user model, different org structure.
- Clean history — Sinau's git history is purely its own.
activities.course_idandactivities.chapter_idare denormalized.- Avoids JOINs for common queries (e.g., "all activities in course X").
- Trade-off: Slightly more writes on create/delete. Acceptable for LMS read-heavy workload.
- Time-sortable (unlike UUID v4).
- Decentralized (no sequence coordination like auto-increment).
- Works identically on SQLite and PostgreSQL.
The storage layer uses a trait-based backend so files can live on local filesystem OR any S3-compatible endpoint:
- Local: files saved to UPLOAD_DIR, served via Axum static files
- S3: files uploaded to S3-compatible bucket (MinIO, AWS S3, Cloudflare R2, etc.)
ENV toggle: STORAGE_BACKEND=local (default) or STORAGE_BACKEND=s3
Default infra: MinIO at s3.ajianaz.dev with dedicated sinau bucket.
File path convention: {org_slug}/{type}/{uuid}.{ext}
Implementation pattern:
#[async_trait]
pub trait StorageBackend: Send + Sync {
async fn upload(&self, key: &str, data: bytes::Bytes, content_type: &str) -> Result<String>;
async fn delete(&self, key: &str) -> Result<()>;
async fn get_url(&self, key: &str) -> String;
async fn exists(&self, key: &str) -> Result<bool>;
}
pub struct LocalStorage { base_dir: PathBuf, base_url: String }
pub struct S3Storage { client: S3Client, bucket: String, public_url: String }Config.rs returns Box based on STORAGE_BACKEND env.
Browser (SvelteKit SSR)
│
├── GET /course/:uuid (SSR) ──> Axum API ──> SQLite/Postgres
│ │
└── POST /api/courses ──────> Axum API ──────> SQLite/Postgres
│
OAuth Flow:
Browser ──> GET /api/auth/google/login ──> Redirect to Google
Google ──> GET /api/auth/google/callback ──> exchange_code()
──> fetch_identity()
──> find_or_create_user()
──> issue_jwt()
Browser <── 302 + Set-Cookie (session)
| Variable | Required | Default | Description |
|---|---|---|---|
APP_URL |
Yes | — | Base URL (e.g., https://sinau.example.com) |
APP_SECRET |
Yes | — | JWT signing secret + cookie encryption |
DATABASE_URL |
Yes | — | sqlite:./data/sinau.db or postgres://... |
ADMIN_EMAILS |
No | — | Comma-separated emails auto-promoted to admin |
SECURE_COOKIE |
No | false | Set true for HTTPS (Secure; SameSite=None) |
STORAGE_BACKEND |
No | local | Storage backend: local or s3 |
UPLOAD_DIR |
No | ./uploads | Local filesystem upload directory |
UPLOAD_MAX_SIZE_MB |
No | 10 | Max file upload size in MB |
S3_ENDPOINT |
No | — | S3-compatible endpoint (e.g. MinIO, AWS S3, Cloudflare R2) |
S3_BUCKET |
No | sinau | S3 bucket name |
S3_ACCESS_KEY |
No | — | S3 access key |
S3_SECRET_KEY |
No | — | S3 secret key |
S3_REGION |
No | us-east-1 | S3 region |
S3_PUBLIC_URL |
No | — | Public URL base for file access |
OAUTH_GOOGLE_CLIENT_ID |
No | — | Google OAuth client ID |
OAUTH_GOOGLE_CLIENT_SECRET |
No | — | Google OAuth client secret |
OAUTH_GITHUB_CLIENT_ID |
No | — | GitHub OAuth client ID |
OAUTH_GITHUB_CLIENT_SECRET |
No | — | GitHub OAuth client secret |
OAUTH_KEYCLOAK_CLIENT_ID |
No | — | Keycloak OAuth client ID |
OAUTH_KEYCLOAK_CLIENT_SECRET |
No | — | Keycloak OAuth client secret |
OAUTH_KEYCLOAK_AUTH_URL |
No | — | Keycloak auth endpoint URL |
OAUTH_KEYCLOAK_TOKEN_URL |
No | — | Keycloak token endpoint URL |
OAUTH_KEYCLOAK_USERINFO_URL |
No | — | Keycloak userinfo endpoint URL |
OAUTH_KEYCLOAK_REDIRECT_URI |
No | — | Keycloak callback URL |
CORS_ORIGINS |
No | * | Allowed CORS origins (comma-separated) |