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 = ` +
+ + +
+

Neuen Partner-Code erstellen

+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+

Aktive Codes

+
+ ${codes.length === 0 + ? `

Noch keine Partner-Codes angelegt.

` + : ` + + + + + + + + + + + ${codes.map(c => ` + + + + + + + + `).join('')} + +
CodeBezeichnungNutzungenGründer
+ ${c.code} + ${c.label} + ${c.uses}${c.max_uses ? `/${c.max_uses}` : ''} + + ${c.grants_founder ? '✓' : '—'} + + +
` + } +
+
+ + +
+

Nutzer-Status manuell vergeben

+
+
+ + +
+
+
+ + +
+ +
+
+ +
+ `; + + // Code erstellen + el.querySelector('#adm-partner-create')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = e.target.querySelector('[type="submit"]'); + const fd = UI.formData(e.target); + const code = (fd.code || '').trim().toUpperCase(); + if (!code) return; + await UI.asyncButton(btn, async () => { + await API.post('/api/admin/partner/codes', { + code, + label: fd.label || code, + grants_founder: e.target.querySelector('[name="grants_founder"]').checked ? 1 : 0, + max_uses: fd.max_uses ? parseInt(fd.max_uses) : null, + }); + UI.toast.success(`Code "${code}" erstellt.`); + await _renderPartner(el); + }); + }); + + // Code löschen + el.querySelectorAll('.adm-del-code').forEach(btn => { + btn.addEventListener('click', async () => { + if (!window.confirm(`Code wirklich löschen?`)) return; + const id = btn.dataset.id; + await UI.asyncButton(btn, async () => { + await API.del(`/api/admin/partner/codes/${id}`); + UI.toast.success('Code gelöscht.'); + await _renderPartner(el); + }); + }); + }); + + // User suchen für Status-Vergabe + let _grantUserId = null; + const searchInput = el.querySelector('#adm-grant-search'); + const grantResult = el.querySelector('#adm-grant-result'); + let _searchTimeout = null; + searchInput?.addEventListener('input', () => { + clearTimeout(_searchTimeout); + _grantUserId = null; + const q = searchInput.value.trim(); + if (q.length < 2) { grantResult.innerHTML = ''; return; } + _searchTimeout = setTimeout(async () => { + try { + const users = await API.get(`/api/admin/users/search?q=${encodeURIComponent(q)}`); + if (!users.length) { + grantResult.innerHTML = `

Kein User gefunden.

`; + return; + } + grantResult.innerHTML = users.map(u => ` +
+ ${u.name} + + ${u.is_founder ? '⭐ Gründer ' : ''}${u.is_partner ? '🤝 Partner' : ''} + +
+ `).join(''); + grantResult.querySelectorAll('.adm-grant-user').forEach(div => { + div.addEventListener('click', () => { + _grantUserId = parseInt(div.dataset.id); + searchInput.value = div.dataset.name; + grantResult.innerHTML = `

✓ ${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 = `
diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 8685fba..9ce3d6a 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -124,14 +124,21 @@ window.Page_settings = (() => {
${_esc(u.name)}
${_esc(u.email)}
- ${u.is_premium - ? ` - Ban Yaro Plus - ` - : ` - Kostenlos - `} +
+ ${u.is_premium + ? ` + Ban Yaro Plus + ` + : `Kostenlos`} + ${u.is_founder + ? ` + Gründer + ` : ''} + ${u.is_partner + ? ` + Partner + ` : ''} +
@@ -1283,6 +1290,16 @@ window.Page_settings = (() => { +
+ + + +
@@ -1375,6 +1392,44 @@ window.Page_settings = (() => { }); } + // Partner-Code live validieren + const partnerInput = document.getElementById('reg-partner-code'); + const partnerHint = document.getElementById('reg-partner-hint'); + let _partnerValid = false; + if (partnerInput) { + // Vorausfüllen falls via sessionStorage gesetzt + const stored = sessionStorage.getItem('by_ref_code') || ''; + if (stored) partnerInput.value = stored; + + let _debounce = null; + partnerInput.addEventListener('input', () => { + const code = partnerInput.value.trim().toUpperCase(); + partnerInput.value = code; + clearTimeout(_debounce); + partnerHint.style.display = 'none'; + _partnerValid = false; + if (code.length < 3) return; + _debounce = setTimeout(async () => { + try { + const info = await API.get(`/api/partner/codes/${encodeURIComponent(code)}/info`); + if (info.redeemable) { + partnerHint.textContent = info.grants_founder + ? `✓ Gültiger Code von "${info.label}" — du erhältst eine lebenslange Gründer-Lizenz!` + : `✓ Gültiger Einladungscode von "${info.label}"`; + partnerHint.style.cssText = 'display:block;background:var(--c-success-bg,#f0fdf4);color:var(--c-success,#16a34a);padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);font-size:var(--text-xs)'; + _partnerValid = true; + } else { + partnerHint.textContent = 'Dieser Code ist bereits vollständig eingelöst.'; + partnerHint.style.cssText = 'display:block;background:#fef2f2;color:#dc2626;padding:var(--space-2) var(--space-3);border-radius:var(--radius-sm);font-size:var(--text-xs)'; + } + } catch { + partnerHint.textContent = 'Code nicht gefunden.'; + partnerHint.style.cssText = 'display:block;color:var(--c-text-muted);font-size:var(--text-xs)'; + } + }, 500); + }); + } + document.getElementById('auth-form')?.addEventListener('submit', async e => { e.preventDefault(); const btn = e.target.querySelector('[type="submit"]'); @@ -1390,8 +1445,10 @@ window.Page_settings = (() => { } await UI.asyncButton(btn, async () => { - const refCode = sessionStorage.getItem('by_ref_code') || ''; - const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), refCode || undefined); + const partnerCode = (fd.partner_code || '').trim().toUpperCase() || undefined; + const refCode = sessionStorage.getItem('by_ref_code') || ''; + const finalCode = partnerCode || refCode || undefined; + const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode); localStorage.setItem('by_token', result.token); if (refCode) sessionStorage.removeItem('by_ref_code'); @@ -1401,7 +1458,10 @@ window.Page_settings = (() => { _appState.activeDog = null; document.getElementById('header-login-btn')?.remove(); - UI.toast.success(`Willkommen bei Ban Yaro, ${_appState.user.name}!`); + const greeting = _appState.user.is_founder + ? `Willkommen, Gründer ${_appState.user.name}! 🎉` + : `Willkommen bei Ban Yaro, ${_appState.user.name}!`; + UI.toast.success(greeting); App.showOnboarding(); }); }); diff --git a/backend/static/sw.js b/backend/static/sw.js index 8b5ea5f..fad85ae 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v514'; +const CACHE_VERSION = 'by-v515'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache