diff --git a/web/src/padelnomics/auth/routes.py b/web/src/padelnomics/auth/routes.py index 0bce851..77c4c3a 100644 --- a/web/src/padelnomics/auth/routes.py +++ b/web/src/padelnomics/auth/routes.py @@ -1,6 +1,7 @@ """ Auth domain: magic link authentication, user management, decorators. """ + import secrets from datetime import datetime, timedelta from functools import wraps @@ -47,19 +48,16 @@ async def pull_auth_lang() -> None: # SQL Queries # ============================================================================= + async def get_user_by_id(user_id: int) -> dict | None: """Get user by ID.""" - return await fetch_one( - "SELECT * FROM users WHERE id = ? AND deleted_at IS NULL", - (user_id,) - ) + return await fetch_one("SELECT * FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,)) async def get_user_by_email(email: str) -> dict | None: """Get user by email.""" return await fetch_one( - "SELECT * FROM users WHERE email = ? AND deleted_at IS NULL", - (email.lower(),) + "SELECT * FROM users WHERE email = ? AND deleted_at IS NULL", (email.lower(),) ) @@ -67,8 +65,7 @@ async def create_user(email: str) -> int: """Create new user, return ID.""" now = datetime.utcnow().isoformat() return await execute( - "INSERT INTO users (email, created_at) VALUES (?, ?)", - (email.lower(), now) + "INSERT INTO users (email, created_at) VALUES (?, ?)", (email.lower(), now) ) @@ -87,7 +84,7 @@ async def create_auth_token(user_id: int, token: str, minutes: int = None) -> in expires = datetime.utcnow() + timedelta(minutes=minutes) return await execute( "INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)", - (user_id, token, expires.isoformat()) + (user_id, token, expires.isoformat()), ) @@ -100,15 +97,14 @@ async def get_valid_token(token: str) -> dict | None: JOIN users u ON u.id = at.user_id WHERE at.token = ? AND at.expires_at > ? AND at.used_at IS NULL """, - (token, datetime.utcnow().isoformat()) + (token, datetime.utcnow().isoformat()), ) async def mark_token_used(token_id: int) -> None: """Mark token as used.""" await execute( - "UPDATE auth_tokens SET used_at = ? WHERE id = ?", - (datetime.utcnow().isoformat(), token_id) + "UPDATE auth_tokens SET used_at = ? WHERE id = ?", (datetime.utcnow().isoformat(), token_id) ) @@ -116,19 +112,23 @@ async def mark_token_used(token_id: int) -> None: # Decorators # ============================================================================= + def login_required(f): """Require authenticated user.""" + @wraps(f) async def decorated(*args, **kwargs): if not g.get("user"): await flash("Please sign in to continue.", "warning") return redirect(url_for("auth.login", next=request.path)) return await f(*args, **kwargs) + return decorated def role_required(*roles): """Require user to have at least one of the given roles.""" + def decorator(f): @wraps(f) async def decorated(*args, **kwargs): @@ -140,7 +140,9 @@ def role_required(*roles): await flash("You don't have permission to access that page.", "error") return redirect(url_for("dashboard.index")) return await f(*args, **kwargs) + return decorated + return decorator @@ -174,6 +176,7 @@ def subscription_required( Reads from g.subscription (eager-loaded in load_user) — zero extra queries. """ + def decorator(f): @wraps(f) async def decorated(*args, **kwargs): @@ -191,7 +194,9 @@ def subscription_required( return redirect(url_for("billing.pricing")) return await f(*args, **kwargs) + return decorated + return decorator @@ -199,13 +204,14 @@ def subscription_required( # Routes # ============================================================================= + @bp.route("/login", methods=["GET", "POST"]) @csrf_protect async def login(): """Login page - request magic link.""" if g.get("user"): return redirect(url_for("dashboard.index")) - + if request.method == "POST": _t = get_translations(g.lang) form = await request.form @@ -231,6 +237,7 @@ async def login(): # Queue email from ..worker import enqueue + await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang}) await flash(_t["auth_flash_login_sent"], "success") @@ -292,12 +299,13 @@ async def signup(): # Queue emails from ..worker import enqueue + await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang}) await enqueue("send_welcome", {"email": email, "lang": g.lang}) await flash(_t["auth_flash_signup_sent"], "success") return redirect(url_for("auth.magic_link_sent", email=email)) - + return await render_template("signup.html", plan=plan) @@ -305,7 +313,7 @@ async def signup(): async def verify(): """Verify magic link token.""" token = request.args.get("token") - + _t = get_translations(g.lang) if not token: @@ -360,15 +368,15 @@ async def dev_login(): """Instant login for development. Only works in DEBUG mode.""" if not config.DEBUG: return "Not available", 404 - + email = request.args.get("email", "dev@localhost") - + user = await get_user_by_email(email) if not user: user_id = await create_user(email) else: user_id = user["id"] - + session.permanent = True session["user_id"] = user_id @@ -397,6 +405,7 @@ async def resend(): await create_auth_token(user["id"], token) from ..worker import enqueue + await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang}) # Always show success (don't reveal if email exists) diff --git a/web/src/padelnomics/core.py b/web/src/padelnomics/core.py index f51553a..bd925f2 100644 --- a/web/src/padelnomics/core.py +++ b/web/src/padelnomics/core.py @@ -1,6 +1,7 @@ """ Core infrastructure: database, config, email, and shared utilities. """ + import hashlib import hmac import os @@ -30,19 +31,20 @@ def _env(key: str, default: str) -> str: # Configuration # ============================================================================= + class Config: APP_NAME: str = _env("APP_NAME", "Padelnomics") SECRET_KEY: str = _env("SECRET_KEY", "change-me-in-production") BASE_URL: str = _env("BASE_URL", "http://localhost:5000") DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true" - + DATABASE_PATH: str = os.getenv("DATABASE_PATH", "data/app.db") - + MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15")) SESSION_LIFETIME_DAYS: int = int(os.getenv("SESSION_LIFETIME_DAYS", "30")) - + PAYMENT_PROVIDER: str = "paddle" - + PADDLE_API_KEY: str = os.getenv("PADDLE_API_KEY", "") PADDLE_CLIENT_TOKEN: str = os.getenv("PADDLE_CLIENT_TOKEN", "") PADDLE_WEBHOOK_SECRET: str = os.getenv("PADDLE_WEBHOOK_SECRET", "") @@ -51,7 +53,7 @@ class Config: UMAMI_API_URL: str = os.getenv("UMAMI_API_URL", "https://umami.padelnomics.io") UMAMI_API_TOKEN: str = os.getenv("UMAMI_API_TOKEN", "") UMAMI_WEBSITE_ID: str = "4474414b-58d6-4c6e-89a1-df5ea1f49d70" - + RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "") EMAIL_FROM: str = _env("EMAIL_FROM", "hello@padelnomics.io") LEADS_EMAIL: str = _env("LEADS_EMAIL", "leads@padelnomics.io") @@ -63,13 +65,13 @@ class Config: RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100")) RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60")) - + PLAN_FEATURES: dict = { "free": ["basic"], "starter": ["basic", "export"], "pro": ["basic", "export", "api", "priority_support"], } - + PLAN_LIMITS: dict = { "free": {"items": 100, "api_calls": 1000}, "starter": {"items": 1000, "api_calls": 10000}, @@ -91,10 +93,10 @@ async def init_db(path: str = None) -> None: global _db db_path = path or config.DATABASE_PATH Path(db_path).parent.mkdir(parents=True, exist_ok=True) - + _db = await aiosqlite.connect(db_path) _db.row_factory = aiosqlite.Row - + await _db.execute("PRAGMA journal_mode=WAL") await _db.execute("PRAGMA foreign_keys=ON") await _db.execute("PRAGMA busy_timeout=5000") @@ -154,11 +156,11 @@ async def execute_many(sql: str, params_list: list[tuple]) -> None: class transaction: """Async context manager for transactions.""" - + async def __aenter__(self): self.db = await get_db() return self.db - + async def __aexit__(self, exc_type, exc_val, exc_tb): if exc_type is None: await self.db.commit() @@ -166,6 +168,7 @@ class transaction: await self.db.rollback() return False + # ============================================================================= # Email # ============================================================================= @@ -181,81 +184,134 @@ EMAIL_ADDRESSES = { # Input validation helpers # ────────────────────────────────────────────────────────────────────────────── -_DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset({ - # Germany / Austria / Switzerland common disposables - "byom.de", "trash-mail.de", "spamgourmet.de", "mailnull.com", - "spambog.de", "trashmail.de", "wegwerf-email.de", "spam4.me", - "yopmail.de", - # Global well-known disposables - "guerrillamail.com", "guerrillamail.net", "guerrillamail.org", - "guerrillamail.biz", "guerrillamail.de", "guerrillamail.info", - "guerrillamailblock.com", "grr.la", "spam4.me", - "mailinator.com", "mailinator.net", "mailinator.org", - "tempmail.com", "temp-mail.org", "tempmail.net", "tempmail.io", - "10minutemail.com", "10minutemail.net", "10minutemail.org", - "10minemail.com", "10minutemail.de", - "yopmail.com", "yopmail.fr", "yopmail.net", - "sharklasers.com", "guerrillamail.info", "grr.la", - "throwam.com", "throwam.net", - "maildrop.cc", "dispostable.com", - "discard.email", "discardmail.com", "discardmail.de", - "spamgourmet.com", "spamgourmet.net", - "trashmail.at", "trashmail.com", "trashmail.io", - "trashmail.me", "trashmail.net", "trashmail.org", - "trash-mail.at", "trash-mail.com", - "fakeinbox.com", "fakemail.fr", "fakemail.net", - "getnada.com", "getairmail.com", - "bccto.me", "chacuo.net", - "crapmail.org", "crap.email", - "spamherelots.com", "spamhereplease.com", - "throwam.com", "throwam.net", - "spamspot.com", "spamthisplease.com", - "filzmail.com", - "mytemp.email", "mynullmail.com", - "mailnesia.com", "mailnull.com", - "no-spam.ws", "noblepioneer.com", - "nospam.ze.tc", "nospam4.us", - "owlpic.com", - "pookmail.com", - "poof.email", - "qq1234.org", - "receivemail.org", - "rtrtr.com", - "s0ny.net", - "safetymail.info", - "shitmail.me", - "smellfear.com", - "spamavert.com", - "spambog.com", "spambog.net", "spambog.ru", - "spamgob.com", - "spamherelots.com", - "spamslicer.com", - "spamthisplease.com", - "spoofmail.de", - "super-auswahl.de", - "tempr.email", - "throwam.com", - "tilien.com", - "tmailinator.com", - "trashdevil.com", "trashdevil.de", - "trbvm.com", - "turual.com", - "uggsrock.com", - "viditag.com", - "vomoto.com", - "vpn.st", - "wegwerfemail.de", "wegwerfemail.net", "wegwerfemail.org", - "wetrainbayarea.com", - "willhackforfood.biz", - "wuzupmail.net", - "xemaps.com", - "xmailer.be", - "xoxy.net", - "yep.it", - "yogamaven.com", - "z1p.biz", - "zoemail.org", -}) +_DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset( + { + # Germany / Austria / Switzerland common disposables + "byom.de", + "trash-mail.de", + "spamgourmet.de", + "mailnull.com", + "spambog.de", + "trashmail.de", + "wegwerf-email.de", + "spam4.me", + "yopmail.de", + # Global well-known disposables + "guerrillamail.com", + "guerrillamail.net", + "guerrillamail.org", + "guerrillamail.biz", + "guerrillamail.de", + "guerrillamail.info", + "guerrillamailblock.com", + "grr.la", + "spam4.me", + "mailinator.com", + "mailinator.net", + "mailinator.org", + "tempmail.com", + "temp-mail.org", + "tempmail.net", + "tempmail.io", + "10minutemail.com", + "10minutemail.net", + "10minutemail.org", + "10minemail.com", + "10minutemail.de", + "yopmail.com", + "yopmail.fr", + "yopmail.net", + "sharklasers.com", + "guerrillamail.info", + "grr.la", + "throwam.com", + "throwam.net", + "maildrop.cc", + "dispostable.com", + "discard.email", + "discardmail.com", + "discardmail.de", + "spamgourmet.com", + "spamgourmet.net", + "trashmail.at", + "trashmail.com", + "trashmail.io", + "trashmail.me", + "trashmail.net", + "trashmail.org", + "trash-mail.at", + "trash-mail.com", + "fakeinbox.com", + "fakemail.fr", + "fakemail.net", + "getnada.com", + "getairmail.com", + "bccto.me", + "chacuo.net", + "crapmail.org", + "crap.email", + "spamherelots.com", + "spamhereplease.com", + "throwam.com", + "throwam.net", + "spamspot.com", + "spamthisplease.com", + "filzmail.com", + "mytemp.email", + "mynullmail.com", + "mailnesia.com", + "mailnull.com", + "no-spam.ws", + "noblepioneer.com", + "nospam.ze.tc", + "nospam4.us", + "owlpic.com", + "pookmail.com", + "poof.email", + "qq1234.org", + "receivemail.org", + "rtrtr.com", + "s0ny.net", + "safetymail.info", + "shitmail.me", + "smellfear.com", + "spamavert.com", + "spambog.com", + "spambog.net", + "spambog.ru", + "spamgob.com", + "spamherelots.com", + "spamslicer.com", + "spamthisplease.com", + "spoofmail.de", + "super-auswahl.de", + "tempr.email", + "throwam.com", + "tilien.com", + "tmailinator.com", + "trashdevil.com", + "trashdevil.de", + "trbvm.com", + "turual.com", + "uggsrock.com", + "viditag.com", + "vomoto.com", + "vpn.st", + "wegwerfemail.de", + "wegwerfemail.net", + "wegwerfemail.org", + "wetrainbayarea.com", + "willhackforfood.biz", + "wuzupmail.net", + "xemaps.com", + "xmailer.be", + "xoxy.net", + "yep.it", + "yogamaven.com", + "z1p.biz", + "zoemail.org", + } +) def is_disposable_email(email: str) -> bool: @@ -299,22 +355,26 @@ async def send_email( resend.api_key = config.RESEND_API_KEY try: - resend.Emails.send({ - "from": from_addr or config.EMAIL_FROM, - "to": to, - "subject": subject, - "html": html, - "text": text or html, - }) + resend.Emails.send( + { + "from": from_addr or config.EMAIL_FROM, + "to": to, + "subject": subject, + "html": html, + "text": text or html, + } + ) return True except Exception as e: print(f"[EMAIL] Error sending to {to}: {e}") return False + # ============================================================================= # Waitlist # ============================================================================= + async def _get_or_create_resend_audience(name: str) -> str | None: """Get cached Resend audience ID, or create one via API. Returns None on failure.""" row = await fetch_one("SELECT audience_id FROM resend_audiences WHERE name = ?", (name,)) @@ -349,7 +409,9 @@ def _audience_for_blueprint(blueprint: str) -> str: return _BLUEPRINT_TO_AUDIENCE.get(blueprint, "newsletter") -async def capture_waitlist_email(email: str, intent: str, plan: str = None, email_intent: str = None) -> bool: +async def capture_waitlist_email( + email: str, intent: str, plan: str = None, email_intent: str = None +) -> bool: """Insert email into waitlist, enqueue confirmation, add to Resend audience. Args: @@ -365,7 +427,7 @@ async def capture_waitlist_email(email: str, intent: str, plan: str = None, emai try: cursor_result = await execute( "INSERT OR IGNORE INTO waitlist (email, intent, plan, ip_address) VALUES (?, ?, ?, ?)", - (email, intent, plan, request.remote_addr) + (email, intent, plan, request.remote_addr), ) is_new = cursor_result > 0 except Exception: @@ -375,6 +437,7 @@ async def capture_waitlist_email(email: str, intent: str, plan: str = None, emai # Enqueue confirmation email only if new if is_new: from .worker import enqueue + email_intent_value = email_intent if email_intent is not None else intent lang = g.get("lang", "en") if g else "en" await enqueue("send_waitlist_confirmation", {"email": email, "intent": email_intent_value, "lang": lang}) @@ -394,10 +457,12 @@ async def capture_waitlist_email(email: str, intent: str, plan: str = None, emai return is_new + # ============================================================================= # CSRF Protection # ============================================================================= + def get_csrf_token() -> str: """Get or create CSRF token for current session.""" if "csrf_token" not in session: @@ -412,6 +477,7 @@ def validate_csrf_token(token: str) -> bool: def csrf_protect(f): """Decorator to require valid CSRF token for POST requests.""" + @wraps(f) async def decorated(*args, **kwargs): if request.method == "POST": @@ -420,12 +486,15 @@ def csrf_protect(f): if not validate_csrf_token(token): return {"error": "Invalid CSRF token"}, 403 return await f(*args, **kwargs) + return decorated + # ============================================================================= # Rate Limiting (SQLite-based) # ============================================================================= + async def check_rate_limit(key: str, limit: int = None, window: int = None) -> tuple[bool, dict]: """ Check if rate limit exceeded. Returns (is_allowed, info). @@ -435,39 +504,36 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t window = window or config.RATE_LIMIT_WINDOW now = datetime.utcnow() window_start = now - timedelta(seconds=window) - + # Clean old entries and count recent await execute( - "DELETE FROM rate_limits WHERE key = ? AND timestamp < ?", - (key, window_start.isoformat()) + "DELETE FROM rate_limits WHERE key = ? AND timestamp < ?", (key, window_start.isoformat()) ) - + result = await fetch_one( "SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?", - (key, window_start.isoformat()) + (key, window_start.isoformat()), ) count = result["count"] if result else 0 - + info = { "limit": limit, "remaining": max(0, limit - count - 1), "reset": int((window_start + timedelta(seconds=window)).timestamp()), } - + if count >= limit: return False, info - + # Record this request - await execute( - "INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)", - (key, now.isoformat()) - ) - + await execute("INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)", (key, now.isoformat())) + return True, info def rate_limit(limit: int = None, window: int = None, key_func=None): """Decorator for rate limiting routes.""" + def decorator(f): @wraps(f) async def decorated(*args, **kwargs): @@ -475,17 +541,20 @@ def rate_limit(limit: int = None, window: int = None, key_func=None): key = key_func() else: key = f"ip:{request.remote_addr}" - + allowed, info = await check_rate_limit(key, limit, window) - + if not allowed: response = {"error": "Rate limit exceeded", **info} return response, 429 - + return await f(*args, **kwargs) + return decorated + return decorator + # ============================================================================= # Request ID Tracking # ============================================================================= @@ -500,17 +569,19 @@ def get_request_id() -> str: def setup_request_id(app): """Setup request ID middleware.""" + @app.before_request async def set_request_id(): rid = request.headers.get("X-Request-ID") or secrets.token_hex(8) request_id_var.set(rid) g.request_id = rid - + @app.after_request async def add_request_id_header(response): response.headers["X-Request-ID"] = get_request_id() return response + # ============================================================================= # Webhook Signature Verification # ============================================================================= @@ -526,21 +597,19 @@ def verify_hmac_signature(payload: bytes, signature: str, secret: str) -> bool: # Soft Delete Helpers # ============================================================================= + async def soft_delete(table: str, id: int) -> bool: """Mark record as deleted.""" result = await execute( f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL", - (datetime.utcnow().isoformat(), id) + (datetime.utcnow().isoformat(), id), ) return result > 0 async def restore(table: str, id: int) -> bool: """Restore soft-deleted record.""" - result = await execute( - f"UPDATE {table} SET deleted_at = NULL WHERE id = ?", - (id,) - ) + result = await execute(f"UPDATE {table} SET deleted_at = NULL WHERE id = ?", (id,)) return result > 0 @@ -554,8 +623,7 @@ async def purge_deleted(table: str, days: int = 30) -> int: """Purge records deleted more than X days ago.""" cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat() return await execute( - f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", - (cutoff,) + f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,) ) @@ -563,11 +631,10 @@ async def purge_deleted(table: str, days: int = 30) -> int: # Paddle Product Lookup # ============================================================================= + async def get_paddle_price(key: str) -> str | None: """Look up a Paddle price ID by product key from the paddle_products table.""" - row = await fetch_one( - "SELECT paddle_price_id FROM paddle_products WHERE key = ?", (key,) - ) + row = await fetch_one("SELECT paddle_price_id FROM paddle_products WHERE key = ?", (key,)) return row["paddle_price_id"] if row else None @@ -581,6 +648,7 @@ async def get_all_paddle_prices() -> dict[str, str]: # Text Utilities # ============================================================================= + def slugify(text: str, max_length_chars: int = 80) -> str: """Convert text to URL-safe slug.""" text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() @@ -593,6 +661,7 @@ def slugify(text: str, max_length_chars: int = 80) -> str: # A/B Testing # ============================================================================= + def _has_functional_consent() -> bool: """Return True if the visitor has accepted functional cookies.""" return "functional" in request.cookies.get("cookie_consent", "") @@ -605,6 +674,7 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")): cookie consent. Without consent a random variant is picked per-request (so the page renders fine and Umami is tagged), but no cookie is set. """ + def decorator(f): @wraps(f) async def wrapper(*args, **kwargs): @@ -622,7 +692,9 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")): if has_consent: response.set_cookie(cookie_key, assigned, max_age=30 * 24 * 60 * 60) return response + return wrapper + return decorator @@ -646,6 +718,7 @@ def waitlist_gate(template: str, **extra_context): # POST handling and normal signup code here ... """ + def decorator(f): @wraps(f) async def decorated(*args, **kwargs): @@ -655,5 +728,7 @@ def waitlist_gate(template: str, **extra_context): ctx[key] = val() if callable(val) else val return await render_template(template, **ctx) return await f(*args, **kwargs) + return decorated + return decorator diff --git a/web/src/padelnomics/directory/routes.py b/web/src/padelnomics/directory/routes.py index 2769ad8..f0d3f59 100644 --- a/web/src/padelnomics/directory/routes.py +++ b/web/src/padelnomics/directory/routes.py @@ -1,6 +1,7 @@ """ Supplier directory: public, searchable listing of padel court suppliers. """ + from datetime import UTC, datetime from pathlib import Path @@ -17,17 +18,39 @@ bp = Blueprint( ) COUNTRY_LABELS = { - "DE": "Germany", "ES": "Spain", "IT": "Italy", "FR": "France", - "PT": "Portugal", "GB": "United Kingdom", "NL": "Netherlands", - "BE": "Belgium", "SE": "Sweden", "DK": "Denmark", "FI": "Finland", - "NO": "Norway", "AT": "Austria", "SI": "Slovenia", "IS": "Iceland", - "CH": "Switzerland", "EE": "Estonia", - "US": "United States", "CA": "Canada", - "MX": "Mexico", "BR": "Brazil", "AR": "Argentina", - "AE": "UAE", "SA": "Saudi Arabia", "TR": "Turkey", - "CN": "China", "IN": "India", "SG": "Singapore", - "ID": "Indonesia", "TH": "Thailand", "AU": "Australia", - "ZA": "South Africa", "EG": "Egypt", + "DE": "Germany", + "ES": "Spain", + "IT": "Italy", + "FR": "France", + "PT": "Portugal", + "GB": "United Kingdom", + "NL": "Netherlands", + "BE": "Belgium", + "SE": "Sweden", + "DK": "Denmark", + "FI": "Finland", + "NO": "Norway", + "AT": "Austria", + "SI": "Slovenia", + "IS": "Iceland", + "CH": "Switzerland", + "EE": "Estonia", + "US": "United States", + "CA": "Canada", + "MX": "Mexico", + "BR": "Brazil", + "AR": "Argentina", + "AE": "UAE", + "SA": "Saudi Arabia", + "TR": "Turkey", + "CN": "China", + "IN": "India", + "SG": "Singapore", + "ID": "Indonesia", + "TH": "Thailand", + "AU": "Australia", + "ZA": "South Africa", + "EG": "Egypt", } CATEGORY_LABELS = { @@ -75,9 +98,7 @@ async def _build_directory_query(q, country, category, region, page, per_page=24 terms = [t for t in q.split() if t] if terms: fts_q = " ".join(t + "*" for t in terms) - wheres.append( - "s.id IN (SELECT rowid FROM suppliers_fts WHERE suppliers_fts MATCH ?)" - ) + wheres.append("s.id IN (SELECT rowid FROM suppliers_fts WHERE suppliers_fts MATCH ?)") params.append(fts_q) if country: @@ -127,6 +148,7 @@ async def _build_directory_query(q, country, category, region, page, per_page=24 tuple(supplier_ids), ) import json + for row in color_rows: meta = {} if row["metadata"]: @@ -170,14 +192,11 @@ async def index(): ) category_counts = await fetch_all( - "SELECT category, COUNT(*) as cnt FROM suppliers" - " GROUP BY category ORDER BY cnt DESC" + "SELECT category, COUNT(*) as cnt FROM suppliers GROUP BY category ORDER BY cnt DESC" ) total_suppliers = await fetch_one("SELECT COUNT(*) as cnt FROM suppliers") - total_countries = await fetch_one( - "SELECT COUNT(DISTINCT country_code) as cnt FROM suppliers" - ) + total_countries = await fetch_one("SELECT COUNT(DISTINCT country_code) as cnt FROM suppliers") return await render_template( "directory.html", @@ -195,6 +214,7 @@ async def supplier_detail(slug: str): supplier = await fetch_one("SELECT * FROM suppliers WHERE slug = ?", (slug,)) if not supplier: from quart import abort + abort(404) # Get active boosts @@ -206,7 +226,9 @@ async def supplier_detail(slug: str): # Parse services_offered into list raw_services = (supplier.get("services_offered") or "").strip() - services_list = [s.strip() for s in raw_services.split(",") if s.strip()] if raw_services else [] + services_list = ( + [s.strip() for s in raw_services.split(",") if s.strip()] if raw_services else [] + ) # Build social links dict social_links = { @@ -250,12 +272,13 @@ async def supplier_enquiry(slug: str): ) if not supplier: from quart import abort + abort(404) form = await request.form - contact_name = (form.get("contact_name", "") or "").strip() + contact_name = (form.get("contact_name", "") or "").strip() contact_email = (form.get("contact_email", "") or "").strip().lower() - message = (form.get("message", "") or "").strip() + message = (form.get("message", "") or "").strip() errors = [] if not contact_name: @@ -294,15 +317,19 @@ async def supplier_enquiry(slug: str): # Enqueue email to supplier if supplier.get("contact_email"): from ..worker import enqueue - await enqueue("send_supplier_enquiry_email", { - "supplier_id": supplier["id"], - "supplier_name": supplier["name"], - "supplier_email": supplier["contact_email"], - "contact_name": contact_name, - "contact_email": contact_email, - "message": message, - "lang": g.get("lang", "en"), - }) + + await enqueue( + "send_supplier_enquiry_email", + { + "supplier_id": supplier["id"], + "supplier_name": supplier["name"], + "supplier_email": supplier["contact_email"], + "contact_name": contact_name, + "contact_email": contact_email, + "message": message, + "lang": g.get("lang", "en"), + }, + ) return await render_template( "partials/enquiry_result.html", @@ -317,6 +344,7 @@ async def supplier_website(slug: str): supplier = await fetch_one("SELECT website FROM suppliers WHERE slug = ?", (slug,)) if not supplier or not supplier["website"]: from quart import abort + abort(404) url = supplier["website"] if not url.startswith("http"): diff --git a/web/src/padelnomics/leads/routes.py b/web/src/padelnomics/leads/routes.py index 4828353..ba73d8c 100644 --- a/web/src/padelnomics/leads/routes.py +++ b/web/src/padelnomics/leads/routes.py @@ -1,6 +1,7 @@ """ Leads domain: capture interest in court suppliers and financing. """ + import json import secrets from datetime import datetime @@ -41,6 +42,7 @@ bp = Blueprint( # Heat Score Calculation # ============================================================================= + def calculate_heat_score(form: dict) -> str: """Score lead readiness from form data. Returns 'hot', 'warm', or 'cool'.""" score = 0 @@ -83,6 +85,7 @@ def calculate_heat_score(form: dict) -> str: # Routes # ============================================================================= + @bp.route("/suppliers", methods=["GET", "POST"]) @login_required @csrf_protect @@ -183,7 +186,11 @@ def _get_quote_steps(lang: str) -> list: {"n": 6, "title": t["q6_heading"], "required": ["financing_status", "decision_process"]}, {"n": 7, "title": t["q7_heading"], "required": ["stakeholder_type"]}, {"n": 8, "title": t["q8_heading"], "required": ["services_needed"]}, - {"n": 9, "title": t["q9_heading"], "required": ["contact_name", "contact_email", "contact_phone"]}, + { + "n": 9, + "title": t["q9_heading"], + "required": ["contact_name", "contact_email", "contact_phone"], + }, ] @@ -235,7 +242,9 @@ async def quote_step(step): if errors: return await render_template( f"partials/quote_step_{step}.html", - data=accumulated, step=step, steps=steps, + data=accumulated, + step=step, + steps=steps, errors=errors, ) # Return next step @@ -244,7 +253,9 @@ async def quote_step(step): next_step = len(steps) return await render_template( f"partials/quote_step_{next_step}.html", - data=accumulated, step=next_step, steps=steps, + data=accumulated, + step=next_step, + steps=steps, errors=[], ) @@ -252,7 +263,9 @@ async def quote_step(step): accumulated = _parse_accumulated(request.args) return await render_template( f"partials/quote_step_{step}.html", - data=accumulated, step=step, steps=steps, + data=accumulated, + step=step, + steps=steps, errors=[], ) @@ -296,7 +309,9 @@ async def quote_request(): if field_errors: if is_json: return jsonify({"ok": False, "errors": field_errors}), 422 - form_data = {k: v for k, v in form.items() if not k.startswith("_") and k != "csrf_token"} + form_data = { + k: v for k, v in form.items() if not k.startswith("_") and k != "csrf_token" + } form_data["services_needed"] = services return await render_template( "quote_request.html", @@ -310,6 +325,7 @@ async def quote_request(): # Compute credit cost from heat tier from ..credits import HEAT_CREDIT_COSTS + credit_cost = HEAT_CREDIT_COSTS.get(heat, HEAT_CREDIT_COSTS["cool"]) services_json = json.dumps(services) if services else None @@ -318,10 +334,7 @@ async def quote_request(): contact_email = form.get("contact_email", "").strip().lower() # Logged-in user with matching email → skip verification - is_verified_user = ( - g.user is not None - and g.user["email"].lower() == contact_email - ) + is_verified_user = g.user is not None and g.user["email"].lower() == contact_email status = "new" if is_verified_user else "pending_verification" lead_id = await execute( @@ -370,6 +383,7 @@ async def quote_request(): if config.RESEND_AUDIENCE_PLANNER and config.RESEND_API_KEY: try: import resend + resend.api_key = config.RESEND_API_KEY resend.Contacts.remove( audience_id=config.RESEND_AUDIENCE_PLANNER, @@ -423,23 +437,25 @@ async def quote_request(): token = secrets.token_urlsafe(32) await create_auth_token(new_user_id, token, minutes=60) - lead_token_row = await fetch_one( - "SELECT token FROM lead_requests WHERE id = ?", (lead_id,) - ) + lead_token_row = await fetch_one("SELECT token FROM lead_requests WHERE id = ?", (lead_id,)) lead_token = lead_token_row["token"] from ..worker import enqueue - await enqueue("send_quote_verification", { - "email": contact_email, - "token": token, - "lead_id": lead_id, - "lead_token": lead_token, - "lang": g.get("lang", "en"), - "contact_name": form.get("contact_name", ""), - "facility_type": form.get("facility_type", ""), - "court_count": form.get("court_count", ""), - "country": form.get("country", ""), - }) + + await enqueue( + "send_quote_verification", + { + "email": contact_email, + "token": token, + "lead_id": lead_id, + "lead_token": lead_token, + "lang": g.get("lang", "en"), + "contact_name": form.get("contact_name", ""), + "facility_type": form.get("facility_type", ""), + "court_count": form.get("court_count", ""), + "country": form.get("country", ""), + }, + ) if is_json: return jsonify({"ok": True, "pending_verification": True}) @@ -465,7 +481,9 @@ async def quote_request(): start_step = 2 # skip project step, already filled return await render_template( "quote_request.html", - data=data, step=start_step, steps=_get_quote_steps(g.get("lang", "en")), + data=data, + step=start_step, + steps=_get_quote_steps(g.get("lang", "en")), ) @@ -500,6 +518,7 @@ async def verify_quote(): # Compute credit cost and activate lead from ..credits import compute_credit_cost + credit_cost = compute_credit_cost(dict(lead)) now = datetime.utcnow().isoformat() await execute( @@ -535,6 +554,7 @@ async def verify_quote(): # Send welcome email from ..worker import enqueue + await enqueue("send_welcome", {"email": contact_email, "lang": g.get("lang", "en")}) return await render_template( diff --git a/web/src/padelnomics/suppliers/routes.py b/web/src/padelnomics/suppliers/routes.py index b5d7cc9..89ffb30 100644 --- a/web/src/padelnomics/suppliers/routes.py +++ b/web/src/padelnomics/suppliers/routes.py @@ -86,10 +86,34 @@ PLAN_FEATURES = { } BOOST_OPTIONS = [ - {"key": "boost_logo", "type": "logo", "name_key": "sd_boost_logo_name", "price": 29, "desc_key": "sd_boost_logo_desc"}, - {"key": "boost_highlight", "type": "highlight", "name_key": "sd_boost_highlight_name", "price": 39, "desc_key": "sd_boost_highlight_desc"}, - {"key": "boost_verified", "type": "verified", "name_key": "sd_boost_verified_name", "price": 49, "desc_key": "sd_boost_verified_desc"}, - {"key": "boost_card_color", "type": "card_color", "name_key": "sd_boost_card_color_name", "price": 19, "desc_key": "sd_boost_card_color_desc"}, + { + "key": "boost_logo", + "type": "logo", + "name_key": "sd_boost_logo_name", + "price": 29, + "desc_key": "sd_boost_logo_desc", + }, + { + "key": "boost_highlight", + "type": "highlight", + "name_key": "sd_boost_highlight_name", + "price": 39, + "desc_key": "sd_boost_highlight_desc", + }, + { + "key": "boost_verified", + "type": "verified", + "name_key": "sd_boost_verified_name", + "price": 49, + "desc_key": "sd_boost_verified_desc", + }, + { + "key": "boost_card_color", + "type": "card_color", + "name_key": "sd_boost_card_color_name", + "price": 19, + "desc_key": "sd_boost_card_color_desc", + }, ] CREDIT_PACK_OPTIONS = [ @@ -100,14 +124,51 @@ CREDIT_PACK_OPTIONS = [ ] SERVICE_CATEGORIES = [ - "manufacturer", "turnkey", "consultant", "hall_builder", - "turf", "lighting", "software", "industry_body", "franchise", + "manufacturer", + "turnkey", + "consultant", + "hall_builder", + "turf", + "lighting", + "software", + "industry_body", + "franchise", ] COUNTRIES = [ - "DE", "ES", "IT", "FR", "PT", "GB", "NL", "BE", "SE", "DK", "FI", - "NO", "AT", "SI", "IS", "CH", "EE", "US", "CA", "MX", "BR", "AR", - "AE", "SA", "TR", "CN", "IN", "SG", "ID", "TH", "AU", "ZA", "EG", + "DE", + "ES", + "IT", + "FR", + "PT", + "GB", + "NL", + "BE", + "SE", + "DK", + "FI", + "NO", + "AT", + "SI", + "IS", + "CH", + "EE", + "US", + "CA", + "MX", + "BR", + "AR", + "AE", + "SA", + "TR", + "CN", + "IN", + "SG", + "ID", + "TH", + "AU", + "ZA", + "EG", ] @@ -122,15 +183,14 @@ def _parse_accumulated(form_or_args): def _get_supplier_for_user(user_id: int): """Get the supplier record claimed by a user.""" - return fetch_one( - "SELECT * FROM suppliers WHERE claimed_by = ?", (user_id,) - ) + return fetch_one("SELECT * FROM suppliers WHERE claimed_by = ?", (user_id,)) # ============================================================================= # Auth decorator # ============================================================================= + def _supplier_required(f): """Require authenticated user with a claimed supplier on any paid tier (basic, growth, pro).""" from functools import wraps @@ -181,6 +241,7 @@ def _lead_tier_required(f): # Signup Wizard # ============================================================================= + @bp.route("/signup") @waitlist_gate( "suppliers/waitlist.html", @@ -370,6 +431,7 @@ async def signup_checkout(): return jsonify({"error": "Email is required."}), 400 from ..auth.routes import create_user, get_user_by_email + user = await get_user_by_email(email) if not user: user_id = await create_user(email) @@ -413,13 +475,15 @@ async def signup_checkout(): "plan": plan, } - return jsonify({ - "items": items, - "customData": custom_data, - "settings": { - "successUrl": f"{config.BASE_URL}/suppliers/signup/success", - }, - }) + return jsonify( + { + "items": items, + "customData": custom_data, + "settings": { + "successUrl": f"{config.BASE_URL}/suppliers/signup/success", + }, + } + ) @bp.route("/claim/") @@ -445,6 +509,7 @@ async def signup_success(): # Supplier Lead Feed # ============================================================================= + async def _get_lead_feed_data(supplier, country="", heat="", timeline="", q="", limit=50): """Shared query for lead feed — used by standalone and dashboard.""" wheres = ["lr.lead_type = 'quote'", "lr.status = 'new'", "lr.verified_at IS NOT NULL"] @@ -538,12 +603,16 @@ async def unlock_lead(token: str): # Enqueue lead forward email from ..worker import enqueue + lang = g.get("lang", "en") - await enqueue("send_lead_forward_email", { - "lead_id": lead_id, - "supplier_id": supplier["id"], - "lang": lang, - }) + await enqueue( + "send_lead_forward_email", + { + "lead_id": lead_id, + "supplier_id": supplier["id"], + "lang": lang, + }, + ) # Notify entrepreneur on first unlock lead = result["lead"] @@ -577,6 +646,7 @@ async def unlock_lead(token: str): # Supplier Dashboard # ============================================================================= + @bp.route("/dashboard") @_supplier_required async def dashboard(): @@ -680,7 +750,9 @@ async def dashboard_leads(): # Look up scenario IDs for unlocked leads scenario_ids = {} - unlocked_user_ids = [lead["user_id"] for lead in leads if lead.get("is_unlocked") and lead.get("user_id")] + unlocked_user_ids = [ + lead["user_id"] for lead in leads if lead.get("is_unlocked") and lead.get("user_id") + ] if unlocked_user_ids: placeholders = ",".join("?" * len(unlocked_user_ids)) scenarios = await fetch_all( diff --git a/web/src/padelnomics/worker.py b/web/src/padelnomics/worker.py index 062cbc9..def5f09 100644 --- a/web/src/padelnomics/worker.py +++ b/web/src/padelnomics/worker.py @@ -1,6 +1,7 @@ """ Background task worker - SQLite-based queue (no Redis needed). """ + import asyncio import json import traceback @@ -90,15 +91,17 @@ def _email_button(url: str, label: str) -> str: f'' f'' - f'{label}' + f"{label}" ) def task(name: str): """Decorator to register a task handler.""" + def decorator(f): HANDLERS[name] = f return f + return decorator @@ -106,6 +109,7 @@ def task(name: str): # Task Queue Operations # ============================================================================= + async def enqueue(task_name: str, payload: dict = None, run_at: datetime = None) -> int: """Add a task to the queue.""" return await execute( @@ -118,7 +122,7 @@ async def enqueue(task_name: str, payload: dict = None, run_at: datetime = None) json.dumps(payload or {}), (run_at or datetime.utcnow()).isoformat(), datetime.utcnow().isoformat(), - ) + ), ) @@ -132,7 +136,7 @@ async def get_pending_tasks(limit: int = 10) -> list[dict]: ORDER BY run_at ASC LIMIT ? """, - (now, limit) + (now, limit), ) @@ -140,31 +144,30 @@ async def mark_complete(task_id: int) -> None: """Mark task as completed.""" await execute( "UPDATE tasks SET status = 'complete', completed_at = ? WHERE id = ?", - (datetime.utcnow().isoformat(), task_id) + (datetime.utcnow().isoformat(), task_id), ) async def mark_failed(task_id: int, error: str, retries: int) -> None: """Mark task as failed, schedule retry if attempts remain.""" max_retries = 3 - + if retries < max_retries: # Exponential backoff: 1min, 5min, 25min - delay = timedelta(minutes=5 ** retries) + delay = timedelta(minutes=5**retries) run_at = datetime.utcnow() + delay - + await execute( """ UPDATE tasks SET status = 'pending', error = ?, retries = ?, run_at = ? WHERE id = ? """, - (error, retries + 1, run_at.isoformat(), task_id) + (error, retries + 1, run_at.isoformat(), task_id), ) else: await execute( - "UPDATE tasks SET status = 'failed', error = ? WHERE id = ?", - (error, task_id) + "UPDATE tasks SET status = 'failed', error = ? WHERE id = ?", (error, task_id) ) @@ -172,6 +175,7 @@ async def mark_failed(task_id: int, error: str, retries: int) -> None: # Built-in Task Handlers # ============================================================================= + @task("send_email") async def handle_send_email(payload: dict) -> None: """Send an email.""" @@ -191,10 +195,10 @@ async def handle_send_magic_link(payload: dict) -> None: link = f"{config.BASE_URL}/auth/verify?token={payload['token']}" if config.DEBUG: - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f" MAGIC LINK for {payload['email']}") print(f" {link}") - print(f"{'='*60}\n") + print(f"{'=' * 60}\n") body = ( f'

{_t("email_magic_link_heading", lang, app_name=config.APP_NAME)}

' @@ -223,12 +227,14 @@ async def handle_send_quote_verification(payload: dict) -> None: ) if config.DEBUG: - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f" QUOTE VERIFICATION for {payload['email']}") print(f" {link}") - print(f"{'='*60}\n") + print(f"{'=' * 60}\n") - first_name = payload.get("contact_name", "").split()[0] if payload.get("contact_name") else "there" + first_name = ( + payload.get("contact_name", "").split()[0] if payload.get("contact_name") else "there" + ) project_desc = "" parts = [] if payload.get("court_count"): @@ -322,10 +328,7 @@ async def handle_send_waitlist_confirmation(payload: dict) -> None: @task("cleanup_expired_tokens") async def handle_cleanup_tokens(payload: dict) -> None: """Clean up expired auth tokens.""" - await execute( - "DELETE FROM auth_tokens WHERE expires_at < ?", - (datetime.utcnow().isoformat(),) - ) + await execute("DELETE FROM auth_tokens WHERE expires_at < ?", (datetime.utcnow().isoformat(),)) @task("cleanup_rate_limits") @@ -360,7 +363,7 @@ async def handle_send_lead_forward_email(payload: dict) -> None: (t("email_lead_forward_lbl_facility"), f"{lead['facility_type'] or '-'} ({lead['build_context'] or '-'})"), (t("email_lead_forward_lbl_courts"), f"{courts} | Glass: {lead['glass_type'] or '-'} | Lighting: {lead['lighting_type'] or '-'}"), (t("email_lead_forward_lbl_location"), f"{lead['location'] or '-'}, {country}"), - (t("email_lead_forward_lbl_timeline"), f"{lead['timeline'] or '-'} | Budget: €{budget}"), + (t("email_lead_forward_lbl_timeline"), f"{lead['timeline'] or '-'} | Budget: \u20ac{budget}"), (t("email_lead_forward_lbl_phase"), f"{lead['location_status'] or '-'} | Financing: {lead['financing_status'] or '-'}"), (t("email_lead_forward_lbl_services"), lead["services_needed"] or "-"), (t("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"), @@ -455,10 +458,10 @@ async def handle_send_supplier_enquiry_email(payload: dict) -> None: if not supplier_email: return - supplier_name = payload.get("supplier_name", "") - contact_name = payload.get("contact_name", "") - contact_email = payload.get("contact_email", "") - message = payload.get("message", "") + supplier_name = payload.get("supplier_name", "") + contact_name = payload.get("contact_name", "") + contact_email = payload.get("contact_email", "") + message = payload.get("message", "") body = ( f'

' @@ -565,8 +568,7 @@ async def handle_cleanup_tasks(payload: dict) -> None: """Clean up completed/failed tasks older than 7 days.""" cutoff = (datetime.utcnow() - timedelta(days=7)).isoformat() await execute( - "DELETE FROM tasks WHERE status IN ('complete', 'failed') AND created_at < ?", - (cutoff,) + "DELETE FROM tasks WHERE status IN ('complete', 'failed') AND created_at < ?", (cutoff,) ) @@ -574,17 +576,18 @@ async def handle_cleanup_tasks(payload: dict) -> None: # Worker Loop # ============================================================================= + async def process_task(task: dict) -> None: """Process a single task.""" task_name = task["task_name"] task_id = task["id"] retries = task.get("retries", 0) - + handler = HANDLERS.get(task_name) if not handler: await mark_failed(task_id, f"Unknown task: {task_name}", retries) return - + try: payload = json.loads(task["payload"]) if task["payload"] else {} await handler(payload) @@ -600,17 +603,17 @@ async def run_worker(poll_interval: float = 1.0) -> None: """Main worker loop.""" print("[WORKER] Starting...") await init_db() - + while True: try: tasks = await get_pending_tasks(limit=10) - + for task in tasks: await process_task(task) - + if not tasks: await asyncio.sleep(poll_interval) - + except Exception as e: print(f"[WORKER] Error: {e}") await asyncio.sleep(poll_interval * 5) @@ -632,6 +635,7 @@ async def run_scheduler() -> None: # Monthly credit refill — run on the 1st of each month from datetime import datetime + today = datetime.utcnow() this_month = f"{today.year}-{today.month:02d}" if today.day == 1 and last_credit_refill != this_month: @@ -640,7 +644,7 @@ async def run_scheduler() -> None: print(f"[SCHEDULER] Queued monthly credit refill for {this_month}") await asyncio.sleep(3600) # 1 hour - + except Exception as e: print(f"[SCHEDULER] Error: {e}") await asyncio.sleep(60) @@ -648,7 +652,7 @@ async def run_scheduler() -> None: if __name__ == "__main__": import sys - + if len(sys.argv) > 1 and sys.argv[1] == "scheduler": asyncio.run(run_scheduler()) else: