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} vergeben 100
+
+
+
+
+
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 aufnehmen Schreib uns kurz an partner@banyaro.app — wir richten deinen persönlichen Code ein.
+
+
+
2
+
Code teilen Du postest deinen Code in Story, Reel oder Post — deine Follower registrieren sich auf banyaro.app.
+
+
+
3
+
Gründer werden Jede Registrierung mit deinem Code sichert automatisch einen der 100 Gründer-Plätze. Du siehst deinen Fortschritt in Echtzeit.
+
+
+
4
+
Im Ranking aufsteigen Je 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 →
+
+
+
+
+
+
+
+
+
+
+"""
+ 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">
+
+
+
+
+
+
Bitte bestätige deine E-Mail-Adresse — wir haben dir eine Mail geschickt.
+
+ Erneut senden
+
+
✕
+
+
@@ -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)">
-
-
+
+
+
+
+
+
+
+ Nur für Mitglieder
+
+
+
+
` : `
+
-
+
-
+
`}
@@ -213,14 +232,13 @@ const App = (() => {
-
-
- Anmelden
-
-
+
Kostenlos registrieren
+
+ Schon dabei? Anmelden
+
@@ -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
+
+ ${UI.icon('plus')} Neue Vorlage
+
+
+ ${templates.length === 0
+ ? `
Noch keine Vorlagen.
`
+ : `
+ ${templates.map(t => `
+
+
+
+ ${_esc(t.label)}
+ ${accountBadge(t.from_account)}
+
+
+ ${_esc(t.subject)}
+
+
+
+
+ ${UI.icon('arrow-bend-up-left')}
+
+
+ ${UI.icon('pencil-simple')}
+
+
+ ${UI.icon('trash')}
+
+
+
`).join('')}
+
`}
+
+
+
+
+
+
+
+
Versand-Log
+ ${log.length === 0
+ ? `
Noch keine E-Mails gesendet.
`
+ : `
+
+
+ Von
+ Empfänger
+ Betreff
+ Wer
+ Wann
+
+
+
+ ${log.map(l => `
+
+ ${accountBadge(l.from_account)}
+ ${_esc(l.recipient)}
+ ${_esc(l.subject)}
+ ${_esc(l.sent_by_name || '')}
+ ${(l.sent_at||'').slice(0,16).replace('T',' ')}
+ `).join('')}
+
+
`}
+
+
+
+ `;
+
+ // 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: `
+
+
+
+ Name (intern)
+
+
+
+ Absender
+
+ partner@banyaro.app
+ support@banyaro.app
+
+
+
+
+ Bezeichnung (sichtbar)
+
+
+
+ Betreff
+
+
+
+ Text
+ ${_esc(tpl?.body || '')}
+
+ `,
+ footer: `
+
Abbrechen
+
Speichern `,
+ });
+
+ 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