diff --git a/account.html b/account.html new file mode 100644 index 0000000..4295271 --- /dev/null +++ b/account.html @@ -0,0 +1,177 @@ + + + + + Mon Compte — Turf IA + + + + +
+

⚙️ Mon compte

+
+ + + + +
+
+
+
Informations personnelles
+
Profil mis à jour.
+
+
+
+
+
+
+
+ +
+
+
+
Informations de compte
+
+
+
+
+
+
+
Changer le mot de passe
+
Mot de passe mis à jour.
+
+
+
+
+
+ +
+
+
+
Zone dangereuse
+

La suppression de votre compte est irréversible.

+ +
+
+
+
+
Votre plan actuel
+
+
Free
0€ /mois
  • 1 course/jour
  • Aperçu Top-3
+
Premium ⭐
9,90€ /mois
  • Toutes les courses
  • Alertes Telegram
  • Value bets
+
Pro 🚀
24,90€ /mois
  • Tout Premium
  • Export CSV
  • API REST
+
+

💳 Paiement Stripe disponible dans le Sprint 5-6.

+
+
+
+
+
Alertes Telegram
+
Préférences enregistrées.
+
+

Envoyez /start à @TurfIABot pour obtenir votre Chat ID.

+
+

Recevoir des alertes pour :

+ + + +
+ +
+
+
+
+
+ + + diff --git a/dashboard_saas.html b/dashboard_saas.html new file mode 100644 index 0000000..0644894 --- /dev/null +++ b/dashboard_saas.html @@ -0,0 +1,230 @@ + + + + + Dashboard — Turf IA + + + + +
+
+
Tableau de bord
+
+ + +
+
+
+ +
+
Courses analysées
aujourd'hui
+
Précision Top-3
30 derniers jours
+
Value bets du jour
+
Prochaine course
+
+
🏁 Prédictions du jourChargement…
+
Chargement des prédictions…
+ +
+
+
+ + + diff --git a/landing.html b/landing.html new file mode 100644 index 0000000..dced9da --- /dev/null +++ b/landing.html @@ -0,0 +1,226 @@ + + + + + + Turf IA — Prédictions PMU par Intelligence Artificielle + + + + + +
+
🤖 Intelligence Artificielle · XGBoost · Données PMU temps réel
+

Pariez plus intelligemment
grâce à l'IA

+

Nos modèles XGBoost analysent chaque course PMU en temps réel — cotes, historique, jockeys, météo — pour vous donner les meilleures prédictions du marché.

+
+ Commencer gratuitement + Voir comment ça marche +
+
+
+73%précision Top-3
+
150+courses analysées/jour
+
2.4stemps de réponse moyen
+
3 plansadaptés à chaque profil
+
+
+
+
+ +

Tout ce dont vous avez besoin pour gagner

+

Un moteur IA complet, des alertes instantanées, et des analyses détaillées pour chaque parieur.

+
+
+
🧠

Prédictions XGBoost

Modèle entraîné sur des milliers de courses PMU. Probabilités Top-1 et Top-3 pour chaque partant, mis à jour en continu.

+
💎

Value Bets identifiés

Détection automatique des cotes sous-évaluées par le marché. Seulement les paris où l'espérance mathématique est positive.

+
📱

Alertes Telegram

Recevez les meilleures opportunités directement sur votre téléphone, avant le départ, avec toutes les infos clés.

+
📊

Dashboard temps réel

Tableau de bord complet : courses du jour, historique de performance, ROI, statistiques par hippodrome et discipline.

+
🌤️

Analyse météo & terrain

Impact des conditions météo et de l'état du terrain intégré dans chaque prédiction pour une précision maximale.

+
📤

Export CSV & API

Exportez vos données, intégrez nos prédictions dans vos propres outils via notre API documentée (plan Pro).

+
+
+
+
+ +

En 3 étapes, prêt à parier

+

De l'inscription à votre première prédiction en moins de 2 minutes.

+
+
+
1

Créez votre compte gratuitement

Inscription en 30 secondes, sans carte bancaire. Accès immédiat au plan Free avec un aperçu des prédictions du jour.

+
2

Choisissez votre plan

Free pour découvrir, Premium (9,90€/mois) pour toutes les courses et alertes, Pro (24,90€/mois) pour l'API et les exports.

+
3

Recevez vos premières prédictions

Notre IA analyse les 150+ courses du jour. Accédez aux Top-3, value bets et probabilités depuis votre dashboard ou Telegram.

+
+
+
+
+ +

Des prix transparents

+

Commencez gratuitement. Passez au niveau supérieur quand vous êtes prêt.

+
+
+
+
Free
+
0/mois
+

Pour découvrir la puissance de l'IA turf.

+
    +
  • Aperçu Top-3 du jour (limité)
  • 1 course complète par jour
  • Statistiques basiques
  • +
  • Alertes Telegram
  • Toutes les courses
  • Value bets
  • +
+ Commencer gratuitement +
+ +
+
Pro
+
24,90/mois
+

Pour les professionnels et développeurs qui veulent tout.

+
    +
  • Tout du plan Premium
  • Export CSV illimité
  • Accès API REST documentée
  • +
  • Backtest personnalisé
  • Historique illimité
  • Support prioritaire
  • +
+ Choisir Pro +
+
+
+
+
+ +

Questions fréquentes

+
+
+
Comment fonctionne le modèle IA ?
Notre modèle XGBoost est entraîné sur plusieurs années de données PMU : cotes, historique des chevaux et drivers, conditions météo, état du terrain, statistiques par hippodrome. Il calcule pour chaque partant une probabilité d'arriver dans le Top-1 et Top-3.
+
Les prédictions garantissent-elles des gains ?
Non. Aucune prédiction ne garantit des gains. Le pari hippique reste un jeu de hasard. Notre IA améliore vos chances en identifiant les opportunités statistiquement favorables. Pariez de façon responsable.
+
Puis-je annuler à tout moment ?
Oui, sans engagement. Vous pouvez annuler votre abonnement Premium ou Pro à tout moment depuis votre espace compte.
+
Les alertes Telegram fonctionnent-elles sur mobile ?
Oui. Après activation dans vos paramètres, vous recevrez les alertes value bets et top picks directement dans votre application Telegram.
+
Quelles disciplines sont couvertes ?
Nous couvrons toutes les disciplines PMU : Plat, Trot Attelé, Trot Monté, et Galop sur les hippodromes français.
+
+
+
+
+

Prêt à parier plus intelligemment ?

+

Rejoignez des centaines de parieurs qui utilisent déjà Turf IA chaque jour. Essai gratuit, sans carte bancaire.

+ Créer mon compte gratuit → +
+
+ + + + + diff --git a/login.html b/login.html new file mode 100644 index 0000000..fd49071 --- /dev/null +++ b/login.html @@ -0,0 +1,102 @@ + + + + + Connexion — Turf IA + + + + +
+
+

Bon retour !

+

Connectez-vous à votre compte Turf IA.

+
+
+
+ + +
Email invalide.
+
+
+ + +
Mot de passe requis.
+
+ +
+
ou
+ +
+
+ + + + \ No newline at end of file diff --git a/onboarding.html b/onboarding.html new file mode 100644 index 0000000..4428083 --- /dev/null +++ b/onboarding.html @@ -0,0 +1,150 @@ + + + + + Bienvenue — Turf IA + + + +
+
+
1
+
+
2
+
+
3
+
+
+
+
Étape 1 sur 3
+

Bienvenue sur Turf IA 🏇

+

Votre compte est créé. Confirmez votre plan de départ pour personnaliser votre expérience.

+
+
🆓

Free

Aperçu quotidien, 1 course complète

0€
/mois
+

Premium

Toutes les courses, alertes Telegram

9,90€
/mois
+
🚀

Pro

API, export CSV, support prioritaire

24,90€
/mois
+
+
+
+
+
+
+
Étape 2 sur 3
+

Configurez vos alertes

+

Recevez les meilleures opportunités directement sur votre téléphone avant chaque départ.

+
+

📱 Alertes Telegram

+

Envoyez /start à @TurfIABot et collez votre Chat ID ci-dessous.

+
+ + +
+
+
+

🔔 Quand recevoir les alertes :

+ + + +
+
+ + +
+
+
+
+
+
Étape 3 sur 3
+
🎉
+

Vous êtes prêt !

+

Voici un aperçu de votre première prédiction du jour.

+
+
PRÉDICTION DU JOUR
+
🥇
Chargement…
+
+
    +
  • Compte créé et configuré
  • +
  • Alertes Telegram configurées
  • +
  • Dashboard accessible 24h/24
  • +
+ +
+
+
+ + + + diff --git a/portal_server.py b/portal_server.py index 906c62c..685196c 100755 --- a/portal_server.py +++ b/portal_server.py @@ -10,17 +10,64 @@ app = Flask(__name__) DASHBOARD_API_URL = "http://localhost:8791" COMBINED_API_URL = "http://localhost:8790" -COMBINED_API_URL = "http://localhost:8790" +SAAS_DIR = "/home/h3r7/turf_saas" + +# ─── SaaS Auth & API v1 blueprints (Sprint 4-5 HRT-30) ─────────────────────── +try: + from saas_auth import auth_bp + from saas_api_v1 import api_v1_bp + + app.register_blueprint(auth_bp) + app.register_blueprint(api_v1_bp) + print("[portal] SaaS auth & API v1 blueprints registered") +except Exception as e: + print(f"[portal] Warning: SaaS blueprints not loaded: {e}") + +# ─── Landing & SaaS pages ───────────────────────────────────────────────────── @app.route("/") -def portal(): - return send_from_directory("/home/h3r7/turf_saas", "portail.html") +def landing(): + """Marketing landing page (Sprint 4-5).""" + return send_from_directory(SAAS_DIR, "landing.html") + + +@app.route("/login") +def login_page(): + return send_from_directory(SAAS_DIR, "login.html") + + +@app.route("/register") +def register_page(): + return send_from_directory(SAAS_DIR, "register.html") + + +@app.route("/dashboard") +def dashboard_saas(): + return send_from_directory(SAAS_DIR, "dashboard_saas.html") + + +@app.route("/onboarding") +def onboarding(): + return send_from_directory(SAAS_DIR, "onboarding.html") + + +@app.route("/account") +def account(): + return send_from_directory(SAAS_DIR, "account.html") + + +@app.route("/portal") +@app.route("/portail") +def portal_legacy(): + return send_from_directory(SAAS_DIR, "portail.html") @app.route("/favicon.ico") def favicon(): - return send_from_directory("/home/h3r7/turf_saas", "favicon.ico") + return send_from_directory(SAAS_DIR, "favicon.ico") + + @app.route("/prompts", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) @app.route("/prompts/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) @app.route("/prompts/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) @@ -269,9 +316,7 @@ def niches_business(): @app.route("/template_restaurant_json.html") def template_restaurant(): - return send_from_directory( - "/home/h3r7/turf_saas", "template_restaurant_json.html" - ) + return send_from_directory("/home/h3r7/turf_saas", "template_restaurant_json.html") @app.route("/template_boulangerie_final.html") @@ -288,9 +333,7 @@ def template_artisan(): @app.route("/template_restaurant_final.html") def template_restaurant_final(): - return send_from_directory( - "/home/h3r7/turf_saas", "template_restaurant_final.html" - ) + return send_from_directory("/home/h3r7/turf_saas", "template_restaurant_final.html") @app.route("/template_complet.html") @@ -300,9 +343,7 @@ def template_complet(): @app.route("/boite_a_idees_dashboard") def boite_a_idees_dashboard(): - return send_from_directory( - "/home/h3r7/turf_saas", "boite_a_idees_dashboard.html" - ) + return send_from_directory("/home/h3r7/turf_saas", "boite_a_idees_dashboard.html") @app.route("/datagouv_explorer.html") @@ -345,13 +386,23 @@ def api_chat_workflows(): return jsonify([dict(w) for w in workflows]) except Exception as e: return jsonify({"error": str(e)}), 500 + + @app.route("/api/chat/nvidia-models", methods=["GET"]) def api_nvidia_models(): - return jsonify([ - {"id": k, "name": v.split("/")[-1].replace("-instruct", "").replace("-", " ").title(), "full_id": v} - for k, v in NVIDIA_MODELS.items() - ]) - + return jsonify( + [ + { + "id": k, + "name": v.split("/")[-1] + .replace("-instruct", "") + .replace("-", " ") + .title(), + "full_id": v, + } + for k, v in NVIDIA_MODELS.items() + ] + ) @app.route("/api/chat/sessions", methods=["GET"]) @@ -457,7 +508,9 @@ def api_chat_cleanup(): OPENCLAW_TOKEN = "szDd75yG15ZRADWEhGus3dfNoQve7ZhdxpGq9gudeEzoVhUqB0TomVJd90XcwkUz" OPENCLAW_CONTAINER = "openclaw-ow404080wwkkgkgc4oswwssc" -NVIDIA_API_KEY = "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" +NVIDIA_API_KEY = ( + "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" +) NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions" NVIDIA_MODEL = "meta/llama-3.1-8b-instruct" # Default model NVIDIA_MODELS = { @@ -476,7 +529,6 @@ NVIDIA_MODELS = { } - @app.route("/webhook/telegram", methods=["POST"]) def telegram_webhook(): try: @@ -542,25 +594,25 @@ def webhook_proxy(workflow_slug): model_key = request.json.get("model", "llama-3.1-8b") model_id = NVIDIA_MODELS.get(model_key, NVIDIA_MODEL) resp = requests.post( - NVIDIA_API_URL, - headers={ - "Authorization": f"Bearer {NVIDIA_API_KEY}", - "Content-Type": "application/json", - }, - json={ - "model": model_id, - "messages": [{"role": "user", "content": user_message}], - "max_tokens": 1024, - "temperature": 0.7, - }, - timeout=60, + NVIDIA_API_URL, + headers={ + "Authorization": f"Bearer {NVIDIA_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "model": model_id, + "messages": [{"role": "user", "content": user_message}], + "max_tokens": 1024, + "temperature": 0.7, + }, + timeout=60, ) data = resp.json() ai_response = ( - data.get("choices", [{}])[0] - .get("message", {}) - .get("content", str(data)) - ) + data.get("choices", [{}])[0] + .get("message", {}) + .get("content", str(data)) + ) else: # Proxy vers webhook n8n resp = requests.post( @@ -702,12 +754,17 @@ def api_proxy(api_path=""): url = f"{DASHBOARD_API_URL}/turf/api" try: fwd_method = request.method - fwd_json = request.get_json(silent=True) if fwd_method in ("POST", "PUT", "PATCH") else None + fwd_json = ( + request.get_json(silent=True) + if fwd_method in ("POST", "PUT", "PATCH") + else None + ) fwd_headers = {"Content-Type": "application/json"} if request.headers.get("Authorization"): fwd_headers["Authorization"] = request.headers.get("Authorization") - resp = requests.request(method=fwd_method, url=url, json=fwd_json, timeout=30, - headers=fwd_headers) + resp = requests.request( + method=fwd_method, url=url, json=fwd_json, timeout=30, headers=fwd_headers + ) return resp.content, resp.status_code, {"Content-Type": "application/json"} except Exception as e: return jsonify({"error": str(e), "url": url}), 500 @@ -744,23 +801,26 @@ def opencode_api(): return jsonify({"error": str(e)}), 500 - @app.route("/candidatures/") def candidatures_index(): return send_from_directory("/home/h3r7/turf_saas", "crm_candidatures.html") + @app.route("/candidatures/") def candidatures_static(filename): return send_from_directory("/home/h3r7/turf_saas", filename) + @app.route("/map") def map_visual(): return send_from_directory("/home/h3r7/turf_saas", "map_visual.html") + @app.route("/architecture.json") def architecture_json(): return send_from_directory("/home/h3r7/turf_saas", "architecture.json") + if __name__ == "__main__": app.run(host="0.0.0.0", port=8792, debug=False) @@ -827,5 +887,3 @@ def proxy_prompts_test(): return response except Exception as e: return f"Erreur proxy prompts: {e}", 502 - - diff --git a/register.html b/register.html new file mode 100644 index 0000000..7a4f3d1 --- /dev/null +++ b/register.html @@ -0,0 +1,129 @@ + + + + + Inscription — Turf IA + + + + +
+
+

Créer votre compte

+

Commencez gratuitement, sans carte bancaire.

+
+
+
+
Requis.
+
Requis.
+
+
Email invalide.
+
8 caractères minimum.
+
+

En vous inscrivant, vous acceptez nos CGU et notre Politique de Confidentialité. Vous devez avoir 18 ans ou plus.

+ +
+ +
+
+
Free
0€/mois
  • Aperçu Top-3 du jour
  • 1 course complète/jour
+ + +

⚡ Pas de carte bancaire pour le plan Free

+
+
+
© 2026 Turf IA — H3R7 Tech. Jouez de façon responsable. 18+
+ + + \ No newline at end of file diff --git a/saas_api_v1.py b/saas_api_v1.py new file mode 100644 index 0000000..7e89cd4 --- /dev/null +++ b/saas_api_v1.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +SaaS API v1 Blueprint — /api/v1/* +Stats, prédictions, résumés pour le dashboard SaaS. +Sprint 4-5 — HRT-30 +""" + +from flask import Blueprint, request, jsonify, Response +import sqlite3 +import csv +import io +import os +from datetime import datetime +from saas_auth import require_auth + +DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db") + +api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1") + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def plan_allows(user_plan, required): + order = {"free": 0, "premium": 1, "pro": 2} + return order.get(user_plan, 0) >= order.get(required, 0) + + +@api_v1_bp.route("/stats/summary", methods=["GET"]) +@require_auth +def stats_summary(): + today = datetime.now().strftime("%Y-%m-%d") + conn = get_db() + try: + courses_today = conn.execute( + "SELECT COUNT(DISTINCT num_reunion||'-'||num_course) FROM ml_predictions_cache WHERE date=?", + (today,) + ).fetchone()[0] or 0 + value_bets_today = conn.execute( + "SELECT COUNT(*) FROM ml_predictions_cache WHERE date=? AND is_value_bet=1", + (today,) + ).fetchone()[0] or 0 + acc_row = conn.execute(""" + SELECT CAST(SUM(CASE WHEN p.ordre_arrivee BETWEEN 1 AND 3 AND m.recommendation='top3' THEN 1 ELSE 0 END) AS FLOAT) + / NULLIF(COUNT(CASE WHEN m.recommendation='top3' THEN 1 END), 0) * 100 AS acc + FROM ml_predictions_cache m + JOIN pmu_partants p ON m.horse_name=p.nom AND m.date=p.date_programme + WHERE m.date >= date('now', '-30 days') + """).fetchone() + accuracy_top3 = round(acc_row[0], 1) if acc_row and acc_row[0] else None + next_race = conn.execute( + "SELECT heure, hippodrome FROM ml_predictions_cache WHERE date=? AND heure IS NOT NULL ORDER BY heure LIMIT 1", + (today,) + ).fetchone() + conn.close() + return jsonify({ + "courses_today": courses_today, + "value_bets_today": value_bets_today, + "accuracy_top3": accuracy_top3, + "next_race_time": next_race["heure"] if next_race else None, + "next_race_hippodrome": next_race["hippodrome"] if next_race else None, + }), 200 + except Exception as e: + conn.close() + return jsonify({"error": str(e), "courses_today": 0, "value_bets_today": 0}), 200 + + +@api_v1_bp.route("/predictions/today", methods=["GET"]) +@require_auth +def predictions_today(): + user = request.current_user + plan = user.get("plan", "free") + today = datetime.now().strftime("%Y-%m-%d") + conn = get_db() + try: + rows = conn.execute(""" + SELECT horse_name, horse_number, odds, prob_top1, prob_top3, + ml_score, recommendation, is_value_bet, is_outlier, + race_label, race_name, hippodrome, discipline, distance, + heure, risque_label, risque_score, num_reunion, num_course + FROM ml_predictions_cache + WHERE date=? + ORDER BY num_reunion, num_course, ml_score DESC + """, (today,)).fetchall() + conn.close() + predictions = [dict(r) for r in rows] + if plan == "free" and predictions: + first = predictions[0] + first_key = (first["num_reunion"], first["num_course"]) + predictions = [p for p in predictions if (p["num_reunion"], p["num_course"]) == first_key] + for p in predictions: + p["is_value_bet"] = 0 + return jsonify({"date": today, "plan": plan, "count": len(predictions), "predictions": predictions}), 200 + except Exception as e: + conn.close() + return jsonify({"error": str(e), "predictions": []}), 200 + + +@api_v1_bp.route("/value-bets/today", methods=["GET"]) +@require_auth +def value_bets_today(): + user = request.current_user + plan = user.get("plan", "free") + if not plan_allows(plan, "premium"): + return jsonify({"error": "Cette fonctionnalité requiert un plan Premium ou Pro.", "upgrade_required": True}), 403 + today = datetime.now().strftime("%Y-%m-%d") + conn = get_db() + rows = conn.execute(""" + SELECT horse_name, race_label, race_name, hippodrome, odds, prob_top3, ml_score, risque_label, heure + FROM ml_predictions_cache WHERE date=? AND is_value_bet=1 ORDER BY ml_score DESC + """, (today,)).fetchall() + conn.close() + return jsonify({"value_bets": [dict(r) for r in rows], "count": len(rows)}), 200 + + +@api_v1_bp.route("/export/csv", methods=["GET"]) +@require_auth +def export_csv(): + user = request.current_user + plan = user.get("plan", "free") + if not plan_allows(plan, "pro"): + return jsonify({"error": "L'export CSV requiert un plan Pro.", "upgrade_required": True}), 403 + date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d")) + conn = get_db() + rows = conn.execute( + "SELECT * FROM ml_predictions_cache WHERE date=? ORDER BY num_reunion, num_course, ml_score DESC", + (date_param,) + ).fetchall() + conn.close() + output = io.StringIO() + if rows: + writer = csv.DictWriter(output, fieldnames=rows[0].keys()) + writer.writeheader() + writer.writerows([dict(r) for r in rows]) + return Response( + output.getvalue(), + mimetype="text/csv", + headers={"Content-Disposition": f"attachment; filename=turf_ia_{date_param}.csv"} + ) diff --git a/saas_auth.py b/saas_auth.py new file mode 100644 index 0000000..2117843 --- /dev/null +++ b/saas_auth.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +""" +SaaS Auth Blueprint — /api/v1/auth/* +Gestion des utilisateurs, JWT, plans, préférences. +Sprint 4-5 — HRT-30 +""" + +from flask import Blueprint, request, jsonify +import sqlite3 +import hashlib +import secrets +import os +import time +from functools import wraps +from datetime import datetime + +DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db") +JWT_SECRET = os.environ.get("JWT_SECRET", secrets.token_hex(32)) +TOKEN_TTL = int(os.environ.get("JWT_TTL_SECONDS", 30 * 24 * 3600)) # 30 days + +auth_bp = Blueprint("auth_v1", __name__, url_prefix="/api/v1/auth") + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_users_table(): + conn = get_db() + conn.executescript(""" + CREATE TABLE IF NOT EXISTS saas_users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + firstname TEXT DEFAULT '', + lastname TEXT DEFAULT '', + password_hash TEXT NOT NULL, + plan TEXT DEFAULT 'free', + telegram_chat_id TEXT DEFAULT NULL, + alert_value_bets INTEGER DEFAULT 1, + alert_top1 INTEGER DEFAULT 1, + alert_quinte_only INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS saas_tokens ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + """) + conn.commit() + conn.close() + + +try: + init_users_table() +except Exception as e: + print(f"[auth_bp] DB init warning: {e}") + + +def generate_token(user_id): + token = secrets.token_urlsafe(48) + expires = int(time.time()) + TOKEN_TTL + conn = get_db() + conn.execute("INSERT INTO saas_tokens (token, user_id, expires_at) VALUES (?,?,?)", (token, user_id, expires)) + conn.commit() + conn.close() + return token + + +def validate_token(token): + if not token: + return None + conn = get_db() + now = int(time.time()) + row = conn.execute( + "SELECT t.user_id, u.* FROM saas_tokens t JOIN saas_users u ON t.user_id=u.id " + "WHERE t.token=? AND t.expires_at>?", + (token, now) + ).fetchone() + conn.close() + return dict(row) if row else None + + +def hash_password(password): + return hashlib.sha256(password.encode("utf-8")).hexdigest() + + +def require_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + auth = request.headers.get("Authorization", "") + token = auth[7:].strip() if auth.startswith("Bearer ") else None + user = validate_token(token) + if not user: + return jsonify({"error": "Non authentifié"}), 401 + request.current_user = user + return f(*args, **kwargs) + return decorated + + +def user_to_dict(user): + if isinstance(user, sqlite3.Row): + user = dict(user) + return { + "id": user.get("id"), + "email": user.get("email"), + "firstname": user.get("firstname", ""), + "lastname": user.get("lastname", ""), + "plan": user.get("plan", "free"), + "telegram_chat_id": user.get("telegram_chat_id"), + "alert_value_bets": bool(user.get("alert_value_bets", 1)), + "alert_top1": bool(user.get("alert_top1", 1)), + "alert_quinte_only": bool(user.get("alert_quinte_only", 0)), + "created_at": user.get("created_at"), + } + + +@auth_bp.route("/register", methods=["POST"]) +def register(): + data = request.get_json(silent=True) or {} + email = (data.get("email") or "").strip().lower() + password = data.get("password") or "" + firstname = (data.get("firstname") or "").strip() + lastname = (data.get("lastname") or "").strip() + plan = data.get("plan", "free") + + if not email or "@" not in email: + return jsonify({"error": "Adresse email invalide."}), 400 + if len(password) < 8: + return jsonify({"error": "Mot de passe trop court (8 caractères minimum)."}), 400 + if plan not in ("free", "premium", "pro"): + plan = "free" + + uid = secrets.token_hex(16) + pw_hash = hash_password(password) + conn = get_db() + try: + conn.execute( + "INSERT INTO saas_users (id, email, firstname, lastname, password_hash, plan) VALUES (?,?,?,?,?,?)", + (uid, email, firstname, lastname, pw_hash, plan) + ) + conn.commit() + except sqlite3.IntegrityError: + conn.close() + return jsonify({"error": "Cette adresse email est déjà utilisée."}), 409 + conn.close() + token = generate_token(uid) + user_row = validate_token(token) + return jsonify({"token": token, "user": user_to_dict(user_row)}), 201 + + +@auth_bp.route("/login", methods=["POST"]) +def login(): + data = request.get_json(silent=True) or {} + email = (data.get("email") or "").strip().lower() + password = data.get("password") or "" + if not email or not password: + return jsonify({"error": "Email et mot de passe requis."}), 400 + pw_hash = hash_password(password) + conn = get_db() + user = conn.execute( + "SELECT * FROM saas_users WHERE email=? AND password_hash=?", + (email, pw_hash) + ).fetchone() + conn.close() + if not user: + return jsonify({"error": "Identifiants incorrects."}), 401 + token = generate_token(user["id"]) + return jsonify({"token": token, "user": user_to_dict(user)}), 200 + + +@auth_bp.route("/me", methods=["GET"]) +@require_auth +def me(): + return jsonify({"user": user_to_dict(request.current_user)}), 200 + + +@auth_bp.route("/update-profile", methods=["POST"]) +@require_auth +def update_profile(): + data = request.get_json(silent=True) or {} + uid = request.current_user["id"] + fields = {} + if "firstname" in data: fields["firstname"] = data["firstname"].strip() + if "lastname" in data: fields["lastname"] = data["lastname"].strip() + if "email" in data: + email = data["email"].strip().lower() + if "@" not in email: + return jsonify({"error": "Email invalide."}), 400 + fields["email"] = email + if not fields: + return jsonify({"ok": True}), 200 + set_clause = ", ".join(f"{k}=?" for k in fields) + values = list(fields.values()) + [datetime.utcnow().isoformat(), uid] + conn = get_db() + try: + conn.execute(f"UPDATE saas_users SET {set_clause}, updated_at=? WHERE id=?", values) + conn.commit() + except sqlite3.IntegrityError: + conn.close() + return jsonify({"error": "Cet email est déjà utilisé."}), 409 + conn.close() + return jsonify({"ok": True}), 200 + + +@auth_bp.route("/change-password", methods=["POST"]) +@require_auth +def change_password(): + data = request.get_json(silent=True) or {} + uid = request.current_user["id"] + cur_pwd = data.get("current_password") or "" + new_pwd = data.get("new_password") or "" + if len(new_pwd) < 8: + return jsonify({"error": "Nouveau mot de passe trop court."}), 400 + conn = get_db() + user = conn.execute("SELECT * FROM saas_users WHERE id=? AND password_hash=?", + (uid, hash_password(cur_pwd))).fetchone() + if not user: + conn.close() + return jsonify({"error": "Mot de passe actuel incorrect."}), 401 + conn.execute("UPDATE saas_users SET password_hash=?, updated_at=? WHERE id=?", + (hash_password(new_pwd), datetime.utcnow().isoformat(), uid)) + conn.commit() + conn.close() + return jsonify({"ok": True}), 200 + + +@auth_bp.route("/update-plan", methods=["POST"]) +@require_auth +def update_plan(): + data = request.get_json(silent=True) or {} + plan = data.get("plan", "free") + if plan not in ("free", "premium", "pro"): + return jsonify({"error": "Plan invalide."}), 400 + uid = request.current_user["id"] + conn = get_db() + conn.execute("UPDATE saas_users SET plan=?, updated_at=? WHERE id=?", + (plan, datetime.utcnow().isoformat(), uid)) + conn.commit() + conn.close() + return jsonify({"ok": True, "plan": plan}), 200 + + +@auth_bp.route("/update-preferences", methods=["POST"]) +@require_auth +def update_preferences(): + data = request.get_json(silent=True) or {} + uid = request.current_user["id"] + fields = {} + if "telegram_chat_id" in data: fields["telegram_chat_id"] = data["telegram_chat_id"] or None + if "alert_value_bets" in data: fields["alert_value_bets"] = 1 if data["alert_value_bets"] else 0 + if "alert_top1" in data: fields["alert_top1"] = 1 if data["alert_top1"] else 0 + if "alert_quinte_only" in data: fields["alert_quinte_only"] = 1 if data["alert_quinte_only"] else 0 + if fields: + set_clause = ", ".join(f"{k}=?" for k in fields) + values = list(fields.values()) + [datetime.utcnow().isoformat(), uid] + conn = get_db() + conn.execute(f"UPDATE saas_users SET {set_clause}, updated_at=? WHERE id=?", values) + conn.commit() + conn.close() + return jsonify({"ok": True}), 200 + + +@auth_bp.route("/logout", methods=["POST"]) +@require_auth +def logout(): + auth = request.headers.get("Authorization", "") + token = auth[7:].strip() if auth.startswith("Bearer ") else "" + conn = get_db() + conn.execute("DELETE FROM saas_tokens WHERE token=?", (token,)) + conn.commit() + conn.close() + return jsonify({"ok": True}), 200 + + +@auth_bp.route("/delete-account", methods=["DELETE"]) +@require_auth +def delete_account(): + uid = request.current_user["id"] + conn = get_db() + conn.execute("DELETE FROM saas_tokens WHERE user_id=?", (uid,)) + conn.execute("DELETE FROM saas_users WHERE id=?", (uid,)) + conn.commit() + conn.close() + return jsonify({"ok": True}), 200