diff --git a/backend/database.py b/backend/database.py index 5c5516a..2c37d10 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2341,6 +2341,24 @@ def _migrate(conn_factory): except Exception: pass + # upgrade_requests: Abo-Upgrade-Anfragen von Nutzern + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS upgrade_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tier TEXT NOT NULL, + message TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + fulfilled_at TEXT, + fulfilled_by INTEGER REFERENCES users(id) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_upgrade_req_pending ON upgrade_requests(fulfilled_at, created_at DESC)") + logger.info("Migration: upgrade_requests bereit.") + except Exception as e: + logger.warning(f"Migration upgrade_requests: {e}") + # route_dogs: bestehende Routen allen Hunden des Users zuweisen try: existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0] diff --git a/backend/main.py b/backend/main.py index 097edd7..9f5a2c4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -406,7 +406,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "918" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "921" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): @@ -465,10 +465,11 @@ async def sitemap(): today = date.today().isoformat() urls = [ ("https://banyaro.app/", "weekly", "1.0"), - ("https://banyaro.app/info", "monthly", "0.9"), - ("https://banyaro.app/presse", "monthly", "0.8"), + ("https://banyaro.app/zuechter", "weekly", "0.9"), + ("https://banyaro.app/info", "monthly", "0.8"), + ("https://banyaro.app/presse", "monthly", "0.7"), ("https://banyaro.app/wiki/rassen", "weekly", "0.8"), - ("https://banyaro.app/knigge", "monthly", "0.8"), + ("https://banyaro.app/knigge", "monthly", "0.7"), ("https://banyaro.app/wurfboerse", "daily", "0.8"), ] diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 92a199d..19e93eb 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -124,13 +124,17 @@ async def action_items(user=Depends(require_mod)): users_today = conn.execute( "SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')" ).fetchone()[0] + upgrades_pending = conn.execute( + "SELECT COUNT(*) FROM upgrade_requests WHERE fulfilled_at IS NULL" + ).fetchone()[0] return { - "jobs_pending": jobs, - "breeder_pending": breeders, - "reports_open": reports, - "fotos_pending": fotos, - "poi_edits_pending": poi_edits, - "users_today": users_today, + "jobs_pending": jobs, + "breeder_pending": breeders, + "reports_open": reports, + "fotos_pending": fotos, + "poi_edits_pending": poi_edits, + "users_today": users_today, + "upgrades_pending": upgrades_pending, } @@ -1091,3 +1095,66 @@ async def generate_media_previews(user=Depends(require_admin)): errors += 1 return {"generated": generated, "skipped": skipped, "errors": errors} + + +# ------------------------------------------------------------------ +# GET /api/admin/upgrade-requests — offene Upgrade-Anfragen +# POST /api/admin/upgrade-requests/{id}/fulfill — Tier setzen + Mail +# ------------------------------------------------------------------ +@router.get("/upgrade-requests") +async def list_upgrade_requests(user=Depends(require_admin)): + with db() as conn: + rows = conn.execute(""" + SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at, + u.name, u.email + FROM upgrade_requests r + JOIN users u ON u.id = r.user_id + ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC + LIMIT 100 + """).fetchall() + return [dict(r) for r in rows] + + +@router.post("/upgrade-requests/{req_id}/fulfill") +async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)): + with db() as conn: + req = conn.execute( + "SELECT r.*, u.name, u.email FROM upgrade_requests r JOIN users u ON u.id=r.user_id WHERE r.id=?", + (req_id,) + ).fetchone() + if not req: + raise HTTPException(404, "Anfrage nicht gefunden.") + if req["fulfilled_at"]: + raise HTTPException(400, "Bereits erledigt.") + if req["tier"] not in _VALID_TIERS: + raise HTTPException(400, "Ungültiger Tier.") + conn.execute( + "UPDATE users SET subscription_tier=? WHERE id=?", + (req["tier"], req["user_id"]) + ) + conn.execute( + "UPDATE upgrade_requests SET fulfilled_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), fulfilled_by=? WHERE id=?", + (user["id"], req_id) + ) + _audit(conn, user, "fulfill_upgrade", f"user:{req['user_id']}", f"tier={req['tier']}") + + tier_labels = {"pro": "Ban Yaro Pro", "breeder": "Züchter"} + tier_label = tier_labels.get(req["tier"], req["tier"]) + try: + from mailer import send_email, email_html + body_html = f""" +

Hallo {req['name']},

+

dein Account wurde soeben auf {tier_label} freigeschaltet.

+

Du kannst alle {tier_label}-Features ab sofort in der App nutzen. + Öffne Ban Yaro und lade die App einmal neu — dann ist dein neuer Tarif aktiv.

+

Vielen Dank für dein Vertrauen!

+

Viele Grüße
René & das Ban Yaro Team

""" + html = email_html(body_html, cta_url="https://banyaro.app", cta_label="Ban Yaro öffnen") + plain = (f"Hallo {req['name']},\n\ndein Account wurde auf {tier_label} freigeschaltet.\n" + f"Öffne Ban Yaro und lade die App neu.\n\nViele Grüße\nRené") + await send_email(req["email"], f"Dein {tier_label}-Zugang ist aktiv", html, plain) + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"Bestätigungsmail fehlgeschlagen: {e}") + + return {"ok": True, "tier": req["tier"], "user": req["name"]} diff --git a/backend/routes/auth.py b/backend/routes/auth.py index a0174e6..863be66 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -240,7 +240,7 @@ async def me(user=Depends(get_current_user)): profil_sichtbarkeit, avatar_url, created_at, is_founder, is_partner, founder_number, is_founder_pending, notes_ki_enabled, gassi_stunde_push, - preferred_theme + preferred_theme, subscription_tier FROM users WHERE id=?""", (user["id"],) ).fetchone() @@ -335,6 +335,46 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request): return {"ok": True} +class UpgradeRequestBody(BaseModel): + tier: str + message: Optional[str] = None + +@router.post("/upgrade-request") +async def create_upgrade_request(data: UpgradeRequestBody, user=Depends(get_current_user)): + _VALID = {"pro", "breeder"} + if data.tier not in _VALID: + raise HTTPException(400, "Ungültiger Tarif.") + with db() as conn: + existing = conn.execute( + "SELECT id FROM upgrade_requests WHERE user_id=? AND tier=? AND fulfilled_at IS NULL", + (user["id"], data.tier) + ).fetchone() + if existing: + return {"ok": True, "already": True} + conn.execute( + "INSERT INTO upgrade_requests (user_id, tier, message) VALUES (?,?,?)", + (user["id"], data.tier, data.message or None) + ) + email = conn.execute("SELECT email FROM users WHERE id=?", (user["id"],)).fetchone()["email"] + + tier_labels = {"pro": "Ban Yaro Pro", "breeder": "Züchter"} + tier_label = tier_labels[data.tier] + admin_email = os.getenv("ADMIN_EMAIL", "") + if admin_email: + try: + from routes.outreach import _send_smtp + subject = f"[Ban Yaro] Upgrade-Anfrage: {tier_label} — {user['name']}" + body = (f"Neue Upgrade-Anfrage:\n\n" + f"Nutzer: {user['name']} ({email})\n" + f"Tarif: {tier_label}\n" + f"Nachricht: {data.message or '—'}\n\n" + f"Admin-Panel: https://banyaro.app/#admin") + _send_smtp(admin_email, subject, body, "support") + except Exception: + pass + return {"ok": True} + + @router.post("/reset-password") async def reset_password(data: ResetPasswordRequest, request: Request): rl_check(request, max_requests=5, window_seconds=3600, key="reset_pw") diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py index 1afe535..3728277 100644 --- a/backend/routes/breeder.py +++ b/backend/routes/breeder.py @@ -183,6 +183,27 @@ async def admin_pending_breeders(admin=Depends(require_admin)): return [dict(r) for r in rows] +# ------------------------------------------------------------------ +# GET /api/admin/breeders — alle aktiven Züchter +# ------------------------------------------------------------------ +@router.get("/admin/breeders") +async def admin_all_breeders(admin=Depends(require_admin)): + with db() as conn: + rows = conn.execute(""" + SELECT u.id, u.name, u.email, u.created_at, u.subscription_tier, + u.breeder_status, u.last_login, + bp.zwingername, bp.rasse_text, bp.verein, bp.vdh_mitglied, + bp.stadt, bp.website, bp.verified_at, + (SELECT COUNT(*) FROM litters WHERE user_id=u.id) AS wuerfe_count, + (SELECT COUNT(*) FROM dogs WHERE user_id=u.id AND is_zucht_hund=1) AS zuchthunde_count + FROM users u + LEFT JOIN breeder_profiles bp ON bp.user_id = u.id + WHERE u.rolle = 'breeder' OR u.breeder_status = 'approved' + ORDER BY bp.verified_at DESC NULLS LAST, u.created_at DESC + """).fetchall() + return [dict(r) for r in rows] + + # ------------------------------------------------------------------ # GET /api/admin/breeder/{user_id}/documents — Dokumente eines Antrags # ------------------------------------------------------------------ diff --git a/backend/static/index.html b/backend/static/index.html index 0e13659..d079f75 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -599,10 +599,10 @@ - - - - + + + + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 9781362..f75b2cf 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -124,6 +124,9 @@ const API = (() => { return get('/auth/me'); }, referral: () => get('/auth/referral'), + upgradeRequest(tier, message) { + return post('/auth/upgrade-request', { tier, message }); + }, }; // ---------------------------------------------------------- @@ -688,6 +691,7 @@ const API = (() => { updateProfile(data) { return put('/breeder/profile', data); }, adminCreateProfile() { return post('/admin/breeder/create-profile', {}); }, pendingList() { return get('/admin/breeders/pending'); }, + allList() { return get('/admin/breeders'); }, documents(userId) { return get(`/admin/breeder/${userId}/documents`); }, documentUrl(userId, docId) { return `/api/admin/breeder/${userId}/document/${docId}`; }, approve(userId) { return post(`/admin/breeder/${userId}/approve`, {}); }, diff --git a/backend/static/js/app.js b/backend/static/js/app.js index c6d2189..1aa2c36 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 = '918'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '921'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 2747256..0f03bb4 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -26,6 +26,7 @@ window.Page_admin = (() => { { id: 'hilfe', label: 'Hilfe/FAQ', icon: 'question' }, { id: 'uebungen_admin', label: 'Übungen', icon: 'barbell' }, { id: 'referrals', label: 'Referrals', icon: 'share-network' }, + { id: 'upgrades', label: 'Upgrades', icon: 'crown-simple' }, ]; // ------------------------------------------------------------------ @@ -90,6 +91,7 @@ window.Page_admin = (() => { try { d = await API.get('/admin/action-items'); } catch { return; } const items = [ + { key: 'upgrades_pending', label: 'Upgrade-Anfragen', tab: 'upgrades', icon: 'crown-simple' }, { key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' }, { key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' }, { key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' }, @@ -163,6 +165,7 @@ window.Page_admin = (() => { case 'hilfe': await _renderHilfe(el); break; case 'uebungen_admin': await _renderUebungenAdmin(el); break; case 'referrals': await _renderReferrals(el); break; + case 'upgrades': await _renderUpgrades(el); break; } } catch (e) { el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); @@ -1890,12 +1893,17 @@ window.Page_admin = (() => { ${UI.icon('arrows-clockwise')} Aktualisieren -
Lade…
+
Lade…
+
Lade…
`; - el.querySelector('#adm-zuchter-refresh').addEventListener('click', () => - _loadZuechterAntraege(el.querySelector('#adm-zuchter-list')) - ); - await _loadZuechterAntraege(el.querySelector('#adm-zuchter-list')); + el.querySelector('#adm-zuchter-refresh').addEventListener('click', () => { + _loadZuechterAntraege(el.querySelector('#adm-zuchter-antraege')); + _loadZuechterListe(el.querySelector('#adm-zuchter-liste')); + }); + await Promise.all([ + _loadZuechterAntraege(el.querySelector('#adm-zuchter-antraege')), + _loadZuechterListe(el.querySelector('#adm-zuchter-liste')), + ]); } async function _loadZuechterAntraege(el) { @@ -1909,12 +1917,20 @@ window.Page_admin = (() => { } if (!antraege.length) { - el.innerHTML = _emptyState('certificate', 'Keine offenen Anträge', 'Aktuell liegen keine Züchter-Anträge zur Prüfung vor.'); + el.innerHTML = `
+
Offene Anträge
+
+ ${UI.icon('check-circle')} Keine offenen Anträge +
+
`; return; } el.innerHTML = ` -
+
+
Offene Anträge (${antraege.length})
+
+
${antraege.map(a => `
@@ -2069,6 +2085,74 @@ window.Page_admin = (() => { }); } + async function _loadZuechterListe(el) { + el.innerHTML = `
Lade…
`; + let breeders; + try { + breeders = await API.breeder.allList(); + } catch (e) { + el.innerHTML = _emptyState('warning', 'Fehler', e.message); + return; + } + + const tierBadge = t => { + if (t === 'breeder') return `Züchter-Abo`; + if (t === 'breeder_test') return `Test`; + return `Standard`; + }; + + const rows = breeders.map(b => ` + + +
${_esc(b.name)}
+
${_esc(b.email)}
+ + ${_esc(b.zwingername || '—')} + ${_esc(b.rasse_text || '—')} + ${_esc(b.stadt || '—')} + + ${b.wuerfe_count || 0} Würfe
+ ${b.zuchthunde_count || 0} Zuchthunde + + ${tierBadge(b.subscription_tier)} + + ${b.verified_at ? new Date(b.verified_at).toLocaleDateString('de-DE') : '—'} + + + + + `).join(''); + + el.innerHTML = ` +
+
Alle Züchter (${breeders.length})
+
+ + + ${['Nutzer','Zwingername','Rasse','Stadt','Aktivität','Abo','Seit',''].map(h => + `` + ).join('')} + + + ${rows || ``} + +
${h}
Noch keine Züchter
+
+
`; + + el.querySelectorAll('.adm-breeder-tier-btn').forEach(btn => { + btn.addEventListener('click', () => + _changeTier(btn.dataset.uid, btn.dataset.name, btn.dataset.tier) + ); + }); + } + // ------------------------------------------------------------------ async function _renderJobs(el) { el.innerHTML = ` @@ -3419,6 +3503,104 @@ window.Page_admin = (() => {
`; } + // ------------------------------------------------------------------ + // TAB: UPGRADES + // ------------------------------------------------------------------ + async function _renderUpgrades(el) { + const rows = await API.get('/admin/upgrade-requests'); + + const tierBadge = t => { + const cfg = { pro: ['Pro', '#16a34a'], breeder: ['Züchter', '#C4843A'] }; + const [label, color] = cfg[t] || [t, '#888']; + return `${label}`; + }; + + const pending = rows.filter(r => !r.fulfilled_at); + const done = rows.filter(r => r.fulfilled_at); + + const _row = (r, showBtn) => ` + + ${_esc(r.name)}
+ ${_esc(r.email)} + ${tierBadge(r.tier)} + + ${r.message ? _esc(r.message) : '—'} + + ${r.created_at?.slice(0,10) || ''} + + ${showBtn + ? `` + : `✓ ${r.fulfilled_at?.slice(0,10)}`} + + `; + + const thead = ` + ${['Nutzer','Tarif','Nachricht','Datum','Aktion'].map(h => + `${h}` + ).join('')}`; + + el.innerHTML = ` +
+
Offene Anfragen (${pending.length})
+
+ + ${thead} + + ${pending.length + ? pending.map(r => _row(r, true)).join('') + : ``} + +
+ Keine offenen Anfragen +
+
+
+ ${done.length ? ` +
+
Erledigt (${done.length})
+
+ + ${thead} + ${done.map(r => _row(r, false)).join('')} +
+
+
` : ''}`; + + el.querySelectorAll('.adm-fulfill-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const { id, name, tier } = btn.dataset; + const tierLabel = { pro: 'Pro', breeder: 'Züchter' }[tier] || tier; + const ok = await UI.modal.confirm({ + title: `${name} auf ${tierLabel} freischalten?`, + body: `

+ Der Account wird auf ${tierLabel} gesetzt und + eine Bestätigungsmail gesendet. +

`, + confirmLabel: 'Freischalten', + danger: false, + }); + if (!ok) return; + btn.disabled = true; + btn.textContent = '…'; + try { + const res = await API.post(`/admin/upgrade-requests/${id}/fulfill`); + UI.toast.success(`${res.user} wurde auf ${tierLabel} freigeschaltet.`); + _renderTab(); + } catch (e) { + UI.toast.error(e.message); + btn.disabled = false; + btn.textContent = 'Freischalten'; + } + }); + }); + } + return { init, refresh, onDogChange }; })(); diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 8767cc6..96ee9f7 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -77,6 +77,137 @@ window.Page_settings = (() => { } } + // ---------------------------------------------------------- + // ABO & TARIF + // ---------------------------------------------------------- + function _tierCard(u) { + const tier = u.subscription_tier || 'standard'; + const rolle = u.rolle || 'user'; + const isAdmin = rolle === 'admin' || rolle === 'moderator'; + const isPro = ['pro','pro_test'].includes(tier); + const isBreeder = ['breeder','breeder_test'].includes(tier) || rolle === 'breeder'; + const isStandard = !isAdmin && !isPro && !isBreeder; + + const _badge = (label, color) => + `${label}`; + + const _upgradeBtn = (id, label, price, color) => + ``; + + let statusHtml = ''; + let actionsHtml = ''; + + if (isAdmin) { + statusHtml = _badge('Admin', '#6366f1'); + } else if (isBreeder) { + statusHtml = _badge('Züchter aktiv', '#C4843A'); + } else if (isPro) { + statusHtml = _badge('Pro aktiv', '#16a34a'); + actionsHtml = ` +
+ ${_upgradeBtn('settings-upgrade-breeder-btn','Züchter werden','49 €/Jahr','#C4843A')} +
`; + } else { + statusHtml = _badge('Kostenlos', '#888'); + actionsHtml = ` +
+ ${_upgradeBtn('settings-upgrade-pro-btn','Ban Yaro Pro','29 €/Jahr','#16a34a')} + ${_upgradeBtn('settings-upgrade-breeder-btn','Züchter','49 €/Jahr','#C4843A')} +
`; + } + + return ` +
+
Abo & Tarif
+
+
+ Aktueller Tarif: + ${statusHtml} +
+ ${actionsHtml} +
+
`; + } + + function _showUpgradeModal(tier) { + const isPro = tier === 'pro'; + const label = isPro ? 'Ban Yaro Pro' : 'Züchter'; + const price = isPro ? '29 €/Jahr' : '49 €/Jahr'; + const color = isPro ? '#16a34a' : '#C4843A'; + const features = isPro + ? ['Mehrere Hunde verwalten', 'Ernährungsbereich mit KI-Berater', 'Erweiterte Karten-Layer', 'Alle künftigen Pro-Features'] + : ['Vollständige Züchter-Plattform', 'Warteliste, Läufigkeit & Trächtigkeit', 'Wurfverwaltung, Stammbaum, IK-Rechner', 'KI-Züchter-Assistent & Datenexport']; + + const featureList = features.map(f => + `
  • ✓ ${f}
  • ` + ).join(''); + + UI.modal.open({ + title: `${label} freischalten`, + body: ` +
    +
    +
    ${price}
    +
    + Einmaliger Jahresbeitrag
    Kündigung jederzeit möglich +
    +
    +
      + ${featureList} +
    +
    + Wir schalten deinen Account manuell frei — innerhalb von 24 Stunden. + Wir melden uns mit den Zahlungsdetails per E-Mail. +
    +
    `, + footer: ` + + ` + }); + + document.getElementById('upgrade-request-send-btn')?.addEventListener('click', async () => { + const btn = document.getElementById('upgrade-request-send-btn'); + if (!btn) return; + btn.disabled = true; + btn.textContent = 'Wird gesendet…'; + try { + const res = await API.auth.upgradeRequest(tier); + UI.modal.close(); + if (res.already) { + UI.toast.info('Deine Anfrage liegt bereits vor — wir melden uns bald.'); + } else { + UI.toast.success('Anfrage gesendet! Wir melden uns per E-Mail.'); + } + } catch (e) { + btn.disabled = false; + btn.textContent = 'Anfrage senden'; + UI.toast.error(e.message || 'Fehler beim Senden.'); + } + }); + } + // ---------------------------------------------------------- // RENDER // ---------------------------------------------------------- @@ -276,13 +407,6 @@ window.Page_settings = (() => { Feedback geben
    - ${!_appState.user?.subscription_tier || _appState.user.subscription_tier === 'standard' || _appState.user.subscription_tier === 'standard_test' ? ` -
    - ⭐ Ban Yaro Pro kommt bald — mehr Features, mehrere Hunde. -
    - ` : ''}