diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..f6fdb0d --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"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 76c1c2c..e428a56 100644 --- a/backend/database.py +++ b/backend/database.py @@ -488,8 +488,7 @@ def _migrate(conn_factory): # WebCal: Kalender-Abo-Token ("users", "calendar_token", "TEXT"), # User-Profil-Felder - ("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"), - ("users", "verification_token", "TEXT"), + ("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"), ("users", "bio", "TEXT"), ("users", "wohnort", "TEXT"), ("users", "erfahrung", "TEXT"), @@ -561,10 +560,9 @@ 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_pending", "INTEGER NOT NULL DEFAULT 0"), + ("users", "is_founder", "INTEGER NOT NULL DEFAULT 0"), + ("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"), + ("users", "founder_number", "INTEGER"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -1510,54 +1508,6 @@ 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 ddb4acc..db883dc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -163,7 +163,6 @@ 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"]) @@ -196,7 +195,6 @@ 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"]) @@ -1402,208 +1400,6 @@ 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 7661c80..e46cda0 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -6,7 +6,6 @@ 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 ( @@ -17,28 +16,7 @@ from username_blocklist import is_username_blocked from ratelimit import check as rl_check router = APIRouter() -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 +COOKIE_NAME = "by_token" class LoginRequest(BaseModel): @@ -86,13 +64,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, verification_token) VALUES (?,?,?,?,?)", - (data.email, hash_password(data.password), name, code, verify_token) + "INSERT INTO users (email, pw_hash, name, referral_code) VALUES (?,?,?,?)", + (data.email, hash_password(data.password), name, code) ) 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,) @@ -119,8 +97,9 @@ async def register(data: RegisterRequest, response: Response, request: Request): "SELECT COUNT(*) FROM users WHERE is_founder=1" ).fetchone()[0] if total_founders < 100: - # Pending — wird nach erstem Hunde-Profil mit Plausibilitätsprüfung aktiviert - updates["is_founder_pending"] = 1 + founder_num = total_founders + 1 + updates["is_founder"] = 1 + updates["founder_number"] = founder_num set_clause = ", ".join(f"{k}=?" for k in updates) conn.execute( f"UPDATE users SET {set_clause} WHERE id=?", @@ -138,8 +117,7 @@ async def register(data: RegisterRequest, response: Response, request: Request): token = create_token(user["id"], user["rolle"]) _set_cookie(response, token) - _send_verification_email(data.email, name, verify_token) - return {"token": token, "name": name, "email_verified": 0} + return {"token": token, "name": name} @router.post("/login") @@ -220,7 +198,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_pending + is_founder, is_partner, founder_number FROM users WHERE id=?""", (user["id"],) ).fetchone() @@ -229,37 +207,3 @@ 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 bb5efc8..061c9a7 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", "admin@banyaro.app") +ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "mail@motocamp.de") APP_URL = os.getenv("APP_URL", "https://banyaro.app") diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 74f1c95..8e176f8 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -78,41 +78,6 @@ 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: @@ -128,28 +93,6 @@ 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 2bcf629..f47c809 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", "admin@banyaro.app") + admin_email = os.getenv("ADMIN_EMAIL", "mail@motocamp.de") 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 e08742b..d75631b 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: admin@banyaro.app)' +_OVERPASS_UA = 'BanYaro/1.0 (https://banyaro.app; dog-walking PWA; contact: mail@motocamp.de)' _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 deleted file mode 100644 index 11f4152..0000000 --- a/backend/routes/outreach.py +++ /dev/null @@ -1,188 +0,0 @@ -"""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 2fcc061..9b4843c 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 21afd73..1cd9cfa 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -108,26 +108,6 @@ border-radius:999px;padding:1px 7px;font-size:11px;font-weight:700"> - - - @@ -250,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 6937188..cb4b4a9 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 = '552'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '539'; // ← 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,15 +76,13 @@ const App = (() => { // AUTH GUARD — Login-Gate Texte pro Seite // ---------------------------------------------------------- const AUTH_GATE = { - 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 }, + 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.' }, }; // ---------------------------------------------------------- @@ -124,9 +122,10 @@ const App = (() => { async function _loadPage(pageId, params = {}) { const page = pages[pageId]; - // AUTH GUARD — geschützte Seiten für nicht-eingeloggte User → Welcome + // AUTH GUARD — geschützte Seiten für nicht-eingeloggte User if (page.requiresAuth && !state.user) { - navigate('welcome', false); + const container = document.querySelector(`#page-${pageId} .page-body`); + if (container) _renderLoginGate(container, pageId); return; } @@ -189,34 +188,16 @@ const App = (() => { container.innerHTML = `
+ min-height:60vh;padding:var(--space-8) var(--space-5);text-align:center;gap:var(--space-5)"> - - ${gate.preview ? ` -
- ${UI.escape(title)} -
-
- - - Nur für Mitglieder - -
-
-
` : ` -
-
`} +
@@ -232,13 +213,14 @@ const App = (() => {
- + -
@@ -473,7 +455,6 @@ const App = (() => { navigate('onboarding'); } - _showVerifyBanner(); _updateNotifBadge(); _updateChatBadge(); _checkNearbyAlerts(); @@ -548,30 +529,13 @@ const App = (() => { _updateHeaderUserBtn(false); - // 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; + // 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); } - 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) { @@ -823,21 +787,8 @@ 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); + navigate(startPage, false, hashParams); } async function _handleInvite(token) { diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index cd34154..69ed773 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -20,7 +20,6 @@ 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' }, ]; @@ -91,7 +90,6 @@ 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) { @@ -2018,256 +2016,6 @@ 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 73c2ba8..838aaf4 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: hallo@banyaro.app + E-Mail: mail@motocamp.de

`)} ${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 - hallo@banyaro.app.

+ mail@motocamp.de.

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 ffccb44..ac8b3bf 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: hallo@banyaro.app
- Kontaktformular: mail@motocamp.de
+ Kontaktformular: Nachricht senden

diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index ded9a7d..9ea9e0a 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -807,7 +807,6 @@ 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 a0f3a9a..9a6b596 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -138,15 +138,7 @@ window.Page_settings = (() => { style="display:none">
${_esc(u.name)}
-
- ${_esc(u.email)} - ${u.email_verified - ? `` - : `Nicht bestätigt`} -
+
${_esc(u.email)}
${u.is_premium ? ` @@ -157,12 +149,6 @@ window.Page_settings = (() => { ? ` ${u.founder_number ? `Gründer #${u.founder_number}` : 'Gründer'} - ` - : u.is_founder_pending - ? ` - - Gründer-Platz reserviert ` : ''} ${u.is_partner ? ` @@ -488,12 +474,6 @@ 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) { @@ -1545,9 +1525,7 @@ window.Page_settings = (() => { _appState.activeDog = null; document.getElementById('header-login-btn')?.remove(); - 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 + const greeting = _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 9b0bb55..b443693 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-v575'; +const CACHE_VERSION = 'by-v562'; 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 d1f4a45..a3d6772 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,8 +15,6 @@ 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