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:
@@ -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,13 +204,14 @@ 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():
|
||||||
"""Login page - request magic link."""
|
"""Login page - request magic link."""
|
||||||
if g.get("user"):
|
if g.get("user"):
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
_t = get_translations(g.lang)
|
_t = get_translations(g.lang)
|
||||||
form = await request.form
|
form = await request.form
|
||||||
@@ -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,12 +299,13 @@ 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})
|
||||||
|
|
||||||
await flash(_t["auth_flash_signup_sent"], "success")
|
await flash(_t["auth_flash_signup_sent"], "success")
|
||||||
return redirect(url_for("auth.magic_link_sent", email=email))
|
return redirect(url_for("auth.magic_link_sent", email=email))
|
||||||
|
|
||||||
return await render_template("signup.html", plan=plan)
|
return await render_template("signup.html", plan=plan)
|
||||||
|
|
||||||
|
|
||||||
@@ -305,7 +313,7 @@ async def signup():
|
|||||||
async def verify():
|
async def verify():
|
||||||
"""Verify magic link token."""
|
"""Verify magic link token."""
|
||||||
token = request.args.get("token")
|
token = request.args.get("token")
|
||||||
|
|
||||||
_t = get_translations(g.lang)
|
_t = get_translations(g.lang)
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
@@ -360,15 +368,15 @@ async def dev_login():
|
|||||||
"""Instant login for development. Only works in DEBUG mode."""
|
"""Instant login for development. Only works in DEBUG mode."""
|
||||||
if not config.DEBUG:
|
if not config.DEBUG:
|
||||||
return "Not available", 404
|
return "Not available", 404
|
||||||
|
|
||||||
email = request.args.get("email", "dev@localhost")
|
email = request.args.get("email", "dev@localhost")
|
||||||
|
|
||||||
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)
|
||||||
else:
|
else:
|
||||||
user_id = user["id"]
|
user_id = user["id"]
|
||||||
|
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
session["user_id"] = user_id
|
session["user_id"] = user_id
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,19 +31,20 @@ 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")
|
||||||
BASE_URL: str = _env("BASE_URL", "http://localhost:5000")
|
BASE_URL: str = _env("BASE_URL", "http://localhost:5000")
|
||||||
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
|
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
|
||||||
|
|
||||||
DATABASE_PATH: str = os.getenv("DATABASE_PATH", "data/app.db")
|
DATABASE_PATH: str = os.getenv("DATABASE_PATH", "data/app.db")
|
||||||
|
|
||||||
MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15"))
|
MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15"))
|
||||||
SESSION_LIFETIME_DAYS: int = int(os.getenv("SESSION_LIFETIME_DAYS", "30"))
|
SESSION_LIFETIME_DAYS: int = int(os.getenv("SESSION_LIFETIME_DAYS", "30"))
|
||||||
|
|
||||||
PAYMENT_PROVIDER: str = "paddle"
|
PAYMENT_PROVIDER: str = "paddle"
|
||||||
|
|
||||||
PADDLE_API_KEY: str = os.getenv("PADDLE_API_KEY", "")
|
PADDLE_API_KEY: str = os.getenv("PADDLE_API_KEY", "")
|
||||||
PADDLE_CLIENT_TOKEN: str = os.getenv("PADDLE_CLIENT_TOKEN", "")
|
PADDLE_CLIENT_TOKEN: str = os.getenv("PADDLE_CLIENT_TOKEN", "")
|
||||||
PADDLE_WEBHOOK_SECRET: str = os.getenv("PADDLE_WEBHOOK_SECRET", "")
|
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_URL: str = os.getenv("UMAMI_API_URL", "https://umami.padelnomics.io")
|
||||||
UMAMI_API_TOKEN: str = os.getenv("UMAMI_API_TOKEN", "")
|
UMAMI_API_TOKEN: str = os.getenv("UMAMI_API_TOKEN", "")
|
||||||
UMAMI_WEBSITE_ID: str = "4474414b-58d6-4c6e-89a1-df5ea1f49d70"
|
UMAMI_WEBSITE_ID: str = "4474414b-58d6-4c6e-89a1-df5ea1f49d70"
|
||||||
|
|
||||||
RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
|
RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
|
||||||
EMAIL_FROM: str = _env("EMAIL_FROM", "hello@padelnomics.io")
|
EMAIL_FROM: str = _env("EMAIL_FROM", "hello@padelnomics.io")
|
||||||
LEADS_EMAIL: str = _env("LEADS_EMAIL", "leads@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_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
|
||||||
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
|
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
|
||||||
|
|
||||||
PLAN_FEATURES: dict = {
|
PLAN_FEATURES: dict = {
|
||||||
"free": ["basic"],
|
"free": ["basic"],
|
||||||
"starter": ["basic", "export"],
|
"starter": ["basic", "export"],
|
||||||
"pro": ["basic", "export", "api", "priority_support"],
|
"pro": ["basic", "export", "api", "priority_support"],
|
||||||
}
|
}
|
||||||
|
|
||||||
PLAN_LIMITS: dict = {
|
PLAN_LIMITS: dict = {
|
||||||
"free": {"items": 100, "api_calls": 1000},
|
"free": {"items": 100, "api_calls": 1000},
|
||||||
"starter": {"items": 1000, "api_calls": 10000},
|
"starter": {"items": 1000, "api_calls": 10000},
|
||||||
@@ -91,10 +93,10 @@ async def init_db(path: str = None) -> None:
|
|||||||
global _db
|
global _db
|
||||||
db_path = path or config.DATABASE_PATH
|
db_path = path or config.DATABASE_PATH
|
||||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
_db = await aiosqlite.connect(db_path)
|
_db = await aiosqlite.connect(db_path)
|
||||||
_db.row_factory = aiosqlite.Row
|
_db.row_factory = aiosqlite.Row
|
||||||
|
|
||||||
await _db.execute("PRAGMA journal_mode=WAL")
|
await _db.execute("PRAGMA journal_mode=WAL")
|
||||||
await _db.execute("PRAGMA foreign_keys=ON")
|
await _db.execute("PRAGMA foreign_keys=ON")
|
||||||
await _db.execute("PRAGMA busy_timeout=5000")
|
await _db.execute("PRAGMA busy_timeout=5000")
|
||||||
@@ -154,11 +156,11 @@ async def execute_many(sql: str, params_list: list[tuple]) -> None:
|
|||||||
|
|
||||||
class transaction:
|
class transaction:
|
||||||
"""Async context manager for transactions."""
|
"""Async context manager for transactions."""
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
self.db = await get_db()
|
self.db = await get_db()
|
||||||
return self.db
|
return self.db
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
if exc_type is None:
|
if exc_type is None:
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
@@ -166,6 +168,7 @@ class transaction:
|
|||||||
await self.db.rollback()
|
await self.db.rollback()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Email
|
# Email
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -181,81 +184,134 @@ 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
|
{
|
||||||
"byom.de", "trash-mail.de", "spamgourmet.de", "mailnull.com",
|
# Germany / Austria / Switzerland common disposables
|
||||||
"spambog.de", "trashmail.de", "wegwerf-email.de", "spam4.me",
|
"byom.de",
|
||||||
"yopmail.de",
|
"trash-mail.de",
|
||||||
# Global well-known disposables
|
"spamgourmet.de",
|
||||||
"guerrillamail.com", "guerrillamail.net", "guerrillamail.org",
|
"mailnull.com",
|
||||||
"guerrillamail.biz", "guerrillamail.de", "guerrillamail.info",
|
"spambog.de",
|
||||||
"guerrillamailblock.com", "grr.la", "spam4.me",
|
"trashmail.de",
|
||||||
"mailinator.com", "mailinator.net", "mailinator.org",
|
"wegwerf-email.de",
|
||||||
"tempmail.com", "temp-mail.org", "tempmail.net", "tempmail.io",
|
"spam4.me",
|
||||||
"10minutemail.com", "10minutemail.net", "10minutemail.org",
|
"yopmail.de",
|
||||||
"10minemail.com", "10minutemail.de",
|
# Global well-known disposables
|
||||||
"yopmail.com", "yopmail.fr", "yopmail.net",
|
"guerrillamail.com",
|
||||||
"sharklasers.com", "guerrillamail.info", "grr.la",
|
"guerrillamail.net",
|
||||||
"throwam.com", "throwam.net",
|
"guerrillamail.org",
|
||||||
"maildrop.cc", "dispostable.com",
|
"guerrillamail.biz",
|
||||||
"discard.email", "discardmail.com", "discardmail.de",
|
"guerrillamail.de",
|
||||||
"spamgourmet.com", "spamgourmet.net",
|
"guerrillamail.info",
|
||||||
"trashmail.at", "trashmail.com", "trashmail.io",
|
"guerrillamailblock.com",
|
||||||
"trashmail.me", "trashmail.net", "trashmail.org",
|
"grr.la",
|
||||||
"trash-mail.at", "trash-mail.com",
|
"spam4.me",
|
||||||
"fakeinbox.com", "fakemail.fr", "fakemail.net",
|
"mailinator.com",
|
||||||
"getnada.com", "getairmail.com",
|
"mailinator.net",
|
||||||
"bccto.me", "chacuo.net",
|
"mailinator.org",
|
||||||
"crapmail.org", "crap.email",
|
"tempmail.com",
|
||||||
"spamherelots.com", "spamhereplease.com",
|
"temp-mail.org",
|
||||||
"throwam.com", "throwam.net",
|
"tempmail.net",
|
||||||
"spamspot.com", "spamthisplease.com",
|
"tempmail.io",
|
||||||
"filzmail.com",
|
"10minutemail.com",
|
||||||
"mytemp.email", "mynullmail.com",
|
"10minutemail.net",
|
||||||
"mailnesia.com", "mailnull.com",
|
"10minutemail.org",
|
||||||
"no-spam.ws", "noblepioneer.com",
|
"10minemail.com",
|
||||||
"nospam.ze.tc", "nospam4.us",
|
"10minutemail.de",
|
||||||
"owlpic.com",
|
"yopmail.com",
|
||||||
"pookmail.com",
|
"yopmail.fr",
|
||||||
"poof.email",
|
"yopmail.net",
|
||||||
"qq1234.org",
|
"sharklasers.com",
|
||||||
"receivemail.org",
|
"guerrillamail.info",
|
||||||
"rtrtr.com",
|
"grr.la",
|
||||||
"s0ny.net",
|
"throwam.com",
|
||||||
"safetymail.info",
|
"throwam.net",
|
||||||
"shitmail.me",
|
"maildrop.cc",
|
||||||
"smellfear.com",
|
"dispostable.com",
|
||||||
"spamavert.com",
|
"discard.email",
|
||||||
"spambog.com", "spambog.net", "spambog.ru",
|
"discardmail.com",
|
||||||
"spamgob.com",
|
"discardmail.de",
|
||||||
"spamherelots.com",
|
"spamgourmet.com",
|
||||||
"spamslicer.com",
|
"spamgourmet.net",
|
||||||
"spamthisplease.com",
|
"trashmail.at",
|
||||||
"spoofmail.de",
|
"trashmail.com",
|
||||||
"super-auswahl.de",
|
"trashmail.io",
|
||||||
"tempr.email",
|
"trashmail.me",
|
||||||
"throwam.com",
|
"trashmail.net",
|
||||||
"tilien.com",
|
"trashmail.org",
|
||||||
"tmailinator.com",
|
"trash-mail.at",
|
||||||
"trashdevil.com", "trashdevil.de",
|
"trash-mail.com",
|
||||||
"trbvm.com",
|
"fakeinbox.com",
|
||||||
"turual.com",
|
"fakemail.fr",
|
||||||
"uggsrock.com",
|
"fakemail.net",
|
||||||
"viditag.com",
|
"getnada.com",
|
||||||
"vomoto.com",
|
"getairmail.com",
|
||||||
"vpn.st",
|
"bccto.me",
|
||||||
"wegwerfemail.de", "wegwerfemail.net", "wegwerfemail.org",
|
"chacuo.net",
|
||||||
"wetrainbayarea.com",
|
"crapmail.org",
|
||||||
"willhackforfood.biz",
|
"crap.email",
|
||||||
"wuzupmail.net",
|
"spamherelots.com",
|
||||||
"xemaps.com",
|
"spamhereplease.com",
|
||||||
"xmailer.be",
|
"throwam.com",
|
||||||
"xoxy.net",
|
"throwam.net",
|
||||||
"yep.it",
|
"spamspot.com",
|
||||||
"yogamaven.com",
|
"spamthisplease.com",
|
||||||
"z1p.biz",
|
"filzmail.com",
|
||||||
"zoemail.org",
|
"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:
|
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,
|
{
|
||||||
"to": to,
|
"from": from_addr or config.EMAIL_FROM,
|
||||||
"subject": subject,
|
"to": to,
|
||||||
"html": html,
|
"subject": subject,
|
||||||
"text": text or html,
|
"html": 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).
|
||||||
@@ -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
|
window = window or config.RATE_LIMIT_WINDOW
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
window_start = now - timedelta(seconds=window)
|
window_start = now - timedelta(seconds=window)
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
info = {
|
info = {
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"remaining": max(0, limit - count - 1),
|
"remaining": max(0, limit - count - 1),
|
||||||
"reset": int((window_start + timedelta(seconds=window)).timestamp()),
|
"reset": int((window_start + timedelta(seconds=window)).timestamp()),
|
||||||
}
|
}
|
||||||
|
|
||||||
if count >= limit:
|
if count >= limit:
|
||||||
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):
|
||||||
@@ -475,17 +541,20 @@ def rate_limit(limit: int = None, window: int = None, key_func=None):
|
|||||||
key = key_func()
|
key = key_func()
|
||||||
else:
|
else:
|
||||||
key = f"ip:{request.remote_addr}"
|
key = f"ip:{request.remote_addr}"
|
||||||
|
|
||||||
allowed, info = await check_rate_limit(key, limit, window)
|
allowed, info = await check_rate_limit(key, limit, window)
|
||||||
|
|
||||||
if not allowed:
|
if not allowed:
|
||||||
response = {"error": "Rate limit exceeded", **info}
|
response = {"error": "Rate limit exceeded", **info}
|
||||||
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,17 +569,19 @@ 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)
|
||||||
request_id_var.set(rid)
|
request_id_var.set(rid)
|
||||||
g.request_id = rid
|
g.request_id = rid
|
||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
async def add_request_id_header(response):
|
async def add_request_id_header(response):
|
||||||
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
|
||||||
|
|||||||
@@ -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,12 +272,13 @@ 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
|
||||||
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()
|
contact_email = (form.get("contact_email", "") or "").strip().lower()
|
||||||
message = (form.get("message", "") or "").strip()
|
message = (form.get("message", "") or "").strip()
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
if not contact_name:
|
if not contact_name:
|
||||||
@@ -294,15 +317,19 @@ 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", {
|
|
||||||
"supplier_id": supplier["id"],
|
await enqueue(
|
||||||
"supplier_name": supplier["name"],
|
"send_supplier_enquiry_email",
|
||||||
"supplier_email": supplier["contact_email"],
|
{
|
||||||
"contact_name": contact_name,
|
"supplier_id": supplier["id"],
|
||||||
"contact_email": contact_email,
|
"supplier_name": supplier["name"],
|
||||||
"message": message,
|
"supplier_email": supplier["contact_email"],
|
||||||
"lang": g.get("lang", "en"),
|
"contact_name": contact_name,
|
||||||
})
|
"contact_email": contact_email,
|
||||||
|
"message": message,
|
||||||
|
"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"):
|
||||||
|
|||||||
@@ -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,23 +437,25 @@ 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", {
|
|
||||||
"email": contact_email,
|
await enqueue(
|
||||||
"token": token,
|
"send_quote_verification",
|
||||||
"lead_id": lead_id,
|
{
|
||||||
"lead_token": lead_token,
|
"email": contact_email,
|
||||||
"lang": g.get("lang", "en"),
|
"token": token,
|
||||||
"contact_name": form.get("contact_name", ""),
|
"lead_id": lead_id,
|
||||||
"facility_type": form.get("facility_type", ""),
|
"lead_token": lead_token,
|
||||||
"court_count": form.get("court_count", ""),
|
"lang": g.get("lang", "en"),
|
||||||
"country": form.get("country", ""),
|
"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:
|
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(
|
||||||
|
|||||||
@@ -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,
|
{
|
||||||
"customData": custom_data,
|
"items": items,
|
||||||
"settings": {
|
"customData": custom_data,
|
||||||
"successUrl": f"{config.BASE_URL}/suppliers/signup/success",
|
"settings": {
|
||||||
},
|
"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(
|
||||||
"lead_id": lead_id,
|
"send_lead_forward_email",
|
||||||
"supplier_id": supplier["id"],
|
{
|
||||||
"lang": lang,
|
"lead_id": lead_id,
|
||||||
})
|
"supplier_id": supplier["id"],
|
||||||
|
"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(
|
||||||
|
|||||||
@@ -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,31 +144,30 @@ 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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def mark_failed(task_id: int, error: str, retries: int) -> None:
|
async def mark_failed(task_id: int, error: str, retries: int) -> None:
|
||||||
"""Mark task as failed, schedule retry if attempts remain."""
|
"""Mark task as failed, schedule retry if attempts remain."""
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
|
|
||||||
if retries < max_retries:
|
if retries < max_retries:
|
||||||
# Exponential backoff: 1min, 5min, 25min
|
# Exponential backoff: 1min, 5min, 25min
|
||||||
delay = timedelta(minutes=5 ** retries)
|
delay = timedelta(minutes=5**retries)
|
||||||
run_at = datetime.utcnow() + delay
|
run_at = datetime.utcnow() + delay
|
||||||
|
|
||||||
await execute(
|
await execute(
|
||||||
"""
|
"""
|
||||||
UPDATE tasks
|
UPDATE tasks
|
||||||
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."""
|
||||||
@@ -191,10 +195,10 @@ async def handle_send_magic_link(payload: dict) -> None:
|
|||||||
link = f"{config.BASE_URL}/auth/verify?token={payload['token']}"
|
link = f"{config.BASE_URL}/auth/verify?token={payload['token']}"
|
||||||
|
|
||||||
if config.DEBUG:
|
if config.DEBUG:
|
||||||
print(f"\n{'='*60}")
|
print(f"\n{'=' * 60}")
|
||||||
print(f" MAGIC LINK for {payload['email']}")
|
print(f" MAGIC LINK for {payload['email']}")
|
||||||
print(f" {link}")
|
print(f" {link}")
|
||||||
print(f"{'='*60}\n")
|
print(f"{'=' * 60}\n")
|
||||||
|
|
||||||
body = (
|
body = (
|
||||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_magic_link_heading", lang, app_name=config.APP_NAME)}</h2>'
|
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">{_t("email_magic_link_heading", lang, app_name=config.APP_NAME)}</h2>'
|
||||||
@@ -223,12 +227,14 @@ async def handle_send_quote_verification(payload: dict) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if config.DEBUG:
|
if config.DEBUG:
|
||||||
print(f"\n{'='*60}")
|
print(f"\n{'=' * 60}")
|
||||||
print(f" QUOTE VERIFICATION for {payload['email']}")
|
print(f" QUOTE VERIFICATION for {payload['email']}")
|
||||||
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 "-"),
|
||||||
@@ -455,10 +458,10 @@ async def handle_send_supplier_enquiry_email(payload: dict) -> None:
|
|||||||
if not supplier_email:
|
if not supplier_email:
|
||||||
return
|
return
|
||||||
|
|
||||||
supplier_name = payload.get("supplier_name", "")
|
supplier_name = payload.get("supplier_name", "")
|
||||||
contact_name = payload.get("contact_name", "")
|
contact_name = payload.get("contact_name", "")
|
||||||
contact_email = payload.get("contact_email", "")
|
contact_email = payload.get("contact_email", "")
|
||||||
message = payload.get("message", "")
|
message = payload.get("message", "")
|
||||||
|
|
||||||
body = (
|
body = (
|
||||||
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">'
|
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">'
|
||||||
@@ -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,17 +576,18 @@ 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"]
|
||||||
task_id = task["id"]
|
task_id = task["id"]
|
||||||
retries = task.get("retries", 0)
|
retries = task.get("retries", 0)
|
||||||
|
|
||||||
handler = HANDLERS.get(task_name)
|
handler = HANDLERS.get(task_name)
|
||||||
if not handler:
|
if not handler:
|
||||||
await mark_failed(task_id, f"Unknown task: {task_name}", retries)
|
await mark_failed(task_id, f"Unknown task: {task_name}", retries)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(task["payload"]) if task["payload"] else {}
|
payload = json.loads(task["payload"]) if task["payload"] else {}
|
||||||
await handler(payload)
|
await handler(payload)
|
||||||
@@ -600,17 +603,17 @@ async def run_worker(poll_interval: float = 1.0) -> None:
|
|||||||
"""Main worker loop."""
|
"""Main worker loop."""
|
||||||
print("[WORKER] Starting...")
|
print("[WORKER] Starting...")
|
||||||
await init_db()
|
await init_db()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
tasks = await get_pending_tasks(limit=10)
|
tasks = await get_pending_tasks(limit=10)
|
||||||
|
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
await process_task(task)
|
await process_task(task)
|
||||||
|
|
||||||
if not tasks:
|
if not tasks:
|
||||||
await asyncio.sleep(poll_interval)
|
await asyncio.sleep(poll_interval)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WORKER] Error: {e}")
|
print(f"[WORKER] Error: {e}")
|
||||||
await asyncio.sleep(poll_interval * 5)
|
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
|
# 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:
|
||||||
@@ -640,7 +644,7 @@ async def run_scheduler() -> None:
|
|||||||
print(f"[SCHEDULER] Queued monthly credit refill for {this_month}")
|
print(f"[SCHEDULER] Queued monthly credit refill for {this_month}")
|
||||||
|
|
||||||
await asyncio.sleep(3600) # 1 hour
|
await asyncio.sleep(3600) # 1 hour
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[SCHEDULER] Error: {e}")
|
print(f"[SCHEDULER] Error: {e}")
|
||||||
await asyncio.sleep(60)
|
await asyncio.sleep(60)
|
||||||
@@ -648,7 +652,7 @@ async def run_scheduler() -> None:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
if len(sys.argv) > 1 and sys.argv[1] == "scheduler":
|
if len(sys.argv) > 1 and sys.argv[1] == "scheduler":
|
||||||
asyncio.run(run_scheduler())
|
asyncio.run(run_scheduler())
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user