From 128aee52de4784d374074d9353e8ee156e080cb5 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 17:23:18 +0200 Subject: [PATCH 01/13] =?UTF-8?q?UI:=20Sidebar-Footer=20=E2=80=94=20Impres?= =?UTF-8?q?sum/Datenschutz=20zentriert=20(wie=20'die=20100'),=20SW=20by-v5?= =?UTF-8?q?63?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/index.html | 2 +- backend/static/js/app.js | 2 +- backend/static/sw.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/static/index.html b/backend/static/index.html index 1cd9cfa..5277ae8 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -230,7 +230,7 @@ border-top:1px solid var(--c-border,#e5e7eb); font-size:var(--text-xs);color:var(--c-text-muted); display:flex;flex-direction:column;gap:var(--space-2);padding-bottom:var(--space-2)"> -
+
Impressum Datenschutz
diff --git a/backend/static/js/app.js b/backend/static/js/app.js index cb4b4a9..0d6f737 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 = '539'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '540'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/sw.js b/backend/static/sw.js index b443693..651018c 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-v562'; +const CACHE_VERSION = 'by-v563'; 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 From fd9714550786e7d16a307ba00f3bc688167cfee1 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 17:39:55 +0200 Subject: [PATCH 02/13] =?UTF-8?q?Feature:=20/partner=20Influencer-Landingp?= =?UTF-8?q?age=20=E2=80=94=20Live-Counter,=20Vorteile,=20Ranking,=20CTA,?= =?UTF-8?q?=20SW=20by-v564?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 202 +++++++++++++++++++++++++++++++++++++++ backend/static/js/app.js | 2 +- backend/static/sw.js | 2 +- 3 files changed, 204 insertions(+), 2 deletions(-) diff --git a/backend/main.py b/backend/main.py index db883dc..fb55815 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1400,6 +1400,208 @@ async def knigge_page(): return HTMLResponse(content=html, headers={"Cache-Control": "max-age=7200"}) +# ------------------------------------------------------------------ +# /partner — Influencer-Landingpage +# ------------------------------------------------------------------ +@app.get("/partner") +async def partner_landing(): + from fastapi.responses import HTMLResponse + from database import db as _db + with _db() as conn: + total_founders = conn.execute("SELECT COUNT(*) FROM users WHERE is_founder=1").fetchone()[0] + partners = conn.execute( + """SELECT label, uses FROM partner_codes WHERE grants_founder=1 ORDER BY uses DESC LIMIT 5""" + ).fetchall() + open_slots = max(0, 100 - total_founders) + + partner_rows = ''.join([ + f'
{p["label"]}' + f'{p["uses"]} Gründer
' + for p in partners + ]) or '
Noch keine Partner aktiv — sei der Erste.
' + + html = f""" + + + + + Ban Yaro Partner — Werde Teil der ersten 100 + + + + + + + +
+ + +
+ +
Ban Yaro · Influencer-Programm
+

Gib deiner Community
etwas für immer.

+

100 Gründer-Plätze. Weltweit. Nie wieder erhältlich.
Als Partner bringst du deine Follower nach vorne — und steigst im Ranking auf.

+ Jetzt Partner werden +
+ + +
+
{open_slots}
+
Gründer-Plätze noch frei (von 100)
+
+
0{total_founders} vergeben100
+
+ + +
+
Was du und deine Community bekommen
+ +
+
🏆
+
+
Gründer-Lizenz für deine Follower
+
Jeder der sich mit deinem Code registriert bekommt einen der 100 Gründer-Plätze — mit einer nummerierten Badge „Gründer #N" die dauerhaft im Profil und im Forum sichtbar ist. Nie wieder erhältlich.
+
+
+ +
+
🤝
+
+
Dein persönlicher Partner-Code
+
Du bekommst einen eigenen Code (z.B. HUNDEBLOG). Follower die sich damit registrieren werden automatisch Gründer — du siehst in Echtzeit wie viele du gebracht hast.
+
+
+ +
+
📊
+
+
Öffentliches Partner-Ranking
+
Auf der Gründer-Seite siehen alle wer die meisten Gründer gebracht hat. Das Ranking motiviert deine Follower mitzumachen — und stärkt deine Position gegenüber anderen Influencern.
+
+
+ +
+
💜
+
+
Partner-Badge für dich
+
Du selbst bekommst ein „Partner"-Badge in deinem Profil — sichtbar für alle Nutzer der App.
+
+
+ +
+
🎁
+
+
Lebenslang kostenlos — für immer
+
Gründer zahlen nie für Premium-Features — egal was wir in Zukunft einführen. Das ist ein echtes Dankeschön für die Pioniere.
+
+
+
+ + +
+
Wie es funktioniert
+
+
1
+
Kontakt aufnehmenSchreib uns kurz an partner@banyaro.app — wir richten deinen persönlichen Code ein.
+
+
+
2
+
Code teilenDu postest deinen Code in Story, Reel oder Post — deine Follower registrieren sich auf banyaro.app.
+
+
+
3
+
Gründer werdenJede Registrierung mit deinem Code sichert automatisch einen der 100 Gründer-Plätze. Du siehst deinen Fortschritt in Echtzeit.
+
+
+
4
+
Im Ranking aufsteigenJe mehr Gründer du bringst, desto höher dein Platz auf der öffentlichen Gründer-Seite.
+
+
+ + + {'
🏅 Aktuelles Partner-Ranking
' + partner_rows + '
' if partners else ''} + + +
+
Was ist Ban Yaro?
+

Ban Yaro ist die Hunde-App für alles was Halter brauchen — Tagebuch, Gesundheit, Routen, Giftköder-Alarm, Community. Kostenlos, ohne App Store, direkt im Browser oder als PWA.

+ banyaro.app entdecken → +
+ + +
+

Bereit dabei zu sein?

+

Schreib uns kurz wer du bist und auf welchem Kanal du aktiv bist — wir richten deinen Code binnen 24h ein.

+ 📧 partner@banyaro.app +
+ + + + +
+ +""" + return HTMLResponse(content=html, headers={"Cache-Control": "no-cache"}) + + # SPA Fallback — ALLE nicht-API-Routen gehen zur index.html @app.get("/{full_path:path}") async def spa_fallback(full_path: str): diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 0d6f737..5e803d0 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 = '540'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '541'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/sw.js b/backend/static/sw.js index 651018c..a9dd746 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-v563'; +const CACHE_VERSION = 'by-v564'; 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 From 7fd71342dadcf495b5f2b7f2755a4651dcdb2719 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 18:29:51 +0200 Subject: [PATCH 03/13] =?UTF-8?q?Config:=20mail@motocamp.de=20=E2=86=92=20?= =?UTF-8?q?banyaro.app-Adressen=20=C3=BCberall=20=E2=80=94=20ADMIN=5FEMAIL?= =?UTF-8?q?=3Dadmin@,=20hallo@=20in=20Impressum/Datenschutz,=20SW=20by-v56?= =?UTF-8?q?5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/breeder.py | 2 +- backend/routes/litters.py | 2 +- backend/routes/osm.py | 2 +- backend/static/js/app.js | 2 +- backend/static/js/pages/datenschutz.js | 4 ++-- backend/static/js/pages/impressum.js | 6 +++--- backend/static/sw.js | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/routes/breeder.py b/backend/routes/breeder.py index 061c9a7..bb5efc8 100644 --- a/backend/routes/breeder.py +++ b/backend/routes/breeder.py @@ -21,7 +21,7 @@ _TZ = ZoneInfo("Europe/Berlin") BREEDER_DOCS_DIR = os.getenv("BREEDER_DOCS_DIR", "/data/breeder_docs") os.makedirs(BREEDER_DOCS_DIR, exist_ok=True) -ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "mail@motocamp.de") +ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "admin@banyaro.app") APP_URL = os.getenv("APP_URL", "https://banyaro.app") diff --git a/backend/routes/litters.py b/backend/routes/litters.py index f47c809..2bcf629 100644 --- a/backend/routes/litters.py +++ b/backend/routes/litters.py @@ -258,7 +258,7 @@ async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)): "FROM breeder_profiles bp JOIN users u ON u.id=bp.user_id " "WHERE bp.user_id=?", (user["id"],) ).fetchone() - admin_email = os.getenv("ADMIN_EMAIL", "mail@motocamp.de") + admin_email = os.getenv("ADMIN_EMAIL", "admin@banyaro.app") app_url = os.getenv("APP_URL", "https://banyaro.app") zuechter = profile["name"] if profile else user.get("name", "Unbekannt") zwinger = profile["zwingername"] if profile else "—" diff --git a/backend/routes/osm.py b/backend/routes/osm.py index d75631b..e08742b 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -28,7 +28,7 @@ OVERPASS_URLS = [ _overpass_sem = asyncio.Semaphore(1) _overpass_last_req = 0.0 _OVERPASS_MIN_DELAY = 2.0 # Sekunden zwischen Anfragen -_OVERPASS_UA = 'BanYaro/1.0 (https://banyaro.app; dog-walking PWA; contact: mail@motocamp.de)' +_OVERPASS_UA = 'BanYaro/1.0 (https://banyaro.app; dog-walking PWA; contact: admin@banyaro.app)' _OVERPASS_HEADERS = { 'User-Agent': _OVERPASS_UA, 'Referer': 'https://banyaro.app/', # von overpass-api.de verlangt gegen 406 diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 5e803d0..692526b 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 = '541'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '542'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/datenschutz.js b/backend/static/js/pages/datenschutz.js index 838aaf4..73c2ba8 100644 --- a/backend/static/js/pages/datenschutz.js +++ b/backend/static/js/pages/datenschutz.js @@ -29,7 +29,7 @@ window.Page_datenschutz = (() => { ${sec('Verantwortlicher', `

René Degelmann, Ringstr. 26, 85560 Ebersberg
- E-Mail: mail@motocamp.de + E-Mail: hallo@banyaro.app

`)} ${sec('Deine Daten gehören dir', ` @@ -169,7 +169,7 @@ window.Page_datenschutz = (() => { (Art. 18) sowie Datenportabilität (Art. 20). Erteilte Einwilligungen kannst du jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3 DSGVO). Zur Ausübung deiner Rechte wende dich per E-Mail an - mail@motocamp.de.

+ hallo@banyaro.app.

Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde Beschwerde einzulegen:
Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)
diff --git a/backend/static/js/pages/impressum.js b/backend/static/js/pages/impressum.js index ac8b3bf..ffccb44 100644 --- a/backend/static/js/pages/impressum.js +++ b/backend/static/js/pages/impressum.js @@ -25,9 +25,9 @@ window.Page_impressum = (() => {

Kontakt

- E-Mail: mail@motocamp.de
- Kontaktformular: hallo@banyaro.app
+ Kontaktformular: Nachricht senden

diff --git a/backend/static/sw.js b/backend/static/sw.js index a9dd746..d493e2a 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-v564'; +const CACHE_VERSION = 'by-v565'; 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 From 230455c250956ae1cb541c5954f43e20b969ec55 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 18:59:20 +0200 Subject: [PATCH 04/13] =?UTF-8?q?Feature:=20Gr=C3=BCnder-Aktivierung=20nac?= =?UTF-8?q?h=20Hunde-Profil=20mit=20Plausibilit=C3=A4tspr=C3=BCfung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - is_founder_pending: bei Registrierung mit Code gesetzt (statt sofort is_founder) - dogs.py: erstes Hunde-Profil → Plausibilitätsprüfung → is_founder aktivieren - Prüfung: Name min. 2 Zeichen + Buchstaben, Rasse gültig, Geburtsjahr realistisch - Settings: gelbes 'Gründer-Platz reserviert' Badge mit Link zu Hunde-Profil - Onboarding-Toast informiert über nötiges Hunde-Profil - SW by-v566, APP_VER 543 --- backend/database.py | 7 ++-- backend/routes/auth.py | 7 ++-- backend/routes/dogs.py | 57 +++++++++++++++++++++++++++++ backend/static/js/app.js | 2 +- backend/static/js/pages/settings.js | 10 ++++- backend/static/sw.js | 2 +- 6 files changed, 75 insertions(+), 10 deletions(-) diff --git a/backend/database.py b/backend/database.py index e428a56..9822d42 100644 --- a/backend/database.py +++ b/backend/database.py @@ -560,9 +560,10 @@ def _migrate(conn_factory): ("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"), - ("users", "founder_number", "INTEGER"), + ("users", "is_founder", "INTEGER NOT NULL DEFAULT 0"), + ("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"), + ("users", "founder_number", "INTEGER"), + ("users", "is_founder_pending", "INTEGER NOT NULL DEFAULT 0"), ] with conn_factory() as conn: for table, column, col_type in migrations: diff --git a/backend/routes/auth.py b/backend/routes/auth.py index e46cda0..fb30584 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -97,9 +97,8 @@ async def register(data: RegisterRequest, response: Response, request: Request): "SELECT COUNT(*) FROM users WHERE is_founder=1" ).fetchone()[0] if total_founders < 100: - founder_num = total_founders + 1 - updates["is_founder"] = 1 - updates["founder_number"] = founder_num + # Pending — wird nach erstem Hunde-Profil mit Plausibilitätsprüfung aktiviert + updates["is_founder_pending"] = 1 set_clause = ", ".join(f"{k}=?" for k in updates) conn.execute( f"UPDATE users SET {set_clause} WHERE id=?", @@ -198,7 +197,7 @@ async def me(user=Depends(get_current_user)): """SELECT id, name, real_name, email, rolle, is_premium, email_verified, bio, wohnort, erfahrung, social_link, profil_sichtbarkeit, avatar_url, created_at, - is_founder, is_partner, founder_number + is_founder, is_partner, founder_number, is_founder_pending FROM users WHERE id=?""", (user["id"],) ).fetchone() diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 8e176f8..74f1c95 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -78,6 +78,41 @@ async def list_dogs(user=Depends(get_current_user)): return result +def _is_plausible_dog(name: str, rasse: str, geburtstag) -> tuple[bool, str]: + """Einfache Plausibilitätsprüfung für Hunde-Profile.""" + import re, datetime + name = (name or "").strip() + rasse = (rasse or "").strip() + + if len(name) < 2: + return False, "Der Name muss mindestens 2 Zeichen haben." + if not re.search(r'[a-zA-ZäöüÄÖÜß]', name): + return False, "Der Name muss mindestens einen Buchstaben enthalten." + if len(set(name.lower())) < 2: + return False, "Bitte einen echten Namen eingeben." + + if rasse and len(rasse) < 2: + return False, "Bitte eine gültige Rasse eingeben." + if rasse and not re.search(r'[a-zA-ZäöüÄÖÜß]', rasse): + return False, "Die Rasse muss Buchstaben enthalten." + + if geburtstag: + try: + if isinstance(geburtstag, str): + year = int(geburtstag[:4]) + else: + year = geburtstag.year + now = datetime.date.today().year + if year > now: + return False, "Das Geburtsdatum liegt in der Zukunft." + if year < now - 30: + return False, "Das Geburtsdatum ist unrealistisch." + except Exception: + pass + + return True, "" + + @router.post("") async def create_dog(data: DogCreate, user=Depends(get_current_user)): with db() as conn: @@ -93,6 +128,28 @@ async def create_dog(data: DogCreate, user=Depends(get_current_user)): "SELECT * FROM dogs WHERE user_id=? ORDER BY id DESC LIMIT 1", (user["id"],) ).fetchone() + + # Gründer-Aktivierung: erstes Hunde-Profil + is_founder_pending + user_row = conn.execute( + "SELECT is_founder_pending, is_founder FROM users WHERE id=?", + (user["id"],) + ).fetchone() + if user_row and user_row["is_founder_pending"] and not user_row["is_founder"]: + dog_count = conn.execute( + "SELECT COUNT(*) FROM dogs WHERE user_id=?", (user["id"],) + ).fetchone()[0] + if dog_count == 1: # genau dieser erste Hund + plausible, reason = _is_plausible_dog(data.name, data.rasse, data.geburtstag) + if plausible: + total = conn.execute( + "SELECT COUNT(*) FROM users WHERE is_founder=1" + ).fetchone()[0] + if total < 100: + conn.execute( + "UPDATE users SET is_founder=1, founder_number=?, is_founder_pending=0 WHERE id=?", + (total + 1, user["id"]) + ) + return dict(dog) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 692526b..3680a86 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 = '542'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '543'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 9a6b596..8f45cf6 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -149,6 +149,12 @@ window.Page_settings = (() => { ? ` ${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'} + ` + : u.is_founder_pending + ? ` + + Gründer-Platz reserviert ` : ''} ${u.is_partner ? ` @@ -1525,7 +1531,9 @@ window.Page_settings = (() => { _appState.activeDog = null; document.getElementById('header-login-btn')?.remove(); - const greeting = _appState.user.is_founder + const greeting = _appState.user.is_founder_pending + ? `Willkommen, ${_appState.user.name}! 🎉 Dein Gründer-Platz ist reserviert — leg jetzt dein Hunde-Profil an um ihn zu sichern!` + : _appState.user.is_founder ? `Willkommen, Gründer ${_appState.user.name}! 🎉` : `Willkommen bei Ban Yaro, ${_appState.user.name}!`; UI.toast.success(greeting); diff --git a/backend/static/sw.js b/backend/static/sw.js index d493e2a..54f2cc0 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-v565'; +const CACHE_VERSION = 'by-v566'; 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 From b6258db6bcf7b4958dd941ed7ea04c5095727310 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:10:54 +0200 Subject: [PATCH 05/13] =?UTF-8?q?Feature:=20Admin=20Outreach=20=E2=80=94?= =?UTF-8?q?=20E-Mail-Versand=20via=20Hetzner=20SMTP,=20Vorlagen,=20Log,=20?= =?UTF-8?q?SW=20by-v567?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/database.py | 15 ++++ backend/main.py | 2 + backend/routes/outreach.py | 123 +++++++++++++++++++++++++++++++ backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 119 ++++++++++++++++++++++++++++++ backend/static/sw.js | 2 +- 6 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 backend/routes/outreach.py diff --git a/backend/database.py b/backend/database.py index 9822d42..5269002 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1509,6 +1509,21 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration partner_codes: {e}") + # Outreach-Log (Admin-E-Mail-Versand) + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS outreach_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sent_by INTEGER REFERENCES users(id), + recipient TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + sent_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) + except Exception as e: + logger.warning(f"Migration outreach_log: {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 fb55815..ddb4acc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -163,6 +163,7 @@ 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 +from routes.outreach import router as outreach_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -195,6 +196,7 @@ app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkart 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(outreach_router, prefix="/api/outreach", tags=["Outreach"]) 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/outreach.py b/backend/routes/outreach.py new file mode 100644 index 0000000..f2ca8ff --- /dev/null +++ b/backend/routes/outreach.py @@ -0,0 +1,123 @@ +"""BAN YARO — Outreach E-Mail (Admin)""" + +import os +import smtplib +import ssl +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.utils import formataddr +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from typing import List, Optional + +from auth import require_admin +from database import db + +router = APIRouter() + +_SMTP_HOST = os.getenv("SMTP_HOST", "") +_SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +_SMTP_USER = os.getenv("SMTP_USER", "") +_SMTP_PASS = os.getenv("SMTP_PASS", "") +_SMTP_FROM = os.getenv("SMTP_FROM", "partner@banyaro.app") + +TEMPLATES = { + "influencer_de": { + "label": "Influencer-Ansprache (DE)", + "subject": "Ban Yaro — 100 Gründer-Plätze, einer davon für deine Community", + "body": """Hallo {name}, + +ich bin René und habe Ban Yaro gebaut — eine Hunde-App für Tagebuch, Gesundheit, Giftköder-Alarm und Community. Kostenlos, ohne App Store, direkt als PWA. + +Ich kontaktiere gerade einige Influencer aus der deutschen Hunde-Community mit einem konkreten Angebot: + +Was deine Follower bekommen: Wer sich mit deinem persönlichen Code registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42") die dauerhaft im Profil sichtbar ist. Diese 100 Plätze gibt es genau einmal. + +Was du bekommst: Partner-Badge in der App, eigener Code, öffentliches Ranking wer die meisten Gründer bringt. + +Kein Geld, kein verpflichtender Post — aber eine echte Exklusivität die du deiner Community geben kannst. + +Alle Infos: https://banyaro.app/partner + +Wenn dich das interessiert, antworte einfach kurz — ich richte deinen Code binnen 24h ein. + +Viele Grüße, +René +banyaro.app""", + }, +} + + +class SendRequest(BaseModel): + to: List[str] + subject: str + body: str + template_name: Optional[str] = None + + +def _send_smtp(to: str, subject: str, body: str): + if not _SMTP_HOST or not _SMTP_USER: + raise RuntimeError("SMTP nicht konfiguriert.") + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = formataddr(("Ban Yaro Partner", _SMTP_FROM)) + msg["To"] = to + msg["Reply-To"] = _SMTP_FROM + msg.attach(MIMEText(body, "plain", "utf-8")) + ctx = ssl.create_default_context() + with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s: + s.ehlo() + s.starttls(context=ctx) + s.login(_SMTP_USER, _SMTP_PASS) + s.sendmail(_SMTP_FROM, to, msg.as_bytes()) + + +@router.get("/templates") +def list_templates(user=Depends(require_admin)): + return [{"id": k, "label": v["label"], "subject": v["subject"], "body": v["body"]} + for k, v in TEMPLATES.items()] + + +@router.post("/send") +def send_outreach(data: SendRequest, user=Depends(require_admin)): + if not data.to: + raise HTTPException(400, "Mindestens eine Empfänger-Adresse angeben.") + if not data.subject.strip() or not data.body.strip(): + raise HTTPException(400, "Betreff und Text dürfen nicht leer sein.") + + sent, failed = [], [] + for addr in data.to: + addr = addr.strip() + if not addr: + continue + try: + _send_smtp(addr, data.subject, data.body) + sent.append(addr) + # Log in DB + with db() as conn: + conn.execute( + """INSERT INTO outreach_log + (sent_by, recipient, subject, body, sent_at) + VALUES (?, ?, ?, ?, ?)""", + (user["id"], addr, data.subject, data.body, + datetime.utcnow().isoformat()) + ) + except Exception as e: + failed.append({"addr": addr, "error": str(e)}) + + return {"sent": sent, "failed": failed} + + +@router.get("/log") +def outreach_log(user=Depends(require_admin)): + with db() as conn: + rows = conn.execute( + """SELECT ol.id, ol.recipient, ol.subject, ol.sent_at, + u.name AS sent_by_name + FROM outreach_log ol + JOIN users u ON u.id = ol.sent_by + ORDER BY ol.sent_at DESC LIMIT 100""" + ).fetchall() + return [dict(r) for r in rows] diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 3680a86..74f5a94 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 = '543'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '544'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← 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 69ed773..b05c6f6 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -20,6 +20,7 @@ window.Page_admin = (() => { { id: 'system', label: 'System', icon: 'gear' }, { id: 'jobs', label: 'Jobs', icon: 'clock' }, { id: 'partner', label: 'Partner', icon: 'handshake' }, + { id: 'outreach', label: 'Outreach', icon: 'envelope' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, ]; @@ -90,6 +91,7 @@ window.Page_admin = (() => { case 'system': await _renderSystem(el); break; case 'jobs': await _renderJobs(el); break; case 'partner': await _renderPartner(el); break; + case 'outreach': await _renderOutreach(el); break; case 'audit': await _renderAudit(el); break; } } catch (e) { @@ -2016,6 +2018,123 @@ window.Page_admin = (() => { }); } + async function _renderOutreach(el) { + const [templates, log] = await Promise.all([ + API.get('/outreach/templates').catch(() => []), + API.get('/outreach/log').catch(() => []), + ]); + + el.innerHTML = ` +
+ + +
+

E-Mail senden

+

+ Von: partner@banyaro.app via Hetzner SMTP +

+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + + Hinweis: {name} im Text wird nicht automatisch ersetzt — bitte manuell anpassen. + +
+
+
+ + +
+

Versand-Log

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

Noch keine E-Mails gesendet.

` + : ` + + + + + + + + + ${log.map(l => ` + + + + + `).join('')} + +
EmpfängerBetreffGesendet
${_esc(l.recipient)}${_esc(l.subject)}${l.sent_at?.slice(0,16).replace('T',' ')}
`} +
+ +
+ `; + + // Vorlage laden + el.querySelector('#adm-outreach-tpl')?.addEventListener('change', e => { + const tpl = templates.find(t => t.id === e.target.value); + if (!tpl) return; + el.querySelector('#adm-outreach-subject').value = tpl.subject; + el.querySelector('#adm-outreach-body').value = tpl.body; + }); + + // Senden + el.querySelector('#adm-outreach-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = e.target.querySelector('[type="submit"]'); + const to = (el.querySelector('#adm-outreach-to').value || '') + .split(',').map(s => s.trim()).filter(Boolean); + const subject = el.querySelector('#adm-outreach-subject').value.trim(); + const body = el.querySelector('#adm-outreach-body').value.trim(); + + if (!to.length) { UI.toast.warning('Bitte mindestens einen Empfänger eingeben.'); return; } + if (!subject) { UI.toast.warning('Betreff fehlt.'); return; } + if (!body) { UI.toast.warning('Text fehlt.'); return; } + + await UI.asyncButton(btn, async () => { + const res = await API.post('/outreach/send', { to, subject, body }); + if (res.sent?.length) UI.toast.success(`${res.sent.length} E-Mail(s) gesendet.`); + if (res.failed?.length) UI.toast.error(`${res.failed.length} Fehler: ${res.failed.map(f=>f.error).join(', ')}`); + await _renderOutreach(el); + }); + }); + } + async function _renderAudit(el) { el.innerHTML = `
diff --git a/backend/static/sw.js b/backend/static/sw.js index 54f2cc0..7f21be0 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-v566'; +const CACHE_VERSION = 'by-v567'; 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 From 7b25eac286a79a3a325111f2413e932e20d41ecf Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:14:10 +0200 Subject: [PATCH 06/13] =?UTF-8?q?Fix:=20envelope-simple=20Icon=20zum=20Spr?= =?UTF-8?q?ite=20hinzugef=C3=BCgt=20f=C3=BCr=20Outreach-Tab,=20SW=20by-v56?= =?UTF-8?q?8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/static/icons/phosphor.svg | 2 +- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 2 +- backend/static/sw.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index 9b4843c..2fcc061 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -189,4 +189,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 74f5a94..7016f7d 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 = '544'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '545'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← 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 b05c6f6..daa83a4 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -20,7 +20,7 @@ window.Page_admin = (() => { { id: 'system', label: 'System', icon: 'gear' }, { id: 'jobs', label: 'Jobs', icon: 'clock' }, { id: 'partner', label: 'Partner', icon: 'handshake' }, - { id: 'outreach', label: 'Outreach', icon: 'envelope' }, + { id: 'outreach', label: 'Outreach', icon: 'envelope-simple' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, ]; diff --git a/backend/static/sw.js b/backend/static/sw.js index 7f21be0..9a5beac 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-v567'; +const CACHE_VERSION = 'by-v568'; 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 From 6aae03191e19022c370cdcc17afc8c589a07c53a Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:19:08 +0200 Subject: [PATCH 07/13] UX: Auth-Gate mit Screenshot-Preview (blur+lock), bessere Texte, Register-CTA prominent, SW by-v569 --- backend/static/js/app.js | 55 +++++++++++++++++++++++++++------------- backend/static/sw.js | 2 +- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 7016f7d..8812d99 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 = '545'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '546'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -76,13 +76,15 @@ const App = (() => { // AUTH GUARD — Login-Gate Texte pro Seite // ---------------------------------------------------------- const AUTH_GATE = { - diary: { icon: 'book-open', text: 'Halte besondere Momente mit deinem Hund fest — Spaziergänge, Meilensteine, Fotos.' }, - health: { icon: 'syringe', text: 'Impfungen, Tierarztbesuche, Medikamente und Allergien deines Hundes immer im Blick.' }, - 'dog-profile':{ icon: 'paw-print', text: 'Erstelle ein Profil für deinen Hund — mit Foto, Bio und NFC-Tag.' }, - friends: { icon: 'users', text: 'Finde Hundebesitzer in deiner Nähe und baue ein Netzwerk auf.' }, - chat: { icon: 'chat-circle-dots', text: 'Schreibe direkt mit anderen Hundebesitzern.' }, - walks: { icon: 'paw-print', text: 'Organisiere Gassi-Treffen und triff andere Hunde in deiner Gegend.' }, - sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.' }, + diary: { icon: 'book-open', text: 'Dein persönliches Hunde-Tagebuch — Fotos, Notizen, Stimmungen. Nur für dich, privat und sicher.', preview: '/img/screenshots/screen-1.jpg' }, + health: { icon: 'syringe', text: 'Impfungen, Tierarztbesuche, Gewicht und Medikamente — alles an einem Ort, immer abrufbar.', preview: '/img/screenshots/screen-3.jpg' }, + 'dog-profile':{ icon: 'paw-print', text: 'Erstelle ein Profil für deinen Hund mit Foto, Bio, Chip-Nr. und NFC-Tag.', preview: null }, + friends: { icon: 'users', text: 'Finde Hundebesitzer in deiner Nähe und tausche dich aus.', preview: null }, + chat: { icon: 'chat-circle-dots', text: 'Schreibe direkt mit anderen Hundebesitzern.', preview: null }, + walks: { icon: 'paw-print', text: 'Organisiere Gassi-Treffen und triff andere Hunde.', preview: '/img/screenshots/screen-5.jpg' }, + sitting: { icon: 'house-line', text: 'Finde einen Dogsitter oder biete selbst Sitting an.', preview: null }, + uebungen: { icon: 'target', text: 'Über 100 Übungen mit Anleitungen — tracke deinen Trainingsfortschritt.', preview: '/img/screenshots/screen-4.jpg' }, + notes: { icon: 'note-pencil', text: 'Dein persönlicher Notizblock — für alles was du nicht vergessen willst.', preview: null }, }; // ---------------------------------------------------------- @@ -188,16 +190,34 @@ const App = (() => { container.innerHTML = `
+ min-height:60vh;padding:var(--space-6) var(--space-5);text-align:center;gap:var(--space-4)"> - -
+ ${UI.escape(title)} +
+
+ + + Nur für Mitglieder + +
+
+
` : ` +
-
+
`}
@@ -213,14 +233,13 @@ const App = (() => {
- - +
diff --git a/backend/static/sw.js b/backend/static/sw.js index 9a5beac..42b4212 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-v568'; +const CACHE_VERSION = 'by-v569'; 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 From de02169c57f87cb881854f6735160929842fbe2b Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:24:45 +0200 Subject: [PATCH 08/13] Fix: Nicht eingeloggte User landen immer auf Welcome-Seite (nicht Forum) SW by-v570, APP_VER 547 --- backend/static/js/app.js | 11 +++-------- backend/static/sw.js | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 8812d99..29fa667 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 = '546'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '547'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -548,13 +548,8 @@ const App = (() => { _updateHeaderUserBtn(false); - // Wenn aktuelle Seite geschützt ist → zu freier Seite wechseln - if (pages[state.page]?.requiresAuth) { - navigate('map', false); - } else { - // Bleib auf der Seite, zeige aber den Gate-Screen - _loadPage(state.page); - } + // Nicht eingeloggte User immer zur Welcome-Seite + navigate('welcome', false); } function _updateHeaderUserBtn(loggedIn) { diff --git a/backend/static/sw.js b/backend/static/sw.js index 42b4212..c79c789 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-v569'; +const CACHE_VERSION = 'by-v570'; 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 From 87aeed8de83fe30c2cd50c7273600029b55341f4 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:27:48 +0200 Subject: [PATCH 09/13] =?UTF-8?q?Fix:=20Nicht=20eingeloggte=20User=20lande?= =?UTF-8?q?n=20immer=20auf=20Welcome=20=E2=80=94=20auch=20bei=20direktem?= =?UTF-8?q?=20Hash-Link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit navigate() beim Start prüft jetzt state.user; #forum, #wiki etc. werden für Nicht-Eingeloggte auf welcome umgeleitet. SW by-v571, APP_VER 548 --- backend/static/js/app.js | 5 +++-- backend/static/sw.js | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 29fa667..bc671d7 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 = '547'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '548'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -802,7 +802,8 @@ const App = (() => { }); } const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome'; - navigate(startPage, false, hashParams); + // Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc. + navigate(state.user ? startPage : 'welcome', false, hashParams); } async function _handleInvite(token) { diff --git a/backend/static/sw.js b/backend/static/sw.js index c79c789..2b792d5 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-v570'; +const CACHE_VERSION = 'by-v571'; 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 From a16f0268cc1c06abc4b271de267b3b9dc44e5085 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:30:28 +0200 Subject: [PATCH 10/13] =?UTF-8?q?Fix:=20Nicht-eingeloggte=20User=20werden?= =?UTF-8?q?=20bei=20gesch=C3=BCtzten=20Seiten=20zu=20Welcome=20umgeleitet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forum erhält requiresAuth, Auth-Guard navigiert zu Welcome statt Inline-Gate. SW by-v572, APP_VER 549 --- backend/static/js/app.js | 9 ++++----- backend/static/sw.js | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index bc671d7..4c58e2d 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 = '548'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '549'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -45,7 +45,7 @@ const App = (() => { poison: { title: 'Giftköder-Alarm', module: null }, walks: { title: 'Gassi-Treffen', module: null, requiresAuth: true }, sitting: { title: 'Sitting', module: null, requiresAuth: true }, - forum: { title: 'Forum', module: null }, + forum: { title: 'Forum', module: null, requiresAuth: true }, wiki: { title: 'Wiki', module: null }, knigge: { title: 'Knigge', module: null }, movies: { title: 'Filme', module: null }, @@ -124,10 +124,9 @@ const App = (() => { async function _loadPage(pageId, params = {}) { const page = pages[pageId]; - // AUTH GUARD — geschützte Seiten für nicht-eingeloggte User + // AUTH GUARD — geschützte Seiten für nicht-eingeloggte User → Welcome if (page.requiresAuth && !state.user) { - const container = document.querySelector(`#page-${pageId} .page-body`); - if (container) _renderLoginGate(container, pageId); + navigate('welcome', false); return; } diff --git a/backend/static/sw.js b/backend/static/sw.js index 2b792d5..92fa1d6 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-v571'; +const CACHE_VERSION = 'by-v572'; 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 From b17b061496e17f0578f1111c85bbce17d0d9cf78 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:33:39 +0200 Subject: [PATCH 11/13] =?UTF-8?q?Fix:=20Karten-Pin-Setzen=20erfordert=20Lo?= =?UTF-8?q?gin=20=E2=80=94=20Weiterleitung=20zu=20Welcome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SW by-v573, APP_VER 550 --- backend/static/js/app.js | 2 +- backend/static/js/pages/map.js | 1 + backend/static/sw.js | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 4c58e2d..bf5c33e 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 = '549'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '550'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 9ea9e0a..ded9a7d 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -807,6 +807,7 @@ window.Page_map = (() => { // Marker setzen (Placement-Mode) // ---------------------------------------------------------- function _togglePlacementMode() { + if (!_appState?.user) { App.navigate('welcome'); return; } _placingMarker = !_placingMarker; const btn = document.getElementById('map-pin-btn'); if (_placingMarker) { diff --git a/backend/static/sw.js b/backend/static/sw.js index 92fa1d6..fd9719f 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-v572'; +const CACHE_VERSION = 'by-v573'; 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 From e79290edb729943617107b805668c97e7521064f Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:41:58 +0200 Subject: [PATCH 12/13] =?UTF-8?q?Feature:=20Mailing=20=E2=80=94=20Template?= =?UTF-8?q?-Manager,=20zwei=20SMTP-Accounts=20(partner/support)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - email_templates Tabelle (CRUD), Startwert-Vorlage wird einmalig geseedet - outreach_log.from_account Spalte ergänzt - Admin-UI: Template-Liste mit Laden/Bearbeiten/Löschen + Modal zum Anlegen - Compose mit Absender-Auswahl (partner@/support@) - send_support_mail() intern aufrufbar für Moderations-Trigger - SW by-v574, APP_VER 551 --- backend/database.py | 33 +++++ backend/routes/outreach.py | 179 +++++++++++++++++--------- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 211 +++++++++++++++++++++++++------ backend/static/sw.js | 2 +- docker-compose.yml | 2 + 6 files changed, 331 insertions(+), 98 deletions(-) diff --git a/backend/database.py b/backend/database.py index 5269002..b6e5d8a 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1524,6 +1524,39 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration outreach_log: {e}") + # E-Mail-Vorlagen (DB-gespeichert, CRUD über Admin) + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS email_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + label TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + from_account TEXT NOT NULL DEFAULT 'partner', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT + ) + """) + # Startwert-Vorlage einspielen wenn Tabelle noch leer + count = conn.execute("SELECT COUNT(*) FROM email_templates").fetchone()[0] + if count == 0: + conn.execute(""" + INSERT INTO email_templates (key, label, subject, body, from_account) VALUES + ('influencer_de', + 'Influencer-Ansprache (DE)', + 'Ban Yaro — 100 Gründer-Plätze, einer davon für deine Community', + 'Hallo {name},\n\nich bin René und habe Ban Yaro gebaut — eine Hunde-App für Tagebuch, Gesundheit, Giftköder-Alarm und Community. Kostenlos, ohne App Store, direkt als PWA.\n\nIch kontaktiere gerade einige Influencer aus der deutschen Hunde-Community mit einem konkreten Angebot:\n\nWas deine Follower bekommen: Wer sich mit deinem persönlichen Code registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42") die dauerhaft im Profil sichtbar ist. Diese 100 Plätze gibt es genau einmal.\n\nWas du bekommst: Partner-Badge in der App, eigener Code, öffentliches Ranking wer die meisten Gründer bringt.\n\nKein Geld, kein verpflichtender Post — aber eine echte Exklusivität die du deiner Community geben kannst.\n\nAlle Infos: https://banyaro.app/partner\n\nWenn dich das interessiert, antworte einfach kurz — ich richte deinen Code binnen 24h ein.\n\nViele Grüße,\nRené\nbanyaro.app', + 'partner') + """) + except Exception as e: + logger.warning(f"Migration email_templates: {e}") + + # from_account-Spalte in outreach_log nachträglich hinzufügen + existing_ol = [row[1] for row in conn.execute("PRAGMA table_info(outreach_log)").fetchall()] + if 'from_account' not in existing_ol: + conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'") + # 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/routes/outreach.py b/backend/routes/outreach.py index f2ca8ff..11f4152 100644 --- a/backend/routes/outreach.py +++ b/backend/routes/outreach.py @@ -1,4 +1,4 @@ -"""BAN YARO — Outreach E-Mail (Admin)""" +"""BAN YARO — Mailing (Admin)""" import os import smtplib @@ -7,81 +7,134 @@ from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.utils import formataddr from datetime import datetime +from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel -from typing import List, Optional from auth import require_admin from database import db router = APIRouter() -_SMTP_HOST = os.getenv("SMTP_HOST", "") +_SMTP_HOST = os.getenv("SMTP_HOST", "mail.your-server.de") _SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) -_SMTP_USER = os.getenv("SMTP_USER", "") -_SMTP_PASS = os.getenv("SMTP_PASS", "") -_SMTP_FROM = os.getenv("SMTP_FROM", "partner@banyaro.app") -TEMPLATES = { - "influencer_de": { - "label": "Influencer-Ansprache (DE)", - "subject": "Ban Yaro — 100 Gründer-Plätze, einer davon für deine Community", - "body": """Hallo {name}, - -ich bin René und habe Ban Yaro gebaut — eine Hunde-App für Tagebuch, Gesundheit, Giftköder-Alarm und Community. Kostenlos, ohne App Store, direkt als PWA. - -Ich kontaktiere gerade einige Influencer aus der deutschen Hunde-Community mit einem konkreten Angebot: - -Was deine Follower bekommen: Wer sich mit deinem persönlichen Code registriert, sichert sich einen der 100 Gründer-Plätze — eine nummerierte Badge ("Gründer #42") die dauerhaft im Profil sichtbar ist. Diese 100 Plätze gibt es genau einmal. - -Was du bekommst: Partner-Badge in der App, eigener Code, öffentliches Ranking wer die meisten Gründer bringt. - -Kein Geld, kein verpflichtender Post — aber eine echte Exklusivität die du deiner Community geben kannst. - -Alle Infos: https://banyaro.app/partner - -Wenn dich das interessiert, antworte einfach kurz — ich richte deinen Code binnen 24h ein. - -Viele Grüße, -René -banyaro.app""", +_ACCOUNTS = { + "partner": { + "user": os.getenv("SMTP_USER", ""), + "pass": os.getenv("SMTP_PASS", ""), + "from": "partner@banyaro.app", + "name": "Ban Yaro Partner", + }, + "support": { + "user": os.getenv("SMTP_SUPPORT_USER", "support@banyaro.de"), + "pass": os.getenv("SMTP_SUPPORT_PASS", ""), + "from": "support@banyaro.app", + "name": "Ban Yaro Support", }, } +def _send_smtp(to: str, subject: str, body: str, account: str = "partner"): + acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] + if not acc["user"] or not acc["pass"]: + raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.") + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = formataddr((acc["name"], acc["from"])) + msg["To"] = to + msg["Reply-To"] = acc["from"] + msg.attach(MIMEText(body, "plain", "utf-8")) + ctx = ssl.create_default_context() + with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s: + s.ehlo() + s.starttls(context=ctx) + s.login(acc["user"], acc["pass"]) + s.sendmail(acc["from"], [to], msg.as_bytes()) + + +# ------------------------------------------------------------------ +# Schemas +# ------------------------------------------------------------------ + +class TemplateIn(BaseModel): + key: str + label: str + subject: str + body: str + from_account: str = "partner" + + +class TemplateUpdate(BaseModel): + label: str + subject: str + body: str + from_account: str = "partner" + + class SendRequest(BaseModel): to: List[str] subject: str body: str - template_name: Optional[str] = None + from_account: str = "partner" + template_id: Optional[int] = None -def _send_smtp(to: str, subject: str, body: str): - if not _SMTP_HOST or not _SMTP_USER: - raise RuntimeError("SMTP nicht konfiguriert.") - msg = MIMEMultipart("alternative") - msg["Subject"] = subject - msg["From"] = formataddr(("Ban Yaro Partner", _SMTP_FROM)) - msg["To"] = to - msg["Reply-To"] = _SMTP_FROM - msg.attach(MIMEText(body, "plain", "utf-8")) - ctx = ssl.create_default_context() - with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s: - s.ehlo() - s.starttls(context=ctx) - s.login(_SMTP_USER, _SMTP_PASS) - s.sendmail(_SMTP_FROM, to, msg.as_bytes()) - +# ------------------------------------------------------------------ +# Templates CRUD +# ------------------------------------------------------------------ @router.get("/templates") def list_templates(user=Depends(require_admin)): - return [{"id": k, "label": v["label"], "subject": v["subject"], "body": v["body"]} - for k, v in TEMPLATES.items()] + with db() as conn: + rows = conn.execute( + "SELECT id, key, label, subject, body, from_account FROM email_templates ORDER BY id" + ).fetchall() + return [dict(r) for r in rows] +@router.post("/templates", status_code=201) +def create_template(data: TemplateIn, user=Depends(require_admin)): + try: + with db() as conn: + row = conn.execute( + """INSERT INTO email_templates (key, label, subject, body, from_account, created_at) + VALUES (?, ?, ?, ?, ?, ?) RETURNING id""", + (data.key, data.label, data.subject, data.body, data.from_account, + datetime.utcnow().isoformat()) + ).fetchone() + return {"id": row["id"]} + except Exception as e: + raise HTTPException(400, f"Vorlage konnte nicht angelegt werden: {e}") + + +@router.put("/templates/{tpl_id}") +def update_template(tpl_id: int, data: TemplateUpdate, user=Depends(require_admin)): + with db() as conn: + conn.execute( + """UPDATE email_templates + SET label=?, subject=?, body=?, from_account=?, updated_at=? + WHERE id=?""", + (data.label, data.subject, data.body, data.from_account, + datetime.utcnow().isoformat(), tpl_id) + ) + return {"ok": True} + + +@router.delete("/templates/{tpl_id}") +def delete_template(tpl_id: int, user=Depends(require_admin)): + with db() as conn: + conn.execute("DELETE FROM email_templates WHERE id=?", (tpl_id,)) + return {"ok": True} + + +# ------------------------------------------------------------------ +# Senden +# ------------------------------------------------------------------ + @router.post("/send") -def send_outreach(data: SendRequest, user=Depends(require_admin)): +def send_mail(data: SendRequest, user=Depends(require_admin)): if not data.to: raise HTTPException(400, "Mindestens eine Empfänger-Adresse angeben.") if not data.subject.strip() or not data.body.strip(): @@ -93,15 +146,14 @@ def send_outreach(data: SendRequest, user=Depends(require_admin)): if not addr: continue try: - _send_smtp(addr, data.subject, data.body) + _send_smtp(addr, data.subject, data.body, data.from_account) sent.append(addr) - # Log in DB with db() as conn: conn.execute( """INSERT INTO outreach_log - (sent_by, recipient, subject, body, sent_at) - VALUES (?, ?, ?, ?, ?)""", - (user["id"], addr, data.subject, data.body, + (sent_by, recipient, subject, body, from_account, sent_at) + VALUES (?, ?, ?, ?, ?, ?)""", + (user["id"], addr, data.subject, data.body, data.from_account, datetime.utcnow().isoformat()) ) except Exception as e: @@ -110,14 +162,27 @@ def send_outreach(data: SendRequest, user=Depends(require_admin)): return {"sent": sent, "failed": failed} +# ------------------------------------------------------------------ +# Support-Versand (intern, ohne Admin-Auth — für Moderatoren-Trigger) +# ------------------------------------------------------------------ + +def send_support_mail(to: str, subject: str, body: str): + """Intern aufrufbar ohne HTTP-Request — z.B. aus Moderations-Logik.""" + _send_smtp(to, subject, body, "support") + + +# ------------------------------------------------------------------ +# Log +# ------------------------------------------------------------------ + @router.get("/log") -def outreach_log(user=Depends(require_admin)): +def outreach_log_endpoint(user=Depends(require_admin)): with db() as conn: rows = conn.execute( """SELECT ol.id, ol.recipient, ol.subject, ol.sent_at, - u.name AS sent_by_name + ol.from_account, u.name AS sent_by_name FROM outreach_log ol JOIN users u ON u.id = ol.sent_by - ORDER BY ol.sent_at DESC LIMIT 100""" + ORDER BY ol.sent_at DESC LIMIT 200""" ).fetchall() return [dict(r) for r in rows] diff --git a/backend/static/js/app.js b/backend/static/js/app.js index bf5c33e..3b3a388 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 = '550'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '551'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← 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 daa83a4..cd34154 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -2024,34 +2024,72 @@ window.Page_admin = (() => { API.get('/outreach/log').catch(() => []), ]); + const accountBadge = a => a === 'support' + ? `support@` + : `partner@`; + el.innerHTML = `
+ +
+
+

Vorlagen

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

Noch keine Vorlagen.

` + : `
+ ${templates.map(t => ` +
+
+
+ ${_esc(t.label)} + ${accountBadge(t.from_account)} +
+
+ ${_esc(t.subject)} +
+
+
+ + + +
+
`).join('')} +
`} +
+
-

E-Mail senden

-

- Von: partner@banyaro.app via Hetzner SMTP -

- +

E-Mail senden

- -
- - -
- - -
- - + +
+
+ + +
+
+ + +
@@ -2063,7 +2101,7 @@ window.Page_admin = (() => {
-
@@ -2072,7 +2110,7 @@ window.Page_admin = (() => { ${UI.icon('paper-plane-tilt')} Senden - Hinweis: {name} im Text wird nicht automatisch ersetzt — bitte manuell anpassen. + {name} wird nicht automatisch ersetzt — bitte manuell anpassen.
@@ -2086,17 +2124,21 @@ window.Page_admin = (() => { : ` + - + + ${log.map(l => ` + - + + `).join('')}
Von Empfänger BetreffGesendetWerWann
${accountBadge(l.from_account)} ${_esc(l.recipient)} ${_esc(l.subject)}${l.sent_at?.slice(0,16).replace('T',' ')}${_esc(l.sent_by_name || '')}${(l.sent_at||'').slice(0,16).replace('T',' ')}
`} @@ -2105,36 +2147,127 @@ window.Page_admin = (() => {
`; - // Vorlage laden - el.querySelector('#adm-outreach-tpl')?.addEventListener('change', e => { - const tpl = templates.find(t => t.id === e.target.value); + // Vorlage in Compose laden + function _loadTplIntoCompose(id) { + const tpl = templates.find(t => t.id === id); if (!tpl) return; + el.querySelector('#adm-outreach-from').value = tpl.from_account || 'partner'; el.querySelector('#adm-outreach-subject').value = tpl.subject; - el.querySelector('#adm-outreach-body').value = tpl.body; + el.querySelector('#adm-outreach-body').value = tpl.body; + } + + el.querySelectorAll('.adm-tpl-load').forEach(btn => { + btn.addEventListener('click', () => _loadTplIntoCompose(Number(btn.dataset.id))); }); + // Vorlage löschen + el.querySelectorAll('.adm-tpl-del').forEach(btn => { + btn.addEventListener('click', async () => { + if (!window.confirm('Vorlage löschen?')) return; + await API.del(`/outreach/templates/${btn.dataset.id}`); + await _renderOutreach(el); + }); + }); + + // Vorlage bearbeiten + el.querySelectorAll('.adm-tpl-edit').forEach(btn => { + btn.addEventListener('click', () => { + const tpl = templates.find(t => t.id === Number(btn.dataset.id)); + if (tpl) _openTplModal(el, tpl); + }); + }); + + // Neue Vorlage + el.querySelector('#adm-tpl-new')?.addEventListener('click', () => _openTplModal(el, null)); + // Senden el.querySelector('#adm-outreach-form')?.addEventListener('submit', async e => { e.preventDefault(); - const btn = e.target.querySelector('[type="submit"]'); - const to = (el.querySelector('#adm-outreach-to').value || '') - .split(',').map(s => s.trim()).filter(Boolean); - const subject = el.querySelector('#adm-outreach-subject').value.trim(); - const body = el.querySelector('#adm-outreach-body').value.trim(); + const btn = e.target.querySelector('[type="submit"]'); + const from_account = el.querySelector('#adm-outreach-from').value; + const to = (el.querySelector('#adm-outreach-to').value || '') + .split(',').map(s => s.trim()).filter(Boolean); + const subject = el.querySelector('#adm-outreach-subject').value.trim(); + const body = el.querySelector('#adm-outreach-body').value.trim(); - if (!to.length) { UI.toast.warning('Bitte mindestens einen Empfänger eingeben.'); return; } - if (!subject) { UI.toast.warning('Betreff fehlt.'); return; } - if (!body) { UI.toast.warning('Text fehlt.'); return; } + if (!to.length) { UI.toast.warning('Bitte mindestens einen Empfänger eingeben.'); return; } + if (!subject) { UI.toast.warning('Betreff fehlt.'); return; } + if (!body) { UI.toast.warning('Text fehlt.'); return; } await UI.asyncButton(btn, async () => { - const res = await API.post('/outreach/send', { to, subject, body }); + const res = await API.post('/outreach/send', { to, subject, body, from_account }); if (res.sent?.length) UI.toast.success(`${res.sent.length} E-Mail(s) gesendet.`); - if (res.failed?.length) UI.toast.error(`${res.failed.length} Fehler: ${res.failed.map(f=>f.error).join(', ')}`); + if (res.failed?.length) UI.toast.error(`${res.failed.length} Fehler: ${res.failed.map(f => f.error).join(', ')}`); await _renderOutreach(el); }); }); } + function _openTplModal(el, tpl) { + const isNew = !tpl; + const id = `adm-tpl-modal-${Date.now()}`; + UI.modal.open({ + title: isNew ? 'Neue Vorlage' : 'Vorlage bearbeiten', + body: ` +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
`, + footer: ` + + `, + }); + + document.getElementById(id)?.addEventListener('submit', async e => { + e.preventDefault(); + const payload = { + label: document.getElementById(`${id}-label`).value.trim(), + subject: document.getElementById(`${id}-subject`).value.trim(), + body: document.getElementById(`${id}-body`).value.trim(), + from_account: document.getElementById(`${id}-from`).value, + }; + if (!payload.label || !payload.subject || !payload.body) { + UI.toast.warning('Alle Felder ausfüllen.'); return; + } + if (isNew) { + const key = document.getElementById(`${id}-key`).value.trim(); + if (!key) { UI.toast.warning('Interner Name fehlt.'); return; } + await API.post('/outreach/templates', { ...payload, key }); + } else { + await API.put(`/outreach/templates/${tpl.id}`, payload); + } + UI.modal.close(); + await _renderOutreach(el); + }); + } + async function _renderAudit(el) { el.innerHTML = `
diff --git a/backend/static/sw.js b/backend/static/sw.js index fd9719f..4cd74e1 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-v573'; +const CACHE_VERSION = 'by-v574'; 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 diff --git a/docker-compose.yml b/docker-compose.yml index a3d6772..d1f4a45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: - VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0 - VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878 - VAPID_CONTACT=mailto:admin@banyaro.app + - SMTP_SUPPORT_USER=support@banyaro.de + - SMTP_SUPPORT_PASS=Marbled-Drool8-Whacky healthcheck: test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"] interval: 30s From b9ee67b8dddbaef8d816d446db2f3f7e7073fe9b Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 30 Apr 2026 19:51:07 +0200 Subject: [PATCH 13/13] =?UTF-8?q?Feature:=20E-Mail-Verifikation=20+=20Foru?= =?UTF-8?q?m=20=C3=B6ffentlich=20lesbar=20+=20Launch-Vorbereitung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Forum ohne requiresAuth: öffentlich lesbar, Schreiben weiter via API-Guard - E-Mail-Verifikation: Token bei Registrierung, support@-Mail, /verify-email/{token} - Verifikations-Banner (orange, dismissible) wenn email_verified=0 - Grüner Haken / "Nicht bestätigt"-Chip in Settings - POST /auth/resend-verification für Chip und Banner - DB-Migration: users.verification_token TEXT - SW by-v575, APP_VER 552 --- .claude/scheduled_tasks.lock | 1 - backend/database.py | 3 +- backend/routes/auth.py | 67 ++++++++++++++++++++++++++--- backend/static/index.html | 20 +++++++++ backend/static/js/app.js | 39 ++++++++++++++++- backend/static/js/pages/settings.js | 16 ++++++- backend/static/sw.js | 2 +- 7 files changed, 137 insertions(+), 11 deletions(-) delete mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index f6fdb0d..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"39ad9ffb-6cac-40b2-8c2d-b3974db3a4b8","pid":1946,"procStart":"Sat Apr 25 14:22:02 2026","acquiredAt":1777133270339} \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index b6e5d8a..76c1c2c 100644 --- a/backend/database.py +++ b/backend/database.py @@ -488,7 +488,8 @@ def _migrate(conn_factory): # WebCal: Kalender-Abo-Token ("users", "calendar_token", "TEXT"), # User-Profil-Felder - ("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"), + ("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"), + ("users", "verification_token", "TEXT"), ("users", "bio", "TEXT"), ("users", "wohnort", "TEXT"), ("users", "erfahrung", "TEXT"), diff --git a/backend/routes/auth.py b/backend/routes/auth.py index fb30584..7661c80 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -6,6 +6,7 @@ import string from typing import Optional from fastapi import APIRouter, HTTPException, Request, Response, Depends +from fastapi.responses import RedirectResponse from pydantic import BaseModel, EmailStr from database import db from auth import ( @@ -16,7 +17,28 @@ from username_blocklist import is_username_blocked from ratelimit import check as rl_check router = APIRouter() -COOKIE_NAME = "by_token" +COOKIE_NAME = "by_token" +_APP_URL = os.getenv("APP_URL", "https://banyaro.app") +_SMTP_READY = bool(os.getenv("SMTP_SUPPORT_USER") and os.getenv("SMTP_SUPPORT_PASS")) + + +def _send_verification_email(email: str, name: str, token: str): + if not _SMTP_READY: + return + from routes.outreach import _send_smtp + subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse" + body = ( + f"Hallo {name},\n\n" + "willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse mit diesem Link:\n\n" + f"{_APP_URL}/api/auth/verify-email/{token}\n\n" + "Der Link ist 7 Tage gültig.\n\n" + "Falls du dich nicht bei Ban Yaro registriert hast, kannst du diese Mail ignorieren.\n\n" + "Viele Grüße,\nDas Ban Yaro Team\nbanyaro.app" + ) + try: + _send_smtp(email, subject, body, "support") + except Exception: + pass # Nicht blockieren wenn SMTP fehlschlägt class LoginRequest(BaseModel): @@ -64,13 +86,13 @@ async def register(data: RegisterRequest, response: Response, request: Request): ).fetchone(): raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.") code = _gen_referral_code() + verify_token = secrets.token_urlsafe(32) try: conn.execute( - "INSERT INTO users (email, pw_hash, name, referral_code) VALUES (?,?,?,?)", - (data.email, hash_password(data.password), name, code) + "INSERT INTO users (email, pw_hash, name, referral_code, verification_token) VALUES (?,?,?,?,?)", + (data.email, hash_password(data.password), name, code, verify_token) ) except Exception: - # Fallback falls UNIQUE-Index greift (Race Condition) raise HTTPException(400, "Dieser Name ist bereits vergeben. Bitte wähle einen anderen.") user = conn.execute( "SELECT id, rolle FROM users WHERE email=?", (data.email,) @@ -116,7 +138,8 @@ async def register(data: RegisterRequest, response: Response, request: Request): token = create_token(user["id"], user["rolle"]) _set_cookie(response, token) - return {"token": token, "name": name} + _send_verification_email(data.email, name, verify_token) + return {"token": token, "name": name, "email_verified": 0} @router.post("/login") @@ -206,3 +229,37 @@ async def me(user=Depends(get_current_user)): data = dict(row) data["is_premium"] = bool(data["is_premium"]) return data + + +@router.get("/verify-email/{token}") +async def verify_email(token: str): + with db() as conn: + row = conn.execute( + "SELECT id, email_verified FROM users WHERE verification_token=?", (token,) + ).fetchone() + if not row: + return RedirectResponse(f"{_APP_URL}/#settings?verified=error", status_code=302) + conn.execute( + "UPDATE users SET email_verified=1, verification_token=NULL WHERE id=?", + (row["id"],) + ) + return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302) + + +@router.post("/resend-verification") +async def resend_verification(user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT email, name, email_verified FROM users WHERE id=?", (user["id"],) + ).fetchone() + if not row: + raise HTTPException(404) + if row["email_verified"]: + return {"ok": True, "already_verified": True} + token = secrets.token_urlsafe(32) + with db() as conn: + conn.execute( + "UPDATE users SET verification_token=? WHERE id=?", (token, user["id"]) + ) + _send_verification_email(row["email"], row["name"], token) + return {"ok": True} diff --git a/backend/static/index.html b/backend/static/index.html index 5277ae8..21afd73 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -108,6 +108,26 @@ border-radius:999px;padding:1px 7px;font-size:11px;font-weight:700">
+ + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 3b3a388..6937188 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 = '551'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '552'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; @@ -45,7 +45,7 @@ const App = (() => { poison: { title: 'Giftköder-Alarm', module: null }, walks: { title: 'Gassi-Treffen', module: null, requiresAuth: true }, sitting: { title: 'Sitting', module: null, requiresAuth: true }, - forum: { title: 'Forum', module: null, requiresAuth: true }, + forum: { title: 'Forum', module: null }, wiki: { title: 'Wiki', module: null }, knigge: { title: 'Knigge', module: null }, movies: { title: 'Filme', module: null }, @@ -473,6 +473,7 @@ const App = (() => { navigate('onboarding'); } + _showVerifyBanner(); _updateNotifBadge(); _updateChatBadge(); _checkNearbyAlerts(); @@ -551,6 +552,28 @@ const App = (() => { navigate('welcome', false); } + function _showVerifyBanner() { + const banner = document.getElementById('verify-banner'); + if (!banner) return; + if (!state.user || state.user.email_verified) { + banner.style.display = 'none'; + return; + } + const dismissed = sessionStorage.getItem('by_verify_dismissed'); + if (dismissed) return; + banner.style.display = 'flex'; + + document.getElementById('verify-resend-btn')?.addEventListener('click', async () => { + await API.post('/auth/resend-verification', {}); + UI.toast.success('Bestätigungs-Mail erneut gesendet.'); + }, { once: true }); + + document.getElementById('verify-banner-close')?.addEventListener('click', () => { + banner.style.display = 'none'; + sessionStorage.setItem('by_verify_dismissed', '1'); + }, { once: true }); + } + function _updateHeaderUserBtn(loggedIn) { const btn = document.getElementById('header-user-btn'); const icon = document.getElementById('header-user-icon'); @@ -800,6 +823,18 @@ const App = (() => { hashParams[k] = isNaN(v) ? v : Number(v); }); } + + // E-Mail-Verifikation: Redirect von /api/auth/verify-email/{token} + if (hashParams.verified === '1' || hashParams.verified === 1) { + if (state.user) state.user.email_verified = 1; + document.getElementById('verify-banner')?.style?.setProperty('display', 'none'); + UI.toast.success('E-Mail-Adresse erfolgreich bestätigt!'); + history.replaceState(null, '', '/'); + } else if (hashParams.verified === 'error') { + UI.toast.error('Ungültiger oder abgelaufener Bestätigungs-Link.'); + history.replaceState(null, '', '/'); + } + const startPage = (hashPage && pages[hashPage]) ? hashPage : 'welcome'; // Nicht eingeloggte User immer zur Welcome-Seite — auch bei direktem Link auf Forum, Map etc. navigate(state.user ? startPage : 'welcome', false, hashParams); diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 8f45cf6..a0f3a9a 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -138,7 +138,15 @@ window.Page_settings = (() => { style="display:none">
${_esc(u.name)}
-
${_esc(u.email)}
+
+ ${_esc(u.email)} + ${u.email_verified + ? `` + : `Nicht bestätigt`} +
${u.is_premium ? ` @@ -480,6 +488,12 @@ window.Page_settings = (() => { }); // Avatar-Hover-Overlay + // E-Mail-Verifikation: Chip → erneut senden + document.getElementById('settings-verify-chip')?.addEventListener('click', async () => { + await API.post('/auth/resend-verification', {}); + UI.toast.success('Bestätigungs-Mail gesendet — bitte prüfe dein Postfach.'); + }); + const avatarBtn = document.getElementById('settings-avatar-btn'); const avatarOverlay = avatarBtn?.querySelector('.avatar-overlay'); if (avatarBtn && avatarOverlay) { diff --git a/backend/static/sw.js b/backend/static/sw.js index 4cd74e1..9b0bb55 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-v574'; +const CACHE_VERSION = 'by-v575'; 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