From 3d7d5dc1c40dedfedbd2db74731fd52ec85e4120 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 7 Jun 2026 18:51:54 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Dank-Mail=20an=20Partner=20bei=20bes?= =?UTF-8?q?t=C3=A4tigter=20Registrierung=20=E2=80=94=20mit=20Statistik?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger ist die E-Mail-Bestätigung des Geworbenen (nicht die rohe Registrierung — konsistent zur Registrierungen/Versuche-Zählung) und nur beim ersten Verify (Doppelklick auf den Link = keine zweite Mail). Inhalt: Dank + Bilanz (bestätigte Registrierungen gesamt + diesen Monat), bei QR-Herkunft der Sticker (#seq, Kontingent-Label), bei Gründer-Codes die offenen Plätze; CTA zur Partner-Statistik. Versand über das partner@-Konto, Fehler landen in failed_emails (context partner_thank_you). Env-Fund dabei: SMTP_PASS fehlte in BEIDEN .env (nur SMTP_SUPPORT_PASS da) — Partner-Konto-Versand wäre fehlgeschlagen; auf der DS ergänzt. Test: Mail-Capture per monkeypatch, prüft Statistik + Sticker-Nr + Einmaligkeit. Suite grün. --- VERSION | 2 +- backend/routes/auth.py | 82 +++++++++++++++++++++++++++++++++++++ backend/static/index.html | 24 +++++------ backend/static/js/app.js | 2 +- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- tests/test_partner_qr.py | 46 +++++++++++++++++++++ 7 files changed, 144 insertions(+), 16 deletions(-) diff --git a/VERSION b/VERSION index 6ee69cb..50a143c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1259 \ No newline at end of file +1260 \ No newline at end of file diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 718d6cd..c0e7e5a 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -397,6 +397,85 @@ async def me(user=Depends(get_current_user)): return data +def _notify_partner_registration(user_id: int): + """Dank-Mail an den Partner (Code-Besitzer), wenn ein Geworbener seine + E-Mail bestätigt hat — inkl. kleiner Statistik. Best effort.""" + import html as _html + with db() as conn: + u = conn.execute( + "SELECT referred_by, referred_qr FROM users WHERE id=?", (user_id,) + ).fetchone() + if not u or (u["referred_by"] or 0) >= 0: + return # kein Partner-Code im Spiel + code_id = -u["referred_by"] + pc = conn.execute( + """SELECT pc.code, pc.label, pc.grants_founder, pc.owner_user_id, + o.name AS owner_name, o.email AS owner_email + FROM partner_codes pc + LEFT JOIN users o ON o.id = pc.owner_user_id + WHERE pc.id=?""", + (code_id,) + ).fetchone() + if not pc or not pc["owner_email"]: + return # Code ohne Besitzer → niemand zu benachrichtigen + total = conn.execute( + "SELECT COUNT(*) FROM users WHERE referred_by=? AND email_verified=1", + (-code_id,) + ).fetchone()[0] + month = conn.execute( + """SELECT COUNT(*) FROM users + WHERE referred_by=? AND email_verified=1 + AND strftime('%Y-%m', created_at) = strftime('%Y-%m', 'now')""", + (-code_id,) + ).fetchone()[0] + qr_line = "" + if u["referred_qr"]: + qr = conn.execute( + """SELECT q.seq, b.label FROM partner_qr_codes q + JOIN partner_qr_batches b ON b.id = q.batch_id + WHERE q.token=?""", + (u["referred_qr"],) + ).fetchone() + if qr: + qr_line = f"Gekommen über deinen gedruckten QR-Code #{qr['seq']} (Kontingent „{qr['label']}“)." + founder_line = "" + if pc["grants_founder"]: + founders = conn.execute( + "SELECT COUNT(*) FROM users WHERE is_founder=1" + ).fetchone()[0] + founder_line = f"Noch {max(0, 100 - founders)} von 100 Gründer-Plätzen frei." + + subject = "🐾 Danke! Neue Registrierung über deinen Partner-Code" + _oname = _html.escape(pc["owner_name"] or "Partner") + stats_html = ( + f"

Deine Bilanz mit dem Code {pc['code']}:
" + f"{total} bestätigte Registrierung{'en' if total != 1 else ''} insgesamt · " + f"{month} in diesem Monat.

" + ) + body_html = f""" +

Hallo {_oname},

+

+ gerade hat ein neuer Hundefreund seine Registrierung über deinen + Partner-Code bestätigt — danke, dass du Ban Yaro weiterträgst! 🎉 +

+ {f'

{_html.escape(qr_line)}

' if qr_line else ''} + {stats_html} + {f'

{_html.escape(founder_line)}

' if founder_line else ''}""" + plain = (f"Hallo {pc['owner_name'] or 'Partner'},\n\n" + f"gerade hat ein neuer Hundefreund seine Registrierung über deinen Partner-Code bestätigt — danke!\n" + + (f"\n{qr_line}\n" if qr_line else "") + + f"\nDeine Bilanz mit dem Code {pc['code']}: {total} bestätigte Registrierungen insgesamt, {month} in diesem Monat.\n" + + (f"{founder_line}\n" if founder_line else "") + + f"\nDeine Statistik: {_APP_URL}/#partner-profil\n") + try: + from routes.outreach import _send_smtp + from mailer import email_html + html = email_html(body_html, cta_url=f"{_APP_URL}/#partner-profil", cta_label="Meine Partner-Statistik") + _send_smtp(pc["owner_email"], subject, plain, "partner", html=html) + except Exception as exc: + _log_smtp_failure(pc["owner_email"], subject, plain, exc, context="partner_thank_you") + + @router.get("/verify-email/{token}") async def verify_email(token: str): with db() as conn: @@ -409,6 +488,9 @@ async def verify_email(token: str): "UPDATE users SET email_verified=1, verification_token=NULL WHERE id=?", (row["id"],) ) + # Dank-Mail an den Partner — nur beim ERSTEN Bestätigen (Link doppelt geklickt = kein Spam) + if not row["email_verified"]: + _notify_partner_registration(row["id"]) return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302) diff --git a/backend/static/index.html b/backend/static/index.html index 08c14dc..4ed0115 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -612,11 +612,11 @@ - - - - - + + + + + @@ -626,7 +626,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index f167267..573ad40 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 = '1259'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1260'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/landing.html b/backend/static/landing.html index e989a93..c57180e 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index f513e8a..6057213 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1259'; +const VER = '1260'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/tests/test_partner_qr.py b/tests/test_partner_qr.py index f02f1d9..84fa51e 100644 --- a/tests/test_partner_qr.py +++ b/tests/test_partner_qr.py @@ -123,6 +123,52 @@ def test_qr_token_must_match_code(client, admin): assert row["referred_qr"] is None +def test_partner_thank_you_mail(client, admin, user, monkeypatch): + """E-Mail-Bestaetigung eines Geworbenen -> Dank-Mail mit Statistik an den Code-Besitzer.""" + from database import db + with db() as conn: + conn.execute("UPDATE users SET is_partner=1 WHERE email=?", (user["email"],)) + uid = conn.execute("SELECT id FROM users WHERE email=?", (user["email"],)).fetchone()["id"] + + code = _create_code(client, admin) + batch = _create_batch(client, admin, code["id"], quantity=1) + token = _batch_tokens(batch["id"])[0] + client.post(f"/api/admin/partner/codes/{code['id']}/owner", + headers=admin["headers"], json={"user_id": uid}) + + sent = [] + import routes.outreach as outreach + monkeypatch.setattr(outreach, "_send_smtp", + lambda to, subject, body, account="partner", html=None: + sent.append({"to": to, "subject": subject, "body": body})) + + email = f"qrm-{secrets.token_hex(4)}@example.com" + r = client.post("/api/auth/register", json={ + "email": email, "password": "QrTest1234!", "name": f"qrm{secrets.token_hex(3)}", + "ref_code": code["code"], "qr_token": token, + }) + assert r.status_code == 200, r.text + with db() as conn: + vtoken = conn.execute( + "SELECT verification_token FROM users WHERE email=?", (email,) + ).fetchone()["verification_token"] + + sent.clear() # Verifikations-Mail an den Neuen ignorieren + r = client.get(f"/api/auth/verify-email/{vtoken}", follow_redirects=False) + assert r.status_code == 302 + + thank = [m for m in sent if m["to"] == user["email"]] + assert len(thank) == 1, f"Dank-Mail fehlt: {sent}" + assert "Danke" in thank[0]["subject"] + assert "1 bestätigte Registrierung" in thank[0]["body"] # Statistik + assert "#1" in thank[0]["body"] # QR-Sticker-Herkunft + + # Doppelt verifizieren -> keine zweite Mail + sent.clear() + client.get(f"/api/auth/verify-email/{vtoken}", follow_redirects=False) + assert not [m for m in sent if m["to"] == user["email"]] + + def test_partner_self_service_qr(client, admin, user): """Code-Besitzer sieht eigene Kontingente + kann PDF laden; Fremde nicht.""" from database import db