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 e428a56..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"), @@ -560,9 +561,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: @@ -1508,6 +1510,54 @@ 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}") + + # 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/main.py b/backend/main.py index db883dc..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"]) @@ -1400,6 +1402,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/routes/auth.py b/backend/routes/auth.py index e46cda0..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,) @@ -97,9 +119,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=?", @@ -117,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") @@ -198,7 +220,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() @@ -207,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/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/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/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/routes/outreach.py b/backend/routes/outreach.py new file mode 100644 index 0000000..11f4152 --- /dev/null +++ b/backend/routes/outreach.py @@ -0,0 +1,188 @@ +"""BAN YARO — Mailing (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 typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from auth import require_admin +from database import db + +router = APIRouter() + +_SMTP_HOST = os.getenv("SMTP_HOST", "mail.your-server.de") +_SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) + +_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 + from_account: str = "partner" + template_id: Optional[int] = None + + +# ------------------------------------------------------------------ +# Templates CRUD +# ------------------------------------------------------------------ + +@router.get("/templates") +def list_templates(user=Depends(require_admin)): + 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_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(): + 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, data.from_account) + sent.append(addr) + with db() as conn: + conn.execute( + """INSERT INTO outreach_log + (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: + failed.append({"addr": addr, "error": str(e)}) + + 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_endpoint(user=Depends(require_admin)): + with db() as conn: + rows = conn.execute( + """SELECT ol.id, ol.recipient, ol.subject, ol.sent_at, + 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 200""" + ).fetchall() + return [dict(r) for r in rows] 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/index.html b/backend/static/index.html index 1cd9cfa..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"> + + + @@ -230,7 +250,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..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 = '539'; // ← 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'; @@ -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 }, }; // ---------------------------------------------------------- @@ -122,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; } @@ -188,16 +189,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 +232,13 @@ const App = (() => {
- - +
@@ -455,6 +473,7 @@ const App = (() => { navigate('onboarding'); } + _showVerifyBanner(); _updateNotifBadge(); _updateChatBadge(); _checkNearbyAlerts(); @@ -529,13 +548,30 @@ 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 _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) { @@ -787,8 +823,21 @@ 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'; - 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/js/pages/admin.js b/backend/static/js/pages/admin.js index 69ed773..cd34154 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-simple' }, { 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,256 @@ window.Page_admin = (() => { }); } + async function _renderOutreach(el) { + const [templates, log] = await Promise.all([ + API.get('/outreach/templates').catch(() => []), + 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

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ +
+ + + {name} wird nicht automatisch ersetzt — bitte manuell anpassen. + +
+
+
+ + +
+

Versand-Log

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

Noch keine E-Mails gesendet.

` + : ` + + + + + + + + + + + ${log.map(l => ` + + + + + + + `).join('')} + +
VonEmpfängerBetreffWerWann
${accountBadge(l.from_account)}${_esc(l.recipient)}${_esc(l.subject)}${_esc(l.sent_by_name || '')}${(l.sent_at||'').slice(0,16).replace('T',' ')}
`} +
+ +
+ `; + + // 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.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 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; } + + await UI.asyncButton(btn, async () => { + 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(', ')}`); + 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/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/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/js/pages/settings.js b/backend/static/js/pages/settings.js index 9a6b596..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 ? ` @@ -149,6 +157,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 ? ` @@ -474,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) { @@ -1525,7 +1545,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 b443693..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-v562'; +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 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