feat: email i18n + Resend audience restructuring

Add lang parameter to all enqueue() calls for email internationalization.
Restructure Resend audiences to 3 named audiences (owners, suppliers, waitlist).
Use _t() translation function in all email template handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-23 01:06:03 +01:00
parent 13c86ebf84
commit 4e8d94de47
6 changed files with 462 additions and 254 deletions

View File

@@ -1,6 +1,7 @@
""" """
Auth domain: magic link authentication, user management, decorators. Auth domain: magic link authentication, user management, decorators.
""" """
import secrets import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import wraps from functools import wraps
@@ -47,19 +48,16 @@ async def pull_auth_lang() -> None:
# SQL Queries # SQL Queries
# ============================================================================= # =============================================================================
async def get_user_by_id(user_id: int) -> dict | None: async def get_user_by_id(user_id: int) -> dict | None:
"""Get user by ID.""" """Get user by ID."""
return await fetch_one( return await fetch_one("SELECT * FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,))
"SELECT * FROM users WHERE id = ? AND deleted_at IS NULL",
(user_id,)
)
async def get_user_by_email(email: str) -> dict | None: async def get_user_by_email(email: str) -> dict | None:
"""Get user by email.""" """Get user by email."""
return await fetch_one( return await fetch_one(
"SELECT * FROM users WHERE email = ? AND deleted_at IS NULL", "SELECT * FROM users WHERE email = ? AND deleted_at IS NULL", (email.lower(),)
(email.lower(),)
) )
@@ -67,8 +65,7 @@ async def create_user(email: str) -> int:
"""Create new user, return ID.""" """Create new user, return ID."""
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
return await execute( return await execute(
"INSERT INTO users (email, created_at) VALUES (?, ?)", "INSERT INTO users (email, created_at) VALUES (?, ?)", (email.lower(), now)
(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) expires = datetime.utcnow() + timedelta(minutes=minutes)
return await execute( return await execute(
"INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)", "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 JOIN users u ON u.id = at.user_id
WHERE at.token = ? AND at.expires_at > ? AND at.used_at IS NULL 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: async def mark_token_used(token_id: int) -> None:
"""Mark token as used.""" """Mark token as used."""
await execute( await execute(
"UPDATE auth_tokens SET used_at = ? WHERE id = ?", "UPDATE auth_tokens SET used_at = ? WHERE id = ?", (datetime.utcnow().isoformat(), token_id)
(datetime.utcnow().isoformat(), token_id)
) )
@@ -116,19 +112,23 @@ async def mark_token_used(token_id: int) -> None:
# Decorators # Decorators
# ============================================================================= # =============================================================================
def login_required(f): def login_required(f):
"""Require authenticated user.""" """Require authenticated user."""
@wraps(f) @wraps(f)
async def decorated(*args, **kwargs): async def decorated(*args, **kwargs):
if not g.get("user"): if not g.get("user"):
await flash("Please sign in to continue.", "warning") await flash("Please sign in to continue.", "warning")
return redirect(url_for("auth.login", next=request.path)) return redirect(url_for("auth.login", next=request.path))
return await f(*args, **kwargs) return await f(*args, **kwargs)
return decorated return decorated
def role_required(*roles): def role_required(*roles):
"""Require user to have at least one of the given roles.""" """Require user to have at least one of the given roles."""
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)
async def decorated(*args, **kwargs): 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") await flash("You don't have permission to access that page.", "error")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
return await f(*args, **kwargs) return await f(*args, **kwargs)
return decorated return decorated
return decorator return decorator
@@ -174,6 +176,7 @@ def subscription_required(
Reads from g.subscription (eager-loaded in load_user) — zero extra queries. Reads from g.subscription (eager-loaded in load_user) — zero extra queries.
""" """
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)
async def decorated(*args, **kwargs): async def decorated(*args, **kwargs):
@@ -191,7 +194,9 @@ def subscription_required(
return redirect(url_for("billing.pricing")) return redirect(url_for("billing.pricing"))
return await f(*args, **kwargs) return await f(*args, **kwargs)
return decorated return decorated
return decorator return decorator
@@ -199,6 +204,7 @@ def subscription_required(
# Routes # Routes
# ============================================================================= # =============================================================================
@bp.route("/login", methods=["GET", "POST"]) @bp.route("/login", methods=["GET", "POST"])
@csrf_protect @csrf_protect
async def login(): async def login():
@@ -231,6 +237,7 @@ async def login():
# Queue email # Queue email
from ..worker import enqueue from ..worker import enqueue
await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang}) await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang})
await flash(_t["auth_flash_login_sent"], "success") await flash(_t["auth_flash_login_sent"], "success")
@@ -292,6 +299,7 @@ async def signup():
# Queue emails # Queue emails
from ..worker import enqueue from ..worker import enqueue
await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang}) await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang})
await enqueue("send_welcome", {"email": email, "lang": g.lang}) await enqueue("send_welcome", {"email": email, "lang": g.lang})
@@ -397,6 +405,7 @@ async def resend():
await create_auth_token(user["id"], token) await create_auth_token(user["id"], token)
from ..worker import enqueue from ..worker import enqueue
await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang}) await enqueue("send_magic_link", {"email": email, "token": token, "lang": g.lang})
# Always show success (don't reveal if email exists) # Always show success (don't reveal if email exists)

View File

@@ -1,6 +1,7 @@
""" """
Core infrastructure: database, config, email, and shared utilities. Core infrastructure: database, config, email, and shared utilities.
""" """
import hashlib import hashlib
import hmac import hmac
import os import os
@@ -30,6 +31,7 @@ def _env(key: str, default: str) -> str:
# Configuration # Configuration
# ============================================================================= # =============================================================================
class Config: class Config:
APP_NAME: str = _env("APP_NAME", "Padelnomics") APP_NAME: str = _env("APP_NAME", "Padelnomics")
SECRET_KEY: str = _env("SECRET_KEY", "change-me-in-production") SECRET_KEY: str = _env("SECRET_KEY", "change-me-in-production")
@@ -166,6 +168,7 @@ class transaction:
await self.db.rollback() await self.db.rollback()
return False return False
# ============================================================================= # =============================================================================
# Email # Email
# ============================================================================= # =============================================================================
@@ -181,40 +184,87 @@ EMAIL_ADDRESSES = {
# Input validation helpers # Input validation helpers
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
_DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset({ _DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset(
{
# Germany / Austria / Switzerland common disposables # Germany / Austria / Switzerland common disposables
"byom.de", "trash-mail.de", "spamgourmet.de", "mailnull.com", "byom.de",
"spambog.de", "trashmail.de", "wegwerf-email.de", "spam4.me", "trash-mail.de",
"spamgourmet.de",
"mailnull.com",
"spambog.de",
"trashmail.de",
"wegwerf-email.de",
"spam4.me",
"yopmail.de", "yopmail.de",
# Global well-known disposables # Global well-known disposables
"guerrillamail.com", "guerrillamail.net", "guerrillamail.org", "guerrillamail.com",
"guerrillamail.biz", "guerrillamail.de", "guerrillamail.info", "guerrillamail.net",
"guerrillamailblock.com", "grr.la", "spam4.me", "guerrillamail.org",
"mailinator.com", "mailinator.net", "mailinator.org", "guerrillamail.biz",
"tempmail.com", "temp-mail.org", "tempmail.net", "tempmail.io", "guerrillamail.de",
"10minutemail.com", "10minutemail.net", "10minutemail.org", "guerrillamail.info",
"10minemail.com", "10minutemail.de", "guerrillamailblock.com",
"yopmail.com", "yopmail.fr", "yopmail.net", "grr.la",
"sharklasers.com", "guerrillamail.info", "grr.la", "spam4.me",
"throwam.com", "throwam.net", "mailinator.com",
"maildrop.cc", "dispostable.com", "mailinator.net",
"discard.email", "discardmail.com", "discardmail.de", "mailinator.org",
"spamgourmet.com", "spamgourmet.net", "tempmail.com",
"trashmail.at", "trashmail.com", "trashmail.io", "temp-mail.org",
"trashmail.me", "trashmail.net", "trashmail.org", "tempmail.net",
"trash-mail.at", "trash-mail.com", "tempmail.io",
"fakeinbox.com", "fakemail.fr", "fakemail.net", "10minutemail.com",
"getnada.com", "getairmail.com", "10minutemail.net",
"bccto.me", "chacuo.net", "10minutemail.org",
"crapmail.org", "crap.email", "10minemail.com",
"spamherelots.com", "spamhereplease.com", "10minutemail.de",
"throwam.com", "throwam.net", "yopmail.com",
"spamspot.com", "spamthisplease.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", "filzmail.com",
"mytemp.email", "mynullmail.com", "mytemp.email",
"mailnesia.com", "mailnull.com", "mynullmail.com",
"no-spam.ws", "noblepioneer.com", "mailnesia.com",
"nospam.ze.tc", "nospam4.us", "mailnull.com",
"no-spam.ws",
"noblepioneer.com",
"nospam.ze.tc",
"nospam4.us",
"owlpic.com", "owlpic.com",
"pookmail.com", "pookmail.com",
"poof.email", "poof.email",
@@ -226,7 +276,9 @@ _DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset({
"shitmail.me", "shitmail.me",
"smellfear.com", "smellfear.com",
"spamavert.com", "spamavert.com",
"spambog.com", "spambog.net", "spambog.ru", "spambog.com",
"spambog.net",
"spambog.ru",
"spamgob.com", "spamgob.com",
"spamherelots.com", "spamherelots.com",
"spamslicer.com", "spamslicer.com",
@@ -237,14 +289,17 @@ _DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset({
"throwam.com", "throwam.com",
"tilien.com", "tilien.com",
"tmailinator.com", "tmailinator.com",
"trashdevil.com", "trashdevil.de", "trashdevil.com",
"trashdevil.de",
"trbvm.com", "trbvm.com",
"turual.com", "turual.com",
"uggsrock.com", "uggsrock.com",
"viditag.com", "viditag.com",
"vomoto.com", "vomoto.com",
"vpn.st", "vpn.st",
"wegwerfemail.de", "wegwerfemail.net", "wegwerfemail.org", "wegwerfemail.de",
"wegwerfemail.net",
"wegwerfemail.org",
"wetrainbayarea.com", "wetrainbayarea.com",
"willhackforfood.biz", "willhackforfood.biz",
"wuzupmail.net", "wuzupmail.net",
@@ -255,7 +310,8 @@ _DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset({
"yogamaven.com", "yogamaven.com",
"z1p.biz", "z1p.biz",
"zoemail.org", "zoemail.org",
}) }
)
def is_disposable_email(email: str) -> bool: def is_disposable_email(email: str) -> bool:
@@ -299,22 +355,26 @@ async def send_email(
resend.api_key = config.RESEND_API_KEY resend.api_key = config.RESEND_API_KEY
try: try:
resend.Emails.send({ resend.Emails.send(
{
"from": from_addr or config.EMAIL_FROM, "from": from_addr or config.EMAIL_FROM,
"to": to, "to": to,
"subject": subject, "subject": subject,
"html": html, "html": html,
"text": text or html, "text": text or html,
}) }
)
return True return True
except Exception as e: except Exception as e:
print(f"[EMAIL] Error sending to {to}: {e}") print(f"[EMAIL] Error sending to {to}: {e}")
return False return False
# ============================================================================= # =============================================================================
# Waitlist # Waitlist
# ============================================================================= # =============================================================================
async def _get_or_create_resend_audience(name: str) -> str | None: 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.""" """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,)) 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") 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. """Insert email into waitlist, enqueue confirmation, add to Resend audience.
Args: Args:
@@ -365,7 +427,7 @@ async def capture_waitlist_email(email: str, intent: str, plan: str = None, emai
try: try:
cursor_result = await execute( cursor_result = await execute(
"INSERT OR IGNORE INTO waitlist (email, intent, plan, ip_address) VALUES (?, ?, ?, ?)", "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 is_new = cursor_result > 0
except Exception: 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 # Enqueue confirmation email only if new
if is_new: if is_new:
from .worker import enqueue from .worker import enqueue
email_intent_value = email_intent if email_intent is not None else intent email_intent_value = email_intent if email_intent is not None else intent
lang = g.get("lang", "en") if g else "en" lang = g.get("lang", "en") if g else "en"
await enqueue("send_waitlist_confirmation", {"email": email, "intent": email_intent_value, "lang": lang}) 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 return is_new
# ============================================================================= # =============================================================================
# CSRF Protection # CSRF Protection
# ============================================================================= # =============================================================================
def get_csrf_token() -> str: def get_csrf_token() -> str:
"""Get or create CSRF token for current session.""" """Get or create CSRF token for current session."""
if "csrf_token" not in session: if "csrf_token" not in session:
@@ -412,6 +477,7 @@ def validate_csrf_token(token: str) -> bool:
def csrf_protect(f): def csrf_protect(f):
"""Decorator to require valid CSRF token for POST requests.""" """Decorator to require valid CSRF token for POST requests."""
@wraps(f) @wraps(f)
async def decorated(*args, **kwargs): async def decorated(*args, **kwargs):
if request.method == "POST": if request.method == "POST":
@@ -420,12 +486,15 @@ def csrf_protect(f):
if not validate_csrf_token(token): if not validate_csrf_token(token):
return {"error": "Invalid CSRF token"}, 403 return {"error": "Invalid CSRF token"}, 403
return await f(*args, **kwargs) return await f(*args, **kwargs)
return decorated return decorated
# ============================================================================= # =============================================================================
# Rate Limiting (SQLite-based) # Rate Limiting (SQLite-based)
# ============================================================================= # =============================================================================
async def check_rate_limit(key: str, limit: int = None, window: int = None) -> tuple[bool, dict]: 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). Check if rate limit exceeded. Returns (is_allowed, info).
@@ -438,13 +507,12 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t
# Clean old entries and count recent # Clean old entries and count recent
await execute( await execute(
"DELETE FROM rate_limits WHERE key = ? AND timestamp < ?", "DELETE FROM rate_limits WHERE key = ? AND timestamp < ?", (key, window_start.isoformat())
(key, window_start.isoformat())
) )
result = await fetch_one( result = await fetch_one(
"SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?", "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 count = result["count"] if result else 0
@@ -458,16 +526,14 @@ async def check_rate_limit(key: str, limit: int = None, window: int = None) -> t
return False, info return False, info
# Record this request # Record this request
await execute( await execute("INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)", (key, now.isoformat()))
"INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)",
(key, now.isoformat())
)
return True, info return True, info
def rate_limit(limit: int = None, window: int = None, key_func=None): def rate_limit(limit: int = None, window: int = None, key_func=None):
"""Decorator for rate limiting routes.""" """Decorator for rate limiting routes."""
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)
async def decorated(*args, **kwargs): async def decorated(*args, **kwargs):
@@ -483,9 +549,12 @@ def rate_limit(limit: int = None, window: int = None, key_func=None):
return response, 429 return response, 429
return await f(*args, **kwargs) return await f(*args, **kwargs)
return decorated return decorated
return decorator return decorator
# ============================================================================= # =============================================================================
# Request ID Tracking # Request ID Tracking
# ============================================================================= # =============================================================================
@@ -500,6 +569,7 @@ def get_request_id() -> str:
def setup_request_id(app): def setup_request_id(app):
"""Setup request ID middleware.""" """Setup request ID middleware."""
@app.before_request @app.before_request
async def set_request_id(): async def set_request_id():
rid = request.headers.get("X-Request-ID") or secrets.token_hex(8) rid = request.headers.get("X-Request-ID") or secrets.token_hex(8)
@@ -511,6 +581,7 @@ def setup_request_id(app):
response.headers["X-Request-ID"] = get_request_id() response.headers["X-Request-ID"] = get_request_id()
return response return response
# ============================================================================= # =============================================================================
# Webhook Signature Verification # Webhook Signature Verification
# ============================================================================= # =============================================================================
@@ -526,21 +597,19 @@ def verify_hmac_signature(payload: bytes, signature: str, secret: str) -> bool:
# Soft Delete Helpers # Soft Delete Helpers
# ============================================================================= # =============================================================================
async def soft_delete(table: str, id: int) -> bool: async def soft_delete(table: str, id: int) -> bool:
"""Mark record as deleted.""" """Mark record as deleted."""
result = await execute( result = await execute(
f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL", f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL",
(datetime.utcnow().isoformat(), id) (datetime.utcnow().isoformat(), id),
) )
return result > 0 return result > 0
async def restore(table: str, id: int) -> bool: async def restore(table: str, id: int) -> bool:
"""Restore soft-deleted record.""" """Restore soft-deleted record."""
result = await execute( result = await execute(f"UPDATE {table} SET deleted_at = NULL WHERE id = ?", (id,))
f"UPDATE {table} SET deleted_at = NULL WHERE id = ?",
(id,)
)
return result > 0 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.""" """Purge records deleted more than X days ago."""
cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat() cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
return await execute( return await execute(
f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
(cutoff,)
) )
@@ -563,11 +631,10 @@ async def purge_deleted(table: str, days: int = 30) -> int:
# Paddle Product Lookup # Paddle Product Lookup
# ============================================================================= # =============================================================================
async def get_paddle_price(key: str) -> str | None: async def get_paddle_price(key: str) -> str | None:
"""Look up a Paddle price ID by product key from the paddle_products table.""" """Look up a Paddle price ID by product key from the paddle_products table."""
row = await fetch_one( row = await fetch_one("SELECT paddle_price_id FROM paddle_products WHERE key = ?", (key,))
"SELECT paddle_price_id FROM paddle_products WHERE key = ?", (key,)
)
return row["paddle_price_id"] if row else None return row["paddle_price_id"] if row else None
@@ -581,6 +648,7 @@ async def get_all_paddle_prices() -> dict[str, str]:
# Text Utilities # Text Utilities
# ============================================================================= # =============================================================================
def slugify(text: str, max_length_chars: int = 80) -> str: def slugify(text: str, max_length_chars: int = 80) -> str:
"""Convert text to URL-safe slug.""" """Convert text to URL-safe slug."""
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode() 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 # A/B Testing
# ============================================================================= # =============================================================================
def _has_functional_consent() -> bool: def _has_functional_consent() -> bool:
"""Return True if the visitor has accepted functional cookies.""" """Return True if the visitor has accepted functional cookies."""
return "functional" in request.cookies.get("cookie_consent", "") 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 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. (so the page renders fine and Umami is tagged), but no cookie is set.
""" """
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
@@ -622,7 +692,9 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
if has_consent: if has_consent:
response.set_cookie(cookie_key, assigned, max_age=30 * 24 * 60 * 60) response.set_cookie(cookie_key, assigned, max_age=30 * 24 * 60 * 60)
return response return response
return wrapper return wrapper
return decorator return decorator
@@ -646,6 +718,7 @@ def waitlist_gate(template: str, **extra_context):
# POST handling and normal signup code here # POST handling and normal signup code here
... ...
""" """
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)
async def decorated(*args, **kwargs): async def decorated(*args, **kwargs):
@@ -655,5 +728,7 @@ def waitlist_gate(template: str, **extra_context):
ctx[key] = val() if callable(val) else val ctx[key] = val() if callable(val) else val
return await render_template(template, **ctx) return await render_template(template, **ctx)
return await f(*args, **kwargs) return await f(*args, **kwargs)
return decorated return decorated
return decorator return decorator

View File

@@ -1,6 +1,7 @@
""" """
Supplier directory: public, searchable listing of padel court suppliers. Supplier directory: public, searchable listing of padel court suppliers.
""" """
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
@@ -17,17 +18,39 @@ bp = Blueprint(
) )
COUNTRY_LABELS = { COUNTRY_LABELS = {
"DE": "Germany", "ES": "Spain", "IT": "Italy", "FR": "France", "DE": "Germany",
"PT": "Portugal", "GB": "United Kingdom", "NL": "Netherlands", "ES": "Spain",
"BE": "Belgium", "SE": "Sweden", "DK": "Denmark", "FI": "Finland", "IT": "Italy",
"NO": "Norway", "AT": "Austria", "SI": "Slovenia", "IS": "Iceland", "FR": "France",
"CH": "Switzerland", "EE": "Estonia", "PT": "Portugal",
"US": "United States", "CA": "Canada", "GB": "United Kingdom",
"MX": "Mexico", "BR": "Brazil", "AR": "Argentina", "NL": "Netherlands",
"AE": "UAE", "SA": "Saudi Arabia", "TR": "Turkey", "BE": "Belgium",
"CN": "China", "IN": "India", "SG": "Singapore", "SE": "Sweden",
"ID": "Indonesia", "TH": "Thailand", "AU": "Australia", "DK": "Denmark",
"ZA": "South Africa", "EG": "Egypt", "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 = { 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] terms = [t for t in q.split() if t]
if terms: if terms:
fts_q = " ".join(t + "*" for t in terms) fts_q = " ".join(t + "*" for t in terms)
wheres.append( wheres.append("s.id IN (SELECT rowid FROM suppliers_fts WHERE suppliers_fts MATCH ?)")
"s.id IN (SELECT rowid FROM suppliers_fts WHERE suppliers_fts MATCH ?)"
)
params.append(fts_q) params.append(fts_q)
if country: if country:
@@ -127,6 +148,7 @@ async def _build_directory_query(q, country, category, region, page, per_page=24
tuple(supplier_ids), tuple(supplier_ids),
) )
import json import json
for row in color_rows: for row in color_rows:
meta = {} meta = {}
if row["metadata"]: if row["metadata"]:
@@ -170,14 +192,11 @@ async def index():
) )
category_counts = await fetch_all( category_counts = await fetch_all(
"SELECT category, COUNT(*) as cnt FROM suppliers" "SELECT category, COUNT(*) as cnt FROM suppliers GROUP BY category ORDER BY cnt DESC"
" GROUP BY category ORDER BY cnt DESC"
) )
total_suppliers = await fetch_one("SELECT COUNT(*) as cnt FROM suppliers") total_suppliers = await fetch_one("SELECT COUNT(*) as cnt FROM suppliers")
total_countries = await fetch_one( total_countries = await fetch_one("SELECT COUNT(DISTINCT country_code) as cnt FROM suppliers")
"SELECT COUNT(DISTINCT country_code) as cnt FROM suppliers"
)
return await render_template( return await render_template(
"directory.html", "directory.html",
@@ -195,6 +214,7 @@ async def supplier_detail(slug: str):
supplier = await fetch_one("SELECT * FROM suppliers WHERE slug = ?", (slug,)) supplier = await fetch_one("SELECT * FROM suppliers WHERE slug = ?", (slug,))
if not supplier: if not supplier:
from quart import abort from quart import abort
abort(404) abort(404)
# Get active boosts # Get active boosts
@@ -206,7 +226,9 @@ async def supplier_detail(slug: str):
# Parse services_offered into list # Parse services_offered into list
raw_services = (supplier.get("services_offered") or "").strip() 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 # Build social links dict
social_links = { social_links = {
@@ -250,6 +272,7 @@ async def supplier_enquiry(slug: str):
) )
if not supplier: if not supplier:
from quart import abort from quart import abort
abort(404) abort(404)
form = await request.form form = await request.form
@@ -294,7 +317,10 @@ async def supplier_enquiry(slug: str):
# Enqueue email to supplier # Enqueue email to supplier
if supplier.get("contact_email"): if supplier.get("contact_email"):
from ..worker import enqueue from ..worker import enqueue
await enqueue("send_supplier_enquiry_email", {
await enqueue(
"send_supplier_enquiry_email",
{
"supplier_id": supplier["id"], "supplier_id": supplier["id"],
"supplier_name": supplier["name"], "supplier_name": supplier["name"],
"supplier_email": supplier["contact_email"], "supplier_email": supplier["contact_email"],
@@ -302,7 +328,8 @@ async def supplier_enquiry(slug: str):
"contact_email": contact_email, "contact_email": contact_email,
"message": message, "message": message,
"lang": g.get("lang", "en"), "lang": g.get("lang", "en"),
}) },
)
return await render_template( return await render_template(
"partials/enquiry_result.html", "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,)) supplier = await fetch_one("SELECT website FROM suppliers WHERE slug = ?", (slug,))
if not supplier or not supplier["website"]: if not supplier or not supplier["website"]:
from quart import abort from quart import abort
abort(404) abort(404)
url = supplier["website"] url = supplier["website"]
if not url.startswith("http"): if not url.startswith("http"):

View File

@@ -1,6 +1,7 @@
""" """
Leads domain: capture interest in court suppliers and financing. Leads domain: capture interest in court suppliers and financing.
""" """
import json import json
import secrets import secrets
from datetime import datetime from datetime import datetime
@@ -41,6 +42,7 @@ bp = Blueprint(
# Heat Score Calculation # Heat Score Calculation
# ============================================================================= # =============================================================================
def calculate_heat_score(form: dict) -> str: def calculate_heat_score(form: dict) -> str:
"""Score lead readiness from form data. Returns 'hot', 'warm', or 'cool'.""" """Score lead readiness from form data. Returns 'hot', 'warm', or 'cool'."""
score = 0 score = 0
@@ -83,6 +85,7 @@ def calculate_heat_score(form: dict) -> str:
# Routes # Routes
# ============================================================================= # =============================================================================
@bp.route("/suppliers", methods=["GET", "POST"]) @bp.route("/suppliers", methods=["GET", "POST"])
@login_required @login_required
@csrf_protect @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": 6, "title": t["q6_heading"], "required": ["financing_status", "decision_process"]},
{"n": 7, "title": t["q7_heading"], "required": ["stakeholder_type"]}, {"n": 7, "title": t["q7_heading"], "required": ["stakeholder_type"]},
{"n": 8, "title": t["q8_heading"], "required": ["services_needed"]}, {"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: if errors:
return await render_template( return await render_template(
f"partials/quote_step_{step}.html", f"partials/quote_step_{step}.html",
data=accumulated, step=step, steps=steps, data=accumulated,
step=step,
steps=steps,
errors=errors, errors=errors,
) )
# Return next step # Return next step
@@ -244,7 +253,9 @@ async def quote_step(step):
next_step = len(steps) next_step = len(steps)
return await render_template( return await render_template(
f"partials/quote_step_{next_step}.html", f"partials/quote_step_{next_step}.html",
data=accumulated, step=next_step, steps=steps, data=accumulated,
step=next_step,
steps=steps,
errors=[], errors=[],
) )
@@ -252,7 +263,9 @@ async def quote_step(step):
accumulated = _parse_accumulated(request.args) accumulated = _parse_accumulated(request.args)
return await render_template( return await render_template(
f"partials/quote_step_{step}.html", f"partials/quote_step_{step}.html",
data=accumulated, step=step, steps=steps, data=accumulated,
step=step,
steps=steps,
errors=[], errors=[],
) )
@@ -296,7 +309,9 @@ async def quote_request():
if field_errors: if field_errors:
if is_json: if is_json:
return jsonify({"ok": False, "errors": field_errors}), 422 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 form_data["services_needed"] = services
return await render_template( return await render_template(
"quote_request.html", "quote_request.html",
@@ -310,6 +325,7 @@ async def quote_request():
# Compute credit cost from heat tier # Compute credit cost from heat tier
from ..credits import HEAT_CREDIT_COSTS from ..credits import HEAT_CREDIT_COSTS
credit_cost = HEAT_CREDIT_COSTS.get(heat, HEAT_CREDIT_COSTS["cool"]) credit_cost = HEAT_CREDIT_COSTS.get(heat, HEAT_CREDIT_COSTS["cool"])
services_json = json.dumps(services) if services else None 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() contact_email = form.get("contact_email", "").strip().lower()
# Logged-in user with matching email → skip verification # Logged-in user with matching email → skip verification
is_verified_user = ( is_verified_user = g.user is not None and g.user["email"].lower() == contact_email
g.user is not None
and g.user["email"].lower() == contact_email
)
status = "new" if is_verified_user else "pending_verification" status = "new" if is_verified_user else "pending_verification"
lead_id = await execute( lead_id = await execute(
@@ -370,6 +383,7 @@ async def quote_request():
if config.RESEND_AUDIENCE_PLANNER and config.RESEND_API_KEY: if config.RESEND_AUDIENCE_PLANNER and config.RESEND_API_KEY:
try: try:
import resend import resend
resend.api_key = config.RESEND_API_KEY resend.api_key = config.RESEND_API_KEY
resend.Contacts.remove( resend.Contacts.remove(
audience_id=config.RESEND_AUDIENCE_PLANNER, audience_id=config.RESEND_AUDIENCE_PLANNER,
@@ -423,13 +437,14 @@ async def quote_request():
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
await create_auth_token(new_user_id, token, minutes=60) await create_auth_token(new_user_id, token, minutes=60)
lead_token_row = await fetch_one( lead_token_row = await fetch_one("SELECT token FROM lead_requests WHERE id = ?", (lead_id,))
"SELECT token FROM lead_requests WHERE id = ?", (lead_id,)
)
lead_token = lead_token_row["token"] lead_token = lead_token_row["token"]
from ..worker import enqueue from ..worker import enqueue
await enqueue("send_quote_verification", {
await enqueue(
"send_quote_verification",
{
"email": contact_email, "email": contact_email,
"token": token, "token": token,
"lead_id": lead_id, "lead_id": lead_id,
@@ -439,7 +454,8 @@ async def quote_request():
"facility_type": form.get("facility_type", ""), "facility_type": form.get("facility_type", ""),
"court_count": form.get("court_count", ""), "court_count": form.get("court_count", ""),
"country": form.get("country", ""), "country": form.get("country", ""),
}) },
)
if is_json: if is_json:
return jsonify({"ok": True, "pending_verification": True}) return jsonify({"ok": True, "pending_verification": True})
@@ -465,7 +481,9 @@ async def quote_request():
start_step = 2 # skip project step, already filled start_step = 2 # skip project step, already filled
return await render_template( return await render_template(
"quote_request.html", "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 # Compute credit cost and activate lead
from ..credits import compute_credit_cost from ..credits import compute_credit_cost
credit_cost = compute_credit_cost(dict(lead)) credit_cost = compute_credit_cost(dict(lead))
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
await execute( await execute(
@@ -535,6 +554,7 @@ async def verify_quote():
# Send welcome email # Send welcome email
from ..worker import enqueue from ..worker import enqueue
await enqueue("send_welcome", {"email": contact_email, "lang": g.get("lang", "en")}) await enqueue("send_welcome", {"email": contact_email, "lang": g.get("lang", "en")})
return await render_template( return await render_template(

View File

@@ -86,10 +86,34 @@ PLAN_FEATURES = {
} }
BOOST_OPTIONS = [ 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_logo",
{"key": "boost_verified", "type": "verified", "name_key": "sd_boost_verified_name", "price": 49, "desc_key": "sd_boost_verified_desc"}, "type": "logo",
{"key": "boost_card_color", "type": "card_color", "name_key": "sd_boost_card_color_name", "price": 19, "desc_key": "sd_boost_card_color_desc"}, "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 = [ CREDIT_PACK_OPTIONS = [
@@ -100,14 +124,51 @@ CREDIT_PACK_OPTIONS = [
] ]
SERVICE_CATEGORIES = [ SERVICE_CATEGORIES = [
"manufacturer", "turnkey", "consultant", "hall_builder", "manufacturer",
"turf", "lighting", "software", "industry_body", "franchise", "turnkey",
"consultant",
"hall_builder",
"turf",
"lighting",
"software",
"industry_body",
"franchise",
] ]
COUNTRIES = [ COUNTRIES = [
"DE", "ES", "IT", "FR", "PT", "GB", "NL", "BE", "SE", "DK", "FI", "DE",
"NO", "AT", "SI", "IS", "CH", "EE", "US", "CA", "MX", "BR", "AR", "ES",
"AE", "SA", "TR", "CN", "IN", "SG", "ID", "TH", "AU", "ZA", "EG", "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): def _get_supplier_for_user(user_id: int):
"""Get the supplier record claimed by a user.""" """Get the supplier record claimed by a user."""
return fetch_one( return fetch_one("SELECT * FROM suppliers WHERE claimed_by = ?", (user_id,))
"SELECT * FROM suppliers WHERE claimed_by = ?", (user_id,)
)
# ============================================================================= # =============================================================================
# Auth decorator # Auth decorator
# ============================================================================= # =============================================================================
def _supplier_required(f): def _supplier_required(f):
"""Require authenticated user with a claimed supplier on any paid tier (basic, growth, pro).""" """Require authenticated user with a claimed supplier on any paid tier (basic, growth, pro)."""
from functools import wraps from functools import wraps
@@ -181,6 +241,7 @@ def _lead_tier_required(f):
# Signup Wizard # Signup Wizard
# ============================================================================= # =============================================================================
@bp.route("/signup") @bp.route("/signup")
@waitlist_gate( @waitlist_gate(
"suppliers/waitlist.html", "suppliers/waitlist.html",
@@ -370,6 +431,7 @@ async def signup_checkout():
return jsonify({"error": "Email is required."}), 400 return jsonify({"error": "Email is required."}), 400
from ..auth.routes import create_user, get_user_by_email from ..auth.routes import create_user, get_user_by_email
user = await get_user_by_email(email) user = await get_user_by_email(email)
if not user: if not user:
user_id = await create_user(email) user_id = await create_user(email)
@@ -413,13 +475,15 @@ async def signup_checkout():
"plan": plan, "plan": plan,
} }
return jsonify({ return jsonify(
{
"items": items, "items": items,
"customData": custom_data, "customData": custom_data,
"settings": { "settings": {
"successUrl": f"{config.BASE_URL}/suppliers/signup/success", "successUrl": f"{config.BASE_URL}/suppliers/signup/success",
}, },
}) }
)
@bp.route("/claim/<slug>") @bp.route("/claim/<slug>")
@@ -445,6 +509,7 @@ async def signup_success():
# Supplier Lead Feed # Supplier Lead Feed
# ============================================================================= # =============================================================================
async def _get_lead_feed_data(supplier, country="", heat="", timeline="", q="", limit=50): async def _get_lead_feed_data(supplier, country="", heat="", timeline="", q="", limit=50):
"""Shared query for lead feed — used by standalone and dashboard.""" """Shared query for lead feed — used by standalone and dashboard."""
wheres = ["lr.lead_type = 'quote'", "lr.status = 'new'", "lr.verified_at IS NOT NULL"] 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 # Enqueue lead forward email
from ..worker import enqueue from ..worker import enqueue
lang = g.get("lang", "en") lang = g.get("lang", "en")
await enqueue("send_lead_forward_email", { await enqueue(
"send_lead_forward_email",
{
"lead_id": lead_id, "lead_id": lead_id,
"supplier_id": supplier["id"], "supplier_id": supplier["id"],
"lang": lang, "lang": lang,
}) },
)
# Notify entrepreneur on first unlock # Notify entrepreneur on first unlock
lead = result["lead"] lead = result["lead"]
@@ -577,6 +646,7 @@ async def unlock_lead(token: str):
# Supplier Dashboard # Supplier Dashboard
# ============================================================================= # =============================================================================
@bp.route("/dashboard") @bp.route("/dashboard")
@_supplier_required @_supplier_required
async def dashboard(): async def dashboard():
@@ -680,7 +750,9 @@ async def dashboard_leads():
# Look up scenario IDs for unlocked leads # Look up scenario IDs for unlocked leads
scenario_ids = {} 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: if unlocked_user_ids:
placeholders = ",".join("?" * len(unlocked_user_ids)) placeholders = ",".join("?" * len(unlocked_user_ids))
scenarios = await fetch_all( scenarios = await fetch_all(

View File

@@ -1,6 +1,7 @@
""" """
Background task worker - SQLite-based queue (no Redis needed). Background task worker - SQLite-based queue (no Redis needed).
""" """
import asyncio import asyncio
import json import json
import traceback import traceback
@@ -90,15 +91,17 @@ def _email_button(url: str, label: str) -> str:
f'<tr><td style="background-color:#1D4ED8;border-radius:7px;text-align:center;">' f'<tr><td style="background-color:#1D4ED8;border-radius:7px;text-align:center;">'
f'<a href="{url}" style="display:inline-block;padding:13px 30px;' f'<a href="{url}" style="display:inline-block;padding:13px 30px;'
f'color:#FFFFFF;font-size:15px;font-weight:600;text-decoration:none;letter-spacing:-0.01em;">' f'color:#FFFFFF;font-size:15px;font-weight:600;text-decoration:none;letter-spacing:-0.01em;">'
f'{label}</a></td></tr></table>' f"{label}</a></td></tr></table>"
) )
def task(name: str): def task(name: str):
"""Decorator to register a task handler.""" """Decorator to register a task handler."""
def decorator(f): def decorator(f):
HANDLERS[name] = f HANDLERS[name] = f
return f return f
return decorator return decorator
@@ -106,6 +109,7 @@ def task(name: str):
# Task Queue Operations # Task Queue Operations
# ============================================================================= # =============================================================================
async def enqueue(task_name: str, payload: dict = None, run_at: datetime = None) -> int: async def enqueue(task_name: str, payload: dict = None, run_at: datetime = None) -> int:
"""Add a task to the queue.""" """Add a task to the queue."""
return await execute( return await execute(
@@ -118,7 +122,7 @@ async def enqueue(task_name: str, payload: dict = None, run_at: datetime = None)
json.dumps(payload or {}), json.dumps(payload or {}),
(run_at or datetime.utcnow()).isoformat(), (run_at or datetime.utcnow()).isoformat(),
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 ORDER BY run_at ASC
LIMIT ? LIMIT ?
""", """,
(now, limit) (now, limit),
) )
@@ -140,7 +144,7 @@ async def mark_complete(task_id: int) -> None:
"""Mark task as completed.""" """Mark task as completed."""
await execute( await execute(
"UPDATE tasks SET status = 'complete', completed_at = ? WHERE id = ?", "UPDATE tasks SET status = 'complete', completed_at = ? WHERE id = ?",
(datetime.utcnow().isoformat(), task_id) (datetime.utcnow().isoformat(), task_id),
) )
@@ -159,12 +163,11 @@ async def mark_failed(task_id: int, error: str, retries: int) -> None:
SET status = 'pending', error = ?, retries = ?, run_at = ? SET status = 'pending', error = ?, retries = ?, run_at = ?
WHERE id = ? WHERE id = ?
""", """,
(error, retries + 1, run_at.isoformat(), task_id) (error, retries + 1, run_at.isoformat(), task_id),
) )
else: else:
await execute( await execute(
"UPDATE tasks SET status = 'failed', error = ? WHERE id = ?", "UPDATE tasks SET status = 'failed', error = ? WHERE id = ?", (error, task_id)
(error, task_id)
) )
@@ -172,6 +175,7 @@ async def mark_failed(task_id: int, error: str, retries: int) -> None:
# Built-in Task Handlers # Built-in Task Handlers
# ============================================================================= # =============================================================================
@task("send_email") @task("send_email")
async def handle_send_email(payload: dict) -> None: async def handle_send_email(payload: dict) -> None:
"""Send an email.""" """Send an email."""
@@ -228,7 +232,9 @@ async def handle_send_quote_verification(payload: dict) -> None:
print(f" {link}") 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 = "" project_desc = ""
parts = [] parts = []
if payload.get("court_count"): if payload.get("court_count"):
@@ -322,10 +328,7 @@ async def handle_send_waitlist_confirmation(payload: dict) -> None:
@task("cleanup_expired_tokens") @task("cleanup_expired_tokens")
async def handle_cleanup_tokens(payload: dict) -> None: async def handle_cleanup_tokens(payload: dict) -> None:
"""Clean up expired auth tokens.""" """Clean up expired auth tokens."""
await execute( await execute("DELETE FROM auth_tokens WHERE expires_at < ?", (datetime.utcnow().isoformat(),))
"DELETE FROM auth_tokens WHERE expires_at < ?",
(datetime.utcnow().isoformat(),)
)
@task("cleanup_rate_limits") @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_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_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_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_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_services"), lead["services_needed"] or "-"),
(t("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"), (t("email_lead_forward_lbl_additional"), lead["additional_info"] or "-"),
@@ -565,8 +568,7 @@ async def handle_cleanup_tasks(payload: dict) -> None:
"""Clean up completed/failed tasks older than 7 days.""" """Clean up completed/failed tasks older than 7 days."""
cutoff = (datetime.utcnow() - timedelta(days=7)).isoformat() cutoff = (datetime.utcnow() - timedelta(days=7)).isoformat()
await execute( await execute(
"DELETE FROM tasks WHERE status IN ('complete', 'failed') AND created_at < ?", "DELETE FROM tasks WHERE status IN ('complete', 'failed') AND created_at < ?", (cutoff,)
(cutoff,)
) )
@@ -574,6 +576,7 @@ async def handle_cleanup_tasks(payload: dict) -> None:
# Worker Loop # Worker Loop
# ============================================================================= # =============================================================================
async def process_task(task: dict) -> None: async def process_task(task: dict) -> None:
"""Process a single task.""" """Process a single task."""
task_name = task["task_name"] task_name = task["task_name"]
@@ -632,6 +635,7 @@ async def run_scheduler() -> None:
# Monthly credit refill — run on the 1st of each month # Monthly credit refill — run on the 1st of each month
from datetime import datetime from datetime import datetime
today = datetime.utcnow() today = datetime.utcnow()
this_month = f"{today.year}-{today.month:02d}" this_month = f"{today.year}-{today.month:02d}"
if today.day == 1 and last_credit_refill != this_month: if today.day == 1 and last_credit_refill != this_month: