From fbc1fad8b94a35e65af15fe7a420318dd3f7e875 Mon Sep 17 00:00:00 2001 From: billisdead Date: Sun, 24 May 2026 10:00:39 +0200 Subject: [PATCH] =?UTF-8?q?P5=20=E2=80=94=20Mode=20consultation=20cibl?= =?UTF-8?q?=C3=A9e=20(Option=20B,=20impl=C3=A9mentation=20compl=C3=A8te)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend : - Nouvelle table `consultations` (slug unique, fenêtre temporelle, webhook, logo) - `ideas.consultation_id` FK nullable (NULL = contexte global home) - `synthesis.consultation_id` FK nullable (synthèse par contexte) - Boucle auto-fermeture (thread daemon, 60 s) — ferme + webhook à l'échéance - Webhook de clôture : POST JSON (synthèse + métadonnées) via urllib.request - Routes publiques : GET/POST /api/consultations/, synthèse, contributions, export/print - Routes admin : list, create, close (+ webhook), delete (cascade explicite) - CSP ajustée sur /export/print pour autoriser window.print() Frontend : - Nouvelle page /consultation/:slug — formulaire, synthèse live, contributions paginées, PDF - Admin panel : onglet Consultations — liste, formulaire création, fermeture, suppression Docs : DAT.md v1.5, DEX.md v1.7 (section P5, tables, routes, webhook) Co-Authored-By: Claude Sonnet 4.6 --- artifacts/flask-api/app.py | 506 ++++++++-- artifacts/flask-api/database.py | 313 ++++-- artifacts/voix-du-peuple/src/App.tsx | 2 + artifacts/voix-du-peuple/src/pages/admin.tsx | 897 +++++++++++++----- .../voix-du-peuple/src/pages/consultation.tsx | 525 ++++++++++ docs/DAT.md | 71 +- docs/DEX.md | 61 +- 7 files changed, 1991 insertions(+), 384 deletions(-) create mode 100644 artifacts/voix-du-peuple/src/pages/consultation.tsx diff --git a/artifacts/flask-api/app.py b/artifacts/flask-api/app.py index 829e430..4279d20 100644 --- a/artifacts/flask-api/app.py +++ b/artifacts/flask-api/app.py @@ -22,8 +22,10 @@ Sécurité : """ import csv +import datetime as dt import hashlib import hmac +import html as html_module import io import json import logging @@ -45,6 +47,9 @@ from database import ( get_synthesis, get_all_ideas, get_ideas_admin, delete_idea, bulk_delete_ideas, override_idea, flag_idea, unflag_idea, create_consent, get_public_contributions, get_public_stats, + create_consultation, get_consultation_by_slug, list_consultations, + close_consultation, get_consultations_to_autoclose, get_consultation_stats, + get_consultation_contributions, delete_consultation, ) from ai_agent import filter_idea, synthesize_ideas @@ -58,21 +63,15 @@ logger = logging.getLogger(__name__) # ─── Constantes anti-abus ──────────────────────────────────────────────────── -# Rate limit pour POST /api/ideas (format flask-limiter) RATE_LIMIT_CONTRIBUTIONS = os.environ.get( "RATE_LIMIT_CONTRIBUTIONS", "5 per minute;3 per hour" ) - -# Cooldown entre deux soumissions d'une même session (secondes) CONTRIBUTION_COOLDOWN_SECONDS = int( os.environ.get("CONTRIBUTION_COOLDOWN_SECONDS", "3600") ) - -# Seuil de flood : nombre de soumissions / 5 min / IP avant alerte FLOOD_THRESHOLD = int(os.environ.get("FLOOD_THRESHOLD", "10")) -FLOOD_WINDOW_SECONDS = 300 # 5 minutes +FLOOD_WINDOW_SECONDS = 300 -# État interne flood detection (en mémoire, reset au redémarrage) _flood_tracker: dict[str, list[float]] = {} _flood_lock = threading.Lock() @@ -81,10 +80,8 @@ _flood_lock = threading.Lock() app = Flask(__name__) app.config["JSON_SORT_KEYS"] = False -# CORS : autorise uniquement les origines du même domaine en production CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=False) -# Storage Redis si disponible, sinon mémoire (dev / instance unique) _redis_url = os.environ.get("REDIS_URL", "") _storage_uri = _redis_url if _redis_url else "memory://" @@ -96,6 +93,32 @@ limiter = Limiter( strategy="fixed-window", ) +# ─── Boucle de fermeture automatique des consultations ─────────────────────── + +def _autoclose_consultations_loop() -> None: + """Vérifie toutes les 60 s les consultations expirées et les ferme automatiquement.""" + while True: + time.sleep(60) + try: + expired = get_consultations_to_autoclose() + for c in expired: + closed = close_consultation(c["id"]) + if closed: + logger.info( + "Consultation '%s' fermée automatiquement (échéance dépassée)", c["slug"] + ) + synthesis = get_synthesis(consultation_id=c["id"]) + threading.Thread( + target=_trigger_webhook, + args=(closed, synthesis), + daemon=True, + ).start() + except Exception: + logger.exception("Erreur dans la boucle de fermeture automatique des consultations") + + +threading.Thread(target=_autoclose_consultations_loop, daemon=True).start() + # ─── En-têtes de sécurité HTTP ─────────────────────────────────────────────── @app.after_request @@ -104,11 +127,12 @@ def set_security_headers(response: Response) -> Response: response.headers["X-Frame-Options"] = "DENY" response.headers["X-XSS-Protection"] = "1; mode=block" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" - response.headers["Content-Security-Policy"] = ( - "default-src 'self'; " - "script-src 'none'; " - "object-src 'none';" - ) + # Les routes /export/print contiennent un légitime + if request.path.endswith("/export/print"): + csp = "default-src 'self'; script-src 'unsafe-inline'; object-src 'none';" + else: + csp = "default-src 'self'; script-src 'none'; object-src 'none';" + response.headers["Content-Security-Policy"] = csp response.headers["Cache-Control"] = "no-store" response.headers["Pragma"] = "no-cache" return response @@ -181,7 +205,6 @@ def get_fingerprint_key() -> str: def _sign_cooldown(secret: str) -> str: - """Génère un token de cooldown signé HMAC-SHA256.""" ts = int(time.time()) msg = ts.to_bytes(8, "big") sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16] @@ -189,7 +212,6 @@ def _sign_cooldown(secret: str) -> str: def _verify_cooldown(cookie: str, secret: str, cooldown_seconds: int) -> bool: - """Retourne True si le cooldown est encore actif pour ce cookie.""" try: ts_str, sig = cookie.rsplit(".", 1) ts = int(ts_str) @@ -209,7 +231,7 @@ def _verify_hcaptcha(token: str) -> bool: """ secret = os.environ.get("HCAPTCHA_SECRET_KEY", "").strip() if not secret: - return True # Stub désactivé — pas de clé configurée + return True if not token: return False try: @@ -224,24 +246,84 @@ def _verify_hcaptcha(token: str) -> bool: return bool(result.get("success", False)) except Exception: logger.warning("Vérification hCaptcha impossible (erreur réseau) — skip") - return True # En cas d'erreur réseau, on ne bloque pas + return True def _check_flood(ip: str, fingerprint_hash: str | None) -> bool: - """ - Enregistre la soumission et retourne True si le seuil de flood est dépassé. - Utilise l'empreinte si disponible, sinon l'IP seule. - """ key = fingerprint_hash if fingerprint_hash else ip now = time.time() with _flood_lock: times = _flood_tracker.get(key, []) - # Nettoyage des timestamps expirés times = [t for t in times if now - t < FLOOD_WINDOW_SECONDS] times.append(now) _flood_tracker[key] = times return len(times) > FLOOD_THRESHOLD +# ─── Helpers consultations ──────────────────────────────────────────────────── + +def _is_consultation_open(c: dict) -> bool: + """Retourne True si la consultation est dans sa fenêtre temporelle et non fermée.""" + if c.get("closed_at"): + return False + now = dt.datetime.now(dt.timezone.utc) + starts_at = c.get("starts_at") + if starts_at and starts_at.tzinfo and starts_at > now: + return False + ends_at = c.get("ends_at") + if ends_at and ends_at.tzinfo and ends_at < now: + return False + return True + + +def serialize_consultation(c: dict) -> dict: + return { + "id": c["id"], + "slug": c["slug"], + "title": c["title"], + "subject": c["subject"], + "introMessage": c.get("intro_message"), + "organizerName": c.get("organizer_name"), + "organizerLogoUrl": c.get("organizer_logo_url"), + "startsAt": c["starts_at"].isoformat() if c.get("starts_at") else None, + "endsAt": c["ends_at"].isoformat() if c.get("ends_at") else None, + "closedAt": c["closed_at"].isoformat() if c.get("closed_at") else None, + "createdAt": c["created_at"].isoformat() if c.get("created_at") else None, + "isOpen": _is_consultation_open(c), + } + + +def _trigger_webhook(consultation: dict, synthesis: dict | None) -> None: + """Envoie les résultats de clôture au webhook configuré (non-bloquant).""" + url = consultation.get("webhook_url") + if not url: + return + try: + payload = { + "event": "consultation_closed", + "consultation": { + "slug": consultation["slug"], + "title": consultation["title"], + "closedAt": consultation["closed_at"].isoformat() if consultation.get("closed_at") else None, + }, + "synthesis": { + "text": synthesis["text"] if synthesis else None, + "ideaCount": synthesis["idea_count"] if synthesis else 0, + }, + } + data = json.dumps(payload).encode() + req = urllib.request.Request( + url, + data=data, + headers={ + "Content-Type": "application/json", + "User-Agent": "LaVoixDuPeuple/1.0", + }, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + logger.info("Webhook consultation '%s' → HTTP %d", consultation["slug"], resp.status) + except Exception: + logger.warning("Webhook consultation '%s' échoué (non-bloquant)", consultation.get("slug")) + # ─── Gestion des erreurs ───────────────────────────────────────────────────── @app.errorhandler(400) @@ -282,15 +364,14 @@ def health(): @app.get("/api/ideas") @limiter.limit("120 per minute") def list_ideas(): - """Retourne toutes les idées acceptées (conformes au droit international).""" - ideas = get_accepted_ideas() + """Retourne toutes les idées globales acceptées (consultation_id IS NULL).""" + ideas = get_accepted_ideas(consultation_id=None) return jsonify([serialize_idea(i) for i in ideas]) @app.get("/api/ideas/stats") @limiter.limit("120 per minute") def idea_stats(): - """Statistiques publiques : total, acceptées, rejetées.""" stats = get_stats() return jsonify(stats) @@ -299,7 +380,7 @@ def idea_stats(): @limiter.limit(RATE_LIMIT_CONTRIBUTIONS, key_func=get_fingerprint_key) def submit_idea(): """ - Soumet une idée citoyenne. + Soumet une idée citoyenne dans le contexte global. Protections anti-abus (dans l'ordre) : 1. Honeypot — rejet silencieux si champ leurre rempli @@ -317,13 +398,10 @@ def submit_idea(): if data is None: return jsonify({"error": "bad_request", "message": "Corps JSON invalide."}), 400 - # 1. Honeypot — si le champ leurre est rempli, c'est un bot if data.get("_hp"): logger.info("Honeypot déclenché — soumission ignorée silencieusement") - # Réponse 201 factice pour ne pas informer le bot return jsonify({"id": 0, "accepted": True, "reason": None, "legalBasis": None}), 201 - # 2. hCaptcha (stub — skip si HCAPTCHA_SECRET_KEY non configuré) hcaptcha_token = ( (data.get("_h") or "") or request.headers.get("X-HCaptcha-Token", "") @@ -332,7 +410,6 @@ def submit_idea(): logger.warning("hCaptcha échoué — IP: %s", get_remote_address()) return jsonify({"error": "captcha_failed", "message": "Vérification CAPTCHA échouée. Veuillez réessayer."}), 400 - # 3. Cooldown cookie — protection contre les soumissions en rafale d'une même session secret_key = os.environ.get("SECRET_KEY", "").strip() if CONTRIBUTION_COOLDOWN_SECONDS > 0 and secret_key: cv = request.cookies.get("_cv", "") @@ -343,7 +420,6 @@ def submit_idea(): "message": "Vous avez déjà contribué récemment. Veuillez patienter avant de soumettre une nouvelle idée.", }), 429 - # 4. Validation et assainissement validated, error = validate_idea_input(data) if error: return jsonify({"error": "validation_error", "message": error}), 400 @@ -351,13 +427,11 @@ def submit_idea(): content = validated["content"] author = validated["author"] - # 5. Extraction et hashage du fingerprint (non-PII) raw_fp = request.headers.get("X-Visitor-Id", "").strip() fingerprint_hash = ( hashlib.sha256(raw_fp.encode()).hexdigest()[:32] if raw_fp else None ) - # 6. Détection de flood client_ip = get_remote_address() if _check_flood(client_ip, fingerprint_hash): logger.warning( @@ -365,7 +439,6 @@ def submit_idea(): client_ip, fingerprint_hash, FLOOD_THRESHOLD, ) - # 7. Filtrage IA selon le cadre légal international logger.info("Filtrage d'une nouvelle idée (longueur: %d)", len(content)) filter_result = filter_idea(content) accepted = bool(filter_result.get("accepted", False)) @@ -377,7 +450,6 @@ def submit_idea(): if accepted: threading.Thread(target=_update_synthesis_background, daemon=True).start() - # Construction de la réponse resp_data = { "id": idea["id"], "accepted": accepted, @@ -387,7 +459,6 @@ def submit_idea(): } response = make_response(jsonify(resp_data), 201) - # Cookie cooldown httpOnly — marque la session comme ayant contribué if accepted and CONTRIBUTION_COOLDOWN_SECONDS > 0 and secret_key: response.set_cookie( "_cv", @@ -404,8 +475,8 @@ def submit_idea(): @app.get("/api/synthesis") @limiter.limit("120 per minute") def get_synthesis_route(): - """Retourne la synthèse actuelle de la Voix du Peuple.""" - synthesis = get_synthesis() + """Retourne la synthèse globale (consultation_id IS NULL).""" + synthesis = get_synthesis(consultation_id=None) if not synthesis: return jsonify({ "text": ( @@ -438,21 +509,24 @@ def record_consent(): hashlib.sha256(raw_fp.encode()).hexdigest()[:32] if raw_fp else "anonymous" ) create_consent(fingerprint_hash, consent_version) - logger.info("Consentement enregistré — fingerprint: %s... | version: %s", fingerprint_hash[:8], consent_version) + logger.info( + "Consentement enregistré — fingerprint: %s... | version: %s", + fingerprint_hash[:8], consent_version, + ) return jsonify({"ok": True}), 201 @app.get("/api/stats/public") @limiter.limit("120 per minute") def public_stats(): - """Statistiques publiques : total soumis et acceptées (sans données de rejet).""" + """Statistiques publiques globales (hors consultations).""" return jsonify(get_public_stats()) @app.get("/api/contributions") @limiter.limit("60 per minute") def public_contributions(): - """Liste paginée des contributions acceptées — vue publique anti-chronologique.""" + """Contributions globales acceptées, paginées, anti-chronologiques.""" try: page = max(1, int(request.args.get("page", 1))) per_page = min(50, max(5, int(request.args.get("per_page", 20)))) @@ -480,7 +554,7 @@ def public_contributions(): @app.get("/api/contributions/export/json") @limiter.limit("10 per minute") def export_contributions_json(): - """Exporte l'intégralité des contributions acceptées en JSON (champs publics).""" + """Export JSON intégral des contributions globales acceptées.""" contributions, _ = get_public_contributions(page=1, per_page=10000) payload = [ { @@ -501,7 +575,7 @@ def export_contributions_json(): @app.get("/api/contributions/export/csv") @limiter.limit("10 per minute") def export_contributions_csv(): - """Exporte les contributions acceptées en CSV (champs publics uniquement — pas de fingerprint).""" + """Export CSV des contributions globales acceptées (champs publics uniquement).""" contributions, _ = get_public_contributions(page=1, per_page=10000) output = io.StringIO() writer = csv.writer(output, quoting=csv.QUOTE_ALL) @@ -531,6 +605,213 @@ def flag_idea_route(idea_id: int): return jsonify({"error": "not_found", "message": "Contribution introuvable ou non publiée."}), 404 return jsonify({"ok": True, "flagCount": result.get("flag_count", 1)}) + +# ─── Routes publiques : consultations (P5) ─────────────────────────────────── + +@app.get("/api/consultations") +@limiter.limit("120 per minute") +def list_consultations_route(): + """Liste les consultations (actives par défaut, toutes si include_closed=true).""" + include_closed = request.args.get("include_closed", "false").lower() == "true" + consultations = list_consultations(include_closed=include_closed) + return jsonify([serialize_consultation(c) for c in consultations]) + + +@app.get("/api/consultations/") +@limiter.limit("120 per minute") +def get_consultation_route(slug: str): + """Détails et statistiques d'une consultation.""" + slug = sanitize_text(slug)[:100] + consultation = get_consultation_by_slug(slug) + if not consultation: + return jsonify({"error": "not_found", "message": "Consultation introuvable."}), 404 + stats = get_consultation_stats(consultation["id"]) + return jsonify({**serialize_consultation(consultation), "stats": stats}) + + +@app.get("/api/consultations//synthesis") +@limiter.limit("120 per minute") +def get_consultation_synthesis_route(slug: str): + """Synthèse collective d'une consultation.""" + slug = sanitize_text(slug)[:100] + consultation = get_consultation_by_slug(slug) + if not consultation: + return jsonify({"error": "not_found", "message": "Consultation introuvable."}), 404 + synthesis = get_synthesis(consultation_id=consultation["id"]) + if not synthesis: + return jsonify({ + "text": "Aucune contribution n'a encore été soumise pour cette consultation.", + "ideaCount": 0, + "updatedAt": None, + }) + return jsonify({ + "text": synthesis["text"], + "ideaCount": synthesis["idea_count"], + "updatedAt": synthesis["updated_at"].isoformat() if synthesis["updated_at"] else None, + }) + + +@app.get("/api/consultations//contributions") +@limiter.limit("60 per minute") +def get_consultation_contributions_route(slug: str): + """Contributions acceptées d'une consultation, paginées.""" + slug = sanitize_text(slug)[:100] + consultation = get_consultation_by_slug(slug) + if not consultation: + return jsonify({"error": "not_found", "message": "Consultation introuvable."}), 404 + try: + page = max(1, int(request.args.get("page", 1))) + per_page = min(50, max(5, int(request.args.get("per_page", 20)))) + except (ValueError, TypeError): + page, per_page = 1, 20 + contributions, total = get_consultation_contributions( + consultation["id"], page=page, per_page=per_page + ) + pages = max(1, -(-total // per_page)) + return jsonify({ + "contributions": [ + { + "id": c["id"], + "content": c["content"], + "author": c.get("author"), + "createdAt": c["created_at"].isoformat() if c.get("created_at") else None, + } + for c in contributions + ], + "total": total, + "page": page, + "perPage": per_page, + "pages": pages, + }) + + +@app.post("/api/consultations//ideas") +@limiter.limit(RATE_LIMIT_CONTRIBUTIONS, key_func=get_fingerprint_key) +def submit_consultation_idea(slug: str): + """Soumet une idée dans le contexte d'une consultation ciblée.""" + slug = sanitize_text(slug)[:100] + consultation = get_consultation_by_slug(slug) + if not consultation: + return jsonify({"error": "not_found", "message": "Consultation introuvable."}), 404 + if not _is_consultation_open(consultation): + return jsonify({ + "error": "consultation_closed", + "message": "Cette consultation est terminée ou n'a pas encore débuté.", + }), 403 + + if not request.is_json: + return jsonify({"error": "bad_request", "message": "Content-Type doit être application/json."}), 400 + data = request.get_json(silent=True) + if data is None: + return jsonify({"error": "bad_request", "message": "Corps JSON invalide."}), 400 + + if data.get("_hp"): + return jsonify({"id": 0, "accepted": True, "reason": None}), 201 + + hcaptcha_token = (data.get("_h") or "") or request.headers.get("X-HCaptcha-Token", "") + if not _verify_hcaptcha(hcaptcha_token): + return jsonify({"error": "captcha_failed", "message": "Vérification CAPTCHA échouée."}), 400 + + validated, error = validate_idea_input(data) + if error: + return jsonify({"error": "validation_error", "message": error}), 400 + + content = validated["content"] + author = validated["author"] + + raw_fp = request.headers.get("X-Visitor-Id", "").strip() + fingerprint_hash = hashlib.sha256(raw_fp.encode()).hexdigest()[:32] if raw_fp else None + + if _check_flood(get_remote_address(), fingerprint_hash): + logger.warning("ALERTE FLOOD — consultation '%s' | IP: %s", slug, get_remote_address()) + + filter_result = filter_idea(content) + accepted = bool(filter_result.get("accepted", False)) + rejection_reason = filter_result.get("reason") if not accepted else None + legal_basis = filter_result.get("legal_basis") if not accepted else None + + idea = insert_idea( + content, author, accepted, rejection_reason, legal_basis, + fingerprint_hash, consultation["id"], + ) + + if accepted: + threading.Thread( + target=_update_synthesis_background, + kwargs={"consultation_id": consultation["id"]}, + daemon=True, + ).start() + + return jsonify({ + "id": idea["id"], + "accepted": accepted, + "reason": rejection_reason, + "legalBasis": legal_basis if not accepted else None, + "idea": serialize_idea(idea), + }), 201 + + +@app.get("/api/consultations//export/print") +@limiter.limit("10 per minute") +def export_consultation_print(slug: str): + """Rendu HTML de la synthèse pour impression/export PDF côté client.""" + slug = sanitize_text(slug)[:100] + consultation = get_consultation_by_slug(slug) + if not consultation: + return jsonify({"error": "not_found", "message": "Consultation introuvable."}), 404 + + synthesis = get_synthesis(consultation_id=consultation["id"]) + stats = get_consultation_stats(consultation["id"]) + + synthesis_text = synthesis["text"] if synthesis else "Aucune synthèse disponible." + idea_count = synthesis["idea_count"] if synthesis else 0 + + title_esc = html_module.escape(consultation["title"]) + subject_esc = html_module.escape(consultation["subject"]) + organizer_esc = html_module.escape(consultation.get("organizer_name") or "") + synthesis_esc = html_module.escape(synthesis_text).replace("\n", "
") + now_str = dt.datetime.now().strftime("%d/%m/%Y %H:%M") + + html_content = f""" + + + +{title_esc} + + + +
+
+ + + +
+

{title_esc}

+ {f'

{organizer_esc}

' if organizer_esc else ""} +

{subject_esc}

+

Exporté le {now_str} — {idea_count} contribution(s) intégrée(s)

+
+
{synthesis_esc}
+ + + +""" + + return Response(html_content, mimetype="text/html") + + # ─── Routes Admin ───────────────────────────────────────────────────────────── @app.post("/api/admin/login") @@ -552,7 +833,7 @@ def admin_login(): @app.get("/api/admin/stats") @require_admin def admin_stats(): - """Statistiques détaillées pour l'administrateur.""" + """Statistiques détaillées pour l'administrateur (toutes contributions confondues).""" stats = get_stats() return jsonify(stats) @@ -583,7 +864,7 @@ def admin_list_ideas(): @app.delete("/api/admin/ideas/") @require_admin def admin_delete_idea(idea_id: int): - """Supprime une contribution et régénère la synthèse.""" + """Supprime une contribution et régénère la synthèse du contexte concerné.""" deleted = delete_idea(idea_id) if not deleted: return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404 @@ -611,7 +892,7 @@ def admin_bulk_delete(): @app.post("/api/admin/ideas//override") @require_admin def admin_override_idea(idea_id: int): - """Modifie manuellement le statut d'une contribution (accepter/rejeter).""" + """Modifie manuellement le statut d'une contribution.""" data = request.get_json(silent=True) or {} accepted = bool(data.get("accepted", False)) reason = sanitize_text(data.get("reason", "") or "") @@ -638,7 +919,7 @@ def admin_unflag_idea(idea_id: int): @require_admin @limiter.limit("5 per minute") def admin_regenerate_synthesis(): - """Force la régénération complète de la synthèse.""" + """Force la régénération complète de la synthèse globale.""" threading.Thread(target=_update_synthesis_background, daemon=True).start() logger.info("Admin — régénération manuelle de la synthèse déclenchée") return jsonify({"ok": True, "message": "Régénération lancée en arrière-plan."}) @@ -647,13 +928,15 @@ def admin_regenerate_synthesis(): @app.get("/api/admin/export/csv") @require_admin def admin_export_csv(): - """Exporte toutes les contributions en CSV.""" + """Exporte toutes les contributions en CSV (admin).""" ideas, _ = get_ideas_admin(status="all", page=1, per_page=10000) output = io.StringIO() writer = csv.writer(output, quoting=csv.QUOTE_ALL) - writer.writerow(["id", "content", "author", "accepted", "flagged", - "flag_count", "rejection_reason", "legal_basis", - "admin_note", "fingerprint_hash", "created_at"]) + writer.writerow([ + "id", "content", "author", "accepted", "flagged", "flag_count", + "rejection_reason", "legal_basis", "admin_note", + "fingerprint_hash", "consultation_id", "created_at", + ]) for idea in ideas: writer.writerow([ idea.get("id"), @@ -666,16 +949,116 @@ def admin_export_csv(): idea.get("legal_basis", ""), idea.get("admin_note", ""), idea.get("fingerprint_hash", ""), + idea.get("consultation_id", ""), idea.get("created_at").isoformat() if idea.get("created_at") else "", ]) - csv_bytes = output.getvalue().encode("utf-8-sig") return Response( - csv_bytes, + output.getvalue().encode("utf-8-sig"), mimetype="text/csv", headers={"Content-Disposition": "attachment; filename=contributions.csv"}, ) -# ─── Helpers ───────────────────────────────────────────────────────────────── + +# ─── Routes Admin : consultations (P5) ─────────────────────────────────────── + +@app.get("/api/admin/consultations") +@require_admin +def admin_list_consultations(): + """Liste toutes les consultations (actives et fermées) avec leurs statistiques.""" + consultations = list_consultations(include_closed=True) + result = [] + for c in consultations: + stats = get_consultation_stats(c["id"]) + result.append({**serialize_consultation(c), "stats": stats}) + return jsonify(result) + + +@app.post("/api/admin/consultations") +@require_admin +@limiter.limit("20 per minute") +def admin_create_consultation(): + """Crée une nouvelle consultation ciblée.""" + data = request.get_json(silent=True) or {} + + slug_raw = sanitize_text(str(data.get("slug", "") or ""))[:100].lower() + # Normalisation : uniquement alphanumériques et tirets + slug = "".join(c if c.isalnum() or c == "-" else "-" for c in slug_raw) + slug = "-".join(filter(None, slug.split("-"))) # supprime les tirets multiples/en bord + + title = sanitize_text(str(data.get("title", "") or ""))[:200] + subject = sanitize_text(str(data.get("subject", "") or ""))[:2000] + intro_message = sanitize_text(str(data.get("introMessage", "") or ""))[:2000] or None + organizer_name = sanitize_text(str(data.get("organizerName", "") or ""))[:200] or None + webhook_url = sanitize_text(str(data.get("webhookUrl", "") or ""))[:500] or None + + # URL logo : uniquement HTTPS pour éviter le contenu mixte + logo_raw = sanitize_text(str(data.get("organizerLogoUrl", "") or ""))[:500] + organizer_logo_url = logo_raw if logo_raw.startswith("https://") else None + + if not slug or not title or not subject: + return jsonify({"error": "validation_error", "message": "slug, title et subject sont requis."}), 400 + + try: + starts_at_str = data.get("startsAt", "") + starts_at = dt.datetime.fromisoformat(starts_at_str) if starts_at_str else dt.datetime.now(dt.timezone.utc) + ends_at_str = data.get("endsAt", "") + ends_at = dt.datetime.fromisoformat(ends_at_str) if ends_at_str else None + except (ValueError, TypeError): + return jsonify({"error": "validation_error", "message": "Format de date invalide (ISO 8601 requis)."}), 400 + + try: + consultation = create_consultation( + slug, title, subject, intro_message, + organizer_name, organizer_logo_url, + starts_at, ends_at, webhook_url, + ) + except Exception as e: + if "unique" in str(e).lower() or "duplicate" in str(e).lower(): + return jsonify({"error": "conflict", "message": f"Le slug '{slug}' est déjà utilisé."}), 409 + raise + + logger.info("Admin — consultation '%s' créée", slug) + return jsonify(serialize_consultation(consultation)), 201 + + +@app.post("/api/admin/consultations//close") +@require_admin +def admin_close_consultation(slug: str): + """Ferme manuellement une consultation et déclenche le webhook si configuré.""" + slug = sanitize_text(slug)[:100] + consultation = get_consultation_by_slug(slug) + if not consultation: + return jsonify({"error": "not_found", "message": "Consultation introuvable."}), 404 + + closed = close_consultation(consultation["id"]) + if not closed: + return jsonify({"error": "already_closed", "message": "Cette consultation est déjà fermée."}), 409 + + synthesis = get_synthesis(consultation_id=consultation["id"]) + threading.Thread(target=_trigger_webhook, args=(closed, synthesis), daemon=True).start() + + logger.info("Admin — consultation '%s' fermée manuellement", slug) + return jsonify({"ok": True, "consultation": serialize_consultation(closed)}) + + +@app.delete("/api/admin/consultations/") +@require_admin +def admin_delete_consultation(slug: str): + """Supprime une consultation et toutes ses contributions/synthèse associées.""" + slug = sanitize_text(slug)[:100] + consultation = get_consultation_by_slug(slug) + if not consultation: + return jsonify({"error": "not_found", "message": "Consultation introuvable."}), 404 + + deleted = delete_consultation(consultation["id"]) + if not deleted: + return jsonify({"error": "not_found", "message": "Consultation introuvable."}), 404 + + logger.info("Admin — consultation '%s' supprimée", slug) + return jsonify({"ok": True}) + + +# ─── Helpers sérialiseurs ───────────────────────────────────────────────────── def serialize_idea(idea: dict) -> dict: return { @@ -695,16 +1078,21 @@ def serialize_idea_admin(idea: dict) -> dict: base = serialize_idea(idea) base["adminNote"] = idea.get("admin_note") base["fingerprintHash"] = idea.get("fingerprint_hash") + base["consultationId"] = idea.get("consultation_id") return base -def _update_synthesis_background() -> None: +def _update_synthesis_background(consultation_id: int | None = None) -> None: + """Met à jour la synthèse d'un contexte donné en arrière-plan.""" try: - ideas = get_accepted_ideas() + ideas = get_accepted_ideas(consultation_id=consultation_id) texts = [i["content"] for i in ideas] synthesized = synthesize_ideas(texts) - upsert_synthesis(synthesized, len(texts)) - logger.info("Synthèse mise à jour — %d idée(s) intégrée(s).", len(texts)) + upsert_synthesis(synthesized, len(texts), consultation_id=consultation_id) + logger.info( + "Synthèse mise à jour (consultation_id=%s) — %d idée(s).", + consultation_id, len(texts), + ) except Exception: logger.exception("Erreur lors de la mise à jour de la synthèse en arrière-plan") diff --git a/artifacts/flask-api/database.py b/artifacts/flask-api/database.py index fbe2362..cf61490 100644 --- a/artifacts/flask-api/database.py +++ b/artifacts/flask-api/database.py @@ -37,6 +37,23 @@ def db_cursor(): def init_db() -> None: """Crée les tables si elles n'existent pas, et applique les migrations nécessaires.""" with db_cursor() as cur: + # Table consultations — créée AVANT ideas pour la contrainte FK + cur.execute(""" + CREATE TABLE IF NOT EXISTS consultations ( + id SERIAL PRIMARY KEY, + slug VARCHAR(100) UNIQUE NOT NULL, + title VARCHAR(200) NOT NULL, + subject TEXT NOT NULL, + intro_message TEXT, + organizer_name VARCHAR(200), + organizer_logo_url TEXT, + starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ends_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + webhook_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) cur.execute(""" CREATE TABLE IF NOT EXISTS ideas ( id SERIAL PRIMARY KEY, @@ -54,6 +71,10 @@ def init_db() -> None: cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS flag_count INTEGER NOT NULL DEFAULT 0") cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS admin_note TEXT") cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS fingerprint_hash VARCHAR(64)") + # P5 — consultation_id : NULL = contribution globale (page d'accueil) + cur.execute( + "ALTER TABLE ideas ADD COLUMN IF NOT EXISTS consultation_id INTEGER REFERENCES consultations(id) ON DELETE SET NULL" + ) cur.execute(""" CREATE TABLE IF NOT EXISTS synthesis ( id SERIAL PRIMARY KEY, @@ -62,6 +83,10 @@ def init_db() -> None: updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """) + # P5 — une synthèse par contexte (NULL = synthèse globale) + cur.execute( + "ALTER TABLE synthesis ADD COLUMN IF NOT EXISTS consultation_id INTEGER REFERENCES consultations(id) ON DELETE CASCADE" + ) # Table de traçabilité des consentements RGPD (art. 7.1 — charge de la preuve) cur.execute(""" CREATE TABLE IF NOT EXISTS consents ( @@ -74,9 +99,13 @@ def init_db() -> None: cur.execute( "CREATE INDEX IF NOT EXISTS idx_consents_fingerprint ON consents(fingerprint_hash)" ) + cur.execute("CREATE INDEX IF NOT EXISTS idx_ideas_consultation ON ideas(consultation_id)") + cur.execute("CREATE INDEX IF NOT EXISTS idx_synthesis_consultation ON synthesis(consultation_id)") logger.info("Base de données initialisée.") +# ─── Idées ──────────────────────────────────────────────────────────────────── + def insert_idea( content: str, author: str | None, @@ -84,73 +113,33 @@ def insert_idea( rejection_reason: str | None, legal_basis: str | None, fingerprint_hash: str | None = None, + consultation_id: int | None = None, ) -> dict: with db_cursor() as cur: cur.execute( """ - INSERT INTO ideas (content, author, accepted, rejection_reason, legal_basis, fingerprint_hash) - VALUES (%s, %s, %s, %s, %s, %s) + INSERT INTO ideas + (content, author, accepted, rejection_reason, legal_basis, fingerprint_hash, consultation_id) + VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING * """, - (content, author, accepted, rejection_reason, legal_basis, fingerprint_hash), + (content, author, accepted, rejection_reason, legal_basis, fingerprint_hash, consultation_id), ) return dict(cur.fetchone()) -def create_consent(fingerprint_hash: str, consent_version: str) -> dict: - """Enregistre un consentement explicite (art. 7.1 RGPD — preuve).""" +def get_accepted_ideas(consultation_id: int | None = None) -> list[dict]: + """Retourne les idées acceptées d'un contexte donné (NULL = global).""" with db_cursor() as cur: - cur.execute( - """ - INSERT INTO consents (fingerprint_hash, consent_version) - VALUES (%s, %s) - RETURNING * - """, - (fingerprint_hash, consent_version), - ) - return dict(cur.fetchone()) - - -def get_public_contributions(page: int = 1, per_page: int = 20) -> tuple[list[dict], int]: - """Retourne les contributions acceptées paginées pour la vue publique.""" - offset = (page - 1) * per_page - with db_cursor() as cur: - cur.execute( - "SELECT COUNT(*) as total FROM ideas WHERE accepted = TRUE AND flagged = FALSE" - ) - total = cur.fetchone()["total"] - cur.execute( - """ - SELECT id, content, author, created_at - FROM ideas - WHERE accepted = TRUE AND flagged = FALSE - ORDER BY created_at DESC - LIMIT %s OFFSET %s - """, - (per_page, offset), - ) - rows = [dict(row) for row in cur.fetchall()] - return rows, total - - -def get_public_stats() -> dict: - """Statistiques publiques — ne révèle pas les chiffres de rejet.""" - with db_cursor() as cur: - cur.execute("SELECT COUNT(*) as total FROM ideas") - total = cur.fetchone()["total"] - cur.execute("SELECT COUNT(*) as accepted FROM ideas WHERE accepted = TRUE") - accepted = cur.fetchone()["accepted"] - cur.execute("SELECT updated_at FROM synthesis LIMIT 1") - row = cur.fetchone() - last_updated = row["updated_at"].isoformat() if row and row.get("updated_at") else None - return {"total": total, "accepted": accepted, "lastUpdated": last_updated} - - -def get_accepted_ideas() -> list[dict]: - with db_cursor() as cur: - cur.execute( - "SELECT * FROM ideas WHERE accepted = TRUE ORDER BY created_at ASC" - ) + if consultation_id is None: + cur.execute( + "SELECT * FROM ideas WHERE accepted = TRUE AND consultation_id IS NULL ORDER BY created_at ASC" + ) + else: + cur.execute( + "SELECT * FROM ideas WHERE accepted = TRUE AND consultation_id = %s ORDER BY created_at ASC", + (consultation_id,), + ) return [dict(row) for row in cur.fetchall()] @@ -167,11 +156,19 @@ def get_ideas_admin( page: int = 1, per_page: int = 50, search: str = "", + consultation_id: int | None = None, + global_only: bool = False, ) -> tuple[list[dict], int]: offset = (page - 1) * per_page conditions = [] params: list = [] + if global_only: + conditions.append("consultation_id IS NULL") + elif consultation_id is not None: + conditions.append("consultation_id = %s") + params.append(consultation_id) + if status == "accepted": conditions.append("accepted = TRUE") elif status == "rejected": @@ -274,9 +271,15 @@ def get_stats() -> dict: return {"total": total, "accepted": accepted, "rejected": rejected, "flagged": flagged} -def upsert_synthesis(text: str, idea_count: int) -> dict: +# ─── Synthèse ───────────────────────────────────────────────────────────────── + +def upsert_synthesis(text: str, idea_count: int, consultation_id: int | None = None) -> dict: + """Crée ou met à jour la synthèse d'un contexte (NULL = global, int = consultation).""" with db_cursor() as cur: - cur.execute("SELECT id FROM synthesis LIMIT 1") + if consultation_id is None: + cur.execute("SELECT id FROM synthesis WHERE consultation_id IS NULL LIMIT 1") + else: + cur.execute("SELECT id FROM synthesis WHERE consultation_id = %s LIMIT 1", (consultation_id,)) row = cur.fetchone() if row: cur.execute( @@ -291,17 +294,201 @@ def upsert_synthesis(text: str, idea_count: int) -> dict: else: cur.execute( """ - INSERT INTO synthesis (text, idea_count) - VALUES (%s, %s) + INSERT INTO synthesis (text, idea_count, consultation_id) + VALUES (%s, %s, %s) RETURNING * """, - (text, idea_count), + (text, idea_count, consultation_id), ) return dict(cur.fetchone()) -def get_synthesis() -> dict | None: +def get_synthesis(consultation_id: int | None = None) -> dict | None: + """Retourne la synthèse d'un contexte (NULL = globale).""" with db_cursor() as cur: - cur.execute("SELECT * FROM synthesis LIMIT 1") + if consultation_id is None: + cur.execute("SELECT * FROM synthesis WHERE consultation_id IS NULL LIMIT 1") + else: + cur.execute("SELECT * FROM synthesis WHERE consultation_id = %s LIMIT 1", (consultation_id,)) row = cur.fetchone() return dict(row) if row else None + + +# ─── RGPD — Consentements ───────────────────────────────────────────────────── + +def create_consent(fingerprint_hash: str, consent_version: str) -> dict: + """Enregistre un consentement explicite (art. 7.1 RGPD — preuve).""" + with db_cursor() as cur: + cur.execute( + """ + INSERT INTO consents (fingerprint_hash, consent_version) + VALUES (%s, %s) + RETURNING * + """, + (fingerprint_hash, consent_version), + ) + return dict(cur.fetchone()) + + +# ─── Contributions publiques (contexte global) ──────────────────────────────── + +def get_public_contributions(page: int = 1, per_page: int = 20) -> tuple[list[dict], int]: + """Contributions globales (consultation_id IS NULL) acceptées, paginées.""" + offset = (page - 1) * per_page + with db_cursor() as cur: + cur.execute( + "SELECT COUNT(*) as total FROM ideas WHERE accepted = TRUE AND flagged = FALSE AND consultation_id IS NULL" + ) + total = cur.fetchone()["total"] + cur.execute( + """ + SELECT id, content, author, created_at + FROM ideas + WHERE accepted = TRUE AND flagged = FALSE AND consultation_id IS NULL + ORDER BY created_at DESC + LIMIT %s OFFSET %s + """, + (per_page, offset), + ) + rows = [dict(row) for row in cur.fetchall()] + return rows, total + + +def get_public_stats() -> dict: + """Statistiques publiques globales (hors consultations).""" + with db_cursor() as cur: + cur.execute("SELECT COUNT(*) as total FROM ideas WHERE consultation_id IS NULL") + total = cur.fetchone()["total"] + cur.execute( + "SELECT COUNT(*) as accepted FROM ideas WHERE accepted = TRUE AND consultation_id IS NULL" + ) + accepted = cur.fetchone()["accepted"] + cur.execute("SELECT updated_at FROM synthesis WHERE consultation_id IS NULL LIMIT 1") + row = cur.fetchone() + last_updated = row["updated_at"].isoformat() if row and row.get("updated_at") else None + return {"total": total, "accepted": accepted, "lastUpdated": last_updated} + + +# ─── Consultations (P5) ─────────────────────────────────────────────────────── + +def create_consultation( + slug: str, + title: str, + subject: str, + intro_message: str | None, + organizer_name: str | None, + organizer_logo_url: str | None, + starts_at, + ends_at, + webhook_url: str | None, +) -> dict: + with db_cursor() as cur: + cur.execute( + """ + INSERT INTO consultations + (slug, title, subject, intro_message, organizer_name, + organizer_logo_url, starts_at, ends_at, webhook_url) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING * + """, + (slug, title, subject, intro_message, organizer_name, + organizer_logo_url, starts_at, ends_at, webhook_url), + ) + return dict(cur.fetchone()) + + +def get_consultation_by_slug(slug: str) -> dict | None: + with db_cursor() as cur: + cur.execute("SELECT * FROM consultations WHERE slug = %s", (slug,)) + row = cur.fetchone() + return dict(row) if row else None + + +def list_consultations(include_closed: bool = False) -> list[dict]: + with db_cursor() as cur: + if include_closed: + cur.execute("SELECT * FROM consultations ORDER BY created_at DESC") + else: + cur.execute( + "SELECT * FROM consultations WHERE closed_at IS NULL ORDER BY created_at DESC" + ) + return [dict(row) for row in cur.fetchall()] + + +def close_consultation(consultation_id: int) -> dict | None: + with db_cursor() as cur: + cur.execute( + "UPDATE consultations SET closed_at = NOW() WHERE id = %s AND closed_at IS NULL RETURNING *", + (consultation_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + + +def get_consultations_to_autoclose() -> list[dict]: + """Consultations dont la date de fin est passée mais non encore fermées.""" + with db_cursor() as cur: + cur.execute( + """ + SELECT * FROM consultations + WHERE ends_at IS NOT NULL + AND ends_at < NOW() + AND closed_at IS NULL + """ + ) + return [dict(row) for row in cur.fetchall()] + + +def get_consultation_stats(consultation_id: int) -> dict: + with db_cursor() as cur: + cur.execute( + "SELECT COUNT(*) as total FROM ideas WHERE consultation_id = %s", + (consultation_id,), + ) + total = cur.fetchone()["total"] + cur.execute( + "SELECT COUNT(*) as accepted FROM ideas WHERE consultation_id = %s AND accepted = TRUE", + (consultation_id,), + ) + accepted = cur.fetchone()["accepted"] + cur.execute( + "SELECT updated_at FROM synthesis WHERE consultation_id = %s LIMIT 1", + (consultation_id,), + ) + row = cur.fetchone() + last_updated = row["updated_at"].isoformat() if row and row.get("updated_at") else None + return {"total": total, "accepted": accepted, "lastUpdated": last_updated} + + +def get_consultation_contributions( + consultation_id: int, page: int = 1, per_page: int = 20 +) -> tuple[list[dict], int]: + offset = (page - 1) * per_page + with db_cursor() as cur: + cur.execute( + "SELECT COUNT(*) as total FROM ideas WHERE consultation_id = %s AND accepted = TRUE AND flagged = FALSE", + (consultation_id,), + ) + total = cur.fetchone()["total"] + cur.execute( + """ + SELECT id, content, author, created_at + FROM ideas + WHERE consultation_id = %s AND accepted = TRUE AND flagged = FALSE + ORDER BY created_at DESC + LIMIT %s OFFSET %s + """, + (consultation_id, per_page, offset), + ) + rows = [dict(row) for row in cur.fetchall()] + return rows, total + + +def delete_consultation(consultation_id: int) -> bool: + """Supprime une consultation et toutes ses données (idées, synthèse) dans la même transaction.""" + with db_cursor() as cur: + # Supprimer explicitement les idées pour éviter qu'elles ne deviennent "globales" par SET NULL + cur.execute("DELETE FROM ideas WHERE consultation_id = %s", (consultation_id,)) + cur.execute("DELETE FROM synthesis WHERE consultation_id = %s", (consultation_id,)) + cur.execute("DELETE FROM consultations WHERE id = %s RETURNING id", (consultation_id,)) + return cur.fetchone() is not None diff --git a/artifacts/voix-du-peuple/src/App.tsx b/artifacts/voix-du-peuple/src/App.tsx index 4d9e16c..d39afdb 100644 --- a/artifacts/voix-du-peuple/src/App.tsx +++ b/artifacts/voix-du-peuple/src/App.tsx @@ -13,6 +13,7 @@ import Admin from "@/pages/admin"; import LegalNotice from "@/pages/legal-notice"; import PrivacyPolicy from "@/pages/privacy-policy"; import ContributionsBrutes from "@/pages/contributions-brutes"; +import ConsultationPage from "@/pages/consultation"; import { AccessibilityProvider } from "@/hooks/use-accessibility"; import { AccessibilityPanel } from "@/components/accessibility-panel"; import { setVisitorId } from "@workspace/api-client-react"; @@ -104,6 +105,7 @@ function Router() { + diff --git a/artifacts/voix-du-peuple/src/pages/admin.tsx b/artifacts/voix-du-peuple/src/pages/admin.tsx index 53082d0..dab9224 100644 --- a/artifacts/voix-du-peuple/src/pages/admin.tsx +++ b/artifacts/voix-du-peuple/src/pages/admin.tsx @@ -1,4 +1,6 @@ -import { useState, useEffect, useCallback } from "react"; +// Copyright (C) 2026 billisdead — Licence EUPL-1.2 +import React, { useState, useEffect, useCallback } from "react"; +import { Link } from "wouter"; import { useToast } from "@/hooks/use-toast"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -24,10 +26,13 @@ import { import { Trash2, RefreshCw, Download, LogOut, Check, X, Flag, ChevronLeft, ChevronRight, Search, ShieldCheck, Eye, Loader2, + Plus, Lock, Clock, Users, Building2, ExternalLink, Calendar, } from "lucide-react"; const API_BASE = import.meta.env.VITE_API_URL ?? ""; +// ─── Types ──────────────────────────────────────────────────────────────────── + type Idea = { id: number; content: string; @@ -38,6 +43,7 @@ type Idea = { rejectionReason: string | null; legalBasis: string | null; adminNote: string | null; + consultationId: number | null; createdAt: string | null; }; @@ -56,6 +62,30 @@ type IdeaList = { perPage: number; }; +type ConsultationStats = { + total: number; + accepted: number; + lastUpdated: string | null; +}; + +type Consultation = { + id: number; + slug: string; + title: string; + subject: string; + introMessage: string | null; + organizerName: string | null; + organizerLogoUrl: string | null; + startsAt: string | null; + endsAt: string | null; + closedAt: string | null; + createdAt: string | null; + isOpen: boolean; + stats: ConsultationStats; +}; + +// ─── Hook auth ──────────────────────────────────────────────────────────────── + function useAdminAuth() { const [token, setToken] = useState(() => sessionStorage.getItem("admin_token") @@ -74,6 +104,8 @@ function useAdminAuth() { return { token, login, logout, headers }; } +// ─── Composants utilitaires ─────────────────────────────────────────────────── + function LoginPanel({ onLogin }: { onLogin: (t: string) => void }) { const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); @@ -91,10 +123,7 @@ function LoginPanel({ onLogin }: { onLogin: (t: string) => void }) { body: JSON.stringify({ password }), }); const data = await res.json(); - if (!res.ok) { - setError(data.message || "Mot de passe incorrect."); - return; - } + if (!res.ok) { setError(data.message || "Mot de passe incorrect."); return; } onLogin(data.token); toast({ title: "Connecté", description: "Bienvenue dans le panel admin." }); } catch { @@ -150,10 +179,380 @@ function StatsBadge({ label, value, color }: { label: string; value: number; col ); } +// ─── Panel Consultations ────────────────────────────────────────────────────── + +function ConsultationsPanel({ headers, token }: { headers: Record; token: string }) { + const { toast } = useToast(); + const [consultations, setConsultations] = useState([]); + const [loading, setLoading] = useState(false); + const [showCreateForm, setShowCreateForm] = useState(false); + const [closeTarget, setCloseTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + // Champs du formulaire de création + const [fSlug, setFSlug] = useState(""); + const [fTitle, setFTitle] = useState(""); + const [fSubject, setFSubject] = useState(""); + const [fIntro, setFIntro] = useState(""); + const [fOrgName, setFOrgName] = useState(""); + const [fOrgLogo, setFOrgLogo] = useState(""); + const [fStartsAt, setFStartsAt] = useState(""); + const [fEndsAt, setFEndsAt] = useState(""); + const [fWebhook, setFWebhook] = useState(""); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(""); + + const fetchConsultations = useCallback(async () => { + setLoading(true); + try { + const res = await fetch(`${API_BASE}/api/admin/consultations`, { headers }); + if (res.ok) setConsultations(await res.json()); + } catch { /* ignore */ } + finally { setLoading(false); } + }, [token]); + + useEffect(() => { fetchConsultations(); }, [fetchConsultations]); + + // Normalise le slug en temps réel (alphanumériques + tirets) + const handleSlugChange = (v: string) => { + setFSlug(v.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-{2,}/g, "-")); + }; + + const resetForm = () => { + setFSlug(""); setFTitle(""); setFSubject(""); setFIntro(""); + setFOrgName(""); setFOrgLogo(""); setFStartsAt(""); setFEndsAt(""); setFWebhook(""); + setCreateError(""); + }; + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + setCreating(true); + setCreateError(""); + try { + const res = await fetch(`${API_BASE}/api/admin/consultations`, { + method: "POST", + headers, + body: JSON.stringify({ + slug: fSlug, title: fTitle, subject: fSubject, + introMessage: fIntro || undefined, + organizerName: fOrgName || undefined, + organizerLogoUrl: fOrgLogo || undefined, + startsAt: fStartsAt || undefined, + endsAt: fEndsAt || undefined, + webhookUrl: fWebhook || undefined, + }), + }); + const data = await res.json(); + if (!res.ok) { + setCreateError(data.message || "Création échouée."); + return; + } + toast({ title: "Consultation créée", description: `Slug : ${data.slug}` }); + resetForm(); + setShowCreateForm(false); + fetchConsultations(); + } catch { + setCreateError("Erreur réseau."); + } finally { + setCreating(false); + } + }; + + const handleClose = async () => { + if (!closeTarget) return; + const res = await fetch(`${API_BASE}/api/admin/consultations/${closeTarget.slug}/close`, { + method: "POST", headers, + }); + if (res.ok) { + toast({ title: "Consultation fermée", description: "Le webhook a été déclenché si configuré." }); + fetchConsultations(); + } else { + toast({ title: "Erreur", description: "Fermeture échouée.", variant: "destructive" }); + } + setCloseTarget(null); + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + const res = await fetch(`${API_BASE}/api/admin/consultations/${deleteTarget.slug}`, { + method: "DELETE", headers, + }); + if (res.ok) { + toast({ title: "Consultation supprimée" }); + fetchConsultations(); + } else { + toast({ title: "Erreur", description: "Suppression échouée.", variant: "destructive" }); + } + setDeleteTarget(null); + }; + + const fmtDate = (iso: string | null) => + iso ? new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "2-digit" }) : "—"; + + return ( +
+ {/* Toolbar */} +
+

Consultations ciblées

+ +
+ + {/* Formulaire de création */} + {showCreateForm && ( +
+

Créer une consultation

+ +
+
+ + handleSlugChange(e.target.value)} + placeholder="mairie-paris-budget-2026" + required + className="text-sm font-mono" + /> +

+ Sera accessible sur /consultation/{fSlug || "slug"} +

+
+
+ + setFTitle(e.target.value)} placeholder="Budget participatif 2026" required className="text-sm" /> +
+
+ +
+ +