# Contributing to MNW Server Patterns, conventions, and rules for working on the MNW server codebase. Read this before making changes. ## Project Structure ``` server/ src/ main.rs # Entry point lib.rs # Library root, AppState definition config.rs # Configuration from environment error.rs # AppError enum, HTTP error responses auth.rs # Session auth extractors (AuthUser, MaybeUser, AdminUser) csrf.rs # CSRF middleware + token management helpers.rs # Shared utilities (format_price, get_initials, etc.) db/ # Database queries, one file per domain types/ # View types + Db→View conversions routes/ # HTTP handlers, grouped by domain templates/ # Askama template structs (Rust side) payments/ # Stripe integration email/ # Postmark transactional email scanning/ # File scanning (ClamAV, YARA, hash lookup) storage.rs # S3 storage abstraction synckit_auth.rs # SyncKit JWT auth migrations/ # PostgreSQL migrations (numbered, auto-applied on boot) templates/ # Askama HTML templates (Jinja2-like) static/ # CSS, JS, fonts, images tests/ # Integration tests deploy/ # Deployment scripts and configs ``` ## Error Handling All handlers return `Result`. The `AppError` enum (`src/error.rs`) maps each variant to an HTTP status code, a Sentry tag, and a user-facing message. Internal details (DB errors, storage errors) are logged but never exposed to users. ```rust #[derive(Debug, thiserror::Error)] pub enum AppError { #[error("Not found")] NotFound, // 404 #[error("Bad request: {0}")] BadRequest(String), // 400 — message shown to user #[error("Database error: {0}")] Database(#[from] sqlx::Error), // 500 — generic message to user #[error("Internal server error")] Internal(#[from] anyhow::Error), // 500 — generic message to user // ... see error.rs for full list } ``` **Rules:** - Use `?` for error propagation. Never `.unwrap()` in production code. - Use `.ok_or(AppError::NotFound)?` when an optional DB result must exist. - Use `AppError::BadRequest("message")` for user-caused errors — the string is shown directly. - Use `AppError::Validation("message")` for form validation failures (returns 422). - Never expose internal error details to users. `Database` and `Internal` variants always show "Something went wrong." - Convert external errors with `From` impls, not string formatting. Add `#[from]` to AppError variants for automatic conversion. On API routes (`/api/*`), a middleware layer (`json_error_layer`) automatically converts HTML error responses to `{"error": "message"}` JSON. Handlers don't need to handle this — it's transparent. ## Route Handlers ### Signature Pattern Every handler follows this structure: ```rust #[tracing::instrument(skip_all, name = "module::handler_name")] async fn handler_name( State(state): State, // App state (DB pool, config, etc.) session: Session, // Session store AuthUser(user): AuthUser, // Or MaybeUser(maybe_user), or AdminUser Path(slug): Path, // URL parameters Query(params): Query, // Query string Form(form): Form, // POST body (form-encoded) ) -> Result { let csrf_token = get_csrf_token(&session).await; // ... business logic ... Ok(MyTemplate { csrf_token, session_user: Some(user.into()), /* ... */ }) } ``` **Rules:** - Every handler gets `#[tracing::instrument(skip_all, name = "...")]` for structured logging. - Return type is always `Result`. - Extract auth requirements via the type system: `AuthUser` (login required), `MaybeUser` (optional), `AdminUser` (admin only, returns 404 to hide admin routes from non-admins). - Askama templates implement `IntoResponse` — return the struct directly. - CSRF token goes into every template that renders forms. ### HTMX Responses Full-page handlers extend `base.html` and include `session_user`, `csrf_token`, navigation, etc. HTMX handlers return partial templates — HTML fragments without the base layout. ```rust // Full page — extends base.html Ok(FullPageTemplate { csrf_token, session_user: maybe_user, /* ... */ }) // HTMX fragment — standalone partial, no base layout Ok(FilteredEntriesTemplate { items, current_page, total_pages }) ``` In templates, HTMX attributes trigger fragment requests: ```html ``` The CSRF token is included in HTMX requests via the `X-CSRF-Token` header, set globally from the `` tag. ### Route Module Organization Routes are grouped by domain under `src/routes/`. Each domain is either a single file (for small domains) or a directory module (when it exceeds ~500 lines). ``` routes/ mod.rs # Declares modules + re-exports *_routes() functions auth.rs # Login, signup, logout admin.rs # Admin panel (or admin/ directory) pages/ # All public HTML pages (directory module) mod.rs # Composes sub-routers public/ # Public-facing pages dashboard/ # Creator dashboard + HTMX tabs api/ # JSON API endpoints stripe/ # Stripe webhooks + checkout synckit/ # SyncKit API ``` Each module exposes a `*_routes()` function that returns an `axum::Router`: ```rust pub fn page_routes() -> Router { Router::new() .route("/", get(home)) .route("/:username", get(profile)) // ... } ``` These are composed in `main.rs` via `.merge()` or `.nest()`. ## Database Layer ### Query Pattern All queries use sqlx with compile-time checking. Queries live in `src/db/`, one file per domain (e.g., `db/users.rs`, `db/items.rs`, `db/synckit.rs`). ```rust pub async fn get_user_by_id(pool: &PgPool, id: UserId) -> Result> { let user = sqlx::query_as::<_, DbUser>( "SELECT * FROM users WHERE id = $1" ) .bind(id) .fetch_optional(pool) .await?; Ok(user) } ``` **Rules:** - Always use positional parameters (`$1`, `$2`, ...). Never interpolate values into SQL strings. - Use `sqlx::query_as::<_, DbRow>` for typed results. Use `sqlx::query!` only when the macro's compile-time checking is needed. - `.fetch_one()` when exactly one row expected (errors on zero), `.fetch_optional()` when zero or one, `.fetch_all()` for lists. - Newtype ID wrappers (`UserId`, `ProjectId`, etc.) work directly with `.bind()` — they implement sqlx's `Encode`/`Decode`. - Multi-line SQL uses `r#"..."#` raw strings. ### DB Row Types vs View Types Database rows are `Db*` structs (e.g., `DbUser`, `DbProject`) in `src/db/`. View types for templates are in `src/types/` (e.g., `User`, `Project`). Conversions between them use `From` trait impls in `src/types/conversions.rs`. ```rust // In src/types/conversions.rs impl From<&db::DbUser> for User { fn from(u: &db::DbUser) -> Self { User { username: u.username.to_string(), avatar_initials: get_initials(u.display_name.as_deref().unwrap_or(&u.username)), stripe_connected: u.stripe_account_id.is_some(), // ... computed fields } } } ``` This separation means: - `Db*` types mirror the database schema exactly (derive `sqlx::FromRow`). - View types have display-ready fields: formatted dates, computed booleans, pre-rendered HTML. - Handlers call `let user: User = (&db_user).into();` to convert. ## Type Safety ### ID Newtypes All database IDs are newtype wrappers defined via the `define_pg_uuid_id!` macro in `src/db/id_types.rs`: ```rust define_pg_uuid_id!(UserId, ProjectId, ItemId, VersionId, /* ... */); ``` This generates `UserId(Uuid)` with `Display`, `FromStr`, `sqlx::Type`, `Encode`, `Decode`, `Serialize`, `Deserialize`, and `Default` (generates new v4 UUID). Use these everywhere — never pass raw `Uuid` or `String` for IDs. ### String Enums Domain enums that map to database TEXT columns use the `impl_str_enum!` macro in `src/db/enums.rs`: ```rust #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ItemType { Audio, Video, Text, Software, /* ... */ } impl_str_enum!(ItemType { Audio => "audio", Video => "video", Text => "text", Software => "software", }); ``` This generates `Display`, `FromStr`, and sqlx `Type`/`Encode`/`Decode`. Add `#[serde(rename_all = "lowercase")]` if the JSON representation should match the database strings. ## Migrations Migrations live in `server/migrations/` and are numbered sequentially (e.g., `001_initial_schema.sql`, `053_add_video_support.sql`). They run automatically on application boot via sqlx. **Rules:** - Use `IF NOT EXISTS` guards wherever possible (tables, indexes, extensions). - Use `TIMESTAMPTZ` for all timestamps (UTC-aware). - Use `gen_random_uuid()` for UUID primary keys. - Always add `DEFAULT` values for `NOT NULL` columns. - Create indexes after the table definition, in the same migration. - Prefer additive migrations (add columns, add tables). Destructive changes (drop columns, rename tables) need careful planning. - Name migrations descriptively: `NNN_what_it_does.sql`. ```sql -- Example: 004_file_scan_status.sql ALTER TABLE items ADD COLUMN scan_status TEXT NOT NULL DEFAULT 'pending'; CREATE TABLE file_scan_results ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), s3_key TEXT NOT NULL, scan_status TEXT NOT NULL, scanned_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_file_scan_results_s3_key ON file_scan_results(s3_key); ``` ## Templates Askama templates (Jinja2-like syntax) live in `server/templates/`. Template structs (Rust side) live in `server/src/templates/`. ### Layout Inheritance All full pages extend `base.html`: ```html {% extends "base.html" %} {% block title %}Page Title{% endblock %} {% block content %} {% include "partials/site_header.html" %} {% endblock %} ``` Blocks available in `base.html`: `title`, `meta_description`, `head` (extra CSS/meta), `body_attrs`, `content`, `scripts`. ### HTMX Partials HTMX fragment templates do NOT extend `base.html`. They render standalone HTML fragments: ```html {# No extends — this is a partial #} {% for item in items %}
{{ item.title }}
{% endfor %} ``` ### Template Variables Every full-page template needs at minimum: - `csrf_token: Option` — for the CSRF meta tag - `session_user: Option` — for the header (login state, avatar) ## Frontend Performance These rules apply to all HTML templates. The goal is zero layout shift, no wasted pixels, and instant-feeling interactions. ### Images Every `` must have explicit dimensions to prevent layout shift: ```html {{ title }} {{ title }} ``` Never use `loading="lazy"` for images that may appear above the fold (the first visible screen before scrolling). ### Navigation and Reloads Never use `window.location.reload()` when an HTMX partial swap can do the job. Full-page reloads discard all client state, re-parse all JS/CSS, and feel slow. ```html