Feature: Upgrade-Anfragen-System — User-Flow + Admin-Panel (SW by-v920)

- DB: upgrade_requests-Tabelle (user_id, tier, message, fulfilled_at)
- POST /api/upgrade-request: Anfrage speichern + Admin-Benachrichtigungsmail
- GET/POST /api/admin/upgrade-requests[/{id}/fulfill]: Admin-Endpunkte
  — fulfill setzt subscription_tier + sendet Bestätigungsmail an User
- action-items: upgrades_pending zählt offene Anfragen → Badge im Admin
- Admin-Tab "Upgrades": Tabelle offener/erledigter Anfragen, Freischalten-Button
  mit Confirm-Modal, automatischer Tier-Setzung und Bestätigungsmail
- Settings: Upgrade-Modal sendet echte API-Anfrage statt nur mailto
  — doppelte Anfrage wird erkannt (already:true → Toast statt Fehler)
- api.js: API.auth.upgradeRequest(tier, message) hinzugefügt
- SW by-v920, APP_VER 920
This commit is contained in:
rene 2026-05-14 09:59:11 +02:00
parent d61fd155c5
commit f6b37717b4
9 changed files with 268 additions and 27 deletions

View file

@ -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]

View file

@ -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():

View file

@ -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"""
<p>Hallo {req['name']},</p>
<p>dein Account wurde soeben auf <strong>{tier_label}</strong> freigeschaltet.</p>
<p>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.</p>
<p>Vielen Dank für dein Vertrauen!</p>
<p>Viele Grüße<br>René &amp; das Ban Yaro Team</p>"""
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"]}

View file

@ -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")

View file

@ -124,6 +124,9 @@ const API = (() => {
return get('/auth/me');
},
referral: () => get('/auth/referral'),
upgradeRequest(tier, message) {
return post('/auth/upgrade-request', { tier, message });
},
};
// ----------------------------------------------------------

View file

@ -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

View file

@ -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 = (() => {
</div>`;
}
// ------------------------------------------------------------------
// 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 `<span style="display:inline-block;padding:1px 8px;border-radius:999px;
font-size:11px;font-weight:700;background:${color};color:#fff">${label}</span>`;
};
const pending = rows.filter(r => !r.fulfilled_at);
const done = rows.filter(r => r.fulfilled_at);
const _row = (r, showBtn) => `
<tr>
<td style="padding:var(--space-2) var(--space-3)">${_esc(r.name)}<br>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(r.email)}</span></td>
<td style="padding:var(--space-2) var(--space-3)">${tierBadge(r.tier)}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
${r.message ? _esc(r.message) : '—'}</td>
<td style="padding:var(--space-2) var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
${r.created_at?.slice(0,10) || ''}</td>
<td style="padding:var(--space-2) var(--space-3)">
${showBtn
? `<button class="btn btn-sm adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}"
style="background:#16a34a;color:#fff;border:none;padding:4px 12px;
border-radius:var(--radius-md);cursor:pointer;font-size:var(--text-xs);font-weight:600">
Freischalten
</button>`
: `<span style="font-size:var(--text-xs);color:var(--c-success)">✓ ${r.fulfilled_at?.slice(0,10)}</span>`}
</td>
</tr>`;
const thead = `<thead><tr>
${['Nutzer','Tarif','Nachricht','Datum','Aktion'].map(h =>
`<th style="padding:var(--space-2) var(--space-3);text-align:left;font-size:var(--text-xs);
color:var(--c-text-muted);font-weight:600;border-bottom:1px solid var(--c-border)">${h}</th>`
).join('')}</tr></thead>`;
el.innerHTML = `
<div class="card adm-table-card" style="margin-bottom:var(--space-4)">
<div class="by-card-section-header">Offene Anfragen (${pending.length})</div>
<div class="adm-table-scroll">
<table class="adm-table" style="width:100%;border-collapse:collapse">
${thead}
<tbody>
${pending.length
? pending.map(r => _row(r, true)).join('')
: `<tr><td colspan="5" style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">
Keine offenen Anfragen
</td></tr>`}
</tbody>
</table>
</div>
</div>
${done.length ? `
<div class="card adm-table-card">
<div class="by-card-section-header">Erledigt (${done.length})</div>
<div class="adm-table-scroll">
<table class="adm-table" style="width:100%;border-collapse:collapse">
${thead}
<tbody>${done.map(r => _row(r, false)).join('')}</tbody>
</table>
</div>
</div>` : ''}`;
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: `<p style="font-size:var(--text-sm)">
Der Account wird auf <strong>${tierLabel}</strong> gesetzt und
eine Bestätigungsmail gesendet.
</p>`,
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 };
})();

View file

@ -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 = (() => {
`<li style="padding:var(--space-1) 0;font-size:var(--text-sm)">✓ ${f}</li>`
).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: `
<div style="padding:var(--space-2) 0">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
<div style="font-size:2rem;font-weight:800;color:var(--c-primary)">${price}</div>
<div style="font-size:2rem;font-weight:800;color:${color}">${price}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
Einmaliger Jahresbeitrag<br>Kündigung jederzeit möglich
</div>
@ -173,9 +168,8 @@ window.Page_settings = (() => {
<div style="padding:var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-raised,rgba(0,0,0,.04));
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.6">
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.
</div>
</div>`,
footer: `
@ -185,14 +179,32 @@ window.Page_settings = (() => {
color:var(--c-text);font-size:var(--text-sm);cursor:pointer">
Abbrechen
</button>
<a href="${mailHref}"
style="display:inline-flex;align-items:center;gap:var(--space-2);
padding:var(--space-2) var(--space-4);
border-radius:var(--radius-md);border:none;cursor:pointer;
background:var(--c-primary);color:#fff;
font-size:var(--text-sm);font-weight:600;text-decoration:none">
E-Mail senden
</a>`
<button id="upgrade-request-send-btn"
style="padding:var(--space-2) var(--space-4);border-radius:var(--radius-md);
border:none;cursor:pointer;background:${color};color:#fff;
font-size:var(--text-sm);font-weight:600">
Anfrage senden
</button>`
});
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.');
}
});
}

View file

@ -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