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 2588f0c..8e75ede 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 = "919" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "920" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): 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 9ad43fe..863be66 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -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/static/js/api.js b/backend/static/js/api.js index 9781362..62b0056 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 }); + }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 3e3529d..2860ab3 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 = '919'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '920'; // ← 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..e95aa34 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.'); @@ -3419,6 +3422,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 f95e235..96ee9f7 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -143,6 +143,7 @@ window.Page_settings = (() => { 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']; @@ -151,18 +152,12 @@ window.Page_settings = (() => { `
  • ✓ ${f}
  • ` ).join(''); - const subject = encodeURIComponent(`Upgrade auf ${label} — Ban Yaro`); - const body = encodeURIComponent( - `Hallo,\n\nich möchte meinen Account auf ${label} upgraden.\n\nMein Account: ${_appState.user?.email || ''}\n\nBitte schickt mir die Zahlungsinformationen.\n\nViele Grüße` - ); - const mailHref = `mailto:hallo@banyaro.app?subject=${subject}&body=${body}`; - UI.modal.open({ title: `${label} freischalten`, body: `
    -
    ${price}
    +
    ${price}
    Einmaliger Jahresbeitrag
    Kündigung jederzeit möglich
    @@ -173,9 +168,8 @@ window.Page_settings = (() => {
    - Aktuell läuft die Freischaltung noch manuell. Schreib uns kurz eine E-Mail — - wir schalten deinen Account innerhalb von 24 Stunden frei und schicken - dir die Bankverbindung. + Wir schalten deinen Account manuell frei — innerhalb von 24 Stunden. + Wir melden uns mit den Zahlungsdetails per E-Mail.
    `, footer: ` @@ -185,14 +179,32 @@ window.Page_settings = (() => { color:var(--c-text);font-size:var(--text-sm);cursor:pointer"> Abbrechen - - E-Mail senden - ` + ` + }); + + 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.'); + } }); } diff --git a/backend/static/sw.js b/backend/static/sw.js index dfad2b4..7fe3d87 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-v919'; +const CACHE_VERSION = 'by-v920'; 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