From aa12648228b11398768aad6a1b5033662c95015c Mon Sep 17 00:00:00 2001 From: derekc Date: Tue, 17 Mar 2026 23:19:29 -0700 Subject: [PATCH] Add multi-user auth, admin panel, and timezone support; rename to Yolkbook - Rename app from Eggtracker to Yolkbook throughout - Add JWT-based authentication (python-jose, passlib/bcrypt) - Add users table; all data tables gain user_id FK for full data isolation - Super admin credentials sourced from ADMIN_USERNAME/ADMIN_PASSWORD env vars, synced on every startup; orphaned rows auto-assigned to admin post-migration - Login page with self-registration; JWT stored in localStorage (30-day expiry) - Admin panel (/admin): list users, reset passwords, disable/enable, delete, and impersonate (Login As) with Return to Admin banner - Settings modal (gear icon in nav): timezone selector and change password - Timezone stored per-user; stats date windows computed in user's timezone; date input setToday() respects user timezone via Intl API - migrate_v2.sql for existing single-user installs - Auto-migration adds timezone column to users on startup - Updated README with full setup, auth, admin, and migration docs Co-Authored-By: Claude Sonnet 4.6 --- README.md | 116 +++++++++++++---- backend/auth.py | 72 ++++++++++ backend/main.py | 76 ++++++++++- backend/models.py | 19 ++- backend/requirements.txt | 3 + backend/routers/admin.py | 111 ++++++++++++++++ backend/routers/auth_router.py | 85 ++++++++++++ backend/routers/eggs.py | 37 ++++-- backend/routers/feed.py | 35 ++++- backend/routers/flock.py | 60 +++++++-- backend/routers/other.py | 35 ++++- backend/routers/stats.py | 101 ++++++++------ backend/schemas.py | 38 ++++++ docker-compose.yml | 5 +- mysql/init.sql | 40 ++++-- mysql/migrate_v2.sql | 50 +++++++ nginx/html/404.html | 4 +- nginx/html/50x.html | 4 +- nginx/html/admin.html | 85 ++++++++++++ nginx/html/budget.html | 7 +- nginx/html/css/style.css | 117 +++++++++++++++++ nginx/html/flock.html | 7 +- nginx/html/history.html | 7 +- nginx/html/index.html | 7 +- nginx/html/js/admin.js | 157 ++++++++++++++++++++++ nginx/html/js/api.js | 25 +++- nginx/html/js/auth.js | 232 +++++++++++++++++++++++++++++++++ nginx/html/js/summary.js | 2 +- nginx/html/log.html | 7 +- nginx/html/login.html | 161 +++++++++++++++++++++++ nginx/html/summary.html | 7 +- 31 files changed, 1572 insertions(+), 140 deletions(-) create mode 100644 backend/auth.py create mode 100644 backend/routers/admin.py create mode 100644 backend/routers/auth_router.py create mode 100644 mysql/migrate_v2.sql create mode 100644 nginx/html/admin.html create mode 100644 nginx/html/js/admin.js create mode 100644 nginx/html/js/auth.js create mode 100644 nginx/html/login.html diff --git a/README.md b/README.md index 13808b4..06d7d9f 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,19 @@ -# Eggtracker +# Yolkbook -A self-hosted web app for backyard chicken keepers to track egg production, flock size, feed costs, and egg economics over time. +A self-hosted, multi-user web app for backyard chicken keepers to track egg production, flock size, feed costs, and egg economics over time. ## Features -- **Dashboard** — at-a-glance stats: total eggs, 7/30-day totals, average eggs per day and per hen +- **Dashboard** — at-a-glance stats: total eggs, 7/30-day totals, average eggs per day and per hen, cost per egg and per dozen - **Daily log** — record egg collections with one entry per day - **History** — browse, edit, and delete past egg collection records - **Flock management** — track changes to your flock size over time so per-hen averages stay accurate - **Feed tracking** — log feed purchases (bags + price per bag) - **Budget** — cost per egg and cost per dozen, all-time and over the last 30 days - **Monthly summary** — month-by-month breakdown of production, averages, feed cost, and cost per egg +- **Multi-user** — each user has their own isolated data; self-registration on the login page +- **Admin panel** — view all users, reset passwords, disable/enable accounts, delete accounts, and log in as any user +- **Timezone support** — each user sets their own timezone so dates and stat windows are always accurate ## Tech Stack @@ -38,49 +41,118 @@ A self-hosted web app for backyard chicken keepers to track egg production, floc 2. Create a `.env` file in the project root: ```env + # MySQL MYSQL_ROOT_PASSWORD=your_root_password MYSQL_DATABASE=eggtracker MYSQL_USER=eggtracker - MYSQL_PASSWORD=your_password + MYSQL_PASSWORD=your_db_password + + # Super admin account (created/synced automatically on every startup) + ADMIN_USERNAME=admin + ADMIN_PASSWORD=your_admin_password + + # JWT signing secret — generate with: openssl rand -hex 32 + JWT_SECRET=your_long_random_secret ``` 3. Start the stack: ```bash - docker compose up -d + docker compose up -d --build ``` -4. Open your browser at `http://localhost:8056` +4. Open your browser at `http://localhost:8056/login` and sign in with the admin credentials you set in `.env`. -The database schema is applied automatically on first start via `mysql/init.sql`. +The database schema is applied automatically on first start via `mysql/init.sql`. The admin user is created (or synced) automatically every time the API starts. + +## Authentication + +- **Login / Register** — the landing page (`/login`) has both a sign-in form and a self-registration link so users can create their own accounts. +- **JWT tokens** — stored in `localStorage`, valid for 30 days. +- **Admin password** — always sourced from the `ADMIN_PASSWORD` env var. Changing it in `.env` and restarting will update the admin's password. + +## Admin Panel + +Accessible at `/admin` for admin accounts. Features: + +| Action | Description | +|--------|-------------| +| Reset password | Set a new password for any user | +| Disable / Enable | Block or restore a user's access | +| Delete | Permanently remove a user and all their data | +| Login As | Impersonate a user to view or edit their data directly | + +When impersonating a user, an amber banner appears in the nav with a **Return to Admin** button. + +## User Settings + +The gear icon (⚙) in the top-right nav opens the Settings panel: + +- **Timezone** — choose from a full list of IANA timezones or click *Detect automatically*. Affects what "today" is when pre-filling date fields and the 30-day/7-day windows on the dashboard and budget pages. +- **Change Password** — update your own password (requires current password). + +## Migrating an Existing Install (pre-multi-user) + +If you have an existing single-user install, run the migration script before rebuilding: + +```bash +# 1. Run the migration while the database is still running +docker compose exec db mysql -u root -p"${MYSQL_ROOT_PASSWORD}" eggtracker < mysql/migrate_v2.sql + +# 2. Rebuild and restart +docker compose up -d --build +``` + +All existing data will be automatically assigned to the admin account on first startup. ## API The FastAPI backend is available at `/api`. Interactive docs (Swagger UI) are at `/api/docs`. -| Prefix | Description | -|---------------|--------------------------| -| `/api/eggs` | Egg collection records | -| `/api/flock` | Flock size history | -| `/api/feed` | Feed purchase records | -| `/api/stats` | Dashboard, budget, and monthly summary stats | +| Prefix | Description | +|------------------|------------------------------------------| +| `/api/auth` | Login, register, change password, timezone | +| `/api/admin` | User management (admin only) | +| `/api/eggs` | Egg collection records | +| `/api/flock` | Flock size history | +| `/api/feed` | Feed purchase records | +| `/api/other` | Other purchases (bedding, snacks, etc.) | +| `/api/stats` | Dashboard, budget, and monthly summary | + +All data endpoints require a valid JWT (`Authorization: Bearer `). Data is always scoped to the authenticated user. ## Project Structure ``` -eggtracker/ +yolkbook/ ├── backend/ -│ ├── main.py # FastAPI app entry point -│ ├── models.py # SQLAlchemy models -│ ├── schemas.py # Pydantic schemas -│ ├── database.py # DB connection -│ ├── routers/ # Route handlers (eggs, flock, feed, stats) +│ ├── main.py # FastAPI app entry point + startup seeding +│ ├── auth.py # JWT utilities, password hashing, auth dependencies +│ ├── models.py # SQLAlchemy models +│ ├── schemas.py # Pydantic schemas +│ ├── database.py # DB connection +│ ├── routers/ +│ │ ├── auth_router.py # /api/auth — login, register, settings +│ │ ├── admin.py # /api/admin — user management +│ │ ├── eggs.py +│ │ ├── flock.py +│ │ ├── feed.py +│ │ ├── other.py +│ │ └── stats.py │ ├── requirements.txt │ └── Dockerfile ├── nginx/ -│ ├── html/ # Frontend (HTML, CSS, JS) +│ ├── html/ # Frontend (HTML, CSS, JS) +│ │ ├── login.html +│ │ ├── admin.html +│ │ ├── index.html # Dashboard +│ │ ├── js/ +│ │ │ ├── api.js # Shared fetch helpers +│ │ │ └── auth.js # Auth utilities, nav, settings modal +│ │ └── css/style.css │ └── nginx.conf ├── mysql/ -│ └── init.sql # Schema applied on first start +│ ├── init.sql # Schema for fresh installs +│ └── migrate_v2.sql # Migration for pre-multi-user installs ├── docker-compose.yml -└── .env # Secrets — not committed +└── .env # Secrets — not committed ``` diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..e2bbef6 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,72 @@ +import os +from datetime import datetime, timedelta, timezone +from typing import Optional + +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session + +from database import get_db +from models import User + +SECRET_KEY = os.environ["JWT_SECRET"] +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_DAYS = 30 + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(user_id: int, username: str, is_admin: bool, user_timezone: str = "UTC") -> str: + expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS) + payload = { + "sub": str(user_id), + "username": username, + "is_admin": is_admin, + "timezone": user_timezone, + "exp": expire, + } + return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: Session = Depends(get_db), +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + user_id_str: Optional[str] = payload.get("sub") + if user_id_str is None: + raise credentials_exception + user_id = int(user_id_str) + except (JWTError, ValueError): + raise credentials_exception + + user = db.get(User, user_id) + if user is None or user.is_disabled: + raise credentials_exception + return user + + +async def get_current_admin(current_user: User = Depends(get_current_user)) -> User: + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required", + ) + return current_user diff --git a/backend/main.py b/backend/main.py index 40255cf..ea7ebc4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,11 +1,81 @@ +import os +import logging +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import select, update, text +from database import Base, engine, SessionLocal +from models import User, EggCollection, FlockHistory, FeedPurchase, OtherPurchase +from auth import hash_password from routers import eggs, flock, feed, stats, other +from routers import auth_router, admin -app = FastAPI(title="Eggtracker API") +logger = logging.getLogger("yolkbook") + + +def _seed_admin(): + """Create or update the admin user from environment variables. + Also assigns any records with NULL user_id to the admin (post-migration). + """ + admin_username = os.environ["ADMIN_USERNAME"] + admin_password = os.environ["ADMIN_PASSWORD"] + + with SessionLocal() as db: + admin_user = db.scalars( + select(User).where(User.username == admin_username) + ).first() + + if admin_user is None: + admin_user = User( + username=admin_username, + hashed_password=hash_password(admin_password), + is_admin=True, + ) + db.add(admin_user) + db.commit() + db.refresh(admin_user) + logger.info("Admin user '%s' created.", admin_username) + else: + # Always sync password + admin flag from env vars + admin_user.hashed_password = hash_password(admin_password) + admin_user.is_admin = True + db.commit() + + # Assign orphaned records (from pre-migration data) to admin + for model in [EggCollection, FlockHistory, FeedPurchase, OtherPurchase]: + db.execute( + update(model) + .where(model.user_id == None) # noqa: E711 + .values(user_id=admin_user.id) + ) + db.commit() + + +def _run_migrations(): + """Apply incremental schema changes that create_all won't handle on existing tables.""" + with SessionLocal() as db: + # v2.1 — timezone column on users + try: + db.execute(text( + "ALTER TABLE users ADD COLUMN timezone VARCHAR(64) NOT NULL DEFAULT 'UTC'" + )) + db.commit() + except Exception: + db.rollback() # column already exists — safe to ignore + + +@asynccontextmanager +async def lifespan(app: FastAPI): + Base.metadata.create_all(bind=engine) + _run_migrations() + _seed_admin() + yield + + +app = FastAPI(title="Yolkbook API", lifespan=lifespan) -# Allow requests from the Nginx frontend (same host, different port internally) app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -13,6 +83,8 @@ app.add_middleware( allow_headers=["*"], ) +app.include_router(auth_router.router) +app.include_router(admin.router) app.include_router(eggs.router) app.include_router(flock.router) app.include_router(feed.router) diff --git a/backend/models.py b/backend/models.py index bf45591..0ef807e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,13 +1,27 @@ from datetime import date, datetime -from sqlalchemy import Integer, Date, DateTime, Text, Numeric, func +from sqlalchemy import Boolean, Integer, Date, DateTime, Text, Numeric, String, ForeignKey, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column from database import Base +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_disabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + timezone: Mapped[str] = mapped_column(String(64), nullable=False, default='UTC') + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + class EggCollection(Base): __tablename__ = "egg_collections" + __table_args__ = (UniqueConstraint("user_id", "date", name="uq_user_date"),) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) date: Mapped[date] = mapped_column(Date, nullable=False, index=True) eggs: Mapped[int] = mapped_column(Integer, nullable=False) notes: Mapped[str] = mapped_column(Text, nullable=True) @@ -18,6 +32,7 @@ class FlockHistory(Base): __tablename__ = "flock_history" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) date: Mapped[date] = mapped_column(Date, nullable=False, index=True) chicken_count: Mapped[int] = mapped_column(Integer, nullable=False) notes: Mapped[str] = mapped_column(Text, nullable=True) @@ -28,6 +43,7 @@ class FeedPurchase(Base): __tablename__ = "feed_purchases" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) date: Mapped[date] = mapped_column(Date, nullable=False, index=True) bags: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False) price_per_bag: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) @@ -39,6 +55,7 @@ class OtherPurchase(Base): __tablename__ = "other_purchases" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) date: Mapped[date] = mapped_column(Date, nullable=False, index=True) total: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False) notes: Mapped[str] = mapped_column(Text, nullable=True) diff --git a/backend/requirements.txt b/backend/requirements.txt index f0f9307..fb9d40c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,3 +4,6 @@ sqlalchemy==2.0.36 pymysql==1.1.1 cryptography==43.0.3 pydantic==2.9.2 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 diff --git a/backend/routers/admin.py b/backend/routers/admin.py new file mode 100644 index 0000000..c945077 --- /dev/null +++ b/backend/routers/admin.py @@ -0,0 +1,111 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session + +from database import get_db +from models import User +from schemas import UserCreate, UserOut, ResetPasswordRequest, TokenResponse +from auth import hash_password, create_access_token, get_current_admin, get_current_user + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +@router.get("/users", response_model=list[UserOut]) +def list_users( + _: User = Depends(get_current_admin), + db: Session = Depends(get_db), +): + return db.scalars(select(User).order_by(User.created_at)).all() + + +@router.post("/users", response_model=UserOut, status_code=201) +def create_user( + body: UserCreate, + _: User = Depends(get_current_admin), + db: Session = Depends(get_db), +): + existing = db.scalars(select(User).where(User.username == body.username)).first() + if existing: + raise HTTPException(status_code=409, detail="Username already taken") + user = User( + username=body.username, + hashed_password=hash_password(body.password), + is_admin=False, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@router.post("/users/{user_id}/reset-password") +def reset_password( + user_id: int, + body: ResetPasswordRequest, + current_admin: User = Depends(get_current_admin), + db: Session = Depends(get_db), +): + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + user.hashed_password = hash_password(body.new_password) + db.commit() + return {"detail": f"Password reset for {user.username}"} + + +@router.post("/users/{user_id}/disable") +def disable_user( + user_id: int, + current_admin: User = Depends(get_current_admin), + db: Session = Depends(get_db), +): + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if user.id == current_admin.id: + raise HTTPException(status_code=400, detail="Cannot disable your own account") + user.is_disabled = True + db.commit() + return {"detail": f"User {user.username} disabled"} + + +@router.post("/users/{user_id}/enable") +def enable_user( + user_id: int, + _: User = Depends(get_current_admin), + db: Session = Depends(get_db), +): + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + user.is_disabled = False + db.commit() + return {"detail": f"User {user.username} enabled"} + + +@router.delete("/users/{user_id}", status_code=204) +def delete_user( + user_id: int, + current_admin: User = Depends(get_current_admin), + db: Session = Depends(get_db), +): + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if user.id == current_admin.id: + raise HTTPException(status_code=400, detail="Cannot delete your own account") + db.delete(user) + db.commit() + + +@router.post("/users/{user_id}/impersonate", response_model=TokenResponse) +def impersonate_user( + user_id: int, + _: User = Depends(get_current_admin), + db: Session = Depends(get_db), +): + user = db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + token = create_access_token(user.id, user.username, user.is_admin, user.timezone) + return TokenResponse(access_token=token) diff --git a/backend/routers/auth_router.py b/backend/routers/auth_router.py new file mode 100644 index 0000000..a78c0cf --- /dev/null +++ b/backend/routers/auth_router.py @@ -0,0 +1,85 @@ +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +from database import get_db +from models import User +from schemas import LoginRequest, TokenResponse, UserOut, UserCreate, ChangePasswordRequest, TimezoneUpdate +from auth import verify_password, hash_password, create_access_token, get_current_user + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +def _make_token(user: User) -> str: + return create_access_token(user.id, user.username, user.is_admin, user.timezone) + + +@router.post("/login", response_model=TokenResponse) +def login(body: LoginRequest, db: Session = Depends(get_db)): + user = db.scalars(select(User).where(User.username == body.username)).first() + if not user or not verify_password(body.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password", + ) + if user.is_disabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account is disabled. Contact your administrator.", + ) + return TokenResponse(access_token=_make_token(user)) + + +@router.post("/register", response_model=TokenResponse, status_code=201) +def register(body: UserCreate, db: Session = Depends(get_db)): + existing = db.scalars(select(User).where(User.username == body.username)).first() + if existing: + raise HTTPException(status_code=409, detail="Username already taken") + # Default timezone to UTC; user can change it in settings + user = User( + username=body.username, + hashed_password=hash_password(body.password), + is_admin=False, + timezone="UTC", + ) + db.add(user) + db.commit() + db.refresh(user) + return TokenResponse(access_token=_make_token(user)) + + +@router.get("/me", response_model=UserOut) +def me(current_user: User = Depends(get_current_user)): + return current_user + + +@router.post("/change-password") +def change_password( + body: ChangePasswordRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + if not verify_password(body.current_password, current_user.hashed_password): + raise HTTPException(status_code=400, detail="Current password is incorrect") + current_user.hashed_password = hash_password(body.new_password) + db.commit() + return {"detail": "Password updated"} + + +@router.put("/timezone", response_model=TokenResponse) +def update_timezone( + body: TimezoneUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + try: + ZoneInfo(body.timezone) # validate it's a real IANA timezone + except ZoneInfoNotFoundError: + raise HTTPException(status_code=400, detail=f"Unknown timezone: {body.timezone}") + current_user.timezone = body.timezone + db.commit() + db.refresh(current_user) + # Return a fresh token with the updated timezone embedded + return TokenResponse(access_token=_make_token(current_user)) diff --git a/backend/routers/eggs.py b/backend/routers/eggs.py index cb44d84..783188f 100644 --- a/backend/routers/eggs.py +++ b/backend/routers/eggs.py @@ -6,8 +6,9 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from database import get_db -from models import EggCollection +from models import EggCollection, User from schemas import EggCollectionCreate, EggCollectionUpdate, EggCollectionOut +from auth import get_current_user router = APIRouter(prefix="/api/eggs", tags=["eggs"]) @@ -17,8 +18,13 @@ def list_eggs( start: Optional[date] = None, end: Optional[date] = None, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): - q = select(EggCollection).order_by(EggCollection.date.desc()) + q = ( + select(EggCollection) + .where(EggCollection.user_id == current_user.id) + .order_by(EggCollection.date.desc()) + ) if start: q = q.where(EggCollection.date >= start) if end: @@ -27,8 +33,12 @@ def list_eggs( @router.post("", response_model=EggCollectionOut, status_code=201) -def create_egg_collection(body: EggCollectionCreate, db: Session = Depends(get_db)): - record = EggCollection(**body.model_dump()) +def create_egg_collection( + body: EggCollectionCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + record = EggCollection(**body.model_dump(), user_id=current_user.id) db.add(record) try: db.commit() @@ -44,8 +54,12 @@ def update_egg_collection( record_id: int, body: EggCollectionUpdate, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): - record = db.get(EggCollection, record_id) + record = db.scalars( + select(EggCollection) + .where(EggCollection.id == record_id, EggCollection.user_id == current_user.id) + ).first() if not record: raise HTTPException(status_code=404, detail="Record not found") for field, value in body.model_dump(exclude_none=True).items(): @@ -54,14 +68,21 @@ def update_egg_collection( db.commit() except IntegrityError: db.rollback() - raise HTTPException(status_code=409, detail=f"An entry for that date already exists.") + raise HTTPException(status_code=409, detail="An entry for that date already exists.") db.refresh(record) return record @router.delete("/{record_id}", status_code=204) -def delete_egg_collection(record_id: int, db: Session = Depends(get_db)): - record = db.get(EggCollection, record_id) +def delete_egg_collection( + record_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + record = db.scalars( + select(EggCollection) + .where(EggCollection.id == record_id, EggCollection.user_id == current_user.id) + ).first() if not record: raise HTTPException(status_code=404, detail="Record not found") db.delete(record) diff --git a/backend/routers/feed.py b/backend/routers/feed.py index 8ba2ed7..870485e 100644 --- a/backend/routers/feed.py +++ b/backend/routers/feed.py @@ -5,8 +5,9 @@ from sqlalchemy import select from sqlalchemy.orm import Session from database import get_db -from models import FeedPurchase +from models import FeedPurchase, User from schemas import FeedPurchaseCreate, FeedPurchaseUpdate, FeedPurchaseOut +from auth import get_current_user router = APIRouter(prefix="/api/feed", tags=["feed"]) @@ -16,8 +17,13 @@ def list_feed_purchases( start: Optional[date] = None, end: Optional[date] = None, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): - q = select(FeedPurchase).order_by(FeedPurchase.date.desc()) + q = ( + select(FeedPurchase) + .where(FeedPurchase.user_id == current_user.id) + .order_by(FeedPurchase.date.desc()) + ) if start: q = q.where(FeedPurchase.date >= start) if end: @@ -26,8 +32,12 @@ def list_feed_purchases( @router.post("", response_model=FeedPurchaseOut, status_code=201) -def create_feed_purchase(body: FeedPurchaseCreate, db: Session = Depends(get_db)): - record = FeedPurchase(**body.model_dump()) +def create_feed_purchase( + body: FeedPurchaseCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + record = FeedPurchase(**body.model_dump(), user_id=current_user.id) db.add(record) db.commit() db.refresh(record) @@ -39,8 +49,12 @@ def update_feed_purchase( record_id: int, body: FeedPurchaseUpdate, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): - record = db.get(FeedPurchase, record_id) + record = db.scalars( + select(FeedPurchase) + .where(FeedPurchase.id == record_id, FeedPurchase.user_id == current_user.id) + ).first() if not record: raise HTTPException(status_code=404, detail="Record not found") for field, value in body.model_dump(exclude_none=True).items(): @@ -51,8 +65,15 @@ def update_feed_purchase( @router.delete("/{record_id}", status_code=204) -def delete_feed_purchase(record_id: int, db: Session = Depends(get_db)): - record = db.get(FeedPurchase, record_id) +def delete_feed_purchase( + record_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + record = db.scalars( + select(FeedPurchase) + .where(FeedPurchase.id == record_id, FeedPurchase.user_id == current_user.id) + ).first() if not record: raise HTTPException(status_code=404, detail="Record not found") db.delete(record) diff --git a/backend/routers/flock.py b/backend/routers/flock.py index 5cfcccb..54e5f9c 100644 --- a/backend/routers/flock.py +++ b/backend/routers/flock.py @@ -5,30 +5,49 @@ from sqlalchemy import select from sqlalchemy.orm import Session from database import get_db -from models import FlockHistory +from models import FlockHistory, User from schemas import FlockHistoryCreate, FlockHistoryUpdate, FlockHistoryOut +from auth import get_current_user router = APIRouter(prefix="/api/flock", tags=["flock"]) @router.get("", response_model=list[FlockHistoryOut]) -def list_flock_history(db: Session = Depends(get_db)): - q = select(FlockHistory).order_by(FlockHistory.date.desc()) +def list_flock_history( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + q = ( + select(FlockHistory) + .where(FlockHistory.user_id == current_user.id) + .order_by(FlockHistory.date.desc()) + ) return db.scalars(q).all() @router.get("/current", response_model=Optional[FlockHistoryOut]) -def get_current_flock(db: Session = Depends(get_db)): - """Returns the most recent flock entry — the current flock size.""" - q = select(FlockHistory).order_by(FlockHistory.date.desc()).limit(1) +def get_current_flock( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + q = ( + select(FlockHistory) + .where(FlockHistory.user_id == current_user.id) + .order_by(FlockHistory.date.desc()) + .limit(1) + ) return db.scalars(q).first() @router.get("/at/{target_date}", response_model=Optional[FlockHistoryOut]) -def get_flock_at_date(target_date: date, db: Session = Depends(get_db)): - """Returns the flock size that was in effect on a given date.""" +def get_flock_at_date( + target_date: date, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): q = ( select(FlockHistory) + .where(FlockHistory.user_id == current_user.id) .where(FlockHistory.date <= target_date) .order_by(FlockHistory.date.desc()) .limit(1) @@ -37,8 +56,12 @@ def get_flock_at_date(target_date: date, db: Session = Depends(get_db)): @router.post("", response_model=FlockHistoryOut, status_code=201) -def create_flock_entry(body: FlockHistoryCreate, db: Session = Depends(get_db)): - record = FlockHistory(**body.model_dump()) +def create_flock_entry( + body: FlockHistoryCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + record = FlockHistory(**body.model_dump(), user_id=current_user.id) db.add(record) db.commit() db.refresh(record) @@ -50,8 +73,12 @@ def update_flock_entry( record_id: int, body: FlockHistoryUpdate, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): - record = db.get(FlockHistory, record_id) + record = db.scalars( + select(FlockHistory) + .where(FlockHistory.id == record_id, FlockHistory.user_id == current_user.id) + ).first() if not record: raise HTTPException(status_code=404, detail="Record not found") for field, value in body.model_dump(exclude_none=True).items(): @@ -62,8 +89,15 @@ def update_flock_entry( @router.delete("/{record_id}", status_code=204) -def delete_flock_entry(record_id: int, db: Session = Depends(get_db)): - record = db.get(FlockHistory, record_id) +def delete_flock_entry( + record_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + record = db.scalars( + select(FlockHistory) + .where(FlockHistory.id == record_id, FlockHistory.user_id == current_user.id) + ).first() if not record: raise HTTPException(status_code=404, detail="Record not found") db.delete(record) diff --git a/backend/routers/other.py b/backend/routers/other.py index dedb7a1..5721f38 100644 --- a/backend/routers/other.py +++ b/backend/routers/other.py @@ -5,8 +5,9 @@ from sqlalchemy import select from sqlalchemy.orm import Session from database import get_db -from models import OtherPurchase +from models import OtherPurchase, User from schemas import OtherPurchaseCreate, OtherPurchaseUpdate, OtherPurchaseOut +from auth import get_current_user router = APIRouter(prefix="/api/other", tags=["other"]) @@ -16,8 +17,13 @@ def list_other_purchases( start: Optional[date] = None, end: Optional[date] = None, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): - q = select(OtherPurchase).order_by(OtherPurchase.date.desc()) + q = ( + select(OtherPurchase) + .where(OtherPurchase.user_id == current_user.id) + .order_by(OtherPurchase.date.desc()) + ) if start: q = q.where(OtherPurchase.date >= start) if end: @@ -26,8 +32,12 @@ def list_other_purchases( @router.post("", response_model=OtherPurchaseOut, status_code=201) -def create_other_purchase(body: OtherPurchaseCreate, db: Session = Depends(get_db)): - record = OtherPurchase(**body.model_dump()) +def create_other_purchase( + body: OtherPurchaseCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + record = OtherPurchase(**body.model_dump(), user_id=current_user.id) db.add(record) db.commit() db.refresh(record) @@ -39,8 +49,12 @@ def update_other_purchase( record_id: int, body: OtherPurchaseUpdate, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): - record = db.get(OtherPurchase, record_id) + record = db.scalars( + select(OtherPurchase) + .where(OtherPurchase.id == record_id, OtherPurchase.user_id == current_user.id) + ).first() if not record: raise HTTPException(status_code=404, detail="Record not found") for field, value in body.model_dump(exclude_none=True).items(): @@ -51,8 +65,15 @@ def update_other_purchase( @router.delete("/{record_id}", status_code=204) -def delete_other_purchase(record_id: int, db: Session = Depends(get_db)): - record = db.get(OtherPurchase, record_id) +def delete_other_purchase( + record_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + record = db.scalars( + select(OtherPurchase) + .where(OtherPurchase.id == record_id, OtherPurchase.user_id == current_user.id) + ).first() if not record: raise HTTPException(status_code=404, detail="Record not found") db.delete(record) diff --git a/backend/routers/stats.py b/backend/routers/stats.py index f3e7933..99c51ab 100644 --- a/backend/routers/stats.py +++ b/backend/routers/stats.py @@ -1,25 +1,30 @@ import calendar -from datetime import date, timedelta +from datetime import date, datetime, timedelta from decimal import Decimal +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from fastapi import APIRouter, Depends from sqlalchemy import select, func from sqlalchemy.orm import Session from database import get_db -from models import EggCollection, FlockHistory, FeedPurchase, OtherPurchase +from models import EggCollection, FlockHistory, FeedPurchase, OtherPurchase, User from schemas import DashboardStats, BudgetStats, MonthlySummary +from auth import get_current_user router = APIRouter(prefix="/api/stats", tags=["stats"]) -def _avg_per_hen_30d(db: Session, start_30d: date) -> float | None: - """ - For each collection in the last 30 days, look up the flock size that was - in effect on that date using a correlated subquery, then average eggs/hen - across those days. This gives an accurate result even when flock size changed. - """ +def _today(user_timezone: str) -> date: + try: + return datetime.now(ZoneInfo(user_timezone)).date() + except ZoneInfoNotFoundError: + return date.today() + + +def _avg_per_hen_30d(db: Session, user_id: int, start_30d: date) -> float | None: flock_at_date = ( select(FlockHistory.chicken_count) + .where(FlockHistory.user_id == user_id) .where(FlockHistory.date <= EggCollection.date) .order_by(FlockHistory.date.desc()) .limit(1) @@ -29,6 +34,7 @@ def _avg_per_hen_30d(db: Session, start_30d: date) -> float | None: rows = db.execute( select(EggCollection.eggs, flock_at_date.label('flock_count')) + .where(EggCollection.user_id == user_id) .where(EggCollection.date >= start_30d) ).all() @@ -38,15 +44,18 @@ def _avg_per_hen_30d(db: Session, start_30d: date) -> float | None: return round(sum(e / f for e, f in valid) / len(valid), 3) -def _current_flock(db: Session) -> int | None: +def _current_flock(db: Session, user_id: int) -> int | None: row = db.scalars( - select(FlockHistory).order_by(FlockHistory.date.desc()).limit(1) + select(FlockHistory) + .where(FlockHistory.user_id == user_id) + .order_by(FlockHistory.date.desc()) + .limit(1) ).first() return row.chicken_count if row else None -def _total_eggs(db: Session, start: date | None = None, end: date | None = None) -> int: - q = select(func.coalesce(func.sum(EggCollection.eggs), 0)) +def _total_eggs(db: Session, user_id: int, start: date | None = None, end: date | None = None) -> int: + q = select(func.coalesce(func.sum(EggCollection.eggs), 0)).where(EggCollection.user_id == user_id) if start: q = q.where(EggCollection.date >= start) if end: @@ -54,10 +63,10 @@ def _total_eggs(db: Session, start: date | None = None, end: date | None = None) return db.scalar(q) -def _total_feed_cost(db: Session, start: date | None = None, end: date | None = None): +def _total_feed_cost(db: Session, user_id: int, start: date | None = None, end: date | None = None): q = select( func.coalesce(func.sum(FeedPurchase.bags * FeedPurchase.price_per_bag), 0) - ) + ).where(FeedPurchase.user_id == user_id) if start: q = q.where(FeedPurchase.date >= start) if end: @@ -65,8 +74,8 @@ def _total_feed_cost(db: Session, start: date | None = None, end: date | None = return db.scalar(q) -def _total_other_cost(db: Session, start: date | None = None, end: date | None = None): - q = select(func.coalesce(func.sum(OtherPurchase.total), 0)) +def _total_other_cost(db: Session, user_id: int, start: date | None = None, end: date | None = None): + q = select(func.coalesce(func.sum(OtherPurchase.total), 0)).where(OtherPurchase.user_id == user_id) if start: q = q.where(OtherPurchase.date >= start) if end: @@ -75,29 +84,33 @@ def _total_other_cost(db: Session, start: date | None = None, end: date | None = @router.get("/dashboard", response_model=DashboardStats) -def dashboard_stats(db: Session = Depends(get_db)): - today = date.today() - start_30d = today - timedelta(days=30) - start_7d = today - timedelta(days=7) +def dashboard_stats( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + uid = current_user.id + today = _today(current_user.timezone) + start_30d = today - timedelta(days=30) + start_7d = today - timedelta(days=7) - total_alltime = _total_eggs(db) - total_30d = _total_eggs(db, start=start_30d) - total_7d = _total_eggs(db, start=start_7d) - flock = _current_flock(db) + total_alltime = _total_eggs(db, uid) + total_30d = _total_eggs(db, uid, start=start_30d) + total_7d = _total_eggs(db, uid, start=start_7d) + flock = _current_flock(db, uid) - # Count how many distinct days have a collection logged days_tracked = db.scalar( select(func.count(func.distinct(EggCollection.date))) + .where(EggCollection.user_id == uid) ) - # Average eggs per day over the last 30 days (only counting days with data) days_with_data_30d = db.scalar( select(func.count(func.distinct(EggCollection.date))) + .where(EggCollection.user_id == uid) .where(EggCollection.date >= start_30d) ) avg_per_day = round(total_30d / days_with_data_30d, 2) if days_with_data_30d else None - avg_per_hen = _avg_per_hen_30d(db, start_30d) + avg_per_hen = _avg_per_hen_30d(db, uid, start_30d) return DashboardStats( current_flock=flock, @@ -111,10 +124,13 @@ def dashboard_stats(db: Session = Depends(get_db)): @router.get("/monthly", response_model=list[MonthlySummary]) -def monthly_stats(db: Session = Depends(get_db)): +def monthly_stats( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + uid = current_user.id MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] - # Monthly egg totals egg_rows = db.execute( select( func.year(EggCollection.date).label('year'), @@ -122,6 +138,7 @@ def monthly_stats(db: Session = Depends(get_db)): func.sum(EggCollection.eggs).label('total_eggs'), func.count(EggCollection.date).label('days_logged'), ) + .where(EggCollection.user_id == uid) .group_by(func.year(EggCollection.date), func.month(EggCollection.date)) .order_by(func.year(EggCollection.date).desc(), func.month(EggCollection.date).desc()) ).all() @@ -129,25 +146,25 @@ def monthly_stats(db: Session = Depends(get_db)): if not egg_rows: return [] - # Monthly feed costs feed_rows = db.execute( select( func.year(FeedPurchase.date).label('year'), func.month(FeedPurchase.date).label('month'), func.sum(FeedPurchase.bags * FeedPurchase.price_per_bag).label('feed_cost'), ) + .where(FeedPurchase.user_id == uid) .group_by(func.year(FeedPurchase.date), func.month(FeedPurchase.date)) ).all() feed_map = {(r.year, r.month): r.feed_cost for r in feed_rows} - # Monthly other costs other_rows = db.execute( select( func.year(OtherPurchase.date).label('year'), func.month(OtherPurchase.date).label('month'), func.sum(OtherPurchase.total).label('other_cost'), ) + .where(OtherPurchase.user_id == uid) .group_by(func.year(OtherPurchase.date), func.month(OtherPurchase.date)) ).all() @@ -159,9 +176,9 @@ def monthly_stats(db: Session = Depends(get_db)): last_day = calendar.monthrange(y, m)[1] month_end = date(y, m, last_day) - # Flock size in effect at the end of this month flock_row = db.scalars( select(FlockHistory) + .where(FlockHistory.user_id == uid) .where(FlockHistory.date <= month_end) .order_by(FlockHistory.date.desc()) .limit(1) @@ -201,16 +218,20 @@ def monthly_stats(db: Session = Depends(get_db)): @router.get("/budget", response_model=BudgetStats) -def budget_stats(db: Session = Depends(get_db)): - today = date.today() +def budget_stats( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + uid = current_user.id + today = _today(current_user.timezone) start_30d = today - timedelta(days=30) - total_feed_cost = _total_feed_cost(db) - total_feed_cost_30d = _total_feed_cost(db, start=start_30d) - total_other_cost = _total_other_cost(db) - total_other_cost_30d = _total_other_cost(db, start=start_30d) - total_eggs = _total_eggs(db) - total_eggs_30d = _total_eggs(db, start=start_30d) + total_feed_cost = _total_feed_cost(db, uid) + total_feed_cost_30d = _total_feed_cost(db, uid, start=start_30d) + total_other_cost = _total_other_cost(db, uid) + total_other_cost_30d = _total_other_cost(db, uid, start=start_30d) + total_eggs = _total_eggs(db, uid) + total_eggs_30d = _total_eggs(db, uid, start=start_30d) def cost_per_egg(cost, eggs): if not eggs or not cost: diff --git a/backend/schemas.py b/backend/schemas.py index 67896c1..193b4a5 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -4,6 +4,44 @@ from typing import Optional from pydantic import BaseModel, Field +# ── Auth ────────────────────────────────────────────────────────────────────── + +class LoginRequest(BaseModel): + username: str + password: str + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str = Field(min_length=6) + +class ResetPasswordRequest(BaseModel): + new_password: str = Field(min_length=6) + +class TimezoneUpdate(BaseModel): + timezone: str = Field(min_length=1, max_length=64) + + +# ── Users ───────────────────────────────────────────────────────────────────── + +class UserCreate(BaseModel): + username: str = Field(min_length=2, max_length=64) + password: str = Field(min_length=6) + +class UserOut(BaseModel): + id: int + username: str + is_admin: bool + is_disabled: bool + timezone: str + created_at: datetime + + model_config = {"from_attributes": True} + + # ── Egg Collections ─────────────────────────────────────────────────────────── class EggCollectionCreate(BaseModel): diff --git a/docker-compose.yml b/docker-compose.yml index 4ad8e01..e918891 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,10 @@ services: restart: unless-stopped env_file: .env environment: - DATABASE_URL: mysql+pymysql://${MYSQL_USER}:${MYSQL_PASSWORD}@db/${MYSQL_DATABASE} + DATABASE_URL: mysql+pymysql://${MYSQL_USER}:${MYSQL_PASSWORD}@db/${MYSQL_DATABASE} + ADMIN_USERNAME: ${ADMIN_USERNAME} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + JWT_SECRET: ${JWT_SECRET} depends_on: db: condition: service_healthy # wait for MySQL to be ready before starting diff --git a/mysql/init.sql b/mysql/init.sql index 6554384..36b810d 100644 --- a/mysql/init.sql +++ b/mysql/init.sql @@ -1,4 +1,4 @@ --- Eggtracker schema +-- Eggtracker schema — multi-user edition -- This file runs automatically on first container startup only. -- To re-run it, remove the mysql_data volume: docker compose down -v @@ -8,50 +8,72 @@ CREATE DATABASE IF NOT EXISTS eggtracker USE eggtracker; +-- ── Users ───────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + username VARCHAR(64) NOT NULL, + hashed_password VARCHAR(255) NOT NULL, + is_admin TINYINT(1) NOT NULL DEFAULT 0, + is_disabled TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_username (username) +) ENGINE=InnoDB; + -- ── Egg collections ─────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS egg_collections ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INT UNSIGNED NOT NULL, date DATE NOT NULL, eggs INT UNSIGNED NOT NULL, notes TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), - UNIQUE KEY uq_date (date) + UNIQUE KEY uq_user_date (user_id, date), + INDEX idx_user_id (user_id), + INDEX idx_date (date), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB; -- ── Flock history ───────────────────────────────────────────────────────────── --- Each row records a change in flock size. The count in effect for any given --- date is the most recent row with date <= that date. CREATE TABLE IF NOT EXISTS flock_history ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INT UNSIGNED NOT NULL, date DATE NOT NULL, chicken_count INT UNSIGNED NOT NULL, notes TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), - INDEX idx_date (date) + INDEX idx_user_id (user_id), + INDEX idx_date (date), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB; -- ── Feed purchases ──────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS feed_purchases ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INT UNSIGNED NOT NULL, date DATE NOT NULL, - bags DECIMAL(5, 2) NOT NULL, -- decimal for partial bags + bags DECIMAL(5, 2) NOT NULL, price_per_bag DECIMAL(10, 2) NOT NULL, notes TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), - INDEX idx_date (date) + INDEX idx_user_id (user_id), + INDEX idx_date (date), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB; -- ── Other purchases ─────────────────────────────────────────────────────────── --- Catch-all for non-feed costs: bedding, snacks, shelter, etc. CREATE TABLE IF NOT EXISTS other_purchases ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INT UNSIGNED NOT NULL, date DATE NOT NULL, total DECIMAL(10, 2) NOT NULL, notes TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), - INDEX idx_date (date) + INDEX idx_user_id (user_id), + INDEX idx_date (date), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB; diff --git a/mysql/migrate_v2.sql b/mysql/migrate_v2.sql new file mode 100644 index 0000000..5f15deb --- /dev/null +++ b/mysql/migrate_v2.sql @@ -0,0 +1,50 @@ +-- Eggtracker v2 migration — adds multi-user support to an existing database. +-- Run this ONCE on an existing install BEFORE restarting with the new image: +-- +-- docker compose exec db mysql -u root -p"${MYSQL_ROOT_PASSWORD}" eggtracker < mysql/migrate_v2.sql +-- +-- After running this script, restart the stack (docker compose up -d --build). +-- The API will automatically create the admin user (from ADMIN_USERNAME / +-- ADMIN_PASSWORD in .env) and assign all existing records to that admin account. +-- +-- NOTE: Run this script only ONCE. Running it again will fail on the ADD COLUMN +-- statements since the columns will already exist. + +USE eggtracker; + +-- ── Create users table ──────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + username VARCHAR(64) NOT NULL, + hashed_password VARCHAR(255) NOT NULL, + is_admin TINYINT(1) NOT NULL DEFAULT 0, + is_disabled TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_username (username) +) ENGINE=InnoDB; + +-- ── Add user_id columns (nullable so existing rows remain valid) ─────────────── +ALTER TABLE egg_collections + ADD COLUMN user_id INT UNSIGNED NULL AFTER id, + ADD INDEX idx_user_id (user_id); + +ALTER TABLE flock_history + ADD COLUMN user_id INT UNSIGNED NULL AFTER id, + ADD INDEX idx_user_id (user_id); + +ALTER TABLE feed_purchases + ADD COLUMN user_id INT UNSIGNED NULL AFTER id, + ADD INDEX idx_user_id (user_id); + +ALTER TABLE other_purchases + ADD COLUMN user_id INT UNSIGNED NULL AFTER id, + ADD INDEX idx_user_id (user_id); + +-- ── Remove old single-column unique index on egg_collections.date ───────────── +-- It will be replaced by (user_id, date) once the admin is seeded. +ALTER TABLE egg_collections DROP INDEX uq_date; + +-- The API startup will: +-- 1. Create the admin user from ADMIN_USERNAME / ADMIN_PASSWORD in .env +-- 2. Set user_id = admin.id on all rows where user_id IS NULL diff --git a/nginx/html/404.html b/nginx/html/404.html index 198e164..caf2a51 100644 --- a/nginx/html/404.html +++ b/nginx/html/404.html @@ -3,7 +3,7 @@ - 404 Not Found — Eggtracker + 404 Not Found — Yolkbook