# Project Blueprint: EventSnap

> *A private, QR-code-accessed photo & video sharing platform for weddings, birthdays, and personal events — built for guests, run by you.*

---

## Table of Contents

1. [Overview](#1-overview)
2. [Target Audience](#2-target-audience)
3. [Goals & Success Metrics](#3-goals--success-metrics)
4. [Feature Set](#4-feature-set)
5. [User Flows](#5-user-flows)
6. [System Architecture](#6-system-architecture)
7. [Tech Stack](#7-tech-stack)
8. [Third-Party Integrations](#8-third-party-integrations)
9. [Authentication & Authorization](#9-authentication--authorization)
10. [Non-Functional Requirements](#10-non-functional-requirements)
11. [Export System](#11-export-system)
12. [Upload Queue & Limits](#12-upload-queue--limits)
13. [Rate Limiting](#13-rate-limiting)
14. [Database Model (ERM & SQL)](#14-database-model-erm--sql)
15. [API Specification](#15-api-specification)
16. [Performance](#16-performance)
17. [Deployment](#17-deployment)
18. [Risks & Decision Log](#18-risks--decision-log)
19. [Next Steps](#19-next-steps)
- [Appendix A: Rust Crates](#appendix-a-rust-crates)
- [Appendix B: Frontend Libraries](#appendix-b-frontend-libraries)

---

## 1. Overview

### Problem Statement
At private events like weddings and birthday parties, photos and videos are scattered across dozens of guests' phones and never truly shared. Existing solutions (WhatsApp groups, Google Photos shared albums) require accounts, expose personal data, and lack event-specific social features. EventSnap gives every guest instant, frictionless access to a shared, living gallery — no app store, no email, no password.

### Vision
A guest scans the QR code on their way in, types their name, and is immediately part of a shared moment. They upload, react, and comment throughout the day. After the event, the host releases the gallery — every guest walks away with a beautiful offline HTML keepsake and the full archive. Guests feel seen; the host keeps everything.

### Project Type
Mobile-first Progressive Web App (PWA) — accessible via browser, no app store required.

### Status
Implementation in progress (~v0.16). Core flows + new features all wired: auth, feed, upload, host/admin dashboards, ZIP + HTML-viewer export, SSE with delta-fetch on reconnect, toggleable rate limits + quotas with live per-user estimate, host PIN reset with one-time modal, data-mode (Saver/Original), Datenschutzhinweis, mobile gestures (long-press context sheet, double-tap to like), and the live Diashow with pluggable transitions. Open items: low-disk alert, event banner UI, chunked resumable upload for very large videos. See [FEATURES.md](docs/FEATURES.md) for the capability matrix.

---

## 2. Target Audience

### Primary Users
**Event guests** — non-technical consumers of all ages, accessing the app via a shared QR code link. They need zero-friction onboarding (name only), intuitive upload from camera or library, and a social feed they can browse and interact with. Mobile cellular or slow Wi-Fi must be assumed as the norm — data efficiency is a first-class concern.

### Secondary Users
- **Host** — the event organiser (not necessarily technical); manages guests and content via the Host Dashboard.
- **Admin** — the developer / technical operator (you); manages configuration, export pipeline, storage limits, and rate limits via the Admin Dashboard.

### User Scale
Personal / private use. One event at a time. Up to ~100 users uploading ~1,000 files over the course of a day and the days following.

---

## 3. Goals & Success Metrics

| Goal | Metric |
|------|--------|
| Frictionless guest onboarding | Name entry to first upload in < 60 seconds |
| Reliable media collection | 0 lost uploads; all files retrievable after event |
| Engaged social experience | Guests like/comment; hashtag use emerges naturally |
| Data-light browsing | Feed usable on slow cellular via compressed previews |
| Complete archive export | Host releases gallery; guests can download within minutes |
| Simple deployment | Fresh VPS to running app: fill `.env`, run `docker compose up -d` |

---

## 4. Feature Set

### Must Have (MVP)

**Onboarding & Sessions**
- **QR code join flow** — event URL encoded in QR code; guests scan to land on the join page
- **Name-only registration** — enter display name → server issues a persistent JWT + recovery PIN
- **Session recovery** — guest on a new device enters name + PIN to reclaim their account; rate-limited: 3 attempts then 15-minute lockout
- **30-day session expiry** — JWT valid for 30 days, refreshed on activity
- **First-visit onboarding guide** — short, dismissible overlay on first login explaining the app (upload, hashtags, feed, PIN importance)

**Uploads**
- **Photo & video upload** — from device library or in-app camera/video capture via `getUserMedia`
- **Client-side upload queue** — select many files at once; app uploads sequentially with per-file progress bars and a retry button; persisted in IndexedDB
- **Caption + hashtag support** — optional short text with #hashtags; editable after upload
- **Lossless compression on ingest** — server applies lossless compression to all uploads transparently
- **Compressed preview generation** — smaller display variant for the feed; full-quality original retained for export only
- **Video thumbnail generation** — ffmpeg extracts a poster frame as the feed preview image

**Feed & Social**
- **Chronological gallery feed** — scrollable grid; shows compressed previews
- **Like & comment** — guests react to any upload; comments support hashtags
- **Own-content deletion** — guests delete only their own uploads and comments
- **Hashtag filtering in live feed** — filter gallery by one or more hashtags
- **Real-time feed updates via SSE** — new uploads/likes/comments appear without manual refresh
- **SSE lifecycle management** — client pauses SSE when app is backgrounded (Page Visibility API); reconnects + delta-fetches on foreground

**Roles & Dashboards**
- **Host Dashboard** — ban/unban users (with modal: hide or keep their uploads?), delete any content, promote guests to Host, toggle "Close event" (locks new uploads; reactions remain open), toggle "Release gallery" (unlocks export)
- **Admin Dashboard** — all Host permissions + configure per-user storage quota tolerance, per-file size limits (images & videos separately), rate limits per endpoint class, disk usage widget, export activation + progress bar
- **Admin seeded via `.env`** — `ADMIN_PASSWORD_HASH`; Hosts are promoted from guest accounts by Admin

**Export**
- **Export gate** — locked by default; Host/Admin releases post-event via "Release gallery"
- **On-demand generation** — releasing triggers an async server-side job; dashboard shows live progress bar; SSE notifies all guests when complete
- **Locked-state message** — guests see "Export not yet available — check back after the event"
- **ZIP export** — full-quality originals in structured folders
- **HTML offline viewer export** — self-contained ZIP with `Memories.html`, `README.txt`, all media, client-side hashtag filtering, comments, likes; fully offline
- **HTML export in-app guide** — brief modal shown before the HTML download

### Should Have (v1.x)
- **Individual file download** — full-quality download button per file in the gallery
- **Low-disk alert** — background task warns when < 10 GB free
- **Event banner / cover image** — configurable event name and hero image on the join page
- **Chunked upload for large videos** — resumable upload for files > 100 MB
- **Story-style highlights** — Host-curated "best of" selection pinned at the top of the feed
- **Slideshow / presentation mode** — fullscreen auto-advancing gallery for a venue screen/TV

### Could Have (Future)
- Multiple simultaneous events (multi-tenancy)
- Push notifications for new comments on a guest's uploads
- Per-guest gallery view (filter feed to a single user's uploads)

### Out of Scope
- Native iOS/Android apps
- iOS/Android native album export (users download ZIP and import via platform file manager)
- CI/CD pipeline
- User-to-user direct messaging
- Payment / monetisation
- Email-based auth
- Multi-event management UI

---

## 5. User Flows

### Core Flow: Guest Joins & Uploads
1. Guest scans QR code → lands on `https://yourdomain.com/`
2. First-visit onboarding overlay appears (dismissible after reading)
3. Enters display name → `POST /api/v1/join`
4. Server creates `User`, generates 4-digit recovery PIN, stores `bcrypt(PIN)`, issues JWT
5. PIN displayed **prominently** — large, bold, with a copy-to-clipboard button and a clear warning; also saved to `localStorage` and always accessible in "My Account"
6. Guest lands on gallery feed
7. Taps upload button → selects multiple files or opens in-app camera
8. Files enter the client-side upload queue with progress indicators
9. Per file: server receives → stores original → lossless compress → generate preview + thumbnail → write DB record → broadcast SSE `new-upload`
10. All connected guests' feeds update in real time

### Secondary Flow: Session Recovery (New Device / Cleared Storage)
1. Guest enters their name on a new device → server finds an existing match → prompt: "Were you here before? Enter your code."
2. Guest enters PIN → `bcrypt.verify()` → match → new JWT issued for existing `user_id`; PIN saved to `localStorage` on the new device
3. Wrong PIN: up to 3 attempts, then 15-minute lockout

### Secondary Flow: Returning Guest (Same Device)
1. App finds valid JWT in `localStorage` → guest lands directly in feed, no re-login required

### Secondary Flow: Event Closed (Uploads Locked)
1. Host toggles "Close event" → server sets `event.uploads_locked_at`; SSE broadcasts `event-closed`
2. Banner shown: "The event has ended — no new uploads are possible."
3. Upload button disabled; likes, comments, and browsing remain fully available

### Secondary Flow: Host Bans a User
1. Host taps "Ban user" in Host Dashboard
2. Modal: "What should happen to this user's uploads?" → "Keep visible" / "Hide"
3. Host confirms → `user.is_banned = true`, `uploads_hidden = choice`
4. Banned user's next request: HTTP 403

### Secondary Flow: Export Activation & Download
1. Host/Admin taps "Release gallery" → two `ExportJob` records enqueued
2. Background tasks process jobs; `ExportJob.progress_pct` updated in DB; dashboard shows live progress bar
3. Complete: SSE `export-available` → guest toast: "The gallery is now ready for download!"
4. Guests open Export page → two options: ZIP or HTML Viewer
5. HTML Viewer: in-app guide modal shown before download begins
6. Download starts; rate limiting enforced per IP

---

## 6. System Architecture

### Components and Data Flow

```
┌─────────────────────────────────────────────────────────────────┐
│  Guest Device (iOS Safari / Android Chrome)                     │
│                                                                 │
│  SvelteKit PWA                                                  │
│  ┌──────────┐  ┌────────────┐  ┌──────────┐  ┌─────────────┐  │
│  │  Feed /  │  │  Upload    │  │  Export  │  │  Host /     │  │
│  │  Gallery │  │  Queue     │  │  Page    │  │  Admin      │  │
│  │          │  │ (IndexedDB)│  │          │  │  Dashboard  │  │
│  └──────────┘  └────────────┘  └──────────┘  └─────────────┘  │
│        │  HTTPS REST + SSE (EventSource)                        │
└────────┼────────────────────────────────────────────────────────┘
         │
         ▼
┌─────────────────────┐
│  Caddy 2            │  Port 80/443 · Automatic TLS (Let's Encrypt)
│  Reverse Proxy      │  HTTP/2 · gzip/zstd · Cache-Control headers
└────────┬────────────┘
         │ HTTP internal port 3000
         ▼
┌─────────────────────────────────────────────────────────────────┐
│  Axum HTTP Server (Rust — Single Binary)                        │
│                                                                 │
│  ┌─────────────┐  ┌──────────────┐  ┌────────────────────────┐ │
│  │  REST API   │  │  SSE Engine  │  │  Media Static Server   │ │
│  │  /api/v1/*  │  │  /api/v1/   │  │  /media/* (originals,  │ │
│  │             │  │  stream      │  │  previews, thumbnails) │ │
│  └──────┬──────┘  └──────┬───────┘  └────────────────────────┘ │
│         │                │                                      │
│  ┌──────▼──────────────────────┐  ┌──────────────────────────┐ │
│  │  Tower Middleware Stack     │  │  Background Workers      │ │
│  │  · Auth (JWT)               │  │  (tokio::spawn)          │ │
│  │  · Rate Limiting (governor) │  │  · Compression worker    │ │
│  │  · CORS                     │  │  · Preview generator     │ │
│  │  · Request tracing          │  │  · ffmpeg thumbnail      │ │
│  └─────────────────────────────┘  │  · Export generator      │ │
│                                   └──────────────────────────┘ │
└────────────────────┬────────────────────────────────────────────┘
                     │
         ┌───────────┴──────────────┐
         │                          │
         ▼                          ▼
┌─────────────────┐      ┌──────────────────────────┐
│  PostgreSQL 16  │      │  Local File Storage      │
│                 │      │                          │
│  event          │      │  /media/originals/       │
│  user           │      │  /media/previews/        │
│  session        │      │  /media/thumbnails/      │
│  upload         │      │  /exports/               │
│  hashtag        │      │    Gallery.zip           │
│  upload_hashtag │      │    Memories.zip          │
│  comment        │      │  /backups/               │
│  comment_hashtag│      │    db_YYYY-MM-DD.sql.gz  │
│  like           │      └──────────────────────────┘
│  export_job     │
│  config         │
└─────────────────┘
```

### Docker Compose Stack

Four services: Postgres, the Rust API (`app`), the SvelteKit Node server (`frontend`), and Caddy. Caddy routes `/api/*` and `/media/*` to the Rust binary and everything else to the SvelteKit server. See [docker-compose.yml](docker-compose.yml) for the authoritative definition.

```yaml
services:
  db:        # postgres:16-alpine, persisted in postgres_data volume
  app:       # ./backend — Rust API on :3000, mounts media_data:/media
  frontend:  # ./frontend — SvelteKit (adapter-node) on :3001
  caddy:     # caddy:2-alpine — terminates TLS on :80/:443, proxies app + frontend
```

### Caddyfile

```
{$DOMAIN} {
    reverse_proxy app:3000
    encode zstd gzip

    # Immutable cache for content-hashed SvelteKit assets
    @hashed_assets path_regexp \.[a-f0-9]{8,}\.(js|css|woff2)$
    header @hashed_assets Cache-Control "public, max-age=31536000, immutable"

    # Preview images and thumbnails: browser caches 1 hour
    @previews path /media/previews/* /media/thumbnails/*
    header @previews Cache-Control "public, max-age=3600"

    # Original files: auth-gated, 24h browser cache
    @originals path /media/originals/*
    header @originals Cache-Control "private, max-age=86400"

    # API and SSE: never cache
    @api path /api/*
    header @api Cache-Control "no-store"
}
```

### .env Configuration Variables

```dotenv
# Server
DOMAIN=my-wedding.com
APP_PORT=3000

# Database
DATABASE_URL=postgres://eventsnap:secret@db:5432/eventsnap
POSTGRES_USER=eventsnap
POSTGRES_PASSWORD=secret
POSTGRES_DB=eventsnap

# Auth
ADMIN_PASSWORD_HASH=$2b$12$...   # bcrypt hash
JWT_SECRET=<random 64-byte key>
SESSION_EXPIRY_DAYS=30

# Event
EVENT_NAME=Max & Maria's Wedding
EVENT_SLUG=max-maria-2026

# Storage & limits (overridable via Admin Dashboard)
MEDIA_PATH=/media
DEFAULT_MAX_IMAGE_SIZE_MB=20
DEFAULT_MAX_VIDEO_SIZE_MB=500
DEFAULT_UPLOAD_RATE_PER_HOUR=10
DEFAULT_FEED_RATE_PER_MIN=60
DEFAULT_EXPORT_RATE_PER_DAY=3
DEFAULT_QUOTA_TOLERANCE=0.75
DEFAULT_ESTIMATED_GUEST_COUNT=100
COMPRESSION_WORKER_CONCURRENCY=2
```

### Architecture Pattern
**Modular monolith.** A single Rust binary behind Caddy. Right-sized for a personal single-event project — no distributed complexity, trivially deployable and restorable.

### SSE Event Catalogue

| Event | Payload | Trigger |
|-------|---------|---------|
| `new-upload` | `{ id, preview_url, uploader, caption, created_at }` | Upload processing complete |
| `new-comment` | `{ id, upload_id, body, uploader, created_at }` | Comment posted |
| `like-update` | `{ upload_id, like_count }` | Like toggled |
| `upload-deleted` | `{ upload_id }` | Upload deleted |
| `event-closed` | `{}` | Host locks uploads |
| `event-opened` | `{}` | Host unlocks uploads |
| `export-available` | `{ types: ["zip","html"] }` | Export generation complete |
| `upload-processed` | `{ upload_id, preview_url, thumbnail_url }` | Server-side compression / preview generation finished |
| `upload-error` | `{ upload_id, message }` | Compression / preview generation failed |
| `export-progress` | `{ type, progress_pct }` | Periodic progress update from an export job |

**Client SSE lifecycle:** `visibilitychange: hidden` → close connection · `visible` → reconnect + delta-fetch via `GET /api/v1/feed/delta?since=`

---

## 7. Tech Stack

| Layer | Technology | Rationale |
|-------|-----------|-----------|
| Frontend | SvelteKit + TypeScript | Small JS bundles, excellent mobile performance, built-in routing |
| Styling | Tailwind CSS | Utility-first, mobile-first; zero runtime CSS overhead |
| Backend | Rust + Axum | Developer preference; memory safety, single-binary deploy |
| Async Runtime | Tokio | De-facto Rust async runtime; Axum is built on it |
| Database Driver | SQLx | Async PostgreSQL; automatic prepared statements. **Queries use the runtime `sqlx::query()` API, not the checked macros** — see the note under "Schema changes" |
| Database | PostgreSQL 16 | Robust, relational; straightforward to back up |
| Auth | Custom JWT (`jsonwebtoken` crate) | No external service needed; name + PIN is the full auth model |
| Image Compression | `image` crate + `oxipng` | Lossless PNG compression; JPEG preview generation |
| Video Processing | ffmpeg (`tokio::process::Command`) | Lossless pass, thumbnail extraction, preview transcoding |
| File Storage | Local disk (`/media/`) | No S3 cost; 70 GB available is ample |
| Real-Time | Axum SSE + `tokio::sync::broadcast` | Native, lightweight, perfect for fan-out at this scale |
| ZIP Export | `async-zip` crate | Streaming ZIP generation without buffering the full archive in RAM |
| HTML Export | `minijinja` (Rust templating) | Generates `Memories.html` as a single self-contained file |
| Rate Limiting | Custom in-memory sliding-window limiter ([services/rate_limiter.rs](backend/src/services/rate_limiter.rs)) | Per IP / per user; limits read from `config` DB table on each request; hot-reloadable without restart |
| Reverse Proxy | Caddy 2 | Automatic HTTPS via Let's Encrypt; zero certificate management |
| Containerisation | Docker + Docker Compose | Full stack in one file; `.env` for all config; single-command deploy |
| Infrastructure | Hetzner CX33 (4 vCPU, 8 GB RAM, 80 GB SSD, 20 TB traffic) | Well-sized; 20 TB/month means post-event bulk downloads are no issue |

---

## 8. Third-Party Integrations

| Service | Purpose | Notes |
|---------|---------|-------|
| Let's Encrypt (via Caddy) | Automatic TLS certificates | Required — `getUserMedia` only works over HTTPS; Caddy handles renewals |
| ffmpeg | Video thumbnail extraction, lossless compression, preview transcoding | Installed in the Docker image |
| Browser `getUserMedia` API | In-app camera & video capture | Works on iOS Safari 14.3+, all modern Android browsers |
| Page Visibility API | SSE lifecycle management | Browser-native; no library needed |

No paid third-party services required.

---

## 9. Authentication & Authorization

### Auth Method

**New guest flow:**
1. `POST /api/v1/join` with `{ display_name }`
2. Server creates `User`, generates a 4-digit recovery PIN, stores `bcrypt(PIN)`, issues JWT (`user_id`, `event_id`, `role`, `exp: now+30d`)
3. JWT stored in `localStorage`; PIN also saved to `localStorage` (plaintext, on-device)
4. PIN displayed prominently in a registration modal with a copy button
5. PIN always accessible afterwards in the **"My Account"** settings page — the SvelteKit component reads it directly from `localStorage`

**Recovery flow (new device / cleared storage):**
1. Guest enters their name → server finds an existing match → prompts for PIN
2. `bcrypt.verify(input, stored_hash)` → match → new JWT issued for existing `user_id`; PIN saved to `localStorage` on the new device
3. 3 failed attempts → 15-minute lockout
4. Name collision → PIN is the only disambiguator; Host can manually re-link via the Host Dashboard if PIN is lost

**Admin auth:** Password compared against `ADMIN_PASSWORD_HASH` from `.env`; Admin JWT stored in `sessionStorage` (clears on tab close)

**Host auth:** Guest accounts promoted to `role: host` by Admin; authenticate identically to guests

### Roles & Permissions

| Role | Permissions |
|------|------------|
| Guest | Upload (within quota), caption/hashtag, like, comment, delete own content, view feed, download export (after release), pick data mode, read privacy note |
| Host | All guest permissions + ban/unban users (with upload visibility prompt), delete any content, promote guests to Host, demote *other* Hosts to guest (never self), reset guest PINs (planned), lock/unlock uploads, release gallery export |
| Admin | All Host permissions + reset any non-admin PIN, configure storage/file/rate limits with on/off toggles, edit quota tolerance and per-area quota toggles, edit the Datenschutzhinweis, view disk usage, manage app config, trigger export generation |
| Banned Guest | View feed only — cannot upload, like, comment, or export |

### Compliance
EU-hosted (Hetzner, Germany). Guests provide only a display name. Join page notice: *"Your name and uploads are stored for this event. Admins can delete content."* No formal GDPR tooling required for a private personal project.

---

## 10. Non-Functional Requirements

| Requirement | Target |
|-------------|--------|
| Feed performance | Loads < 1s on 4G via compressed previews |
| Upload UX | Non-blocking client queue; guest browses feed while uploads proceed |
| Availability | Best-effort; single VPS; no SLA |
| Scalability | ~100 users, ~1,000 uploads over 24–48h — well within CX33 capacity |
| Disk storage | ~70 GB available; dynamic quota + file limits prevent exhaustion |
| Per-file limits | Configurable in Admin Dashboard; env defaults: images 20 MB, videos 500 MB |
| Rate limiting | Configurable per endpoint class; hot-reloadable without restart |
| Lossless compression | Applied transparently on ingest; zero quality loss |
| SSE efficiency | Client pauses on app background; server cleans up idle connections > 5 min |
| Mobile support | Primary: iOS Safari 14.3+, Android Chrome; responsive, touch-first, large tap targets |
| Camera capture | HTTPS required; Caddy ensures this automatically |
| UI language | German (DE) — all UI strings, error messages, export filenames, and guides |
| Accessibility | Best effort; semantic HTML, sufficient contrast |

---

## 11. Export System

Export is generated **on-demand after the Host/Admin releases it** — nothing is pre-built during the event.

### Lifecycle
1. "Release gallery" tapped → `event.export_released_at` set + two `ExportJob` records enqueued
2. Background Tokio tasks process jobs; `ExportJob.progress_pct` updated continuously in DB
3. Dashboard shows live progress bar (SSE-driven)
4. Complete: `export_zip_ready` / `export_html_ready` set → SSE `export-available` broadcast → guest toast

### Export Type 1: ZIP Archive (`Gallery.zip`)

Streamed via `async-zip` — no full archive RAM buffer:
```
Gallery/
  Photos/
    2026-06-14_15-32_Anna_DSC_0042.jpg
    ...
  Videos/
    2026-06-14_19-11_Tom_VID_0007.mp4
    ...
```
Full-quality originals only. File naming: `{date}_{time}_{username}_{original_filename}`

### Export Type 2: HTML Offline Viewer (`Memories.zip`)

The HTML export is a **pre-built SvelteKit static app** (`adapter-static`, `ssr=false`) shipped together with the event data. It is a non-interactive, read-only clone of the live feed — same components, same Tailwind tokens, same look — minus auth, upload, comment, and any dashboards. Full design rationale in [docs/CONCEPT_HTML_VIEWER.md](docs/CONCEPT_HTML_VIEWER.md).

```
Memories/
  index.html            ← entry point; open this in any browser
  _app/
    immutable/...       ← hashed JS/CSS bundles (viewer SPA)
  data.json             ← event metadata, posts, comments, likes, hashtags
  media/
    {id}_thumb.jpg      ← grid thumbnails (≈400 px wide)
    {id}_full.jpg/.mp4  ← full-size media for the lightbox
```

**How it works:** open `index.html` in any modern browser. The viewer hydrates client-side, `fetch('./data.json')` loads the event snapshot, all media references are relative paths into `media/`. No network calls, no service required. The ZIP must be unzipped first; the viewer does not run from inside an archive.

**Viewer feature parity with the live app:**
- List view (chronological) and 3-column grid view with the same toggle as the live app
- Lightbox with swipe navigation
- Hashtag filter chips and grid-view search/autocomplete
- Like counts and comment lists shown as a static snapshot from export time
- All UI strings in German

**Build flow:** The viewer lives at [frontend/export-viewer/](frontend/export-viewer/) and is built ahead of time into [backend/static/export-viewer/](backend/static/export-viewer/) (committed to the repo). The export job embeds those assets via `include_dir!`, generates `data.json` from the database, processes thumbnails/full-sized variants, and streams the ZIP.

**Source files (ZIP archive export, see below)** still contain the unmodified originals — the viewer is the polished read-only experience, the ZIP is the raw archive.

For video-heavy events the viewer ZIP can be several GB. The in-app download guide warns guests: *"Am besten im WLAN herunterladen."* ("Best downloaded on Wi-Fi.")

---

## 12. Upload Queue & Limits

### Client-Side Upload Queue
- Select any number of files at once (photo + video mix)
- Queued in **IndexedDB** — survives page reloads and app backgrounding
- Uploads proceed sequentially — one file at a time — to avoid saturating mobile connections
- Per-file display: filename, size, progress bar, status (`Pending` / `Uploading` / `Done` / `Error`)
- Retry button on failed uploads
- Guest can freely browse the feed while the queue runs in the background

### Server-Side Limits

| Limit | Default | Scope |
|-------|---------|-------|
| Max image file size | 20 MB | Per file |
| Max video file size | 500 MB | Per file |
| Per-user storage quota | Dynamic (formula) | Per user per event |
| Upload rate | 10 / hour | Per `user_id` |
| Feed request rate | 60 / min | Per IP |
| Export download rate | 3 / day | Per IP |

### Dynamic Per-User Storage Quota

```
quota_per_user = ⌊(free_disk_bytes × tolerance) / max(active_uploaders, 1)⌋
```

- `free_disk_bytes`: queried via the `sysinfo` crate on each upload attempt
- `tolerance`: admin-configurable (default: 0.75)
- `active_uploaders`: users with ≥ 1 non-deleted upload

**Example:** 70 GB free · tolerance 0.75 · 30 active uploaders → ~1.75 GB per user

On quota exceeded: HTTP 429 — *"Du hast dein Upload-Limit für dieses Event erreicht."*

---

## 13. Rate Limiting

Tower middleware layers on the Axum router. All limits stored in the `config` table — hot-reloadable from the Admin Dashboard without a restart.

| Endpoint Class | Default | Key |
|---------------|---------|-----|
| `POST /api/v1/upload` | 10 / hour | per `user_id` |
| `GET /api/v1/feed` | 60 / min | per IP |
| `GET /media/previews/*` | 120 / min | per IP |
| `GET /api/v1/export/zip` | 3 / day | per IP |
| `GET /api/v1/export/html` | 3 / day | per IP |
| `POST /api/v1/join` | 5 / min | per IP |
| Recovery PIN attempts | 3 then 15-min lockout | per `user_id` |

Rate limit response: HTTP 429 — *"Zu viele Anfragen. Bitte warte kurz und versuche es erneut."*

---

## 14. Database Model (ERM & SQL)

### Entity-Relationship Overview

```
event ──< user ──< session
  │          │
  │          └──< upload ──< comment ──< comment_hashtag >── hashtag
  │                  │                                            ▲
  │                  └──< like                                    │
  │                  └──< upload_hashtag >─────────────────────────
  │
  └──< export_job
  └──  config (key/value)
```

**Cardinalities:**
- `event` 1:N `user`, `upload`, `export_job`
- `user` 1:N `session`, `upload`, `comment`
- `upload` 1:N `comment`, `like`
- `upload` M:N `hashtag` via `upload_hashtag`
- `comment` M:N `hashtag` via `comment_hashtag`

### DDL: Full Schema

```sql
-- Extensions
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- Enums
CREATE TYPE user_role     AS ENUM ('guest', 'host', 'admin');
CREATE TYPE export_type   AS ENUM ('zip', 'html');
CREATE TYPE export_status AS ENUM ('pending', 'running', 'done', 'failed');

-- ─────────────────────────────────────────
-- event
-- ─────────────────────────────────────────
CREATE TABLE event (
    id                  UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    slug                TEXT        NOT NULL UNIQUE,
    name                TEXT        NOT NULL,
    cover_image_path    TEXT,
    is_active           BOOLEAN     NOT NULL DEFAULT TRUE,
    uploads_locked_at   TIMESTAMPTZ,                    -- NULL = open
    export_released_at  TIMESTAMPTZ,                    -- NULL = locked
    export_zip_ready    BOOLEAN     NOT NULL DEFAULT FALSE,
    export_html_ready   BOOLEAN     NOT NULL DEFAULT FALSE,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- ─────────────────────────────────────────
-- user
-- ─────────────────────────────────────────
CREATE TABLE "user" (
    id                  UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id            UUID        NOT NULL REFERENCES event(id) ON DELETE CASCADE,
    display_name        TEXT        NOT NULL,
    role                user_role   NOT NULL DEFAULT 'guest',
    is_banned           BOOLEAN     NOT NULL DEFAULT FALSE,
    uploads_hidden      BOOLEAN     NOT NULL DEFAULT FALSE,
    recovery_pin_hash   TEXT        NOT NULL,           -- bcrypt(PIN)
    total_upload_bytes  BIGINT      NOT NULL DEFAULT 0, -- running sum for quota checks
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
    -- Case-insensitive UNIQUE on (event_id, LOWER(display_name)) added by migration 007.
    -- Name collisions are rejected on join; the user is prompted to recover with their PIN
    -- (or to pick a different name).
);

-- ─────────────────────────────────────────
-- session
-- ─────────────────────────────────────────
CREATE TABLE session (
    id           UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id      UUID        NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
    token_hash   TEXT        NOT NULL UNIQUE,           -- SHA-256(JWT)
    expires_at   TIMESTAMPTZ NOT NULL,
    last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- ─────────────────────────────────────────
-- upload
-- ─────────────────────────────────────────
CREATE TABLE upload (
    id                  UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id            UUID        NOT NULL REFERENCES event(id) ON DELETE CASCADE,
    user_id             UUID        NOT NULL REFERENCES "user"(id),
    original_path       TEXT        NOT NULL,
    preview_path        TEXT,                           -- NULL until compression completes
    thumbnail_path      TEXT,                           -- NULL until ffmpeg thumbnail ready
    mime_type           TEXT        NOT NULL,
    original_size_bytes BIGINT      NOT NULL,
    caption             TEXT,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at          TIMESTAMPTZ                     -- soft delete
);

-- ─────────────────────────────────────────
-- hashtag
-- ─────────────────────────────────────────
CREATE TABLE hashtag (
    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id   UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
    tag        TEXT NOT NULL,                           -- normalised: lowercase, no #
    UNIQUE (event_id, tag)
);

CREATE TABLE upload_hashtag (
    upload_id   UUID NOT NULL REFERENCES upload(id) ON DELETE CASCADE,
    hashtag_id  UUID NOT NULL REFERENCES hashtag(id) ON DELETE CASCADE,
    PRIMARY KEY (upload_id, hashtag_id)
);

-- ─────────────────────────────────────────
-- comment
-- ─────────────────────────────────────────
CREATE TABLE comment (
    id          UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    upload_id   UUID        NOT NULL REFERENCES upload(id) ON DELETE CASCADE,
    user_id     UUID        NOT NULL REFERENCES "user"(id),
    body        TEXT        NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at  TIMESTAMPTZ
);

CREATE TABLE comment_hashtag (
    comment_id  UUID NOT NULL REFERENCES comment(id) ON DELETE CASCADE,
    hashtag_id  UUID NOT NULL REFERENCES hashtag(id) ON DELETE CASCADE,
    PRIMARY KEY (comment_id, hashtag_id)
);

-- ─────────────────────────────────────────
-- like
-- ─────────────────────────────────────────
CREATE TABLE "like" (
    upload_id   UUID        NOT NULL REFERENCES upload(id) ON DELETE CASCADE,
    user_id     UUID        NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (upload_id, user_id)
);

-- ─────────────────────────────────────────
-- export_job
-- ─────────────────────────────────────────
CREATE TABLE export_job (
    id            UUID           PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id      UUID           NOT NULL REFERENCES event(id) ON DELETE CASCADE,
    type          export_type    NOT NULL,
    status        export_status  NOT NULL DEFAULT 'pending',
    progress_pct  SMALLINT       NOT NULL DEFAULT 0 CHECK (progress_pct BETWEEN 0 AND 100),
    file_path     TEXT,                                -- NULL until generation complete
    error_message TEXT,
    created_at    TIMESTAMPTZ    NOT NULL DEFAULT NOW(),
    completed_at  TIMESTAMPTZ,
    UNIQUE (event_id, type)                            -- max one active job per type
);

-- ─────────────────────────────────────────
-- config  (admin-configurable runtime settings)
-- ─────────────────────────────────────────
CREATE TABLE config (
    key        TEXT        PRIMARY KEY,
    value      TEXT        NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Seeded from .env on first start; ON CONFLICT = keep existing overrides
INSERT INTO config (key, value) VALUES
    ('max_image_size_mb',       '20'),
    ('max_video_size_mb',       '500'),
    ('upload_rate_per_hour',    '100'),  -- raised from 10 in migration 015 (guests upload bursts of 10-20)
    ('feed_rate_per_min',       '60'),
    ('export_rate_per_day',     '3'),
    ('quota_tolerance',         '0.75'),
    ('estimated_guest_count',   '100'),
    ('compression_concurrency', '2'),
    -- Planned (see docs/FEATURES.md §2.6 and §2.7):
    --   on/off switches for rate limits and quotas, and the privacy note text
    ('rate_limits_enabled',     'true'),
    ('upload_rate_enabled',     'true'),
    ('feed_rate_enabled',       'true'),
    ('export_rate_enabled',     'true'),
    ('join_rate_enabled',       'true'),
    ('quota_enabled',           'true'),
    ('storage_quota_enabled',   'true'),
    ('upload_count_quota_enabled', 'true'),
    ('privacy_note',            '')      -- free text, whitespace + newlines preserved, no HTML
ON CONFLICT (key) DO NOTHING;
```

### Indexes

```sql
-- Feed main query (covers > 90% of all DB requests)
CREATE INDEX idx_upload_event_created
    ON upload(event_id, created_at DESC)
    WHERE deleted_at IS NULL;

-- A user's own uploads (quota check, "My Account")
CREATE INDEX idx_upload_user
    ON upload(user_id)
    WHERE deleted_at IS NULL;

-- Comments per upload
CREATE INDEX idx_comment_upload
    ON comment(upload_id)
    WHERE deleted_at IS NULL;

-- Like count (without this = full table scan on every feed request)
CREATE INDEX idx_like_upload
    ON "like"(upload_id);

-- Hashtag filtering
CREATE INDEX idx_upload_hashtag_hashtag
    ON upload_hashtag(hashtag_id);

CREATE INDEX idx_hashtag_event_tag
    ON hashtag(event_id, tag);

-- Session lookup (runs on every authenticated API request)
CREATE UNIQUE INDEX idx_session_token_hash
    ON session(token_hash);

-- Expired session cleanup
CREATE INDEX idx_session_expires
    ON session(expires_at);

-- User lookup for recovery flow
CREATE INDEX idx_user_event_name
    ON "user"(event_id, display_name);

-- Export job status
CREATE UNIQUE INDEX idx_export_job_event_type
    ON export_job(event_id, type);
```

### Views

```sql
-- v_feed: uploads with uploader name, like count, and comment count
-- Used in the feed endpoint as a single efficient query
CREATE VIEW v_feed AS
SELECT
    u.id,
    u.event_id,
    u.user_id,
    usr.display_name             AS uploader_name,
    usr.is_banned,
    usr.uploads_hidden,
    u.preview_path,
    u.thumbnail_path,
    u.mime_type,
    u.caption,
    u.created_at,
    COUNT(DISTINCT l.user_id)    AS like_count,
    COUNT(DISTINCT c.id)         AS comment_count
FROM upload u
JOIN "user" usr ON u.user_id = usr.id
LEFT JOIN "like" l  ON l.upload_id = u.id
LEFT JOIN comment c ON c.upload_id = u.id AND c.deleted_at IS NULL
WHERE u.deleted_at IS NULL
  AND usr.uploads_hidden = FALSE
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;


-- v_hashtag_counts: most-used hashtags for an event (for filter chips)
CREATE VIEW v_hashtag_counts AS
SELECT
    h.event_id,
    h.tag,
    COUNT(uh.upload_id) AS upload_count
FROM hashtag h
JOIN upload_hashtag uh ON uh.hashtag_id = h.id
JOIN upload u          ON u.id = uh.upload_id AND u.deleted_at IS NULL
GROUP BY h.event_id, h.id, h.tag
ORDER BY upload_count DESC;
```

---

## 15. API Specification

### Versioning & Base Path
All endpoints under `/api/v1/`. Breaking changes introduced as `/api/v2/` without removing existing routes.

### Authentication
- Guests / Hosts / Admin: `Authorization: Bearer <JWT>`
- Missing token: `401 Unauthorized`
- Insufficient permissions: `403 Forbidden`

### DTOs

```typescript
// ─── Auth ──────────────────────────────────────────────────────
interface JoinRequest    { display_name: string }
interface JoinResponse   { jwt: string; pin: string; user_id: string; is_new: boolean }

interface RecoverRequest  { display_name: string; pin: string }
interface RecoverResponse { jwt: string; user_id: string }

interface AdminLoginRequest  { password: string }
interface AdminLoginResponse { jwt: string }

// ─── Feed ──────────────────────────────────────────────────────
interface UploadDto {
    id: string;           uploader_name: string;  user_id: string;
    preview_url: string;  mime_type: string;
    caption: string | null;                        hashtags: string[];
    like_count: number;   comment_count: number;   liked_by_me: boolean;
    created_at: string;
}
interface FeedResponse  { uploads: UploadDto[]; next_cursor: string | null }
interface DeltaResponse { uploads: UploadDto[]; deleted_ids: string[] }

// ─── Upload ────────────────────────────────────────────────────
// Request: multipart/form-data; fields: file (Blob), caption? (string), hashtags? (CSV)
interface UploadResponse    { upload: UploadDto }
interface EditUploadRequest { caption?: string; hashtags?: string[] }

// ─── Comments ──────────────────────────────────────────────────
interface CommentDto {
    id: string;  upload_id: string;  user_id: string;
    uploader_name: string;  body: string;
    hashtags: string[];  created_at: string;
}
interface CommentsResponse  { comments: CommentDto[] }
interface AddCommentRequest { body: string }

// ─── Export ────────────────────────────────────────────────────
interface ExportJobDto { status: 'locked'|'pending'|'running'|'done'|'failed'; progress_pct: number }
interface ExportStatusResponse { released: boolean; zip: ExportJobDto; html: ExportJobDto }

// ─── Host / Admin ──────────────────────────────────────────────
interface UserDto {
    id: string;  display_name: string;  role: string;
    is_banned: boolean;  uploads_hidden: boolean;
    upload_count: number;  total_upload_bytes: number;  created_at: string;
}
interface BanRequest { hide_uploads: boolean }

interface ConfigDto { [key: string]: string }
interface StatsDto {
    user_count: number;  upload_count: number;  comment_count: number;
    disk_total_bytes: number;  disk_used_bytes: number;  disk_free_bytes: number;
}
```

### Endpoints

#### Auth

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/v1/join` | — | Join event; returns JWT + PIN |
| `POST` | `/api/v1/recover` | — | Recover session by name + PIN |
| `POST` | `/api/v1/admin/login` | — | Admin password auth |
| `DELETE` | `/api/v1/session` | Guest | Log out (invalidate JWT) |

#### Feed & Uploads

| Method | Path | Auth | Query | Description |
|--------|------|------|-------|-------------|
| `GET` | `/api/v1/feed` | Guest | `cursor`, `limit` (def. 20), `hashtag` | Paginated feed (cursor-based) |
| `GET` | `/api/v1/feed/delta` | Guest | `since` (ISO timestamp) | Uploads since last SSE reconnect |
| `POST` | `/api/v1/upload` | Guest | — | Upload a file (`multipart/form-data`) |
| `PATCH` | `/api/v1/upload/{id}` | Guest (own) | — | Edit caption / hashtags |
| `DELETE` | `/api/v1/upload/{id}` | Guest (own) | — | Soft-delete own upload |
| `GET` | `/api/v1/upload/{id}/original` | Guest | — | Download full-quality original |

#### Social

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/v1/upload/{id}/like` | Guest | Toggle like (like / unlike) |
| `GET` | `/api/v1/upload/{id}/comments` | Guest | List comments for an upload |
| `POST` | `/api/v1/upload/{id}/comment` | Guest | Add a comment |
| `DELETE` | `/api/v1/comment/{id}` | Guest (own) | Delete own comment |
| `GET` | `/api/v1/hashtags` | Guest | All event hashtags with counts (for filter chips) |

#### SSE

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/v1/stream` | Guest | Open SSE connection; delivers all feed events |

#### Export

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/v1/export/status` | Guest | Release status and generation progress |
| `GET` | `/api/v1/export/zip` | Guest | Download `Gallery.zip` (only if `export_zip_ready`) |
| `GET` | `/api/v1/export/html` | Guest | Download `Memories.zip` (only if `export_html_ready`) |

#### Host Dashboard

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/v1/host/users` | Host | List all event users |
| `POST` | `/api/v1/host/users/{id}/ban` | Host | Ban user (`BanRequest`) |
| `POST` | `/api/v1/host/users/{id}/unban` | Host | Lift ban |
| `PATCH` | `/api/v1/host/users/{id}/role` | Host | Set role (`{ role: "guest"\|"host" }`) |
| `DELETE` | `/api/v1/host/upload/{id}` | Host | Delete any upload |
| `DELETE` | `/api/v1/host/comment/{id}` | Host | Delete any comment |
| `POST` | `/api/v1/host/event/close` | Host | Lock new uploads |
| `POST` | `/api/v1/host/event/open` | Host | Unlock new uploads |
| `POST` | `/api/v1/host/gallery/release` | Host | Trigger export generation + set release |

#### Admin Dashboard

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/v1/admin/stats` | Admin | Disk usage, user/upload counts |
| `GET` | `/api/v1/admin/config` | Admin | Retrieve all configurable settings |
| `PATCH` | `/api/v1/admin/config` | Admin | Update settings (`ConfigDto`) |
| `GET` | `/api/v1/admin/export/jobs` | Admin | Export job status and progress |

### Error Format

```json
{
  "error": "upload_quota_exceeded",
  "message": "Du hast dein Upload-Limit für dieses Event erreicht.",
  "status": 429
}
```

### HTTP Status Codes

| Code | Usage |
|------|-------|
| `200 OK` | Successful GET / PATCH |
| `201 Created` | Successful POST (upload, comment, join) |
| `204 No Content` | Successful action with no response body (like toggle, logout) |
| `400 Bad Request` | Validation error, missing required field |
| `401 Unauthorized` | Missing or invalid JWT |
| `403 Forbidden` | Insufficient permissions or banned user |
| `404 Not Found` | Resource not found or soft-deleted |
| `413 Payload Too Large` | File exceeds configured size limit |
| `429 Too Many Requests` | Rate limit or quota exceeded |
| `500 Internal Server Error` | Unexpected server error |

---

## 16. Performance

### Strategy Overview

EventSnap does not need a dedicated caching layer like Redis or Memcached. The combination of OS page cache, HTTP cache headers, SQL indexes, and SQLx prepared statements delivers excellent performance for the projected load (~100 users, ~1,000 uploads). Redis would make sense with thousands of concurrent users across multiple app instances — that is not the case here.

### 1. HTTP Caching (Highest Impact)

| Asset Type | `Cache-Control` | Effect |
|------------|----------------|--------|
| SvelteKit JS/CSS/Fonts (content-hashed) | `public, max-age=31536000, immutable` | Loaded once, cached forever — 0 bytes on subsequent visits |
| Preview images & thumbnails | `public, max-age=3600` | Once seen = never re-downloaded within the same hour |
| Original files (auth-gated) | `private, max-age=86400` | One download per day per browser |
| API endpoints & SSE | `no-store` | Feed always fresh |

A guest who reloads the feed or switches back to the app does **not re-download** previews they have already seen. On mobile networks this is the single largest contributor to perceived speed and data efficiency.

### 2. OS Page Cache (Free, Automatic)

Linux uses the RAM not occupied by PostgreSQL and the Rust binary (~7 GB of 8 GB) as a file cache automatically. Frequently accessed preview images and thumbnails stay in memory with no additional infrastructure required. No Redis needed.

### 3. SQL Prepared Statements via SQLx

SQLx validates all queries **at compile time** and submits them as prepared statements to PostgreSQL on first execution. Subsequent calls skip the parse/plan step entirely — particularly valuable for the feed endpoint, which runs on every page load:

```sql
-- Critical feed query via v_feed (held as a prepared statement):
SELECT *
FROM v_feed
WHERE event_id = $1
  AND created_at < $2       -- cursor-based pagination
ORDER BY created_at DESC
LIMIT $3;
```

### 4. Database Connection Pooling

```rust
let pool = PgPoolOptions::new()
    .max_connections(10)    // sufficient for 100 concurrent users
    .min_connections(2)     // always keep 2 connections warm
    .connect(&database_url)
    .await?;
```

No connection establishment latency per request.

### 5. Strategic SQL Indexes

| Index | Query it accelerates | Rationale |
|-------|---------------------|-----------|
| `idx_upload_event_created` (Partial) | Feed main query | Covers > 90% of all DB requests; partial index on non-deleted rows only |
| `idx_like_upload` | Like count in `v_feed` | Without it: full table scan on every feed request |
| `idx_session_token_hash` (UNIQUE) | Auth middleware | Runs on **every** authenticated API request |
| `idx_hashtag_event_tag` | Hashtag filtering | Direct lookup instead of sequential scan |
| `idx_comment_upload` (Partial) | Comment lists | Partial on non-deleted; reduces index size |

### 6. Two-Tier Media Serving

| Tier | Used For | Typical Size | Serving Policy |
|------|---------|-------------|----------------|
| **Preview** | Feed grid | ~50–150 KB (JPEG, 800px) | Always; browser-cached 1h |
| **Thumbnail** | Video poster frame in feed | ~30–80 KB (WebP) | Feed grid for videos; browser-cached 1h |
| **Original** | Export, individual download | 4–20 MB (image), up to 500 MB (video) | Explicit request only |

During normal operation guests load previews exclusively — originals are served only on export or conscious download. This reduces feed traffic by ~95%.

### 7. Compression Worker Pool

```rust
// Semaphore bounds concurrent compression tasks
let semaphore = Arc::new(Semaphore::new(concurrency_limit));

tokio::spawn(async move {
    let _permit = semaphore.acquire().await.unwrap();
    compress_and_generate_preview(upload_id, path).await;
});
```

The upload confirmation is returned immediately after the original is written to disk — compression and preview generation run asynchronously afterwards. `COMPRESSION_WORKER_CONCURRENCY` (default: 2) prevents RAM spikes on the 8 GB CX33.

### 8. SSE Connection Management

- Server cleans up SSE connections idle for > 5 minutes
- Client proactively closes the connection on `visibilitychange: hidden`
- On reconnect: only a delta via `GET /api/v1/feed/delta?since=` — no full feed re-fetch

### 9. Recommended PostgreSQL Configuration

Tuning for the CX33 (8 GB RAM), set via Docker Compose environment variables:

```
shared_buffers       = 2GB    # 25% of RAM for PostgreSQL's own buffer cache
work_mem             = 16MB   # for sort and join operations
maintenance_work_mem = 256MB  # for VACUUM, index builds
effective_cache_size = 6GB    # planner hint (OS cache + shared_buffers)
```

---

## 17. Deployment

### Complete Setup on a Fresh VPS

```bash
# 1. Install Docker (includes Compose plugin)
curl -fsSL https://get.docker.com | sh
usermod -aG docker $USER && newgrp docker

# 2. Copy project files to the server
scp -r eventsnap/ user@your-vps:/opt/eventsnap
cd /opt/eventsnap

# 3. Configure
cp .env.example .env
nano .env   # set DOMAIN, ADMIN_PASSWORD_HASH, EVENT_NAME, etc.

# 4. Start
docker compose up -d
```

Caddy automatically obtains a Let's Encrypt certificate on first start. The app is live at `https://DOMAIN` within ~30 seconds.

### Recommended Monorepo Structure

```
eventsnap/
  backend/
    src/
      main.rs
      routes/           ← endpoint handlers
      models/           ← DB structs (SQLx)
      workers/          ← compression, export
      sse.rs
    migrations/         ← SQLx migration files
    Cargo.toml
  frontend/
    src/
      routes/
      lib/
        upload-queue.ts ← IndexedDB-backed queue
        sse.ts          ← SSE client + Page Visibility lifecycle
    package.json
  docker-compose.yml
  Caddyfile
  .env.example
  README.md
```

### Backup Strategy

Three artefacts in three places: the database, the `media_data` volume
(originals + derivatives), and the **separate** `exports_data` volume. See
[README.md](README.md#backup) for the exact commands.

Everything runs through `docker compose` / `docker run`, because `DATABASE_URL`
and the `/media` and `/exports` paths only exist inside the compose network —
they are not host paths, and `DATABASE_URL` is never exported into an operator's
shell.

Export archives are deliberately outside `MEDIA_PATH` (`EXPORT_PATH=/exports`): a
keepsake contains every photo in the event, and keeping it off the media tree is
what stops it being reachable except through the ticket-gated handler. A backup
of the media volume alone silently loses every generated keepsake.

---

## 18. Risks & Decision Log

### Known Risks

| Risk | Mitigation |
|------|-----------|
| Disk exhaustion | Dynamic quota + per-file limits + admin disk widget + low-disk alert (< 10 GB free) |
| iOS Safari `getUserMedia` bugs | Test early on real iOS devices; "Upload from library" always equally prominent |
| Large video upload connection drops | Client queue with retry; chunked upload planned for v1.x (files > 100 MB) |
| Recovery PIN forgotten | PIN visually unmissable at registration + copy button + always in "My Account"; Host can manually re-link |
| Export generation time | `async-zip` streams directly to disk (no RAM buffer); progress bar + SSE notification handle UX |
| ffmpeg RAM spikes | Bounded worker pool via `tokio::sync::Semaphore` (default: 2 concurrent tasks) |

### Decision Log

| Decision | Chosen | Rationale |
|----------|--------|-----------|
| Recovery mechanism | 4-digit PIN, stored in `localStorage` + "My Account" page; Host/Admin can issue a fresh PIN via the user list when a guest loses it entirely | Simple for non-technical guests; no email required; Host-mediated reset preserves the no-email identity model |
| Host demotion authority | Hosts can demote other Hosts (never themselves); Admin can demote anyone non-admin | Avoids requiring an Admin for every staffing change at the event |
| Privacy note | Free-text, plain (no HTML), admin-edited, rendered preformatted in My Account | Many events need a per-event privacy statement; preformatted text avoids any markup-injection risk |
| Data mode | Per-device `localStorage` setting (Saver / Original), default Saver | A guest can be on cellular on one device and Wi-Fi on another; per-device is the right scope |
| Rate-limit & quota toggles | On/off switches plus numeric values in the `config` table | Lets the Admin disable enforcement for testing or trusted events without redeploying |
| Admin dashboard path | `/admin` (standard route) | Correct auth checks are the security; obscure paths add no meaningful protection |
| ZIP contents | Full-quality originals only (Photos + Videos folders) | Clean and simple; no metadata JSON |
| HTML export assets | Fully offline (relative paths, CSS/JS inlined) | True offline experience; no external dependencies |
| Caching layer | No Redis / Memcached | OS page cache + HTTP headers + indexes are sufficient for this load |
| Reverse proxy | Caddy instead of Nginx + Certbot | Automatic TLS with zero manual configuration |
| CI/CD | None — `docker compose up` only | Right-sized for a personal project |

---

## 19. Next Steps

1. **Provision Hetzner CX33** — create server, point DNS A record to IP, install Docker
2. **Scaffold monorepo** — `backend/`, `frontend/`, `docker-compose.yml`, `Caddyfile`, `.env.example`
3. **DB schema + SQLx migrations** — all tables, views, and indexes; test with `docker compose up db`
4. **Auth flow** — join endpoint, JWT issuance, PIN generation + bcrypt storage + recovery verification
5. **Upload pipeline** — multipart POST → disk write → compression worker (Tokio task) → preview generation → DB record → SSE broadcast
6. **Client upload queue** — SvelteKit component: IndexedDB queue, sequential upload, per-file progress, retry, background operation
7. **Gallery feed** — grid view with compressed previews, SSE live updates, hashtag filter chips, Page Visibility API SSE pause/resume + delta-fetch
8. **Camera capture** — `getUserMedia` SvelteKit component; test on real iOS and Android hardware
9. **Host Dashboard** — ban flow with upload visibility modal, delete, promote, event lock toggle, export release toggle
10. **Admin Dashboard** — config UI (limits, rates, quota tolerance), disk usage widget, export progress bar
11. **Export engine** — async ZIP streamer, then HTML bundle generator (`Memories.html` + `README.txt` via `minijinja`)
12. **Rate limiting middleware** — `tower-governor` layers on all endpoint classes; read limits from `config` table
13. **"My Account" page** — PIN display from `localStorage`, session info
14. **Onboarding guide + HTML export guide** — dismissible overlays; `guide-seen` flag in `localStorage`
15. **End-to-end test event** — dry run with 10+ real devices on different networks (Wi-Fi + cellular) before any real event

---

## Appendix A: Rust Crates

| Crate | Purpose |
|-------|---------|
| `axum` | Web framework |
| `tokio` | Async runtime |
| `sqlx` | Async PostgreSQL driver; prepared statements; migrations embedded at compile time. Queries are runtime-checked (`sqlx::query()`), **not** macro-checked |
| `jsonwebtoken` | JWT sign / verify |
| `bcrypt` | PIN + admin password hashing |
| `uuid` | UUID v7 (time-sortable) |
| `serde` / `serde_json` | Serialisation |
| `tower` / `tower-http` | Middleware stack (CORS, compression, static files, request tracing) |
| (custom limiter, no crate) | Token-bucket / sliding window built in-tree at [services/rate_limiter.rs](backend/src/services/rate_limiter.rs) |
| `tokio::sync::Semaphore` | Bounded worker pool for compression tasks |
| `async-zip` | Streaming ZIP export (no in-memory buffer) |
| `minijinja` | HTML export template rendering (`Memories.html`) |
| `image` | Image decoding, resizing, preview generation |
| `oxipng` | Lossless PNG compression |
| `tracing` + `tracing-subscriber` | Structured logging |
| `anyhow` | Ergonomic error handling |
| `dotenvy` | `.env` file loading |
| `sysinfo` | Disk usage querying for dynamic quota calculation |

## Appendix B: Frontend Libraries (npm)

| Library | Purpose |
|---------|---------|
| `@sveltejs/kit` | Routing, SSR, PWA manifest + service worker |
| `qrcode` | Client-side QR code generation for the event join link (Host/Admin view) |
| `idb` | Type-safe IndexedDB wrapper for the upload queue |

---

*Blueprint finalised — March 2026*
