Compare commits

58 Commits
Author SHA1 Message Date
YvvandClaude Fable 5 4b1732916d ci : deploy résilient — repli certificat existant + arrêt de l'ancienne stack
- ACME : le challenge HTTP-01 échoue en IPv6 (AAAA présent, webroot non
  servi en v6) ; quand un certificat est déjà en place (valide jusqu'au
  30/09), on le conserve avec avertissement au lieu de bloquer le déploiement
  — sans certificat existant, échec franc inchangé
- l'org forge renommée EHV→LPK a changé le COMPOSE_PROJECT_NAME : le
  « compose stop » ne touchait plus l'ancienne stack ehv-decision-main-*
  (double inscription fabio, trafic partagé v1/v2) → arrêt par filtre de nom
  avant déploiement

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 04:14:00 +02:00
YvvandClaude Fable 5 ef0347b6aa ci : sbom-publish non bloquant (DTrack en panne côté serveur)
- le POST vers Dependency-Track meurt à la connexion (secret dtrack_domain
  vide ou service éteint) et bloquait le déploiement au step sbom-publish
- failure: ignore — télémétrie ; le scan CVE trivy reste bloquant
- à réexaminer côté serveur : service DTrack + secrets dependency_track_token
  / dtrack_domain du repo Woodpecker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 03:25:25 +02:00
YvvandClaude Fable 5 a80f82cb64 ci : SBOM CycloneDX épinglé en spec 1.5 (compat trivy du runner)
- syft installé en « latest » émet une spec plus récente que ce que le
  trivy 0.69.3 du runner décode → « invalid specification version »
  au step sbom-scan (pipeline 26, tout le reste était vert)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 02:55:08 +02:00
YvvandClaude Fable 5 db156884c9 Fusion de la relance CI faite côté forge (e9f064d)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 02:01:30 +02:00
YvvandClaude Fable 5 493836ff11 build : @yvv/nuxt-base en git+https + git dans les environnements npm
- la dép git+ssh échouait en CI : pas de clé SSH dans les conteneurs, et
  node:20-slim n'a pas le binaire git (ENOENT) — npm signalait un faux
  « tarball corrupted »
- package.json + lock : git+https://git.open.us.org/… (#v0.1.0 → b1c6a2f,
  même commit épinglé) — nécessite le repo yvv-nuxt-base public
- git + ca-certificates ajoutés : step test-frontend (.woodpecker.yml) et
  image de base du frontend.Dockerfile
- vérifié : npm install + build zéro erreur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 01:13:22 +02:00
aya e9f064dc78 force CI 2026-08-11 22:21:00 +02:00
YvvandClaude Fable 5 a8998f0624 ci : relance de la pipeline (config forge non récupérée sur le run 21)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 21:10:22 +02:00
YvvandClaude Fable 5 3301b764f7 v2 : correctifs QA — overlays posés, accueil jamais orphelin, finitions mobiles
- overlays Nuxt UI (UModal/USlideover) : géométrie posée en CSS
  (assets/css/overlays.css) — les utilitaires tailwind attendus par @nuxt/ui
  ne sont pas générés (UnoCSS ne scanne pas node_modules) : drawer mobile et
  modales de confirmation se rendaient hors écran
- a11y : title/description sur les 3 overlays (DialogTitle/Description
  rendus, warnings reka-ui éteints), fermeture du drawer ancrée et testée
- suppression du collectif actif : bascule sur un collectif restant —
  jamais d'accueil orphelin (test store ajouté, 343 vitest verts)
- observatoire : la route « transmis » entre dans les barres (base 100 %)
- overflows mobiles à zéro : formules KaTeX (défilement), table vigueur
  (défilement < 768px), pill cible (retour à la ligne), inputs decider et
  mandat (border-box)
- vérifié navigateur : drawer, modale, 14 routes sans débordement,
  zéro erreur console

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 20:11:54 +02:00
YvvandClaude Fable 5 38fd053e87 Fusion feat/v2-refonte : libreDecision v2 — démocratie d'exercice
- 8 moteurs purs (342 tests verts), 14 écrans, 5 modalités, 7 gabarits
- local-first : IndexedDB + export/import Bundle, seeds Atelier + Ğ1
- correctif QA : désignation « c'est moi » dans un collectif importé
- documentation v2 réécrite, orphelins v1 supprimés

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 17:54:31 +02:00
YvvandClaude Fable 5 cf59bbca0d v2 : désignation « c'est moi » dans un collectif importé (correctif QA)
- les seeds (Ğ1, Atelier) n'ont aucun isMe : la carte identité propose
  désormais de se désigner parmi les personnes existantes (chips) ou de
  s'ajouter (formulaire), au lieu d'un état vide sans issue
- vitest 13 fichiers verts, build zéro erreur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 17:53:16 +02:00
YvvandClaude Fable 5 f2829339cf chore : sort les PDF des livres du suivi git (restent sur disque)
- docs/*.pdf ignorés — sources doctrinales locales, pas des artefacts du code

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 14:11:19 +02:00
YvvandClaude Fable 5 7cfc7ea352 v2 : nettoyage des orphelins v1 + documentation livrable
- 43 fichiers v1 supprimés (composants documents/protocols/sanctuary/toolbox,
  stores auth/documents/groups/mandates/organizations/protocols/votes,
  composables api/notifications/formula/websocket, utils doublons du moteur)
- nuxt.config épuré (polkadot retiré, KaTeX et apiBase gardés), meta v2
- README.md, CONTRIBUTING.md, CLAUDE.md réécrits pour la v2
- Build zéro erreur, 342/342 tests verts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 14:09:05 +02:00
YvvandClaude Fable 5 e164b5f6c1 v2 : les 14 écrans — le tour complet de la démocratie d'exercice
- Aujourd'hui (Fil 13 sections + capture sticky) + Le chemin (tunnel 2 gestes,
  Q0 inline, 3 chips, dérogation asymétrique, alternatives réglage/consignation)
- Registre + fiche décision (timeline, périmètre premier/second lieu auditable,
  affluence non-ignorable, S'instruire condensé, éléments + cartographie de
  clôture, épreuve du réel, Remettre en question, PV A4, gravure)
- Salle de vote 5 modalités (consentement, nuancé+histogramme, binaire hérité
  avec jauge inertielle, Réglage collectif complet — faisceau, médiane basse,
  Pour moi, Explorer, cristallisation-geste —, élection à départage humain)
- Textes (Pacte en clair, document vivant, diff, vue projetée, Atelier des
  formules porté du v1 sur le moteur unique) + Mandats (faits comptés, feux
  de la rampe, wizard 3 étapes) + Observatoire (consigner→observer→protocoliser)
- Onboarding 7 gabarits + Données locales (export/import, attributs, atelier)
- voting→framing gardé (Reformuler d'un réglage figé non cristallisé)
- Pages v1 supprimées (login, documents, mandates, protocols, sanctuary,
  tools, decisions/new) — 342 tests verts, build zéro erreur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 13:47:20 +02:00
YvvandClaude Fable 5 d886302b59 v2 : couche données complète + shell + primitives communes
- stores/collective.ts (état local-first, gabarits, import/export, seeds)
  + stores/decisions.ts (cycle de vie complet : capture→chemin→fenêtres→
  sessions→cristallisation→épreuve du réel→révocation)
- data/templates.ts : 7 gabarits (Page blanche observatoire-d'abord,
  5 points de départ, Institution symétrique)
- composables useFeed (13 sections du Fil en sélecteurs purs) + useSearch
  (index unique Cmd+K/Q0)
- shell : layouts default/bare, app.vue mince, useMood v2 (Source/Margelle/
  Nappe/Minuit), sceau 井 = logo, LdWorkspaceSelector avec finalité A1,
  LdAvatarStack (premier/second lieu), LdCountdown (suspension striée)
- 338 tests vitest verts, npm run build zéro erreur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 10:30:35 +02:00
YvvandClaude Fable 5 6f2bf62295 v2 : seed Duniter Ğ1 extrait sans perte (bundle 331 Ko + 22 tests)
- export_seed_bundle.py : parsing ast de seed.py (zéro dépendance DB),
  uuid5 déterministes, sha256 reproductible
- 33 clauses Certification + 59 Forgeron v2.0.0-fr, voteRecord 97/23/19
  W=7224 seuil 97 D30M50B.1G.2S.1, 11 personnes (5 forgerons), 3 mandats,
  10 protocoles, Pacte 20 clauses, 118 décisions fondatrices chaînées
- Écarts v1 documentés dans le script (sessions de démo non portées,
  ComTech reconstruit depuis la recherche forum, presets câblés par clause)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 09:48:13 +02:00
YvvandClaude Fable 5 f707b5f15d v2 : moteurs purs complets + ambiances + persistance + seed Atelier du Canal
- engine/ : parametric (médiane basse, cristallisation, impact linear-share,
  bimodalité), state (canTransition 9 gardes doctrinales + windowOutcome),
  settings (resolveSettings + replis), triage (R-U→R6, phrases françaises),
  impact (concernés calculés), électionResult (blanc, quorum, égalité sans
  départage machine) — 296 tests vitest verts
- moods.css v2 : Source/Margelle/Nappe/Minuit (champ lexical du puits),
  tokens routes/états, socle borderless, print A4, tampon 井
- data/persistence.ts : IndexedDB local-first, export/import Bundle, lignée
- Seed Atelier du Canal (145 Ko, tous les états de l'UI) + test
- backend/scripts/export_seed_bundle.py (extraction Ğ1, bundle à générer)
- test anti-lexique (marqueur ld-v2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 09:22:19 +02:00
YvvandClaude Fable 5 53d8752e40 v2 : conception ultracode + moteur de formules porté en TS
- Blueprint définitif (docs/dev/BLUEPRINT-V2.md + JSON) : panel 4 visions
  + 3 juges, corpus doctrinal (Mon nom est personne, Une économie du don,
  SejeteralO), 4 critiques d'intégration, 3 vérificateurs, réparations
- Corpus d'intention + lectures brutes archivés
- Moteur porté Python→TS pur : threshold (inertie WoT, Smith, ComTech),
  nuancé 6 niveaux, DSL modeParams — 45 tests vitest, parité croisée
  vérifiée (Forgeron W=7224 T=120 ⇒ 94)
- Contrat de domaine v2 (types/domain.ts) + lexique UI (lexicon.ts)
- vitest + idb-keyval en dépendances

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 05:59:33 +02:00
YvvandClaude Opus 4.8 172eab4c7c design(sceau): hexagramme 井 traits épais, coins nets, aspect carré
- viewBox 112×105, height 8, rx supprimé (rendu cut)
- inline app.vue + SVG public alignés
- géométrie calée sur les modèles IMAGES/PERVIER/Hexagrammes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 06:54:05 +02:00
YvvandClaude Opus 4.8 39883ee444 build(deps): flip @yvv/nuxt-base vers la dép git distante (v0.1.0)
- extends: ['../../yvv-nuxt-base'] → ['@yvv/nuxt-base']
- package.json : dép git+ssh://gitea@git.open.us.org/yvv/yvv-nuxt-base.git#v0.1.0
- résout au commit taggé b1c6a2f (identique au layer local)
- build npm vert, prod :3099 → 200, smoke moods 8/8 (4 moods × light/dark × mobile/desktop)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 18:33:54 +02:00
YvvandClaude Opus 4.8 55020033ee chore(git): ignore les backups .db.bak (données runtime)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:45:02 +02:00
YvvandClaude Opus 4.8 a9e586cda1 refactor(moods): adopte le layer @yvv/nuxt-base pour le mécanisme d'ambiances
- nuxt.config: extends ['../../yvv-nuxt-base']
- useMood.ts: délègue à useMood(moods) du layer, exporté useLibreMood
  (évite la collision avec l'auto-import du layer)
- MoodSwitcher.vue + app.vue: appellent useLibreMood()
- moods.css inchangé (couleurs = identité projet)

Mécanisme partagé (application DOM, persistance, sync color-mode) mutualisé
sans toucher le contrat CSS. Vérifié headless (Playwright) : 4 moods × light/dark
× desktop/mobile OK, persistance au reload OK, zéro erreur JS, zéro régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 09:07:02 +02:00
YvvandClaude Sonnet 4.6 59fff64f9e Compartimentation : isolation stricte des données par espace de travail
- Ajout clause else IS NULL sur tous les endpoints list (protocols, decisions,
  mandates, documents, groups, votes) — sans X-Organization → données globales
  seulement, jamais le contenu d'un autre espace
- _get_protocol/_get_decision/_get_mandate : org_id propagé à tous les
  endpoints GET/PUT/DELETE/advance/assign/revoke/steps → 404 si UUID d'un
  autre espace
- votes.py : list_vote_sessions filtre via JOIN VotingProtocol.organization_id
- groups.py : suppression _org_id_from_header() mort, create_group assigne
  organization_id correctement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:52:16 +02:00
YvvandClaude Sonnet 4.6 f56d84e76b Mandats : origin→FK identité + nomination auto + boutons + tests intégration
- origin TEXT → origin_id UUID FK duniter_identities (migration e3f4a5b6c7d8)
- GET /auth/identities?q= : recherche d'identités par nom/adresse
- MandateCreate.nomination_mode : auto (auto-assign auteur), collective, postpone
- Wizard new.vue : champ origine = picker identité, checkbox "Démarrer maintenant"
- [id].vue : modal "Assigner" = search-picker (résout UUID vs adresse SS58), affiche
  origin_display_name + mandatee_display_name, inputs natifs (<input>/<textarea>)
- Erreurs API visibles dans l'UI (plus de catch silencieux)
- test_mandate_flows.py : 17 tests intégration SQLite réels (origin, nomination,
  assign, lifecycle, revocation, interactions croisées) — 241 tests total OK

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:48:27 +02:00
YvvandClaude Sonnet 4.6 3423ac2e7e Migrations : fix ADD CONSTRAINT IF NOT EXISTS (syntaxe PostgreSQL invalide)
Remplace par un bloc DO $$ IF NOT EXISTS ... $$ pour le FK circulaire
item_versions→document_items. Testé localement sur pg-test : 5 migrations
OK + idempotence OK.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 02:57:39 +02:00
YvvandClaude Sonnet 4.6 2d2ac79cd5 Alembic : migration initiale + chaîne idempotente IF NOT EXISTS
- 0b9c1d2e3f4a : migration initiale (CREATE TABLE IF NOT EXISTS) — safe
  sur une DB déjà bootstrappée via create_all()
- 70914b334cfb : ADD COLUMN IF NOT EXISTS (organization_id) — was down_revision=None
- b78571ae9e00 : CREATE TABLE IF NOT EXISTS qualification_protocols
- c4e812fb3a01 : CREATE TABLE IF NOT EXISTS groups + group_members
- d91a3c7f8b02 : ADD COLUMN IF NOT EXISTS origin (mandates)
- Dockerfile prod : restaure alembic upgrade head au démarrage

Sur le serveur prod, exécuter une fois :
  docker exec <projet>-backend alembic upgrade head

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 02:47:20 +02:00
YvvandClaude Sonnet 4.6 21bca67e6a Docker : supprime alembic upgrade head du startup
Le lifespan FastAPI appelle déjà init_db() (create_all) au démarrage.
Lancer alembic en plus cassait sur une DB vide (migration initiale manquante).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 02:16:50 +02:00
YvvandClaude Sonnet 4.6 56d72eeec2 Auth : mode prototype factice en prod + fix test DB manquante
- Login : panneau proto-mode en avant quand DEMO_MODE actif (profils API)
  masque le formulaire extension-required ; note trustWallet à venir
- auth.ts : TODO trustWallet avec protocole postMessage prévu
- routers/auth.py : TODO trustWallet au point de vérification signature
- test_middleware : fixture _create_tables (autouse) — ASGITransport ne
  déclenche pas le lifespan, init_db() ne tournait pas → duniter_identities
  introuvable au verify ; 224/224 passent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 00:34:49 +02:00
YvvandClaude Sonnet 4.6 3b339b643c Mandats : refonte wizard nomination-first + types statutory/functional
- Réordonnancement étapes : nomination → infos → résumé (au lieu de infos → nomination → toolbox → résumé)
- Cas nomination renommé : 'election' → 'collective' ; auto-désignation scindée en mode période/réunion
- Types mandat ajoutés : statutory, functional (labels + filtres index)
- Légères corrections texte modalities (ortho, reformulation)
- Fix TS : assertions non-null sur statusGroupMap et paramValues

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 20:33:16 +02:00
YvvandClaude Sonnet 4.6 3ba9c43ce3 Mandat : wizard création 4 étapes + boîte à outils élection
- Mandate model : champ origin (contexte/déclencheur) + migration
- MandateCreate schema : origin, starts_at, ends_at
- mandates/new.vue : wizard complet
  · Étape 1 : type · nom · origine · description · durée (relative ou dates)
  · Étape 2 : auto-désignation (ratification) ou désignation collective
  · Étape 3 : boîte à outils — sans/avec candidature, 6 modalités,
    paramètres configurables (durée, quorum, seuil, slider+input)
  · Étape 4 : récap + génération automatique des étapes du mandat
- Tous les boutons "Nouveau mandat" pointent vers /mandates/new
- decisions/new.vue : "Demander un mandat" → /mandates/new

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 05:29:13 +02:00
YvvandClaude Sonnet 4.6 1f92f153c5 Revert Claude : retour au stub Qwen — interface stable, branchement à venir
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 05:10:49 +02:00
YvvandClaude Sonnet 4.6 9b6322c546 IA : Claude substitut Qwen + auth rate limit prototype
- qualify_ai_service : ai_frame_async() avec Claude Haiku
  · round 1 → questions contextualisées si ANTHROPIC_API_KEY définie
  · round 2 → explication enrichie par Claude
  · fallback transparent sur ai_frame() si pas de clé (tests inchangés)
- config : ANTHROPIC_API_KEY + ANTHROPIC_MODEL (claude-haiku-4-5-20251001)
- requirements : anthropic>=0.97.0
- main : auth rate limit = RATE_LIMIT_DEFAULT partout (prototype mode)
  → supporte accès démo/test sans lockout en prod comme en dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 23:55:10 +02:00
YvvandClaude Sonnet 4.6 9a8f10efdf Groupes d'identités : modèle DB, router, store, UI cercles + Protocoles
- Group + GroupMember : modèle SQLAlchemy + migration + router CRUD
- /api/v1/groups : liste, création, suppression, membres (add/remove)
- groups.ts : store Pinia (fetchAll, getGroup, create, remove, addMember, removeMember)
- decisions/new.vue : cercles 1 & 2 en mode texte libre OU groupe prédéfini
  (affected_count calculé depuis le member_count du groupe)
- protocols/index.vue : section Groupes avec expand/collapse, ajout/suppression membres
- lang="fr" + spellcheck sur tous les textareas ; placeholder cercle 2 corrigé
- n8n channels : prévu sprint futur (texte libre → webhook appel à contribution)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:25:44 +02:00
YvvandClaude Sonnet 4.6 e2ae8b196e Qualify : service IA + endpoint ai-chat + wizard Décider (3 cercles + AI 2-rounds)
- qualify_ai_service.py : stub IA 2-allers-retours (réversibilité + urgence)
- qualify.py router : endpoint POST /ai-chat → AIChatRequest/AIChatResponse
- test_qualifier_ai.py : 11 tests A1-A7 (questions stables, done=True au 2e round)
- decisions/new.vue : wizard 4 étapes — branche mandat (liste + lien demande) /
  hors-mandat (3 cercles textarea), questions IA, résultat + boîte à outils,
  formulaire final

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 19:44:00 +02:00
YvvandClaude Sonnet 4.6 5c51cffc93 Qualifier : corrections R2/R6 + router + modèle DB + wizard frontend
Corrections moteur (TDD) :
- R2 : within_mandate → record_in_observatory=True (Observatoire des décisions)
- R6 : >50 personnes → collective recommandé, pas obligatoire (confidence=recommended)
- R3 supprimée : affected_count=1 hors périmètre de l'outil
- R9-R12 renommés G1-G4 (garde-fous internes)
- 23 tests, 213/213 verts

Étape 1 — Router /api/v1/qualify :
- POST / → qualify() avec config depuis DB ou defaults
- GET /protocol → protocole actif
- POST /protocol → créer/remplacer (auth requise)

Étape 2 — Modèle QualificationProtocol :
- Table qualification_protocols (seuils configurables via admin)
- Migration Alembic + seed du protocole par défaut

Étape 3 — Wizard frontend decisions/new.vue :
- Étape 1 : formulaire de qualification (mandat, affected_count, structurant, contexte)
- Étape 2 : résultat (type, raisons, modalités, observatoire, on-chain)
- Étape 3 : formulaire de décision (titre, description, protocole si collectif)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 19:12:01 +02:00
YvvandClaude Sonnet 4.6 428299c9c8 TDD Qualifier : moteur de qualification des décisions (R1–R12)
- engine/qualifier.py : QualificationConfig (seuils configurables),
  QualificationInput, QualificationResult, DecisionType enum, qualify()
- Règles : within_mandate→consultation_avis, affected_count→routing,
  is_structural→recommend_onchain, seuils lus depuis config
- Stub suggest_modalities_from_context() — interface LLM définie,
  intégration Qwen3.6 (MacStudio) à venir
- 22 tests, 212/212 verts, zéro régression

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 16:16:35 +02:00
YvvandClaude Sonnet 4.6 fc84600f97 Fix auth : CORS outermost + dev rate limit + filtre workspace réactif
- Middleware : CORSMiddleware ajouté en dernier = plus externe = tous les
  codes de retour (dont 429) portent Access-Control-Allow-Origin
  → résout "no response / Failed to fetch" sur POST /auth/challenge
- Dev mode : rate_limit_auth = RATE_LIMIT_DEFAULT (60/min) au lieu de 10/min
  → plus de blocage login après quelques reconnexions
- app.vue : watcher activeSlug → refetch documents/décisions/protocoles/mandats
  → le sélecteur de workspace filtre désormais le contenu en temps réel
- TDD : 4 tests middleware (RED→GREEN) + doc méthode docs/dev/tdd-methode.md
- Régression : 190/190 tests verts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 15:51:28 +02:00
YvvandClaude Sonnet 4.6 79e468b40f Multi-tenancy : espaces de travail + fix auth reload (rate limiter OPTIONS)
- Modèles Organization + OrgMember, migration Alembic (SQLite compatible)
- organization_id nullable sur Document, Decision, Mandate, VotingProtocol
- Service, schéma, router /organizations + dependency get_active_org_id
- Seed : Duniter G1 + Axiom Team ; tout le contenu seed attaché à Duniter G1
- Backend : list/create filtrés par header X-Organization
- Frontend : store organizations, WorkspaceSelector réel, useApi injecte l'org
- Fix critique : rate_limiter exclut les requêtes OPTIONS (CORS preflight)
  → résout le bug "Failed to fetch /auth/me" au reload (429 sur preflight)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 15:17:14 +02:00
YvvandClaude Sonnet 4.6 224e5b0f5e Auth Duniter v2 : vérification réelle + extension signing + titres outils
Backend :
- Vérification Sr25519/Ed25519 réelle via substrateinterface (bypass démo)
- Message signé : <Bytes>{challenge}</Bytes> (convention polkadot.js)
- DEV_PROFILES : Charlie → Référent structure, Dave → Auteur (WoT member)

Frontend :
- Signing via extension polkadot.js / Cesium2 (_signWithExtension)
- @polkadot/extension-dapp + @polkadot/util installés
- Vite : global=globalThis + optimizeDeps pour les packages polkadot
- Boîte à outils : titres complets des 4 sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 03:36:51 +01:00
YvvandClaude Sonnet 4.6 02629c4e68 CI seed : corrige nom container (sans suffixe -1)
ehv-decision-main-backend, pas ehv-decision-main-backend-1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 02:58:22 +01:00
YvvandClaude Sonnet 4.6 b63c6f95e1 Home : titres complets des blocs + burger mobile-only + seed CI
- Titres cards : "Décisions et consultation d'avis", "Documents de référence",
  "Mandats et nominations", "Protocoles et fonctionnement"
- Burger supprimé sur desktop (≥768px), conservé mobile uniquement
- CI : step seed (docker exec python seed.py) entre test-deploy et healthcheck

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 02:45:38 +01:00
YvvandClaude Sonnet 4.6 075c3c944c CI : suppression .woodpecker/seed.yml — restaure les triggers push
Le fichier séparé cassait Woodpecker 3.x (multi-file + when:manual
empêchait tous les triggers push depuis commit 1842303c).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 02:21:40 +01:00
Yvv 8c2e8d5d5b ci: webhook repaired — retrigger 2026-03-24 02:14:59 +01:00
Yvv b9d2fd1c6d ci: retrigger pipeline home fix 2026-03-24 02:11:56 +01:00
YvvandClaude Sonnet 4.6 99c5e9dd5a Home : renommage libreDecision + chiffres dynamiques + centrage + typo
- Titre : ğ(Decision) → libreDecision (libre italic/muted + Decision accent)
- Chiffres : plus de valeurs hardcodées, tout dynamique depuis les stores
  (mandates store ajouté, protocoles depuis protocols.protocols.length)
- Affichage intelligent : total quand actif = 0, labels contextuels
- Grilles centrées : margin: 0 auto sur .dash
- Font-weight 800 → 700 partout (moins gras, plus classe)
- Typo : "Nuance" → "Nuancé" dans les tags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 02:06:02 +01:00
YvvandClaude Sonnet 4.6 0310dcba42 ci: pipeline seed séparé (.woodpecker/seed.yml, event: manual)
Pipeline dédié au seeding DB, déclenché uniquement manuellement.
Retire le step seed du pipeline principal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:57:37 +01:00
YvvandClaude Sonnet 4.6 1842303c3b ci: step seed manuel (event: manual)
Déclenche python seed.py dans le container backend via docker exec.
Uniquement sur event: manual — ne s'exécute pas sur les push normaux.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:56:14 +01:00
YvvandClaude Sonnet 4.6 798b3a83d4 Domaine : libredecision.org → decision.librodrome.org partout
- docker-compose.yml : labels Traefik + CORS_ORIGINS + NUXT_PUBLIC_API_BASE
- docs/deployment.md : toutes les URLs et exemples curl
- CLAUDE.md : référence domaine
- .env.example : DOMAIN + noms DB/USER glibredecision → libredecision
- package-lock.json : name glibredecision → libredecision

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:42:33 +01:00
YvvandClaude Sonnet 4.6 a8dc0dfb40 Typo Plus Jakarta Sans + renommage libreDecision + mode démo prod + seed mandats
- Fonte : Nunito → Plus Jakarta Sans (moderne, ronde sans être toy)
- Logo : ğ(Decision) → libreDecision (libre italic/muted + Decision bold)
- Footer et watermark DocumentPreview mis à jour
- Mode démo : DEMO_MODE flag dans config.py + auth.py (profils rapides en prod)
- docker-compose : ENVIRONMENT=production explicite + DEMO_MODE=true par défaut
- Seed : +décision Licence G1 v0.4.0, +3 mandats (ComTech, Admin Forgerons, Modération)
  runner 7→10 étapes, import Mandate/MandateStep ajouté

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:42:22 +01:00
YvvandClaude Sonnet 4.6 feaf4de7e0 Menu : Boîte à outils en premier + sidebar collapsible icônes
- Boîte à outils (/tools) en 1ère position dans la nav (desktop + mobile)
- Sidebar desktop repliable (toggle pictos-only, 14rem → 3.75rem)
  - labels masqués par transition CSS fluide (max-width + opacity)
  - état persisté en localStorage (libred-sidebar-collapsed)
- Page document : toggle Vue structurée / Aperçu document
  - sous-toggle En vigueur / Selon les votes
  - composant DocumentPreview (rendu PDF-like)
    - filigrane discret, items ordonnés par sort_order
    - mode projection : proposed_text substitu + encadrement orange
    - footer horodaté, print-friendly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:41:37 +01:00
syoulandClaude Sonnet 4.6 73c5bf148c ci: remplace Traefik par Fabio/Consul (pattern sonic)
- docker-compose.yml : labels SERVICE_* Registrator, réseau sonic external,
  container_name explicite, name COMPOSE_PROJECT_NAME
- pipeline : APP_DOMAIN (au lieu de DOMAIN), ACME sonic-acme-1 pour TLS,
  test-deploy sans suffixe -1 (container_name fixe)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 15:25:06 +01:00
syoulandClaude Sonnet 4.6 6509137892 fix: sync package-lock.json avec package.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 15:02:47 +01:00
syoulandClaude Sonnet 4.6 488114791c ci: build local sans registry, pattern sejeteralo
- Suppression write-docker-creds et secrets docker_registry/username/password
- build-backend/frontend : docker build local sur sonic (docker.sock)
- sbom-generate : scan des images locales via docker.sock
- docker-compose.yml : ajout image: libredecision-{backend,frontend}:latest
- deploy : suppression docker compose pull (images locales)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:59:42 +01:00
syoulandClaude Sonnet 4.6 3e702fdbf3 ci: remplace plugin-docker-buildx par docker:27-cli + socket
Evite le mode privileged (non supporté par YunoHost Woodpecker).
Pattern: write-docker-creds (from_secret) → docker-backend/frontend (volumes).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:41:32 +01:00
syoul e24c2a65a0 launch pipeline 2026-03-23 14:32:04 +01:00
syoulandClaude Sonnet 4.6 53fc9927ef ci: refonte pipeline selon bonnes pratiques sonic
- Format when/steps migré vers liste Woodpecker next
- Séparation from_secret / volumes (bug Woodpecker)
- Ajout security-check, SBOM (syft+trivy+dtrack), write-env,
  test-env, test-deploy, healthcheck, notify-failure
- Deploy SSH → volumes Docker (docker.sock + /opt/libredecision)
- privileged: true sur les steps docker-buildx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:25:24 +01:00
syoul a9599ba32a ci: refonte pipeline selon bonnes pratiques sonic 2026-03-23 14:24:16 +01:00
YvvandClaude Sonnet 4.6 ed9ed11cd4 Toolbox 30rem sticky + accordéons collapsibles + renommage libreDecision
- Boîte à outils élargie à 30rem (×1.75) — flottante sticky, zéro scroll visible
- ToolboxSection : nouveau composant accordéon générique (chevron, défaut fermé)
- ToolboxVignette : titre cliquable, bullets/actions cachés par défaut
- 4 pages : ContextMapper/SocioElection/WorkflowMilestones/inertie → ToolboxSection
- Suppression doublon SectionLayout (common/) — conflit de nommage résolu
- Renommage complet Glibredecision → libreDecision dans configs/docker/CI
- README.md + CONTRIBUTING.md ajoutés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:48:20 +01:00
YvvandClaude Sonnet 4.6 290548703d Boîtes à outils enrichies : ContextMapper, SocioElection, WorkflowMilestones
- ContextMapper : 4 questions contexte → méthode de décision optimale
  (advice process Laloux, vote inertiel WoT, consentement sociocratique, Smith…)
- SocioElection : guide élection sociocratique 6 étapes + advice process + clarté de rôle
- WorkflowMilestones : 11 jalons de protocole (7 essentiels), durées recommandées, principes Ostrom
- WorkspaceSelector : sélecteur de collectif multi-site dans le header
- SectionLayout : toolbox en USlideover droit sur mobile, sidebar sticky desktop
- Décisions : ContextMapper intégré + guide consentement
- Mandats : SocioElection intégré + cycle de mandat
- Documents : guide inertie 4 niveaux + structure + IPFS
- Protocoles : WorkflowMilestones + protocole élection sociocratique ajouté
- Renommage projet Glibredecision → libreDecision (dossier + sources)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:13:08 +01:00
256 changed files with 50969 additions and 15810 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
# PostgreSQL
POSTGRES_DB=glibredecision
POSTGRES_USER=glibredecision
POSTGRES_DB=libredecision
POSTGRES_USER=libredecision
POSTGRES_PASSWORD=change-me-in-production
DATABASE_URL=postgresql+asyncpg://glibredecision:change-me-in-production@localhost:5432/glibredecision
DATABASE_URL=postgresql+asyncpg://libredecision:change-me-in-production@localhost:5432/libredecision
# Backend
SECRET_KEY=change-me-in-production-with-a-real-secret-key
@@ -46,4 +46,4 @@ IPFS_TIMEOUT_SECONDS=30
NUXT_PUBLIC_API_BASE=http://localhost:8002/api/v1
# Docker / Production
DOMAIN=glibredecision.org
DOMAIN=decision.librodrome.org
+2
View File
@@ -25,6 +25,7 @@ Thumbs.db
# Database
*.db
*.db.bak*
*.sqlite
# Docker volumes
@@ -40,3 +41,4 @@ htmlcov/
# IPFS
ipfs-data/
docs/*.pdf
+232 -53
View File
@@ -1,75 +1,254 @@
when:
branch: main
event: push
- branch: main
event: push
steps:
test-backend:
- name: security-check
image: alpine:3.20
commands:
- |
if [ -f .env ]; then
echo "ERREUR: .env ne doit pas etre commite dans le depot"
exit 1
fi
- 'grep -q "^\.env$" .gitignore || (echo "ERREUR: .env manquant dans .gitignore" && exit 1)'
- echo "Security check OK"
- name: test-backend
image: python:3.11-slim
commands:
- cd backend
- pip install --no-cache-dir -r requirements.txt
- pytest app/tests/ -v --tb=short
test-frontend:
- name: test-frontend
image: node:20-slim
commands:
# git requis : dépendance @yvv/nuxt-base en git+https (node:20-slim n'a pas git)
- apt-get update && apt-get install -y --no-install-recommends git ca-certificates
- cd frontend
- npm ci
- npm run build
docker-backend:
image: woodpeckerci/plugin-docker-buildx
# NOTE: volumes + pas de from_secret : compatible
- name: build-backend
image: docker:27-cli
depends_on:
- test-backend
settings:
repo: ${CI_FORGE_URL}/${CI_REPO}
dockerfile: docker/backend.Dockerfile
context: .
tag:
- latest
- ${CI_COMMIT_SHA:0:8}
target: production
registry:
from_secret: docker_registry
username:
from_secret: docker_username
password:
from_secret: docker_password
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- docker build -t libredecision-backend:latest -f docker/backend.Dockerfile --target production .
- echo "Image backend construite"
docker-frontend:
image: woodpeckerci/plugin-docker-buildx
# NOTE: volumes + pas de from_secret : compatible
- name: build-frontend
image: docker:27-cli
depends_on:
- test-frontend
settings:
repo: ${CI_FORGE_URL}/${CI_REPO}
dockerfile: docker/frontend.Dockerfile
context: .
tag:
- latest
- ${CI_COMMIT_SHA:0:8}
target: production
registry:
from_secret: docker_registry
username:
from_secret: docker_username
password:
from_secret: docker_password
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- docker build -t libredecision-frontend:latest -f docker/frontend.Dockerfile --target production .
- echo "Image frontend construite"
deploy:
image: appleboy/drone-ssh
# NOTE: volumes + pas de from_secret : compatible
- name: sbom-generate
image: alpine:3.20
depends_on:
- docker-backend
- docker-frontend
settings:
host:
from_secret: deploy_host
username:
from_secret: deploy_username
key:
from_secret: deploy_key
port: 22
script:
- cd /opt/glibredecision
- docker compose -f docker/docker-compose.yml pull
- docker compose -f docker/docker-compose.yml up -d --remove-orphans
- docker image prune -f
- build-backend
- build-frontend
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- apk add --no-cache curl
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
- mkdir -p .reports
# spec 1.5 épinglée : syft « latest » émet plus récent que ce que le trivy du runner décode
- syft libredecision-backend:latest -o cyclonedx-json@1.5 --file .reports/sbom-backend.cyclonedx.json
- syft libredecision-frontend:latest -o cyclonedx-json@1.5 --file .reports/sbom-frontend.cyclonedx.json
- echo "SBOM generes"
# NOTE: volumes + pas de from_secret : compatible
- name: sbom-scan
image: aquasec/trivy:latest
depends_on:
- sbom-generate
volumes:
- /home/syoul/trivy-cache:/root/.cache/trivy
commands:
- trivy sbom --format json --output .reports/trivy-backend.json .reports/sbom-backend.cyclonedx.json
- trivy sbom --format json --output .reports/trivy-frontend.json .reports/sbom-frontend.cyclonedx.json
- echo "Scan CVE termine"
# NOTE: from_secret + pas de volumes : compatible
# failure: ignore — la publication vers Dependency-Track est de la télémétrie :
# elle ne doit pas bloquer le déploiement quand DTrack est éteint ou le secret
# dtrack_domain absent (le scan trivy, lui, reste bloquant). Vu en panne le 12/08.
- name: sbom-publish
image: alpine/curl:latest
failure: ignore
depends_on:
- sbom-scan
environment:
DTRACK_TOKEN:
from_secret: dependency_track_token
DTRACK_DOMAIN:
from_secret: dtrack_domain
commands:
- |
VERSION=$(date +%Y-%m-%d)-$(echo "$CI_COMMIT_SHA" | cut -c1-8)
for COMPONENT in backend frontend; do
HTTP=$(curl -s -o /tmp/dtrack-resp.txt -w "%{http_code}" -X POST "https://$DTRACK_DOMAIN/api/v1/bom" \
-H "X-Api-Key: $DTRACK_TOKEN" \
-F "autoCreate=true" \
-F "projectName=libredecision-$COMPONENT" \
-F "projectVersion=$VERSION" \
-F "bom=@.reports/sbom-$COMPONENT.cyclonedx.json")
echo "HTTP $HTTP ($COMPONENT) : $(cat /tmp/dtrack-resp.txt)"
[ "$HTTP" -ge 200 ] && [ "$HTTP" -lt 300 ] || exit 1
done
# NOTE: from_secret + pas de volumes : compatible
- name: write-env
image: alpine:3.20
depends_on:
- sbom-publish
environment:
APP_DOMAIN:
from_secret: app_domain
POSTGRES_PASSWORD:
from_secret: postgres_password
SECRET_KEY:
from_secret: secret_key
commands:
- echo "APP_DOMAIN=$APP_DOMAIN" > .env.deploy
- echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> .env.deploy
- echo "SECRET_KEY=$SECRET_KEY" >> .env.deploy
- OWNER=$(echo "$CI_REPO_OWNER" | tr 'A-Z' 'a-z') && REPO=$(echo "$CI_REPO_NAME" | tr 'A-Z' 'a-z') && BRANCH=$(echo "$CI_COMMIT_BRANCH" | tr 'A-Z/' 'a-z-') && echo "COMPOSE_PROJECT_NAME=$OWNER-$REPO-$BRANCH" >> .env.deploy
- echo ".env.deploy cree ($(wc -c < .env.deploy) octets)"
- name: test-env
image: alpine:3.20
depends_on:
- write-env
commands:
- |
[ -f .env.deploy ] || { echo "FAIL: .env.deploy introuvable"; exit 1; }
echo "PASS: .env.deploy present"
- |
VAL=$(grep '^COMPOSE_PROJECT_NAME=' .env.deploy | cut -d= -f2)
[ -z "$VAL" ] && echo "FAIL: COMPOSE_PROJECT_NAME vide" && exit 1
echo "PASS: COMPOSE_PROJECT_NAME = $VAL"
- |
VAL=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
[ -z "$VAL" ] && echo "FAIL: APP_DOMAIN vide" && exit 1
echo "PASS: APP_DOMAIN = $VAL"
# NOTE: volumes + pas de from_secret : compatible
# Fabio/Traefik routing géré via labels du docker-compose.yml
- name: deploy
image: docker:27-cli
depends_on:
- test-env
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision:/opt/libredecision
commands:
- cp .env.deploy /opt/libredecision/.env
- chmod 600 /opt/libredecision/.env
- cp docker/docker-compose.yml /opt/libredecision/docker-compose.yml
# Arreter avant le challenge ACME : libere le webroot pour sonic-acme-1
# L'org forge a ete renommee (EHV -> LPK) : le nom de projet compose a change,
# on arrete donc aussi l'ancienne stack (ehv-decision-main-*) pour eviter la
# double inscription fabio et le trafic partage v1/v2.
- docker ps -q --filter "name=decision-main" | xargs -r docker stop
- cd /opt/libredecision && docker compose stop
- |
DOMAIN=$(grep '^APP_DOMAIN=' /opt/libredecision/.env | cut -d= -f2)
ACME_EXIT=0
docker exec sonic-acme-1 /app/acme.sh \
--home /etc/acme.sh \
--issue -d "$DOMAIN" \
--webroot /usr/share/nginx/html \
--server letsencrypt \
--accountemail support+acme@asycn.io || ACME_EXIT=$?
if [ "$ACME_EXIT" -eq 0 ] || [ "$ACME_EXIT" -eq 2 ]; then
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/fullchain.cer /host/certs/$DOMAIN-cert.pem
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/$DOMAIN.key /host/certs/$DOMAIN-key.pem
echo "TLS OK (acme exit $ACME_EXIT)"
elif docker exec sonic-acme-1 test -f "/host/certs/$DOMAIN-cert.pem"; then
echo "AVERTISSEMENT: emission ACME en echec (exit $ACME_EXIT) - certificat existant conserve"
echo " Cause vue le 12/08 : challenge HTTP-01 invalide en IPv6 (AAAA present mais webroot"
echo " non servi en v6) - corriger frontal/DNS avant l'expiration du certificat."
else
echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT) et aucun certificat existant pour $DOMAIN"
exit 1
fi
# Images construites localement dans la pipeline : pas de docker compose pull
- cd /opt/libredecision && docker compose up -d --remove-orphans
- cd /opt/libredecision && docker compose ps
# Vérification que les containers sont running
- name: test-deploy
image: docker:27-cli
depends_on:
- deploy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision:/opt/libredecision
commands:
- |
PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' /opt/libredecision/.env | cut -d= -f2)
for SVC in backend frontend; do
STATUS=$(docker inspect --format '{{.State.Status}}' "$PROJECT-$SVC" 2>/dev/null || echo "absent")
echo "$PROJECT-$SVC : $STATUS"
[ "$STATUS" = "running" ] || { echo "FAIL: $PROJECT-$SVC non running"; exit 1; }
done
echo "PASS: tous les containers running"
- name: seed
image: docker:27-cli
depends_on:
- test-deploy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision:/opt/libredecision
commands:
- |
PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' /opt/libredecision/.env | cut -d= -f2)
docker exec "$PROJECT-backend" python seed.py
echo "Seed terminée"
- name: healthcheck
image: alpine:3.20
depends_on:
- seed
commands:
- apk add --no-cache --quiet curl
- |
SITE=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
TARGET="https://$SITE"
echo "Healthcheck $TARGET..."
MAX=60
i=0
until [ $i -ge $MAX ]; do
CODE=$(curl -sSo /dev/null -w "%{http_code}" "$TARGET" 2>/dev/null)
echo "Tentative $((i+1))/$MAX - HTTP $CODE"
if [ "$CODE" = "200" ] || [ "$CODE" = "301" ] || [ "$CODE" = "302" ]; then
echo "PASS: app repond sur $TARGET"
exit 0
fi
i=$((i+1))
sleep 10
done
echo "FAIL: app ne repond pas apres 10 minutes"
exit 1
- name: notify-failure
image: alpine:3.20
commands:
- 'echo "ECHEC pipeline #$CI_BUILD_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
when:
- status: failure
+95 -20
View File
@@ -1,25 +1,100 @@
# Glibredecision
# libreDecision
Plateforme de decisions collectives pour la communaute Duniter/G1.
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
v2 local-first : 6 routes de décision, 5 modalités, documents sous vote permanent, mandats,
observatoire. Marque blanche par construction (seeds Ğ1 + Atelier du Canal).
**Référence produit : `docs/dev/BLUEPRINT-V2.md`** — toute décision de comportement s'y adosse.
## Protocole de début de session
1. `git pull --rebase origin main`
2. Si l'objectif de la session n'est pas précisé, le demander
## Stack
- **Frontend**: Nuxt 4 + Nuxt UI v3 + Pinia + UnoCSS (port 3002)
- **Backend**: Python FastAPI + SQLAlchemy async + PostgreSQL asyncpg (port 8002)
- **Auth**: Duniter V2 Ed25519 challenge-response
- **Sanctuaire**: IPFS (kubo) + hash on-chain (system.remark)
## Commands
- Backend: `cd backend && uvicorn app.main:app --port 8002 --reload`
- Backend tests: `cd backend && pytest tests/ -v`
- Frontend: `cd frontend && npm run dev`
- Frontend build: `cd frontend && npm run build`
- Migrations: `cd backend && alembic upgrade head`
- Docker: `docker compose -f docker/docker-compose.yml up`
- **Frontend** : Nuxt 4 (Vue 3, TypeScript, SPA — SSR off) + Nuxt UI v3 + Pinia + UnoCSS ;
package manager : npm ; port **3002** strict
- **Local-first** : IndexedDB (idb-keyval) via `app/data/persistence.ts` — une clé
`ld2:<collectiveId>` par collectif, export/import de bundles JSON, seeds par le même chemin
- **Backend v1** (FastAPI + SQLAlchemy async, port 8002) : conservé pour la **future synchro**
— non requis pour le dev frontend ; `runtimeConfig.public.apiBase` gardé dans nuxt.config
en prévision
- Déploiement : Docker multi-stage + Traefik ; CI Woodpecker
## Conventions
- French for UI text and documentation
- English for code (variable names, comments, docstrings)
- API versioned under `/api/v1/`
- Pydantic v2 for all schemas
- Async everywhere (SQLAlchemy, FastAPI)
- Ed25519 signatures for vote integrity
## Structure
```
frontend/
app/
engine/ # moteurs PURS (zéro dépendance Nuxt) : threshold (+electionResult),
# nuanced, modeParams, parametric (+crystallize), settings, state (canTransition),
# triage, impact — import UNIQUEMENT via le barrel ~/engine
data/
persistence.ts # IndexedDB local-first
templates.ts # 7 gabarits de collectif (invariant : protocole Consentement partout)
seeds/ # duniter-g1.bundle.json (324 Ko) + atelier-du-canal.bundle.json
stores/
collective.ts # le tenant : collectif actif, état complet, cycle de vie des bundles
decisions.ts # façade d'actions sans état propre (LWW updatedAt, persist debounced)
composables/ # useMood (useLibreMood), useFeed (le Fil = sélecteur pur), useSearch
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/
# onboarding/ common/ (primitives Ld*)
pages/ # 14 routes : / · decider · decisions (+[id], vote, observatoire) ·
# textes (+[slug], formules) · mandats (+[id], nouveau) ·
# creer (layout bare) · donnees
lexicon.ts # SOURCE UNIQUE des libellés FR + FORBIDDEN_UI_TERMS
types/domain.ts # le noyau du modèle
tests/ # 342 tests vitest : engine/, stores/, seeds/ (non-perte), lexicon.spec.ts
backend/ # v1 FastAPI conservé pour la synchro future — ne pas développer dessus
scripts/export_seed_bundle.py # extrait seed.py → duniter-g1.bundle.json (source vivante = bundle)
docker/ # compose + Dockerfiles
docs/dev/BLUEPRINT-V2.md # la référence produit v2
public/hexagram-tsing*.svg # sceau 井 (#48 Tsing)
```
## Données runtime
- **postgres-data** / **ipfs-data** : volumes Docker du backend v1 — jamais écrasés
- `.env` racine : secrets backend — jamais commité
## Commandes
```bash
# Frontend (tout le dev v2)
cd frontend && npm run dev # :3002
npm test # vitest — 342 tests, référence à maintenir
npm run build # zéro erreur exigé avant commit
# Seeds : le bundle est la source vivante ; seed.py (backend) est remplacé par
cd backend && .venv/bin/python scripts/export_seed_bundle.py # régénère duniter-g1.bundle.json
# → test de non-perte bloquant : tests/seeds/duniter-g1.spec.ts
# Docker
docker compose -f docker/docker-compose.yml up
```
## Conventions / pièges
- **UI français, code anglais** (variables, commentaires, docstrings)
- **lexicon.ts d'abord** : aucun libellé UI en dur si le lexicon peut le porter ; ton = tutoyer
la personne, sujet grammatical = la personne ou le collectif, jamais la formule ni le système
- **Anti-lexique** (`tests/lexicon.spec.ts`) : `FORBIDDEN_UI_TERMS` scanné sur lexicon +
templates `.vue` (insensible casse/accents) — « triage », « verdict », « déléguer »… restent
des identifiants de code, jamais des textes affichés
- **Invariants doctrinaux** : jamais de score sur une personne (attributs jamais affichés à
autrui, re-votes visibles du seul auteur) ; jamais de contenu adopté sans geste humain
(cristallisation, clôture de dossier, départage = gestes datés) ; repli universel → consent ;
dérogation asymétrique (alourdir libre, alléger motivé)
- **Budget 400 lignes max par page** (`app/pages/`)
- **Moteurs purs** : engine/ sans Nuxt/Pinia/DOM, import via `~/engine` seulement ; stores en
imports explicites (testables sous vitest nu) ; toute mutation d'état passe par
`canTransition(decision, to, ctx)`
- **Formule inertie** : `R = C + B^W + (M + (1M)(1(T/W)^G))·max(0, TC)`
`frontend/app/engine/threshold.ts` ; l'Atelier (`/textes/formules`) est la seule maison des
lettres W/T/M/B/G/C et de KaTeX
- **Mood system** : `useLibreMood()` (`composables/useMood.ts`) — **jamais** de `:global()`
dans `<style scoped>` pour les styles mood-dépendants (causa le bug dark mode veil)
- **Sceau** `井` (#48 Tsing) : `LdSeal` dans les layouts ; SVGs dans `public/`
- **CSS `drop-shadow()`** safe pour l'emboss ; `<filter>` SVG inline cause des artefacts
- **Composants** : `pathPrefix: false` — noms de fichiers uniques, auto-import
- **Domaine** : decision.librodrome.org (Woodpecker CI ; ancien dossier : Glibredecision)
+91
View File
@@ -0,0 +1,91 @@
# Contribuer à libreDecision
La référence produit est `docs/dev/BLUEPRINT-V2.md` — toute contribution qui touche au
comportement doit s'y conformer (ou proposer un amendement du blueprint, argumenté).
## Environnement
```bash
# Frontend — suffit pour tout le développement v2 (local-first, aucun serveur requis)
cd frontend
npm install
npm run dev # http://localhost:3002 — port strict, jamais de fallback
npm test # vitest — 342 tests
npm run build # doit passer sans erreur avant tout commit
# Backend v1 (optionnel — conservé pour la future synchro)
cd backend
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
```
## Conventions
- **UI en français, code en anglais** (variables, commentaires, docstrings).
- **`app/lexicon.ts` est la source unique des libellés français.** Aucun libellé UI en dur
dans un composant s'il existe (ou devrait exister) dans le lexicon. Ton : tutoyer la
personne dans ses gestes ; actes collectifs à l'infinitif ; le sujet grammatical est
toujours la personne ou le collectif — jamais la formule ni le système.
- **Test anti-lexique** (`tests/lexicon.spec.ts`) : la liste `FORBIDDEN_UI_TERMS` interdit
certains mots dans toute chaîne visible (lexicon + templates `.vue`), insensible à la casse
et aux accents. « Triage », « verdict », « déléguer »… peuvent exister comme identifiants de
code, jamais à l'écran.
- **CSS** : borderless (profondeur via background + box-shadow), palettes par classes
`.mood-*` sur `<html>`, composants natifs (`<input>`, `<select>`, `<button>` custom).
- **Composants** : `pathPrefix: false` — noms de fichiers uniques et parlants, auto-import.
## Invariants doctrinaux (non négociables)
- **Jamais de score sur une personne.** Pas de note, pas de réputation, pas d'agrégat
individuel. `Person.attributes` ne sont jamais affichés à autrui ni agrégés ; l'historique
de re-vote n'est visible que de son auteur.
- **Jamais de contenu adopté sans geste humain.** Le moteur constate des seuils sur des
contenus formulés par des humains ; la cristallisation paramétrique, la clôture d'un
dossier découpé, le départage d'une égalité sont des gestes datés — jamais des automatismes.
- **Le repli universel est le consentement** : tout collectif a un protocole Consentement
(`protocolByRange.consent`), tout repli de résolution y mène.
- **Dérogation asymétrique** : alourdir libre (1 tap journalisé) ; alléger = note obligatoire
+ fenêtre d'objection.
- **Budget 400 lignes maximum par page** (`app/pages/`). Au-delà, extraire des composants.
- **Moteurs purs** : `app/engine/` ne dépend de rien (ni Nuxt, ni Pinia, ni DOM), s'importe
uniquement via le barrel `~/engine`, et chaque module a ses tests vitest. Les stores
s'écrivent avec des imports explicites (pas d'auto-import Nuxt) pour rester testables sous
vitest pur.
## Ajouter une modalité de vote
1. `app/types/domain.ts` — étendre `VoteMethod` et les types de session/vote nécessaires.
2. `app/engine/` — le calcul dans un module pur + ses tests (`tests/engine/`). Exporter via
`engine/index.ts`.
3. `app/engine/state.ts` — la règle de clôture (automatique à `closesAt`, ou geste humain).
4. `app/lexicon.ts` — les libellés (et vérifier l'anti-lexique).
5. `app/components/votes/VoteX.vue` — l'écran de la modalité, branché dans
`pages/decisions/[id]/vote.vue`.
6. Seeds et gabarits — un protocole nommé qui la référence (le modifier = amender le Pacte).
## Ajouter un gabarit de collectif
Dans `app/data/templates.ts` : une entrée `TEMPLATE_CARDS` + le montage dans
`buildTemplateBundle()`. Invariants : protocole Consentement au minimum, clause A1
(« Notre finalité »), préambule Autonomie–ÉquilibreLiaison proposé et modifiable, cercles
typés `kind`. Un bundle seed passe par le même `importBundle()` que l'import utilisateur.
## Tests
```bash
cd frontend && npm test
```
- `tests/engine/` — les moteurs purs (seuils, triage, états, médiane, impact…).
- `tests/stores/` — la façade decisions sous Pinia nu.
- `tests/seeds/` — tests de non-perte : le bundle Ğ1 doit conserver 33+59 clauses, décomptes
et provenance exacts ; l'Atelier du Canal doit couvrir tous les états.
- `tests/lexicon.spec.ts` — l'anti-lexique.
Le compteur de référence est **342 tests verts**. Toute PR qui en casse un est refusée ;
toute fonctionnalité de moteur arrive avec les siens.
## Build
`npm run build` doit rester vert (zéro erreur, zéro warning nouveau) avant chaque commit.
Vérifier mobile + desktop (zéro régression CSS).
+113
View File
@@ -0,0 +1,113 @@
# libreDecision
Boîte à outils de la première démocratie — décider ensemble, du trio à la population.
libreDecision fait de la décision un geste quotidien : chaque question tient en une phrase,
l'outil propose le chemin le plus court — jamais plus lourd que l'enjeu — et tout ce qui est
décidé reste révisable, mesuré, consigné. Le même outil sert un trio d'amis, une association,
une coopérative ou la toile de confiance Ğ1 (marque blanche par construction).
## Manifeste
> Personne n'a jamais vécu en démocratie — alors nous la rêvons de jour, et nous la commençons
> à notre échelle. libreDecision attrape chaque décision en une phrase et propose le chemin le
> plus court : décide seul et trace-le, décide sous mandat, écoute puis décide, ou décidez
> ensemble — jamais plus lourd que l'enjeu. Ici on ne délègue pas, on mandate, et on reprend ;
> on n'est pas consulté, on s'instruit, puis on se prononce ; on ne s'affronte pas en deux
> camps, on règle des curseurs — et la position de chacun reste visible dans le faisceau, la
> médiane n'écrase personne, la dernière main est toujours humaine. Les concernés en premier
> lieu ont voix par construction, chacun voit pourquoi ; lever la main « ça me concerne » est
> un droit, pas une demande. Chaque décision dit ce qu'elle engage, ce qu'elle cherche à
> changer, et repasse l'épreuve du réel : rien n'est définitif, toute décision en vigueur garde
> sa poignée, se tromper est une donnée qu'on consigne. Les règles du jeu elles-mêmes — le
> Pacte — s'écrivent au fil de l'eau, par ceux qu'elles gouvernent, sous les boussoles
> Autonomie, Équilibre, Liaison. Du trio d'amis à la toile Ğ1 : le même geste, dix secondes,
> et l'observatoire qui apprend de nous. La ville change, le puits demeure.
## Les 6 routes de décision
Le triage (moteur pur, toujours expliqué en une phrase, toujours contournable) oriente chaque
décision vers l'une de six routes :
| Route | Geste |
|---|---|
| **solo** | « Personne d'autre n'est concerné — décide. » 2 gestes, moins de 10 secondes, trace facultative |
| **record** | « Déjà tranché — je le consigne. » L'observatoire en un geste, adopté immédiatement |
| **mandate** | « Ton mandat couvre — décide, c'est tracé. » Trace obligatoire + fenêtre d'objection 48 h |
| **transmit** | « Le mandat d'un autre couvre — transmets-lui. » (ou conteste le mandat) |
| **advice** | « Réversible et petit cercle — demande leur avis, puis décide. » 72 h |
| **collective** | « Nous décidons. » Formulation, liste des concernés arrêtée, session de vote |
Dérogation asymétrique : alourdir est libre (1 tap journalisé) ; alléger exige une note motivée
et une fenêtre d'objection.
## Les 5 modalités
Chaque protocole est nommé et référencé par une clause du Pacte — le modifier, c'est amender
le Pacte.
1. **Consentement** — « Ça me va / J'objecte » : zéro objection maintenue = adopté. Le repli
universel : tout collectif en a un (invariant).
2. **Nuancé 6 niveaux** — de « Pas du tout » à « Tout à fait », jamais « contre » ;
histogramme visible, commentaire obligatoire sur les niveaux bas.
3. **Paramétrique** — décider au curseur : faisceau des positions, médiane vivante, carte
« Pour moi » ; la cristallisation finale est un geste humain daté du garant, jamais un
automatisme.
4. **Élection** — réservée aux mandats et aux problématiques d'échelle (6 méthodes de
nomination, vote blanc, jamais de départage par l'outil).
5. **Binaire inertiel** — le seuil `C + B^W + (M + (1M)(1(T/W)^G))·max(0, TC)`, héritage
Ğ1 remis à sa place : l'outil de dernier recours des très grands corps. L'Atelier des
formules (`/textes/formules`) reste son école.
## Local-first
- **Aucun serveur requis** : tout l'état vit dans le navigateur (IndexedDB via idb-keyval,
une clé par collectif).
- **Export / import** de bundles JSON — la fédération, c'est l'échange de bundles, jamais une
table.
- **Seeds** livrés et importés par le même chemin que l'import utilisateur :
- `duniter-g1.bundle.json` — la toile Ğ1 : 33 clauses Certification + 59 clauses Forgeron
(textes votés intacts, provenance et décomptes conservés), protocoles, 3 mandats ;
- `atelier-du-canal.bundle.json` — association fictive de 12 membres, le test permanent de
marque blanche : décisions à tous les états, paramétrique vivant, épreuve du réel due.
- **7 gabarits** de collectif à la création (`/creer`) : page blanche, informel, association,
coopérative, commune, monnaie libre, institution symétrique.
## Stack
- **Frontend** : Nuxt 4 (SPA, SSR off) + Vue 3 + TypeScript + Nuxt UI v3 + Pinia + UnoCSS ;
npm ; port **3002**
- **Moteurs purs** : `frontend/app/engine/` — triage, machine à états, seuils, médiane
paramétrique, nuancé, périmètres — zéro dépendance Nuxt, testés vitest (**342 tests**)
- **Backend v1** (FastAPI + SQLAlchemy async) : conservé dans `backend/` pour la future
synchronisation multi-appareils — non requis pour utiliser ou développer le frontend
## Démarrage
```bash
cd frontend
npm install
npm run dev # http://localhost:3002
npm test # vitest — 342 tests
npm run build # build de production
```
## Structure v2
```
frontend/app/
engine/ # moteurs purs (barrel index.ts — seul point d'import)
data/ # persistence.ts (IndexedDB), templates.ts (7 gabarits), seeds/*.bundle.json
stores/ # collective.ts (le tenant) + decisions.ts (façade d'actions)
composables/ # useMood, useFeed, useSearch
components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/ onboarding/ common/
pages/ # 14 routes : / decider decisions(+id, vote, observatoire)
# textes(+slug, formules) mandats(+id, nouveau) creer donnees
lexicon.ts # source unique des libellés FR + test anti-lexique
types/domain.ts # le noyau du modèle
docs/dev/BLUEPRINT-V2.md # la référence produit
```
## Licence
Logiciel libre. Le fichier LICENSE sera posé avant la première publication.
+4 -1
View File
@@ -1,4 +1,4 @@
"""Alembic async environment for Glibredecision.
"""Alembic async environment for libreDecision.
Uses asyncpg via SQLAlchemy's async engine.
All models are imported so that Base.metadata is fully populated
@@ -26,6 +26,8 @@ from app.database import Base
from app.models import ( # noqa: F401
DuniterIdentity,
Session,
Organization,
OrgMember,
Document,
DocumentItem,
ItemVersion,
@@ -37,6 +39,7 @@ from app.models import ( # noqa: F401
MandateStep,
VotingProtocol,
FormulaConfig,
QualificationProtocol,
SanctuaryEntry,
BlockchainCache,
)
@@ -0,0 +1,301 @@
"""initial schema
Revision ID: 0b9c1d2e3f4a
Revises:
Create Date: 2026-04-23 00:00:00.000000+00:00
Idempotent: uses CREATE TABLE IF NOT EXISTS so it is safe to run
against a DB that was already bootstrapped via SQLAlchemy create_all().
"""
from typing import Sequence, Union
from alembic import op
revision: str = '0b9c1d2e3f4a'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS duniter_identities (
id UUID PRIMARY KEY,
address VARCHAR(64) NOT NULL,
display_name VARCHAR(128),
wot_status VARCHAR(32) NOT NULL DEFAULT 'unknown',
is_smith BOOLEAN NOT NULL DEFAULT FALSE,
is_techcomm BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_duniter_identities_address ON duniter_identities (address)")
op.execute("""
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY,
token_hash VARCHAR(128) NOT NULL,
identity_id UUID NOT NULL REFERENCES duniter_identities(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
)
""")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_sessions_token_hash ON sessions (token_hash)")
op.execute("""
CREATE TABLE IF NOT EXISTS organizations (
id UUID PRIMARY KEY,
name VARCHAR(128) NOT NULL,
slug VARCHAR(128) NOT NULL,
org_type VARCHAR(64) NOT NULL DEFAULT 'community',
is_transparent BOOLEAN NOT NULL DEFAULT FALSE,
color VARCHAR(32),
icon VARCHAR(64),
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_organizations_slug ON organizations (slug)")
op.execute("""
CREATE TABLE IF NOT EXISTS org_members (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organizations(id),
identity_id UUID NOT NULL REFERENCES duniter_identities(id),
role VARCHAR(32) NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS formula_configs (
id UUID PRIMARY KEY,
name VARCHAR(128) NOT NULL,
description TEXT,
duration_days INTEGER NOT NULL DEFAULT 30,
majority_pct INTEGER NOT NULL DEFAULT 50,
base_exponent FLOAT NOT NULL DEFAULT 0.1,
gradient_exponent FLOAT NOT NULL DEFAULT 0.2,
constant_base FLOAT NOT NULL DEFAULT 0.0,
smith_exponent FLOAT,
techcomm_exponent FLOAT,
nuanced_min_participants INTEGER,
nuanced_threshold_pct INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS voting_protocols (
id UUID PRIMARY KEY,
name VARCHAR(128) NOT NULL,
description TEXT,
vote_type VARCHAR(32) NOT NULL,
formula_config_id UUID NOT NULL REFERENCES formula_configs(id),
mode_params VARCHAR(64),
is_meta_governed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS sanctuary_entries (
id UUID PRIMARY KEY,
entry_type VARCHAR(64) NOT NULL,
reference_id UUID NOT NULL,
title VARCHAR(256),
content_hash VARCHAR(128) NOT NULL,
ipfs_cid VARCHAR(128),
chain_tx_hash VARCHAR(128),
chain_block INTEGER,
metadata_json TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS blockchain_cache (
id UUID PRIMARY KEY,
cache_key VARCHAR(256) NOT NULL,
cache_value JSON NOT NULL,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
)
""")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_blockchain_cache_cache_key ON blockchain_cache (cache_key)")
op.execute("""
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY,
slug VARCHAR(128) NOT NULL,
title VARCHAR(256) NOT NULL,
doc_type VARCHAR(64) NOT NULL,
version VARCHAR(32) NOT NULL DEFAULT '0.1.0',
status VARCHAR(32) NOT NULL DEFAULT 'draft',
description TEXT,
ipfs_cid VARCHAR(128),
chain_anchor VARCHAR(128),
genesis_json TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_documents_slug ON documents (slug)")
op.execute("""
CREATE TABLE IF NOT EXISTS decisions (
id UUID PRIMARY KEY,
title VARCHAR(256) NOT NULL,
description TEXT,
context TEXT,
decision_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'draft',
voting_protocol_id UUID REFERENCES voting_protocols(id),
created_by_id UUID REFERENCES duniter_identities(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS item_versions (
id UUID PRIMARY KEY,
item_id UUID NOT NULL,
proposed_text TEXT NOT NULL,
diff_text TEXT,
rationale TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'proposed',
decision_id UUID REFERENCES decisions(id),
proposed_by_id UUID REFERENCES duniter_identities(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS vote_sessions (
id UUID PRIMARY KEY,
decision_id UUID REFERENCES decisions(id),
item_version_id UUID REFERENCES item_versions(id),
voting_protocol_id UUID NOT NULL REFERENCES voting_protocols(id),
wot_size INTEGER NOT NULL DEFAULT 0,
smith_size INTEGER NOT NULL DEFAULT 0,
techcomm_size INTEGER NOT NULL DEFAULT 0,
starts_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ends_at TIMESTAMPTZ NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'open',
votes_for INTEGER NOT NULL DEFAULT 0,
votes_against INTEGER NOT NULL DEFAULT 0,
votes_total INTEGER NOT NULL DEFAULT 0,
smith_votes_for INTEGER NOT NULL DEFAULT 0,
techcomm_votes_for INTEGER NOT NULL DEFAULT 0,
threshold_required FLOAT NOT NULL DEFAULT 0.0,
result VARCHAR(32),
chain_recorded BOOLEAN NOT NULL DEFAULT FALSE,
chain_tx_hash VARCHAR(128),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS decision_steps (
id UUID PRIMARY KEY,
decision_id UUID NOT NULL REFERENCES decisions(id),
step_order INTEGER NOT NULL,
step_type VARCHAR(32) NOT NULL,
title VARCHAR(256),
description TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
vote_session_id UUID REFERENCES vote_sessions(id),
outcome TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS document_items (
id UUID PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id),
position VARCHAR(16) NOT NULL,
item_type VARCHAR(32) NOT NULL DEFAULT 'clause',
title VARCHAR(256),
current_text TEXT NOT NULL,
voting_protocol_id UUID REFERENCES voting_protocols(id),
sort_order INTEGER NOT NULL DEFAULT 0,
section_tag VARCHAR(64),
inertia_preset VARCHAR(16) NOT NULL DEFAULT 'standard',
is_permanent_vote BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'item_versions_item_id_fkey'
AND table_name = 'item_versions'
) THEN
ALTER TABLE item_versions
ADD CONSTRAINT item_versions_item_id_fkey
FOREIGN KEY (item_id) REFERENCES document_items(id);
END IF;
END $$
""")
op.execute("""
CREATE TABLE IF NOT EXISTS votes (
id UUID PRIMARY KEY,
session_id UUID NOT NULL REFERENCES vote_sessions(id),
voter_id UUID NOT NULL REFERENCES duniter_identities(id),
vote_value VARCHAR(32) NOT NULL,
nuanced_level INTEGER,
comment TEXT,
signature TEXT NOT NULL,
signed_payload TEXT NOT NULL,
voter_wot_status VARCHAR(32) NOT NULL DEFAULT 'member',
voter_is_smith BOOLEAN NOT NULL DEFAULT FALSE,
voter_is_techcomm BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS mandates (
id UUID PRIMARY KEY,
title VARCHAR(256) NOT NULL,
description TEXT,
mandate_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'draft',
mandatee_id UUID REFERENCES duniter_identities(id),
decision_id UUID REFERENCES decisions(id),
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("""
CREATE TABLE IF NOT EXISTS mandate_steps (
id UUID PRIMARY KEY,
mandate_id UUID NOT NULL REFERENCES mandates(id),
step_order INTEGER NOT NULL,
step_type VARCHAR(32) NOT NULL,
title VARCHAR(256),
description TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
vote_session_id UUID REFERENCES vote_sessions(id),
outcome TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
def downgrade() -> None:
# Intentionally left empty — dropping the initial schema would destroy all data.
pass
@@ -0,0 +1,40 @@
"""add organizations
Revision ID: 70914b334cfb
Revises:
Create Date: 2026-04-23 12:27:56.220214+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '70914b334cfb'
down_revision: Union[str, None] = '0b9c1d2e3f4a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ADD COLUMN IF NOT EXISTS — idempotent (safe on DBs bootstrapped via create_all)
op.execute("ALTER TABLE decisions ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_decisions_organization_id ON decisions (organization_id)")
op.execute("ALTER TABLE documents ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_documents_organization_id ON documents (organization_id)")
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_mandates_organization_id ON mandates (organization_id)")
op.execute("ALTER TABLE voting_protocols ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_voting_protocols_organization_id ON voting_protocols (organization_id)")
def downgrade() -> None:
op.drop_index(op.f('ix_voting_protocols_organization_id'), table_name='voting_protocols')
op.drop_column('voting_protocols', 'organization_id')
op.drop_index(op.f('ix_mandates_organization_id'), table_name='mandates')
op.drop_column('mandates', 'organization_id')
op.drop_index(op.f('ix_documents_organization_id'), table_name='documents')
op.drop_column('documents', 'organization_id')
op.drop_index(op.f('ix_decisions_organization_id'), table_name='decisions')
op.drop_column('decisions', 'organization_id')
@@ -0,0 +1,34 @@
"""add_qualification_protocol
Revision ID: b78571ae9e00
Revises: 70914b334cfb
Create Date: 2026-04-23 17:08:07.161306+00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = 'b78571ae9e00'
down_revision: Union[str, None] = '70914b334cfb'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS qualification_protocols (
id UUID PRIMARY KEY,
name VARCHAR(128) NOT NULL,
description TEXT,
small_group_max INTEGER NOT NULL DEFAULT 5,
collective_wot_min INTEGER NOT NULL DEFAULT 50,
default_modalities_json TEXT NOT NULL DEFAULT '["vote_wot","vote_smith","consultation_avis","election"]',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT now()
)
""")
def downgrade() -> None:
op.drop_table('qualification_protocols')
@@ -0,0 +1,48 @@
"""Add groups and group_members tables.
Revision ID: c4e812fb3a01
Revises: b78571ae9e00
Create Date: 2026-04-23 19:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "c4e812fb3a01"
down_revision = "b78571ae9e00"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS groups (
id UUID PRIMARY KEY,
name VARCHAR(128) NOT NULL,
description TEXT,
organization_id UUID REFERENCES organizations(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_groups_organization_id ON groups (organization_id)")
op.execute("""
CREATE TABLE IF NOT EXISTS group_members (
id UUID PRIMARY KEY,
group_id UUID NOT NULL REFERENCES groups(id),
identity_id UUID REFERENCES duniter_identities(id),
display_name VARCHAR(128) NOT NULL,
added_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_group_members_group_id ON group_members (group_id)")
def downgrade() -> None:
op.drop_index("ix_group_members_group_id", table_name="group_members")
op.drop_table("group_members")
op.drop_index("ix_groups_organization_id", table_name="groups")
op.drop_table("groups")
@@ -0,0 +1,24 @@
"""Add origin column to mandates table.
Revision ID: d91a3c7f8b02
Revises: c4e812fb3a01
Create Date: 2026-04-24 10:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "d91a3c7f8b02"
down_revision = "c4e812fb3a01"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin TEXT")
def downgrade() -> None:
op.drop_column("mandates", "origin")
@@ -0,0 +1,27 @@
"""Mandate origin: replace free-text with FK to duniter_identities.
Revision ID: e3f4a5b6c7d8
Revises: d91a3c7f8b02
Create Date: 2026-04-25 10:00:00.000000
"""
from __future__ import annotations
from alembic import op
revision = "e3f4a5b6c7d8"
down_revision = "d91a3c7f8b02"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE mandates DROP COLUMN IF EXISTS origin")
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin_id UUID REFERENCES duniter_identities(id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_mandates_origin_id ON mandates (origin_id)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_mandates_origin_id")
op.execute("ALTER TABLE mandates DROP COLUMN IF EXISTS origin_id")
op.execute("ALTER TABLE mandates ADD COLUMN IF NOT EXISTS origin TEXT")
+6 -2
View File
@@ -3,15 +3,16 @@ from pathlib import Path
class Settings(BaseSettings):
APP_NAME: str = "Glibredecision"
APP_NAME: str = "libreDecision"
DEBUG: bool = True
# Environment
ENVIRONMENT: str = "development" # development, staging, production
DEMO_MODE: bool = False # Enable demo profiles (quick login) regardless of ENVIRONMENT
LOG_LEVEL: str = "INFO"
# Database — SQLite by default for local dev, PostgreSQL for Docker/prod
DATABASE_URL: str = "sqlite+aiosqlite:///./glibredecision.db"
DATABASE_URL: str = "sqlite+aiosqlite:///./libredecision.db"
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10
@@ -38,6 +39,9 @@ class Settings(BaseSettings):
RATE_LIMIT_AUTH: int = 10
RATE_LIMIT_VOTE: int = 30
# AI — Qwen3.6 (MacStudio) endpoint, branché plus tard
QWEN_API_URL: str = ""
# Blockchain cache
BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600
+26
View File
@@ -0,0 +1,26 @@
"""FastAPI dependency: resolve X-Organization header → org UUID."""
from __future__ import annotations
import uuid
from fastapi import Depends, Header
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.org_service import get_organization_by_slug
async def get_active_org_id(
x_organization: str | None = Header(default=None, alias="X-Organization"),
db: AsyncSession = Depends(get_db),
) -> uuid.UUID | None:
"""Return the UUID of the org named in the X-Organization header, or None.
None means no org filter — used for backward compat and internal tooling.
An unknown slug is silently treated as None (don't break the client).
"""
if not x_organization:
return None
org = await get_organization_by_slug(db, x_organization)
return org.id if org else None
+211
View File
@@ -0,0 +1,211 @@
"""Decision qualification engine.
Pure functions — no database, no I/O.
Takes a QualificationInput + QualificationConfig and returns a QualificationResult.
LLM integration (suggest_modalities_from_context) is stubbed pending local Qwen deployment.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
class DecisionType(str, Enum):
INDIVIDUAL = "individual"
COLLECTIVE = "collective"
# ---------------------------------------------------------------------------
# Configuration (thresholds — stored as QualificationProtocol in DB)
# ---------------------------------------------------------------------------
@dataclass
class QualificationConfig:
"""Configurable thresholds for the qualification engine.
Seeded as a QualificationProtocol record so they can be adjusted
through the admin interface without code changes.
small_group_max: affected_count <= this → individual recommended, collective available
collective_wot_min: affected_count > this → WoT formula applicable (still recommended, not required)
affected_count must be >= 2 — decisions affecting only the author
have no place in this tool.
"""
small_group_max: int = 5
collective_wot_min: int = 50
default_modalities: list[str] = field(default_factory=lambda: [
"vote_wot",
"vote_smith",
"consultation_avis",
"election",
])
# ---------------------------------------------------------------------------
# Input / Output
# ---------------------------------------------------------------------------
@dataclass
class QualificationInput:
within_mandate: bool = False
affected_count: int | None = None # must be >= 2 when within_mandate=False
is_structural: bool = False
context_description: str | None = None # reserved for LLM suggestion
@dataclass
class QualificationResult:
decision_type: DecisionType
process: str
recommended_modalities: list[str]
recommend_onchain: bool
onchain_reason: str | None
confidence: str # "required" | "recommended" | "optional"
collective_available: bool
record_in_observatory: bool # True → decision must be logged in Observatoire
reasons: list[str]
# ---------------------------------------------------------------------------
# LLM stub
# ---------------------------------------------------------------------------
def suggest_modalities_from_context(
context: str,
config: QualificationConfig,
) -> list[str]:
"""Suggest voting modalities based on a natural-language context description.
Stub — returns empty list until local Qwen (qwen3.6, MacStudio) is integrated.
When implemented, will call the LLM API and return an ordered subset of
config.default_modalities ranked by contextual relevance.
"""
return []
# ---------------------------------------------------------------------------
# Core engine
# ---------------------------------------------------------------------------
def qualify(inp: QualificationInput, config: QualificationConfig) -> QualificationResult:
"""Qualify a decision and recommend a type, process, and modalities.
Rules (in priority order):
R1/R2 within_mandate → individual + consultation_avis, no vote modalities,
decision must be recorded in Observatoire des décisions
R4 2 ≤ affected_count ≤ small_group_max → individual recommended, collective available
R5 small_group_max < affected_count ≤ collective_wot_min → collective recommended
R6 affected_count > collective_wot_min → collective recommended (WoT formula applicable)
R7/R8 is_structural → recommend_onchain with reason
"""
reasons: list[str] = []
# ── R1/R2: mandate scope overrides everything ───────────────────────────
if inp.within_mandate:
reasons.append("Décision dans le périmètre d'un mandat existant.")
return QualificationResult(
decision_type=DecisionType.INDIVIDUAL,
process="consultation_avis",
recommended_modalities=[],
recommend_onchain=_onchain(inp, reasons),
onchain_reason=_onchain_reason(inp),
confidence="required",
collective_available=False,
record_in_observatory=True,
reasons=reasons,
)
count = inp.affected_count if inp.affected_count is not None else 2
# ── R4: small group → individual recommended, collective available ───────
if count <= config.small_group_max:
reasons.append(
f"{count} personnes concernées : décision individuelle recommandée, "
"vote collectif possible."
)
return QualificationResult(
decision_type=DecisionType.INDIVIDUAL,
process="personal",
recommended_modalities=[],
recommend_onchain=_onchain(inp, reasons),
onchain_reason=_onchain_reason(inp),
confidence="recommended",
collective_available=True,
record_in_observatory=False,
reasons=reasons,
)
# ── R5/R6: medium or large group → collective ────────────────────────────
modalities = _collect_modalities(inp, config)
if count <= config.collective_wot_min:
reasons.append(f"{count} personnes concernées : vote collectif recommandé.")
confidence = "recommended"
else:
reasons.append(
f"{count} personnes concernées : vote collectif recommandé "
"(formule WoT applicable à cette échelle)."
)
confidence = "recommended"
if "vote_wot" not in modalities:
modalities = ["vote_wot"] + modalities
return QualificationResult(
decision_type=DecisionType.COLLECTIVE,
process="vote_collective",
recommended_modalities=modalities,
recommend_onchain=_onchain(inp, reasons),
onchain_reason=_onchain_reason(inp),
confidence=confidence,
collective_available=True,
record_in_observatory=False,
reasons=reasons,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _onchain(inp: QualificationInput, reasons: list[str]) -> bool:
if inp.is_structural:
reasons.append(
"Décision structurante : gravure on-chain recommandée "
"(a force de loi ou déclenche une action machine)."
)
return inp.is_structural
def _onchain_reason(inp: QualificationInput) -> str | None:
if not inp.is_structural:
return None
return (
"Cette décision est structurante : elle a valeur de loi au sein de la "
"communauté ou déclenche une action machine (ex : runtime upgrade). "
"La gravure on-chain (IPFS + system.remark) garantit son immuabilité "
"et sa vérifiabilité publique."
)
def _collect_modalities(inp: QualificationInput, config: QualificationConfig) -> list[str]:
"""Combine default modalities with any LLM suggestions (stub for now)."""
llm_suggestions = []
if inp.context_description:
llm_suggestions = suggest_modalities_from_context(inp.context_description, config)
seen: set[str] = set()
result: list[str] = []
for m in llm_suggestions + config.default_modalities:
if m not in seen:
seen.add(m)
result.append(m)
return result
+28 -11
View File
@@ -13,6 +13,9 @@ from app.middleware.rate_limiter import RateLimiterMiddleware
from app.middleware.security_headers import SecurityHeadersMiddleware
from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket
from app.routers import public
from app.routers import organizations
from app.routers import qualify
from app.routers import groups
# ── Structured logging setup ───────────────────────────────────────────────
@@ -85,8 +88,28 @@ app = FastAPI(
# ── Middleware stack ──────────────────────────────────────────────────────
# Middleware is applied in reverse order: last added = first executed.
# Order: SecurityHeaders -> RateLimiter -> CORS -> Application
# add_middleware is LIFO: last added = outermost = first to execute on request,
# last to execute on response (wraps everything inside it).
#
# Required order so CORS headers appear on ALL responses including 429:
# CORS (outermost) → RateLimiter → SecurityHeaders → Application
#
# If RateLimiter were outside CORS, its 429 responses would have no CORS
# headers and the browser would silently discard them as network errors.
app.add_middleware(SecurityHeadersMiddleware)
# Prototype mode: use RATE_LIMIT_DEFAULT for auth so demos/testing don't hit
# the stricter RATE_LIMIT_AUTH (10/min). Set RATE_LIMIT_AUTH >= RATE_LIMIT_DEFAULT
# in .env only when going to real production.
_auth_rate_limit = settings.RATE_LIMIT_DEFAULT
app.add_middleware(
RateLimiterMiddleware,
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
rate_limit_auth=_auth_rate_limit,
rate_limit_vote=settings.RATE_LIMIT_VOTE,
)
app.add_middleware(
CORSMiddleware,
@@ -96,15 +119,6 @@ app.add_middleware(
allow_headers=["*"],
)
app.add_middleware(
RateLimiterMiddleware,
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
rate_limit_auth=settings.RATE_LIMIT_AUTH,
rate_limit_vote=settings.RATE_LIMIT_VOTE,
)
app.add_middleware(SecurityHeadersMiddleware)
# ── Routers ──────────────────────────────────────────────────────────────
@@ -117,6 +131,9 @@ app.include_router(protocols.router, prefix="/api/v1/protocols", tags=["protocol
app.include_router(sanctuary.router, prefix="/api/v1/sanctuary", tags=["sanctuary"])
app.include_router(websocket.router, prefix="/api/v1/ws", tags=["websocket"])
app.include_router(public.router, prefix="/api/v1/public", tags=["public"])
app.include_router(organizations.router, prefix="/api/v1/organizations", tags=["organizations"])
app.include_router(qualify.router, prefix="/api/v1/qualify", tags=["qualify"])
app.include_router(groups.router, prefix="/api/v1/groups", tags=["groups"])
# ── Health check ─────────────────────────────────────────────────────────
+18 -11
View File
@@ -64,14 +64,6 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
self._last_cleanup: float = time.time()
self._lock = asyncio.Lock()
def _get_limit_for_path(self, path: str) -> int:
"""Return the rate limit applicable to the given request path."""
if "/auth" in path:
return self.rate_limit_auth
if "/vote" in path:
return self.rate_limit_vote
return self.rate_limit_default
def _get_client_ip(self, request: Request) -> str:
"""Extract the client IP from the request, respecting X-Forwarded-For."""
forwarded = request.headers.get("x-forwarded-for")
@@ -101,6 +93,22 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
if ips_to_delete:
logger.debug("Nettoyage rate limiter: %d IPs supprimees", len(ips_to_delete))
def _get_limit_for_request(self, request: Request) -> int:
"""Return the rate limit applicable to the given request.
CORS preflight (OPTIONS) requests are never rate-limited — blocking them
breaks authenticated cross-origin requests in browsers.
Strict auth limit applies only to POST (login flows), not to GET /auth/me.
"""
if request.method == "OPTIONS":
return 10_000 # effectively unlimited for preflights
path = request.url.path
if request.method == "POST" and "/auth" in path:
return self.rate_limit_auth
if "/vote" in path:
return self.rate_limit_vote
return self.rate_limit_default
async def dispatch(self, request: Request, call_next) -> Response:
"""Check rate limit and either allow the request or return 429."""
# Skip rate limiting for WebSocket upgrades
@@ -111,8 +119,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
await self._cleanup_old_entries()
client_ip = self._get_client_ip(request)
path = request.url.path
limit = self._get_limit_for_path(path)
limit = self._get_limit_for_request(request)
now = time.time()
window_start = now - 60
@@ -133,7 +140,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
logger.warning(
"Rate limit depasse pour %s sur %s (%d/%d)",
client_ip, path, request_count, limit,
client_ip, request.url.path, request_count, limit,
)
return JSONResponse(
+6
View File
@@ -1,19 +1,25 @@
from app.models.user import DuniterIdentity, Session
from app.models.organization import Organization, OrgMember
from app.models.document import Document, DocumentItem, ItemVersion
from app.models.decision import Decision, DecisionStep
from app.models.vote import VoteSession, Vote
from app.models.mandate import Mandate, MandateStep
from app.models.protocol import VotingProtocol, FormulaConfig
from app.models.qualification import QualificationProtocol
from app.models.group import Group, GroupMember
from app.models.sanctuary import SanctuaryEntry
from app.models.cache import BlockchainCache
__all__ = [
"DuniterIdentity", "Session",
"Organization", "OrgMember",
"Document", "DocumentItem", "ItemVersion",
"Decision", "DecisionStep",
"VoteSession", "Vote",
"Mandate", "MandateStep",
"VotingProtocol", "FormulaConfig",
"QualificationProtocol",
"Group", "GroupMember",
"SanctuaryEntry",
"BlockchainCache",
]
+1
View File
@@ -16,6 +16,7 @@ class Decision(Base):
context: Mapped[str | None] = mapped_column(Text)
decision_type: Mapped[str] = mapped_column(String(64), nullable=False) # runtime_upgrade, document_change, mandate_vote, custom
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, qualification, review, voting, executed, closed
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
voting_protocol_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("voting_protocols.id"))
created_by_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+1
View File
@@ -17,6 +17,7 @@ class Document(Base):
version: Mapped[str] = mapped_column(String(32), default="0.1.0")
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, active, archived
description: Mapped[str | None] = mapped_column(Text)
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
ipfs_cid: Mapped[str | None] = mapped_column(String(128))
chain_anchor: Mapped[str | None] = mapped_column(String(128))
genesis_json: Mapped[str | None] = mapped_column(Text) # JSON: source files, repos, forum URLs, formula trigger
+41
View File
@@ -0,0 +1,41 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Group(Base):
__tablename__ = "groups"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
organization_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("organizations.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
members: Mapped[list["GroupMember"]] = relationship(
back_populates="group", cascade="all, delete-orphan"
)
class GroupMember(Base):
__tablename__ = "group_members"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
group_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("groups.id"), nullable=False, index=True)
# FK to duniter_identities when the member is a known WoT member; nullable for free-text entries
identity_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("duniter_identities.id"), nullable=True
)
display_name: Mapped[str] = mapped_column(String(128), nullable=False)
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
group: Mapped["Group"] = relationship(back_populates="members")
+31 -5
View File
@@ -12,9 +12,11 @@ class Mandate(Base):
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
title: Mapped[str] = mapped_column(String(256), nullable=False)
origin_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"), nullable=True, index=True)
description: Mapped[str | None] = mapped_column(Text)
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, candidacy, voting, active, reporting, completed, revoked
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(String(32), default="draft")
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@@ -22,7 +24,27 @@ class Mandate(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
steps: Mapped[list["MandateStep"]] = relationship(back_populates="mandate", cascade="all, delete-orphan", order_by="MandateStep.step_order")
steps: Mapped[list["MandateStep"]] = relationship(
back_populates="mandate", cascade="all, delete-orphan", order_by="MandateStep.step_order"
)
origin_identity: Mapped["DuniterIdentity | None"] = relationship( # type: ignore[name-defined]
"DuniterIdentity", foreign_keys=[origin_id]
)
mandatee: Mapped["DuniterIdentity | None"] = relationship( # type: ignore[name-defined]
"DuniterIdentity", foreign_keys=[mandatee_id]
)
@property
def origin_display_name(self) -> str | None:
if self.origin_identity is not None:
return self.origin_identity.display_name or self.origin_identity.address
return None
@property
def mandatee_display_name(self) -> str | None:
if self.mandatee is not None:
return self.mandatee.display_name or self.mandatee.address
return None
class MandateStep(Base):
@@ -31,12 +53,16 @@ class MandateStep(Base):
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False)
step_order: Mapped[int] = mapped_column(Integer, nullable=False)
step_type: Mapped[str] = mapped_column(String(32), nullable=False) # formulation, candidacy, vote, assignment, reporting, completion, revocation
step_type: Mapped[str] = mapped_column(String(32), nullable=False)
title: Mapped[str | None] = mapped_column(String(256))
description: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(32), default="pending") # pending, active, completed, skipped
status: Mapped[str] = mapped_column(String(32), default="pending")
vote_session_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("vote_sessions.id"))
outcome: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
mandate: Mapped["Mandate"] = relationship(back_populates="steps")
# Avoid circular import — DuniterIdentity imported at runtime by SQLAlchemy relationship resolution
from app.models.user import DuniterIdentity # noqa: E402, F401
+42
View File
@@ -0,0 +1,42 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Organization(Base):
__tablename__ = "organizations"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(128), nullable=False)
slug: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
# commune, enterprise, association, collective, basin, intercommunality, community
org_type: Mapped[str] = mapped_column(String(64), default="community")
# True = all authenticated users see & interact with content (Duniter G1, Axiom Team)
# False = membership required
is_transparent: Mapped[bool] = mapped_column(Boolean, default=False)
color: Mapped[str | None] = mapped_column(String(32)) # CSS color or mood token
icon: Mapped[str | None] = mapped_column(String(64)) # lucide icon name
description: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
members: Mapped[list["OrgMember"]] = relationship(
back_populates="organization", cascade="all, delete-orphan"
)
class OrgMember(Base):
__tablename__ = "org_members"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
org_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("organizations.id"), nullable=False)
identity_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("duniter_identities.id"), nullable=False
)
role: Mapped[str] = mapped_column(String(32), default="member") # admin, member, observer
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
organization: Mapped["Organization"] = relationship(back_populates="members")
+1
View File
@@ -44,6 +44,7 @@ class VotingProtocol(Base):
description: Mapped[str | None] = mapped_column(Text)
vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced
formula_config_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("formula_configs.id"), nullable=False)
organization_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("organizations.id"), nullable=True, index=True)
mode_params: Mapped[str | None] = mapped_column(String(64)) # e.g. "D30M50B.1G.2T.1"
is_meta_governed: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+38
View File
@@ -0,0 +1,38 @@
import json
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class QualificationProtocol(Base):
"""Active configuration for the decision qualification engine.
Thresholds stored here override the engine defaults and can be updated
through the admin interface (meta-governance).
Only one record should be active at a time (is_active=True).
"""
__tablename__ = "qualification_protocols"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
small_group_max: Mapped[int] = mapped_column(Integer, default=5)
collective_wot_min: Mapped[int] = mapped_column(Integer, default=50)
# JSON array of modality slugs, e.g. '["vote_wot","vote_smith","election"]'
default_modalities_json: Mapped[str] = mapped_column(
Text,
default='["vote_wot","vote_smith","consultation_avis","election"]',
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@property
def default_modalities(self) -> list[str]:
return json.loads(self.default_modalities_json)
+67 -16
View File
@@ -5,7 +5,8 @@ from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -45,15 +46,15 @@ DEV_PROFILES = [
},
{
"address": "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmkP7j4bJa3zN7d8tY",
"display_name": "Charlie (Comite Tech)",
"display_name": "Charlie (Référent structure)",
"wot_status": "member",
"is_smith": True,
"is_techcomm": True,
},
{
"address": "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy",
"display_name": "Dave (Observateur)",
"wot_status": "unknown",
"display_name": "Dave (Auteur)",
"wot_status": "member",
"is_smith": False,
"is_techcomm": False,
},
@@ -132,22 +133,51 @@ async def verify_challenge(
detail="Challenge invalide",
)
# 4. Verify Ed25519 signature
# TODO: Implement actual Ed25519 verification using substrate-interface
# For now we accept any signature to allow development/testing.
# In production this MUST verify: verify(address_pubkey, challenge_bytes, signature_bytes)
#
# from substrateinterface import Keypair
# keypair = Keypair(ss58_address=payload.address)
# if not keypair.verify(payload.challenge.encode(), bytes.fromhex(payload.signature)):
# raise HTTPException(status_code=401, detail="Signature invalide")
# 4. Verify signature
# TODO: trustWallet — déléguer la vérification au protocole trustWallet (librodrome)
# Quand trustWallet sera disponible : remplacer le bloc ci-dessous par une vérification
# du token signé fourni par trustWallet (JWT ou preuve Ed25519 via iframe postMessage).
# Le bypass DEMO_MODE sera alors supprimé.
_demo_addresses = {p["address"] for p in DEV_PROFILES}
is_demo_bypass = (settings.DEMO_MODE or settings.ENVIRONMENT == "development") and payload.address in _demo_addresses
if not is_demo_bypass:
# polkadot.js / Cesium2 signRaw(type='bytes') wraps: <Bytes>{challenge}</Bytes>
message = f"<Bytes>{payload.challenge}</Bytes>".encode("utf-8")
sig_hex = payload.signature.removeprefix("0x")
try:
sig_bytes = bytes.fromhex(sig_hex)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Format de signature invalide (hex attendu)",
)
from substrateinterface import Keypair, KeypairType
verified = False
# Try Sr25519 first (default Substrate/Cesium2), then Ed25519 (Duniter v1 migration)
for key_type in [KeypairType.SR25519, KeypairType.ED25519]:
try:
kp = Keypair(ss58_address=payload.address, crypto_type=key_type)
if kp.verify(message, sig_bytes):
verified = True
break
except Exception:
continue
if not verified:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Signature invalide",
)
# 5. Consume the challenge
del _pending_challenges[payload.address]
# 6. Get or create identity (apply dev profile if available)
dev_profile = None
if settings.ENVIRONMENT == "development":
if settings.ENVIRONMENT == "development" or settings.DEMO_MODE:
dev_profile = next((p for p in DEV_PROFILES if p["address"] == payload.address), None)
identity = await get_or_create_identity(db, payload.address, dev_profile=dev_profile)
@@ -162,8 +192,8 @@ async def verify_challenge(
@router.get("/dev/profiles")
async def list_dev_profiles():
"""List available dev profiles for quick login. Only available in development."""
if settings.ENVIRONMENT != "development":
"""List available demo profiles for quick login. Available in development or demo mode."""
if settings.ENVIRONMENT != "development" and not settings.DEMO_MODE:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not available")
return DEV_PROFILES
@@ -203,3 +233,24 @@ async def logout(
for session in sessions:
await db.delete(session)
await db.commit()
@router.get("/identities", response_model=list[IdentityOut])
async def search_identities(
q: str = Query(..., min_length=1, description="Recherche par adresse ou nom"),
limit: int = Query(default=10, ge=1, le=50),
db: AsyncSession = Depends(get_db),
) -> list[IdentityOut]:
"""Search Duniter identities by address prefix or display_name."""
result = await db.execute(
select(DuniterIdentity)
.where(
or_(
DuniterIdentity.address.ilike(f"{q}%"),
DuniterIdentity.display_name.ilike(f"%{q}%"),
)
)
.order_by(DuniterIdentity.display_name)
.limit(limit)
)
return [IdentityOut.model_validate(i) for i in result.scalars().all()]
+31 -10
View File
@@ -21,6 +21,7 @@ from app.schemas.decision import (
DecisionUpdate,
)
from app.schemas.vote import VoteSessionOut
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
from app.services.decision_service import advance_decision, create_vote_session_for_step
@@ -30,13 +31,20 @@ router = APIRouter()
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
"""Fetch a decision by ID with its steps eagerly loaded, or raise 404."""
result = await db.execute(
async def _get_decision(
db: AsyncSession, decision_id: uuid.UUID, org_id: uuid.UUID | None = None
) -> Decision:
"""Fetch a decision by ID within the active org scope, or raise 404."""
stmt = (
select(Decision)
.options(selectinload(Decision.steps))
.where(Decision.id == decision_id)
)
if org_id is not None:
stmt = stmt.where(Decision.organization_id == org_id)
else:
stmt = stmt.where(Decision.organization_id.is_(None))
result = await db.execute(stmt)
decision = result.scalar_one_or_none()
if decision is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Decision introuvable")
@@ -49,6 +57,7 @@ async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
@router.get("/", response_model=list[DecisionOut])
async def list_decisions(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
decision_type: str | None = Query(default=None, description="Filtrer par type de decision"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
skip: int = Query(default=0, ge=0),
@@ -57,6 +66,10 @@ async def list_decisions(
"""List all decisions with optional filters."""
stmt = select(Decision).options(selectinload(Decision.steps))
if org_id is not None:
stmt = stmt.where(Decision.organization_id == org_id)
else:
stmt = stmt.where(Decision.organization_id.is_(None))
if decision_type is not None:
stmt = stmt.where(Decision.decision_type == decision_type)
if status_filter is not None:
@@ -74,18 +87,20 @@ async def create_decision(
payload: DecisionCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionOut:
"""Create a new decision process."""
decision = Decision(
**payload.model_dump(),
created_by_id=identity.id,
organization_id=org_id,
)
db.add(decision)
await db.commit()
await db.refresh(decision)
# Reload with steps (empty at creation)
decision = await _get_decision(db, decision.id)
decision = await _get_decision(db, decision.id, org_id)
return DecisionOut.model_validate(decision)
@@ -93,9 +108,10 @@ async def create_decision(
async def get_decision(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionOut:
"""Get a single decision with all its steps."""
decision = await _get_decision(db, id)
decision = await _get_decision(db, id, org_id)
return DecisionOut.model_validate(decision)
@@ -105,9 +121,10 @@ async def update_decision(
payload: DecisionUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionOut:
"""Update a decision's metadata (title, description, status, protocol)."""
decision = await _get_decision(db, id)
decision = await _get_decision(db, id, org_id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
@@ -117,7 +134,7 @@ async def update_decision(
await db.refresh(decision)
# Reload with steps
decision = await _get_decision(db, decision.id)
decision = await _get_decision(db, decision.id, org_id)
return DecisionOut.model_validate(decision)
@@ -130,10 +147,11 @@ async def add_step(
payload: DecisionStepCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionStepOut:
"""Add a step to a decision process."""
# Verify decision exists
decision = await _get_decision(db, id)
decision = await _get_decision(db, id, org_id)
step = DecisionStep(
decision_id=decision.id,
@@ -154,6 +172,7 @@ async def advance_decision_endpoint(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DecisionAdvanceOut:
"""Advance a decision to its next step or status."""
try:
@@ -162,7 +181,7 @@ async def advance_decision_endpoint(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps for complete output
decision = await _get_decision(db, decision.id)
decision = await _get_decision(db, decision.id, org_id)
data = DecisionOut.model_validate(decision).model_dump()
data["message"] = f"Decision avancee au statut : {decision.status}"
return DecisionAdvanceOut(**data)
@@ -178,6 +197,7 @@ async def create_vote_session_for_step_endpoint(
step_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VoteSessionOut:
"""Create a vote session linked to a decision step."""
try:
@@ -193,9 +213,10 @@ async def delete_decision(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> None:
"""Delete a decision (only if in draft status)."""
decision = await _get_decision(db, id)
decision = await _get_decision(db, id, org_id)
if decision.status != "draft":
raise HTTPException(
+8 -1
View File
@@ -25,6 +25,7 @@ from app.schemas.document import (
ItemVersionCreate,
ItemVersionOut,
)
from app.dependencies.org import get_active_org_id
from app.services import document_service
from app.services.auth_service import get_current_identity
@@ -65,6 +66,7 @@ async def _get_item(db: AsyncSession, document_id: uuid.UUID, item_id: uuid.UUID
@router.get("/", response_model=list[DocumentOut])
async def list_documents(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
skip: int = Query(default=0, ge=0),
@@ -73,6 +75,10 @@ async def list_documents(
"""List all reference documents, with optional filters."""
stmt = select(Document)
if org_id is not None:
stmt = stmt.where(Document.organization_id == org_id)
else:
stmt = stmt.where(Document.organization_id.is_(None))
if doc_type is not None:
stmt = stmt.where(Document.doc_type == doc_type)
if status_filter is not None:
@@ -101,6 +107,7 @@ async def create_document(
payload: DocumentCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> DocumentOut:
"""Create a new reference document."""
# Check slug uniqueness
@@ -111,7 +118,7 @@ async def create_document(
detail="Un document avec ce slug existe deja",
)
doc = Document(**payload.model_dump())
doc = Document(**payload.model_dump(), organization_id=org_id)
db.add(doc)
await db.commit()
await db.refresh(doc)
+122
View File
@@ -0,0 +1,122 @@
"""Groups router — predefined sets of Duniter identities used in decision circles."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.dependencies.org import get_active_org_id
from app.models.group import Group, GroupMember
from app.schemas.group import GroupCreate, GroupMemberCreate, GroupMemberOut, GroupOut, GroupSummary
from app.services.auth_service import get_current_identity
router = APIRouter()
@router.get("/", response_model=list[GroupSummary])
async def list_groups(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> list[GroupSummary]:
"""List groups within the active workspace."""
stmt = select(Group).options(selectinload(Group.members)).order_by(Group.name)
if org_id is not None:
stmt = stmt.where(Group.organization_id == org_id)
else:
stmt = stmt.where(Group.organization_id.is_(None))
result = await db.execute(stmt)
groups = result.scalars().all()
return [
GroupSummary(
id=g.id,
name=g.name,
description=g.description,
organization_id=g.organization_id,
member_count=len(g.members),
)
for g in groups
]
@router.post("/", response_model=GroupOut, status_code=201)
async def create_group(
payload: GroupCreate,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> GroupOut:
group = Group(name=payload.name, description=payload.description, organization_id=org_id)
db.add(group)
await db.commit()
await db.refresh(group)
await db.execute(select(Group).where(Group.id == group.id).options(selectinload(Group.members)))
return GroupOut.model_validate(group)
@router.get("/{group_id}", response_model=GroupOut)
async def get_group(group_id: uuid.UUID, db: AsyncSession = Depends(get_db)) -> GroupOut:
result = await db.execute(
select(Group).where(Group.id == group_id).options(selectinload(Group.members))
)
group = result.scalar_one_or_none()
if group is None:
raise HTTPException(status_code=404, detail="Group not found")
return GroupOut.model_validate(group)
@router.delete("/{group_id}", status_code=204, response_class=Response, response_model=None)
async def delete_group(
group_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> None:
result = await db.execute(select(Group).where(Group.id == group_id))
group = result.scalar_one_or_none()
if group is None:
raise HTTPException(status_code=404, detail="Group not found")
await db.delete(group)
await db.commit()
@router.post("/{group_id}/members", response_model=GroupMemberOut, status_code=201)
async def add_member(
group_id: uuid.UUID,
payload: GroupMemberCreate,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> GroupMemberOut:
result = await db.execute(select(Group).where(Group.id == group_id))
if result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Group not found")
member = GroupMember(
group_id=group_id,
display_name=payload.display_name,
identity_id=payload.identity_id,
)
db.add(member)
await db.commit()
await db.refresh(member)
return GroupMemberOut.model_validate(member)
@router.delete("/{group_id}/members/{member_id}", status_code=204, response_class=Response, response_model=None)
async def remove_member(
group_id: uuid.UUID,
member_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> None:
result = await db.execute(
select(GroupMember).where(GroupMember.id == member_id, GroupMember.group_id == group_id)
)
member = result.scalar_one_or_none()
if member is None:
raise HTTPException(status_code=404, detail="Member not found")
await db.delete(member)
await db.commit()
+75 -51
View File
@@ -22,6 +22,7 @@ from app.schemas.mandate import (
MandateUpdate,
)
from app.schemas.vote import VoteSessionOut
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
from app.services.mandate_service import (
advance_mandate,
@@ -36,33 +37,58 @@ router = APIRouter()
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
"""Fetch a mandate by ID with its steps eagerly loaded, or raise 404."""
result = await db.execute(
async def _get_mandate(
db: AsyncSession, mandate_id: uuid.UUID, org_id: uuid.UUID | None = None
) -> Mandate:
stmt = (
select(Mandate)
.options(selectinload(Mandate.steps))
.options(
selectinload(Mandate.steps),
selectinload(Mandate.origin_identity),
selectinload(Mandate.mandatee),
)
.where(Mandate.id == mandate_id)
)
if org_id is not None:
stmt = stmt.where(Mandate.organization_id == org_id)
else:
stmt = stmt.where(Mandate.organization_id.is_(None))
result = await db.execute(stmt)
mandate = result.scalar_one_or_none()
if mandate is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mandat introuvable")
return mandate
def _mandate_out(mandate: Mandate) -> MandateOut:
out = MandateOut.model_validate(mandate)
out.origin_display_name = mandate.origin_display_name
out.mandatee_display_name = mandate.mandatee_display_name
return out
# ── Mandate routes ──────────────────────────────────────────────────────────
@router.get("/", response_model=list[MandateOut])
async def list_mandates(
db: AsyncSession = Depends(get_db),
mandate_type: str | None = Query(default=None, description="Filtrer par type de mandat"),
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
org_id: uuid.UUID | None = Depends(get_active_org_id),
mandate_type: str | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[MandateOut]:
"""List all mandates with optional filters."""
stmt = select(Mandate).options(selectinload(Mandate.steps))
stmt = select(Mandate).options(
selectinload(Mandate.steps),
selectinload(Mandate.origin_identity),
selectinload(Mandate.mandatee),
)
if org_id is not None:
stmt = stmt.where(Mandate.organization_id == org_id)
else:
stmt = stmt.where(Mandate.organization_id.is_(None))
if mandate_type is not None:
stmt = stmt.where(Mandate.mandate_type == mandate_type)
if status_filter is not None:
@@ -72,7 +98,7 @@ async def list_mandates(
result = await db.execute(stmt)
mandates = result.scalars().unique().all()
return [MandateOut.model_validate(m) for m in mandates]
return [_mandate_out(m) for m in mandates]
@router.post("/", response_model=MandateOut, status_code=status.HTTP_201_CREATED)
@@ -80,26 +106,32 @@ async def create_mandate(
payload: MandateCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
"""Create a new mandate."""
mandate = Mandate(**payload.model_dump())
data = payload.model_dump()
nomination_mode = data.pop("nomination_mode", "postpone")
mandate = Mandate(**data, organization_id=org_id)
if nomination_mode == "auto":
mandate.mandatee_id = identity.id
db.add(mandate)
await db.commit()
await db.refresh(mandate)
# Reload with steps (empty at creation)
mandate = await _get_mandate(db, mandate.id)
return MandateOut.model_validate(mandate)
mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
@router.get("/{id}", response_model=MandateOut)
async def get_mandate(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
"""Get a single mandate with all its steps."""
mandate = await _get_mandate(db, id)
return MandateOut.model_validate(mandate)
mandate = await _get_mandate(db, id, org_id)
return _mandate_out(mandate)
@router.put("/{id}", response_model=MandateOut)
@@ -108,20 +140,16 @@ async def update_mandate(
payload: MandateUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
"""Update a mandate's metadata."""
mandate = await _get_mandate(db, id)
mandate = await _get_mandate(db, id, org_id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(mandate, field, value)
await db.commit()
await db.refresh(mandate)
# Reload with steps
mandate = await _get_mandate(db, mandate.id)
return MandateOut.model_validate(mandate)
mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
@@ -129,9 +157,9 @@ async def delete_mandate(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> None:
"""Delete a mandate (only if in draft status)."""
mandate = await _get_mandate(db, id)
mandate = await _get_mandate(db, id, org_id)
if mandate.status != "draft":
raise HTTPException(
@@ -152,14 +180,11 @@ async def add_step(
payload: MandateStepCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateStepOut:
"""Add a step to a mandate process."""
mandate = await _get_mandate(db, id)
mandate = await _get_mandate(db, id, org_id)
step = MandateStep(
mandate_id=mandate.id,
**payload.model_dump(),
)
step = MandateStep(mandate_id=mandate.id, **payload.model_dump())
db.add(step)
await db.commit()
await db.refresh(step)
@@ -171,9 +196,9 @@ async def add_step(
async def list_steps(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> list[MandateStepOut]:
"""List all steps for a mandate, ordered by step_order."""
mandate = await _get_mandate(db, id)
mandate = await _get_mandate(db, id, org_id)
return [MandateStepOut.model_validate(s) for s in mandate.steps]
@@ -185,18 +210,19 @@ async def advance_mandate_endpoint(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateAdvanceOut:
"""Advance a mandate to its next step or status."""
try:
mandate = await advance_mandate(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps for complete output
mandate = await _get_mandate(db, mandate.id)
data = MandateOut.model_validate(mandate).model_dump()
data["message"] = f"Mandat avance au statut : {mandate.status}"
return MandateAdvanceOut(**data)
mandate = await _get_mandate(db, mandate.id, org_id)
out = _mandate_out(mandate)
return MandateAdvanceOut(
**out.model_dump(),
message=f"Mandat avance au statut : {mandate.status}",
)
@router.post("/{id}/assign", response_model=MandateOut)
@@ -205,16 +231,15 @@ async def assign_mandatee_endpoint(
payload: MandateAssignRequest,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
"""Assign a mandatee to a mandate."""
try:
mandate = await assign_mandatee(id, payload.mandatee_id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps
mandate = await _get_mandate(db, mandate.id)
return MandateOut.model_validate(mandate)
mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
@router.post("/{id}/revoke", response_model=MandateOut)
@@ -222,16 +247,15 @@ async def revoke_mandate_endpoint(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> MandateOut:
"""Revoke an active mandate."""
try:
mandate = await revoke_mandate(id, db)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
# Reload with steps
mandate = await _get_mandate(db, mandate.id)
return MandateOut.model_validate(mandate)
mandate = await _get_mandate(db, mandate.id, org_id)
return _mandate_out(mandate)
@router.post(
@@ -244,8 +268,8 @@ async def create_vote_session_for_step_endpoint(
step_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VoteSessionOut:
"""Create a vote session linked to a mandate step."""
try:
session = await create_vote_session_for_step(id, step_id, db)
except ValueError as exc:
+96
View File
@@ -0,0 +1,96 @@
"""Organizations router: list, create, membership."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import DuniterIdentity
from app.schemas.organization import OrgMemberOut, OrganizationCreate, OrganizationOut
from app.services.auth_service import get_current_identity
from app.services.org_service import (
add_member,
create_organization,
get_organization,
get_organization_by_slug,
is_member,
list_members,
list_organizations,
)
router = APIRouter()
@router.get("/", response_model=list[OrganizationOut])
async def get_organizations(db: AsyncSession = Depends(get_db)) -> list[OrganizationOut]:
"""List all organizations (public — transparent ones need no auth)."""
orgs = await list_organizations(db)
return [OrganizationOut.model_validate(o) for o in orgs]
@router.post("/", response_model=OrganizationOut, status_code=status.HTTP_201_CREATED)
async def post_organization(
payload: OrganizationCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> OrganizationOut:
"""Create a new organization (authenticated users only)."""
existing = await get_organization_by_slug(db, payload.slug)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Slug '{payload.slug}' déjà utilisé",
)
org = await create_organization(db, **payload.model_dump())
# Creator becomes admin
await add_member(db, org.id, identity.id, role="admin")
return OrganizationOut.model_validate(org)
@router.get("/{org_id}", response_model=OrganizationOut)
async def get_organization_detail(
org_id: uuid.UUID, db: AsyncSession = Depends(get_db)
) -> OrganizationOut:
org = await get_organization(db, org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
return OrganizationOut.model_validate(org)
@router.get("/{org_id}/members", response_model=list[OrgMemberOut])
async def get_members(
org_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> list[OrgMemberOut]:
org = await get_organization(db, org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
if not org.is_transparent and not await is_member(db, org_id, identity.id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Accès refusé")
members = await list_members(db, org_id)
return [OrgMemberOut.model_validate(m) for m in members]
@router.post("/{org_id}/join", response_model=OrgMemberOut, status_code=status.HTTP_201_CREATED)
async def join_organization(
org_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> OrgMemberOut:
"""Join a transparent organization."""
org = await get_organization(db, org_id)
if not org:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
if not org.is_transparent:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Rejoindre cette organisation nécessite une invitation",
)
if await is_member(db, org_id, identity.id):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Déjà membre")
member = await add_member(db, org_id, identity.id)
return OrgMemberOut.model_validate(member)
+24 -8
View File
@@ -25,6 +25,7 @@ from app.schemas.protocol import (
VotingProtocolOut,
VotingProtocolUpdate,
)
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
router = APIRouter()
@@ -33,13 +34,20 @@ router = APIRouter()
# ── Helpers ─────────────────────────────────────────────────────────────────
async def _get_protocol(db: AsyncSession, protocol_id: uuid.UUID) -> VotingProtocol:
"""Fetch a voting protocol by ID with its formula config, or raise 404."""
result = await db.execute(
async def _get_protocol(
db: AsyncSession, protocol_id: uuid.UUID, org_id: uuid.UUID | None = None
) -> VotingProtocol:
"""Fetch a voting protocol by ID within the active org scope, or raise 404."""
stmt = (
select(VotingProtocol)
.options(selectinload(VotingProtocol.formula_config))
.where(VotingProtocol.id == protocol_id)
)
if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id)
else:
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
result = await db.execute(stmt)
protocol = result.scalar_one_or_none()
if protocol is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Protocole introuvable")
@@ -63,6 +71,7 @@ async def _get_formula(db: AsyncSession, formula_id: uuid.UUID) -> FormulaConfig
@router.get("/", response_model=list[VotingProtocolOut])
async def list_protocols(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
vote_type: str | None = Query(default=None, description="Filtrer par type de vote (binary, nuanced)"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
@@ -70,6 +79,10 @@ async def list_protocols(
"""List all voting protocols with their formula configurations."""
stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config))
if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id)
else:
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
if vote_type is not None:
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
@@ -85,6 +98,7 @@ async def create_protocol(
payload: VotingProtocolCreate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VotingProtocolOut:
"""Create a new voting protocol.
@@ -100,13 +114,13 @@ async def create_protocol(
detail="Configuration de formule introuvable",
)
protocol = VotingProtocol(**payload.model_dump())
protocol = VotingProtocol(**payload.model_dump(), organization_id=org_id)
db.add(protocol)
await db.commit()
await db.refresh(protocol)
# Reload with formula config
protocol = await _get_protocol(db, protocol.id)
protocol = await _get_protocol(db, protocol.id, org_id)
return VotingProtocolOut.model_validate(protocol)
@@ -114,9 +128,10 @@ async def create_protocol(
async def get_protocol(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VotingProtocolOut:
"""Get a single voting protocol with its formula configuration."""
protocol = await _get_protocol(db, id)
protocol = await _get_protocol(db, id, org_id)
return VotingProtocolOut.model_validate(protocol)
@@ -126,12 +141,13 @@ async def update_protocol(
payload: VotingProtocolUpdate,
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
org_id: uuid.UUID | None = Depends(get_active_org_id),
) -> VotingProtocolOut:
"""Update a voting protocol (meta-governance).
Only provided fields will be updated. Requires authentication.
"""
protocol = await _get_protocol(db, id)
protocol = await _get_protocol(db, id, org_id)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
@@ -140,7 +156,7 @@ async def update_protocol(
await db.commit()
# Reload with formula config
protocol = await _get_protocol(db, protocol.id)
protocol = await _get_protocol(db, protocol.id, org_id)
return VotingProtocolOut.model_validate(protocol)
+1 -1
View File
@@ -240,7 +240,7 @@ async def platform_status(
sanctuary_count = sanctuary_count_result.scalar() or 0
return {
"platform": "Glibredecision",
"platform": "libreDecision",
"documents_count": documents_count,
"decisions_count": decisions_count,
"active_votes_count": active_votes_count,
+186
View File
@@ -0,0 +1,186 @@
"""Qualify router: decision qualification engine endpoint."""
from __future__ import annotations
from dataclasses import asdict
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.engine.qualifier import QualificationConfig, QualificationInput, qualify
from app.models.qualification import QualificationProtocol
from app.schemas.qualification import (
QualificationProtocolCreate,
QualificationProtocolOut,
QualifyRequest,
QualifyResponse,
)
from app.services.auth_service import get_current_identity
from app.services.qualify_ai_service import (
AIFrameRequest,
AIFrameResponse,
AIMessage,
AIQuestion,
AIQualifyResult,
ai_frame,
)
router = APIRouter()
# ── Pydantic wrappers for AI chat (FastAPI needs Pydantic, not dataclasses) ──
class AIMessagePayload(BaseModel):
role: str
content: str
class AIChatRequest(BaseModel):
within_mandate: bool = False
affected_count: int | None = None
is_structural: bool = False
context: str | None = None
messages: list[AIMessagePayload] = []
class AIQuestionOut(BaseModel):
id: str
text: str
options: list[str]
class AIQualifyResultOut(BaseModel):
decision_type: str
process: str
recommended_modalities: list[str]
recommend_onchain: bool
onchain_reason: str | None
confidence: str
collective_available: bool
record_in_observatory: bool
reasons: list[str]
class AIChatResponse(BaseModel):
done: bool
questions: list[AIQuestionOut] = []
result: AIQualifyResultOut | None = None
explanation: str | None = None
async def _load_config(db: AsyncSession) -> QualificationConfig:
"""Load the active QualificationProtocol from DB, or fall back to defaults."""
result = await db.execute(
select(QualificationProtocol)
.where(QualificationProtocol.is_active == True) # noqa: E712
.order_by(QualificationProtocol.created_at.desc())
.limit(1)
)
proto = result.scalar_one_or_none()
if proto is None:
return QualificationConfig()
return QualificationConfig(
small_group_max=proto.small_group_max,
collective_wot_min=proto.collective_wot_min,
default_modalities=proto.default_modalities,
)
@router.post("/", response_model=QualifyResponse)
async def qualify_decision(
payload: QualifyRequest,
db: AsyncSession = Depends(get_db),
) -> QualifyResponse:
"""Qualify a decision: determine type, process, and modalities.
No authentication required — this is an advisory endpoint that helps
users understand which decision pathway fits their situation.
"""
config = await _load_config(db)
inp = QualificationInput(
within_mandate=payload.within_mandate,
affected_count=payload.affected_count,
is_structural=payload.is_structural,
context_description=payload.context_description,
)
result = qualify(inp, config)
return QualifyResponse(**asdict(result))
@router.post("/ai-chat", response_model=AIChatResponse)
async def ai_chat(payload: AIChatRequest) -> AIChatResponse:
"""Run one round of AI-assisted qualification framing.
Round 1 (messages=[]) → returns 2 clarifying questions.
Round 2 (messages set) → returns final qualification result.
No auth required — advisory endpoint.
"""
req = AIFrameRequest(
within_mandate=payload.within_mandate,
affected_count=payload.affected_count,
is_structural=payload.is_structural,
context=payload.context,
messages=[AIMessage(role=m.role, content=m.content) for m in payload.messages],
)
resp = ai_frame(req)
return AIChatResponse(
done=resp.done,
questions=[AIQuestionOut(id=q.id, text=q.text, options=q.options) for q in resp.questions],
result=AIQualifyResultOut(**asdict(resp.result)) if resp.result else None,
explanation=resp.explanation,
)
@router.get("/protocol", response_model=QualificationProtocolOut | None)
async def get_active_protocol(
db: AsyncSession = Depends(get_db),
) -> QualificationProtocolOut | None:
"""Return the currently active qualification protocol (thresholds)."""
result = await db.execute(
select(QualificationProtocol)
.where(QualificationProtocol.is_active == True) # noqa: E712
.order_by(QualificationProtocol.created_at.desc())
.limit(1)
)
proto = result.scalar_one_or_none()
if proto is None:
return None
return QualificationProtocolOut.model_validate(proto)
@router.post("/protocol", response_model=QualificationProtocolOut, status_code=201)
async def create_protocol(
payload: QualificationProtocolCreate,
db: AsyncSession = Depends(get_db),
_identity=Depends(get_current_identity),
) -> QualificationProtocolOut:
"""Create a new qualification protocol (requires auth).
Deactivates the current active protocol before saving the new one.
"""
# Deactivate current
current = await db.execute(
select(QualificationProtocol).where(QualificationProtocol.is_active == True) # noqa: E712
)
for proto in current.scalars().all():
proto.is_active = False
import json
proto = QualificationProtocol(
name=payload.name,
description=payload.description,
small_group_max=payload.small_group_max,
collective_wot_min=payload.collective_wot_min,
default_modalities_json=json.dumps(payload.default_modalities),
is_active=True,
)
db.add(proto)
await db.commit()
await db.refresh(proto)
return QualificationProtocolOut.model_validate(proto)
+10 -1
View File
@@ -25,6 +25,7 @@ from app.schemas.vote import (
VoteSessionListOut,
VoteSessionOut,
)
from app.dependencies.org import get_active_org_id
from app.services.auth_service import get_current_identity
from app.services.vote_service import (
close_session as svc_close_session,
@@ -170,14 +171,22 @@ def _compute_result(
@router.get("/sessions", response_model=list[VoteSessionListOut])
async def list_vote_sessions(
db: AsyncSession = Depends(get_db),
org_id: uuid.UUID | None = Depends(get_active_org_id),
session_status: str | None = Query(default=None, alias="status", description="Filtrer par statut (open, closed, tallied)"),
decision_id: uuid.UUID | None = Query(default=None, description="Filtrer par decision_id"),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=200),
) -> list[VoteSessionListOut]:
"""List all vote sessions with optional filters by status and decision_id."""
stmt = select(VoteSession)
stmt = (
select(VoteSession)
.join(VotingProtocol, VoteSession.voting_protocol_id == VotingProtocol.id)
)
if org_id is not None:
stmt = stmt.where(VotingProtocol.organization_id == org_id)
else:
stmt = stmt.where(VotingProtocol.organization_id.is_(None))
if session_status is not None:
stmt = stmt.where(VoteSession.status == session_status)
if decision_id is not None:
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class GroupMemberCreate(BaseModel):
display_name: str
identity_id: uuid.UUID | None = None
class GroupMemberOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
display_name: str
identity_id: uuid.UUID | None
added_at: datetime
class GroupCreate(BaseModel):
name: str
description: str | None = None
class GroupOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
organization_id: uuid.UUID | None
created_at: datetime
members: list[GroupMemberOut] = []
class GroupSummary(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
description: str | None
organization_id: uuid.UUID | None
member_count: int = 0
+17 -36
View File
@@ -10,21 +10,13 @@ from pydantic import BaseModel, ConfigDict, Field
class MandateStepCreate(BaseModel):
"""Payload for creating a step within a mandate process."""
step_order: int = Field(..., ge=0)
step_type: str = Field(
...,
max_length=32,
description="formulation, candidacy, vote, assignment, reporting, completion, revocation",
)
step_type: str = Field(..., max_length=32)
title: str | None = Field(default=None, max_length=256)
description: str | None = None
class MandateStepOut(BaseModel):
"""Full mandate step representation."""
model_config = ConfigDict(from_attributes=True)
id: UUID
@@ -43,40 +35,45 @@ class MandateStepOut(BaseModel):
class MandateCreate(BaseModel):
"""Payload for creating a new mandate."""
title: str = Field(..., min_length=1, max_length=256)
origin_id: UUID | None = None
description: str | None = None
mandate_type: str = Field(..., max_length=64, description="techcomm, smith, custom")
mandate_type: str = Field(..., max_length=64)
nomination_mode: str = Field(
default="postpone",
description="auto (auto-assign author), collective, postpone",
)
decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
class MandateUpdate(BaseModel):
"""Partial update for a mandate."""
title: str | None = Field(default=None, max_length=256)
origin_id: UUID | None = None
description: str | None = None
mandate_type: str | None = Field(default=None, max_length=64)
decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
class MandateAssignRequest(BaseModel):
"""Request body for assigning a mandatee to a mandate."""
mandatee_id: UUID = Field(..., description="ID de l'identite Duniter du mandataire")
mandatee_id: UUID = Field(..., description="UUID de l'identite Duniter du mandataire")
class MandateOut(BaseModel):
"""Full mandate representation returned by the API."""
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
origin_id: UUID | None = None
origin_display_name: str | None = None
description: str | None = None
mandate_type: str
status: str
mandatee_id: UUID | None = None
mandatee_display_name: str | None = None
decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
@@ -85,21 +82,5 @@ class MandateOut(BaseModel):
steps: list[MandateStepOut] = Field(default_factory=list)
class MandateAdvanceOut(BaseModel):
"""Output after advancing a mandate through its workflow."""
model_config = ConfigDict(from_attributes=True)
id: UUID
title: str
description: str | None = None
mandate_type: str
status: str
mandatee_id: UUID | None = None
decision_id: UUID | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
created_at: datetime
updated_at: datetime
steps: list[MandateStepOut] = Field(default_factory=list)
class MandateAdvanceOut(MandateOut):
message: str = Field(..., description="Message decrivant l'avancement effectue")
+42
View File
@@ -0,0 +1,42 @@
"""Pydantic v2 schemas for organizations."""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class OrganizationOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
slug: str
org_type: str
is_transparent: bool
color: str | None
icon: str | None
description: str | None
created_at: datetime
class OrganizationCreate(BaseModel):
name: str
slug: str
org_type: str = "community"
is_transparent: bool = False
color: str | None = None
icon: str | None = None
description: str | None = None
class OrgMemberOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
org_id: uuid.UUID
identity_id: uuid.UUID
role: str
created_at: datetime
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class QualifyRequest(BaseModel):
within_mandate: bool = False
affected_count: int | None = Field(default=None, ge=2, description="Nombre de personnes concernées (minimum 2)")
is_structural: bool = False
context_description: str | None = Field(default=None, max_length=2000)
class QualifyResponse(BaseModel):
decision_type: str
process: str
recommended_modalities: list[str]
recommend_onchain: bool
onchain_reason: str | None
confidence: str
collective_available: bool
record_in_observatory: bool
reasons: list[str]
class QualificationProtocolOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
description: str | None = None
small_group_max: int
collective_wot_min: int
default_modalities: list[str]
is_active: bool
created_at: datetime
class QualificationProtocolCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=128)
description: str | None = None
small_group_max: int = Field(default=5, ge=1)
collective_wot_min: int = Field(default=50, ge=1)
default_modalities: list[str] = Field(
default=["vote_wot", "vote_smith", "consultation_avis", "election"]
)
+60
View File
@@ -0,0 +1,60 @@
"""Organization service: CRUD + membership helpers."""
from __future__ import annotations
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.organization import OrgMember, Organization
async def list_organizations(db: AsyncSession) -> Sequence[Organization]:
result = await db.execute(select(Organization).order_by(Organization.name))
return result.scalars().all()
async def get_organization(db: AsyncSession, org_id: uuid.UUID) -> Organization | None:
return await db.get(Organization, org_id)
async def get_organization_by_slug(db: AsyncSession, slug: str) -> Organization | None:
result = await db.execute(select(Organization).where(Organization.slug == slug))
return result.scalar_one_or_none()
async def create_organization(db: AsyncSession, **kwargs) -> Organization:
org = Organization(**kwargs)
db.add(org)
await db.commit()
await db.refresh(org)
return org
async def is_member(db: AsyncSession, org_id: uuid.UUID, identity_id: uuid.UUID) -> bool:
result = await db.execute(
select(OrgMember).where(
OrgMember.org_id == org_id,
OrgMember.identity_id == identity_id,
)
)
return result.scalar_one_or_none() is not None
async def add_member(
db: AsyncSession, org_id: uuid.UUID, identity_id: uuid.UUID, role: str = "member"
) -> OrgMember:
member = OrgMember(org_id=org_id, identity_id=identity_id, role=role)
db.add(member)
await db.commit()
await db.refresh(member)
return member
async def list_members(db: AsyncSession, org_id: uuid.UUID) -> Sequence[OrgMember]:
result = await db.execute(
select(OrgMember).where(OrgMember.org_id == org_id).order_by(OrgMember.created_at)
)
return result.scalars().all()
+196
View File
@@ -0,0 +1,196 @@
"""AI framing service for decision qualification.
Orchestrates a 2-round conversation that clarifies reversibility and urgency
before producing a final QualificationResult.
Rule-based stub — Qwen3.6 (MacStudio) calls will replace ai_frame() internals
once the local endpoint is available. The interface is stable.
"""
from __future__ import annotations
from dataclasses import dataclass
# ---------------------------------------------------------------------------
# Schemas (dataclasses — no Pydantic dependency in the engine layer)
# ---------------------------------------------------------------------------
@dataclass
class AIMessage:
role: str # "user" | "assistant"
content: str
@dataclass
class AIQuestion:
id: str
text: str
options: list[str]
@dataclass
class AIQualifyResult:
decision_type: str
process: str
recommended_modalities: list[str]
recommend_onchain: bool
onchain_reason: str | None
confidence: str
collective_available: bool
record_in_observatory: bool
reasons: list[str]
@dataclass
class AIFrameRequest:
within_mandate: bool = False
affected_count: int | None = None
is_structural: bool = False
context: str | None = None
messages: list[AIMessage] | None = None
def __post_init__(self):
if self.messages is None:
self.messages = []
@dataclass
class AIFrameResponse:
done: bool
questions: list[AIQuestion]
result: AIQualifyResult | None
explanation: str | None
# ---------------------------------------------------------------------------
# Standard clarifying questions
# ---------------------------------------------------------------------------
_CLARIFYING_QUESTIONS: list[AIQuestion] = [
AIQuestion(
id="reversibility",
text="Si cette décision s'avère inappropriée dans 6 mois, peut-on facilement revenir en arrière ?",
options=[
"Oui, facilement",
"Difficilement",
"Non, c'est irréversible",
],
),
AIQuestion(
id="urgency",
text="Y a-t-il une contrainte temporelle sur cette décision ?",
options=[
"Urgente (< 1 semaine)",
"Délai raisonnable (quelques semaines)",
"Pas d'urgence",
],
),
]
# ---------------------------------------------------------------------------
# Core function
# ---------------------------------------------------------------------------
def ai_frame(request: AIFrameRequest) -> AIFrameResponse:
"""Run one round of AI framing.
Round 1 (messages=[]) → return 2 clarifying questions, done=False
Round 2 (messages set) → parse answers, qualify, return result, done=True
"""
messages = request.messages or []
if not messages:
return AIFrameResponse(
done=False,
questions=list(_CLARIFYING_QUESTIONS),
result=None,
explanation=None,
)
answers = _parse_answers(messages)
result = _build_result(request, answers)
explanation = _build_explanation(answers)
return AIFrameResponse(
done=True,
questions=[],
result=result,
explanation=explanation,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_answers(messages: list[AIMessage]) -> dict[str, str]:
"""Extract question answers from the last user message.
Expected format: "reversibility:<answer>|urgency:<answer>"
"""
answers: dict[str, str] = {}
for msg in reversed(messages):
if msg.role == "user" and "|" in msg.content and ":" in msg.content:
for part in msg.content.split("|"):
if ":" in part:
key, _, val = part.partition(":")
answers[key.strip()] = val.strip()
break
return answers
def _build_result(request: AIFrameRequest, answers: dict[str, str]) -> AIQualifyResult:
"""Produce a qualification result enriched by the AI answers."""
from app.engine.qualifier import (
QualificationConfig,
QualificationInput,
qualify,
)
config = QualificationConfig()
inp = QualificationInput(
within_mandate=request.within_mandate,
affected_count=request.affected_count,
is_structural=request.is_structural,
context_description=request.context,
)
base = qualify(inp, config)
reasons = list(base.reasons)
reversibility = answers.get("reversibility", "")
if "irréversible" in reversibility.lower():
reasons.append("Décision irréversible : consensus élevé recommandé.")
urgency = answers.get("urgency", "")
if "urgente" in urgency.lower() or "< 1" in urgency:
reasons.append("Urgence signalée : privilégier un protocole à délai court.")
return AIQualifyResult(
decision_type=base.decision_type.value,
process=base.process,
recommended_modalities=base.recommended_modalities,
recommend_onchain=base.recommend_onchain,
onchain_reason=base.onchain_reason,
confidence=base.confidence,
collective_available=base.collective_available,
record_in_observatory=base.record_in_observatory,
reasons=reasons,
)
def _build_explanation(answers: dict[str, str]) -> str:
parts = []
rev = answers.get("reversibility", "")
urg = answers.get("urgency", "")
if rev:
parts.append(f"Réversibilité : {rev}.")
if urg:
parts.append(f"Urgence : {urg}.")
return " ".join(parts) if parts else "Qualification basée sur les éléments fournis."
+2 -2
View File
@@ -1,6 +1,6 @@
"""Sanctuary service: immutable archival to IPFS + on-chain hash.
The sanctuary is the immutable layer of Glibredecision. Every adopted
The sanctuary is the immutable layer of libreDecision. Every adopted
document version, decision result, or vote tally is hashed (SHA-256),
stored on IPFS, and anchored on-chain via system.remark.
"""
@@ -241,7 +241,7 @@ async def _anchor_on_chain(content_hash: str) -> tuple[str | None, int | None]:
call = substrate.compose_call(
call_module="System",
call_function="remark",
call_params={"remark": f"glibredecision:sanctuary:{content_hash}"},
call_params={"remark": f"libredecision:sanctuary:{content_hash}"},
)
extrinsic = substrate.create_signed_extrinsic(call=call, keypair=keypair)
receipt = substrate.submit_extrinsic(extrinsic, wait_for_inclusion=True)
+405
View File
@@ -0,0 +1,405 @@
"""Integration tests for mandate flows: nomination, lifecycle, assignment, revocation.
Uses a real in-memory SQLite database — no mocks of the DB layer.
Tests the service functions directly to verify interconnected business logic.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import selectinload
from sqlalchemy import select
import app.models # noqa: F401 — registers all models with Base.metadata
from app.database import Base
from app.models.mandate import Mandate, MandateStep
from app.models.user import DuniterIdentity
from app.services.mandate_service import (
advance_mandate,
assign_mandatee,
revoke_mandate,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def engine():
eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield eng
await eng.dispose()
@pytest_asyncio.fixture
async def db(engine):
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
yield session
async def _mk_identity(db: AsyncSession, display_name: str = "Alice") -> DuniterIdentity:
ident = DuniterIdentity(
id=uuid.uuid4(),
address=f"5{uuid.uuid4().hex[:46]}",
display_name=display_name,
wot_status="member",
is_smith=False,
is_techcomm=False,
)
db.add(ident)
await db.commit()
await db.refresh(ident)
return ident
async def _mk_mandate(
db: AsyncSession,
*,
mandatee_id: uuid.UUID | None = None,
origin_id: uuid.UUID | None = None,
status: str = "draft",
steps: list[dict] | None = None,
) -> Mandate:
mandate = Mandate(
id=uuid.uuid4(),
title="Mandat test",
mandate_type="functional",
status=status,
mandatee_id=mandatee_id,
origin_id=origin_id,
)
db.add(mandate)
await db.flush()
for i, s in enumerate(steps or []):
step = MandateStep(
id=uuid.uuid4(),
mandate_id=mandate.id,
step_order=i,
step_type=s.get("step_type", "formulation"),
title=s.get("title"),
status=s.get("status", "pending"),
)
db.add(step)
await db.commit()
await db.refresh(mandate)
return mandate
async def _reload(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
result = await db.execute(
select(Mandate)
.options(
selectinload(Mandate.steps),
selectinload(Mandate.origin_identity),
selectinload(Mandate.mandatee),
)
.where(Mandate.id == mandate_id)
)
return result.scalar_one()
# ---------------------------------------------------------------------------
# TestMandateOrigin
# ---------------------------------------------------------------------------
class TestMandateOrigin:
"""origin_id must link to a real DuniterIdentity and expose display_name."""
@pytest.mark.asyncio
async def test_origin_id_linked(self, db: AsyncSession):
author = await _mk_identity(db, "Baptiste")
mandate = await _mk_mandate(db, origin_id=author.id)
loaded = await _reload(db, mandate.id)
assert loaded.origin_id == author.id
assert loaded.origin_display_name == "Baptiste"
@pytest.mark.asyncio
async def test_origin_id_optional(self, db: AsyncSession):
mandate = await _mk_mandate(db)
loaded = await _reload(db, mandate.id)
assert loaded.origin_id is None
assert loaded.origin_display_name is None
# ---------------------------------------------------------------------------
# TestAutoNomination
# ---------------------------------------------------------------------------
class TestAutoNomination:
"""Auto-désignation: mandatee = author, no candidacy steps needed."""
@pytest.mark.asyncio
async def test_auto_assign_author(self, db: AsyncSession):
author = await _mk_identity(db, "Constance")
mandate = await _mk_mandate(db, mandatee_id=author.id)
loaded = await _reload(db, mandate.id)
assert loaded.mandatee_id == author.id
assert loaded.mandatee_display_name == "Constance"
@pytest.mark.asyncio
async def test_auto_assign_then_advance_to_active(self, db: AsyncSession):
author = await _mk_identity(db, "David")
mandate = await _mk_mandate(
db,
mandatee_id=author.id,
steps=[
{"step_type": "formulation", "status": "pending"},
{"step_type": "assignment", "status": "pending"},
{"step_type": "reporting", "status": "pending"},
],
)
# First advance: draft → candidacy, activates step 0
result = await advance_mandate(mandate.id, db)
assert result.status == "candidacy"
loaded = await _reload(db, mandate.id)
assert loaded.steps[0].status == "active"
assert loaded.steps[1].status == "pending"
# Second advance: step 0 → completed, step 1 → active
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.steps[0].status == "completed"
assert loaded.steps[1].status == "active"
# ---------------------------------------------------------------------------
# TestMandateAssign
# ---------------------------------------------------------------------------
class TestMandateAssign:
"""assign_mandatee service: proper UUID lookup, display_name populated."""
@pytest.mark.asyncio
async def test_assign_sets_mandatee_and_starts_at(self, db: AsyncSession):
identity = await _mk_identity(db, "Elodie")
mandate = await _mk_mandate(db, status="active")
await assign_mandatee(mandate.id, identity.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.mandatee_id == identity.id
assert loaded.starts_at is not None
assert loaded.mandatee_display_name == "Elodie"
@pytest.mark.asyncio
async def test_assign_unknown_identity_raises(self, db: AsyncSession):
mandate = await _mk_mandate(db, status="active")
with pytest.raises(ValueError, match="Identite Duniter introuvable"):
await assign_mandatee(mandate.id, uuid.uuid4(), db)
@pytest.mark.asyncio
async def test_assign_completed_mandate_raises(self, db: AsyncSession):
identity = await _mk_identity(db, "Fabien")
mandate = await _mk_mandate(db, status="completed")
with pytest.raises(ValueError, match="statut terminal"):
await assign_mandatee(mandate.id, identity.id, db)
@pytest.mark.asyncio
async def test_reassign_replaces_mandatee(self, db: AsyncSession):
first = await _mk_identity(db, "Gilles")
second = await _mk_identity(db, "Hélène")
mandate = await _mk_mandate(db, mandatee_id=first.id, status="active")
await assign_mandatee(mandate.id, second.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.mandatee_id == second.id
# ---------------------------------------------------------------------------
# TestMandateLifecycle
# ---------------------------------------------------------------------------
class TestMandateLifecycle:
"""advance_mandate: full lifecycle with and without steps."""
@pytest.mark.asyncio
async def test_full_lifecycle_no_steps(self, db: AsyncSession):
mandate = await _mk_mandate(db)
statuses = [mandate.status]
for _ in range(10):
m = await advance_mandate(mandate.id, db)
statuses.append(m.status)
if m.status == "completed":
break
assert statuses == ["draft", "candidacy", "voting", "active", "reporting", "completed"]
@pytest.mark.asyncio
async def test_steps_activate_in_order(self, db: AsyncSession):
mandate = await _mk_mandate(
db,
steps=[
{"step_type": "formulation", "status": "pending"},
{"step_type": "candidacy", "status": "pending"},
{"step_type": "vote", "status": "pending"},
],
)
# Advance 1: activates step 0, moves to candidacy
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.status == "candidacy"
assert loaded.steps[0].status == "active"
assert loaded.steps[1].status == "pending"
# Advance 2: step 0 → completed, step 1 → active
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.steps[0].status == "completed"
assert loaded.steps[1].status == "active"
# Advance 3: step 1 → completed, step 2 → active
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.steps[1].status == "completed"
assert loaded.steps[2].status == "active"
# Advance 4: step 2 → completed, no more pending → advance mandate status
await advance_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.steps[2].status == "completed"
assert loaded.status == "voting"
@pytest.mark.asyncio
async def test_advance_terminal_raises(self, db: AsyncSession):
for terminal in ("completed", "revoked"):
mandate = await _mk_mandate(db, status=terminal)
with pytest.raises(ValueError, match="statut terminal"):
await advance_mandate(mandate.id, db)
# ---------------------------------------------------------------------------
# TestMandateRevocation
# ---------------------------------------------------------------------------
class TestMandateRevocation:
"""revoke_mandate: active/pending steps cancelled, completed steps preserved."""
@pytest.mark.asyncio
async def test_revoke_cancels_active_and_pending(self, db: AsyncSession):
mandate = await _mk_mandate(
db,
status="active",
steps=[
{"step_type": "formulation", "status": "completed"},
{"step_type": "assignment", "status": "active"},
{"step_type": "reporting", "status": "pending"},
],
)
await revoke_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.status == "revoked"
assert loaded.ends_at is not None
assert loaded.steps[0].status == "completed"
assert loaded.steps[1].status == "cancelled"
assert loaded.steps[2].status == "cancelled"
@pytest.mark.asyncio
async def test_revoke_sets_ends_at(self, db: AsyncSession):
mandate = await _mk_mandate(db, status="draft")
await revoke_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.ends_at is not None
@pytest.mark.asyncio
async def test_revoke_already_revoked_raises(self, db: AsyncSession):
mandate = await _mk_mandate(db, status="revoked")
with pytest.raises(ValueError, match="statut terminal"):
await revoke_mandate(mandate.id, db)
# ---------------------------------------------------------------------------
# TestNominationInteractions
# ---------------------------------------------------------------------------
class TestNominationInteractions:
"""Cross-process: nomination + assignment + lifecycle are consistent."""
@pytest.mark.asyncio
async def test_assign_then_advance_full_cycle(self, db: AsyncSession):
"""Assigning a mandatee then running the full lifecycle completes cleanly."""
mandatee = await _mk_identity(db, "Isabelle")
mandate = await _mk_mandate(
db,
steps=[
{"step_type": "formulation", "status": "pending"},
{"step_type": "assignment", "status": "pending"},
{"step_type": "completion", "status": "pending"},
],
)
# Assign before starting
await assign_mandatee(mandate.id, mandatee.id, db)
# Run through all steps
for _ in range(5):
m = await advance_mandate(mandate.id, db)
if m.status in ("completed", "revoked"):
break
loaded = await _reload(db, mandate.id)
# All steps completed, mandate advanced beyond steps
assert all(s.status == "completed" for s in loaded.steps)
assert loaded.mandatee_id == mandatee.id
@pytest.mark.asyncio
async def test_revoke_after_assign_preserves_mandatee_id(self, db: AsyncSession):
"""Revoking a mandate keeps mandatee_id (for audit trail)."""
mandatee = await _mk_identity(db, "Jacques")
mandate = await _mk_mandate(db, mandatee_id=mandatee.id, status="active")
await revoke_mandate(mandate.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.status == "revoked"
assert loaded.mandatee_id == mandatee.id
@pytest.mark.asyncio
async def test_origin_and_mandatee_can_differ(self, db: AsyncSession):
"""The person who proposed the mandate (origin) is different from the mandatee."""
proposer = await _mk_identity(db, "Kim")
mandatee = await _mk_identity(db, "Laurent")
mandate = await _mk_mandate(db, origin_id=proposer.id)
await assign_mandatee(mandate.id, mandatee.id, db)
loaded = await _reload(db, mandate.id)
assert loaded.origin_id == proposer.id
assert loaded.mandatee_id == mandatee.id
assert loaded.origin_display_name == "Kim"
assert loaded.mandatee_display_name == "Laurent"
+185
View File
@@ -0,0 +1,185 @@
"""Tests for middleware stack: CORS headers, rate limiting, dev auth flow.
Critical invariants:
- ALL responses (including 429) must carry CORS headers when origin is allowed
- Dev login flow must survive repeated logins without hitting rate limit
- OPTIONS preflight must never be rate-limited
Note: each test uses a unique X-Forwarded-For IP to isolate rate limit counters,
since the rate limiter is in-memory and shared across the app instance.
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
import app.models # noqa: F401 — registers all models with Base.metadata before create_all
from app.database import init_db
from app.main import app
@pytest.fixture(scope="module", autouse=True)
async def _create_tables():
"""Create DB tables once for this module.
ASGITransport does not trigger the FastAPI lifespan, so init_db() would
never run. Tests that hit endpoints backed by the DB need the tables to
exist beforehand.
"""
await init_db()
ORIGIN = "http://localhost:3002"
CHALLENGE_URL = "/api/v1/auth/challenge"
VERIFY_URL = "/api/v1/auth/verify"
ME_URL = "/api/v1/auth/me"
DEV_ADDRESS = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
# ---------------------------------------------------------------------------
# INVARIANT 1: 429 responses must include CORS headers
# Without this, the browser sees "Failed to fetch" instead of "Too Many Requests"
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_rate_limited_response_has_cors_headers():
"""A 429 from the rate limiter must still carry Access-Control-Allow-Origin.
Root cause of the "no response / Failed to fetch" bug: the rate limiter
sits outside CORS in the middleware stack, so its 429 responses have no
CORS headers and the browser discards them as network errors.
"""
# dev auth limit = 60/min (RATE_LIMIT_DEFAULT), prod = 10/min (RATE_LIMIT_AUTH)
# Send 65 requests to guarantee 429 regardless of environment.
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
last_response = None
for _ in range(65):
resp = await client.post(
CHALLENGE_URL,
json={"address": DEV_ADDRESS},
headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.1.1"},
)
last_response = resp
if resp.status_code == 429:
break
assert last_response is not None
assert last_response.status_code == 429, (
"Expected 429 after exceeding auth rate limit"
)
assert "access-control-allow-origin" in last_response.headers, (
"429 response must include CORS headers so the browser can read the error"
)
assert last_response.headers["access-control-allow-origin"] == ORIGIN
# ---------------------------------------------------------------------------
# INVARIANT 2: OPTIONS preflight must never be rate-limited
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_options_preflight_never_rate_limited():
"""OPTIONS requests must pass through regardless of request count.
Browsers send a preflight before every cross-origin POST with custom headers.
A 429 on OPTIONS prevents the real request from ever being sent.
"""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
for i in range(20):
resp = await client.options(
CHALLENGE_URL,
headers={
"Origin": ORIGIN,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "content-type",
"X-Forwarded-For": "10.0.2.1",
},
)
assert resp.status_code != 429, (
f"OPTIONS request #{i + 1} was rate-limited (429) — preflights must never be blocked"
)
# ---------------------------------------------------------------------------
# INVARIANT 3: Dev login flow must survive ≥ 10 consecutive logins
# (challenge + verify cycle, dev profile bypass)
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_dev_login_survives_repeated_cycles():
"""Complete login cycle (challenge → verify) must work ≥ 10 times in a row.
In dev mode, the developer disconnects and reconnects frequently.
With auth rate limit = 10/min, the 6th challenge request would be blocked.
Dev mode must use a higher limit ( 60/min) to prevent login lockout.
"""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
tokens = []
for i in range(10):
# Step 1: get challenge
ch_resp = await client.post(
CHALLENGE_URL,
json={"address": DEV_ADDRESS},
headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.3.1"},
)
assert ch_resp.status_code == 200, (
f"Login cycle #{i + 1}: challenge returned {ch_resp.status_code}"
f"rate limit likely hit. Dev mode requires RATE_LIMIT_AUTH ≥ 60/min."
)
challenge = ch_resp.json()["challenge"]
# Step 2: verify (dev bypass — any signature accepted for dev addresses)
v_resp = await client.post(
VERIFY_URL,
json={
"address": DEV_ADDRESS,
"challenge": challenge,
"signature": "0x" + "ab" * 64,
},
headers={"Origin": ORIGIN, "X-Forwarded-For": "10.0.3.1"},
)
assert v_resp.status_code == 200, (
f"Login cycle #{i + 1}: verify returned {v_resp.status_code}"
)
tokens.append(v_resp.json()["token"])
assert len(tokens) == 10, "Expected 10 successful login cycles"
assert len(set(tokens)) == 10, "Each login cycle must produce a unique token"
# ---------------------------------------------------------------------------
# INVARIANT 4: /auth/me OPTIONS preflight must return CORS headers
# ---------------------------------------------------------------------------
@pytest.mark.anyio
async def test_auth_me_options_preflight_returns_cors():
"""OPTIONS preflight for /auth/me must return 200 with CORS headers.
This was the root cause of the session-lost-on-reload bug:
repeated /auth/me calls would exhaust the auth rate limit,
the 429 OPTIONS response had no CORS headers,
and the browser threw 'Failed to fetch'.
"""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.options(
ME_URL,
headers={
"Origin": ORIGIN,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "authorization",
"X-Forwarded-For": "10.0.4.1",
},
)
assert resp.status_code in (200, 204), (
f"OPTIONS /auth/me returned {resp.status_code}"
)
assert "access-control-allow-origin" in resp.headers
+270
View File
@@ -0,0 +1,270 @@
"""TDD — Moteur de qualification des décisions.
Source de vérité exécutable des règles métier du tunnel "Décider".
Règles testées :
R1 within_mandate individual + consultation_avis
R2 within_mandate aucune modalité de vote + consignation Observatoire
R4 2 affected_count small_group_max individual recommandé, collectif disponible
R5 small_group_max < affected_count collective_wot_min collective recommandé
R6 affected_count > collective_wot_min collective recommandé (WoT applicable, non obligatoire)
R7 is_structural recommend_onchain + raison explicite
R8 is_structural=False recommend_onchain=False
GARDE-FOUS (invariants internes qui ne doivent jamais régresser) :
G1 decision_type est toujours dans l'enum autorisé
G2 individual n'expose jamais de modalités de vote
G3 collective expose au moins une modalité
G4 les seuils sont lus depuis QualificationConfig (configurables)
"""
from __future__ import annotations
import pytest
from app.engine.qualifier import (
DecisionType,
QualificationConfig,
QualificationInput,
QualificationResult,
qualify,
)
DEFAULT_CONFIG = QualificationConfig()
# ---------------------------------------------------------------------------
# R1 — within_mandate → individual + consultation_avis
# ---------------------------------------------------------------------------
def test_r1_within_mandate_gives_individual():
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.decision_type == DecisionType.INDIVIDUAL
def test_r1_within_mandate_gives_consultation_avis():
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.process == "consultation_avis"
def test_r1_within_mandate_overrides_large_affected_count():
"""Même si de nombreuses personnes sont concernées, un mandat impose individual."""
result = qualify(
QualificationInput(within_mandate=True, affected_count=500),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.INDIVIDUAL
assert result.process == "consultation_avis"
# ---------------------------------------------------------------------------
# R2 — within_mandate → aucune modalité de vote + consignation Observatoire
# ---------------------------------------------------------------------------
def test_r2_within_mandate_no_vote_modalities():
"""Le mandataire décide seul après consultation — pas de vote collectif."""
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.recommended_modalities == []
def test_r2_within_mandate_records_in_observatory():
"""Une décision dans un mandat doit être consignée dans l'Observatoire."""
result = qualify(QualificationInput(within_mandate=True), DEFAULT_CONFIG)
assert result.record_in_observatory is True
def test_r2_out_of_mandate_does_not_force_observatory():
"""Hors mandat, la consignation dans l'Observatoire n'est pas imposée."""
result = qualify(
QualificationInput(within_mandate=False, affected_count=10),
DEFAULT_CONFIG,
)
assert result.record_in_observatory is False
# ---------------------------------------------------------------------------
# R4 — 2 ≤ affected_count ≤ small_group_max → individual recommandé
# ---------------------------------------------------------------------------
def test_r4_small_group_recommends_individual():
for count in range(2, DEFAULT_CONFIG.small_group_max + 1):
result = qualify(
QualificationInput(within_mandate=False, affected_count=count),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.INDIVIDUAL, (
f"affected_count={count} devrait recommander individual"
)
def test_r4_small_group_collective_is_available():
result = qualify(
QualificationInput(within_mandate=False, affected_count=3),
DEFAULT_CONFIG,
)
assert result.collective_available is True
def test_r4_small_group_confidence_is_recommended():
result = qualify(
QualificationInput(within_mandate=False, affected_count=3),
DEFAULT_CONFIG,
)
assert result.confidence == "recommended"
# ---------------------------------------------------------------------------
# R5 — small_group_max < affected_count ≤ collective_wot_min → collective recommandé
# ---------------------------------------------------------------------------
def test_r5_medium_group_recommends_collective():
mid = DEFAULT_CONFIG.small_group_max + 1
result = qualify(
QualificationInput(within_mandate=False, affected_count=mid),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.COLLECTIVE
def test_r5_medium_group_confidence_is_recommended():
mid = DEFAULT_CONFIG.small_group_max + 1
result = qualify(
QualificationInput(within_mandate=False, affected_count=mid),
DEFAULT_CONFIG,
)
assert result.confidence == "recommended"
# ---------------------------------------------------------------------------
# R6 — affected_count > collective_wot_min → collective recommandé (pas obligatoire)
# ---------------------------------------------------------------------------
def test_r6_large_group_recommends_collective():
result = qualify(
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.COLLECTIVE
def test_r6_large_group_confidence_is_recommended_not_required():
"""Au-delà du seuil WoT, le vote collectif est recommandé — pas imposé."""
result = qualify(
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
DEFAULT_CONFIG,
)
assert result.confidence == "recommended"
def test_r6_large_group_includes_vote_wot_modality():
result = qualify(
QualificationInput(within_mandate=False, affected_count=DEFAULT_CONFIG.collective_wot_min + 1),
DEFAULT_CONFIG,
)
assert "vote_wot" in result.recommended_modalities
# ---------------------------------------------------------------------------
# R7 — is_structural=True → recommend_onchain + raison
# ---------------------------------------------------------------------------
def test_r7_structural_recommends_onchain():
result = qualify(
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
DEFAULT_CONFIG,
)
assert result.recommend_onchain is True
def test_r7_structural_provides_onchain_reason():
result = qualify(
QualificationInput(within_mandate=False, affected_count=100, is_structural=True),
DEFAULT_CONFIG,
)
assert result.onchain_reason is not None and len(result.onchain_reason) > 0
def test_r7_structural_within_mandate_can_also_recommend_onchain():
"""Même une décision dans un mandat peut être gravée si structurante."""
result = qualify(
QualificationInput(within_mandate=True, is_structural=True),
DEFAULT_CONFIG,
)
assert result.recommend_onchain is True
# ---------------------------------------------------------------------------
# R8 — is_structural=False → recommend_onchain=False
# ---------------------------------------------------------------------------
def test_r8_non_structural_never_recommends_onchain():
for count in [2, 3, 10, 100]:
result = qualify(
QualificationInput(within_mandate=False, affected_count=count, is_structural=False),
DEFAULT_CONFIG,
)
assert result.recommend_onchain is False, (
f"affected_count={count} non structurant : on-chain ne doit pas être proposé"
)
# ---------------------------------------------------------------------------
# GARDE-FOUS internes (régressions silencieuses)
# ---------------------------------------------------------------------------
def test_g1_decision_type_always_valid():
valid_types = set(DecisionType)
for inp in [
QualificationInput(within_mandate=True),
QualificationInput(within_mandate=False, affected_count=2),
QualificationInput(within_mandate=False, affected_count=10),
QualificationInput(within_mandate=False, affected_count=100),
]:
result = qualify(inp, DEFAULT_CONFIG)
assert result.decision_type in valid_types
def test_g2_individual_never_has_vote_modalities():
for inp in [
QualificationInput(within_mandate=True),
QualificationInput(within_mandate=False, affected_count=2),
QualificationInput(within_mandate=False, affected_count=3),
]:
result = qualify(inp, DEFAULT_CONFIG)
if result.decision_type == DecisionType.INDIVIDUAL:
assert result.recommended_modalities == [], (
f"Individual ne doit pas exposer de modalités : {inp}"
)
def test_g3_collective_has_at_least_one_modality():
result = qualify(
QualificationInput(within_mandate=False, affected_count=20),
DEFAULT_CONFIG,
)
assert result.decision_type == DecisionType.COLLECTIVE
assert len(result.recommended_modalities) >= 1
def test_g4_custom_config_overrides_thresholds():
"""Les seuils viennent de QualificationConfig — pas de constantes hardcodées."""
custom = QualificationConfig(small_group_max=2, collective_wot_min=10)
result = qualify(
QualificationInput(within_mandate=False, affected_count=3),
custom,
)
assert result.decision_type == DecisionType.COLLECTIVE
def test_g4_default_thresholds_are_stable():
cfg = QualificationConfig()
assert cfg.small_group_max == 5
assert cfg.collective_wot_min == 50
+172
View File
@@ -0,0 +1,172 @@
"""TDD — Service AI de cadrage des décisions (qualify/ai-chat).
Invariants testés :
A1 Premier appel (messages=[]) retourne toujours 2 questions, done=False
A2 Les 2 questions couvrent réversibilité et urgence (ids stables)
A3 Deuxième appel (messages=[q+réponse]) done=True, résultat qualifié
A4 Réponse "irréversible" recommend_onchain conservé si is_structural
A5 Réponse "urgente" raison "urgence" présente dans le résultat
A6 La qualification finale respecte les règles du moteur (R1/R2/R4/R5/R6)
A7 Sans contexte, les questions restent les mêmes (stub ne dépend pas du LLM)
"""
from __future__ import annotations
import pytest
from app.services.qualify_ai_service import (
AIFrameRequest,
AIMessage,
ai_frame,
)
DEFAULT_REQUEST = AIFrameRequest(
context="Révision du règlement intérieur de l'association",
within_mandate=False,
affected_count=20,
is_structural=False,
messages=[],
)
# ---------------------------------------------------------------------------
# A1 — Premier appel → 2 questions, done=False
# ---------------------------------------------------------------------------
def test_a1_first_call_returns_questions():
resp = ai_frame(DEFAULT_REQUEST)
assert resp.done is False
assert len(resp.questions) == 2
def test_a1_first_call_result_is_none():
resp = ai_frame(DEFAULT_REQUEST)
assert resp.result is None
# ---------------------------------------------------------------------------
# A2 — Questions couvrent réversibilité et urgence
# ---------------------------------------------------------------------------
def test_a2_questions_have_stable_ids():
resp = ai_frame(DEFAULT_REQUEST)
ids = {q.id for q in resp.questions}
assert "reversibility" in ids
assert "urgency" in ids
def test_a2_questions_have_options():
resp = ai_frame(DEFAULT_REQUEST)
for q in resp.questions:
assert len(q.options) >= 2, f"Question '{q.id}' doit avoir au moins 2 options"
# ---------------------------------------------------------------------------
# A3 — Deuxième appel (avec réponses) → done=True + résultat
# ---------------------------------------------------------------------------
def _make_second_request(reversibility_ans: str, urgency_ans: str, **kwargs) -> AIFrameRequest:
questions = ai_frame(DEFAULT_REQUEST).questions
messages = []
for q in questions:
messages.append(AIMessage(role="assistant", content=q.text))
# One user message bundling all answers
messages.append(AIMessage(
role="user",
content=f"reversibility:{reversibility_ans}|urgency:{urgency_ans}",
))
return AIFrameRequest(
**{**vars(DEFAULT_REQUEST), "messages": messages, **kwargs}
)
def test_a3_second_call_is_done():
req = _make_second_request("Difficilement", "Pas d'urgence")
resp = ai_frame(req)
assert resp.done is True
def test_a3_second_call_has_result():
req = _make_second_request("Difficilement", "Pas d'urgence")
resp = ai_frame(req)
assert resp.result is not None
assert resp.result.decision_type in ("individual", "collective")
# ---------------------------------------------------------------------------
# A4 — Irréversible + structurant → recommend_onchain
# ---------------------------------------------------------------------------
def test_a4_irreversible_structural_recommends_onchain():
req = _make_second_request(
"Non, c'est irréversible",
"Pas d'urgence",
is_structural=True,
)
resp = ai_frame(req)
assert resp.result is not None
assert resp.result.recommend_onchain is True
# ---------------------------------------------------------------------------
# A5 — Urgence → raison présente
# ---------------------------------------------------------------------------
def test_a5_urgent_adds_urgency_reason():
req = _make_second_request("Oui, facilement", "Urgente (< 1 semaine)")
resp = ai_frame(req)
assert resp.result is not None
reasons_text = " ".join(resp.result.reasons).lower()
assert "urgence" in reasons_text or "urgent" in reasons_text
# ---------------------------------------------------------------------------
# A6 — Résultat respecte les règles du moteur
# ---------------------------------------------------------------------------
def test_a6_within_mandate_gives_individual():
req = AIFrameRequest(
within_mandate=True,
affected_count=None,
messages=[
AIMessage(role="assistant", content="q"),
AIMessage(role="user", content="reversibility:Facilement|urgency:Pas d'urgence"),
],
)
resp = ai_frame(req)
assert resp.done is True
assert resp.result is not None
assert resp.result.decision_type == "individual"
assert resp.result.process == "consultation_avis"
def test_a6_large_group_gives_collective():
req = _make_second_request("Difficilement", "Pas d'urgence", affected_count=100)
resp = ai_frame(req)
assert resp.result is not None
assert resp.result.decision_type == "collective"
# ---------------------------------------------------------------------------
# A7 — Sans contexte, mêmes questions (stub ne dépend pas du LLM)
# ---------------------------------------------------------------------------
def test_a7_no_context_same_question_ids():
req_with = DEFAULT_REQUEST
req_without = AIFrameRequest(
context=None,
within_mandate=False,
affected_count=20,
messages=[],
)
ids_with = {q.id for q in ai_frame(req_with).questions}
ids_without = {q.id for q in ai_frame(req_without).questions}
assert ids_with == ids_without
File diff suppressed because it is too large Load Diff
+373 -14
View File
@@ -31,6 +31,9 @@ from app.database import async_session, engine, Base, init_db
from app.models.protocol import FormulaConfig, VotingProtocol
from app.models.document import Document, DocumentItem
from app.models.decision import Decision, DecisionStep
from app.models.mandate import Mandate, MandateStep
from app.models.organization import Organization
from app.models.qualification import QualificationProtocol
from app.models.user import DuniterIdentity
from app.models.vote import VoteSession, Vote
@@ -160,6 +163,7 @@ async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig
async def seed_voting_protocols(
session: AsyncSession,
formulas: dict[str, FormulaConfig],
org_id: uuid.UUID | None = None,
) -> dict[str, VotingProtocol]:
protocols: dict[str, dict] = {
"Vote WoT standard": {
@@ -205,6 +209,7 @@ async def seed_voting_protocols(
instance, created = await get_or_create(
session, VotingProtocol, "name", name, **params,
)
instance.organization_id = org_id
status = "created" if created else "exists"
print(f" VotingProtocol '{name}': {status}")
result[name] = instance
@@ -828,6 +833,7 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
async def seed_document_engagement_certification(
session: AsyncSession,
protocols: dict[str, VotingProtocol],
org_id: uuid.UUID | None = None,
) -> Document:
genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2)
@@ -849,6 +855,7 @@ async def seed_document_engagement_certification(
),
genesis_json=genesis,
)
doc.organization_id = org_id
print(f" Document 'Acte d'engagement Certification': {'created' if created else 'exists'}")
if created:
@@ -1892,6 +1899,7 @@ ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
async def seed_document_engagement_forgeron(
session: AsyncSession,
protocols: dict[str, VotingProtocol],
org_id: uuid.UUID | None = None,
) -> Document:
genesis = json.dumps(GENESIS_FORGERON, ensure_ascii=False, indent=2)
@@ -1915,6 +1923,7 @@ async def seed_document_engagement_forgeron(
),
genesis_json=genesis,
)
doc.organization_id = org_id
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
if created:
@@ -1987,7 +1996,7 @@ RUNTIME_UPGRADE_STEPS: list[dict] = [
]
async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
async def seed_decision_runtime_upgrade(session: AsyncSession, org_id: uuid.UUID | None = None) -> Decision:
decision, created = await get_or_create(
session,
Decision,
@@ -2008,6 +2017,7 @@ async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
decision_type="runtime_upgrade",
status="draft",
)
decision.organization_id = org_id
print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}")
if created:
@@ -2143,40 +2153,384 @@ async def seed_votes_on_items(
print(f" VoteSession for '{item.title}': created (10 pour, 1 contre)")
# ---------------------------------------------------------------------------
# Seed: Additional decisions (demo content)
# ---------------------------------------------------------------------------
async def seed_decision_licence_evolution(session: AsyncSession, org_id: uuid.UUID | None = None) -> Decision:
"""Seed a community decision: evolution of the G1 monetary license."""
decision, created = await get_or_create(
session,
Decision,
"title",
"Évolution Licence G1 v0.4.0",
description=(
"Proposition d'évolution de la Licence G1 vers la version 0.4.0. "
"Intègre les retours du forum, clarifie les engagements de certification "
"et précise le processus de vote nuancé."
),
context=(
"La Licence G1 v0.3.0 est en vigueur depuis l'origine de la monnaie libre. "
"Des discussions communautaires approfondies (topics 31066, 32375, 32409, 32412) "
"ont permis d'identifier des clarifications nécessaires. "
"Cette décision lance le processus de vote communautaire pour l'adoption "
"de la v0.4.0 selon le protocole Vote WoT standard."
),
decision_type="community",
status="draft",
)
decision.organization_id = org_id
print(f" Decision 'Évolution Licence G1 v0.4.0': {'created' if created else 'exists'}")
if created:
steps = [
{
"step_order": 1,
"step_type": "qualification",
"title": "Rédaction de la proposition",
"description": (
"Co-rédaction de la v0.4.0 en intégrant les retours des discussions "
"forum. Coordination entre 1000i100, Natha, Pini et la communauté."
),
},
{
"step_order": 2,
"step_type": "review",
"title": "Période de commentaires",
"description": (
"Publication de la proposition sur le forum pendant 30 jours. "
"Recueil des amendements et objections. Intégration des retours "
"dans la version finale soumise au vote."
),
},
{
"step_order": 3,
"step_type": "vote",
"title": "Vote WoT (nuancé)",
"description": (
"Vote nuancé à 6 niveaux ouvert à tous les membres de la WoT. "
"Durée : 30 jours. Protocole : Vote WoT standard avec formule inertie."
),
},
{
"step_order": 4,
"step_type": "execution",
"title": "Mise à jour du dépôt officiel",
"description": (
"Si le seuil est atteint : mise à jour du dépôt git officiel, "
"calcul du hash IPFS, ancrage on-chain via system.remark."
),
},
]
for step_data in steps:
step = DecisionStep(decision_id=decision.id, **step_data)
session.add(step)
await session.flush()
print(f" -> {len(steps)} steps created")
return decision
# ---------------------------------------------------------------------------
# Seed: Mandates (Comité Technique + Admin Forgerons)
# ---------------------------------------------------------------------------
async def seed_mandates(session: AsyncSession, voters: list[DuniterIdentity], org_id: uuid.UUID | None = None) -> None:
"""Seed example mandates: TechComm and Smith Admin."""
now = datetime.now(timezone.utc)
# Find Charlie (techcomm voter) as mandatee, or use first voter
mandatee_techcomm = next(
(v for v in voters if "Cgeek" in v.display_name or "Elois" in v.display_name),
voters[0] if voters else None,
)
mandatee_smith = next(
(v for v in voters if "Moul" in v.display_name or "Tuxmain" in v.display_name),
voters[1] if len(voters) > 1 else None,
)
mandates_data = [
{
"title": "Mandat Comité Technique — Session 2025-2026",
"description": (
"Le Comité Technique (ComTech) est mandaté par la communauté pour "
"assurer la validation technique des propositions on-chain : "
"runtime upgrades, modifications de paramètres réseau, audits de code. "
"Composition : 5 membres élus pour 12 mois."
),
"mandate_type": "techcomm",
"status": "active",
"mandatee_id": mandatee_techcomm.id if mandatee_techcomm else None,
"starts_at": now - timedelta(days=90),
"ends_at": now + timedelta(days=275),
"steps": [
{
"step_order": 1,
"step_type": "candidacy",
"title": "Appel à candidatures",
"status": "completed",
"description": "Période de candidatures ouvertes sur le forum Duniter. Durée : 14 jours.",
"outcome": "5 candidats retenus : Elois, Cgeek, Maaltir, Hugo, Tuxmain",
},
{
"step_order": 2,
"step_type": "vote",
"title": "Élection par vote nuancé",
"status": "completed",
"description": "Vote WoT ouvert à tous les membres. Protocole Vote Nuancé (6 niveaux).",
"outcome": "Quorum atteint. 5 membres élus à plus de 70% de soutien.",
},
{
"step_order": 3,
"step_type": "assignment",
"title": "Prise de fonction",
"status": "completed",
"description": "Mise en place du ComTech, définition des processus internes de décision.",
"outcome": "ComTech opérationnel depuis le 2025-09-15.",
},
{
"step_order": 4,
"step_type": "reporting",
"title": "Rapport de mi-mandat",
"status": "active",
"description": "Rapport public d'activité à mi-parcours du mandat.",
},
{
"step_order": 5,
"step_type": "completion",
"title": "Fin de mandat et bilan",
"status": "pending",
"description": "Rapport final, transmission aux successeurs, renouvellement ou dissolution.",
},
],
},
{
"title": "Mandat Administrateur des Forgerons — Rotation 2026-Q1",
"description": (
"L'Administrateur des Forgerons coordonne l'onboarding des nouveaux "
"forgerons, maintient la liste des nœuds actifs et anime les "
"discussions techniques de la sous-WoT Smith. "
"Mandat tournant de 6 mois, renouvelable une fois."
),
"mandate_type": "smith",
"status": "voting",
"mandatee_id": mandatee_smith.id if mandatee_smith else None,
"starts_at": None,
"ends_at": None,
"steps": [
{
"step_order": 1,
"step_type": "formulation",
"title": "Définition du rôle",
"status": "completed",
"description": "Rédaction de la fiche de rôle et des responsabilités de l'Administrateur.",
"outcome": "Fiche de rôle validée par consensus sur le forum Duniter (topic 34201).",
},
{
"step_order": 2,
"step_type": "candidacy",
"title": "Appel à candidatures forgerons",
"status": "completed",
"description": "Candidatures ouvertes aux forgerons actifs depuis plus de 6 mois.",
"outcome": "2 candidats : Moul (Forgeron senior), Tuxmain (Forgeron actif).",
},
{
"step_order": 3,
"step_type": "vote",
"title": "Vote forgeron (Smith)",
"status": "active",
"description": (
"Vote Smith à double critère : quorum WoT + quorum forgerons. "
"Durée : 30 jours. En cours."
),
},
{
"step_order": 4,
"step_type": "assignment",
"title": "Prise de fonction",
"status": "pending",
"description": "Passation avec l'administrateur sortant. Accès aux outils de coordination.",
},
],
},
{
"title": "Mandat Modération Forum — Duniter V2",
"description": (
"Équipe de modération élue pour maintenir la qualité des discussions "
"sur forum.duniter.org et forum.monnaie-libre.fr. "
"3 modérateurs, mandat de 12 mois."
),
"mandate_type": "custom",
"status": "draft",
"mandatee_id": None,
"starts_at": None,
"ends_at": None,
"steps": [
{
"step_order": 1,
"step_type": "formulation",
"title": "Élaboration de la charte de modération",
"status": "active",
"description": "Définition des règles, outils et périmètre de la modération communautaire.",
},
{
"step_order": 2,
"step_type": "candidacy",
"title": "Appel à candidatures",
"status": "pending",
"description": "Ouvert à tous les membres de la WoT G1.",
},
{
"step_order": 3,
"step_type": "vote",
"title": "Élection",
"status": "pending",
"description": "Vote WoT standard.",
},
{
"step_order": 4,
"step_type": "assignment",
"title": "Prise de fonction",
"status": "pending",
"description": "Formation aux outils Discourse et mise en place de la rotation.",
},
],
},
]
for m_data in mandates_data:
steps_data = m_data.pop("steps")
mandate, created = await get_or_create(
session,
Mandate,
"title",
m_data["title"],
**{k: v for k, v in m_data.items() if k != "title"},
)
mandate.organization_id = org_id
status_str = "created" if created else "exists"
print(f" Mandate '{mandate.title[:50]}': {status_str}")
if created:
for step_data in steps_data:
step = MandateStep(mandate_id=mandate.id, **step_data)
session.add(step)
await session.flush()
print(f" -> {len(steps_data)} steps created")
# ---------------------------------------------------------------------------
# Seed: Organizations
# ---------------------------------------------------------------------------
async def seed_organizations(session: AsyncSession) -> dict[str, Organization]:
"""Create the two base transparent organizations (idempotent)."""
orgs_data = [
{
"slug": "duniter-g1",
"name": "Duniter G1",
"org_type": "community",
"is_transparent": True,
"color": "#22c55e",
"icon": "i-lucide-globe",
"description": "Communauté Duniter — monnaie libre G1. Accessible à tous les membres authentifiés.",
},
{
"slug": "axiom-team",
"name": "Axiom Team",
"org_type": "collective",
"is_transparent": True,
"color": "#3b82f6",
"icon": "i-lucide-users",
"description": "Équipe Axiom — développement et gouvernance des outils communs.",
},
]
orgs: dict[str, Organization] = {}
for data in orgs_data:
org, created = await get_or_create(session, Organization, "slug", data["slug"], **{k: v for k, v in data.items() if k != "slug"})
status_str = "created" if created else "exists"
print(f" Organisation '{org.name}': {status_str}")
orgs[org.slug] = org
return orgs
# ---------------------------------------------------------------------------
# Main seed runner
# ---------------------------------------------------------------------------
async def seed_qualification_protocol(session: AsyncSession) -> QualificationProtocol:
"""Seed the default qualification protocol (thresholds for the Décider tunnel)."""
stmt = select(QualificationProtocol).where(QualificationProtocol.is_active == True) # noqa: E712
result = await session.execute(stmt)
existing = result.scalar_one_or_none()
if existing is not None:
print(" [skip] Protocole de qualification déjà présent")
return existing
proto = QualificationProtocol(
name="Protocole de qualification par défaut",
description=(
"Seuils utilisés par le tunnel Décider pour router vers "
"individual/collective et proposer les modalités de vote."
),
small_group_max=5,
collective_wot_min=50,
default_modalities_json=json.dumps([
"vote_wot",
"vote_smith",
"consultation_avis",
"election",
]),
is_active=True,
)
session.add(proto)
await session.flush()
print(" [ok] Protocole de qualification créé")
return proto
async def run_seed():
print("=" * 60)
print("Glibredecision - Seed Database")
print("libreDecision - Seed Database")
print("=" * 60)
# Ensure tables exist
await init_db()
print("[0/8] Tables created.\n")
print("[0/10] Tables created.\n")
async with async_session() as session:
async with session.begin():
print("\n[1/8] Formula Configs...")
print("\n[0/10] Organizations...")
orgs = await seed_organizations(session)
duniter_g1_id = orgs["duniter-g1"].id
print("\n[0b] Protocole de qualification...")
await seed_qualification_protocol(session)
print("\n[1/10] Formula Configs...")
formulas = await seed_formula_configs(session)
print("\n[2/8] Voting Protocols...")
protocols = await seed_voting_protocols(session, formulas)
print("\n[2/10] Voting Protocols...")
protocols = await seed_voting_protocols(session, formulas, org_id=duniter_g1_id)
print("\n[3/8] Document: Acte d'engagement Certification...")
await seed_document_engagement_certification(session, protocols)
print("\n[3/10] Document: Acte d'engagement Certification...")
await seed_document_engagement_certification(session, protocols, org_id=duniter_g1_id)
print("\n[4/8] Document: Acte d'engagement forgeron v2.0.0...")
doc_forgeron = await seed_document_engagement_forgeron(session, protocols)
print("\n[4/10] Document: Acte d'engagement forgeron v2.0.0...")
doc_forgeron = await seed_document_engagement_forgeron(session, protocols, org_id=duniter_g1_id)
print("\n[5/7] Decision: Runtime Upgrade...")
await seed_decision_runtime_upgrade(session)
print("\n[5/10] Decision: Runtime Upgrade...")
await seed_decision_runtime_upgrade(session, org_id=duniter_g1_id)
print("\n[6/7] Simulated voters...")
print("\n[6/10] Decision: Évolution Licence G1 v0.4.0...")
await seed_decision_licence_evolution(session, org_id=duniter_g1_id)
print("\n[7/10] Simulated voters...")
voters = await seed_voters(session)
print("\n[7/7] Votes on first 3 engagements forgeron...")
print("\n[8/10] Votes on first 3 engagements forgeron...")
await seed_votes_on_items(
session,
doc_forgeron,
@@ -2184,6 +2538,11 @@ async def run_seed():
voters,
)
print("\n[9/10] Mandates...")
await seed_mandates(session, voters, org_id=duniter_g1_id)
print("\n[10/10] Done.")
print("\n" + "=" * 60)
print("Seed complete.")
print("=" * 60)
+1 -1
View File
@@ -41,4 +41,4 @@ COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
WORKDIR /app
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002", "--reload"]
+5 -5
View File
@@ -7,15 +7,15 @@ services:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-glibredecision}
POSTGRES_USER: ${POSTGRES_USER:-glibredecision}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-glibredecision-dev}
POSTGRES_DB: ${POSTGRES_DB:-libredecision}
POSTGRES_USER: ${POSTGRES_USER:-libredecision}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-libredecision-dev}
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-glibredecision}"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-libredecision}"]
interval: 5s
timeout: 3s
retries: 10
@@ -34,7 +34,7 @@ services:
ports:
- "8002:8002"
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-glibredecision}:${POSTGRES_PASSWORD:-glibredecision-dev}@postgres:5432/${POSTGRES_DB:-glibredecision}
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-libredecision}:${POSTGRES_PASSWORD:-libredecision-dev}@postgres:5432/${POSTGRES_DB:-libredecision}
SECRET_KEY: dev-secret-key-not-for-production
DEBUG: "true"
ENVIRONMENT: development
+38 -39
View File
@@ -1,85 +1,84 @@
version: "3.9"
name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
container_name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}-postgres
restart: always
environment:
POSTGRES_DB: ${POSTGRES_DB:-glibredecision}
POSTGRES_USER: ${POSTGRES_USER:-glibredecision}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-in-production}
POSTGRES_DB: ${POSTGRES_DB:-libredecision}
POSTGRES_USER: ${POSTGRES_USER:-libredecision}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-glibredecision} -d ${POSTGRES_DB:-glibredecision}"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-libredecision} -d ${POSTGRES_DB:-libredecision}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- glibredecision
- libredecision
# Pas de label SERVICE_* : postgres non exposé publiquement
backend:
build:
context: ../
dockerfile: docker/backend.Dockerfile
target: production
restart: unless-stopped
image: libredecision-backend:latest
container_name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}-backend
restart: always
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-glibredecision}:${POSTGRES_PASSWORD:-change-me-in-production}@postgres:5432/${POSTGRES_DB:-glibredecision}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production-with-a-real-secret-key}
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-libredecision}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-libredecision}
SECRET_KEY: ${SECRET_KEY}
ENVIRONMENT: production
DEBUG: "false"
CORS_ORIGINS: '["https://${DOMAIN:-glibredecision.org}"]'
DEMO_MODE: ${DEMO_MODE:-true}
CORS_ORIGINS: '["https://${APP_DOMAIN:-decision.librodrome.org}"]'
DUNITER_RPC_URL: ${DUNITER_RPC_URL:-wss://gdev.p2p.legal/ws}
IPFS_API_URL: http://ipfs:5001
IPFS_GATEWAY_URL: http://ipfs:8080
labels:
- "traefik.enable=true"
- "traefik.http.routers.glibredecision-api.rule=Host(`${DOMAIN:-glibredecision.org}`) && PathPrefix(`/api`)"
- "traefik.http.routers.glibredecision-api.entrypoints=websecure"
- "traefik.http.routers.glibredecision-api.tls.certresolver=letsencrypt"
- "traefik.http.services.glibredecision-api.loadbalancer.server.port=8002"
# Registrator enregistre dans Consul, Fabio route automatiquement
- SERVICE_8002_NAME=${COMPOSE_PROJECT_NAME:-ehv-decision-main}-backend-8002
- SERVICE_8002_TAGS=urlprefix-${APP_DOMAIN:-decision.librodrome.org}/api/*
# TCP : HTTP check échoue si le service redirige (301/302)
- SERVICE_8002_CHECK_TCP=true
networks:
- glibredecision
- traefik
- libredecision
- sonic
frontend:
build:
context: ../
dockerfile: docker/frontend.Dockerfile
target: production
restart: unless-stopped
image: libredecision-frontend:latest
container_name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}-frontend
restart: always
depends_on:
- backend
environment:
NUXT_PUBLIC_API_BASE: https://${DOMAIN:-glibredecision.org}/api/v1
NUXT_PUBLIC_API_BASE: https://${APP_DOMAIN:-decision.librodrome.org}/api/v1
labels:
- "traefik.enable=true"
- "traefik.http.routers.glibredecision-front.rule=Host(`${DOMAIN:-glibredecision.org}`)"
- "traefik.http.routers.glibredecision-front.entrypoints=websecure"
- "traefik.http.routers.glibredecision-front.tls.certresolver=letsencrypt"
- "traefik.http.services.glibredecision-front.loadbalancer.server.port=3000"
- SERVICE_3000_NAME=${COMPOSE_PROJECT_NAME:-ehv-decision-main}-frontend-3000
- SERVICE_3000_TAGS=urlprefix-${APP_DOMAIN:-decision.librodrome.org}/*
- SERVICE_3000_CHECK_TCP=true
networks:
- glibredecision
- traefik
- sonic
ipfs:
image: ipfs/kubo:latest
restart: unless-stopped
container_name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}-ipfs
restart: always
volumes:
- ipfs-data:/data/ipfs
networks:
- glibredecision
- libredecision
# Pas de label SERVICE_* : ipfs non exposé publiquement
volumes:
postgres-data:
ipfs-data:
networks:
glibredecision:
libredecision:
driver: bridge
traefik:
sonic:
external: true
+1 -1
View File
@@ -7,7 +7,7 @@ FROM node:${NODE_VERSION} AS base
WORKDIR /src
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
apt-get install -y --no-install-recommends curl git ca-certificates && \
rm -rf /var/lib/apt/lists/*
# ── Build ─────────────────────────────────────────────────────────────────────
+4 -4
View File
@@ -1,15 +1,15 @@
---
title: Documentation technique
description: Architecture, API et reference technique de Glibredecision
description: Architecture, API et reference technique de libreDecision
---
# Documentation technique
Bienvenue dans la documentation technique de Glibredecision, la plateforme de decisions collectives pour la communaute Duniter/G1.
Bienvenue dans la documentation technique de libreDecision, la plateforme de decisions collectives pour la communaute Duniter/G1.
## Presentation
Glibredecision est une plateforme de gouvernance decentralisee qui permet aux membres de la Toile de Confiance (WoT) Duniter V2 de gerer des documents de reference modulaires sous vote permanent, prendre des decisions collectives multi-etapes, attribuer des mandats et archiver de maniere immuable les resultats via IPFS et la blockchain Duniter.
libreDecision est une plateforme de gouvernance decentralisee qui permet aux membres de la Toile de Confiance (WoT) Duniter V2 de gerer des documents de reference modulaires sous vote permanent, prendre des decisions collectives multi-etapes, attribuer des mandats et archiver de maniere immuable les resultats via IPFS et la blockchain Duniter.
## Stack technique
@@ -37,7 +37,7 @@ Glibredecision est une plateforme de gouvernance decentralisee qui permet aux me
- **Version** : 1.0.0-rc
- **Statut** : Release candidate -- Sprint 5 (documentation et stabilisation)
- **Depot** : [git.duniter.org/tools/glibredecision](https://git.duniter.org/tools/glibredecision)
- **Depot** : [git.duniter.org/tools/libredecision](https://git.duniter.org/tools/libredecision)
## Sections
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,16 +1,16 @@
---
title: Architecture
description: Vue d'ensemble de l'architecture technique de Glibredecision
description: Vue d'ensemble de l'architecture technique de libreDecision
---
# Architecture
## Vue d'ensemble
Glibredecision est organise en monorepo avec trois composants principaux :
libreDecision est organise en monorepo avec trois composants principaux :
```
Glibredecision/
libreDecision/
backend/ # API Python FastAPI (port 8002)
frontend/ # Application Nuxt 4 (port 3002)
docker/ # Fichiers Docker et orchestration
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Reference API
description: Liste des endpoints de l'API Glibredecision
description: Liste des endpoints de l'API libreDecision
---
# Reference API
+1 -1
View File
@@ -5,7 +5,7 @@ description: Tables et relations de la base de donnees PostgreSQL
# Schema de base de donnees
Glibredecision utilise PostgreSQL 16 avec SQLAlchemy 2.0 en mode asynchrone (asyncpg). Toutes les cles primaires sont des UUID v4.
libreDecision utilise PostgreSQL 16 avec SQLAlchemy 2.0 en mode asynchrone (asyncpg). Toutes les cles primaires sont des UUID v4.
## Tables
+3 -3
View File
@@ -5,7 +5,7 @@ description: Formules mathematiques de seuil WoT, criteres Smith et TechComm, si
# Formules de seuil
Glibredecision utilise un systeme de formules mathematiques pour determiner les seuils d'adoption des votes. Le mecanisme central est la **formule d'inertie WoT** qui impose une quasi-unanimite en cas de faible participation et converge vers une majorite simple a participation elevee.
libreDecision utilise un systeme de formules mathematiques pour determiner les seuils d'adoption des votes. Le mecanisme central est la **formule d'inertie WoT** qui impose une quasi-unanimite en cas de faible participation et converge vers une majorite simple a participation elevee.
## Formule principale -- Seuil WoT
@@ -174,7 +174,7 @@ Les parametres de formule sont encodes dans une chaine compacte pour faciliter l
## Vote nuance
En plus du vote binaire (pour/contre), Glibredecision supporte un vote nuance a 6 niveaux :
En plus du vote binaire (pour/contre), libreDecision supporte un vote nuance a 6 niveaux :
| Niveau | Label | Valeur normalisee |
| ------ | ------------- | ----------------: |
@@ -211,7 +211,7 @@ L'API expose un endpoint de simulation qui permet de tester le comportement de l
**Exemple de requete** :
```bash
curl -X POST https://glibredecision.example.org/api/v1/protocols/simulate \
curl -X POST https://libredecision.example.org/api/v1/protocols/simulate \
-H "Content-Type: application/json" \
-d '{
"wot_size": 7224,
+3 -3
View File
@@ -5,7 +5,7 @@ description: Integration Duniter V2, IPFS et ancrage on-chain
# Integration blockchain
Glibredecision s'integre a la blockchain Duniter V2 pour trois fonctions essentielles :
libreDecision s'integre a la blockchain Duniter V2 pour trois fonctions essentielles :
1. **Authentification** -- Verification de l'identite des membres via signature Ed25519
2. **Donnees WoT** -- Recuperation des tailles WoT, Smith et TechComm pour le calcul des seuils
@@ -100,7 +100,7 @@ L'ancrage on-chain consiste a soumettre un extrinsic `system.remark` contenant l
### Format du remark
```
glibredecision:sanctuary:{content_hash_sha256}
libredecision:sanctuary:{content_hash_sha256}
```
### Soumission
@@ -113,7 +113,7 @@ substrate = SubstrateInterface(url="wss://gdev.p2p.legal/ws")
call = substrate.compose_call(
call_module="System",
call_function="remark",
call_params={"remark": f"glibredecision:sanctuary:{content_hash}"},
call_params={"remark": f"libredecision:sanctuary:{content_hash}"},
)
extrinsic = substrate.create_signed_extrinsic(call=call, keypair=keypair)
+4 -4
View File
@@ -1,11 +1,11 @@
---
title: Contribution
description: Guide de contribution au projet Glibredecision
description: Guide de contribution au projet libreDecision
---
# Guide de contribution
Merci de votre interet pour contribuer a Glibredecision. Ce guide explique comment configurer l'environnement de developpement, les conventions a respecter et le processus de contribution.
Merci de votre interet pour contribuer a libreDecision. Ce guide explique comment configurer l'environnement de developpement, les conventions a respecter et le processus de contribution.
## Prerequis
@@ -21,8 +21,8 @@ Merci de votre interet pour contribuer a Glibredecision. Ce guide explique comme
```bash
# Cloner le depot
git clone https://git.duniter.org/tools/glibredecision.git
cd glibredecision
git clone https://git.duniter.org/tools/libredecision.git
cd libredecision
# Copier le fichier d'environnement
cp .env.example .env
+25 -25
View File
@@ -1,11 +1,11 @@
---
title: Deploiement
description: Guide de deploiement en production de Glibredecision
description: Guide de deploiement en production de libreDecision
---
# Deploiement
Ce guide couvre le deploiement complet de Glibredecision en production avec Docker, Traefik, PostgreSQL et IPFS.
Ce guide couvre le deploiement complet de libreDecision en production avec Docker, Traefik, PostgreSQL et IPFS.
## Prerequis
@@ -13,7 +13,7 @@ Ce guide couvre le deploiement complet de Glibredecision en production avec Dock
| --------- | ---------------- | ----------- |
| Docker | 24+ | Moteur de conteneurs |
| Docker Compose | 2.20+ | Orchestration multi-conteneurs |
| Nom de domaine | -- | Domaine pointe vers le serveur (ex: `glibredecision.org`) |
| Nom de domaine | -- | Domaine pointe vers le serveur (ex: `decision.librodrome.org`) |
| Certificat TLS | -- | Genere automatiquement par Traefik via Let's Encrypt |
| Traefik | 2.10+ | Reverse proxy avec terminaison TLS (reseau externe `traefik`) |
| Serveur | 2 vCPU, 4 Go RAM, 40 Go SSD | Ressources recommandees |
@@ -40,18 +40,18 @@ cp .env.example .env
| Variable | Description | Valeur par defaut | Production |
| -------- | ----------- | ----------------- | ---------- |
| `POSTGRES_DB` | Nom de la base de donnees | `glibredecision` | `glibredecision` |
| `POSTGRES_USER` | Utilisateur PostgreSQL | `glibredecision` | `glibredecision` |
| `POSTGRES_DB` | Nom de la base de donnees | `libredecision` | `libredecision` |
| `POSTGRES_USER` | Utilisateur PostgreSQL | `libredecision` | `libredecision` |
| `POSTGRES_PASSWORD` | Mot de passe PostgreSQL | `change-me-in-production` | **Generer un mot de passe fort** (32+ caracteres) |
| `DATABASE_URL` | URL de connexion asyncpg | `postgresql+asyncpg://...@localhost:5432/...` | Construite automatiquement dans docker-compose |
| `SECRET_KEY` | Cle secrete pour les tokens de session | `change-me-in-production-with-a-real-secret-key` | **Generer une cle aleatoire** (`openssl rand -hex 64`) |
| `DEBUG` | Mode debug | `true` | **`false`** |
| `CORS_ORIGINS` | Origines CORS autorisees | `["http://localhost:3002"]` | `["https://glibredecision.org"]` |
| `CORS_ORIGINS` | Origines CORS autorisees | `["http://localhost:3002"]` | `["https://decision.librodrome.org"]` |
| `DUNITER_RPC_URL` | URL du noeud Duniter V2 RPC | `wss://gdev.p2p.legal/ws` | URL du noeud de production |
| `IPFS_API_URL` | URL de l'API IPFS (kubo) | `http://localhost:5001` | `http://ipfs:5001` (interne Docker) |
| `IPFS_GATEWAY_URL` | URL de la passerelle IPFS | `http://localhost:8080` | `http://ipfs:8080` (interne Docker) |
| `NUXT_PUBLIC_API_BASE` | URL publique de l'API pour le frontend | `http://localhost:8002/api/v1` | `https://glibredecision.org/api/v1` |
| `DOMAIN` | Nom de domaine | `glibredecision.org` | Votre domaine |
| `NUXT_PUBLIC_API_BASE` | URL publique de l'API pour le frontend | `http://localhost:8002/api/v1` | `https://decision.librodrome.org/api/v1` |
| `DOMAIN` | Nom de domaine | `decision.librodrome.org` | Votre domaine |
### Generer les secrets
@@ -73,10 +73,10 @@ Ne commitez jamais le fichier `.env` contenant les secrets de production. Ajoute
```bash
# Se placer dans le repertoire du projet
cd /opt/glibredecision
cd /opt/libredecision
# Cloner le depot
git clone https://git.duniter.org/tools/glibredecision.git .
git clone https://git.duniter.org/tools/libredecision.git .
# Configurer l'environnement
cp .env.example .env
@@ -108,12 +108,12 @@ docker compose -f docker/docker-compose.yml ps
docker compose -f docker/docker-compose.yml logs -f backend
# Health check de l'API
curl -s https://glibredecision.org/api/health | jq .
curl -s https://decision.librodrome.org/api/health | jq .
```
## Migration de base de donnees (Alembic)
Glibredecision utilise Alembic pour les migrations de schema PostgreSQL.
libreDecision utilise Alembic pour les migrations de schema PostgreSQL.
### Appliquer les migrations
@@ -182,7 +182,7 @@ services:
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=admin@glibredecision.org"
- "--certificatesresolvers.letsencrypt.acme.email=admin@decision.librodrome.org"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
@@ -207,10 +207,10 @@ docker compose -f docker-compose.traefik.yml up -d
### Routage
Le `docker-compose.yml` de Glibredecision configure automatiquement les labels Traefik :
Le `docker-compose.yml` de libreDecision configure automatiquement les labels Traefik :
- **Frontend** : `Host(glibredecision.org)` sur le port 3000
- **Backend** : `Host(glibredecision.org) && PathPrefix(/api)` sur le port 8002
- **Frontend** : `Host(decision.librodrome.org)` sur le port 3000
- **Backend** : `Host(decision.librodrome.org) && PathPrefix(/api)` sur le port 8002
- Les deux utilisent le certificat Let's Encrypt (`certresolver=letsencrypt`)
- Redirection HTTP vers HTTPS automatique
@@ -230,7 +230,7 @@ Le service PostgreSQL dispose d'un health check integre (`pg_isready`). Le backe
```bash
# Health check de l'API
curl -s https://glibredecision.org/api/health
curl -s https://decision.librodrome.org/api/health
# Reponse attendue : {"status": "healthy"}
```
@@ -257,8 +257,8 @@ Surveillez les indicateurs suivants :
| ---------- | -------- | --------------- |
| CPU/RAM conteneurs | `docker stats` | > 80% RAM |
| Espace disque | `df -h` | > 85% |
| Connexions PostgreSQL | `docker exec postgres psql -U glibredecision -c "SELECT count(*) FROM pg_stat_activity;"` | > 80 |
| Taille base de donnees | `docker exec postgres psql -U glibredecision -c "SELECT pg_size_pretty(pg_database_size('glibredecision'));"` | Information |
| Connexions PostgreSQL | `docker exec postgres psql -U libredecision -c "SELECT count(*) FROM pg_stat_activity;"` | > 80 |
| Taille base de donnees | `docker exec postgres psql -U libredecision -c "SELECT pg_size_pretty(pg_database_size('libredecision'));"` | Information |
| Statut IPFS | `docker exec ipfs ipfs id` | Erreur |
## Sauvegarde PostgreSQL
@@ -268,7 +268,7 @@ Surveillez les indicateurs suivants :
```bash
# Dump complet de la base
docker compose -f docker/docker-compose.yml exec postgres \
pg_dump -U glibredecision -Fc glibredecision > backup_$(date +%Y%m%d_%H%M%S).dump
pg_dump -U libredecision -Fc libredecision > backup_$(date +%Y%m%d_%H%M%S).dump
```
### Restauration
@@ -276,7 +276,7 @@ docker compose -f docker/docker-compose.yml exec postgres \
```bash
# Restaurer un dump
docker compose -f docker/docker-compose.yml exec -T postgres \
pg_restore -U glibredecision -d glibredecision --clean < backup_20260228_120000.dump
pg_restore -U libredecision -d libredecision --clean < backup_20260228_120000.dump
```
### Sauvegarde automatique (cron)
@@ -288,7 +288,7 @@ Ajoutez un crontab pour des sauvegardes quotidiennes :
crontab -e
# Ajouter une sauvegarde quotidienne a 3h du matin
0 3 * * * cd /opt/glibredecision && docker compose -f docker/docker-compose.yml exec -T postgres pg_dump -U glibredecision -Fc glibredecision > /opt/backups/glibredecision_$(date +\%Y\%m\%d).dump && find /opt/backups -name "glibredecision_*.dump" -mtime +30 -delete
0 3 * * * cd /opt/libredecision && docker compose -f docker/docker-compose.yml exec -T postgres pg_dump -U libredecision -Fc libredecision > /opt/backups/libredecision_$(date +\%Y\%m\%d).dump && find /opt/backups -name "libredecision_*.dump" -mtime +30 -delete
```
Cette commande :
@@ -301,7 +301,7 @@ Cette commande :
### Procedure standard
```bash
cd /opt/glibredecision
cd /opt/libredecision
# 1. Tirer les dernieres images
docker compose -f docker/docker-compose.yml pull
@@ -317,7 +317,7 @@ docker image prune -f
# 5. Verifier le deploiement
docker compose -f docker/docker-compose.yml ps
curl -s https://glibredecision.org/api/health
curl -s https://decision.librodrome.org/api/health
```
### Pipeline CI/CD (Woodpecker)
@@ -377,7 +377,7 @@ docker compose -f docker/docker-compose.yml up -d # recree avec le nouveau m
**Symptome** : Le site est inaccessible en HTTPS ou affiche un certificat invalide.
1. Verifier que le DNS A/AAAA pointe vers le serveur : `dig glibredecision.org`
1. Verifier que le DNS A/AAAA pointe vers le serveur : `dig decision.librodrome.org`
2. Verifier que les ports 80 et 443 sont ouverts : `ss -tlnp | grep -E '80|443'`
3. Consulter les logs Traefik : `docker logs traefik 2>&1 | grep -i acme`
+6 -6
View File
@@ -1,17 +1,17 @@
---
title: Securite
description: Politique de securite et mesures de protection de Glibredecision
description: Politique de securite et mesures de protection de libreDecision
---
# Securite
Ce document decrit les mesures de securite implementees dans Glibredecision pour proteger l'integrite de la plateforme, des votes et des donnees des utilisateurs.
Ce document decrit les mesures de securite implementees dans libreDecision pour proteger l'integrite de la plateforme, des votes et des donnees des utilisateurs.
## Authentification Duniter V2 (Ed25519 challenge-response)
### Principe
Glibredecision n'utilise ni mot de passe ni systeme d'inscription classique. L'authentification repose entierement sur la cryptographie Ed25519 de la blockchain Duniter V2.
libreDecision n'utilise ni mot de passe ni systeme d'inscription classique. L'authentification repose entierement sur la cryptographie Ed25519 de la blockchain Duniter V2.
### Flux challenge-response
@@ -169,10 +169,10 @@ Contenu --> [SHA-256] --> hash
### Format du remark on-chain
```
glibredecision:sanctuary:{content_hash_sha256}
libredecision:sanctuary:{content_hash_sha256}
```
Le prefixe `glibredecision:sanctuary:` permet d'identifier les ancrages de Glibredecision parmi tous les remarks de la blockchain.
Le prefixe `libredecision:sanctuary:` permet d'identifier les ancrages de libreDecision parmi tous les remarks de la blockchain.
## WebSocket : authentification et securite
@@ -243,7 +243,7 @@ Les logs d'audit sont conserves de maniere permanente dans la base de donnees. L
### Processus
Si vous decouvrez une vulnerabilite de securite dans Glibredecision, merci de suivre cette procedure de divulgation responsable :
Si vous decouvrez une vulnerabilite de securite dans libreDecision, merci de suivre cette procedure de divulgation responsable :
1. **Ne divulguez pas publiquement** la vulnerabilite avant qu'un correctif soit disponible.
2. **Contactez l'equipe** via le canal securise indique sur le depot Git Duniter ou via le forum Duniter (message prive aux mainteneurs).
+6 -6
View File
@@ -1,15 +1,15 @@
---
title: Documentation utilisateur
description: Guide d'utilisation de la plateforme Glibredecision
description: Guide d'utilisation de la plateforme libreDecision
---
# Documentation utilisateur
Bienvenue dans la documentation utilisateur de Glibredecision, la plateforme de decisions collectives pour la communaute Duniter/G1.
Bienvenue dans la documentation utilisateur de libreDecision, la plateforme de decisions collectives pour la communaute Duniter/G1.
## Qu'est-ce que Glibredecision ?
## Qu'est-ce que libreDecision ?
Glibredecision est une plateforme de gouvernance decentralisee qui permet aux membres de la Toile de Confiance (WoT) Duniter de :
libreDecision est une plateforme de gouvernance decentralisee qui permet aux membres de la Toile de Confiance (WoT) Duniter de :
- Gerer des **documents de reference** modulaires (Licence G1, Engagements Forgeron, Reglement du Comite Technique, etc.) sous vote permanent
- Prendre des **decisions collectives** via des processus multi-etapes (qualification, examen, vote, execution, rapport)
@@ -33,7 +33,7 @@ La plateforme est entierement transparente : tous les votes sont publics, signes
## Par ou commencer ?
1. **Nouveau sur Glibredecision ?** Commencez par le guide [Premiers pas](/user/getting-started) pour vous connecter et decouvrir l'interface.
1. **Nouveau sur libreDecision ?** Commencez par le guide [Premiers pas](/user/getting-started) pour vous connecter et decouvrir l'interface.
2. **Vous voulez voter ?** Consultez le guide [Vote](/user/voting) pour comprendre les types de vote et la formule de seuil.
3. **Vous voulez proposer une modification ?** Le guide [Documents](/user/documents) explique comment proposer des modifications aux textes fondateurs.
4. **Une question ?** La [FAQ](/user/faq) repond aux questions les plus courantes.
@@ -42,7 +42,7 @@ La plateforme est entierement transparente : tous les votes sont publics, signes
Cette documentation est elle-meme un document en evolution. Si vous constatez une erreur, une imprecision ou un manque, vous pouvez :
- Ouvrir une issue sur le [depot Git](https://git.duniter.org/tools/glibredecision) de Glibredecision
- Ouvrir une issue sur le [depot Git](https://git.duniter.org/tools/libredecision) de libreDecision
- Proposer une modification directement via une merge request
- En discuter sur le [forum Duniter](https://forum.duniter.org)
+5 -5
View File
@@ -1,11 +1,11 @@
---
title: Premiers pas
description: Connexion et prise en main de Glibredecision
description: Connexion et prise en main de libreDecision
---
# Premiers pas
Ce guide vous accompagne de votre premiere visite jusqu'a votre premier vote sur Glibredecision.
Ce guide vous accompagne de votre premiere visite jusqu'a votre premier vote sur libreDecision.
## Prerequis
@@ -28,9 +28,9 @@ Vous pouvez **consulter** les documents, decisions et resultats de vote sans auc
3. Creez ou importez votre compte Duniter V2 dans l'extension.
4. Assurez-vous que votre adresse SS58 est bien celle liee a votre identite Duniter.
## Qui peut utiliser Glibredecision ?
## Qui peut utiliser libreDecision ?
Glibredecision est ouvert a tous les membres de la Toile de Confiance (WoT) Duniter V2. Pour utiliser pleinement la plateforme, vous devez posseder une identite Duniter avec une adresse SS58 valide.
libreDecision est ouvert a tous les membres de la Toile de Confiance (WoT) Duniter V2. Pour utiliser pleinement la plateforme, vous devez posseder une identite Duniter avec une adresse SS58 valide.
- **Consultation** : tout visiteur peut consulter les documents, decisions et resultats de vote.
- **Participation** (voter, proposer) : reservee aux membres authentifies via leur identite Duniter.
@@ -63,7 +63,7 @@ Votre cle privee n'est **jamais** transmise au serveur. Seule la signature du ch
La barre de navigation en haut de page contient :
- **Logo Glibredecision** : retour a l'accueil
- **Logo libreDecision** : retour a l'accueil
- **Menu principal** : acces aux cinq sections
- **Bouton de connexion** / **Votre profil** (si connecte)
- **Indicateur temps reel** : point colore indiquant l'etat de la connexion WebSocket
+2 -2
View File
@@ -1,6 +1,6 @@
---
title: Documents
description: Guide des documents de reference sur Glibredecision
description: Guide des documents de reference sur libreDecision
---
# Documents de reference
@@ -9,7 +9,7 @@ description: Guide des documents de reference sur Glibredecision
Un document de reference est un **texte fondateur** de la communaute Duniter/G1. Il peut s'agir d'une licence monetaire, d'un engagement que les membres s'engagent a respecter, d'un reglement interieur ou d'un texte constitutif. Ces documents definissent les regles, les valeurs et le fonctionnement de la communaute.
Ce qui rend Glibredecision unique, c'est que ces documents sont **modulaires** et sous **vote permanent** : chaque document est compose d'items individuels (clauses, regles, verifications, preambules, sections) qui peuvent etre modifies independamment par proposition et vote. La communaute peut faire evoluer ses textes de maniere continue, sans procedure lourde ni periode speciale.
Ce qui rend libreDecision unique, c'est que ces documents sont **modulaires** et sous **vote permanent** : chaque document est compose d'items individuels (clauses, regles, verifications, preambules, sections) qui peuvent etre modifies independamment par proposition et vote. La communaute peut faire evoluer ses textes de maniere continue, sans procedure lourde ni periode speciale.
## Types de documents
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Decisions
description: Guide des processus decisionnels sur Glibredecision
description: Guide des processus decisionnels sur libreDecision
---
# Decisions
+5 -5
View File
@@ -1,13 +1,13 @@
---
title: Vote
description: Guide du systeme de vote sur Glibredecision
description: Guide du systeme de vote sur libreDecision
---
# Vote
## Principe
Le systeme de vote de Glibredecision est concu pour adapter le seuil d'adoption a la participation reelle. Quand peu de membres votent, une quasi-unanimite est exigee. Quand la participation est elevee, une majorite simple suffit. Ce mecanisme d'**inertie** protege contre les decisions prises par un petit groupe.
Le systeme de vote de libreDecision est concu pour adapter le seuil d'adoption a la participation reelle. Quand peu de membres votent, une quasi-unanimite est exigee. Quand la participation est elevee, une majorite simple suffit. Ce mecanisme d'**inertie** protege contre les decisions prises par un petit groupe.
## Types de vote
@@ -42,7 +42,7 @@ Le vote nuance est recommande pour :
### L'analogie de l'inertie
Imaginez un gros rocher pose au sommet d'une colline. Pour le deplacer, il faut une force considerable : c'est l'**inertie**. Dans Glibredecision, le rocher represente le statu quo et la force necessaire represente le nombre de votes favorables.
Imaginez un gros rocher pose au sommet d'une colline. Pour le deplacer, il faut une force considerable : c'est l'**inertie**. Dans libreDecision, le rocher represente le statu quo et la force necessaire represente le nombre de votes favorables.
- **Quand peu de personnes poussent** (faible participation) : il faut que presque tout le monde pousse dans la meme direction. Si seulement 10 personnes sur 7000 votent, il faut que 9 sur 10 soient pour.
- **Quand beaucoup de personnes poussent** (forte participation) : la majorite simple suffit. Si 7000 personnes votent, il suffit que 3500 soient pour (50%).
@@ -279,7 +279,7 @@ Le simulateur montre visuellement l'impact : avec un gradient plus eleve, l'exig
## Mises a jour en temps reel
Glibredecision utilise une connexion **WebSocket** pour diffuser les mises a jour de vote en temps reel a tous les utilisateurs qui consultent une session de vote.
libreDecision utilise une connexion **WebSocket** pour diffuser les mises a jour de vote en temps reel a tous les utilisateurs qui consultent une session de vote.
### Ce qui est mis a jour en direct
@@ -318,7 +318,7 @@ Sur la page d'une session de vote, l'onglet **Votes** affiche la liste de tous l
- L'horodatage.
- Un lien pour verifier la signature Ed25519.
Les votes sont publics et verifiables par tous. Il n'y a pas de vote secret dans Glibredecision : la transparence est un principe fondamental.
Les votes sont publics et verifiables par tous. Il n'y a pas de vote secret dans libreDecision : la transparence est un principe fondamental.
## Meta-gouvernance : voter sur les regles du vote
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: Mandats
description: Guide des mandats sur Glibredecision
description: Guide des mandats sur libreDecision
---
# Mandats
+9 -9
View File
@@ -1,15 +1,15 @@
---
title: Sanctuaire
description: Guide de l'archivage immuable sur Glibredecision
description: Guide de l'archivage immuable sur libreDecision
---
# Sanctuaire
## Qu'est-ce que le Sanctuaire ?
Le Sanctuaire est la couche d'**archivage immuable** de Glibredecision. C'est l'endroit ou les decisions adoptees, les documents archives et les resultats de vote sont preserves de maniere permanente et verifiable.
Le Sanctuaire est la couche d'**archivage immuable** de libreDecision. C'est l'endroit ou les decisions adoptees, les documents archives et les resultats de vote sont preserves de maniere permanente et verifiable.
Le principe est simple : une fois qu'un contenu entre dans le Sanctuaire, il ne peut plus etre modifie ni supprime. Meme si la plateforme Glibredecision disparaissait, les preuves resteraient accessibles et verifiables de maniere independante.
Le principe est simple : une fois qu'un contenu entre dans le Sanctuaire, il ne peut plus etre modifie ni supprime. Meme si la plateforme libreDecision disparaissait, les preuves resteraient accessibles et verifiables de maniere independante.
## Triple preuve : SHA-256 + IPFS + Blockchain
@@ -58,12 +58,12 @@ L'ancrage on-chain consiste a enregistrer le hash SHA-256 du contenu sur la bloc
- **Horodatage** : la date du bloc prouve que le contenu existait a cette date.
- **Immutabilite** : une fois inscrit dans la blockchain, le remark ne peut pas etre modifie.
- **Independance** : la preuve est verifiable sur la blockchain, independamment de Glibredecision.
- **Independance** : la preuve est verifiable sur la blockchain, independamment de libreDecision.
Le format du remark est :
```
glibredecision:sanctuary:{hash_sha256_du_contenu}
libredecision:sanctuary:{hash_sha256_du_contenu}
```
**Analogie** : C'est comme publier un hash dans un journal date et immuable. N'importe qui peut verifier que le hash etait bien la a cette date.
@@ -74,7 +74,7 @@ La gouvernance exige la transparence et la tracabilite. Le Sanctuaire garantit q
- **Aucune decision adoptee ne peut etre modifiee retroactivement** : le hash et l'ancrage on-chain rendent toute falsification detectable.
- **Tout membre peut verifier l'authenticite** d'un document ou d'un resultat de vote de maniere independante.
- **L'historique des decisions est preserve** independamment de la plateforme : meme sans Glibredecision, les preuves restent sur IPFS et la blockchain.
- **L'historique des decisions est preserve** independamment de la plateforme : meme sans libreDecision, les preuves restent sur IPFS et la blockchain.
## Types d'entrees
@@ -110,7 +110,7 @@ Cette fonctionnalite permet de retracer l'historique complet d'archivage d'un do
### Verification automatique
Glibredecision propose une verification automatique d'integrite pour chaque entree du Sanctuaire :
libreDecision propose une verification automatique d'integrite pour chaque entree du Sanctuaire :
1. Ouvrez l'entree a verifier dans le Sanctuaire.
2. Cliquez sur **Verifier l'integrite**.
@@ -126,7 +126,7 @@ Si les trois controles sont valides, le contenu est authentique et n'a pas ete m
### Verification manuelle (independante de la plateforme)
Pour une verification totalement independante de Glibredecision, suivez ces etapes :
Pour une verification totalement independante de libreDecision, suivez ces etapes :
#### Etape 1 : Recuperer le contenu via IPFS
@@ -161,7 +161,7 @@ Comparez le hash obtenu avec le hash affiche dans le Sanctuaire. Ils doivent etr
Si les trois hash correspondent (calcul local, Sanctuaire, on-chain), le contenu est authentique, integre et horodate. La triple preuve est confirmee.
::callout{type="tip"}
L'ancrage on-chain fournit une preuve horodatee et infalsifiable de l'existence du contenu a la date du bloc. Meme si la plateforme Glibredecision disparait, la preuve reste verifiable sur la blockchain.
L'ancrage on-chain fournit une preuve horodatee et infalsifiable de l'existence du contenu a la date du bloc. Meme si la plateforme libreDecision disparait, la preuve reste verifiable sur la blockchain.
::
## Comprendre les informations d'ancrage on-chain
+11 -11
View File
@@ -1,19 +1,19 @@
---
title: FAQ
description: Questions frequentes sur Glibredecision
description: Questions frequentes sur libreDecision
---
# Questions frequentes
## Acces et authentification
### Ai-je besoin d'un compte Duniter pour utiliser Glibredecision ?
### Ai-je besoin d'un compte Duniter pour utiliser libreDecision ?
Pour **consulter** les documents, decisions et resultats de vote, aucune authentification n'est necessaire. Pour **voter**, **proposer des modifications** ou **creer des decisions**, vous devez posseder une identite Duniter V2 avec une adresse SS58.
### Comment fonctionne la connexion sans mot de passe ?
Glibredecision utilise un systeme challenge-response base sur la cryptographie Ed25519. Voici le processus :
libreDecision utilise un systeme challenge-response base sur la cryptographie Ed25519. Voici le processus :
1. Vous fournissez votre adresse Duniter SS58.
2. Le serveur genere un texte aleatoire (le "challenge") de 64 caracteres hexadecimaux.
@@ -37,7 +37,7 @@ Les sessions durent 24 heures. Reconnectez-vous en suivant le meme processus (ch
### Que se passe-t-il si je perds l'acces a ma cle privee ?
Glibredecision ne stocke jamais votre cle privee. Si vous perdez l'acces a votre cle, vous ne pourrez plus vous authentifier avec cette adresse. Vos votes passes restent enregistres et comptabilises. Contactez la communaute Duniter pour les procedures de recuperation d'identite si necessaire.
libreDecision ne stocke jamais votre cle privee. Si vous perdez l'acces a votre cle, vous ne pourrez plus vous authentifier avec cette adresse. Vos votes passes restent enregistres et comptabilises. Contactez la communaute Duniter pour les procedures de recuperation d'identite si necessaire.
### Puis-je me connecter depuis plusieurs appareils ?
@@ -101,7 +101,7 @@ C'est le codage compact des parametres de formule :
### Les votes sont-ils secrets ?
Non. Les votes et leurs signatures cryptographiques sont **publics**, conformement au principe de transparence de la gouvernance. Chaque vote peut etre verifie independamment par quiconque possede la cle publique du votant. Il n'y a pas de vote secret dans Glibredecision.
Non. Les votes et leurs signatures cryptographiques sont **publics**, conformement au principe de transparence de la gouvernance. Chaque vote peut etre verifie independamment par quiconque possede la cle publique du votant. Il n'y a pas de vote secret dans libreDecision.
### Le seuil peut-il changer pendant le vote ?
@@ -174,7 +174,7 @@ Oui. Un mandat actif peut etre revoque de maniere anticipee via l'action "Revoqu
### Pourquoi archiver sur IPFS et la blockchain ?
**IPFS** fournit un stockage distribue : le contenu est accessible meme si la plateforme Glibredecision est hors ligne. L'**ancrage on-chain** via `system.remark` cree une preuve horodatee immuable sur la blockchain Duniter V2. Le **hash SHA-256** garantit l'integrite du contenu. Ensemble, ils forment une **triple preuve** que le contenu n'a pas ete modifie depuis son archivage.
**IPFS** fournit un stockage distribue : le contenu est accessible meme si la plateforme libreDecision est hors ligne. L'**ancrage on-chain** via `system.remark` cree une preuve horodatee immuable sur la blockchain Duniter V2. Le **hash SHA-256** garantit l'integrite du contenu. Ensemble, ils forment une **triple preuve** que le contenu n'a pas ete modifie depuis son archivage.
### Comment verifier qu'un document n'a pas ete modifie ?
@@ -193,7 +193,7 @@ Oui. L'archivage est declenche automatiquement :
- Quand une decision est executee
- Quand un document est archive manuellement
### Puis-je acceder aux archives sans Glibredecision ?
### Puis-je acceder aux archives sans libreDecision ?
Oui. Les contenus archives sont accessibles via :
@@ -202,9 +202,9 @@ Oui. Les contenus archives sont accessibles via :
## Questions techniques
### Sur quelle blockchain Glibredecision fonctionne-t-il ?
### Sur quelle blockchain libreDecision fonctionne-t-il ?
Glibredecision se connecte a la blockchain **Duniter V2** (basee sur Substrate). En environnement de developpement, il se connecte au reseau de test GDev (`wss://gdev.p2p.legal/ws`).
libreDecision se connecte a la blockchain **Duniter V2** (basee sur Substrate). En environnement de developpement, il se connecte au reseau de test GDev (`wss://gdev.p2p.legal/ws`).
### Que se passe-t-il si la blockchain Duniter est indisponible ?
@@ -224,14 +224,14 @@ Oui. Les votes et leurs signatures cryptographiques sont publics, conformement a
### Les mises a jour sont-elles en temps reel ?
Oui. Glibredecision utilise une connexion WebSocket pour diffuser les mises a jour en temps reel :
Oui. libreDecision utilise une connexion WebSocket pour diffuser les mises a jour en temps reel :
- Nouveaux votes soumis : la jauge de seuil est recalculee instantanement
- Votes modifies : la jauge reflette le changement immediatement
- Sessions cloturees : le resultat final s'affiche
- Un indicateur de connexion (point vert/orange/rouge) en bas a droite indique l'etat de la connexion temps reel
### Ou est heberge Glibredecision ?
### Ou est heberge libreDecision ?
La plateforme est hebergee sur une infrastructure geree par la communaute, avec deploiement automatise via Docker et Woodpecker CI. Le code source est ouvert et disponible sur le depot Git Duniter.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+144
View File
@@ -0,0 +1,144 @@
# Méthode de travail TDD — libreDecision
## Principe fondamental
**Tu décris la règle métier. Claude traduit en test. Tu valides. Claude implémente.**
Jamais l'inverse. Le test est la source de vérité ; l'implémentation n'est que le moyen de le faire passer.
---
## Workflow par itération
```
1. Tu décris une règle en français naturel
→ "Si scope=personal, la décision est toujours individuelle"
2. Claude écrit le(s) test(s) — RED (le test échoue avant l'implémentation)
→ Tu valides que le test capture bien l'intention
3. Claude implémente le minimum pour que le test passe — GREEN
→ Rien de plus que ce que le test exige
4. Claude refactorise si nécessaire — REFACTOR
→ Sans casser les tests existants
5. Répétition avec la règle suivante
```
---
## Commandes de prompt
| Commande | Action |
|---|---|
| `+test` | Écrire le(s) test(s) sans implémenter |
| `+impl` | Implémenter pour faire passer les tests en attente |
| `+test+impl` | Test + implémentation d'un coup (règle simple) |
| `+règle` | Ajouter une règle au moteur existant |
| `+règle remplace` | Une nouvelle règle remplace une précédente (précise laquelle) |
| `+régression` | Vérifier qu'aucun test existant n'est cassé après un changement |
| `+résumé` | Afficher l'état des règles implémentées et en attente |
---
## Format d'une règle métier
Pour être efficace, une règle doit préciser :
```
ENTRÉES : les variables concernées et leurs valeurs
RÉSULTAT : ce que le système doit retourner ou faire
EXCEPTIONS : cas qui brisent la règle générale (si aucune, dire "aucune")
```
**Exemple :**
```
ENTRÉES : scope = "personal"
RÉSULTAT : decision_type = "individual", recommend_onchain = False
EXCEPTIONS : aucune — même si stakes = "critical", une décision personnelle reste individuelle
```
---
## Structure des tests dans ce projet
```
backend/app/tests/
test_qualifier.py ← moteur de qualification (tunnel Décider)
test_middleware.py ← rate limiter, CORS, headers
test_threshold.py ← formules WoT existantes
test_votes.py ← logique de vote
test_decisions.py ← service décisions
...
```
Chaque fichier de test correspond à un module ou un bloc fonctionnel.
Les tests d'intégration (qui touchent la DB) sont marqués `@pytest.mark.integration`.
---
## Les 4 blocs algorithmiques
```
┌─────────────────────────────────────────────────────────┐
│ TUNNEL "DÉCIDER" │
│ │
│ 1. QUALIFIER → nature / enjeu / réversibilité │
│ ↓ │
│ 2. ROUTEUR → individual / collective / delegated │
│ ↓ ↓ │
│ 3. PROTOCOLE → sélection formule WoT + paramètres │
│ ↓ │
│ 4. GRAVURE → recommandation on-chain (IPFS+remark) │
└─────────────────────────────────────────────────────────┘
```
Les blocs sont testés indépendamment puis en intégration.
Un changement dans le bloc 1 ne doit jamais casser silencieusement le bloc 4.
---
## Invariants fondamentaux (ne jamais casser)
Ces règles doivent avoir un test dédié et rester vertes en permanence :
1. Une décision `individual` ne génère jamais de session de vote
2. Une décision `on_chain` implique toujours `recommend_onchain = True`
3. `recommend_onchain = True` requiert `reversibility = "impossible"` **ou** `stakes = "critical"`
4. Le qualificateur ne retourne jamais un type inconnu (enum strict)
5. Un protocole WoT sélectionné doit exister en base (slug valide)
---
## Règles de régression
- Après chaque implémentation : `pytest backend/app/tests/ -v --tb=short`
- Avant tout commit : zéro test rouge
- Si un test existant casse après un nouveau changement → **stop, analyser, ne pas contourner**
- `RATE_LIMIT_AUTH` en dev = 60/min minimum (pas de blocage en développement)
---
## Où sont les règles métier documentées
| Source | Contenu |
|---|---|
| `docs/dev/tdd-methode.md` | Cette méthode |
| `docs/dev/qualifier-rules.md` | Règles du moteur de qualification (créé au fil des itérations) |
| `backend/app/engine/qualifier.py` | Implémentation du qualificateur |
| `backend/app/tests/test_qualifier.py` | Tests — source de vérité exécutable |
---
## À propos de la mémoire de contexte
Entre les sessions, Claude peut perdre le contexte des règles en cours.
Pour reprendre efficacement :
```
"Résume les règles du qualificateur implémentées jusqu'ici"
→ Claude lit test_qualifier.py et qualifier.py et synthétise
```
Les tests sont leur propre documentation. Ne pas dupliquer les règles en commentaires.
+10 -609
View File
@@ -1,620 +1,21 @@
<script setup lang="ts">
const auth = useAuthStore()
const route = useRoute()
const { initMood } = useMood()
// Shell v2 app.vue MINCE : ambiance + hydratation du collectif, rien d'autre.
// Toute la structure (header, sidebar, drawer, sceau) vit dans layouts/default.vue.
import { useCollectiveStore } from '~/stores/collective'
const navigationItems = [
{
label: 'Documents',
icon: 'i-lucide-book-open',
to: '/documents',
},
{
label: 'Decisions',
icon: 'i-lucide-scale',
to: '/decisions',
},
{
label: 'Mandats',
icon: 'i-lucide-user-check',
to: '/mandates',
},
{
label: 'Protocoles',
icon: 'i-lucide-settings',
to: '/protocols',
},
{
label: 'Sanctuaire',
icon: 'i-lucide-archive',
to: '/sanctuary',
},
]
const { initMood } = useLibreMood()
/** Mobile drawer state. */
const mobileMenuOpen = ref(false)
/** Close mobile menu on route change. */
watch(() => route.path, () => {
mobileMenuOpen.value = false
})
/** WebSocket connection and notifications. */
const ws = useWebSocket()
const { setupWsNotifications } = useNotifications()
onMounted(async () => {
onMounted(() => {
initMood()
auth.hydrateFromStorage()
if (auth.token) {
try {
await auth.fetchMe()
} catch {
auth.logout()
}
}
ws.connect()
setupWsNotifications(ws)
useCollectiveStore().init?.()
})
onUnmounted(() => {
ws.disconnect()
})
function isActive(to: string) {
return route.path === to || route.path.startsWith(to + '/')
}
</script>
<template>
<!-- ld-v2 -->
<UApp>
<OfflineBanner />
<div
class="app-shell"
:style="{
backgroundColor: 'var(--mood-bg)',
color: 'var(--mood-text)',
}"
>
<!-- Header -->
<header class="app-header">
<div class="app-header__inner">
<!-- Left: Hamburger (mobile) + Logo -->
<div class="app-header__left">
<button
class="app-header__menu-btn md:hidden"
aria-label="Ouvrir le menu"
@click="mobileMenuOpen = true"
>
<UIcon name="i-lucide-menu" class="text-xl" />
</button>
<NuxtLink to="/" class="app-header__logo">
<span class="app-header__logo-stamp">
<UIcon name="i-lucide-gavel" class="app-header__logo-icon" />
</span>
<span class="app-header__logo-text">
<span class="app-header__logo-g">ğ</span><span class="app-header__logo-paren">(</span><span class="app-header__logo-word">Decision</span><span class="app-header__logo-paren">)</span>
</span>
</NuxtLink>
</div>
<!-- Center: Mood switcher (desktop) -->
<MoodSwitcher class="hidden sm:flex" />
<!-- Right: Auth -->
<div class="app-header__right">
<template v-if="auth.isAuthenticated">
<span class="app-header__identity">
{{ auth.identity?.display_name || auth.identity?.address?.slice(0, 10) + '...' }}
</span>
<span
v-if="auth.identity?.is_smith"
class="app-header__role app-header__role--smith"
>
Forgeron
</span>
<span
v-if="auth.identity?.is_techcomm"
class="app-header__role app-header__role--tech"
>
CoTec
</span>
<button
class="app-header__icon-btn"
aria-label="Se deconnecter"
@click="auth.logout()"
>
<UIcon name="i-lucide-log-out" />
</button>
</template>
<template v-else>
<NuxtLink to="/login" class="app-header__connect-btn">
<UIcon name="i-lucide-log-in" />
<span>Connexion</span>
</NuxtLink>
</template>
</div>
</div>
</header>
<!-- Mobile navigation drawer -->
<USlideover
v-model:open="mobileMenuOpen"
side="left"
title="Navigation"
:ui="{ width: 'max-w-xs' }"
>
<template #body>
<nav class="app-mobile-nav">
<NuxtLink
v-for="item in navigationItems"
:key="item.to"
:to="item.to"
class="app-mobile-nav__link"
:class="{ 'app-mobile-nav__link--active': isActive(item.to) }"
@click="mobileMenuOpen = false"
>
<UIcon :name="item.icon" class="text-lg" />
<span>{{ item.label }}</span>
</NuxtLink>
</nav>
<!-- Mood switcher in mobile drawer -->
<div class="app-mobile-mood">
<span class="app-mobile-mood__label">Ambiance</span>
<MoodSwitcher />
</div>
</template>
</USlideover>
<!-- Main content with sidebar -->
<div class="app-body">
<!-- Desktop sidebar -->
<aside class="app-sidebar">
<nav class="app-sidebar__nav">
<NuxtLink
v-for="item in navigationItems"
:key="item.to"
:to="item.to"
class="app-sidebar__link"
:class="{ 'app-sidebar__link--active': isActive(item.to) }"
>
<UIcon :name="item.icon" class="text-lg" />
<span>{{ item.label }}</span>
</NuxtLink>
</nav>
</aside>
<!-- Page content -->
<main class="app-main">
<ErrorBoundary>
<NuxtPage />
</ErrorBoundary>
<!-- Tsing sceau (proportions calées sur avatars Yvv) -->
<svg class="app-seal" viewBox="0 0 130 100" fill="currentColor" aria-hidden="true">
<!-- Line 6 (top) yin -->
<rect x="5" y="5" width="49" height="5" rx="1"/>
<rect x="76" y="5" width="49" height="5" rx="1"/>
<!-- Line 5 yang -->
<rect x="5" y="22" width="120" height="5" rx="1"/>
<!-- Line 4 yin -->
<rect x="5" y="39" width="49" height="5" rx="1"/>
<rect x="76" y="39" width="49" height="5" rx="1"/>
<!-- Line 3 yang -->
<rect x="5" y="56" width="120" height="5" rx="1"/>
<!-- Line 2 yang -->
<rect x="5" y="73" width="120" height="5" rx="1"/>
<!-- Line 1 (bottom) yin -->
<rect x="5" y="90" width="49" height="5" rx="1"/>
<rect x="76" y="90" width="49" height="5" rx="1"/>
</svg>
</main>
</div>
<!-- WebSocket error banner -->
<Transition name="slide-up">
<div
v-if="ws.error.value"
class="app-ws-banner"
role="alert"
>
<UIcon name="i-lucide-plug-zap" class="text-lg" />
<span>{{ ws.error.value }}</span>
<button class="app-ws-banner__btn" @click="ws.disconnect(); ws.connect()">
Reconnecter
</button>
</div>
</Transition>
<!-- Footer -->
<footer class="app-footer">
<span>ğ(Decision) v0.1.0</span>
<span class="app-footer__sep">·</span>
<span>Licence libre</span>
</footer>
</div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
</template>
<style scoped>
/* === Shell === */
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* === Header === */
.app-header {
position: sticky;
top: 0;
z-index: 30;
background: var(--mood-surface);
}
.app-header__inner {
max-width: 80rem;
margin: 0 auto;
padding: 0 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
height: 3.5rem;
}
.app-header__left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-header__menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
background: none;
color: var(--mood-text-muted);
cursor: pointer;
border-radius: 12px;
}
.app-header__menu-btn:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-header__logo {
text-decoration: none;
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-header__logo-stamp {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: var(--mood-accent);
color: var(--mood-accent-text);
border-radius: 8px;
transform: rotate(-10deg);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.app-header__logo:hover .app-header__logo-stamp {
transform: rotate(-16deg) scale(1.08);
}
.app-header__logo-icon {
font-size: 1.125rem;
transform: scaleX(-1);
}
.app-header__logo-text {
display: flex;
align-items: baseline;
gap: 0;
}
.app-header__logo-g {
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-accent);
line-height: 1;
font-style: italic;
}
.app-header__logo-paren {
font-size: 1.125rem;
font-weight: 300;
color: var(--mood-text-muted);
opacity: 0.5;
}
.app-header__logo-word {
font-size: 1.125rem;
font-weight: 700;
color: var(--mood-text);
}
.app-header__right {
display: flex;
align-items: center;
gap: 0.625rem;
}
.app-header__identity {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
max-width: 6rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (min-width: 640px) {
.app-header__identity {
max-width: 12rem;
}
}
.app-header__role {
font-size: 0.75rem;
font-weight: 700;
padding: 3px 10px;
border-radius: 20px;
display: none;
}
@media (min-width: 640px) {
.app-header__role {
display: inline;
}
}
.app-header__role--smith {
background: rgba(24, 132, 59, 0.15);
color: var(--mood-success);
}
.app-header__role--tech {
background: rgba(24, 86, 168, 0.15);
color: var(--mood-status-vote, #1856a8);
}
.app-header__icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
background: none;
color: var(--mood-text-muted);
cursor: pointer;
border-radius: 12px;
font-size: 1rem;
}
.app-header__icon-btn:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-header__connect-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 24px;
text-decoration: none;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
@media (min-width: 640px) {
.app-header__connect-btn {
gap: 0.5rem;
padding: 0.5rem 1.25rem;
font-size: 0.9375rem;
}
}
.app-header__connect-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--mood-shadow);
}
/* === Sidebar === */
.app-body {
display: flex;
flex: 1;
}
.app-sidebar {
width: 14rem;
flex-shrink: 0;
background: var(--mood-surface);
display: none;
}
@media (min-width: 768px) {
.app-sidebar {
display: block;
}
}
.app-sidebar__nav {
position: sticky;
top: 3.5rem;
padding: 1rem 0.75rem;
display: flex;
flex-direction: column;
gap: 4px;
}
.app-sidebar__link {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.875rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--mood-text-muted);
text-decoration: none;
border-radius: 12px;
transition: all 0.12s ease;
}
.app-sidebar__link:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-sidebar__link--active {
color: var(--mood-accent);
background: var(--mood-accent-soft);
font-weight: 700;
}
/* === Mobile nav === */
.app-mobile-nav {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0.75rem;
}
.app-mobile-nav__link {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 1rem 1.25rem;
font-size: 1.0625rem;
font-weight: 600;
color: var(--mood-text-muted);
text-decoration: none;
border-radius: 14px;
min-height: 3rem;
}
.app-mobile-nav__link:hover,
.app-mobile-nav__link:active {
background: var(--mood-accent-soft);
color: var(--mood-text);
}
.app-mobile-nav__link--active {
color: var(--mood-accent);
background: var(--mood-accent-soft);
font-weight: 700;
}
.app-mobile-mood {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
margin-top: 0.5rem;
border-top: 1px solid var(--mood-accent-soft);
}
.app-mobile-mood__label {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text-muted);
}
/* === Main === */
.app-main {
flex: 1;
min-width: 0;
padding: 1.5rem 1.25rem;
}
@media (min-width: 640px) {
.app-main {
padding: 2rem 1.75rem;
}
}
@media (min-width: 1024px) {
.app-main {
padding: 2rem 2.5rem;
}
}
/* === WS Banner === */
.app-ws-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
gap: 0.625rem;
padding: 0.625rem 1rem;
background: var(--mood-error);
color: white;
font-size: 0.875rem;
font-weight: 600;
}
.app-ws-banner__btn {
margin-left: 0.5rem;
padding: 0.375rem 0.75rem;
background: rgba(255,255,255,0.2);
border-radius: 20px;
color: white;
font-size: 0.8125rem;
font-weight: 700;
cursor: pointer;
}
.app-ws-banner__btn:hover {
background: rgba(255,255,255,0.3);
}
/* === Footer === */
.app-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.app-footer__sep {
opacity: 0.3;
}
/* === Seal — 井 Tsing === */
.app-seal {
display: block;
width: 44px;
margin: 1.5rem 0 0.5rem auto;
color: var(--mood-accent);
opacity: 0.28;
filter: drop-shadow(1px 1px 0.5px rgba(0,0,0,0.25))
drop-shadow(-0.5px -0.5px 0.5px rgba(255,255,255,0.15));
}
/* === Transitions === */
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
opacity: 0;
}
</style>
+286 -183
View File
@@ -1,248 +1,351 @@
/* ==========================================================================
ğ(Decision) Mood / Ambiance System
Palettes harmoniques variees, colores en lite, lumineux en dark.
libreDecision v2 Ambiances (champ lexical du puits )
Source (light, eau claire) · Margelle (light, pierre chaude)
Nappe (dark, eau profonde) · Minuit (dark, encre & lanterne)
Borderless : profondeur par background + box-shadow, jamais de bordures.
========================================================================== */
/* --------------------------------------------------------------------------
Peps Chaud, colore, tonique (Light)
Palette: corail vif / ocre / indigo / vert franc
Source l'eau vive au grand jour (Light)
-------------------------------------------------------------------------- */
.mood-peps {
--mood-bg: #faf8f5;
--mood-surface: #ffffff;
--mood-surface-hover: #fdf2ec;
--mood-text: #1e1410;
--mood-text-muted: #7a5e48;
--mood-accent: #d44a10;
--mood-accent-soft: rgba(212, 74, 16, 0.08);
.mood-source {
--mood-bg: #f0f5f6;
--mood-surface: #fcfefe;
--mood-surface-hover: #e6eff2;
--mood-text: #102126;
--mood-text-muted: #52707a;
--mood-accent: #0f7fa8;
--mood-accent-soft: rgba(15, 127, 168, 0.09);
--mood-accent-text: #ffffff;
--mood-border: #e8d8c8;
--mood-secondary: #4a5ec0;
--mood-tertiary: #c07820;
--mood-success: #1a8c3e;
--mood-warning: #c47d0a;
--mood-error: #c23028;
--mood-gradient: linear-gradient(145deg, #fdf8f0 0%, #ffffff 40%, #f0f4fb 100%);
--mood-shadow: rgba(180, 80, 20, 0.07);
--mood-input-bg: #ffffff;
--mood-input-border: #d4c0aa;
--mood-input-focus: #d44a10;
--mood-secondary: #a8742c; /* laiton — le mandat, le pouvoir confié */
--mood-tertiary: #2e8a72; /* eau végétale — l'avis, le dialogue */
--mood-success: #1d8a4a;
--mood-warning: #b07c14;
--mood-error: #bc3d33;
--mood-gradient: linear-gradient(150deg, #eaf3f5 0%, #fcfefe 45%, #eef2ee 100%);
--mood-shadow: rgba(20, 90, 120, 0.08);
--mood-input-bg: #fcfefe;
--mood-input-border: #b8ccd2;
--mood-input-focus: #0f7fa8;
--mood-status-prepa: #b8600e;
--mood-status-prepa-bg: rgba(184, 96, 14, 0.12);
--mood-status-vote: #4a5ec0;
--mood-status-vote-bg: rgba(74, 94, 192, 0.10);
--mood-status-vigueur: #1a8c3e;
--mood-status-vigueur-bg: rgba(26, 140, 62, 0.10);
--mood-status-clos: #6e5844;
--mood-status-clos-bg: rgba(110, 88, 68, 0.08);
--mood-status-prepa: #8a7040;
--mood-status-prepa-bg: rgba(138, 112, 64, 0.10);
--mood-status-fenetre: #b07c14;
--mood-status-fenetre-bg: rgba(176, 124, 20, 0.11);
--mood-status-vote: #2661b8;
--mood-status-vote-bg: rgba(38, 97, 184, 0.10);
--mood-status-fige: #58708c;
--mood-status-fige-bg: rgba(88, 112, 140, 0.12);
--mood-status-vigueur: #1d8a4a;
--mood-status-vigueur-bg: rgba(29, 138, 74, 0.10);
--mood-status-revoque: #a4553a;
--mood-status-revoque-bg: rgba(164, 85, 58, 0.10);
--mood-status-clos: #5f6f74;
--mood-status-clos-bg: rgba(95, 111, 116, 0.09);
}
/* --------------------------------------------------------------------------
Zen Vegetal lumineux, nature vivante (Light)
Palette: vert franc / terre sienne / bleu canard / bois chaud
Margelle la pierre du puits, calcaire et bronze (Light)
-------------------------------------------------------------------------- */
.mood-zen {
--mood-bg: #f4f6f0;
--mood-surface: #fbfcf8;
--mood-surface-hover: #eaf0e4;
--mood-text: #1a2418;
--mood-text-muted: #5a7050;
--mood-accent: #2e8b48;
--mood-accent-soft: rgba(46, 139, 72, 0.09);
.mood-margelle {
--mood-bg: #f6f3ec;
--mood-surface: #fdfbf6;
--mood-surface-hover: #efe9dc;
--mood-text: #221c10;
--mood-text-muted: #77684c;
--mood-accent: #96682a;
--mood-accent-soft: rgba(150, 104, 42, 0.10);
--mood-accent-text: #ffffff;
--mood-border: #b8d0a8;
--mood-secondary: #8a6030;
--mood-tertiary: #2a7888;
--mood-success: #268040;
--mood-warning: #a87818;
--mood-error: #b83838;
--mood-gradient: linear-gradient(145deg, #f0f5ea 0%, #fbfcf8 40%, #f4efe6 100%);
--mood-shadow: rgba(40, 100, 50, 0.07);
--mood-input-bg: #fbfcf8;
--mood-input-border: #a8c898;
--mood-input-focus: #2e8b48;
--mood-secondary: #285e88; /* le ciel dans l'eau du seau */
--mood-tertiary: #4f7a3c; /* mousse entre les pierres */
--mood-success: #3d7e34;
--mood-warning: #a8720e;
--mood-error: #b23f30;
--mood-gradient: linear-gradient(150deg, #f3eee2 0%, #fdfbf6 45%, #f0eee6 100%);
--mood-shadow: rgba(120, 90, 40, 0.09);
--mood-input-bg: #fdfbf6;
--mood-input-border: #cfc2a6;
--mood-input-focus: #96682a;
--mood-status-prepa: #a87818;
--mood-status-prepa-bg: rgba(168, 120, 24, 0.11);
--mood-status-vote: #2a7888;
--mood-status-vote-bg: rgba(42, 120, 136, 0.10);
--mood-status-vigueur: #268040;
--mood-status-vigueur-bg: rgba(38, 128, 64, 0.10);
--mood-status-clos: #607858;
--mood-status-clos-bg: rgba(96, 120, 88, 0.08);
--mood-status-prepa: #7c6a48;
--mood-status-prepa-bg: rgba(124, 106, 72, 0.10);
--mood-status-fenetre: #a8720e;
--mood-status-fenetre-bg: rgba(168, 114, 14, 0.11);
--mood-status-vote: #285e88;
--mood-status-vote-bg: rgba(40, 94, 136, 0.10);
--mood-status-fige: #6e6a58;
--mood-status-fige-bg: rgba(110, 106, 88, 0.12);
--mood-status-vigueur: #3d7e34;
--mood-status-vigueur-bg: rgba(61, 126, 52, 0.10);
--mood-status-revoque: #9c5236;
--mood-status-revoque-bg: rgba(156, 82, 54, 0.10);
--mood-status-clos: #6f6a5c;
--mood-status-clos-bg: rgba(111, 106, 92, 0.09);
}
/* --------------------------------------------------------------------------
Chagrine Nuit profonde, reflets varies (Dark)
Palette: bleu lavande / ambre chaud / rose ancien / vert d'eau
Nappe l'eau profonde sous la ville (Dark)
-------------------------------------------------------------------------- */
.mood-chagrine {
--mood-bg: #0e1018;
--mood-surface: #151a28;
--mood-surface-hover: #1c2640;
--mood-text: #dce0ec;
--mood-text-muted: #8898b8;
--mood-accent: #6488d8;
--mood-accent-soft: rgba(100, 136, 216, 0.12);
--mood-accent-text: #ffffff;
--mood-border: #222e48;
--mood-secondary: #d0a040;
--mood-tertiary: #c87090;
--mood-success: #48c87a;
--mood-warning: #d8a838;
--mood-error: #d86060;
--mood-gradient: linear-gradient(145deg, #0e1018 0%, #151a28 40%, #101420 100%);
--mood-shadow: rgba(60, 100, 180, 0.12);
--mood-input-bg: #151a28;
--mood-input-border: #2a3858;
--mood-input-focus: #6488d8;
.mood-nappe {
--mood-bg: #0a141b;
--mood-surface: #101f2a;
--mood-surface-hover: #16293a;
--mood-text: #d6e3ea;
--mood-text-muted: #7e98a8;
--mood-accent: #3fa9cc;
--mood-accent-soft: rgba(63, 169, 204, 0.13);
--mood-accent-text: #06141c;
--mood-secondary: #cfa14a; /* laiton à la lueur de la lampe */
--mood-tertiary: #52b894;
--mood-success: #46bf7c;
--mood-warning: #d2a13c;
--mood-error: #d86458;
--mood-gradient: linear-gradient(150deg, #0a141b 0%, #101f2a 45%, #0d1a24 100%);
--mood-shadow: rgba(30, 120, 160, 0.14);
--mood-input-bg: #101f2a;
--mood-input-border: #24425a;
--mood-input-focus: #3fa9cc;
--mood-status-prepa: #d8a838;
--mood-status-prepa-bg: rgba(216, 168, 56, 0.14);
--mood-status-vote: #6488d8;
--mood-status-vote-bg: rgba(100, 136, 216, 0.14);
--mood-status-vigueur: #48c87a;
--mood-status-vigueur-bg: rgba(72, 200, 122, 0.14);
--mood-status-clos: #7888a8;
--mood-status-clos-bg: rgba(120, 136, 168, 0.10);
--mood-status-prepa: #a5987c;
--mood-status-prepa-bg: rgba(165, 152, 124, 0.12);
--mood-status-fenetre: #d2a13c;
--mood-status-fenetre-bg: rgba(210, 161, 60, 0.14);
--mood-status-vote: #5f97e0;
--mood-status-vote-bg: rgba(95, 151, 224, 0.14);
--mood-status-fige: #7d97b5;
--mood-status-fige-bg: rgba(125, 151, 181, 0.14);
--mood-status-vigueur: #46bf7c;
--mood-status-vigueur-bg: rgba(70, 191, 124, 0.13);
--mood-status-revoque: #cd7a56;
--mood-status-revoque-bg: rgba(205, 122, 86, 0.13);
--mood-status-clos: #7d8fa0;
--mood-status-clos-bg: rgba(125, 143, 160, 0.11);
}
/* --------------------------------------------------------------------------
Grave Ambre mineral, ocre lumineux (Dark)
Palette: ambre dore / ocre rouge / bleu ardoise / vert mousse
Minuit l'encre, et la lanterne posée sur la margelle (Dark)
-------------------------------------------------------------------------- */
.mood-grave {
--mood-bg: #131210;
--mood-surface: #1c1a16;
--mood-surface-hover: #28241c;
--mood-text: #ece4d4;
--mood-text-muted: #a89870;
--mood-accent: #d8a030;
--mood-accent-soft: rgba(216, 160, 48, 0.11);
--mood-accent-text: #131210;
--mood-border: #342e24;
--mood-secondary: #6898b8;
--mood-tertiary: #c07040;
--mood-success: #58c870;
--mood-warning: #d0a030;
--mood-error: #d05050;
--mood-gradient: linear-gradient(145deg, #131210 0%, #1c1a16 40%, #16181c 100%);
--mood-shadow: rgba(180, 130, 40, 0.10);
--mood-input-bg: #1c1a16;
--mood-input-border: #3c3428;
--mood-input-focus: #d8a030;
.mood-minuit {
--mood-bg: #101014;
--mood-surface: #17171e;
--mood-surface-hover: #202029;
--mood-text: #e2e2e8;
--mood-text-muted: #8e8e9e;
--mood-accent: #cf9c3e;
--mood-accent-soft: rgba(207, 156, 62, 0.12);
--mood-accent-text: #131309;
--mood-secondary: #6c94d8;
--mood-tertiary: #58ae8e;
--mood-success: #52bd7e;
--mood-warning: #cfa03a;
--mood-error: #d25f55;
--mood-gradient: linear-gradient(150deg, #101014 0%, #17171e 45%, #131318 100%);
--mood-shadow: rgba(190, 140, 50, 0.11);
--mood-input-bg: #17171e;
--mood-input-border: #33333f;
--mood-input-focus: #cf9c3e;
--mood-status-prepa: #c07040;
--mood-status-prepa-bg: rgba(192, 112, 64, 0.15);
--mood-status-vote: #6898b8;
--mood-status-vote-bg: rgba(104, 152, 184, 0.14);
--mood-status-vigueur: #58c870;
--mood-status-vigueur-bg: rgba(88, 200, 112, 0.14);
--mood-status-clos: #807860;
--mood-status-clos-bg: rgba(128, 120, 96, 0.12);
--mood-status-prepa: #9e9584;
--mood-status-prepa-bg: rgba(158, 149, 132, 0.12);
--mood-status-fenetre: #cfa03a;
--mood-status-fenetre-bg: rgba(207, 160, 58, 0.14);
--mood-status-vote: #6c94d8;
--mood-status-vote-bg: rgba(108, 148, 216, 0.14);
--mood-status-fige: #8b8ba0;
--mood-status-fige-bg: rgba(139, 139, 160, 0.14);
--mood-status-vigueur: #52bd7e;
--mood-status-vigueur-bg: rgba(82, 189, 126, 0.13);
--mood-status-revoque: #c97a5c;
--mood-status-revoque-bg: rgba(201, 122, 92, 0.13);
--mood-status-clos: #85859a;
--mood-status-clos-bg: rgba(133, 133, 154, 0.11);
}
/* ==========================================================================
Global design tokens Nunito, rounded, borderless
Tokens dérivés routes, tailles, ombres (identiques dans toutes les ambiances)
========================================================================== */
.mood-source, .mood-margelle, .mood-nappe, .mood-minuit {
/* Routes du chemin — vocabulaire visuel fixe */
--route-solo: var(--mood-accent);
--route-mandate: var(--mood-secondary);
--route-transmit: var(--mood-text-muted);
--route-advice: var(--mood-tertiary);
--route-collective: var(--mood-status-vote);
--route-record: var(--mood-status-prepa);
--route-urgent: var(--mood-error);
*,
*::before,
*::after {
border-color: transparent;
/* Rayons (consignes) */
--r-card: 16px;
--r-input: 12px;
--r-pill: 20px;
--r-icon: 14px;
/* Ombres borderless — deux couches, jamais de bordure */
--shadow-card: 0 1px 2px var(--mood-shadow), 0 5px 18px var(--mood-shadow);
--shadow-card-hover: 0 2px 4px var(--mood-shadow), 0 10px 28px var(--mood-shadow);
--shadow-raised: 0 1px 3px var(--mood-shadow), 0 8px 24px var(--mood-shadow);
}
/* ==========================================================================
Socle global Plus Jakarta Sans, borderless, gestes
========================================================================== */
*, *::before, *::after { border-color: transparent; }
html {
font-family: 'Nunito', system-ui, -apple-system, sans-serif;
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
font-size: 16px;
}
body {
font-family: 'Nunito', system-ui, -apple-system, sans-serif;
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
background: var(--mood-bg);
color: var(--mood-text);
transition: background-color 0.3s ease, color 0.3s ease;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* --- No borders anywhere --- */
button, input, select, textarea {
border: none !important;
outline: none;
font-family: inherit;
color: inherit;
}
input:focus, select:focus, textarea:focus {
input, select, textarea {
background: var(--mood-input-bg);
border-radius: var(--r-input);
}
input:focus-visible, select:focus-visible, textarea:focus-visible,
button:focus-visible, a:focus-visible, [tabindex]:focus-visible {
outline: none;
box-shadow: 0 0 0 2.5px var(--mood-accent-soft);
box-shadow: 0 0 0 2.5px var(--mood-accent-soft), 0 0 0 1.5px var(--mood-accent);
}
/* --- Status pills --- */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* --- Cartes --- */
.ld-card {
background: var(--mood-surface);
border-radius: var(--r-card);
box-shadow: var(--shadow-card);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.ld-card--hover:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-card-hover);
}
.ld-card--hover:active { transform: translateY(0); }
/* --- Boutons pill --- */
.ld-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 2.25rem;
padding: 0.5rem 1.25rem;
border-radius: var(--r-pill);
font-size: 0.9375rem;
font-weight: 700;
cursor: pointer;
background: var(--mood-accent);
color: var(--mood-accent-text);
transition: transform 0.1s ease, box-shadow 0.1s ease, filter 0.1s ease;
user-select: none;
}
.ld-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px var(--mood-shadow); }
.ld-btn:active { transform: translateY(0); }
.ld-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; box-shadow: none; }
.ld-btn--ghost {
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
.ld-btn--quiet {
background: none;
color: var(--mood-text-muted);
}
.ld-btn--quiet:hover { background: var(--mood-accent-soft); color: var(--mood-text); box-shadow: none; }
/* --- Pills d'état (mapping unique état→couleur) --- */
.status-pill {
display: inline-flex;
align-items: center;
padding: 5px 14px;
border-radius: 20px;
gap: 0.3rem;
padding: 4px 13px;
border-radius: var(--r-pill);
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.01em;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
border: none !important;
transition: all 0.15s ease;
}
.status-pill:hover {
filter: brightness(1.08);
transform: translateY(-1px);
}
.status-pill.active {
box-shadow: 0 0 0 2.5px var(--mood-accent);
.status-pill.is-clickable { cursor: pointer; }
.status-pill.is-clickable:hover { filter: brightness(1.08); transform: translateY(-1px); }
.status-pill.active { box-shadow: 0 0 0 2.5px var(--mood-accent); }
.status-draft { background: var(--mood-status-prepa-bg); color: var(--mood-status-prepa); }
.status-advice,
.status-objection { background: var(--mood-status-fenetre-bg); color: var(--mood-status-fenetre); }
.status-framing { background: var(--mood-status-prepa-bg); color: var(--mood-status-prepa); }
.status-voting { background: var(--mood-status-vote-bg); color: var(--mood-status-vote); }
.status-frozen { background: var(--mood-status-fige-bg); color: var(--mood-status-fige); }
.status-adopted { background: var(--mood-status-vigueur-bg); color: var(--mood-status-vigueur); }
.status-rejected { background: var(--mood-status-clos-bg); color: var(--mood-status-clos); text-decoration: line-through; }
.status-revoked { background: var(--mood-status-revoque-bg); color: var(--mood-status-revoque); }
.status-transmitted { background: var(--mood-status-fige-bg); color: var(--mood-status-fige); }
.status-closed { background: var(--mood-status-clos-bg); color: var(--mood-status-clos); }
/* Suspension (frontière contestée) : fond strié */
.status-suspended {
background: repeating-linear-gradient(
-45deg,
var(--mood-status-fenetre-bg),
var(--mood-status-fenetre-bg) 6px,
transparent 6px,
transparent 10px
);
color: var(--mood-status-fenetre);
}
.status-prepa {
background: var(--mood-status-prepa-bg);
color: var(--mood-status-prepa);
}
.status-vote {
background: var(--mood-status-vote-bg);
color: var(--mood-status-vote);
}
.status-vigueur {
background: var(--mood-status-vigueur-bg);
/* --- Le tampon 井 (signature) --- */
.ld-stamp {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.85rem;
border-radius: 10px;
transform: rotate(-10deg);
font-weight: 800;
font-size: 0.8125rem;
letter-spacing: 0.04em;
color: var(--mood-status-vigueur);
}
.status-clos {
background: var(--mood-status-clos-bg);
color: var(--mood-status-clos);
box-shadow: inset 0 0 0 2px var(--mood-status-vigueur);
background: var(--mood-status-vigueur-bg);
user-select: none;
}
/* ==========================================================================
Nuxt UI overrides borderless everything
========================================================================== */
:root .mood-peps,
:root .mood-zen,
:root .mood-chagrine,
:root .mood-grave {
--ui-border: transparent;
--ui-bg: var(--mood-input-bg);
--ui-text-highlighted: var(--mood-accent);
/* --- Sceau hexagramme #48 Tsing --- */
.app-seal {
display: block;
width: 44px;
color: var(--mood-accent);
opacity: 0.28;
filter: drop-shadow(1px 1px 0.5px rgba(0, 0, 0, 0.25))
drop-shadow(-0.5px -0.5px 0.5px rgba(255, 255, 255, 0.15));
}
:root button,
:root [class*="UButton"],
:root [class*="u-button"],
:root [data-variant] {
border: none !important;
font-family: 'Nunito', system-ui, sans-serif !important;
}
:root input,
:root textarea,
:root select,
:root [class*="UInput"],
:root [class*="USelect"],
:root [class*="UTextarea"] {
border: none !important;
font-family: 'Nunito', system-ui, sans-serif !important;
/* --- Impression A4 (PV, affiches de session) --- */
@media print {
body { background: #ffffff !important; color: #111111 !important; }
.app-header, .app-sidebar, .app-fab, .app-seal, .no-print { display: none !important; }
.ld-card { box-shadow: none !important; }
.print-only { display: block !important; }
}
.print-only { display: none; }
+147
View File
@@ -0,0 +1,147 @@
/*
* Overlays Nuxt UI (UModal / USlideover) géométrie posée à la main.
* Les composants @nuxt/ui portent des classes utilitaires tailwind que ce
* projet ne génère pas (UnoCSS ne scanne pas node_modules) : sans ces règles,
* voile et panneaux se rendent à leur position statique, en fin de <body>,
* donc hors écran. On cible la structure stable posée par reka-ui.
*/
/* Voile */
body > div.fixed.inset-0[data-state] {
position: fixed;
inset: 0;
z-index: 90;
background: rgb(15 20 25 / 0.45);
backdrop-filter: blur(2px);
}
body > div.fixed.inset-0[data-state='open'] {
animation: ldov-fade-in 0.15s ease;
}
/* Panneau latéral (USlideover — drawer mobile) */
body > [role='dialog'][data-side] {
position: fixed;
top: 0;
bottom: 0;
width: min(20rem, 86vw);
z-index: 91;
display: flex;
flex-direction: column;
overflow-y: auto;
background: var(--mood-surface);
color: var(--mood-text);
box-shadow: 0 12px 40px rgb(0 0 0 / 0.3);
}
body > [role='dialog'][data-side='left'] {
left: 0;
right: auto;
border-radius: 0 16px 16px 0;
}
body > [role='dialog'][data-side='right'] {
right: 0;
left: auto;
border-radius: 16px 0 0 16px;
}
body > [role='dialog'][data-side='left'][data-state='open'] {
animation: ldov-slide-left 0.18s ease;
}
body > [role='dialog'][data-side='right'][data-state='open'] {
animation: ldov-slide-right 0.18s ease;
}
/* Modale centrée (UModal) */
body > [role='dialog']:not([data-side]) {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(26rem, calc(100vw - 2rem));
max-height: calc(100dvh - 2rem);
overflow-y: auto;
z-index: 91;
background: var(--mood-surface);
color: var(--mood-text);
border-radius: 16px;
box-shadow: 0 18px 50px rgb(0 0 0 / 0.28);
}
body > [role='dialog']:not([data-side])[data-state='open'] {
animation: ldov-pop 0.15s ease;
}
/* Blocs internes générés par Nuxt UI — classes posées via la prop :ui */
.ldov-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 3.5rem 0.5rem 1.25rem;
}
.ldov-title {
margin: 0;
font-size: 1.0625rem;
font-weight: 800;
color: var(--mood-text);
}
/* description a11y sans rendu visuel (lecteurs d'écran seulement) */
.ldov-sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.ldov-desc {
margin: 0;
padding: 0 1.25rem;
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.ldov-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 1.25rem 1.25rem;
}
.ldov-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.25rem;
min-height: 2.25rem;
border-radius: 14px;
background: var(--mood-input-bg);
color: var(--mood-text);
cursor: pointer;
transition: background 0.1s ease, transform 0.1s ease;
}
.ldov-close:hover {
background: var(--mood-accent-soft);
transform: translateY(-1px);
}
.ldov-close:active {
transform: translateY(0);
}
@keyframes ldov-fade-in {
from { opacity: 0; }
}
@keyframes ldov-slide-left {
from { transform: translateX(-100%); }
}
@keyframes ldov-slide-right {
from { transform: translateX(100%); }
}
@keyframes ldov-pop {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.96);
}
}
@@ -0,0 +1,165 @@
<script setup lang="ts">
// « Je choisis autrement » the permanent alternatives, each with its cost.
// Weighting up is one tap; lightening REQUIRES a note and imposes a window.
import type { ParamSpec, Verdict } from '~/types/domain'
import { CHOOSE_OTHERWISE, PARAMETRIC_ALT, RECORD_HOW } from '~/lexicon'
type AltKey = 'parametric' | 'record' | 'binary'
const props = defineProps<{
alternatives: Verdict['alternatives']
parametricHint: boolean
chosen: AltKey | null
lightening: boolean
decidedHow: string
overrideNote: string
spec?: ParamSpec
}>()
const emit = defineEmits<{
(e: 'update:chosen', value: AltKey | null): void
(e: 'update:decidedHow' | 'update:overrideNote', value: string): void
(e: 'update:spec', value: ParamSpec): void
}>()
const open = ref(false)
const keyed = computed(() =>
props.alternatives.map((alt) => ({
...alt,
key: (alt.route === 'record'
? 'record'
: alt.label === PARAMETRIC_ALT ? 'parametric' : 'binary') as AltKey,
})),
)
function pick(key: AltKey) {
emit('update:chosen', props.chosen === key ? null : key)
}
</script>
<template>
<!-- ld-v2 -->
<section class="alt">
<button class="alt__toggle" type="button" @click="open = !open">
<UIcon :name="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'" />
<span>{{ CHOOSE_OTHERWISE }}</span>
<span v-if="chosen && !open" class="alt__chosen-hint">1 choix actif</span>
</button>
<div v-if="open" class="alt__body">
<div class="alt__list">
<button
v-for="alt in keyed"
:key="alt.key"
class="alt__item"
:class="{
'alt__item--on': chosen === alt.key,
'alt__item--hint': alt.key === 'parametric' && parametricHint,
}"
type="button"
@click="pick(alt.key)"
>
<span class="alt__label">{{ alt.label }}</span>
<span class="alt__cost">{{ alt.cost }}</span>
</button>
</div>
<div v-if="chosen === 'parametric'" class="alt__detail">
<CheminParamEditor
:model-value="spec"
@update:model-value="emit('update:spec', $event)"
/>
</div>
<div v-else-if="chosen === 'record'" class="alt__detail">
<label class="alt__field">
<span>{{ RECORD_HOW }}</span>
<input
:value="decidedHow"
type="text"
placeholder="Décidé au café, comme d'habitude…"
@input="emit('update:decidedHow', ($event.target as HTMLInputElement).value)"
>
</label>
</div>
<div v-if="lightening" class="alt__lighten">
<p class="alt__lighten-mention">
<UIcon name="i-lucide-scale" />
Tu prends un chemin plus léger que celui du collectif : une fenêtre d'objection
de 48 h est imposée, et ta raison s'affiche.
</p>
<textarea
:value="overrideNote"
class="alt__note"
rows="2"
placeholder="Pourquoi alléger ? (obligatoire)"
@input="emit('update:overrideNote', ($event.target as HTMLTextAreaElement).value)"
/>
</div>
</div>
</section>
</template>
<style scoped>
.alt { display: flex; flex-direction: column; gap: 0.625rem; }
.alt__toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: none;
padding: 0.375rem 0.25rem;
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text-muted);
cursor: pointer;
align-self: flex-start;
border-radius: var(--r-input);
}
.alt__toggle:hover { color: var(--mood-text); }
.alt__chosen-hint { font-size: 0.75rem; font-weight: 600; color: var(--mood-accent); }
.alt__body { display: flex; flex-direction: column; gap: 0.75rem; }
.alt__list { display: flex; flex-direction: column; gap: 0.5rem; }
.alt__item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
padding: 0.625rem 0.875rem;
border-radius: var(--r-icon);
background: var(--mood-input-bg);
cursor: pointer;
text-align: left;
transition: transform 0.1s ease;
}
.alt__item:hover { transform: translateY(-1px); }
.alt__item--hint { box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--mood-accent) 55%, transparent); }
.alt__item--on {
background: var(--mood-accent-soft);
box-shadow: 0 0 0 2px var(--mood-accent);
}
.alt__label { font-size: 0.9063rem; font-weight: 700; color: var(--mood-text); }
.alt__cost { font-size: 0.8125rem; font-style: italic; color: var(--mood-text-muted); }
.alt__detail { padding-left: 0.25rem; }
.alt__field { display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.875rem; font-weight: 700; }
.alt__field input { padding: 0.625rem 0.75rem; font-size: 0.9375rem; width: 100%; }
.alt__lighten {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border-radius: var(--r-input);
background: var(--mood-status-fenetre-bg);
}
.alt__lighten-mention {
display: flex;
align-items: flex-start;
gap: 0.4rem;
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-status-fenetre);
}
.alt__note { width: 100%; padding: 0.625rem 0.75rem; font-size: 0.9375rem; resize: vertical; }
</style>
@@ -0,0 +1,237 @@
<script setup lang="ts">
// The three tunnel chips Concernés / Réversible ? / Ça engage quoi ?
// one unfolded at a time (mobile-first), plus the discreet urgency toggle,
// the never-blocking « Ce que ça engage » suggestion line and the
// « Aujourd'hui : » status-quo line.
import type { Decision, Person, Reversibility, Weight } from '~/types/domain'
import {
BASELINE_PREFIX,
ENGAGES_LABEL,
REVERSIBILITY_LABELS,
URGENT_TOGGLE,
WEIGHT_LABELS,
} from '~/lexicon'
defineProps<{
stack: { person: Person; origin?: 'computed' | 'declared'; reason?: string }[]
engagesOpen: boolean
baselineSuggested: boolean
}>()
const scope = defineModel<Decision['scope']>('scope', { required: true })
const reversibility = defineModel<Reversibility>('reversibility', { required: true })
const weight = defineModel<Weight>('weight', { required: true })
const urgent = defineModel<boolean>('urgent', { required: true })
const resourceNote = defineModel<string>('resourceNote', { required: true })
const resourceAmount = defineModel<number | null>('resourceAmount', { required: true })
const resourceUnit = defineModel<string>('resourceUnit', { required: true })
const baselineNote = defineModel<string>('baselineNote', { required: true })
const openChip = ref<'scope' | 'rev' | 'weight' | null>(null)
const REV_OPTIONS = Object.keys(REVERSIBILITY_LABELS) as Reversibility[]
const WEIGHT_OPTIONS = Object.keys(WEIGHT_LABELS) as Weight[]
function toggle(chip: 'scope' | 'rev' | 'weight') {
openChip.value = openChip.value === chip ? null : chip
}
</script>
<template>
<!-- ld-v2 -->
<div class="chips">
<div class="chips__row">
<button
class="chips__chip"
:class="{ 'chips__chip--open': openChip === 'scope' }"
type="button"
@click="toggle('scope')"
>
<span class="chips__name">Concernés</span>
<span class="chips__value">
{{ scope.selfOnly ? 'moi seul' : `${stack.length} personne${stack.length > 1 ? 's' : ''}` }}
</span>
</button>
<button
class="chips__chip"
:class="{ 'chips__chip--open': openChip === 'rev' }"
type="button"
@click="toggle('rev')"
>
<span class="chips__name">Réversible ?</span>
<span class="chips__value">{{ REVERSIBILITY_LABELS[reversibility] }}</span>
</button>
<button
class="chips__chip"
:class="{ 'chips__chip--open': openChip === 'weight' }"
type="button"
@click="toggle('weight')"
>
<span class="chips__name">Ça engage quoi ?</span>
<span class="chips__value">{{ WEIGHT_LABELS[weight] }}</span>
</button>
<label class="chips__urgent" :class="{ 'chips__urgent--on': urgent }">
<input v-model="urgent" type="checkbox">
<UIcon name="i-lucide-siren" />
<span>{{ URGENT_TOGGLE }}</span>
</label>
</div>
<div v-if="openChip === 'scope'" class="ld-card chips__panel">
<CheminScope v-model:scope="scope" :stack="stack" />
</div>
<div v-else-if="openChip === 'rev'" class="ld-card chips__panel">
<div class="chips__options">
<button
v-for="opt in REV_OPTIONS"
:key="opt"
class="chips__option"
:class="{ 'chips__option--on': reversibility === opt }"
type="button"
@click="reversibility = opt"
>
{{ REVERSIBILITY_LABELS[opt] }}
</button>
</div>
</div>
<div v-else-if="openChip === 'weight'" class="ld-card chips__panel">
<div class="chips__options">
<button
v-for="opt in WEIGHT_OPTIONS"
:key="opt"
class="chips__option"
:class="{ 'chips__option--on': weight === opt }"
type="button"
@click="weight = opt"
>
{{ WEIGHT_LABELS[opt] }}
</button>
</div>
</div>
<!-- « Ce que ça engage » suggestion dépliée, JAMAIS bloquante -->
<div v-if="engagesOpen" class="chips__engages">
<span class="chips__engages-label">{{ ENGAGES_LABEL }}</span>
<input
v-model="resourceNote"
class="chips__engages-note"
type="text"
placeholder="En une phrase — suggéré, jamais exigé ici"
>
<div class="chips__engages-amount">
<input
v-model.number="resourceAmount"
type="number"
min="0"
placeholder="Montant"
>
<select v-model="resourceUnit">
<option value="heures">heures</option>
<option value="€"></option>
<option value="DU">DU</option>
<option value="jours">jours</option>
</select>
</div>
</div>
<!-- « Aujourd'hui : » le statu quo, suggéré sur avis/collectif -->
<label v-if="baselineSuggested" class="chips__baseline">
<span>{{ BASELINE_PREFIX }}</span>
<input
v-model="baselineNote"
type="text"
placeholder="ce qui se passe si rien ne change"
>
</label>
</div>
</template>
<style scoped>
.chips { display: flex; flex-direction: column; gap: 0.625rem; }
.chips__row { display: flex; flex-wrap: wrap; align-items: stretch; gap: 0.5rem; }
.chips__chip {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
padding: 0.5rem 0.875rem;
border-radius: var(--r-icon);
background: var(--mood-surface);
box-shadow: var(--shadow-card);
cursor: pointer;
text-align: left;
min-height: 2.25rem;
transition: transform 0.1s ease;
}
.chips__chip:hover { transform: translateY(-1px); }
.chips__chip--open { box-shadow: 0 0 0 2px var(--mood-accent), var(--shadow-card); }
.chips__name {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-text-muted);
}
.chips__value { font-size: 0.875rem; font-weight: 700; color: var(--mood-text); }
.chips__urgent {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-left: auto;
padding: 0.4rem 0.875rem;
border-radius: var(--r-pill);
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text-muted);
cursor: pointer;
align-self: center;
}
.chips__urgent input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.chips__urgent--on {
color: var(--route-urgent);
background: color-mix(in srgb, var(--route-urgent) 12%, transparent);
}
.chips__panel { padding: 1rem; }
.chips__options { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.chips__option {
padding: 0.5rem 1rem;
border-radius: var(--r-pill);
font-size: 0.875rem;
font-weight: 600;
background: var(--mood-input-bg);
color: var(--mood-text-muted);
cursor: pointer;
min-height: 2.25rem;
}
.chips__option--on {
background: var(--mood-accent-soft);
color: var(--mood-accent);
box-shadow: 0 0 0 1.5px var(--mood-accent);
}
.chips__engages {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-accent) 6%, transparent);
}
.chips__engages-label { font-size: 0.8125rem; font-weight: 700; color: var(--mood-accent); }
.chips__engages-note { width: 100%; padding: 0.5rem 0.75rem; font-size: 0.9375rem; }
.chips__engages-amount { display: flex; gap: 0.5rem; }
.chips__engages-amount input { width: 7rem; padding: 0.5rem 0.75rem; font-size: 0.9375rem; }
.chips__engages-amount select { padding: 0.5rem 0.75rem; font-size: 0.9375rem; }
.chips__baseline {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
}
.chips__baseline input {
flex: 1;
min-width: 0;
padding: 0.5rem 0.75rem;
font-size: 0.9375rem;
font-weight: 500;
}
</style>
@@ -0,0 +1,194 @@
<script setup lang="ts">
// Mini-editor of a « Réglage collectif »: up to 7 named sliders
// {label, min, max, step, unit, baseline}, plus the 100 % split option with
// its ONE named derived share (« calculé » resolved, never voted).
import type { ParamDef, ParamSpec } from '~/types/domain'
const props = defineProps<{ modelValue?: ParamSpec }>()
const emit = defineEmits<{ (e: 'update:modelValue', spec: ParamSpec): void }>()
interface Row {
label: string
min: number
max: number
step: number
unit: string
baseline: number | null
}
const blankRow = (): Row => ({ label: '', min: 0, max: 100, step: 1, unit: '', baseline: null })
const rows = ref<Row[]>(
props.modelValue
? props.modelValue.params
.filter(p => !p.derived)
.map(p => ({
label: p.label,
min: p.min,
max: p.max,
step: p.step,
unit: p.unit ?? '',
baseline: p.baseline ?? null,
}))
: [blankRow()],
)
const sum100 = ref(props.modelValue?.constraint === 'sum100')
const derivedName = ref(
props.modelValue?.params.find(p => p.derived)?.label ?? 'Réserve (calculé)',
)
const maxRows = computed(() => (sum100.value ? 6 : 7))
const derivedBaseline = computed(() =>
Math.max(0, 100 - rows.value.reduce((sum, r) => sum + (r.baseline ?? 0), 0)),
)
function build(): ParamSpec {
const params: ParamDef[] = rows.value.map((row, i) => ({
key: `p${i + 1}`,
label: row.label.trim() || `Curseur ${i + 1}`,
kind: sum100.value ? 'share' : 'slider',
min: sum100.value ? 0 : row.min,
max: sum100.value ? 100 : row.max,
step: row.step > 0 ? row.step : 1,
...(sum100.value ? { unit: '%' } : row.unit.trim() ? { unit: row.unit.trim() } : {}),
...(row.baseline !== null ? { baseline: row.baseline } : {}),
}))
if (sum100.value) {
params.push({
key: 'derived',
label: derivedName.value.trim() || 'Réserve (calculé)',
kind: 'share',
min: 0,
max: 100,
step: 1,
unit: '%',
baseline: derivedBaseline.value,
derived: true,
})
}
return { params, constraint: sum100.value ? 'sum100' : 'none' }
}
function sync() {
emit('update:modelValue', build())
}
function addRow() {
if (rows.value.length >= maxRows.value) return
rows.value.push(blankRow())
sync()
}
function removeRow(index: number) {
rows.value.splice(index, 1)
if (rows.value.length === 0) rows.value.push(blankRow())
sync()
}
onMounted(sync)
</script>
<template>
<!-- ld-v2 -->
<div class="pe">
<div v-for="(row, i) in rows" :key="i" class="pe__row">
<input
v-model="row.label"
class="pe__label"
type="text"
:placeholder="`Curseur ${i + 1} — libellé métier`"
@input="sync"
>
<div class="pe__nums">
<template v-if="!sum100">
<label class="pe__num"><span>min</span>
<input v-model.number="row.min" type="number" @input="sync">
</label>
<label class="pe__num"><span>max</span>
<input v-model.number="row.max" type="number" @input="sync">
</label>
<label class="pe__num"><span>pas</span>
<input v-model.number="row.step" type="number" min="0" @input="sync">
</label>
<label class="pe__num"><span>unité</span>
<input v-model="row.unit" type="text" placeholder="€, h…" @input="sync">
</label>
</template>
<label class="pe__num"><span>aujourd'hui</span>
<input v-model.number="row.baseline" type="number" @input="sync">
</label>
<button
v-if="rows.length > 1"
class="pe__remove"
type="button"
aria-label="Retirer ce curseur"
@click="removeRow(i)"
>
<UIcon name="i-lucide-x" />
</button>
</div>
</div>
<button v-if="rows.length < maxRows" class="ld-btn ld-btn--quiet pe__add" type="button" @click="addRow">
<UIcon name="i-lucide-plus" /> Ajouter un curseur
</button>
<label class="pe__sum">
<input v-model="sum100" type="checkbox" @change="sync">
<span>Répartition 100 % une part est calculée, jamais votée</span>
</label>
<div v-if="sum100" class="pe__derived">
<input v-model="derivedName" class="pe__label" type="text" @input="sync">
<span class="pe__derived-value">calculé {{ derivedBaseline }} % aujourd'hui</span>
</div>
</div>
</template>
<style scoped>
.pe { display: flex; flex-direction: column; gap: 0.625rem; }
.pe__row {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.625rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
}
.pe__label { padding: 0.5rem 0.75rem; font-size: 0.9375rem; font-weight: 600; width: 100%; }
.pe__nums { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem; }
.pe__num { display: flex; flex-direction: column; gap: 2px; }
.pe__num span {
font-size: 0.6875rem;
font-weight: 700;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pe__num input { width: 5.25rem; padding: 0.375rem 0.5rem; font-size: 0.875rem; }
.pe__remove {
background: none;
color: var(--mood-text-muted);
cursor: pointer;
padding: 0.375rem;
border-radius: var(--r-input);
min-height: 2.25rem;
}
.pe__remove:hover { color: var(--mood-error); background: var(--mood-accent-soft); }
.pe__add { align-self: flex-start; font-size: 0.875rem; }
.pe__sum {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
.pe__sum input { accent-color: var(--mood-accent); width: 1rem; height: 1rem; }
.pe__derived { display: flex; align-items: center; gap: 0.625rem; flex-wrap: wrap; }
.pe__derived .pe__label { max-width: 16rem; }
.pe__derived-value {
font-size: 0.8125rem;
font-style: italic;
color: var(--mood-text-muted);
}
</style>
@@ -0,0 +1,166 @@
<script setup lang="ts">
// « Le chemin » the live card. Big route icon, first-person route name,
// the ONE French explanation sentence (never a rule code), the perimeter /
// duration / threshold strip, the pre-checked follow-ups, ONE main button.
import type { DecisionRoute, Person, Verdict } from '~/types/domain'
import { PATH_CARD_TITLE, ROUTE_ICONS, ROUTE_LABELS, URGENT_BADGE } from '~/lexicon'
const props = defineProps<{
path: Verdict
routeShown: DecisionRoute
overridden: boolean
overrideLabel?: string
stack: { person: Person; origin?: 'computed' | 'declared'; reason?: string }[]
selfOnly: boolean
durationLabel: string
thresholdLabel: string
mainLabel: string
reviewChecked: boolean
engraveChecked: boolean
reviewLocked: boolean
disabled: boolean
blockReason: string
}>()
const emit = defineEmits<{
(e: 'validate'): void
(e: 'update:reviewChecked' | 'update:engraveChecked', value: boolean): void
}>()
const tint = computed(() => `var(--route-${props.routeShown})`)
const showUrgentBadge = computed(
() => props.path.conservatoryChain === true,
)
</script>
<template>
<!-- ld-v2 -->
<section class="ld-card pc" :style="{ '--pc-tint': tint }">
<h2 class="pc__caption">{{ PATH_CARD_TITLE }}</h2>
<div class="pc__head">
<span class="pc__icon"><UIcon :name="ROUTE_ICONS[routeShown]" /></span>
<div class="pc__names">
<span class="pc__route">{{ ROUTE_LABELS[routeShown] }}</span>
<span v-if="overridden" class="pc__override">
Tu choisis autrement{{ overrideLabel ? `${overrideLabel}` : '' }}
</span>
</div>
</div>
<p class="pc__explanation">{{ path.explanation }}</p>
<span v-if="showUrgentBadge" class="status-pill status-advice pc__urgent">
<UIcon name="i-lucide-siren" /> {{ URGENT_BADGE }}
</span>
<div class="pc__strip">
<div class="pc__cell">
<span class="pc__cell-label">Périmètre</span>
<span v-if="selfOnly" class="pc__cell-value">moi seul</span>
<LdAvatarStack v-else-if="stack.length > 0" :people="stack" :max="5" :size="24" />
<span v-else class="pc__cell-value">à préciser</span>
</div>
<div class="pc__cell">
<span class="pc__cell-label">Durée</span>
<span class="pc__cell-value">{{ durationLabel || 'aucune attente' }}</span>
</div>
<div v-if="thresholdLabel" class="pc__cell">
<span class="pc__cell-label">Seuil</span>
<span class="pc__cell-value">{{ thresholdLabel }}</span>
</div>
</div>
<div v-if="path.reviewRequired || path.engravingSuggested" class="pc__checks">
<label v-if="path.reviewRequired" class="pc__check">
<input
type="checkbox"
:checked="reviewChecked"
:disabled="reviewLocked"
@change="emit('update:reviewChecked', ($event.target as HTMLInputElement).checked)"
>
<span>Revoyure l'épreuve du réel sera posée</span>
</label>
<label v-if="path.engravingSuggested" class="pc__check">
<input
type="checkbox"
:checked="engraveChecked"
@change="emit('update:engraveChecked', ($event.target as HTMLInputElement).checked)"
>
<span>Gravure empreinte locale à l'adoption</span>
</label>
</div>
<p v-if="blockReason" class="pc__block">{{ blockReason }}</p>
<button class="ld-btn pc__main" type="button" :disabled="disabled" @click="emit('validate')">
{{ mainLabel }}
</button>
</section>
</template>
<style scoped>
.pc {
display: flex;
flex-direction: column;
gap: 0.875rem;
padding: 1.25rem;
}
.pc__caption {
margin: 0;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--mood-text-muted);
}
.pc__head { display: flex; align-items: center; gap: 0.875rem; }
.pc__icon {
display: flex;
align-items: center;
justify-content: center;
width: 3.25rem;
height: 3.25rem;
border-radius: var(--r-icon);
font-size: 1.625rem;
color: var(--pc-tint);
background: color-mix(in srgb, var(--pc-tint) 14%, transparent);
flex-shrink: 0;
}
.pc__names { display: flex; flex-direction: column; gap: 0.125rem; min-width: 0; }
.pc__route { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.01em; }
.pc__override { font-size: 0.8125rem; font-weight: 600; color: var(--mood-accent); }
.pc__explanation { margin: 0; font-size: 1rem; line-height: 1.5; }
.pc__urgent { align-self: flex-start; }
.pc__strip {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
padding: 0.75rem 0.875rem;
border-radius: var(--r-input);
background: color-mix(in srgb, var(--pc-tint) 6%, transparent);
}
.pc__cell { display: flex; flex-direction: column; gap: 0.25rem; }
.pc__cell-label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--mood-text-muted);
}
.pc__cell-value { font-size: 0.875rem; font-weight: 600; }
.pc__checks { display: flex; flex-direction: column; gap: 0.4rem; }
.pc__check {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
.pc__check input { accent-color: var(--mood-accent); width: 1rem; height: 1rem; }
.pc__block { margin: 0; font-size: 0.8125rem; font-weight: 600; color: var(--mood-error); }
.pc__main { align-self: stretch; font-size: 1.0625rem; min-height: 2.75rem; }
@media (min-width: 480px) {
.pc__main { align-self: flex-start; padding: 0.5rem 2rem; }
}
</style>
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
// Q0 « déjà décidé ? » inline results while typing, from the ONE search
// index (same as Cmd+K). Max 3: standing clauses first, then close decisions.
import type { Id } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
import { useSearch } from '~/composables/useSearch'
const props = defineProps<{ title: string; linkedClauseId?: Id }>()
const emit = defineEmits<{ (e: 'contest', clauseId: Id): void }>()
const col = useCollectiveStore()
const { search } = useSearch()
interface Q0Hit {
kind: 'clause' | 'decision'
id: Id
label: string
to: string
}
const hits = computed<Q0Hit[]>(() => {
const needle = props.title.trim()
if (needle.length < 3) return []
const out: Q0Hit[] = []
for (const hit of search(needle)) {
if (out.length >= 3) break
if (hit.kind === 'clause') {
const clause = col.clauses.find(c => c.id === hit.id)
const doc = col.docs.find(d => d.id === clause?.docId)
out.push({
kind: 'clause',
id: hit.id,
label: hit.label,
to: doc ? `/textes/${doc.slug}` : '/textes',
})
} else if (hit.kind === 'decision') {
out.push({ kind: 'decision', id: hit.id, label: hit.label, to: `/decisions/${hit.id}` })
}
}
return out
})
</script>
<template>
<!-- ld-v2 -->
<div v-if="hits.length > 0" class="q0">
<div v-for="hit in hits" :key="hit.id" class="q0__row">
<template v-if="hit.kind === 'clause'">
<UIcon name="i-lucide-book-open-check" class="q0__icon" />
<NuxtLink :to="hit.to" class="q0__link">
C'est déjà décidé ({{ hit.label }}) agis
</NuxtLink>
<button
class="q0__contest"
type="button"
:class="{ 'q0__contest--active': linkedClauseId === hit.id }"
@click="emit('contest', hit.id)"
>
Conteste la règle
</button>
</template>
<template v-else>
<UIcon name="i-lucide-history" class="q0__icon q0__icon--decision" />
<NuxtLink :to="hit.to" class="q0__link">
Décision proche : {{ hit.label }}
</NuxtLink>
</template>
</div>
</div>
</template>
<style scoped>
.q0 {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.25rem 0.25rem 0;
}
.q0__row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.875rem;
}
.q0__icon { color: var(--mood-accent); flex-shrink: 0; }
.q0__icon--decision { color: var(--mood-text-muted); }
.q0__link {
color: var(--mood-text);
font-weight: 600;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--mood-accent) 45%, transparent);
text-underline-offset: 3px;
}
.q0__link:hover { color: var(--mood-accent); }
.q0__contest {
background: none;
padding: 2px 10px;
border-radius: var(--r-pill);
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent);
background: var(--mood-accent-soft);
cursor: pointer;
}
.q0__contest--active { box-shadow: 0 0 0 2px var(--mood-accent); }
</style>
@@ -0,0 +1,174 @@
<script setup lang="ts">
// « Concernés » perimeter editor of the tunnel. The computed first-rank
// people are a FLOOR: no individual removal, ever. Widening is free.
import type { Decision, Person } from '~/types/domain'
import { useCollectiveStore } from '~/stores/collective'
const props = defineProps<{
scope: Decision['scope']
stack: { person: Person; origin?: 'computed' | 'declared'; reason?: string }[]
}>()
const emit = defineEmits<{ (e: 'update:scope', scope: Decision['scope']): void }>()
const col = useCollectiveStore()
const tappedReason = ref('')
const others = computed(() => col.people.filter(p => !p.isMe))
function patch(partial: Partial<Decision['scope']>) {
emit('update:scope', {
selfOnly: props.scope.selfOnly,
circleIds: [...props.scope.circleIds],
personIds: [...props.scope.personIds],
...partial,
})
}
function setSelfOnly(value: boolean) {
patch(value ? { selfOnly: true, circleIds: [], personIds: [] } : { selfOnly: false })
}
function toggleCircle(id: string) {
const ids = props.scope.circleIds.includes(id)
? props.scope.circleIds.filter(c => c !== id)
: [...props.scope.circleIds, id]
patch({ selfOnly: false, circleIds: ids })
}
function togglePerson(id: string) {
const ids = props.scope.personIds.includes(id)
? props.scope.personIds.filter(p => p !== id)
: [...props.scope.personIds, id]
patch({ selfOnly: false, personIds: ids })
}
function showReason(entry: { person: Person; reason?: string }) {
tappedReason.value = entry.reason
? `${entry.person.displayName}${entry.reason}`
: entry.person.displayName
}
</script>
<template>
<!-- ld-v2 -->
<div class="cs">
<div class="cs__modes">
<button
class="cs__mode"
:class="{ 'cs__mode--active': scope.selfOnly }"
type="button"
@click="setSelfOnly(true)"
>
Moi seul
</button>
<button
class="cs__mode"
:class="{ 'cs__mode--active': !scope.selfOnly }"
type="button"
@click="setSelfOnly(false)"
>
D'autres que moi
</button>
</div>
<template v-if="!scope.selfOnly">
<div v-if="stack.length > 0" class="cs__stack">
<LdAvatarStack :people="stack" :max="8" :size="30" @tap="showReason" />
<span class="cs__count">{{ stack.length }} concerné·e·s</span>
</div>
<p v-if="tappedReason" class="cs__reason">{{ tappedReason }}</p>
<div v-if="col.circles.length > 0" class="cs__group">
<span class="cs__label">Cercles</span>
<div class="cs__chips">
<button
v-for="circle in col.circles"
:key="circle.id"
class="cs__chip"
:class="{ 'cs__chip--on': scope.circleIds.includes(circle.id) }"
type="button"
@click="toggleCircle(circle.id)"
>
<UIcon
:name="circle.kind === 'place' ? 'i-lucide-map-pin'
: circle.kind === 'theme' ? 'i-lucide-tag' : 'i-lucide-users'"
/>
<span>{{ circle.name }}</span>
</button>
</div>
</div>
<div v-if="others.length > 0" class="cs__group">
<span class="cs__label">Personnes nommées</span>
<div class="cs__chips">
<button
v-for="person in others"
:key="person.id"
class="cs__chip"
:class="{ 'cs__chip--on': scope.personIds.includes(person.id) }"
type="button"
@click="togglePerson(person.id)"
>
{{ person.displayName }}
</button>
</div>
</div>
<p class="cs__floor">
On ne retire pas un·e concerné·e en premier lieu le périmètre s'élargit librement,
jamais en dessous du calcul.
</p>
</template>
</div>
</template>
<style scoped>
.cs { display: flex; flex-direction: column; gap: 0.75rem; }
.cs__modes { display: flex; gap: 0.5rem; }
.cs__mode {
padding: 0.4rem 1rem;
border-radius: var(--r-pill);
font-size: 0.875rem;
font-weight: 700;
background: var(--mood-input-bg);
color: var(--mood-text-muted);
cursor: pointer;
}
.cs__mode--active { background: var(--mood-accent); color: var(--mood-accent-text); }
.cs__stack { display: flex; align-items: center; gap: 0.625rem; }
.cs__count { font-size: 0.8125rem; font-weight: 600; color: var(--mood-text-muted); }
.cs__reason { margin: 0; font-size: 0.8125rem; color: var(--mood-accent); }
.cs__group { display: flex; flex-direction: column; gap: 0.4rem; }
.cs__label {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--mood-text-muted);
}
.cs__chips { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.cs__chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.75rem;
border-radius: var(--r-pill);
font-size: 0.8125rem;
font-weight: 600;
background: var(--mood-input-bg);
color: var(--mood-text-muted);
cursor: pointer;
min-height: 2.25rem;
}
.cs__chip--on {
background: var(--mood-accent-soft);
color: var(--mood-accent);
box-shadow: 0 0 0 1.5px var(--mood-accent);
}
.cs__floor {
margin: 0;
font-size: 0.75rem;
font-style: italic;
color: var(--mood-text-muted);
}
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
// R6 / maturation overlay under the path card a discreet suggestion,
// never blocking, never re-routing.
import type { Verdict } from '~/types/domain'
import { MATURATION_CARD } from '~/lexicon'
defineProps<{ suggestion: NonNullable<Verdict['suggestion']> }>()
const mandateAttached = defineModel<boolean>('mandateAttached', { required: true })
</script>
<template>
<!-- ld-v2 -->
<div class="ld-card sug">
<template v-if="suggestion.kind === 'claim-mandate'">
<UIcon name="i-lucide-repeat" class="sug__icon" />
<span>Encore cette décision réclame un mandat pour ne plus y revenir.</span>
<label class="sug__check">
<input v-model="mandateAttached" type="checkbox">
<span>Joindre la demande de mandat</span>
</label>
</template>
<template v-else-if="suggestion.kind === 'protocolize'">
<UIcon name="i-lucide-sprout" class="sug__icon" />
<NuxtLink to="/textes" class="sug__link">{{ MATURATION_CARD }}</NuxtLink>
</template>
<template v-else>
<UIcon name="i-lucide-book-open-check" class="sug__icon" />
<NuxtLink to="/textes" class="sug__link">
Cette pratique revient crée une règle en quelques phrases.
</NuxtLink>
</template>
</div>
</template>
<style scoped>
.sug {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.625rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 600;
}
.sug__icon { font-size: 1.125rem; color: var(--mood-accent); flex-shrink: 0; }
.sug__check {
display: inline-flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
color: var(--mood-accent);
}
.sug__check input { accent-color: var(--mood-accent); }
.sug__link { color: var(--mood-text); }
.sug__link:hover { color: var(--mood-accent); }
</style>
@@ -1,51 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
diff: string
}>()
interface DiffLine {
text: string
type: 'added' | 'removed' | 'header' | 'context'
}
const parsedLines = computed((): DiffLine[] => {
if (!props.diff) return []
return props.diff.split('\n').map((line) => {
if (line.startsWith('@@')) {
return { text: line, type: 'header' as const }
}
if (line.startsWith('+')) {
return { text: line, type: 'added' as const }
}
if (line.startsWith('-')) {
return { text: line, type: 'removed' as const }
}
return { text: line, type: 'context' as const }
})
})
function lineClass(type: DiffLine['type']): string {
switch (type) {
case 'added':
return 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300'
case 'removed':
return 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300'
case 'header':
return 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-semibold'
default:
return 'text-gray-700 dark:text-gray-300'
}
}
</script>
<template>
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<pre class="text-xs font-mono leading-relaxed overflow-x-auto"><template
v-for="(line, index) in parsedLines"
:key="index"
><div
:class="['px-4 py-0.5', lineClass(line.type)]"
>{{ line.text }}</div></template></pre>
</div>
</template>
@@ -1,70 +0,0 @@
<script setup lang="ts">
/**
* Error boundary component.
*
* Wraps slot content with NuxtErrorBoundary and displays a user-friendly
* error message in French when child components crash.
* Logs error details to console and emits error event for monitoring.
*/
const emit = defineEmits<{
error: [error: any]
}>()
const hasError = ref(false)
const errorDetails = ref<string | null>(null)
function handleError(error: any) {
hasError.value = true
errorDetails.value = error?.message || error?.toString() || 'Erreur inconnue'
// Log to console for debugging
console.error('[ErrorBoundary] Erreur capturee:', error)
// Emit for external monitoring
emit('error', error)
}
function retry() {
hasError.value = false
errorDetails.value = null
}
</script>
<template>
<NuxtErrorBoundary @error="handleError">
<template v-if="!hasError">
<slot />
</template>
<template v-else>
<div class="flex flex-col items-center justify-center p-8 text-center">
<div class="max-w-md space-y-4">
<UIcon
name="i-lucide-alert-triangle"
class="text-5xl text-warning mx-auto"
/>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Une erreur est survenue
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
Un probleme inattendu s'est produit lors du chargement de ce contenu.
Vous pouvez essayer de recharger cette section.
</p>
<p
v-if="errorDetails"
class="text-xs text-gray-400 dark:text-gray-500 font-mono bg-gray-100 dark:bg-gray-800 p-2 rounded"
>
{{ errorDetails }}
</p>
<UButton
icon="i-lucide-refresh-cw"
label="Reessayer"
color="primary"
variant="soft"
@click="retry"
/>
</div>
</div>
</template>
</NuxtErrorBoundary>
</template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
// <!-- ld-v2 --> Stacked initials avatars deterministic tint from id, -8px overlap.
// origin glyphs: 'computed' = full dot (concerné·e en premier lieu),
// 'declared' = raised hand (en second lieu). Tap parent shows inclusion reason.
import type { Person } from '~/types/domain'
const props = withDefaults(defineProps<{
people: { person: Person; origin?: 'computed' | 'declared'; reason?: string }[]
max?: number
size?: number
}>(), { max: 6, size: 30 })
const emit = defineEmits<{ (e: 'tap', entry: { person: Person; origin?: string; reason?: string }): void }>()
const visible = computed(() => props.people.slice(0, props.max))
const overflow = computed(() => Math.max(0, props.people.length - props.max))
function initials(name: string): string {
return name.split(/[\s-]+/).map(w => w[0] ?? '').join('').slice(0, 2).toUpperCase()
}
function tint(id: string): string {
let h = 0
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) % 360
return `oklch(0.72 0.09 ${h})`
}
</script>
<template>
<!-- ld-v2 -->
<div class="ld-avatars" :style="{ '--av-size': size + 'px' }">
<button
v-for="entry in visible"
:key="entry.person.id"
class="ld-avatars__item"
:style="{ background: tint(entry.person.id) }"
:title="entry.reason ? `${entry.person.displayName} — ${entry.reason}` : entry.person.displayName"
type="button"
@click="emit('tap', entry)"
>
<span>{{ initials(entry.person.displayName) }}</span>
<span v-if="entry.origin === 'declared'" class="ld-avatars__glyph">
<UIcon name="i-lucide-hand" />
</span>
<span v-else-if="entry.origin === 'computed'" class="ld-avatars__dot" />
</button>
<span v-if="overflow > 0" class="ld-avatars__more">+{{ overflow }}</span>
</div>
</template>
<style scoped>
.ld-avatars { display: inline-flex; align-items: center; }
.ld-avatars__item {
position: relative;
width: var(--av-size);
height: var(--av-size);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: calc(var(--av-size) * 0.36);
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
margin-left: -8px;
box-shadow: 0 0 0 2px var(--mood-surface);
cursor: pointer;
transition: transform 0.1s ease;
}
.ld-avatars__item:first-child { margin-left: 0; }
.ld-avatars__item:hover { transform: translateY(-1px); z-index: 1; }
.ld-avatars__glyph {
position: absolute;
right: -3px;
bottom: -3px;
width: calc(var(--av-size) * 0.48);
height: calc(var(--av-size) * 0.48);
border-radius: 50%;
background: var(--mood-surface);
color: var(--mood-accent);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: calc(var(--av-size) * 0.3);
}
.ld-avatars__dot {
position: absolute;
right: -1px;
bottom: -1px;
width: calc(var(--av-size) * 0.28);
height: calc(var(--av-size) * 0.28);
border-radius: 50%;
background: var(--mood-accent);
box-shadow: 0 0 0 2px var(--mood-surface);
}
.ld-avatars__more {
margin-left: 0.4rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-text-muted);
}
</style>
@@ -0,0 +1,52 @@
<script setup lang="ts">
// <!-- ld-v2 --> Window countdown chip. Suspended (boundary objection) shows the
// pause state the most important state to make visible.
import { BOUNDARY_SUSPENDED } from '~/lexicon'
const props = defineProps<{
endsAt?: string
suspendedAt?: string
}>()
const label = computed(() => {
if (props.suspendedAt) return BOUNDARY_SUSPENDED
if (!props.endsAt) return ''
const ms = new Date(props.endsAt).getTime() - Date.now()
if (ms <= 0) return 'échéance passée'
const h = Math.floor(ms / 3_600_000)
if (h < 1) return `${Math.max(1, Math.floor(ms / 60_000))} min restantes`
if (h < 48) return `${h} h restantes`
return `${Math.floor(h / 24)} jours restants`
})
</script>
<template>
<!-- ld-v2 -->
<span class="ld-countdown" :class="{ 'ld-countdown--paused': suspendedAt }">
<UIcon :name="suspendedAt ? 'i-lucide-pause' : 'i-lucide-timer'" />
<span>{{ label }}</span>
</span>
</template>
<style scoped>
.ld-countdown {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-status-fenetre);
background: var(--mood-status-fenetre-bg);
padding: 3px 10px;
border-radius: var(--r-pill);
}
.ld-countdown--paused {
background: repeating-linear-gradient(
-45deg,
var(--mood-status-fenetre-bg),
var(--mood-status-fenetre-bg) 6px,
transparent 6px,
transparent 10px
);
}
</style>
@@ -0,0 +1,51 @@
<script setup lang="ts">
/** Quatre pastilles rondes — une par ambiance. Ring accent sur l'actif. */
const { moods, currentMood, setMood } = useLibreMood()
</script>
<template>
<!-- ld-v2 -->
<div class="mood-switcher" role="group" aria-label="Ambiance">
<UTooltip v-for="m in moods" :key="m.id" :text="m.label">
<button
class="mood-dot"
:class="{ 'mood-dot--active': currentMood === m.id }"
:style="{ background: m.color }"
:aria-label="m.label"
:aria-pressed="currentMood === m.id"
@click="setMood(m.id)"
/>
</UTooltip>
</div>
</template>
<style scoped>
.mood-switcher {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem;
}
.mood-dot {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
cursor: pointer;
padding: 0;
flex-shrink: 0;
box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, 0.35), 0 1px 3px var(--mood-shadow);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.mood-dot:hover {
transform: translateY(-1px) scale(1.12);
}
.mood-dot:active {
transform: translateY(0);
}
.mood-dot--active {
box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, 0.35),
0 0 0 2px var(--mood-surface),
0 0 0 4px var(--mood-accent);
}
</style>
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
/**
* Sceau hexagramme #48 Tsing géométrie SVG canonique.
* viewBox 112×105, 6 traits (trait 1 en bas = dernier rect), coins vifs.
* Réutilisable : stamp du logo (miniature), sceau de page (.app-seal), badges.
*/
const props = withDefaults(defineProps<{ size?: number }>(), { size: 44 })
const height = computed(() => Math.round((props.size * 105) / 112))
</script>
<template>
<!-- ld-v2 -->
<svg
:width="size"
:height="height"
viewBox="0 0 112 105"
fill="currentColor"
aria-hidden="true"
>
<!-- Trait 6 (haut) yin -->
<rect x="6" y="6" width="42" height="8" />
<rect x="64" y="6" width="42" height="8" />
<!-- Trait 5 yang -->
<rect x="6" y="23" width="100" height="8" />
<!-- Trait 4 yin -->
<rect x="6" y="40" width="42" height="8" />
<rect x="64" y="40" width="42" height="8" />
<!-- Trait 3 yang -->
<rect x="6" y="57" width="100" height="8" />
<!-- Trait 2 yang -->
<rect x="6" y="74" width="100" height="8" />
<!-- Trait 1 (bas) yin -->
<rect x="6" y="91" width="42" height="8" />
<rect x="64" y="91" width="42" height="8" />
</svg>
</template>
@@ -0,0 +1,242 @@
<script setup lang="ts">
/**
* Sélecteur de collectif pastille couleur + nom + finalité (clause A1 du Pacte)
* en une ligne. Menu léger : collectifs locaux, « Créer un collectif », « Données locales ».
* Accès défensifs (?.) : le store peut ne pas être hydraté.
*/
import { useCollectiveStore } from '~/stores/collective'
const store = useCollectiveStore()
const route = useRoute()
const open = ref(false)
const rootEl = ref<HTMLElement | null>(null)
onClickOutside(rootEl, () => { open.value = false })
watch(() => route.path, () => { open.value = false })
const current = computed(() => store.current?.collective ?? null)
const index = computed(() => store.index ?? [])
/** Finalité du collectif = contenu de la version courante de la clause A1 du Pacte. */
const purpose = computed(() => {
const state = store.current
if (!state) return ''
const clause = state.clauses?.find(c => c.code === 'A1')
if (!clause?.currentVersionId) return ''
return state.versions?.find(v => v.id === clause.currentVersionId)?.content ?? ''
})
async function pick(id: string) {
open.value = false
if (id !== store.activeId) await store.switchTo?.(id)
}
</script>
<template>
<!-- ld-v2 -->
<div ref="rootEl" class="ws">
<!-- Aucun collectif : inviter à créer -->
<NuxtLink v-if="!current" to="/creer" class="ld-btn ld-btn--ghost ws__create">
<UIcon name="i-lucide-sparkles" />
<span>Créer</span>
</NuxtLink>
<template v-else>
<button
class="ws__trigger"
:aria-expanded="open"
aria-haspopup="menu"
@click="open = !open"
>
<span class="ws__dot" :style="{ background: current.color }" />
<span class="ws__text">
<span class="ws__name">{{ current.name }}</span>
<span v-if="purpose" class="ws__purpose">{{ purpose }}</span>
</span>
<UIcon
name="i-lucide-chevron-down"
class="ws__chevron"
:class="{ 'ws__chevron--open': open }"
/>
</button>
<Transition name="ws-pop">
<div v-if="open" class="ws__menu ld-card" role="menu">
<button
v-for="entry in index"
:key="entry.id"
class="ws__item"
:class="{ 'ws__item--active': entry.id === store.activeId }"
role="menuitem"
@click="pick(entry.id)"
>
<span class="ws__dot" :style="{ background: entry.color }" />
<span class="ws__item-name">{{ entry.name }}</span>
<UIcon
v-if="entry.id === store.activeId"
name="i-lucide-check"
class="ws__item-check"
/>
</button>
<div class="ws__sep" />
<NuxtLink to="/creer" class="ws__item" role="menuitem">
<UIcon name="i-lucide-sparkles" class="ws__item-icon" />
<span class="ws__item-name">Créer un collectif</span>
</NuxtLink>
<NuxtLink to="/donnees" class="ws__item" role="menuitem">
<UIcon name="i-lucide-hard-drive" class="ws__item-icon" />
<span class="ws__item-name">Données locales</span>
</NuxtLink>
</div>
</Transition>
</template>
</div>
</template>
<style scoped>
.ws {
position: relative;
display: inline-flex;
max-width: 100%;
}
.ws__create {
font-size: 0.875rem;
padding: 0.4rem 1rem;
}
.ws__trigger {
display: flex;
align-items: center;
gap: 0.625rem;
max-width: 20rem;
padding: 0.375rem 0.75rem;
background: none;
border-radius: var(--r-input);
cursor: pointer;
text-align: left;
transition: background 0.12s ease;
}
.ws__trigger:hover {
background: var(--mood-accent-soft);
}
.ws__dot {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 1px 3px var(--mood-shadow);
}
.ws__text {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.25;
}
.ws__name {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws__purpose {
font-size: 0.75rem;
color: var(--mood-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 16rem;
}
.ws__chevron {
font-size: 0.875rem;
color: var(--mood-text-muted);
flex-shrink: 0;
transition: transform 0.15s ease;
}
.ws__chevron--open {
transform: rotate(180deg);
}
/* --- Menu --- */
.ws__menu {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
z-index: 40;
min-width: 15rem;
max-width: 20rem;
padding: 0.375rem;
box-shadow: var(--shadow-raised);
}
.ws__item {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
min-height: 2.25rem;
padding: 0.5rem 0.75rem;
background: none;
border-radius: var(--r-input);
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
text-decoration: none;
cursor: pointer;
text-align: left;
transition: background 0.12s ease;
}
.ws__item:hover {
background: var(--mood-accent-soft);
}
.ws__item--active {
color: var(--mood-accent);
font-weight: 700;
}
.ws__item-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws__item-check {
font-size: 0.875rem;
color: var(--mood-accent);
flex-shrink: 0;
}
.ws__item-icon {
font-size: 1rem;
color: var(--mood-text-muted);
flex-shrink: 0;
}
.ws__sep {
height: 1px;
margin: 0.375rem 0.5rem;
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
}
/* --- Transition --- */
.ws-pop-enter-active,
.ws-pop-leave-active {
transition: opacity 0.12s ease, transform 0.12s ease;
}
.ws-pop-enter-from,
.ws-pop-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
@@ -1,81 +0,0 @@
<script setup lang="ts">
/**
* Reusable skeleton loader component.
*
* Provides multiple skeleton variants for loading states:
* - Card: card layout with title and content lines
* - List: multiple rows with optional avatar
* - Detail: detailed view with mixed content
*
* Uses Nuxt UI USkeleton components.
*/
const props = withDefaults(
defineProps<{
/** Number of skeleton lines to display (default: 3). */
lines?: number
/** Show an avatar circle placeholder. */
avatar?: boolean
/** Render as a card skeleton with border and padding. */
card?: boolean
}>(),
{
lines: 3,
avatar: false,
card: false,
},
)
/** Generate varying line widths for a natural appearance. */
const lineWidths = computed(() => {
const widths = ['w-full', 'w-3/4', 'w-5/6', 'w-2/3', 'w-4/5']
return Array.from({ length: props.lines }, (_, i) => widths[i % widths.length])
})
</script>
<template>
<!-- Card variant -->
<UCard v-if="card" class="animate-pulse">
<div class="space-y-4">
<!-- Optional avatar row -->
<div v-if="avatar" class="flex items-center gap-3">
<USkeleton class="h-10 w-10 rounded-full" />
<div class="flex-1 space-y-2">
<USkeleton class="h-4 w-1/3" />
<USkeleton class="h-3 w-1/4" />
</div>
</div>
<!-- Title line -->
<USkeleton class="h-5 w-2/3" />
<!-- Content lines -->
<div class="space-y-2">
<USkeleton
v-for="(width, i) in lineWidths"
:key="i"
class="h-3"
:class="width"
/>
</div>
</div>
</UCard>
<!-- List / default variant -->
<div v-else class="space-y-3 animate-pulse">
<div
v-for="(width, i) in lineWidths"
:key="i"
class="flex items-center gap-3"
>
<!-- Optional avatar per line -->
<USkeleton v-if="avatar" class="h-8 w-8 rounded-full flex-shrink-0" />
<!-- Line content -->
<div class="flex-1 space-y-1">
<USkeleton class="h-3" :class="width" />
<USkeleton v-if="i === 0" class="h-2 w-1/4" />
</div>
</div>
</div>
</template>
@@ -1,53 +0,0 @@
<script setup lang="ts">
const { currentMood, moods, setMood } = useMood()
</script>
<template>
<div class="mood-switcher" role="radiogroup" aria-label="Ambiance visuelle">
<UTooltip
v-for="mood in moods"
:key="mood.id"
:text="mood.label"
>
<button
type="button"
role="radio"
:aria-checked="currentMood === mood.id"
:aria-label="`Ambiance ${mood.label}`"
class="mood-dot"
:class="{ 'mood-dot--active': currentMood === mood.id }"
:style="{ '--dot-color': mood.color }"
@click="setMood(mood.id)"
/>
</UTooltip>
</div>
</template>
<style scoped>
.mood-switcher {
display: flex;
align-items: center;
gap: 0.375rem;
}
.mood-dot {
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 2px solid transparent;
background: var(--dot-color, var(--mood-text-muted));
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.mood-dot:hover {
transform: scale(1.25);
}
.mood-dot--active {
border-color: var(--mood-text);
box-shadow: 0 0 0 2px var(--mood-bg), 0 0 0 3px var(--mood-text-muted);
transform: scale(1.15);
}
</style>
@@ -1,95 +0,0 @@
<script setup lang="ts">
/**
* Offline detection banner.
*
* Uses navigator.onLine and online/offline events to detect
* network connectivity changes. Shows a warning banner when
* offline and a brief success message when back online.
*/
const isOnline = ref(true)
const showReconnected = ref(false)
let reconnectedTimer: ReturnType<typeof setTimeout> | null = null
function handleOffline() {
isOnline.value = false
showReconnected.value = false
if (reconnectedTimer) {
clearTimeout(reconnectedTimer)
reconnectedTimer = null
}
}
function handleOnline() {
isOnline.value = true
showReconnected.value = true
// Show "reconnected" message briefly then hide
reconnectedTimer = setTimeout(() => {
showReconnected.value = false
reconnectedTimer = null
}, 3000)
}
onMounted(() => {
isOnline.value = navigator.onLine
window.addEventListener('offline', handleOffline)
window.addEventListener('online', handleOnline)
})
onUnmounted(() => {
window.removeEventListener('offline', handleOffline)
window.removeEventListener('online', handleOnline)
if (reconnectedTimer) {
clearTimeout(reconnectedTimer)
}
})
</script>
<template>
<Transition name="slide-down">
<div
v-if="!isOnline"
class="bg-warning-100 dark:bg-warning-900/50 border-b border-warning-300 dark:border-warning-700 px-4 py-2 text-center"
role="alert"
>
<div class="flex items-center justify-center gap-2 text-sm text-warning-800 dark:text-warning-200">
<UIcon name="i-lucide-wifi-off" class="text-lg flex-shrink-0" />
<span>Vous etes hors ligne. Certaines fonctionnalites sont indisponibles.</span>
</div>
</div>
</Transition>
<Transition name="slide-down">
<div
v-if="showReconnected && isOnline"
class="bg-success-100 dark:bg-success-900/50 border-b border-success-300 dark:border-success-700 px-4 py-2 text-center"
role="status"
>
<div class="flex items-center justify-center gap-2 text-sm text-success-800 dark:text-success-200">
<UIcon name="i-lucide-wifi" class="text-lg flex-shrink-0" />
<span>Connexion retablie</span>
</div>
</div>
</Transition>
</template>
<style scoped>
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s ease;
}
.slide-down-enter-from,
.slide-down-leave-to {
transform: translateY(-100%);
opacity: 0;
}
.slide-down-enter-to,
.slide-down-leave-from {
transform: translateY(0);
opacity: 1;
}
</style>
@@ -1,318 +0,0 @@
<script setup lang="ts">
/**
* SectionLayout Mise en page pour sections.
*
* Status pills inside the content block (not header).
* Toolbox sidebar with condensed content.
*/
export interface StatusFilter {
id: string
label: string
count: number
cssClass?: string
}
export interface ToolboxItem {
title: string
description: string
actions: Array<{
label: string
to?: string
onClick?: () => void
}>
}
const props = withDefaults(
defineProps<{
title: string
subtitle?: string
statuses: StatusFilter[]
toolboxItems?: ToolboxItem[]
activeStatus?: string | null
}>(),
{
subtitle: undefined,
toolboxItems: undefined,
activeStatus: null,
},
)
const emit = defineEmits<{
'update:activeStatus': [status: string | null]
}>()
const toolboxOpen = ref(false)
const statusCssMap: Record<string, string> = {
draft: 'status-prepa',
qualification: 'status-prepa',
candidacy: 'status-prepa',
voting: 'status-vote',
review: 'status-vote',
active: 'status-vigueur',
executed: 'status-vigueur',
completed: 'status-vigueur',
closed: 'status-clos',
archived: 'status-clos',
revoked: 'status-clos',
reporting: 'status-vote',
}
function getStatusClass(status: StatusFilter): string {
return status.cssClass || statusCssMap[status.id] || 'status-prepa'
}
function toggleStatus(statusId: string) {
if (props.activeStatus === statusId) {
emit('update:activeStatus', null)
}
else {
emit('update:activeStatus', statusId)
}
}
</script>
<template>
<div class="section">
<!-- Header: just title -->
<div class="section__header">
<h1 class="section__title">{{ title }}</h1>
<p v-if="subtitle" class="section__subtitle">{{ subtitle }}</p>
</div>
<!-- Body: content + toolbox -->
<div class="section__body">
<div class="section__main">
<!-- Status pills INSIDE the list block -->
<div v-if="statuses.length > 0" class="section__pills">
<button
v-for="status in statuses"
:key="status.id"
type="button"
class="status-pill"
:class="[getStatusClass(status), { active: activeStatus === status.id }]"
@click="toggleStatus(status.id)"
>
{{ status.label }}
<span v-if="status.count > 0" class="section__pill-count">{{ status.count }}</span>
</button>
</div>
<div v-if="$slots.search" class="section__search">
<slot name="search" />
</div>
<div class="section__content">
<slot />
</div>
</div>
<aside class="section__toolbox">
<button class="section__toolbox-head" @click="toolboxOpen = !toolboxOpen">
<div class="section__toolbox-head-left">
<UIcon name="i-lucide-wrench" />
<span>Boite a outils</span>
</div>
<UIcon
:name="toolboxOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="section__toolbox-toggle"
/>
</button>
<div class="section__toolbox-content" :class="{ 'section__toolbox-content--open': toolboxOpen }">
<div v-if="$slots.toolbox" class="section__toolbox-body">
<slot name="toolbox" />
</div>
<div v-else-if="toolboxItems && toolboxItems.length > 0" class="section__toolbox-body">
<ToolboxVignette
v-for="(item, idx) in toolboxItems"
:key="idx"
:title="item.title"
/>
</div>
<div v-else class="section__toolbox-empty">
Aucun outil disponible
</div>
</div>
</aside>
</div>
</div>
</template>
<style scoped>
.section {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section__header {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.section__title {
font-size: 1.375rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.02em;
}
@media (min-width: 640px) {
.section__title {
font-size: 1.75rem;
}
}
.section__subtitle {
font-size: 0.875rem;
color: var(--mood-text-muted);
font-weight: 500;
}
@media (min-width: 640px) {
.section__subtitle {
font-size: 1rem;
}
}
.section__body {
display: grid;
grid-template-columns: 1fr 16rem;
gap: 1.5rem;
align-items: start;
}
.section__main {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
.section__pills {
display: flex;
gap: 0.5rem;
align-items: center;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-bottom: 2px;
}
.section__pills::-webkit-scrollbar {
display: none;
}
@media (min-width: 640px) {
.section__pills {
flex-wrap: wrap;
overflow-x: visible;
}
}
.section__pill-count {
margin-left: 0.25rem;
font-size: 0.6875rem;
font-weight: 800;
opacity: 0.7;
}
.section__search {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: stretch;
}
@media (max-width: 639px) {
.section__search {
flex-direction: column;
}
}
.section__content {
min-height: 12rem;
}
.section__toolbox {
position: sticky;
top: 4.5rem;
display: flex;
flex-direction: column;
background: var(--mood-surface);
border-radius: 16px;
overflow: hidden;
}
.section__toolbox-head {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 1rem;
cursor: pointer;
background: none;
}
.section__toolbox-head-left {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
font-weight: 800;
color: var(--mood-accent);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.section__toolbox-toggle {
color: var(--mood-text-muted);
font-size: 0.875rem;
}
.section__toolbox-content {
display: none;
padding: 0 1rem 1rem;
}
.section__toolbox-content--open {
display: block;
}
.section__toolbox-body {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.section__toolbox-empty {
font-size: 0.875rem;
color: var(--mood-text-muted);
text-align: center;
padding: 1rem 0;
}
/* Desktop: toolbox always open, no toggle */
@media (min-width: 1024px) {
.section__toolbox-head {
cursor: default;
}
.section__toolbox-toggle {
display: none;
}
.section__toolbox-content {
display: block;
}
}
@media (max-width: 1023px) {
.section__body {
grid-template-columns: 1fr;
}
.section__toolbox {
position: static;
order: 2;
}
}
</style>
@@ -1,73 +0,0 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
status: string
type?: 'document' | 'decision' | 'mandate' | 'vote' | 'version'
clickable?: boolean
active?: boolean
}>(), {
clickable: true,
active: false,
})
const emit = defineEmits<{
click: []
}>()
const STATUS_MAP: Record<string, { label: string; cssClass: string }> = {
// Universal statuses
draft: { label: 'En prepa', cssClass: 'status-prepa' },
active: { label: 'En vigueur', cssClass: 'status-vigueur' },
closed: { label: 'Clos', cssClass: 'status-clos' },
// Decision/vote specific
qualification: { label: 'En prepa', cssClass: 'status-prepa' },
review: { label: 'En prepa', cssClass: 'status-prepa' },
voting: { label: 'En vote', cssClass: 'status-vote' },
open: { label: 'En vote', cssClass: 'status-vote' },
executed: { label: 'En vigueur', cssClass: 'status-vigueur' },
// Version specific
pending: { label: 'En prepa', cssClass: 'status-prepa' },
accepted: { label: 'En vigueur', cssClass: 'status-vigueur' },
rejected: { label: 'Clos', cssClass: 'status-clos' },
// Mandate specific
formulation: { label: 'En prepa', cssClass: 'status-prepa' },
candidature: { label: 'En prepa', cssClass: 'status-prepa' },
candidacy: { label: 'En prepa', cssClass: 'status-prepa' },
investiture: { label: 'En vote', cssClass: 'status-vote' },
revoked: { label: 'Clos', cssClass: 'status-clos' },
completed: { label: 'Clos', cssClass: 'status-clos' },
archived: { label: 'Clos', cssClass: 'status-clos' },
reporting: { label: 'En vote', cssClass: 'status-vote' },
}
const resolved = computed(() => {
return STATUS_MAP[props.status] ?? { label: props.status, cssClass: 'status-prepa' }
})
function handleClick() {
if (props.clickable) {
emit('click')
}
}
</script>
<template>
<button
v-if="clickable"
type="button"
class="status-pill"
:class="[resolved.cssClass, { active: active }]"
@click="handleClick"
>
{{ resolved.label }}
</button>
<span
v-else
class="status-pill"
:class="[resolved.cssClass]"
>
{{ resolved.label }}
</span>
</template>

Some files were not shown because too many files have changed in this diff Show More