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
+ ? `
+ Freischalten
+ `
+ : `✓ ${r.fulfilled_at?.slice(0,10)} `}
+
+ `;
+
+ const thead = `
+ ${['Nutzer','Tarif','Nachricht','Datum','Aktion'].map(h =>
+ `${h} `
+ ).join('')} `;
+
+ el.innerHTML = `
+
+ ${done.length ? `
+ ` : ''}`;
+
+ 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
- `
+
+ Anfrage 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