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..e71bd06 100644 --- a/backend/database.py +++ b/backend/database.py @@ -559,6 +559,9 @@ 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"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -1485,6 +1488,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 44d780c..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"]) diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 73349b4..a48ecb1 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -78,13 +78,36 @@ 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"]: + updates["is_founder"] = 1 + 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) diff --git a/backend/routes/partner.py b/backend/routes/partner.py new file mode 100644 index 0000000..36d63c7 --- /dev/null +++ b/backend/routes/partner.py @@ -0,0 +1,136 @@ +"""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 FROM users WHERE id=?", (user_id,)).fetchone() + if not target: + raise HTTPException(404, "User nicht gefunden.") + 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 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: Code-Info für Registrierungsseite +# ------------------------------------------------------------------ + +@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) + # Einlösbar? + r["redeemable"] = r["max_uses"] is None or r["uses"] < r["max_uses"] + return r diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 4463bbd..b4cb681 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '491'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '492'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.0.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 9452095..4706cee 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -19,6 +19,7 @@ window.Page_admin = (() => { { id: 'analytics', label: 'Analytics', icon: 'target' }, { id: 'system', label: 'System', icon: 'gear' }, { id: 'jobs', label: 'Jobs', icon: 'clock' }, + { id: 'partner', label: 'Partner & Codes', icon: 'handshake' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, ]; @@ -88,6 +89,7 @@ window.Page_admin = (() => { case 'analytics': await _renderAnalytics(el); break; case 'system': await _renderSystem(el); break; case 'jobs': await _renderJobs(el); break; + case 'partner': await _renderPartner(el); break; case 'audit': await _renderAudit(el); break; } } catch (e) { @@ -1789,6 +1791,210 @@ window.Page_admin = (() => { // ------------------------------------------------------------------ // TAB: AUDIT-LOG // ------------------------------------------------------------------ + async function _renderPartner(el) { + const [codes] = await Promise.all([ + API.get('/api/admin/partner/codes'), + ]); + + el.innerHTML = ` +
Noch keine Partner-Codes angelegt.
` + : `| Code | +Bezeichnung | +Nutzungen | +Gründer | ++ |
|---|---|---|---|---|
+ ${c.code}
+ |
+ ${c.label} | ++ ${c.uses}${c.max_uses ? `/${c.max_uses}` : ''} + | ++ ${c.grants_founder ? '✓' : '—'} + | ++ + | +
Kein User gefunden.
`; + return; + } + grantResult.innerHTML = users.map(u => ` +✓ ${div.dataset.name} ausgewählt
`; + }); + }); + } catch { grantResult.innerHTML = ''; } + }, 400); + }); + + el.querySelector('#adm-partner-grant')?.addEventListener('submit', async e => { + e.preventDefault(); + if (!_grantUserId) { UI.toast.warning('Bitte erst einen User auswählen.'); return; } + const btn = e.target.querySelector('[type="submit"]'); + const isFounder = e.target.querySelector('[name="is_founder"]').checked ? 1 : 0; + const isPartner = e.target.querySelector('[name="is_partner"]').checked ? 1 : 0; + await UI.asyncButton(btn, async () => { + const result = await API.post(`/api/admin/partner/users/${_grantUserId}/grant`, { + is_founder: isFounder, + is_partner: isPartner, + }); + UI.toast.success(`Status für ${result.name} gesetzt.`); + grantResult.innerHTML = `✓ Gründer: ${result.is_founder ? 'Ja' : 'Nein'} | Partner: ${result.is_partner ? 'Ja' : 'Nein'}
`; + }); + }); + } + async function _renderAudit(el) { el.innerHTML = `