P5 — Mode consultation ciblée (Option B, implémentation complète)
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/<slug>, 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 <noreply@anthropic.com>
This commit is contained in:
+447
-59
@@ -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 <script>window.print()</script> 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/<slug>")
|
||||
@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/<slug>/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/<slug>/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/<slug>/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/<slug>/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", "<br>")
|
||||
now_str = dt.datetime.now().strftime("%d/%m/%Y %H:%M")
|
||||
|
||||
html_content = f"""<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{title_esc}</title>
|
||||
<style>
|
||||
body {{ font-family: Georgia, serif; max-width: 800px; margin: 40px auto; padding: 0 20px; color: #1a1a1a; }}
|
||||
.header {{ border-bottom: 3px solid #1b5f6a; padding-bottom: 16px; margin-bottom: 32px; }}
|
||||
.tricolore {{ display: flex; height: 6px; width: 60px; margin-bottom: 12px; }}
|
||||
.tricolore span {{ flex: 1; }}
|
||||
h1 {{ font-size: 24px; margin: 0 0 6px; color: #1b5f6a; }}
|
||||
.meta {{ font-size: 13px; color: #666; margin: 4px 0; }}
|
||||
.synthesis {{ font-size: 15px; line-height: 1.8; }}
|
||||
.footer {{ margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 11px; color: #999; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="tricolore">
|
||||
<span style="background:#002395"></span>
|
||||
<span style="background:#ededed"></span>
|
||||
<span style="background:#ed2939"></span>
|
||||
</div>
|
||||
<h1>{title_esc}</h1>
|
||||
{f'<p class="meta"><strong>{organizer_esc}</strong></p>' if organizer_esc else ""}
|
||||
<p class="meta">{subject_esc}</p>
|
||||
<p class="meta">Exporté le {now_str} — {idea_count} contribution(s) intégrée(s)</p>
|
||||
</div>
|
||||
<div class="synthesis">{synthesis_esc}</div>
|
||||
<div class="footer">
|
||||
La Voix du Peuple — Document généré automatiquement à partir de contributions citoyennes.
|
||||
Ce contenu reflète l'expression des participants, pas une vérité établie.
|
||||
</div>
|
||||
<script>window.print();</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
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/<int:idea_id>")
|
||||
@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/<int:idea_id>/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/<slug>/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/<slug>")
|
||||
@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")
|
||||
|
||||
|
||||
+246
-59
@@ -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,72 +113,32 @@ 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:
|
||||
if consultation_id is None:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO consents (fingerprint_hash, consent_version)
|
||||
VALUES (%s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(fingerprint_hash, consent_version),
|
||||
"SELECT * FROM ideas WHERE accepted = TRUE AND consultation_id IS NULL ORDER BY created_at ASC"
|
||||
)
|
||||
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:
|
||||
else:
|
||||
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"
|
||||
"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
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/mentions-legales" component={LegalNotice} />
|
||||
<Route path="/politique-confidentialite" component={PrivacyPolicy} />
|
||||
<Route path="/contributions-brutes" component={ContributionsBrutes} />
|
||||
<Route path="/consultation/:slug" component={ConsultationPage} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</main>
|
||||
|
||||
@@ -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<string | null>(() =>
|
||||
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<string, string>; token: string }) {
|
||||
const { toast } = useToast();
|
||||
const [consultations, setConsultations] = useState<Consultation[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [closeTarget, setCloseTarget] = useState<Consultation | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Consultation | null>(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 (
|
||||
<div className="space-y-6">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-serif font-semibold text-lg text-primary">Consultations ciblées</h2>
|
||||
<Button size="sm" onClick={() => { setShowCreateForm((v) => !v); resetForm(); }}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
{showCreateForm ? "Annuler" : "Nouvelle consultation"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Formulaire de création */}
|
||||
{showCreateForm && (
|
||||
<form onSubmit={handleCreate} className="bg-background border border-border rounded-xl p-5 space-y-4">
|
||||
<h3 className="font-medium text-sm text-foreground">Créer une consultation</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block">Slug URL *</label>
|
||||
<Input
|
||||
value={fSlug}
|
||||
onChange={(e) => handleSlugChange(e.target.value)}
|
||||
placeholder="mairie-paris-budget-2026"
|
||||
required
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Sera accessible sur /consultation/{fSlug || "slug"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block">Titre *</label>
|
||||
<Input value={fTitle} onChange={(e) => setFTitle(e.target.value)} placeholder="Budget participatif 2026" required className="text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block">Sujet * (affiché publiquement)</label>
|
||||
<Textarea
|
||||
value={fSubject}
|
||||
onChange={(e) => setFSubject(e.target.value)}
|
||||
placeholder="Décrivez la thématique et les questions posées aux participants."
|
||||
rows={2}
|
||||
required
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block">Message d'introduction (facultatif)</label>
|
||||
<Textarea
|
||||
value={fIntro}
|
||||
onChange={(e) => setFIntro(e.target.value)}
|
||||
placeholder="Message de bienvenue ou instructions pour les participants."
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block">Nom de l'organisateur</label>
|
||||
<Input value={fOrgName} onChange={(e) => setFOrgName(e.target.value)} placeholder="Mairie de Paris" className="text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block">URL logo HTTPS (facultatif)</label>
|
||||
<Input value={fOrgLogo} onChange={(e) => setFOrgLogo(e.target.value)} placeholder="https://…" className="text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" /> Ouverture (UTC)
|
||||
</label>
|
||||
<Input type="datetime-local" value={fStartsAt} onChange={(e) => setFStartsAt(e.target.value)} className="text-sm" />
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Vide = immédiatement</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" /> Fermeture automatique (UTC)
|
||||
</label>
|
||||
<Input type="datetime-local" value={fEndsAt} onChange={(e) => setFEndsAt(e.target.value)} className="text-sm" />
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Vide = pas de fermeture auto</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium mb-1 block">URL Webhook de clôture (facultatif)</label>
|
||||
<Input value={fWebhook} onChange={(e) => setFWebhook(e.target.value)} placeholder="https://…/webhook" className="text-sm font-mono" />
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Reçoit un POST JSON avec la synthèse à la clôture (manuelle ou automatique).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{createError && (
|
||||
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
|
||||
{createError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => { setShowCreateForm(false); resetForm(); }}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={creating}>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Plus className="h-4 w-4 mr-1" />}
|
||||
Créer la consultation
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Chargement…
|
||||
</div>
|
||||
) : consultations.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm border border-dashed border-border rounded-xl">
|
||||
Aucune consultation créée. Utilisez le bouton "Nouvelle consultation" pour commencer.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{consultations.map((c) => (
|
||||
<div key={c.id} className="bg-background border border-border rounded-xl p-4 space-y-3">
|
||||
<div className="flex items-start gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-foreground">{c.title}</span>
|
||||
{c.closedAt ? (
|
||||
<Badge variant="secondary" className="text-xs gap-1">
|
||||
<Lock className="h-2.5 w-2.5" /> Clôturée
|
||||
</Badge>
|
||||
) : c.isOpen ? (
|
||||
<Badge className="bg-green-600 text-xs gap-1">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-white/80 animate-pulse inline-block" />
|
||||
En cours
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs gap-1">
|
||||
<Clock className="h-2.5 w-2.5" /> À venir
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono">/consultation/{c.slug}</p>
|
||||
{c.organizerName && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Building2 className="h-3 w-3" /> {c.organizerName}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-foreground/70 line-clamp-2">{c.subject}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex gap-3 text-xs text-muted-foreground flex-shrink-0">
|
||||
<div className="text-center">
|
||||
<div className="text-base font-bold font-mono text-foreground">{c.stats.accepted}</div>
|
||||
<div>intégrées</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-base font-bold font-mono text-foreground">{c.stats.total}</div>
|
||||
<div>soumises</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Ouverture : {fmtDate(c.startsAt)}
|
||||
</span>
|
||||
{c.endsAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Fermeture auto : {fmtDate(c.endsAt)}
|
||||
</span>
|
||||
)}
|
||||
{c.closedAt && (
|
||||
<span className="flex items-center gap-1 text-orange-600">
|
||||
<Lock className="h-3 w-3" />
|
||||
Fermée le : {fmtDate(c.closedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-wrap pt-1 border-t border-border/30">
|
||||
<Link href={`/consultation/${c.slug}`} target="_blank">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs gap-1">
|
||||
<ExternalLink className="h-3 w-3" /> Voir la page
|
||||
</Button>
|
||||
</Link>
|
||||
{!c.closedAt && (
|
||||
<Button
|
||||
size="sm" variant="outline"
|
||||
className="h-7 text-xs gap-1 text-orange-600 border-orange-200 hover:bg-orange-50"
|
||||
onClick={() => setCloseTarget(c)}
|
||||
>
|
||||
<Lock className="h-3 w-3" /> Fermer manuellement
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="h-7 text-xs gap-1 text-red-500 hover:text-red-600 hover:bg-red-50 ml-auto"
|
||||
onClick={() => setDeleteTarget(c)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" /> Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialog fermeture */}
|
||||
<AlertDialog open={closeTarget !== null} onOpenChange={() => setCloseTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Fermer la consultation ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
La consultation « {closeTarget?.title} » sera définitivement fermée aux nouvelles
|
||||
contributions. Si un webhook est configuré, il sera déclenché immédiatement.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||
<AlertDialogAction className="bg-orange-600 hover:bg-orange-700" onClick={handleClose}>
|
||||
Fermer la consultation
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Dialog suppression */}
|
||||
<AlertDialog open={deleteTarget !== null} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Supprimer la consultation ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
La consultation « {deleteTarget?.title} » sera supprimée avec toutes ses contributions
|
||||
et sa synthèse. Cette action est irréversible.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||
<AlertDialogAction className="bg-red-600 hover:bg-red-700" onClick={handleDelete}>
|
||||
Supprimer définitivement
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Composant principal Admin ────────────────────────────────────────────────
|
||||
|
||||
export default function Admin() {
|
||||
const { token, login, logout, headers } = useAdminAuth();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [activePanel, setActivePanel] = useState<"contributions" | "consultations">("contributions");
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [list, setList] = useState<IdeaList | null>(null);
|
||||
const [status, setStatus] = useState("all");
|
||||
@@ -169,7 +568,6 @@ export default function Admin() {
|
||||
const [overrideReason, setOverrideReason] = useState("");
|
||||
const [overrideNote, setOverrideNote] = useState("");
|
||||
const [regenLoading, setRegenLoading] = useState(false);
|
||||
const [flaggedIds, setFlaggedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
if (!token) return;
|
||||
@@ -184,22 +582,21 @@ export default function Admin() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
status,
|
||||
page: String(page),
|
||||
per_page: "50",
|
||||
status, page: String(page), per_page: "50",
|
||||
...(search ? { q: search } : {}),
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/api/admin/ideas?${params}`, { headers });
|
||||
if (res.ok) {
|
||||
const data: IdeaList = await res.json();
|
||||
setList(data);
|
||||
setList(await res.json());
|
||||
setSelected(new Set());
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoading(false); }
|
||||
}, [token, headers, status, page, search]);
|
||||
|
||||
useEffect(() => { if (token) { fetchStats(); fetchList(); } }, [token, fetchStats, fetchList]);
|
||||
useEffect(() => {
|
||||
if (token && activePanel === "contributions") { fetchStats(); fetchList(); }
|
||||
}, [token, fetchStats, fetchList, activePanel]);
|
||||
|
||||
const deleteOne = async (id: number) => {
|
||||
const res = await fetch(`${API_BASE}/api/admin/ideas/${id}`, { method: "DELETE", headers });
|
||||
@@ -219,7 +616,7 @@ export default function Admin() {
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
toast({ title: `${data.deleted} contribution(s) supprimée(s)`, description: "Synthèse en cours de mise à jour." });
|
||||
toast({ title: `${data.deleted} contribution(s) supprimée(s)` });
|
||||
fetchStats(); fetchList();
|
||||
} else {
|
||||
toast({ title: "Erreur", description: "Suppression en masse échouée.", variant: "destructive" });
|
||||
@@ -234,7 +631,7 @@ export default function Admin() {
|
||||
body: JSON.stringify({ accepted: overrideAccepted, reason: overrideReason, note: overrideNote }),
|
||||
});
|
||||
if (res.ok) {
|
||||
toast({ title: "Statut modifié", description: `Contribution ${overrideAccepted ? "acceptée" : "rejetée"} manuellement.` });
|
||||
toast({ title: "Statut modifié" });
|
||||
fetchStats(); fetchList();
|
||||
} else {
|
||||
toast({ title: "Erreur", description: "Modification échouée.", variant: "destructive" });
|
||||
@@ -244,58 +641,41 @@ export default function Admin() {
|
||||
|
||||
const unflag = async (id: number) => {
|
||||
const res = await fetch(`${API_BASE}/api/admin/ideas/${id}/unflag`, { method: "POST", headers });
|
||||
if (res.ok) {
|
||||
setFlaggedIds((prev) => { const s = new Set(prev); s.delete(id); return s; });
|
||||
toast({ title: "Signalement retiré" });
|
||||
fetchList();
|
||||
}
|
||||
if (res.ok) { toast({ title: "Signalement retiré" }); fetchList(); }
|
||||
};
|
||||
|
||||
const regenerate = async () => {
|
||||
setRegenLoading(true);
|
||||
const res = await fetch(`${API_BASE}/api/admin/synthesis/regenerate`, { method: "POST", headers });
|
||||
if (res.ok) {
|
||||
toast({ title: "Régénération lancée", description: "La synthèse sera mise à jour dans quelques secondes." });
|
||||
}
|
||||
if (res.ok) toast({ title: "Régénération lancée", description: "Mise à jour dans quelques secondes." });
|
||||
setRegenLoading(false);
|
||||
};
|
||||
|
||||
const exportCsv = () => {
|
||||
const url = `${API_BASE}/api/admin/export/csv`;
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.setAttribute("download", "contributions.csv");
|
||||
const req = new XMLHttpRequest();
|
||||
req.open("GET", url);
|
||||
req.setRequestHeader("Authorization", `Bearer ${token}`);
|
||||
req.responseType = "blob";
|
||||
req.onload = () => {
|
||||
const blob = req.response;
|
||||
const objUrl = URL.createObjectURL(blob);
|
||||
a.href = objUrl;
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(req.response);
|
||||
a.setAttribute("download", "contributions.csv");
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(objUrl);
|
||||
URL.revokeObjectURL(a.href);
|
||||
};
|
||||
req.send();
|
||||
};
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelected((prev) => {
|
||||
const s = new Set(prev);
|
||||
s.has(id) ? s.delete(id) : s.add(id);
|
||||
return s;
|
||||
});
|
||||
setSelected((prev) => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (!list) return;
|
||||
if (selected.size === list.ideas.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(list.ideas.map((i) => i.id)));
|
||||
}
|
||||
setSelected(selected.size === list.ideas.length ? new Set() : new Set(list.ideas.map((i) => i.id)));
|
||||
};
|
||||
|
||||
const openOverride = (idea: Idea) => {
|
||||
@@ -327,7 +707,34 @@ export default function Admin() {
|
||||
<ShieldCheck className="h-5 w-5 text-primary flex-shrink-0" />
|
||||
<span className="font-serif font-bold text-primary">Administration</span>
|
||||
<span className="text-muted-foreground text-sm hidden sm:inline">La Voix du Peuple</span>
|
||||
|
||||
{/* Sélecteur de panneau */}
|
||||
<div className="flex gap-1 bg-muted/50 rounded-lg p-1 ml-4">
|
||||
<button
|
||||
onClick={() => setActivePanel("contributions")}
|
||||
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||
activePanel === "contributions"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Contributions
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActivePanel("consultations")}
|
||||
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
|
||||
activePanel === "consultations"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Consultations
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{activePanel === "contributions" && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={regenerate} disabled={regenLoading}>
|
||||
{regenLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
|
||||
<span className="hidden sm:inline ml-1.5">Régénérer synthèse</span>
|
||||
@@ -336,6 +743,8 @@ export default function Admin() {
|
||||
<Download className="h-3 w-3" />
|
||||
<span className="hidden sm:inline ml-1.5">CSV</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={logout}>
|
||||
<LogOut className="h-3 w-3" />
|
||||
<span className="hidden sm:inline ml-1.5">Déconnexion</span>
|
||||
@@ -345,6 +754,14 @@ export default function Admin() {
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 py-6 space-y-6">
|
||||
|
||||
{/* ─── Panel Consultations ─── */}
|
||||
{activePanel === "consultations" && token && (
|
||||
<ConsultationsPanel headers={headers} token={token} />
|
||||
)}
|
||||
|
||||
{/* ─── Panel Contributions ─── */}
|
||||
{activePanel === "contributions" && (
|
||||
<>
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
@@ -357,7 +774,6 @@ export default function Admin() {
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-muted/50 rounded-lg p-1">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
@@ -374,7 +790,6 @@ export default function Admin() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="flex gap-2 sm:ml-auto">
|
||||
<Input
|
||||
value={searchInput}
|
||||
@@ -393,20 +808,18 @@ export default function Admin() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Bulk actions */}
|
||||
{/* Sélection multiple */}
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center gap-3 bg-primary/5 border border-primary/20 rounded-lg px-4 py-2">
|
||||
<span className="text-sm font-medium text-primary">{selected.size} sélectionnée(s)</span>
|
||||
<Button size="sm" variant="destructive" onClick={() => setBulkDeleteOpen(true)}>
|
||||
<Trash2 className="h-3 w-3 mr-1" /> Supprimer la sélection
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>Annuler</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{/* Tableau */}
|
||||
<div className="bg-background border border-border rounded-xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground gap-2">
|
||||
@@ -418,7 +831,6 @@ export default function Admin() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border/50 bg-muted/30 text-xs text-muted-foreground font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -432,7 +844,6 @@ export default function Admin() {
|
||||
<span className="w-28 text-right">Actions</span>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{list.ideas.map((idea) => (
|
||||
<div
|
||||
key={idea.id}
|
||||
@@ -452,6 +863,9 @@ export default function Admin() {
|
||||
{idea.author && (
|
||||
<span className="text-xs text-muted-foreground font-mono">{idea.author}</span>
|
||||
)}
|
||||
{idea.consultationId && (
|
||||
<span className="text-xs text-blue-500 font-mono">consultation #{idea.consultationId}</span>
|
||||
)}
|
||||
{idea.flagged && (
|
||||
<span className="text-xs text-orange-600 font-medium flex items-center gap-0.5">
|
||||
<Flag className="h-3 w-3" /> {idea.flagCount}×
|
||||
@@ -471,30 +885,26 @@ export default function Admin() {
|
||||
</div>
|
||||
|
||||
<div className="w-20 flex-shrink-0 text-right pt-0.5">
|
||||
<Badge
|
||||
variant={idea.accepted ? "default" : "destructive"}
|
||||
className="text-xs"
|
||||
>
|
||||
<Badge variant={idea.accepted ? "default" : "destructive"} className="text-xs">
|
||||
{idea.accepted ? "Acceptée" : "Rejetée"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="w-32 flex-shrink-0 text-right pt-1 hidden sm:block">
|
||||
{idea.createdAt ? (
|
||||
{idea.createdAt && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{new Date(idea.createdAt).toLocaleDateString("fr-FR", {
|
||||
day: "2-digit", month: "2-digit", year: "2-digit",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-28 flex-shrink-0 flex items-center gap-1 justify-end pt-0.5">
|
||||
{idea.flagged && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="sm" variant="ghost"
|
||||
className="h-7 px-1.5 text-orange-600 hover:text-orange-700"
|
||||
title="Retirer le signalement"
|
||||
onClick={() => unflag(idea.id)}
|
||||
@@ -503,17 +913,17 @@ export default function Admin() {
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-1.5"
|
||||
size="sm" variant="ghost" className="h-7 px-1.5"
|
||||
title={idea.accepted ? "Rejeter manuellement" : "Accepter manuellement"}
|
||||
onClick={() => openOverride(idea)}
|
||||
>
|
||||
{idea.accepted ? <X className="h-3.5 w-3.5 text-red-500" /> : <Check className="h-3.5 w-3.5 text-green-600" />}
|
||||
{idea.accepted
|
||||
? <X className="h-3.5 w-3.5 text-red-500" />
|
||||
: <Check className="h-3.5 w-3.5 text-green-600" />
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="sm" variant="ghost"
|
||||
className="h-7 px-1.5 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
title="Supprimer définitivement"
|
||||
onClick={() => setDeleteTarget(idea.id)}
|
||||
@@ -530,25 +940,17 @@ export default function Admin() {
|
||||
{/* Pagination */}
|
||||
{list && list.pages > 1 && (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
size="sm" variant="outline"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground font-mono">
|
||||
{page} / {list.pages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm" variant="outline"
|
||||
disabled={page >= list.pages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
<span className="text-sm text-muted-foreground font-mono">{page} / {list.pages}</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= list.pages} onClick={() => setPage((p) => p + 1)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dialog suppression unique */}
|
||||
@@ -557,8 +959,7 @@ export default function Admin() {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Supprimer cette contribution ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Cette action est irréversible. La contribution sera définitivement supprimée
|
||||
et la synthèse collective sera régénérée automatiquement.
|
||||
Cette action est irréversible. La synthèse collective sera régénérée automatiquement.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -604,18 +1005,14 @@ export default function Admin() {
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant={overrideAccepted ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setOverrideAccepted(true)}
|
||||
className="flex-1"
|
||||
variant={overrideAccepted ? "default" : "outline"} size="sm"
|
||||
onClick={() => setOverrideAccepted(true)} className="flex-1"
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" /> Accepter
|
||||
</Button>
|
||||
<Button
|
||||
variant={!overrideAccepted ? "destructive" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setOverrideAccepted(false)}
|
||||
className="flex-1"
|
||||
variant={!overrideAccepted ? "destructive" : "outline"} size="sm"
|
||||
onClick={() => setOverrideAccepted(false)} className="flex-1"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" /> Rejeter
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRoute, Link } from "wouter";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { getVisitorId } from "@workspace/api-client-react";
|
||||
import { ConsentDialog } from "@/components/consent-dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Clock, Users, ChevronLeft, ChevronRight, Building2,
|
||||
Loader2, CheckCircle2, XCircle, Lock, Printer, Share2,
|
||||
Info, ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? "";
|
||||
|
||||
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;
|
||||
isOpen: boolean;
|
||||
stats: { total: number; accepted: number; lastUpdated: string | null };
|
||||
};
|
||||
|
||||
type Synthesis = {
|
||||
text: string;
|
||||
ideaCount: number;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
type Contribution = {
|
||||
id: number;
|
||||
content: string;
|
||||
author: string | null;
|
||||
createdAt: string | null;
|
||||
};
|
||||
|
||||
type SubmitValues = {
|
||||
content: string;
|
||||
author?: string;
|
||||
_hp?: string;
|
||||
};
|
||||
|
||||
export default function ConsultationPage() {
|
||||
const [, params] = useRoute<{ slug: string }>("/consultation/:slug");
|
||||
const slug = params?.slug ?? "";
|
||||
|
||||
const [consultation, setConsultation] = useState<Consultation | null>(null);
|
||||
const [synthesis, setSynthesis] = useState<Synthesis | null>(null);
|
||||
const [contributions, setContributions] = useState<Contribution[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [loadingPage, setLoadingPage] = useState(true);
|
||||
const [pageError, setPageError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitResult, setSubmitResult] = useState<{ accepted: boolean; reason?: string | null } | null>(null);
|
||||
const [consentGiven, setConsentGiven] = useState(() => !!localStorage.getItem("consent_v1"));
|
||||
const [showConsentDialog, setShowConsentDialog] = useState(false);
|
||||
const pendingSubmitData = useRef<SubmitValues | null>(null);
|
||||
|
||||
const { toast } = useToast();
|
||||
const { register, handleSubmit, reset, watch, formState: { errors } } = useForm<SubmitValues>();
|
||||
const contentValue = watch("content", "");
|
||||
|
||||
const fetchConsultation = useCallback(async () => {
|
||||
if (!slug) return;
|
||||
setLoadingPage(true);
|
||||
setPageError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/consultations/${encodeURIComponent(slug)}`);
|
||||
if (!res.ok) {
|
||||
setPageError("Consultation introuvable ou inaccessible.");
|
||||
return;
|
||||
}
|
||||
setConsultation(await res.json());
|
||||
} catch {
|
||||
setPageError("Impossible de charger la consultation.");
|
||||
} finally {
|
||||
setLoadingPage(false);
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
const fetchSynthesis = useCallback(async () => {
|
||||
if (!slug) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/consultations/${encodeURIComponent(slug)}/synthesis`);
|
||||
if (res.ok) setSynthesis(await res.json());
|
||||
} catch { /* non-bloquant */ }
|
||||
}, [slug]);
|
||||
|
||||
const fetchContributions = useCallback(async (p: number) => {
|
||||
if (!slug) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/consultations/${encodeURIComponent(slug)}/contributions?page=${p}&per_page=20`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setContributions(data.contributions ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
setPages(data.pages ?? 1);
|
||||
}
|
||||
} catch { /* non-bloquant */ }
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => { fetchConsultation(); }, [fetchConsultation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!consultation) return;
|
||||
fetchSynthesis();
|
||||
fetchContributions(1);
|
||||
}, [consultation, fetchSynthesis, fetchContributions]);
|
||||
|
||||
const doActualSubmit = async (data: SubmitValues) => {
|
||||
setSubmitting(true);
|
||||
setSubmitResult(null);
|
||||
try {
|
||||
const visitorId = getVisitorId();
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (visitorId) headers["X-Visitor-Id"] = visitorId;
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/consultations/${encodeURIComponent(slug)}/ideas`,
|
||||
{ method: "POST", headers, body: JSON.stringify({ content: data.content, author: data.author || undefined }) }
|
||||
);
|
||||
const result = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
toast({ title: "Erreur", description: result.message || "Une erreur s'est produite.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitResult({ accepted: result.accepted, reason: result.reason });
|
||||
if (result.accepted) {
|
||||
reset();
|
||||
setTimeout(() => { fetchConsultation(); fetchSynthesis(); fetchContributions(1); }, 1500);
|
||||
}
|
||||
} catch {
|
||||
toast({ title: "Erreur réseau", description: "Impossible de joindre le serveur.", variant: "destructive" });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConsentConfirm = async () => {
|
||||
localStorage.setItem("consent_v1", "true");
|
||||
setConsentGiven(true);
|
||||
setShowConsentDialog(false);
|
||||
try {
|
||||
const visitorId = getVisitorId();
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (visitorId) headers["X-Visitor-Id"] = visitorId;
|
||||
await fetch(`${API_BASE}/api/consent`, {
|
||||
method: "POST", headers,
|
||||
body: JSON.stringify({ consent_version: "1.0" }),
|
||||
});
|
||||
} catch { /* non-bloquant */ }
|
||||
if (pendingSubmitData.current) {
|
||||
await doActualSubmit(pendingSubmitData.current);
|
||||
pendingSubmitData.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: SubmitValues) => {
|
||||
if (!consentGiven) {
|
||||
pendingSubmitData.current = data;
|
||||
setShowConsentDialog(true);
|
||||
return;
|
||||
}
|
||||
await doActualSubmit(data);
|
||||
};
|
||||
|
||||
const changePage = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
fetchContributions(newPage);
|
||||
};
|
||||
|
||||
const shareSynthesis = async () => {
|
||||
if (!synthesis || !consultation) return;
|
||||
const text = `${consultation.title}\n\n${synthesis.text}\n\n— La Voix du Peuple, ${new Date().toLocaleDateString("fr-FR")}`;
|
||||
if (navigator.share) {
|
||||
await navigator.share({ title: consultation.title, text });
|
||||
} else {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast({ title: "Synthèse copiée dans le presse-papier" });
|
||||
}
|
||||
};
|
||||
|
||||
const printPdf = () => {
|
||||
window.open(`${API_BASE}/api/consultations/${encodeURIComponent(slug)}/export/print`, "_blank");
|
||||
};
|
||||
|
||||
if (loadingPage) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh] gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Chargement de la consultation…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pageError || !consultation) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-16 text-center space-y-4">
|
||||
<p className="text-muted-foreground">{pageError ?? "Consultation introuvable."}</p>
|
||||
<Link href="/">
|
||||
<Button variant="outline" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" /> Retour à l'accueil
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOpen = consultation.isOpen;
|
||||
const isClosed = !!consultation.closedAt;
|
||||
const endsAt = consultation.endsAt ? new Date(consultation.endsAt) : null;
|
||||
const closedAt = consultation.closedAt ? new Date(consultation.closedAt) : null;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8 space-y-8">
|
||||
|
||||
{/* En-tête de consultation */}
|
||||
<div className="rounded-xl border border-border bg-card p-6 space-y-4">
|
||||
|
||||
{(consultation.organizerName || consultation.organizerLogoUrl) && (
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
{consultation.organizerLogoUrl ? (
|
||||
<img
|
||||
src={consultation.organizerLogoUrl}
|
||||
alt={consultation.organizerName ?? "Organisateur"}
|
||||
className="h-8 w-8 rounded object-contain border border-border"
|
||||
/>
|
||||
) : (
|
||||
<Building2 className="h-5 w-5 flex-shrink-0" />
|
||||
)}
|
||||
<span className="font-medium text-foreground">{consultation.organizerName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-3">
|
||||
<div className="flex-1 space-y-1">
|
||||
<h1 className="font-serif text-2xl font-bold text-primary leading-tight">
|
||||
{consultation.title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{consultation.subject}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
{isClosed ? (
|
||||
<Badge variant="secondary" className="gap-1.5">
|
||||
<Lock className="h-3 w-3" /> Clôturée
|
||||
</Badge>
|
||||
) : isOpen ? (
|
||||
<Badge className="bg-green-600 hover:bg-green-700 gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-white/80 animate-pulse inline-block" />
|
||||
En cours
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="gap-1.5">
|
||||
<Clock className="h-3 w-3" /> À venir
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{consultation.introMessage && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm whitespace-pre-line">
|
||||
{consultation.introMessage}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-xs text-muted-foreground border-t border-border pt-4">
|
||||
{endsAt && !isClosed && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
{isOpen
|
||||
? `Fermeture ${formatDistanceToNow(endsAt, { locale: fr, addSuffix: true })}`
|
||||
: `Ouverture ${formatDistanceToNow(endsAt, { locale: fr, addSuffix: true })}`}
|
||||
{" · "}
|
||||
{format(endsAt, "d MMMM yyyy à HH:mm", { locale: fr })} UTC
|
||||
</span>
|
||||
)}
|
||||
{closedAt && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Lock className="h-3 w-3" />
|
||||
Clôturée le {format(closedAt, "d MMMM yyyy", { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="h-3 w-3" />
|
||||
{consultation.stats.accepted} contribution{consultation.stats.accepted !== 1 ? "s" : ""} intégrée{consultation.stats.accepted !== 1 ? "s" : ""}
|
||||
{consultation.stats.total > consultation.stats.accepted && (
|
||||
<span className="opacity-60 ml-1">
|
||||
({consultation.stats.total} soumise{consultation.stats.total !== 1 ? "s" : ""})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* Colonne gauche : formulaire + contributions */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Formulaire (consultation ouverte) */}
|
||||
{isOpen && (
|
||||
<div className="rounded-xl border border-border bg-card p-5 space-y-4">
|
||||
<h2 className="font-serif text-lg font-semibold text-primary">
|
||||
Exprimer votre avis
|
||||
</h2>
|
||||
|
||||
{submitResult ? (
|
||||
<div className={`rounded-lg border p-4 space-y-2 ${
|
||||
submitResult.accepted
|
||||
? "border-green-200 bg-green-50"
|
||||
: "border-red-200 bg-red-50"
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 font-medium text-sm">
|
||||
{submitResult.accepted
|
||||
? <><CheckCircle2 className="h-4 w-4 text-green-600" /> Contribution intégrée à la synthèse</>
|
||||
: <><XCircle className="h-4 w-4 text-red-600" /> Contribution non retenue</>
|
||||
}
|
||||
</div>
|
||||
{!submitResult.accepted && submitResult.reason && (
|
||||
<p className="text-xs text-red-700 mt-1">{submitResult.reason}</p>
|
||||
)}
|
||||
<Button size="sm" variant="outline" className="mt-2" onClick={() => setSubmitResult(null)}>
|
||||
Soumettre une autre contribution
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
|
||||
<div>
|
||||
<Textarea
|
||||
{...register("content", {
|
||||
required: "Le texte est requis.",
|
||||
minLength: { value: 10, message: "Au moins 10 caractères." },
|
||||
maxLength: { value: 1000, message: "Maximum 1000 caractères." },
|
||||
})}
|
||||
placeholder="Partagez votre avis, proposition ou témoignage…"
|
||||
rows={4}
|
||||
className="resize-none text-sm"
|
||||
disabled={submitting}
|
||||
aria-label="Votre contribution"
|
||||
/>
|
||||
<div className="flex justify-between mt-1">
|
||||
{errors.content
|
||||
? <p className="text-xs text-red-600">{errors.content.message}</p>
|
||||
: <span />
|
||||
}
|
||||
<span className={`text-xs ${(contentValue?.length ?? 0) > 900 ? "text-orange-500" : "text-muted-foreground"}`}>
|
||||
{contentValue?.length ?? 0}/1000
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
{...register("author")}
|
||||
placeholder="Pseudonyme (facultatif)"
|
||||
className="text-sm"
|
||||
disabled={submitting}
|
||||
/>
|
||||
{/* Champ leurre anti-bot — ne pas remplir */}
|
||||
<input
|
||||
{...register("_hp")}
|
||||
type="text"
|
||||
style={{ display: "none" }}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Button type="submit" className="w-full" disabled={submitting}>
|
||||
{submitting
|
||||
? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Envoi en cours…</>
|
||||
: "Soumettre ma contribution"
|
||||
}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Chaque contribution est filtrée par IA selon le droit international des droits humains.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Consultation pas encore ouverte */}
|
||||
{!isOpen && !isClosed && (
|
||||
<Alert>
|
||||
<Clock className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Cette consultation n'est pas encore ouverte aux contributions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Consultation fermée */}
|
||||
{isClosed && (
|
||||
<Alert>
|
||||
<Lock className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Cette consultation est clôturée. Les contributions ne sont plus acceptées.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Contributions */}
|
||||
{contributions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-serif text-lg font-semibold text-primary">
|
||||
Contributions ({total})
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{contributions.map((c) => (
|
||||
<div key={c.id} className="rounded-lg border border-border bg-card p-3 space-y-1">
|
||||
<p className="text-sm leading-relaxed">{c.content}</p>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{c.author || "Anonyme"}</span>
|
||||
{c.createdAt && (
|
||||
<span>{format(new Date(c.createdAt), "d MMM yyyy", { locale: fr })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => changePage(page - 1)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground font-mono">{page} / {pages}</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= pages} onClick={() => changePage(page + 1)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Colonne droite : synthèse */}
|
||||
<div className="space-y-4">
|
||||
{synthesis && synthesis.ideaCount > 0 ? (
|
||||
<div className="rounded-xl border border-primary/20 bg-primary/5 p-5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-serif text-lg font-semibold text-primary">Synthèse collective</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{synthesis.ideaCount} contribution{synthesis.ideaCount !== 1 ? "s" : ""} intégrée{synthesis.ideaCount !== 1 ? "s" : ""}
|
||||
{synthesis.updatedAt && (
|
||||
<> · mise à jour {formatDistanceToNow(new Date(synthesis.updatedAt), { locale: fr, addSuffix: true })}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-shrink-0">
|
||||
<Button
|
||||
size="sm" variant="outline" className="h-7 px-2"
|
||||
onClick={shareSynthesis} title="Copier / partager"
|
||||
aria-label="Copier ou partager la synthèse"
|
||||
>
|
||||
<Share2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm" variant="outline" className="h-7 px-2"
|
||||
onClick={printPdf} title="Exporter en PDF"
|
||||
aria-label="Exporter la synthèse en PDF"
|
||||
>
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed whitespace-pre-line text-foreground/90">
|
||||
{synthesis.text}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground italic border-t border-border/50 pt-3">
|
||||
Synthèse générée par IA à partir des contributions citoyennes.
|
||||
Elle reflète l'expression des participants, pas une vérité établie.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/50 bg-muted/30 p-6 text-center space-y-2">
|
||||
<Users className="h-8 w-8 mx-auto text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
La synthèse apparaîtra dès les premières contributions acceptées.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-3.5 w-3.5 mr-1.5" />
|
||||
Retour à la plateforme principale
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConsentDialog
|
||||
open={showConsentDialog}
|
||||
onConsent={handleConsentConfirm}
|
||||
onCancel={() => { setShowConsentDialog(false); pendingSubmitData.current = null; }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+63
-8
@@ -1,7 +1,7 @@
|
||||
# Document d'Architecture Technique — La Voix du Peuple
|
||||
|
||||
**Version** : 1.4
|
||||
**Date** : Avril 2026
|
||||
**Version** : 1.5
|
||||
**Date** : Mai 2026
|
||||
**Statut** : Prêt pour auto-hébergement
|
||||
|
||||
---
|
||||
@@ -15,6 +15,7 @@
|
||||
| 1.2 | Avril 2026 | Palette pétrol neutre, textes de posture (expression vs. vérité) |
|
||||
| 1.3 | Avril 2026 | Dark mode pétrol, panneau d'accessibilité (dyslexie, contraste, zoom) |
|
||||
| 1.4 | Avril 2026 | Synchronisation Gitea sécurisée — `GITEA_TOKEN` + `scripts/push-gitea.sh` |
|
||||
| 1.5 | Mai 2026 | P5 — Mode consultation ciblée (table `consultations`, routes `/consultation/:slug`, panel admin, webhook, auto-fermeture) |
|
||||
|
||||
---
|
||||
|
||||
@@ -88,6 +89,10 @@
|
||||
| `/about` | À propos, fondements juridiques, posture de la démarche |
|
||||
| `/transparence` | Fonctionnement de l'IA, données collectées, limites, posture éditoriale |
|
||||
| `/flyer` | Flyer imprimable avec QR code configurable pour diffusion physique |
|
||||
| `/consultation/:slug` | Page d'une consultation ciblée : formulaire, synthèse, contributions, export PDF |
|
||||
| `/contributions-brutes` | Contributions globales paginées, exports JSON/CSV, transparence éditoriale |
|
||||
| `/mentions-legales` | Mentions légales (LCEN) |
|
||||
| `/politique-confidentialite` | Politique de confidentialité et droits RGPD |
|
||||
|
||||
**Accessibilité** :
|
||||
|
||||
@@ -137,15 +142,26 @@
|
||||
| Rate limiting | flask-limiter (5 req/min par IP sur POST /api/ideas) |
|
||||
| ORM | psycopg2-binary (requêtes SQL directes) |
|
||||
|
||||
**Endpoints** :
|
||||
**Endpoints publics** :
|
||||
|
||||
| Méthode | Route | Description |
|
||||
|---------|-------|-------------|
|
||||
| `GET` | `/api/ideas` | Liste des contributions acceptées |
|
||||
| `POST` | `/api/ideas` | Soumet une nouvelle contribution |
|
||||
| `GET` | `/api/ideas/stats` | Statistiques (acceptées/refusées) |
|
||||
| `GET` | `/api/synthesis` | Texte de synthèse actuel |
|
||||
| `GET` | `/health` | Health check |
|
||||
| `GET` | `/api/healthz` | Health check |
|
||||
| `GET` | `/api/ideas` | Contributions globales acceptées |
|
||||
| `POST` | `/api/ideas` | Soumet une contribution globale |
|
||||
| `GET` | `/api/synthesis` | Synthèse globale |
|
||||
| `GET` | `/api/stats/public` | Statistiques publiques globales |
|
||||
| `POST` | `/api/consent` | Enregistre le consentement RGPD |
|
||||
| `GET` | `/api/contributions` | Contributions globales paginées (transparence) |
|
||||
| `GET` | `/api/contributions/export/json` | Export JSON contributions globales |
|
||||
| `GET` | `/api/contributions/export/csv` | Export CSV contributions globales |
|
||||
| `POST` | `/api/ideas/<id>/flag` | Signale une contribution |
|
||||
| `GET` | `/api/consultations` | Liste des consultations actives |
|
||||
| `GET` | `/api/consultations/<slug>` | Détails + stats d'une consultation |
|
||||
| `GET` | `/api/consultations/<slug>/synthesis` | Synthèse d'une consultation |
|
||||
| `GET` | `/api/consultations/<slug>/contributions` | Contributions d'une consultation paginées |
|
||||
| `POST` | `/api/consultations/<slug>/ideas` | Soumet une contribution à une consultation |
|
||||
| `GET` | `/api/consultations/<slug>/export/print` | HTML pour impression/PDF |
|
||||
|
||||
**Réponses** : JSON camelCase, statuts HTTP standard.
|
||||
|
||||
@@ -177,6 +193,26 @@ Deux appels distincts à l'API Mistral (compatible OpenAI SDK) :
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Consultations ciblées (P5)
|
||||
|
||||
Le mode consultation permet à un organisateur (mairie, asso, collectif) de créer un espace thématique ciblé, accessible via `/consultation/<slug>`.
|
||||
|
||||
**Caractéristiques** :
|
||||
- Fenêtre temporelle (`starts_at`, `ends_at`) avec fermeture automatique (boucle arrière-plan, 60 s)
|
||||
- Séparation stricte : `consultation_id IS NULL` = contribution globale (page d'accueil)
|
||||
- Synthèse indépendante par consultation (même mécanique IA, contexte isolé)
|
||||
- Webhook de clôture : POST JSON avec synthèse finale vers `webhook_url` configuré
|
||||
- Export HTML/PDF : `GET /api/consultations/<slug>/export/print`
|
||||
- Gestion complète dans le panel admin (onglet Consultations)
|
||||
|
||||
**Boucle auto-fermeture** :
|
||||
```
|
||||
threading.Thread(target=_autoclose_consultations_loop, daemon=True).start()
|
||||
# → time.sleep(60) → get_consultations_to_autoclose() → close_consultation() → _trigger_webhook()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Base de données — PostgreSQL
|
||||
|
||||
**Table `ideas`** :
|
||||
@@ -190,6 +226,7 @@ Deux appels distincts à l'API Mistral (compatible OpenAI SDK) :
|
||||
| `rejection_reason` | TEXT | Motif de refus (nullable) |
|
||||
| `legal_basis` | TEXT | Base légale du refus (nullable) |
|
||||
| `created_at` | TIMESTAMPTZ | Horodatage de soumission |
|
||||
| `consultation_id` | INTEGER FK | Clé étrangère vers `consultations` (NULL = global) |
|
||||
|
||||
**Table `synthesis`** :
|
||||
|
||||
@@ -199,6 +236,24 @@ Deux appels distincts à l'API Mistral (compatible OpenAI SDK) :
|
||||
| `text` | TEXT | Dernier texte de synthèse |
|
||||
| `idea_count` | INTEGER | Nombre de contributions intégrées |
|
||||
| `updated_at` | TIMESTAMPTZ | Dernière mise à jour |
|
||||
| `consultation_id` | INTEGER FK | Clé étrangère vers `consultations` (NULL = synthèse globale) |
|
||||
|
||||
**Table `consultations`** :
|
||||
|
||||
| Colonne | Type | Description |
|
||||
|---------|------|-------------|
|
||||
| `id` | SERIAL PK | Identifiant |
|
||||
| `slug` | VARCHAR(100) UNIQUE | Identifiant URL (`/consultation/<slug>`) |
|
||||
| `title` | VARCHAR(200) | Titre affiché |
|
||||
| `subject` | TEXT | Description de la thématique |
|
||||
| `intro_message` | TEXT | Message d'introduction (nullable) |
|
||||
| `organizer_name` | VARCHAR(200) | Nom de l'organisme (nullable) |
|
||||
| `organizer_logo_url` | TEXT | URL logo HTTPS (nullable) |
|
||||
| `starts_at` | TIMESTAMPTZ | Date d'ouverture (défaut : NOW()) |
|
||||
| `ends_at` | TIMESTAMPTZ | Date de fermeture automatique (nullable) |
|
||||
| `closed_at` | TIMESTAMPTZ | Date de fermeture effective (nullable) |
|
||||
| `webhook_url` | TEXT | URL du webhook de clôture (nullable) |
|
||||
| `created_at` | TIMESTAMPTZ | Création |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+57
-4
@@ -1,7 +1,7 @@
|
||||
# Document d'Exploitation — La Voix du Peuple
|
||||
|
||||
**Version** : 1.4
|
||||
**Date** : Avril 2026
|
||||
**Version** : 1.7
|
||||
**Date** : Mai 2026
|
||||
|
||||
---
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
| 1.4 | Avril 2026 | Synchronisation Gitea sécurisée — `GITEA_TOKEN` + `scripts/push-gitea.sh` |
|
||||
| 1.5 | Avril 2026 | Droit pénal français intégré dans le filtre IA (16 sections) |
|
||||
| 1.6 | Avril 2026 | Panel admin sécurisé (`/admin`), signalement public, export CSV |
|
||||
| 1.7 | Mai 2026 | P5 — Mode consultation ciblée (table `consultations`, webhook, auto-fermeture, panel admin) |
|
||||
|
||||
---
|
||||
|
||||
@@ -405,14 +406,66 @@ Sur la page d'accueil, chaque contribution affiche un bouton **Signaler** au sur
|
||||
| `POST` | `/api/admin/ideas/<id>/unflag` | Retirer le signalement |
|
||||
| `POST` | `/api/admin/synthesis/regenerate` | Forcer la régénération |
|
||||
| `GET` | `/api/admin/export/csv` | Export CSV (toutes les contributions) |
|
||||
| `GET` | `/api/admin/consultations` | Liste toutes les consultations avec stats |
|
||||
| `POST` | `/api/admin/consultations` | Crée une consultation (`{slug, title, subject, ...}`) |
|
||||
| `POST` | `/api/admin/consultations/<slug>/close` | Ferme manuellement + déclenche webhook |
|
||||
| `DELETE` | `/api/admin/consultations/<slug>` | Supprime une consultation et ses données |
|
||||
|
||||
---
|
||||
|
||||
## 17. Contacts et ressources
|
||||
## 17. Mode consultation ciblée
|
||||
|
||||
### Créer une consultation (panel admin)
|
||||
|
||||
1. Ouvrir `/admin` → onglet **Consultations** → **Nouvelle consultation**
|
||||
2. Remplir : slug (URL-safe, ex. `budget-mairie-2026`), titre, sujet
|
||||
3. Optionnel : message d'introduction, organisateur, logo HTTPS, dates, webhook
|
||||
4. Les dates sont interprétées comme UTC
|
||||
5. La consultation est immédiatement accessible sur `/consultation/<slug>`
|
||||
|
||||
### Fermeture automatique
|
||||
|
||||
La fermeture automatique est gérée par un thread arrière-plan (toutes les 60 secondes).
|
||||
Si `ends_at` est défini et dépassé, la consultation est fermée automatiquement et le webhook déclenché.
|
||||
|
||||
```bash
|
||||
# Vérifier les consultations actives
|
||||
psql "$DATABASE_URL" -c "SELECT slug, title, starts_at, ends_at, closed_at FROM consultations;"
|
||||
```
|
||||
|
||||
### Webhook de clôture
|
||||
|
||||
Si `webhook_url` est configuré, un POST JSON est envoyé à la clôture :
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "consultation_closed",
|
||||
"consultation": {
|
||||
"slug": "budget-mairie-2026",
|
||||
"title": "Budget participatif 2026",
|
||||
"closedAt": "2026-06-30T18:00:00+00:00"
|
||||
},
|
||||
"synthesis": {
|
||||
"text": "...",
|
||||
"ideaCount": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Export PDF d'une consultation
|
||||
|
||||
```
|
||||
GET /api/consultations/<slug>/export/print
|
||||
```
|
||||
|
||||
Retourne une page HTML auto-imprimante. Accessible depuis le bouton **Exporter PDF** sur la page de consultation, ou directement en navigateur.
|
||||
|
||||
---
|
||||
|
||||
## 18. Contacts et ressources
|
||||
|
||||
- Documentation Mistral : https://docs.mistral.ai
|
||||
- PostgreSQL : https://www.postgresql.org/docs/
|
||||
- Flask : https://flask.palletsprojects.com
|
||||
- qrcode.react : https://github.com/zpao/qrcode.react
|
||||
- Architecture : `docs/DAT.md`
|
||||
- Synchronisation Gitea : `docs/GITEA_TUTO.md`
|
||||
|
||||
Reference in New Issue
Block a user