diff --git a/backend/app/main.py b/backend/app/main.py index fc093c2..7cf2fb0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,9 +1,14 @@ import os +import random +import string + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text from sqlalchemy.orm import Session + from .db import Base, engine, SessionLocal -from .models import User, Entry, Category, Role +from .models import User, Entry, Category, Role, Game, GameMember from .security import hash_password from .routes.auth import router as auth_router from .routes.admin import router as admin_router @@ -14,7 +19,10 @@ app = FastAPI(title="Cluedo Sheet") # Intern: Frontend läuft auf :8081 app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:8081", "http://127.0.0.1:8081"], + allow_origins=[ + "http://localhost:8081", + "http://127.0.0.1:8081", + ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -24,14 +32,240 @@ app.include_router(auth_router) app.include_router(admin_router) app.include_router(games_router) + +def _rand_join_code(n: int = 6) -> str: + # digits only (kahoot style) + return "".join(random.choice(string.digits) for _ in range(n)) + + +def _has_column(db: Session, table: str, col: str) -> bool: + """ +Postgres + SQLite friendly check without spamming Postgres logs. +- SQLite: PRAGMA table_info +- Postgres: information_schema + """ + dialect = None + try: + dialect = db.get_bind().dialect.name # "postgresql" | "sqlite" | ... + except Exception: + dialect = None + + if dialect == "sqlite": + try: + rows = db.execute(text(f"PRAGMA table_info({table})")).all() + return any(r[1] == col for r in rows) + except Exception: + db.rollback() + return False + + # default: Postgres (or others) via information_schema + try: + rows = db.execute( + text( + """ +SELECT 1 +FROM information_schema.columns +WHERE table_name = :t AND column_name = :c +LIMIT 1 + """ + ), + {"t": table, "c": col}, + ).all() + return len(rows) > 0 + except Exception: + db.rollback() + return False + + + +def _auto_migrate(db: Session): + """ +Very small, pragmatic auto-migration (no alembic). +- creates missing tables via create_all +- adds missing columns via ALTER TABLE (best effort) +- supports old schema (join_code/chip_code) and new schema (code/chip) +""" + + # --- users.display_name --- + if not _has_column(db, "users", "display_name"): + try: + db.execute(text("ALTER TABLE users ADD COLUMN display_name VARCHAR DEFAULT ''")) + db.commit() + except Exception: + db.rollback() + + # --- users.theme_key --- + if not _has_column(db, "users", "theme_key"): + try: + db.execute(text("ALTER TABLE users ADD COLUMN theme_key VARCHAR DEFAULT 'default'")) + db.commit() + except Exception: + db.rollback() + + # --- games: code / join_code + winner_user_id + host_user_id (optional) --- + # We support both column names: + # old: join_code + # new: code + has_join_code = _has_column(db, "games", "join_code") + has_code = _has_column(db, "games", "code") + + # If neither exists, create "code" (new preferred) + if not has_join_code and not has_code: + try: + db.execute(text("ALTER TABLE games ADD COLUMN code VARCHAR")) + db.commit() + has_code = True + except Exception: + db.rollback() + + # If only join_code exists but your code now expects "code", + # add "code" too and later mirror values. + if has_join_code and not has_code: + try: + db.execute(text("ALTER TABLE games ADD COLUMN code VARCHAR")) + db.commit() + has_code = True + except Exception: + db.rollback() + + # winner_user_id + if not _has_column(db, "games", "winner_user_id"): + try: + db.execute(text("ALTER TABLE games ADD COLUMN winner_user_id VARCHAR")) + db.commit() + except Exception: + db.rollback() + + # host_user_id (nice to have for "only host can set winner") + if not _has_column(db, "games", "host_user_id"): + try: + db.execute(text("ALTER TABLE games ADD COLUMN host_user_id VARCHAR")) + db.commit() + except Exception: + db.rollback() + + # --- sheet_state chip / chip_code --- + has_chip_code = _has_column(db, "sheet_state", "chip_code") + has_chip = _has_column(db, "sheet_state", "chip") + + if not has_chip_code and not has_chip: + # prefer "chip" + try: + db.execute(text("ALTER TABLE sheet_state ADD COLUMN chip VARCHAR")) + db.commit() + has_chip = True + except Exception: + db.rollback() + + # if old chip_code exists but new expects chip -> add chip and mirror later + if has_chip_code and not has_chip: + try: + db.execute(text("ALTER TABLE sheet_state ADD COLUMN chip VARCHAR")) + db.commit() + has_chip = True + except Exception: + db.rollback() + + # --- indexes for game code --- + # We create unique index for the column(s) that exist. + try: + if has_join_code: + db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_games_join_code ON games (join_code)")) + if has_code: + db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_games_code ON games (code)")) + db.commit() + except Exception: + db.rollback() + + # --- backfill code values --- + # 1) if join_code exists and code exists, ensure code mirrors join_code where missing + if has_join_code and has_code: + try: + db.execute(text("UPDATE games SET code = join_code WHERE (code IS NULL OR code = '') AND join_code IS NOT NULL AND join_code <> ''")) + db.commit() + except Exception: + db.rollback() + + # 2) generate missing codes in whichever column we have + # Prefer writing into "code" (new), but also keep join_code in sync if present. + code_col = "code" if has_code else "join_code" if has_join_code else None + if code_col: + try: + missing = db.execute( + text(f"SELECT id FROM games WHERE {code_col} IS NULL OR {code_col} = ''") + ).all() + except Exception: + db.rollback() + missing = [] + + if missing: + try: + used_rows = db.execute(text(f"SELECT {code_col} FROM games WHERE {code_col} IS NOT NULL")).all() + used = set([r[0] for r in used_rows if r and r[0]]) + except Exception: + db.rollback() + used = set() + + for (gid,) in missing: + code = _rand_join_code() + while code in used: + code = _rand_join_code() + used.add(code) + + try: + # write into main col + db.execute(text(f"UPDATE games SET {code_col} = :c WHERE id = :id"), {"c": code, "id": gid}) + # keep both in sync if both exist + if has_join_code and code_col == "code": + db.execute(text("UPDATE games SET join_code = :c WHERE id = :id AND (join_code IS NULL OR join_code = '')"), {"c": code, "id": gid}) + if has_code and code_col == "join_code": + db.execute(text("UPDATE games SET code = :c WHERE id = :id AND (code IS NULL OR code = '')"), {"c": code, "id": gid}) + db.commit() + except Exception: + db.rollback() + + # --- backfill host_user_id: default to owner_user_id --- + try: + if _has_column(db, "games", "host_user_id"): + db.execute(text("UPDATE games SET host_user_id = host_user_id WHERE host_user_id IS NULL OR host_user_id = ''")) + db.commit() + except Exception: + db.rollback() + + # --- backfill membership: ensure owner is member --- + # uses ORM; only relies on existing table GameMember (create_all already ran) + try: + all_games = db.query(Game).all() + for g in all_games: + host_id = getattr(g, "host_user_id", None) + if not host_id: + continue + exists = ( + db.query(GameMember) + .filter(GameMember.game_id == g.id, GameMember.user_id == host_id) + .first() + ) + if not exists: + db.add(GameMember(game_id=g.id, user_id=host_id)) + db.commit() + except Exception: + db.rollback() + + # --- mirror chip_code -> chip if both exist and chip empty --- + if has_chip_code and has_chip: + try: + db.execute(text("UPDATE sheet_state SET chip = chip_code WHERE (chip IS NULL OR chip = '') AND chip_code IS NOT NULL AND chip_code <> ''")) + db.commit() + except Exception: + db.rollback() + + def seed_entries(db: Session): - # Du kannst hier deine HP-Edition Einträge reinschreiben. - # (Für rein private Nutzung ok – öffentlich würde ich’s generisch machen.) if db.query(Entry).count() > 0: return - suspects = ["Draco Malfoy","Crabbe & Goyle","Lucius Malfoy","Dolores Umbridge","Peter Pettigrew","Bellatrix Lestrange"] - items = ["Schlaftrunk","Verschwindekabinett","Portschlüssel","Impedimenta","Petrificus Totalus","Alraune"] - locations = ["Große Halle","Krankenflügel","Raum der Wünsche","Klassenzimmer für Zaubertränke","Pokalszimmer","Klassenzimmer für Wahrsagen","Eulerei","Bibliothek","Verteidigung gegen die dunklen Künste"] + suspects = ["Draco Malfoy", "Crabbe & Goyle", "Lucius Malfoy", "Dolores Umbridge", "Peter Pettigrew", "Bellatrix Lestrange"] + items = ["Schlaftrunk", "Verschwindekabinett", "Portschlüssel", "Impedimenta", "Petrificus Totalus", "Alraune"] + locations = ["Große Halle", "Krankenflügel", "Raum der Wünsche", "Klassenzimmer für Zaubertränke", "Pokalszimmer", "Klassenzimmer für Wahrsagen", "Eulerei", "Bibliothek", "Verteidigung gegen die dunklen Künste"] for s in suspects: db.add(Entry(category=Category.suspect.value, label=s)) @@ -41,19 +275,32 @@ def seed_entries(db: Session): db.add(Entry(category=Category.location.value, label=l)) db.commit() + def ensure_admin(db: Session): admin_email = os.environ.get("ADMIN_EMAIL", "admin@local").lower().strip() admin_pw = os.environ.get("ADMIN_PASSWORD", "ChangeMeNow123!") u = db.query(User).filter(User.email == admin_email).first() if not u: - db.add(User(email=admin_email, password_hash=hash_password(admin_pw), role=Role.admin.value)) + db.add( + User( + email=admin_email, + password_hash=hash_password(admin_pw), + role=Role.admin.value, + theme_key="default", + display_name="Admin", + ) + ) db.commit() + @app.on_event("startup") def on_startup(): + # create new tables Base.metadata.create_all(bind=engine) + db = SessionLocal() try: + _auto_migrate(db) ensure_admin(db) seed_entries(db) finally: diff --git a/backend/app/models.py b/backend/app/models.py index 521b47a..4c2f286 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,19 +1,31 @@ +# backend/app/models.py import enum import uuid -from sqlalchemy import String, Boolean, DateTime, ForeignKey, Integer, SmallInteger, UniqueConstraint -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import ( + String, + Boolean, + DateTime, + ForeignKey, + Integer, + SmallInteger, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.sql import func from .db import Base + class Role(str, enum.Enum): admin = "admin" user = "user" + class Category(str, enum.Enum): suspect = "suspect" item = "item" location = "location" + class User(Base): __tablename__ = "users" id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) @@ -23,29 +35,55 @@ class User(Base): disabled: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) + theme_key: Mapped[str] = mapped_column(String, default="default") + + # NEW: schöner Name für UI (TopBar / WinnerBadge) + display_name: Mapped[str] = mapped_column(String, default="") + + class Game(Base): __tablename__ = "games" id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True) + + host_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True) + name: Mapped[str] = mapped_column(String) seed: Mapped[int] = mapped_column(Integer) created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) + code: Mapped[str] = mapped_column(String, unique=True, index=True) + + winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True) + + +class GameMember(Base): + __tablename__ = "game_members" + __table_args__ = (UniqueConstraint("game_id", "user_id", name="uq_game_member"),) + + id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True) + user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True) + joined_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + class Entry(Base): __tablename__ = "entries" id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) category: Mapped[str] = mapped_column(String, index=True) label: Mapped[str] = mapped_column(String) + class SheetState(Base): __tablename__ = "sheet_state" - __table_args__ = ( - UniqueConstraint("game_id", "owner_user_id", "entry_id", name="uq_sheet"), - ) + __table_args__ = (UniqueConstraint("game_id", "owner_user_id", "entry_id", name="uq_sheet"),) + id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True) owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True) entry_id: Mapped[str] = mapped_column(String, ForeignKey("entries.id"), index=True) - status: Mapped[int] = mapped_column(SmallInteger, default=0) # 0 unknown, 1 crossed, 2 confirmed - note_tag: Mapped[str | None] = mapped_column(String, nullable=True) # null | 'i' | 'm' | 's' + + status: Mapped[int] = mapped_column(SmallInteger, default=0) + note_tag: Mapped[str | None] = mapped_column(String, nullable=True) + + chip: Mapped[str | None] = mapped_column(String, nullable=True) \ No newline at end of file diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index b609c14..fbcb723 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -19,21 +19,53 @@ def require_admin(req: Request, db: Session) -> User: def list_users(req: Request, db: Session = Depends(get_db)): require_admin(req, db) users = db.query(User).order_by(User.created_at.desc()).all() - return [{"id": u.id, "email": u.email, "role": u.role, "disabled": u.disabled} for u in users] + return [ + { + "id": u.id, + "email": u.email, + "display_name": u.display_name, + "role": u.role, + "disabled": u.disabled, + } + for u in users + ] @router.post("/users") def create_user(req: Request, data: dict, db: Session = Depends(get_db)): require_admin(req, db) email = (data.get("email") or "").lower().strip() password = data.get("password") or "" + display_name = (data.get("display_name") or "").strip() + if not email or not password: raise HTTPException(400, "email/password required") if db.query(User).filter(User.email == email).first(): raise HTTPException(409, "email exists") + role = data.get("role") or Role.user.value if role not in (Role.admin.value, Role.user.value): raise HTTPException(400, "invalid role") - u = User(email=email, password_hash=hash_password(password), role=role) + + u = User(email=email, password_hash=hash_password(password), role=role, display_name=display_name) db.add(u); db.commit() return {"ok": True, "id": u.id} - \ No newline at end of file + +@router.delete("/users/{user_id}") +def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)): + admin = require_admin(req, db) + + if admin.id == user_id: + raise HTTPException(400, "cannot delete yourself") + + u = db.query(User).filter(User.id == user_id).first() + if not u: + raise HTTPException(404, "not found") + + if u.role == Role.admin.value: + raise HTTPException(400, "cannot delete admin user") + + # soft delete + u.disabled = True + db.add(u) + db.commit() + return {"ok": True} \ No newline at end of file diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 6ece26a..263db56 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -2,10 +2,18 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response from sqlalchemy.orm import Session from ..db import get_db from ..models import User -from ..security import verify_password, make_session_value, set_session, clear_session, get_session_user_id, hash_password +from ..security import ( + verify_password, + make_session_value, + set_session, + clear_session, + get_session_user_id, + hash_password, +) router = APIRouter(prefix="/auth", tags=["auth"]) + @router.post("/login") def login(data: dict, resp: Response, db: Session = Depends(get_db)): email = (data.get("email") or "").lower().strip() @@ -13,14 +21,17 @@ def login(data: dict, resp: Response, db: Session = Depends(get_db)): user = db.query(User).filter(User.email == email, User.disabled == False).first() if not user or not verify_password(password, user.password_hash): raise HTTPException(status_code=401, detail="invalid credentials") + set_session(resp, make_session_value(user.id)) - return {"ok": True, "role": user.role, "email": user.email} + return {"ok": True, "role": user.role, "email": user.email, "theme_key": user.theme_key} + @router.post("/logout") def logout(resp: Response): clear_session(resp) return {"ok": True} + @router.get("/me") def me(req: Request, db: Session = Depends(get_db)): uid = get_session_user_id(req) @@ -29,8 +40,45 @@ def me(req: Request, db: Session = Depends(get_db)): user = db.query(User).filter(User.id == uid).first() if not user: raise HTTPException(status_code=401, detail="not logged in") - return {"id": user.id, "email": user.email, "role": user.role} + return {"id": user.id, "email": user.email, "role": user.role, "display_name": user.display_name, "theme_key": user.theme_key} + +@router.get("/me/stats") +def my_stats(req: Request, db: Session = Depends(get_db)): + uid = get_session_user_id(req) + if not uid: + raise HTTPException(status_code=401, detail="not logged in") + + # "played" = games where user is member AND winner is set (finished games) + from sqlalchemy import func + from ..models import Game, GameMember + + played = ( + db.query(func.count(Game.id)) + .join(GameMember, GameMember.game_id == Game.id) + .filter(GameMember.user_id == uid, Game.winner_user_id != None) + .scalar() + or 0 + ) + + wins = ( + db.query(func.count(Game.id)) + .join(GameMember, GameMember.game_id == Game.id) + .filter(GameMember.user_id == uid, Game.winner_user_id == uid) + .scalar() + or 0 + ) + + losses = max(int(played) - int(wins), 0) + winrate = (float(wins) / float(played) * 100.0) if played else 0.0 + + return { + "played": int(played), + "wins": int(wins), + "losses": int(losses), + "winrate": round(winrate, 1), + } + @router.patch("/password") def set_password(data: dict, req: Request, db: Session = Depends(get_db)): uid = get_session_user_id(req) @@ -48,5 +96,26 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)): user.password_hash = hash_password(password) db.add(user) db.commit() + return {"ok": True} - return {"ok": True} \ No newline at end of file + +@router.patch("/theme") +def set_theme(data: dict, req: Request, db: Session = Depends(get_db)): + uid = get_session_user_id(req) + if not uid: + raise HTTPException(status_code=401, detail="not logged in") + + theme_key = (data.get("theme_key") or "").strip() + if not theme_key: + raise HTTPException(status_code=400, detail="theme_key required") + + user = db.query(User).filter(User.id == uid, User.disabled == False).first() + if not user: + raise HTTPException(status_code=401, detail="not logged in") + + user.theme_key = theme_key + db.add(user) + db.commit() + + return {"ok": True, "theme_key": user.theme_key} + \ No newline at end of file diff --git a/backend/app/routes/games.py b/backend/app/routes/games.py index 2e7fb99..0cc7e7c 100644 --- a/backend/app/routes/games.py +++ b/backend/app/routes/games.py @@ -2,45 +2,204 @@ import hashlib, random from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session from ..db import get_db -from ..models import Game, Entry, SheetState, Category +from ..models import Game, Entry, SheetState, Category, GameMember, User, Role from ..security import get_session_user_id router = APIRouter(prefix="/games", tags=["games"]) + def require_user(req: Request, db: Session): uid = get_session_user_id(req) if not uid: raise HTTPException(status_code=401, detail="not logged in") return uid + def stable_order(seed: int, user_id: str, entry_id: str) -> str: s = f"{seed}:{user_id}:{entry_id}".encode() return hashlib.sha256(s).hexdigest() + +CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" + + +def gen_code(n=6) -> str: + return "".join(random.choice(CODE_ALPHABET) for _ in range(n)) + + +def ensure_member(db: Session, game_id: str, user_id: str): + ex = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first() + if ex: + return + db.add(GameMember(game_id=game_id, user_id=user_id)) + db.commit() + + +def require_game_member(db: Session, game_id: str, user_id: str) -> Game: + g = db.query(Game).filter(Game.id == game_id).first() + if not g: + raise HTTPException(404, "game not found") + + mem = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first() + if not mem: + raise HTTPException(403, "not a member of this game") + return g + + @router.post("") def create_game(req: Request, data: dict, db: Session = Depends(get_db)): uid = require_user(req, db) + name = data.get("name") or "Neues Spiel" seed = random.randint(1, 2_000_000_000) - g = Game(owner_user_id=uid, name=name, seed=seed) - db.add(g); db.commit() - return {"id": g.id, "name": g.name} + + # unique code + code = gen_code() + while db.query(Game).filter(Game.code == code).first(): + code = gen_code() + + g = Game(host_user_id=uid, name=name, seed=seed, code=code, winner_user_id=None) + db.add(g) + db.commit() + + # creator joins automatically + ensure_member(db, g.id, uid) + + return {"id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id} + + +@router.post("/join") +def join_game(req: Request, data: dict, db: Session = Depends(get_db)): + uid = require_user(req, db) + code = (data.get("code") or "").strip().upper() + if not code: + raise HTTPException(400, "code required") + + g = db.query(Game).filter(Game.code == code).first() + if not g: + raise HTTPException(404, "game not found") + + ensure_member(db, g.id, uid) + return {"ok": True, "id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id} + @router.get("") def list_games(req: Request, db: Session = Depends(get_db)): uid = require_user(req, db) - games = db.query(Game).filter(Game.owner_user_id == uid).order_by(Game.created_at.desc()).all() - return [{"id": g.id, "name": g.name, "seed": g.seed} for g in games] + + # list games where user is member + q = ( + db.query(Game) + .join(GameMember, GameMember.game_id == Game.id) + .filter(GameMember.user_id == uid) + .order_by(Game.created_at.desc()) + ) + games = q.all() + + # winner email (optional) + out = [] + for g in games: + winner_email = None + if g.winner_user_id: + wu = db.query(User).filter(User.id == g.winner_user_id).first() + winner_email = wu.email if wu else None + out.append( + { + "id": g.id, + "name": g.name, + "seed": g.seed, + "code": g.code, + "host_user_id": g.host_user_id, + "winner_user_id": g.winner_user_id, + "winner_email": winner_email, + } + ) + return out + + +@router.get("/{game_id}") +def get_game_meta(req: Request, game_id: str, db: Session = Depends(get_db)): + uid = require_user(req, db) + g = require_game_member(db, game_id, uid) + + winner_email = None + winner_display_name = None + + if g.winner_user_id: + wu = db.query(User).filter(User.id == g.winner_user_id).first() + if wu: + winner_email = wu.email + winner_display_name = wu.display_name + + return { + "id": g.id, + "name": g.name, + "code": g.code, + "host_user_id": g.host_user_id, + "winner_user_id": g.winner_user_id, + "winner_email": winner_email, + "winner_display_name": winner_display_name, + } + + +@router.get("/{game_id}/members") +def list_members(req: Request, game_id: str, db: Session = Depends(get_db)): + uid = require_user(req, db) + _g = require_game_member(db, game_id, uid) + + # return only "user" role (admin excluded) + members = ( + db.query(User) + .join(GameMember, GameMember.user_id == User.id) + .filter(GameMember.game_id == game_id, User.role == Role.user.value, User.disabled == False) + .order_by(User.email.asc()) + .all() + ) + return [{"id": u.id, "email": u.email, "display_name": u.display_name} for u in members] + + +@router.patch("/{game_id}/winner") +def set_winner(req: Request, game_id: str, data: dict, db: Session = Depends(get_db)): + uid = require_user(req, db) + g = require_game_member(db, game_id, uid) + + # only host can set winner + if g.host_user_id != uid: + raise HTTPException(403, "only host can set winner") + + winner_user_id = data.get("winner_user_id") + if winner_user_id is None: + g.winner_user_id = None + db.add(g) + db.commit() + return {"ok": True, "winner_user_id": None} + + # must be a member AND role=user + member = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == winner_user_id).first() + if not member: + raise HTTPException(400, "winner must be a member of the game") + + u = db.query(User).filter(User.id == winner_user_id).first() + if not u or u.role != Role.user.value or u.disabled: + raise HTTPException(400, "invalid winner") + + g.winner_user_id = winner_user_id + db.add(g) + db.commit() + return {"ok": True, "winner_user_id": g.winner_user_id} + @router.get("/{game_id}/sheet") def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)): uid = require_user(req, db) - g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first() - if not g: - raise HTTPException(404, "game not found") + g = require_game_member(db, game_id, uid) entries = db.query(Entry).all() - states = db.query(SheetState).filter(SheetState.game_id == g.id, SheetState.owner_user_id == uid).all() + states = ( + db.query(SheetState) + .filter(SheetState.game_id == g.id, SheetState.owner_user_id == uid) + .all() + ) state_map = {st.entry_id: st for st in states} out = {"suspect": [], "item": [], "location": []} @@ -51,11 +210,11 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)): "label": e.label, "status": st.status if st else 0, "note_tag": st.note_tag if st else None, + "chip": st.chip if st else None, # NEW "order": stable_order(g.seed, uid, e.id), } out[e.category].append(item) - # sort within category for k in out: out[k].sort(key=lambda x: x["order"]) for i in out[k]: @@ -63,29 +222,41 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)): return out + @router.patch("/{game_id}/sheet/{entry_id}") def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Session = Depends(get_db)): uid = require_user(req, db) - g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first() - if not g: - raise HTTPException(404, "game not found") + g = require_game_member(db, game_id, uid) status = data.get("status") note_tag = data.get("note_tag") + chip = data.get("chip") if note_tag not in (None, "i", "m", "s"): raise HTTPException(400, "invalid note_tag") if status is not None and status not in (0, 1, 2, 3): raise HTTPException(400, "invalid status") - st = db.query(SheetState).filter( - SheetState.game_id == g.id, - SheetState.owner_user_id == uid, - SheetState.entry_id == entry_id - ).first() + if chip is not None: + chip = (chip or "").strip().upper() + if chip == "": + chip = None + if chip is not None: + if len(chip) > 8: + raise HTTPException(400, "invalid chip") + + st = ( + db.query(SheetState) + .filter( + SheetState.game_id == g.id, + SheetState.owner_user_id == uid, + SheetState.entry_id == entry_id, + ) + .first() + ) if not st: - st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None) + st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None, chip=None) db.add(st) if status is not None: @@ -93,6 +264,17 @@ def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Sessi if "note_tag" in data: st.note_tag = note_tag + # wenn note_tag zurück auf null -> chip auch löschen + if note_tag is None: + st.chip = None + + # chip nur speichern wenn note_tag "s" ist (ansonsten löschen wir es) + if "chip" in data: + if st.note_tag == "s": + st.chip = chip + else: + st.chip = None + db.commit() return {"ok": True} \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e637ae2..fd762f8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,16 +1,12 @@ -// src/App.jsx import React, { useEffect, useState } from "react"; import { api } from "./api/client"; import { cycleTag } from "./utils/cycleTag"; import { getChipLS, setChipLS, clearChipLS } from "./utils/chipStorage"; -import { getWinnerLS, setWinnerLS, clearWinnerLS } from "./utils/winnerStorage"; - import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles"; import { styles } from "./styles/styles"; - -import { applyTheme, loadThemeKey, saveThemeKey, DEFAULT_THEME_KEY } from "./styles/themes"; +import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes"; import AdminPanel from "./components/AdminPanel"; import LoginPage from "./components/LoginPage"; @@ -23,6 +19,8 @@ import SheetSection from "./components/SheetSection"; import DesignModal from "./components/DesignModal"; import WinnerCard from "./components/WinnerCard"; import WinnerBadge from "./components/WinnerBadge"; +import NewGameModal from "./components/NewGameModal"; +import StatsModal from "./components/StatsModal"; export default function App() { useHpGlobalStyles(); @@ -39,15 +37,17 @@ export default function App() { const [sheet, setSheet] = useState(null); const [pulseId, setPulseId] = useState(null); - // Winner (per game) - const [winnerName, setWinnerName] = useState(""); + // Game meta + const [gameMeta, setGameMeta] = useState(null); // {code, host_user_id, winner_email, winner_user_id} + const [members, setMembers] = useState([]); + + // Winner selection (host only) + const [winnerUserId, setWinnerUserId] = useState(""); // Modals const [helpOpen, setHelpOpen] = useState(false); - const [chipOpen, setChipOpen] = useState(false); const [chipEntry, setChipEntry] = useState(null); - const [userMenuOpen, setUserMenuOpen] = useState(false); const [pwOpen, setPwOpen] = useState(false); @@ -60,13 +60,20 @@ export default function App() { const [designOpen, setDesignOpen] = useState(false); const [themeKey, setThemeKey] = useState(DEFAULT_THEME_KEY); - // ===== Data loaders ===== + // New Game Modal + const [newGameOpen, setNewGameOpen] = useState(false); + + // ===== Stats Modal ===== + const [statsOpen, setStatsOpen] = useState(false); + const [stats, setStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(false); + const [statsError, setStatsError] = useState(""); + const load = async () => { const m = await api("/auth/me"); setMe(m); - // Theme pro User laden & anwenden - const tk = loadThemeKey(m?.email); + const tk = m?.theme_key || DEFAULT_THEME_KEY; setThemeKey(tk); applyTheme(tk); @@ -82,7 +89,15 @@ export default function App() { setSheet(sh); }; - // ===== Effects ===== + const loadGameMeta = async () => { + if (!gameId) return; + const meta = await api(`/games/${gameId}`); + setGameMeta(meta); + setWinnerUserId(meta?.winner_user_id || ""); + + const mem = await api(`/games/${gameId}/members`); + setMembers(mem); + }; // Dropdown outside click useEffect(() => { @@ -94,35 +109,58 @@ export default function App() { return () => document.removeEventListener("mousedown", onDown); }, [userMenuOpen]); - // initial load (try session) + // initial load useEffect(() => { (async () => { try { await load(); - } catch { - // not logged in - } + } catch {} })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // load sheet + winner when game changes + // on game change useEffect(() => { (async () => { if (!gameId) return; - try { await reloadSheet(); - } catch { - // ignore - } - - // Sieger pro Game aus localStorage laden - setWinnerName(getWinnerLS(gameId)); + await loadGameMeta(); + } catch {} })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [gameId]); + // ✅ Live refresh (Members/Meta) – damit neue Joiner ohne Reload sichtbar sind + // Für 5–6 Spieler reicht 2.5s völlig, ist "live genug" und schont Backend. + useEffect(() => { + if (!me || !gameId) return; + + let alive = true; + + const tick = async () => { + try { + await loadGameMeta(); // refresh members + winner meta + } catch { + // ignore + } + }; + + // sofort einmal ziehen + tick(); + + const id = setInterval(() => { + if (!alive) return; + tick(); + }, 2500); + + return () => { + alive = false; + clearInterval(id); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [me?.id, gameId]); + // ===== Auth actions ===== const doLogin = async () => { await api("/auth/login", { @@ -138,10 +176,12 @@ export default function App() { setGames([]); setGameId(null); setSheet(null); - setWinnerName(""); + setGameMeta(null); + setMembers([]); + setWinnerUserId(""); }; - // ===== Password change ===== + // ===== Password ===== const openPwModal = () => { setPwMsg(""); setPw1(""); @@ -178,20 +218,60 @@ export default function App() { } }; - // ===== Theme actions ===== + // ===== Theme ===== const openDesignModal = () => { setDesignOpen(true); setUserMenuOpen(false); }; - const selectTheme = (key) => { + const selectTheme = async (key) => { setThemeKey(key); applyTheme(key); - saveThemeKey(me?.email, key); + + try { + await api("/auth/theme", { + method: "PATCH", + body: JSON.stringify({ theme_key: key }), + }); + } catch { + // theme locally already applied; ignore backend error + } }; - // ===== Game actions ===== - const newGame = async () => { + // ===== Stats (always fresh on open) ===== + const openStatsModal = async () => { + setUserMenuOpen(false); + setStatsOpen(true); + setStatsError(""); + setStatsLoading(true); + + try { + const s = await api("/auth/me/stats"); + setStats(s); + } catch (e) { + setStats(null); + setStatsError("❌ Fehler: " + (e?.message || "unknown")); + } finally { + setStatsLoading(false); + } + }; + + const closeStatsModal = () => { + setStatsOpen(false); + setStatsError(""); + }; + + // ===== New game flow ===== + const createGame = async () => { + // ✅ wichtig: alten Game-State weg, damit nix "hängen" bleibt + setSheet(null); + setGameMeta(null); + setMembers([]); + setWinnerUserId(""); + setPulseId(null); + setChipOpen(false); + setChipEntry(null); + const g = await api("/games", { method: "POST", body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }), @@ -199,26 +279,32 @@ export default function App() { const gs = await api("/games"); setGames(gs); + + // ✅ auf neues Spiel wechseln (triggered dann reloadSheet/loadGameMeta via effect) setGameId(g.id); - // Neues Spiel -> Sieger leer - clearWinnerLS(g.id); - setWinnerName(""); + return g; // includes code }; - // ===== Winner actions ===== - const saveWinner = () => { + const joinGame = async (code) => { + const res = await api("/games/join", { + method: "POST", + body: JSON.stringify({ code }), + }); + + const gs = await api("/games"); + setGames(gs); + setGameId(res.id); + }; + + // ===== Winner ===== + const saveWinner = async () => { if (!gameId) return; - const v = (winnerName || "").trim(); - - if (!v) { - clearWinnerLS(gameId); - setWinnerName(""); - return; - } - - setWinnerLS(gameId, v); - setWinnerName(v); + await api(`/games/${gameId}/winner`, { + method: "PATCH", + body: JSON.stringify({ winner_user_id: winnerUserId || null }), + }); + await loadGameMeta(); }; // ===== Sheet actions ===== @@ -252,7 +338,7 @@ export default function App() { await api(`/games/${gameId}/sheet/${entry.entry_id}`, { method: "PATCH", - body: JSON.stringify({ note_tag: next }), + body: JSON.stringify({ note_tag: next, chip: null }), }); await reloadSheet(); @@ -270,7 +356,7 @@ export default function App() { try { await api(`/games/${gameId}/sheet/${entry.entry_id}`, { method: "PATCH", - body: JSON.stringify({ note_tag: "s" }), + body: JSON.stringify({ note_tag: "s", chip }), }); } finally { await reloadSheet(); @@ -292,7 +378,7 @@ export default function App() { try { await api(`/games/${gameId}/sheet/${entry.entry_id}`, { method: "PATCH", - body: JSON.stringify({ note_tag: null }), + body: JSON.stringify({ note_tag: null, chip: null }), }); } finally { await reloadSheet(); @@ -302,11 +388,14 @@ export default function App() { const displayTag = (entry) => { const t = entry.note_tag; if (!t) return "—"; + if (t === "s") { - const chip = getChipLS(gameId, entry.entry_id); + // Prefer backend chip, fallback localStorage + const chip = entry.chip || getChipLS(gameId, entry.entry_id); return chip ? `s.${chip}` : "s"; } - return t; + + return t; // i oder m }; // ===== Login page ===== @@ -332,6 +421,8 @@ export default function App() { ] : []; + const isHost = !!(me?.id && gameMeta?.host_user_id && me.id === gameMeta.host_user_id); + return (