diff --git a/Dockerfile b/Dockerfile index 623ddef..c29da6b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,4 +21,4 @@ RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison \ EXPOSE 8000 -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"] +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"] diff --git a/backend/auth.py b/backend/auth.py index b3365aa..d923c70 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -87,7 +87,7 @@ def get_current_user( user_id = int(payload["sub"]) with db() as conn: row = conn.execute( - "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status FROM users WHERE id=?", + "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner FROM users WHERE id=?", (user_id,) ).fetchone() diff --git a/backend/database.py b/backend/database.py index 940c76f..e428a56 100644 --- a/backend/database.py +++ b/backend/database.py @@ -559,6 +559,10 @@ def _migrate(conn_factory): ("users", "ki_zucht_paarung", "INTEGER NOT NULL DEFAULT 1"), ("users", "ki_zucht_beschreibung", "INTEGER NOT NULL DEFAULT 1"), ("users", "ki_zucht_jahresbericht", "INTEGER NOT NULL DEFAULT 1"), + # Partner-Code + Gründer-Lizenz + ("users", "is_founder", "INTEGER NOT NULL DEFAULT 0"), + ("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"), + ("users", "founder_number", "INTEGER"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -1485,6 +1489,25 @@ def _migrate(conn_factory): CREATE INDEX IF NOT EXISTS idx_bj_user ON breeder_jahresberichte(user_id, jahr DESC); """) + # Partner-Codes (Influencer-Kooperationen + Gründer-Lizenz) + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS partner_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE COLLATE NOCASE, + label TEXT NOT NULL, + grants_founder INTEGER NOT NULL DEFAULT 1, + max_uses INTEGER DEFAULT NULL, + uses INTEGER NOT NULL DEFAULT 0, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_partner_codes_code ON partner_codes(code); + """) + logger.info("Migration: partner_codes Tabelle bereit.") + except Exception as e: + logger.warning(f"Migration partner_codes: {e}") + # js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()] if 'js_exercise_id' not in existing_te: diff --git a/backend/main.py b/backend/main.py index fbae790..db883dc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -162,6 +162,7 @@ from routes.breeder_photos import router as breeder_photos_router from routes.zucht_hunde import router as zucht_hunde_router from routes.breeder_export import router as breeder_export_router from routes.zucht_ki import router as zucht_ki_router +from routes.partner import router as partner_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -193,6 +194,7 @@ app.include_router(breeder_photos_router, prefix="/api", tags=["Züchter- app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkartei"]) app.include_router(breeder_export_router, prefix="/api", tags=["Export"]) app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"]) +app.include_router(partner_router, prefix="/api", tags=["Partner"]) app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"]) app.include_router(profile_router, prefix="/api/profile", tags=["Profil"]) app.include_router(import_router, prefix="/api/import", tags=["Import"]) @@ -691,7 +693,26 @@ async def favicon(): @app.get("/manifest.json") async def manifest(): - return FileResponse(f"{STATIC_DIR}/manifest.json") + import json as _json + IS_STAGING = os.getenv("STAGING", "false").lower() == "true" + with open(f"{STATIC_DIR}/manifest.json", encoding="utf-8") as f: + data = _json.load(f) + if IS_STAGING: + data["name"] = "Ban Yaro Staging" + data["short_name"] = "BY ⚗️" + data["theme_color"] = "#7c3aed" + data["background_color"] = "#2d1b69" + data["id"] = "/staging" + # Icons mit Staging-Variante überschreiben falls vorhanden + staging_icon = "/icons/icon-192-staging.png" + import os as _os + if _os.path.exists(f"{STATIC_DIR}/icons/icon-192-staging.png"): + data["icons"] = [ + {"src": "/icons/icon-192-staging.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable"}, + {"src": "/icons/icon-512-staging.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable"}, + ] + from fastapi.responses import JSONResponse + return JSONResponse(content=data, media_type="application/manifest+json") @app.get("/sw.js") async def service_worker(): @@ -1382,6 +1403,16 @@ async def knigge_page(): # SPA Fallback — ALLE nicht-API-Routen gehen zur index.html @app.get("/{full_path:path}") async def spa_fallback(full_path: str): + IS_STAGING = os.getenv("STAGING", "false").lower() == "true" + if IS_STAGING: + from fastapi.responses import HTMLResponse + with open(f"{STATIC_DIR}/index.html", encoding="utf-8") as f: + html = f.read() + html = html.replace( + '', + '', + ) + return HTMLResponse(content=html, headers={"Cache-Control": "no-cache"}) return FileResponse( f"{STATIC_DIR}/index.html", headers={"Cache-Control": "no-cache"} diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 73349b4..32d5c6c 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -78,13 +78,42 @@ async def register(data: RegisterRequest, response: Response, request: Request): new_user_id = user["id"] if data.ref_code: - referrer = conn.execute( - "SELECT id FROM users WHERE referral_code=? AND id != ?", - (data.ref_code.strip().upper(), new_user_id) + code_upper = data.ref_code.strip().upper() + # Zuerst prüfen ob es ein Partner-Code ist + partner = conn.execute( + "SELECT id, grants_founder, max_uses, uses FROM partner_codes WHERE code=?", + (code_upper,) ).fetchone() - if referrer: - conn.execute("UPDATE users SET referred_by=? WHERE id=?", - (referrer['id'], new_user_id)) + if partner: + # Nur einlösen wenn max_uses nicht erreicht + if partner["max_uses"] is None or partner["uses"] < partner["max_uses"]: + conn.execute( + "UPDATE partner_codes SET uses=uses+1 WHERE id=?", + (partner["id"],) + ) + updates = {"referred_by": -partner["id"]} + if partner["grants_founder"]: + total_founders = conn.execute( + "SELECT COUNT(*) FROM users WHERE is_founder=1" + ).fetchone()[0] + if total_founders < 100: + founder_num = total_founders + 1 + updates["is_founder"] = 1 + updates["founder_number"] = founder_num + set_clause = ", ".join(f"{k}=?" for k in updates) + conn.execute( + f"UPDATE users SET {set_clause} WHERE id=?", + (*updates.values(), new_user_id) + ) + else: + # Fallback: Referral-Code eines anderen Users + referrer = conn.execute( + "SELECT id FROM users WHERE referral_code=? AND id != ?", + (code_upper, new_user_id) + ).fetchone() + if referrer: + conn.execute("UPDATE users SET referred_by=? WHERE id=?", + (referrer['id'], new_user_id)) token = create_token(user["id"], user["rolle"]) _set_cookie(response, token) @@ -120,6 +149,24 @@ async def logout(response: Response): return {"ok": True} +_REFERRAL_TIERS = [ + (50, 50), + (20, 30), + (10, 20), +] + +def _referral_tier(count: int): + for threshold, discount in _REFERRAL_TIERS: + if count >= threshold: + return discount + return 0 + +def _referral_next(count: int): + for threshold, discount in reversed(_REFERRAL_TIERS): + if count < threshold: + return {"count": threshold, "discount": discount} + return None # Maximalstufe erreicht + @router.get("/referral") async def get_referral_info(user=Depends(get_current_user)): with db() as conn: @@ -133,11 +180,14 @@ async def get_referral_info(user=Depends(get_current_user)): if not code: code = _gen_referral_code() conn.execute("UPDATE users SET referral_code=? WHERE id=?", (code, user['id'])) - base = os.getenv("APP_URL", "https://banyaro.app") + count = row["count"] if row else 0 + base = os.getenv("APP_URL", "https://banyaro.app") return { - "code": code, - "count": row["count"] if row else 0, - "link": f"{base}/?ref={code}", + "code": code, + "count": count, + "link": f"{base}/?ref={code}", + "discount_pct": _referral_tier(count), + "next_tier": _referral_next(count), } diff --git a/backend/routes/forum.py b/backend/routes/forum.py index d22a460..b6d204f 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -131,7 +131,7 @@ async def list_threads( t.antworten, t.likes, t.views, t.is_pinned, t.is_locked, t.foto_urls, t.created_at, t.user_id, - u.name AS autor_name + u.name AS autor_name, u.founder_number AS autor_founder_number FROM forum_threads t LEFT JOIN users u ON u.id = t.user_id WHERE t.is_deleted = 0 @@ -183,7 +183,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)): data.thread_lat, data.thread_lon, data.thread_ort, ct) ) row = conn.execute( - """SELECT t.*, u.name AS autor_name + """SELECT t.*, u.name AS autor_name, u.founder_number AS autor_founder_number FROM forum_threads t LEFT JOIN users u ON u.id = t.user_id WHERE t.id = ?""", @@ -203,7 +203,7 @@ async def get_thread(thread_id: int, user=Depends(get_current_user_optional)): uid = user['id'] if user else None with db() as conn: thread = conn.execute( - """SELECT t.*, u.name AS autor_name + """SELECT t.*, u.name AS autor_name, u.founder_number AS autor_founder_number FROM forum_threads t LEFT JOIN users u ON u.id = t.user_id WHERE t.id = ? AND t.is_deleted = 0""", @@ -218,7 +218,7 @@ async def get_thread(thread_id: int, user=Depends(get_current_user_optional)): ) posts = conn.execute( - """SELECT p.*, u.name AS autor_name + """SELECT p.*, u.name AS autor_name, u.founder_number AS autor_founder_number FROM forum_posts p LEFT JOIN users u ON u.id = p.user_id WHERE p.thread_id = ? @@ -288,7 +288,7 @@ async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_curre [*updates.values(), thread_id] ) row = conn.execute( - """SELECT t.*, u.name AS autor_name + """SELECT t.*, u.name AS autor_name, u.founder_number AS autor_founder_number FROM forum_threads t LEFT JOIN users u ON u.id = t.user_id WHERE t.id = ?""", @@ -328,7 +328,7 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current (thread_id,) ) row = conn.execute( - """SELECT p.*, u.name AS autor_name + """SELECT p.*, u.name AS autor_name, u.founder_number AS autor_founder_number FROM forum_posts p LEFT JOIN users u ON u.id = p.user_id WHERE p.id = ?""", diff --git a/backend/routes/partner.py b/backend/routes/partner.py new file mode 100644 index 0000000..745fde7 --- /dev/null +++ b/backend/routes/partner.py @@ -0,0 +1,192 @@ +"""BAN YARO — Partner-Codes + Gründer-Lizenz""" + +from typing import Optional +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel +from database import db +from auth import require_admin, get_current_user + +router = APIRouter() + + +class PartnerCodeCreate(BaseModel): + code: str + label: str + grants_founder: int = 1 + max_uses: Optional[int] = None + + +class GrantRequest(BaseModel): + is_founder: Optional[int] = None + is_partner: Optional[int] = None + + +# ------------------------------------------------------------------ +# Admin: Partner-Codes verwalten +# ------------------------------------------------------------------ + +@router.get("/admin/partner/codes") +def list_partner_codes(user=Depends(require_admin)): + """Alle Partner-Codes mit Stats (admin only).""" + with db() as conn: + rows = conn.execute( + """SELECT pc.id, pc.code, pc.label, pc.grants_founder, + pc.max_uses, pc.uses, pc.created_at, + u.name AS created_by_name + FROM partner_codes pc + LEFT JOIN users u ON u.id = pc.created_by + ORDER BY pc.created_at DESC""" + ).fetchall() + return [dict(r) for r in rows] + + +@router.post("/admin/partner/codes", status_code=201) +def create_partner_code(data: PartnerCodeCreate, user=Depends(require_admin)): + """Neuen Partner-Code erstellen (admin only).""" + code = data.code.strip().upper() + if not code: + raise HTTPException(400, "Code darf nicht leer sein.") + with db() as conn: + existing = conn.execute( + "SELECT id FROM partner_codes WHERE code=?", (code,) + ).fetchone() + if existing: + raise HTTPException(400, "Dieser Code existiert bereits.") + conn.execute( + """INSERT INTO partner_codes (code, label, grants_founder, max_uses, created_by) + VALUES (?, ?, ?, ?, ?)""", + (code, data.label.strip(), data.grants_founder, data.max_uses, user["id"]) + ) + row = conn.execute( + "SELECT * FROM partner_codes WHERE code=?", (code,) + ).fetchone() + return dict(row) + + +@router.delete("/admin/partner/codes/{code_id}", status_code=204) +def delete_partner_code(code_id: int, user=Depends(require_admin)): + """Partner-Code löschen (admin only).""" + with db() as conn: + existing = conn.execute( + "SELECT id FROM partner_codes WHERE id=?", (code_id,) + ).fetchone() + if not existing: + raise HTTPException(404, "Partner-Code nicht gefunden.") + conn.execute("DELETE FROM partner_codes WHERE id=?", (code_id,)) + return None + + +@router.post("/admin/partner/users/{user_id}/grant") +def grant_user_status(user_id: int, data: GrantRequest, user=Depends(require_admin)): + """Founder- und/oder Partner-Status für einen User setzen (admin only).""" + updates = {} + if data.is_founder is not None: + updates["is_founder"] = data.is_founder + if data.is_partner is not None: + updates["is_partner"] = data.is_partner + if not updates: + raise HTTPException(400, "Mindestens is_founder oder is_partner muss angegeben werden.") + with db() as conn: + target = conn.execute( + "SELECT id, is_founder, founder_number FROM users WHERE id=?", (user_id,) + ).fetchone() + if not target: + raise HTTPException(404, "User nicht gefunden.") + # Beim manuellen Vergeben von is_founder: founder_number zuweisen wenn noch keine + if updates.get("is_founder") == 1 and not target["founder_number"]: + total = conn.execute( + "SELECT COUNT(*) FROM users WHERE is_founder=1" + ).fetchone()[0] + if total >= FOUNDER_MAX: + raise HTTPException(400, f"Alle {FOUNDER_MAX} Gründer-Plätze sind vergeben.") + updates["founder_number"] = total + 1 + set_clause = ", ".join(f"{k}=?" for k in updates) + conn.execute( + f"UPDATE users SET {set_clause} WHERE id=?", + (*updates.values(), user_id) + ) + row = conn.execute( + "SELECT id, name, email, is_founder, is_partner, founder_number FROM users WHERE id=?", + (user_id,) + ).fetchone() + return dict(row) + + +@router.get("/admin/users/search") +def search_users(q: str, user=Depends(require_admin)): + """User-Suche für Admin (Name-Präfix, max. 10 Ergebnisse).""" + with db() as conn: + rows = conn.execute( + """SELECT id, name, email, is_founder, is_partner, rolle + FROM users WHERE name LIKE ? COLLATE NOCASE + ORDER BY name LIMIT 10""", + (f"{q}%",) + ).fetchall() + return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# Öffentlich: Gründer-Leaderboard + Code-Info +# ------------------------------------------------------------------ + +FOUNDER_MAX = 100 + +@router.get("/partner/founders/stats") +def founders_stats(): + """Öffentliches Gründer-Leaderboard: Slots, Partner-Ranking, Gründer-Liste.""" + with db() as conn: + total = conn.execute( + "SELECT COUNT(*) FROM users WHERE is_founder=1" + ).fetchone()[0] + # Partner-Ranking: nach uses absteigend + partners = conn.execute( + """SELECT pc.id, pc.label, pc.uses, pc.code, + u.name AS partner_username, u.is_partner + FROM partner_codes pc + LEFT JOIN users u ON u.referred_by = -pc.id AND u.is_partner=1 + WHERE pc.grants_founder=1 + ORDER BY pc.uses DESC""" + ).fetchall() + # Erste 100 Gründer (für Galerie) + founders = conn.execute( + """SELECT u.name, u.founder_number, + pc.label AS via_partner + FROM users u + LEFT JOIN partner_codes pc ON u.referred_by = -pc.id + WHERE u.is_founder=1 AND u.founder_number IS NOT NULL + ORDER BY u.founder_number ASC + LIMIT 100""" + ).fetchall() + return { + "total": total, + "max": FOUNDER_MAX, + "open": max(0, FOUNDER_MAX - total), + "closed": total >= FOUNDER_MAX, + "partners": [dict(p) for p in partners], + "founders": [dict(f) for f in founders], + } + + +@router.get("/partner/codes/{code}/info") +def partner_code_info(code: str): + """Gibt zurück ob ein Partner-Code existiert und dessen Label (öffentlich).""" + with db() as conn: + row = conn.execute( + """SELECT code, label, grants_founder, max_uses, uses + FROM partner_codes WHERE code=?""", + (code.strip().upper(),) + ).fetchone() + if not row: + raise HTTPException(404, "Partner-Code nicht gefunden.") + r = dict(row) + if r["grants_founder"]: + with db() as conn2: + total = conn2.execute( + "SELECT COUNT(*) FROM users WHERE is_founder=1" + ).fetchone()[0] + r["founder_slots_open"] = max(0, FOUNDER_MAX - total) + r["redeemable"] = (r["max_uses"] is None or r["uses"] < r["max_uses"]) and total < FOUNDER_MAX + else: + r["founder_slots_open"] = None + r["redeemable"] = r["max_uses"] is None or r["uses"] < r["max_uses"] + return r diff --git a/backend/static/css/layout.css b/backend/static/css/layout.css index 33fde8e..8ffa323 100644 --- a/backend/static/css/layout.css +++ b/backend/static/css/layout.css @@ -8,25 +8,28 @@ 1. APP SHELL ------------------------------------------------------------ */ #app { - display: flex; + display: flex; flex-direction: column; - min-height: 100dvh; /* dvh: berücksichtigt mobile Browser-Chrome */ + min-height: 100dvh; } /* Content-Bereich: füllt den Raum zwischen Header und Bottom-Nav */ #page-content { - flex: 1; - overflow-y: auto; - overflow-x: hidden; + flex: 1; + min-height: 0; /* iOS-Flex-Bug: ohne das scrollt body statt #page-content */ + overflow-y: auto; + overflow-x: hidden; padding-bottom: calc(var(--nav-bottom-height) + var(--safe-bottom) + 16px); + -webkit-overflow-scrolling: touch; } -/* Desktop: Sidebar-Layout */ +/* Desktop: Sidebar-Layout — kein Bottom-Nav, natürliche Höhe */ @media (min-width: 768px) { #app { flex-direction: row; } #page-content { + min-height: unset; padding-bottom: 0; padding-left: var(--nav-sidebar-width); } @@ -168,6 +171,10 @@ left: 0; right: 0; z-index: 700; /* über Leaflet-Panes (~400) */ + /* GPU-Layer erzwingen → iOS Safari fixed-position Stabilität */ + transform: translateZ(0); + -webkit-transform: translateZ(0); + will-change: transform; min-height: calc(var(--nav-bottom-height) + var(--safe-bottom)); padding-top: var(--space-1); padding-bottom: calc(var(--safe-bottom) + var(--space-1)); diff --git a/backend/static/icons/icon-180-staging.png b/backend/static/icons/icon-180-staging.png new file mode 100644 index 0000000..fc2be5c Binary files /dev/null and b/backend/static/icons/icon-180-staging.png differ diff --git a/backend/static/icons/icon-192-staging.png b/backend/static/icons/icon-192-staging.png new file mode 100644 index 0000000..9386e04 Binary files /dev/null and b/backend/static/icons/icon-192-staging.png differ diff --git a/backend/static/icons/icon-512-staging.png b/backend/static/icons/icon-512-staging.png new file mode 100644 index 0000000..0c2797a Binary files /dev/null and b/backend/static/icons/icon-512-staging.png differ diff --git a/backend/static/index.html b/backend/static/index.html index 82258db..688dd83 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -88,21 +88,12 @@ - - - + + + - - -