# 🥃 Bourbonacci A self-hosted infinity bottle tracker. Log every bourbon you add to your blend, track what you've consumed, and watch your bottle's story unfold over time. Community bottles are visible to anyone who visits the site — click a card to browse the full bourbon list inside that bottle. Built with FastAPI, vanilla JS, and MySQL — fully containerized with Docker Compose. No framework dependencies on the frontend. ## Features - **Infinity bottle tracking** — log `add` entries (bourbon name, proof, shots) and `remove` entries (shots consumed) - **Live stats** — bourbons added, total shots poured in, shots remaining, estimated weighted proof - **Bottle fill bar** — visual level indicator based on your configured bottle size - **Community page** — public leaderboard of all bottles; click any card to see a searchable, alphabetical bourbon list - **Settings modal** — update display name, bottle size, timezone, and password from any page via the gear icon - **Admin panel** — list all users, reset passwords, disable/enable accounts, delete users, and impersonate any user for debugging - **JWT auth** — Bearer token auth with impersonation support (admin tokens carry an `admin_id` claim) ## Stack | Layer | Technology | |---|---| | Backend | FastAPI (Python 3.12), async SQLAlchemy 2.0, Uvicorn | | Database | MySQL 8 | | Frontend | Vanilla HTML/CSS/JS (no framework) | | Reverse Proxy | Nginx | | Auth | JWT via `python-jose`, passwords hashed with `passlib[bcrypt==4.0.1]` | | Container | Docker Compose | ## Project Structure ``` bourbonacci/ ├── backend/ │ ├── app/ │ │ ├── main.py # FastAPI app entry point, lifespan (DB init + admin seed) │ │ ├── config.py # Pydantic settings (reads from .env) │ │ ├── database.py # Async engine, session factory, Base, init_db() │ │ ├── dependencies.py # get_db, get_current_user, get_current_admin │ │ ├── models/ │ │ │ ├── user.py # User ORM model │ │ │ └── entry.py # Entry ORM model (add/remove types) │ │ ├── routers/ │ │ │ ├── auth.py # POST /api/auth/register, /api/auth/login │ │ │ ├── users.py # GET/PUT /api/users/me, PUT /api/users/me/password │ │ │ ├── entries.py # CRUD /api/entries, GET /api/entries/stats │ │ │ ├── public.py # GET /api/public/stats (unauthenticated) │ │ │ └── admin.py # Admin user management + impersonation │ │ ├── schemas/ │ │ │ ├── user.py # Pydantic schemas for user/auth │ │ │ └── entry.py # Pydantic schemas for entries/stats │ │ └── utils/ │ │ └── security.py # JWT creation/decode, password hashing │ ├── Dockerfile │ └── requirements.txt ├── frontend/ │ ├── index.html # Landing page + public community bottles │ ├── login.html │ ├── register.html │ ├── dashboard.html # My Bottle — stats, fill bar, entry log │ ├── log.html # Log add or remove entries │ ├── profile.html # Profile settings (display name, timezone, password) │ ├── admin.html # Admin user management panel │ ├── css/style.css # Bourbon-themed dark UI │ └── js/ │ ├── api.js # Fetch wrapper, token injection, escHtml, admin API │ ├── auth.js # Auth state, nav rendering, settings modal │ └── admin.js # Admin page logic (user table, modals, impersonation) ├── nginx/ │ └── default.conf # Serves frontend, proxies /api/ to backend:8000 ├── docker-compose.yml └── .env.example ``` ## Pages | Page | Auth | Description | |---|---|---| | `/index.html` | No | Landing page with community bottle cards; click to browse bourbons | | `/login.html` | No | Login form | | `/register.html` | No | Registration form | | `/dashboard.html` | Yes | My Bottle — stats, fill bar, full entry log with delete | | `/log.html` | Yes | Log an add or remove entry | | `/profile.html` | Yes | Edit display name, timezone, bottle size, password | | `/admin.html` | Admin | User management — reset PW, disable, impersonate, delete | ## Navigation The top-right nav area shows (when logged in): - **Display name** — non-clickable label - **Admin** button — only visible to admin accounts, links to `/admin.html` - **⚙ Settings** button — opens a modal to update display name, bottle size, timezone, and password - **Logout** button When impersonating a user, the nav shows **"Viewing as [name]"** and a **Return to Admin** button. ## API Endpoints ### Auth | Method | Path | Auth | Description | |---|---|---|---| | POST | `/api/auth/register` | No | Register new account, returns JWT | | POST | `/api/auth/login` | No | Login, returns JWT | ### Users | Method | Path | Auth | Description | |---|---|---|---| | GET | `/api/users/me` | Yes | Get current user profile | | PUT | `/api/users/me` | Yes | Update display name, timezone, bottle size | | PUT | `/api/users/me/password` | Yes | Change password | ### Entries | Method | Path | Auth | Description | |---|---|---|---| | GET | `/api/entries` | Yes | List all entries for current user | | POST | `/api/entries` | Yes | Create an entry (`add` or `remove`) | | DELETE | `/api/entries/{id}` | Yes | Delete an entry | | GET | `/api/entries/stats` | Yes | Aggregated stats for current user | ### Public | Method | Path | Auth | Description | |---|---|---|---| | GET | `/api/public/stats` | No | Stats + bourbon list for all users | ### Admin | Method | Path | Auth | Description | |---|---|---|---| | GET | `/api/admin/users` | Admin | List all users | | POST | `/api/admin/users` | Admin | Create a user | | POST | `/api/admin/users/{id}/reset-password` | Admin | Force-reset a user's password | | POST | `/api/admin/users/{id}/disable` | Admin | Disable a user account | | POST | `/api/admin/users/{id}/enable` | Admin | Re-enable a user account | | DELETE | `/api/admin/users/{id}` | Admin | Hard-delete a user and all their data | | POST | `/api/admin/users/{id}/impersonate` | Admin | Get a token scoped as that user | | POST | `/api/admin/unimpersonate` | Admin | Exchange impersonation token back for admin token | Authenticated routes expect `Authorization: Bearer ` header. ### Entry Schema ```json { "entry_type": "add" | "remove", "date": "YYYY-MM-DD", "bourbon_name": "string (required for add)", "proof": 90.0, "amount_shots": 1.0, "notes": "optional string" } ``` ### Stats Response ```json { "total_add_entries": 12, "total_shots_added": 18.5, "current_total_shots": 14.25, "estimated_proof": 95.3 } ``` `current_total_shots` = total added − total removed. `estimated_proof` = weighted average proof across all add entries by shot count. ## Getting Started ### Prerequisites - Docker + Docker Compose ### Setup ```bash cp .env.example .env # Edit .env — set real passwords and a secure SECRET_KEY ``` Generate a secure secret key: ```bash python3 -c "import secrets; print(secrets.token_hex(64))" ``` ### Run ```bash docker compose up -d ``` The app will be available at `http://localhost` (or whichever port is mapped in `docker-compose.yml`). The backend waits for MySQL to pass its healthcheck before starting. Tables are created automatically on first boot via SQLAlchemy's `create_all`. The admin account defined in `.env` is seeded/re-synced on every container start. ### Stop ```bash docker compose down # To also remove the database volume: docker compose down -v ``` ## Environment Variables | Variable | Description | |---|---| | `MYSQL_ROOT_PASSWORD` | MySQL root password | | `MYSQL_DATABASE` | Database name (default: `bourbonacci`) | | `MYSQL_USER` | App DB user | | `MYSQL_PASSWORD` | App DB password | | `DATABASE_URL` | SQLAlchemy async DSN (`mysql+aiomysql://user:pass@db:3306/dbname`) | | `SECRET_KEY` | JWT signing secret — keep long and random | | `ACCESS_TOKEN_EXPIRE_MINUTES` | JWT TTL in minutes (default: `480`) | | `ADMIN_USERNAME` | Admin account email, seeded on every container start | | `ADMIN_PASSWORD` | Admin account password, re-synced on every container start | ## Data Model **users** | Column | Type | Notes | |---|---|---| | `id` | int PK | | | `email` | varchar(255) unique | | | `password_hash` | varchar(255) | bcrypt | | `display_name` | varchar(100) nullable | | | `timezone` | varchar(50) | default `UTC` | | `bottle_size` | float nullable | user-configured bottle capacity in shots | | `is_admin` | bool | default false | | `is_disabled` | bool | default false | | `created_at` | datetime | server default | **entries** | Column | Type | Notes | |---|---|---| | `id` | int PK | | | `user_id` | int FK → users | cascade delete | | `entry_type` | enum | `add` or `remove` | | `date` | date | user-supplied | | `bourbon_name` | varchar nullable | required for `add` entries | | `proof` | float nullable | | | `amount_shots` | float | default 1.0 | | `notes` | text nullable | | | `created_at` | datetime | server default |