forked from yvv/decision
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f56d84e76b | |||
| 3423ac2e7e | |||
| 2d2ac79cd5 | |||
| 21bca67e6a | |||
| 56d72eeec2 | |||
| 3b339b643c | |||
| 3ba9c43ce3 | |||
| 1f92f153c5 | |||
| 9b6322c546 | |||
| 9a8f10efdf | |||
| e2ae8b196e | |||
| 5c51cffc93 | |||
| 428299c9c8 | |||
| fc84600f97 | |||
| 79e468b40f | |||
| 224e5b0f5e | |||
| 02629c4e68 | |||
| b63c6f95e1 | |||
| 075c3c944c | |||
| 8c2e8d5d5b | |||
| b9d2fd1c6d | |||
| 99c5e9dd5a | |||
| 0310dcba42 | |||
| 1842303c3b | |||
| 798b3a83d4 | |||
| a8dc0dfb40 | |||
| feaf4de7e0 | |||
| 73c5bf148c | |||
| 6509137892 | |||
| 488114791c | |||
| 3e702fdbf3 | |||
| e24c2a65a0 | |||
| 53fc9927ef | |||
| a9599ba32a | |||
| ed9ed11cd4 | |||
| 290548703d |
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
POSTGRES_DB=glibredecision
|
POSTGRES_DB=libredecision
|
||||||
POSTGRES_USER=glibredecision
|
POSTGRES_USER=libredecision
|
||||||
POSTGRES_PASSWORD=change-me-in-production
|
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
|
# Backend
|
||||||
SECRET_KEY=change-me-in-production-with-a-real-secret-key
|
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
|
NUXT_PUBLIC_API_BASE=http://localhost:8002/api/v1
|
||||||
|
|
||||||
# Docker / Production
|
# Docker / Production
|
||||||
DOMAIN=glibredecision.org
|
DOMAIN=decision.librodrome.org
|
||||||
|
|||||||
+216
-53
@@ -1,75 +1,238 @@
|
|||||||
when:
|
when:
|
||||||
branch: main
|
- branch: main
|
||||||
event: push
|
event: push
|
||||||
|
|
||||||
steps:
|
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
|
image: python:3.11-slim
|
||||||
commands:
|
commands:
|
||||||
- cd backend
|
- cd backend
|
||||||
- pip install --no-cache-dir -r requirements.txt
|
- pip install --no-cache-dir -r requirements.txt
|
||||||
- pytest app/tests/ -v --tb=short
|
- pytest app/tests/ -v --tb=short
|
||||||
|
|
||||||
test-frontend:
|
- name: test-frontend
|
||||||
image: node:20-slim
|
image: node:20-slim
|
||||||
commands:
|
commands:
|
||||||
- cd frontend
|
- cd frontend
|
||||||
- npm ci
|
- npm ci
|
||||||
- npm run build
|
- npm run build
|
||||||
|
|
||||||
docker-backend:
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
- name: build-backend
|
||||||
|
image: docker:27-cli
|
||||||
depends_on:
|
depends_on:
|
||||||
- test-backend
|
- test-backend
|
||||||
settings:
|
volumes:
|
||||||
repo: ${CI_FORGE_URL}/${CI_REPO}
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
dockerfile: docker/backend.Dockerfile
|
commands:
|
||||||
context: .
|
- docker build -t libredecision-backend:latest -f docker/backend.Dockerfile --target production .
|
||||||
tag:
|
- echo "Image backend construite"
|
||||||
- latest
|
|
||||||
- ${CI_COMMIT_SHA:0:8}
|
|
||||||
target: production
|
|
||||||
registry:
|
|
||||||
from_secret: docker_registry
|
|
||||||
username:
|
|
||||||
from_secret: docker_username
|
|
||||||
password:
|
|
||||||
from_secret: docker_password
|
|
||||||
|
|
||||||
docker-frontend:
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
- name: build-frontend
|
||||||
|
image: docker:27-cli
|
||||||
depends_on:
|
depends_on:
|
||||||
- test-frontend
|
- test-frontend
|
||||||
settings:
|
volumes:
|
||||||
repo: ${CI_FORGE_URL}/${CI_REPO}
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
dockerfile: docker/frontend.Dockerfile
|
commands:
|
||||||
context: .
|
- docker build -t libredecision-frontend:latest -f docker/frontend.Dockerfile --target production .
|
||||||
tag:
|
- echo "Image frontend construite"
|
||||||
- latest
|
|
||||||
- ${CI_COMMIT_SHA:0:8}
|
|
||||||
target: production
|
|
||||||
registry:
|
|
||||||
from_secret: docker_registry
|
|
||||||
username:
|
|
||||||
from_secret: docker_username
|
|
||||||
password:
|
|
||||||
from_secret: docker_password
|
|
||||||
|
|
||||||
deploy:
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
image: appleboy/drone-ssh
|
- name: sbom-generate
|
||||||
|
image: alpine:3.20
|
||||||
depends_on:
|
depends_on:
|
||||||
- docker-backend
|
- build-backend
|
||||||
- docker-frontend
|
- build-frontend
|
||||||
settings:
|
volumes:
|
||||||
host:
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
from_secret: deploy_host
|
commands:
|
||||||
username:
|
- apk add --no-cache curl
|
||||||
from_secret: deploy_username
|
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
|
||||||
key:
|
- mkdir -p .reports
|
||||||
from_secret: deploy_key
|
- syft libredecision-backend:latest -o cyclonedx-json --file .reports/sbom-backend.cyclonedx.json
|
||||||
port: 22
|
- syft libredecision-frontend:latest -o cyclonedx-json --file .reports/sbom-frontend.cyclonedx.json
|
||||||
script:
|
- echo "SBOM generes"
|
||||||
- cd /opt/glibredecision
|
|
||||||
- docker compose -f docker/docker-compose.yml pull
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
- docker compose -f docker/docker-compose.yml up -d --remove-orphans
|
- name: sbom-scan
|
||||||
- docker image prune -f
|
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
|
||||||
|
- name: sbom-publish
|
||||||
|
image: alpine/curl:latest
|
||||||
|
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
|
||||||
|
- 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" -ne 0 ] && [ "$ACME_EXIT" -ne 2 ]; then
|
||||||
|
echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
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)"
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -1,25 +1,88 @@
|
|||||||
# Glibredecision
|
# libreDecision
|
||||||
|
|
||||||
Plateforme de decisions collectives pour la communaute Duniter/G1.
|
Boîte à outils de gouvernance collective pour la communauté Duniter/G1.
|
||||||
|
Documents modulaires sous vote permanent + protocoles de vote + mandats.
|
||||||
|
Architecture marque blanche — vocation à être intégré dans sweethomeCloud et librodrome.
|
||||||
|
|
||||||
|
## Protocole de début de session
|
||||||
|
|
||||||
|
1. `git pull --rebase origin main`
|
||||||
|
2. Si des migrations DB sont attendues : `cd backend && alembic upgrade head`
|
||||||
|
3. Si l'objectif de la session n'est pas précisé, le demander
|
||||||
|
|
||||||
## Stack
|
## 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
|
- **Frontend** : Nuxt 4 (Vue 3, TypeScript) + Nuxt UI v3 + Pinia + UnoCSS ; package manager : npm
|
||||||
- Backend: `cd backend && uvicorn app.main:app --port 8002 --reload`
|
- **Backend** : Python FastAPI + SQLAlchemy 2.0 async + PostgreSQL asyncpg ; migrations Alembic
|
||||||
- Backend tests: `cd backend && pytest tests/ -v`
|
- **Auth** : Duniter V2 Ed25519 challenge-response (substrate-interface — stub en dev)
|
||||||
- Frontend: `cd frontend && npm run dev`
|
- **Sanctuaire** : IPFS kubo + hash on-chain (system.remark) — TODO sprint 2
|
||||||
- Frontend build: `cd frontend && npm run build`
|
- Déploiement : Docker multi-stage + Traefik (postgres + backend + frontend + ipfs) ; CI Woodpecker
|
||||||
- Migrations: `cd backend && alembic upgrade head`
|
|
||||||
- Docker: `docker compose -f docker/docker-compose.yml up`
|
|
||||||
|
|
||||||
## Conventions
|
## Structure
|
||||||
- French for UI text and documentation
|
|
||||||
- English for code (variable names, comments, docstrings)
|
```
|
||||||
- API versioned under `/api/v1/`
|
frontend/
|
||||||
- Pydantic v2 for all schemas
|
app/
|
||||||
- Async everywhere (SQLAlchemy, FastAPI)
|
components/ # composants Vue
|
||||||
- Ed25519 signatures for vote integrity
|
layouts/ # layouts Nuxt
|
||||||
|
pages/ # routing file-based (9 pages sprint 1)
|
||||||
|
composables/ # (1 composable sprint 1)
|
||||||
|
stores/ # 5 Pinia stores (auth, ...)
|
||||||
|
assets/css/
|
||||||
|
moods.css # système de palettes (.mood-* sur <html>)
|
||||||
|
utils/ # (2 utils sprint 1)
|
||||||
|
nuxt.config.ts # port 3002, host 0.0.0.0, apiBase via NUXT_PUBLIC_API_BASE
|
||||||
|
backend/
|
||||||
|
app/
|
||||||
|
routers/ # 8 routers : auth, communes, documents, protocols, votes, ...
|
||||||
|
services/ # 6 services
|
||||||
|
engine/ # 5 modules : formule inertie, critères Smith/TechComm, médiane
|
||||||
|
models/ # 14 tables SQLAlchemy
|
||||||
|
alembic/versions/ # migrations
|
||||||
|
tests/ # 186 tests (63 intégration TDD sprint 1)
|
||||||
|
seed.py # Engagement Certification (33 items) + Forgeron (51 items) + Runtime Upgrade
|
||||||
|
docker/
|
||||||
|
docker-compose.yml # postgres + backend + frontend + ipfs
|
||||||
|
backend.Dockerfile
|
||||||
|
frontend.Dockerfile
|
||||||
|
docs/content/ # 7 docs dev + 8 docs user
|
||||||
|
public/
|
||||||
|
hexagram-tsing.svg # sceau 井 (embossed)
|
||||||
|
hexagram-tsing-flat.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
## Données runtime
|
||||||
|
|
||||||
|
- **postgres-data** : volume Docker PostgreSQL — jamais écrasé par les builds
|
||||||
|
- **ipfs-data** : volume Docker IPFS kubo
|
||||||
|
- `.env` à la racine : `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `SECRET_KEY`, `DOMAIN`, `DUNITER_RPC_URL`
|
||||||
|
|
||||||
|
## Commandes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && . venv/bin/activate
|
||||||
|
uvicorn app.main:app --port 8002 --host 0.0.0.0 --reload
|
||||||
|
pytest tests/ -v
|
||||||
|
alembic upgrade head
|
||||||
|
python seed.py # reseed Engagement Certification + Forgeron + Runtime Upgrade
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm run dev # :3002
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker compose -f docker/docker-compose.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions / pièges
|
||||||
|
|
||||||
|
- **UI français, code anglais** (variables, commentaires, docstrings)
|
||||||
|
- **API** : préfixe `/api/v1/`, Pydantic v2 pour tous les schémas, async partout (SQLAlchemy + FastAPI)
|
||||||
|
- **Auth** : `get_current_admin` (24h), `get_current_citizen` (4h), `require_super_admin`
|
||||||
|
- **Formule inertie** : `Result = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)` — voir `backend/app/engine/`
|
||||||
|
- **Mood system** : `useMood.ts` synchronise `colorMode.preference` avec la palette — **jamais** de `:global()` dans `<style scoped>` pour les styles mood-dépendants (causa le bug dark mode veil)
|
||||||
|
- **Sceau** `井` (#48 Tsing) : `.app-seal` dans `app.vue`, right-aligned ; SVGs dans `public/`
|
||||||
|
- **CSS drop-shadow()** safe pour effets emboss ; `<filter>` SVG inline cause des artefacts de rendu
|
||||||
|
- **Domaine** : decision.librodrome.org (Woodpecker CI ; ancien dossier : Glibredecision)
|
||||||
|
- **Ed25519 verification** : stub en dev (substrate-interface), autoritaire en prod — ne pas bypasser sans test
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Contribuer à libreDecision
|
||||||
|
|
||||||
|
## Environnement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend (Python 3.11+)
|
||||||
|
cd backend
|
||||||
|
python -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
alembic upgrade head
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload
|
||||||
|
|
||||||
|
# Frontend (Node 20+)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **UI** : français — **Code** : anglais (variables, commentaires, docstrings)
|
||||||
|
- **CSS** : scoped, sans bordures (`border: none`), profondeur via `box-shadow`
|
||||||
|
- **Composants** : `pathPrefix: false` — noms courts, auto-import
|
||||||
|
- **API** : versionnée `/api/v1/`, Pydantic v2, async partout
|
||||||
|
- **Ports stricts** : frontend=3002, backend=8002 — jamais de fallback
|
||||||
|
|
||||||
|
## Architecture toolbox
|
||||||
|
|
||||||
|
Chaque section expose une `<SectionLayout>` avec :
|
||||||
|
- Contenu principal (slot `#default`)
|
||||||
|
- Boîte à outils sticky (slot `#toolbox`) — 30rem, flottante, zéro scroll
|
||||||
|
|
||||||
|
Composants toolbox :
|
||||||
|
- `ToolboxSection` : accordéon collapsible générique
|
||||||
|
- `ToolboxVignette` : carte compacte avec bullets toggleables
|
||||||
|
- `toolbox/ContextMapper` : recommandeur de méthode (4 questions → méthode optimale)
|
||||||
|
- `toolbox/SocioElection` : guide élection sociocratique + advice process
|
||||||
|
- `toolbox/WorkflowMilestones` : jalons de protocole (Ostrom)
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
186 tests, zéro dette technique acceptée depuis le sprint 1.
|
||||||
|
|
||||||
|
## Formule de vote inertiel
|
||||||
|
|
||||||
|
`R = C + B^W + (M + (1-M)·(1-(T/W)^G))·max(0, T-C)`
|
||||||
|
|
||||||
|
Voir `docs/content/dev/` pour la documentation complète.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# libreDecision
|
||||||
|
|
||||||
|
Plateforme de décisions collectives pour la communauté Duniter/G1.
|
||||||
|
Boîte à outils gouvernance multi-collectifs, architecture white-label.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Frontend** : Nuxt 4 + Vue 3 + Pinia + UnoCSS (port 3002)
|
||||||
|
- **Backend** : Python FastAPI + SQLAlchemy async + SQLite (port 8002)
|
||||||
|
- **Auth** : Duniter V2 Ed25519 challenge-response
|
||||||
|
- **Sanctuaire** : IPFS (kubo) + hash on-chain (system.remark)
|
||||||
|
|
||||||
|
## Démarrage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8002 --reload
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sections
|
||||||
|
|
||||||
|
- **Décisions** : processus de vote collectif avec boîte à outils (ContextMapper, consentement, advice process)
|
||||||
|
- **Mandats** : élection sociocratique, cycle de mandat, révocation
|
||||||
|
- **Documents** : documents de référence sous vote permanent, niveaux d'inertie, sanctuaire IPFS
|
||||||
|
- **Protocoles** : protocoles opérationnels, jalons de workflow, formules de vote
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Voir `CLAUDE.md` pour les conventions et `docs/content/dev/` pour la documentation technique.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Alembic async environment for Glibredecision.
|
"""Alembic async environment for libreDecision.
|
||||||
|
|
||||||
Uses asyncpg via SQLAlchemy's async engine.
|
Uses asyncpg via SQLAlchemy's async engine.
|
||||||
All models are imported so that Base.metadata is fully populated
|
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
|
from app.models import ( # noqa: F401
|
||||||
DuniterIdentity,
|
DuniterIdentity,
|
||||||
Session,
|
Session,
|
||||||
|
Organization,
|
||||||
|
OrgMember,
|
||||||
Document,
|
Document,
|
||||||
DocumentItem,
|
DocumentItem,
|
||||||
ItemVersion,
|
ItemVersion,
|
||||||
@@ -37,6 +39,7 @@ from app.models import ( # noqa: F401
|
|||||||
MandateStep,
|
MandateStep,
|
||||||
VotingProtocol,
|
VotingProtocol,
|
||||||
FormulaConfig,
|
FormulaConfig,
|
||||||
|
QualificationProtocol,
|
||||||
SanctuaryEntry,
|
SanctuaryEntry,
|
||||||
BlockchainCache,
|
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")
|
||||||
@@ -3,15 +3,16 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
APP_NAME: str = "Glibredecision"
|
APP_NAME: str = "libreDecision"
|
||||||
DEBUG: bool = True
|
DEBUG: bool = True
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
ENVIRONMENT: str = "development" # development, staging, production
|
ENVIRONMENT: str = "development" # development, staging, production
|
||||||
|
DEMO_MODE: bool = False # Enable demo profiles (quick login) regardless of ENVIRONMENT
|
||||||
LOG_LEVEL: str = "INFO"
|
LOG_LEVEL: str = "INFO"
|
||||||
|
|
||||||
# Database — SQLite by default for local dev, PostgreSQL for Docker/prod
|
# 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_POOL_SIZE: int = 20
|
||||||
DATABASE_MAX_OVERFLOW: int = 10
|
DATABASE_MAX_OVERFLOW: int = 10
|
||||||
|
|
||||||
@@ -38,6 +39,9 @@ class Settings(BaseSettings):
|
|||||||
RATE_LIMIT_AUTH: int = 10
|
RATE_LIMIT_AUTH: int = 10
|
||||||
RATE_LIMIT_VOTE: int = 30
|
RATE_LIMIT_VOTE: int = 30
|
||||||
|
|
||||||
|
# AI — Qwen3.6 (MacStudio) endpoint, branché plus tard
|
||||||
|
QWEN_API_URL: str = ""
|
||||||
|
|
||||||
# Blockchain cache
|
# Blockchain cache
|
||||||
BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600
|
BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -13,6 +13,9 @@ from app.middleware.rate_limiter import RateLimiterMiddleware
|
|||||||
from app.middleware.security_headers import SecurityHeadersMiddleware
|
from app.middleware.security_headers import SecurityHeadersMiddleware
|
||||||
from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket
|
from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket
|
||||||
from app.routers import public
|
from app.routers import public
|
||||||
|
from app.routers import organizations
|
||||||
|
from app.routers import qualify
|
||||||
|
from app.routers import groups
|
||||||
|
|
||||||
|
|
||||||
# ── Structured logging setup ───────────────────────────────────────────────
|
# ── Structured logging setup ───────────────────────────────────────────────
|
||||||
@@ -85,8 +88,28 @@ app = FastAPI(
|
|||||||
|
|
||||||
|
|
||||||
# ── Middleware stack ──────────────────────────────────────────────────────
|
# ── Middleware stack ──────────────────────────────────────────────────────
|
||||||
# Middleware is applied in reverse order: last added = first executed.
|
# add_middleware is LIFO: last added = outermost = first to execute on request,
|
||||||
# Order: SecurityHeaders -> RateLimiter -> CORS -> Application
|
# 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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -96,15 +119,6 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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 ──────────────────────────────────────────────────────────────
|
# ── 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(sanctuary.router, prefix="/api/v1/sanctuary", tags=["sanctuary"])
|
||||||
app.include_router(websocket.router, prefix="/api/v1/ws", tags=["websocket"])
|
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(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 ─────────────────────────────────────────────────────────
|
# ── Health check ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -64,14 +64,6 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
|||||||
self._last_cleanup: float = time.time()
|
self._last_cleanup: float = time.time()
|
||||||
self._lock = asyncio.Lock()
|
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:
|
def _get_client_ip(self, request: Request) -> str:
|
||||||
"""Extract the client IP from the request, respecting X-Forwarded-For."""
|
"""Extract the client IP from the request, respecting X-Forwarded-For."""
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
@@ -101,6 +93,22 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
|||||||
if ips_to_delete:
|
if ips_to_delete:
|
||||||
logger.debug("Nettoyage rate limiter: %d IPs supprimees", len(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:
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
"""Check rate limit and either allow the request or return 429."""
|
"""Check rate limit and either allow the request or return 429."""
|
||||||
# Skip rate limiting for WebSocket upgrades
|
# Skip rate limiting for WebSocket upgrades
|
||||||
@@ -111,8 +119,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
|||||||
await self._cleanup_old_entries()
|
await self._cleanup_old_entries()
|
||||||
|
|
||||||
client_ip = self._get_client_ip(request)
|
client_ip = self._get_client_ip(request)
|
||||||
path = request.url.path
|
limit = self._get_limit_for_request(request)
|
||||||
limit = self._get_limit_for_path(path)
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
window_start = now - 60
|
window_start = now - 60
|
||||||
|
|
||||||
@@ -133,7 +140,7 @@ class RateLimiterMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Rate limit depasse pour %s sur %s (%d/%d)",
|
"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(
|
return JSONResponse(
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
from app.models.user import DuniterIdentity, Session
|
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.document import Document, DocumentItem, ItemVersion
|
||||||
from app.models.decision import Decision, DecisionStep
|
from app.models.decision import Decision, DecisionStep
|
||||||
from app.models.vote import VoteSession, Vote
|
from app.models.vote import VoteSession, Vote
|
||||||
from app.models.mandate import Mandate, MandateStep
|
from app.models.mandate import Mandate, MandateStep
|
||||||
from app.models.protocol import VotingProtocol, FormulaConfig
|
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.sanctuary import SanctuaryEntry
|
||||||
from app.models.cache import BlockchainCache
|
from app.models.cache import BlockchainCache
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DuniterIdentity", "Session",
|
"DuniterIdentity", "Session",
|
||||||
|
"Organization", "OrgMember",
|
||||||
"Document", "DocumentItem", "ItemVersion",
|
"Document", "DocumentItem", "ItemVersion",
|
||||||
"Decision", "DecisionStep",
|
"Decision", "DecisionStep",
|
||||||
"VoteSession", "Vote",
|
"VoteSession", "Vote",
|
||||||
"Mandate", "MandateStep",
|
"Mandate", "MandateStep",
|
||||||
"VotingProtocol", "FormulaConfig",
|
"VotingProtocol", "FormulaConfig",
|
||||||
|
"QualificationProtocol",
|
||||||
|
"Group", "GroupMember",
|
||||||
"SanctuaryEntry",
|
"SanctuaryEntry",
|
||||||
"BlockchainCache",
|
"BlockchainCache",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Decision(Base):
|
|||||||
context: Mapped[str | None] = mapped_column(Text)
|
context: Mapped[str | None] = mapped_column(Text)
|
||||||
decision_type: Mapped[str] = mapped_column(String(64), nullable=False) # runtime_upgrade, document_change, mandate_vote, custom
|
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
|
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"))
|
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_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())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class Document(Base):
|
|||||||
version: Mapped[str] = mapped_column(String(32), default="0.1.0")
|
version: Mapped[str] = mapped_column(String(32), default="0.1.0")
|
||||||
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, active, archived
|
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, active, archived
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
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))
|
ipfs_cid: Mapped[str | None] = mapped_column(String(128))
|
||||||
chain_anchor: 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
|
genesis_json: Mapped[str | None] = mapped_column(Text) # JSON: source files, repos, forum URLs, formula trigger
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -12,9 +12,11 @@ class Mandate(Base):
|
|||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
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)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom
|
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
status: Mapped[str] = mapped_column(String(32), default="draft") # draft, candidacy, voting, active, reporting, completed, revoked
|
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"))
|
mandatee_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("duniter_identities.id"))
|
||||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
|
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
|
||||||
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
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())
|
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())
|
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):
|
class MandateStep(Base):
|
||||||
@@ -31,12 +53,16 @@ class MandateStep(Base):
|
|||||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
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)
|
mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False)
|
||||||
step_order: Mapped[int] = mapped_column(Integer, 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))
|
title: Mapped[str | None] = mapped_column(String(256))
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
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"))
|
vote_session_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("vote_sessions.id"))
|
||||||
outcome: Mapped[str | None] = mapped_column(Text)
|
outcome: Mapped[str | None] = mapped_column(Text)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
mandate: Mapped["Mandate"] = relationship(back_populates="steps")
|
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
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -44,6 +44,7 @@ class VotingProtocol(Base):
|
|||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced
|
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)
|
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"
|
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)
|
is_meta_governed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -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
@@ -5,7 +5,8 @@ from __future__ import annotations
|
|||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -45,15 +46,15 @@ DEV_PROFILES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"address": "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmkP7j4bJa3zN7d8tY",
|
"address": "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmkP7j4bJa3zN7d8tY",
|
||||||
"display_name": "Charlie (Comite Tech)",
|
"display_name": "Charlie (Référent structure)",
|
||||||
"wot_status": "member",
|
"wot_status": "member",
|
||||||
"is_smith": True,
|
"is_smith": True,
|
||||||
"is_techcomm": True,
|
"is_techcomm": True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"address": "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy",
|
"address": "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy",
|
||||||
"display_name": "Dave (Observateur)",
|
"display_name": "Dave (Auteur)",
|
||||||
"wot_status": "unknown",
|
"wot_status": "member",
|
||||||
"is_smith": False,
|
"is_smith": False,
|
||||||
"is_techcomm": False,
|
"is_techcomm": False,
|
||||||
},
|
},
|
||||||
@@ -132,22 +133,51 @@ async def verify_challenge(
|
|||||||
detail="Challenge invalide",
|
detail="Challenge invalide",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Verify Ed25519 signature
|
# 4. Verify signature
|
||||||
# TODO: Implement actual Ed25519 verification using substrate-interface
|
# TODO: trustWallet — déléguer la vérification au protocole trustWallet (librodrome)
|
||||||
# For now we accept any signature to allow development/testing.
|
# Quand trustWallet sera disponible : remplacer le bloc ci-dessous par une vérification
|
||||||
# In production this MUST verify: verify(address_pubkey, challenge_bytes, signature_bytes)
|
# du token signé fourni par trustWallet (JWT ou preuve Ed25519 via iframe postMessage).
|
||||||
#
|
# Le bypass DEMO_MODE sera alors supprimé.
|
||||||
# from substrateinterface import Keypair
|
_demo_addresses = {p["address"] for p in DEV_PROFILES}
|
||||||
# keypair = Keypair(ss58_address=payload.address)
|
is_demo_bypass = (settings.DEMO_MODE or settings.ENVIRONMENT == "development") and payload.address in _demo_addresses
|
||||||
# if not keypair.verify(payload.challenge.encode(), bytes.fromhex(payload.signature)):
|
|
||||||
# raise HTTPException(status_code=401, detail="Signature invalide")
|
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
|
# 5. Consume the challenge
|
||||||
del _pending_challenges[payload.address]
|
del _pending_challenges[payload.address]
|
||||||
|
|
||||||
# 6. Get or create identity (apply dev profile if available)
|
# 6. Get or create identity (apply dev profile if available)
|
||||||
dev_profile = None
|
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)
|
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)
|
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")
|
@router.get("/dev/profiles")
|
||||||
async def list_dev_profiles():
|
async def list_dev_profiles():
|
||||||
"""List available dev profiles for quick login. Only available in development."""
|
"""List available demo profiles for quick login. Available in development or demo mode."""
|
||||||
if settings.ENVIRONMENT != "development":
|
if settings.ENVIRONMENT != "development" and not settings.DEMO_MODE:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not available")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not available")
|
||||||
return DEV_PROFILES
|
return DEV_PROFILES
|
||||||
|
|
||||||
@@ -203,3 +233,24 @@ async def logout(
|
|||||||
for session in sessions:
|
for session in sessions:
|
||||||
await db.delete(session)
|
await db.delete(session)
|
||||||
await db.commit()
|
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()]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.schemas.decision import (
|
|||||||
DecisionUpdate,
|
DecisionUpdate,
|
||||||
)
|
)
|
||||||
from app.schemas.vote import VoteSessionOut
|
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.auth_service import get_current_identity
|
||||||
from app.services.decision_service import advance_decision, create_vote_session_for_step
|
from app.services.decision_service import advance_decision, create_vote_session_for_step
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
|
|||||||
@router.get("/", response_model=list[DecisionOut])
|
@router.get("/", response_model=list[DecisionOut])
|
||||||
async def list_decisions(
|
async def list_decisions(
|
||||||
db: AsyncSession = Depends(get_db),
|
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"),
|
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"),
|
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||||
skip: int = Query(default=0, ge=0),
|
skip: int = Query(default=0, ge=0),
|
||||||
@@ -57,6 +59,8 @@ async def list_decisions(
|
|||||||
"""List all decisions with optional filters."""
|
"""List all decisions with optional filters."""
|
||||||
stmt = select(Decision).options(selectinload(Decision.steps))
|
stmt = select(Decision).options(selectinload(Decision.steps))
|
||||||
|
|
||||||
|
if org_id is not None:
|
||||||
|
stmt = stmt.where(Decision.organization_id == org_id)
|
||||||
if decision_type is not None:
|
if decision_type is not None:
|
||||||
stmt = stmt.where(Decision.decision_type == decision_type)
|
stmt = stmt.where(Decision.decision_type == decision_type)
|
||||||
if status_filter is not None:
|
if status_filter is not None:
|
||||||
@@ -74,11 +78,13 @@ async def create_decision(
|
|||||||
payload: DecisionCreate,
|
payload: DecisionCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> DecisionOut:
|
) -> DecisionOut:
|
||||||
"""Create a new decision process."""
|
"""Create a new decision process."""
|
||||||
decision = Decision(
|
decision = Decision(
|
||||||
**payload.model_dump(),
|
**payload.model_dump(),
|
||||||
created_by_id=identity.id,
|
created_by_id=identity.id,
|
||||||
|
organization_id=org_id,
|
||||||
)
|
)
|
||||||
db.add(decision)
|
db.add(decision)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from app.schemas.document import (
|
|||||||
ItemVersionCreate,
|
ItemVersionCreate,
|
||||||
ItemVersionOut,
|
ItemVersionOut,
|
||||||
)
|
)
|
||||||
|
from app.dependencies.org import get_active_org_id
|
||||||
from app.services import document_service
|
from app.services import document_service
|
||||||
from app.services.auth_service import get_current_identity
|
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])
|
@router.get("/", response_model=list[DocumentOut])
|
||||||
async def list_documents(
|
async def list_documents(
|
||||||
db: AsyncSession = Depends(get_db),
|
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"),
|
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"),
|
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||||
skip: int = Query(default=0, ge=0),
|
skip: int = Query(default=0, ge=0),
|
||||||
@@ -73,6 +75,8 @@ async def list_documents(
|
|||||||
"""List all reference documents, with optional filters."""
|
"""List all reference documents, with optional filters."""
|
||||||
stmt = select(Document)
|
stmt = select(Document)
|
||||||
|
|
||||||
|
if org_id is not None:
|
||||||
|
stmt = stmt.where(Document.organization_id == org_id)
|
||||||
if doc_type is not None:
|
if doc_type is not None:
|
||||||
stmt = stmt.where(Document.doc_type == doc_type)
|
stmt = stmt.where(Document.doc_type == doc_type)
|
||||||
if status_filter is not None:
|
if status_filter is not None:
|
||||||
@@ -101,6 +105,7 @@ async def create_document(
|
|||||||
payload: DocumentCreate,
|
payload: DocumentCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> DocumentOut:
|
) -> DocumentOut:
|
||||||
"""Create a new reference document."""
|
"""Create a new reference document."""
|
||||||
# Check slug uniqueness
|
# Check slug uniqueness
|
||||||
@@ -111,7 +116,7 @@ async def create_document(
|
|||||||
detail="Un document avec ce slug existe deja",
|
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)
|
db.add(doc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(doc)
|
await db.refresh(doc)
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""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.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()
|
||||||
|
|
||||||
|
|
||||||
|
def _org_id_from_header(request_headers) -> uuid.UUID | None:
|
||||||
|
raw = request_headers.get("x-organization")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return uuid.UUID(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[GroupSummary])
|
||||||
|
async def list_groups(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[GroupSummary]:
|
||||||
|
"""List all groups. No auth required — groups are public within the workspace."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Group).options(selectinload(Group.members)).order_by(Group.name)
|
||||||
|
)
|
||||||
|
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),
|
||||||
|
) -> GroupOut:
|
||||||
|
group = Group(name=payload.name, description=payload.description)
|
||||||
|
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()
|
||||||
@@ -22,6 +22,7 @@ from app.schemas.mandate import (
|
|||||||
MandateUpdate,
|
MandateUpdate,
|
||||||
)
|
)
|
||||||
from app.schemas.vote import VoteSessionOut
|
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.auth_service import get_current_identity
|
||||||
from app.services.mandate_service import (
|
from app.services.mandate_service import (
|
||||||
advance_mandate,
|
advance_mandate,
|
||||||
@@ -37,10 +38,13 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
|
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(
|
result = await db.execute(
|
||||||
select(Mandate)
|
select(Mandate)
|
||||||
.options(selectinload(Mandate.steps))
|
.options(
|
||||||
|
selectinload(Mandate.steps),
|
||||||
|
selectinload(Mandate.origin_identity),
|
||||||
|
selectinload(Mandate.mandatee),
|
||||||
|
)
|
||||||
.where(Mandate.id == mandate_id)
|
.where(Mandate.id == mandate_id)
|
||||||
)
|
)
|
||||||
mandate = result.scalar_one_or_none()
|
mandate = result.scalar_one_or_none()
|
||||||
@@ -49,20 +53,33 @@ async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
|
|||||||
return mandate
|
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 ──────────────────────────────────────────────────────────
|
# ── Mandate routes ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[MandateOut])
|
@router.get("/", response_model=list[MandateOut])
|
||||||
async def list_mandates(
|
async def list_mandates(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
mandate_type: str | None = Query(default=None, description="Filtrer par type de mandat"),
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
mandate_type: str | None = Query(default=None),
|
||||||
|
status_filter: str | None = Query(default=None, alias="status"),
|
||||||
skip: int = Query(default=0, ge=0),
|
skip: int = Query(default=0, ge=0),
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
) -> list[MandateOut]:
|
) -> list[MandateOut]:
|
||||||
"""List all mandates with optional filters."""
|
stmt = select(Mandate).options(
|
||||||
stmt = select(Mandate).options(selectinload(Mandate.steps))
|
selectinload(Mandate.steps),
|
||||||
|
selectinload(Mandate.origin_identity),
|
||||||
|
selectinload(Mandate.mandatee),
|
||||||
|
)
|
||||||
|
|
||||||
|
if org_id is not None:
|
||||||
|
stmt = stmt.where(Mandate.organization_id == org_id)
|
||||||
if mandate_type is not None:
|
if mandate_type is not None:
|
||||||
stmt = stmt.where(Mandate.mandate_type == mandate_type)
|
stmt = stmt.where(Mandate.mandate_type == mandate_type)
|
||||||
if status_filter is not None:
|
if status_filter is not None:
|
||||||
@@ -72,7 +89,7 @@ async def list_mandates(
|
|||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
mandates = result.scalars().unique().all()
|
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)
|
@router.post("/", response_model=MandateOut, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -80,16 +97,22 @@ async def create_mandate(
|
|||||||
payload: MandateCreate,
|
payload: MandateCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
"""Create a new mandate."""
|
data = payload.model_dump()
|
||||||
mandate = Mandate(**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)
|
db.add(mandate)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(mandate)
|
await db.refresh(mandate)
|
||||||
|
|
||||||
# Reload with steps (empty at creation)
|
|
||||||
mandate = await _get_mandate(db, mandate.id)
|
mandate = await _get_mandate(db, mandate.id)
|
||||||
return MandateOut.model_validate(mandate)
|
return _mandate_out(mandate)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}", response_model=MandateOut)
|
@router.get("/{id}", response_model=MandateOut)
|
||||||
@@ -97,9 +120,8 @@ async def get_mandate(
|
|||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
"""Get a single mandate with all its steps."""
|
|
||||||
mandate = await _get_mandate(db, id)
|
mandate = await _get_mandate(db, id)
|
||||||
return MandateOut.model_validate(mandate)
|
return _mandate_out(mandate)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{id}", response_model=MandateOut)
|
@router.put("/{id}", response_model=MandateOut)
|
||||||
@@ -109,19 +131,14 @@ async def update_mandate(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
"""Update a mandate's metadata."""
|
|
||||||
mandate = await _get_mandate(db, id)
|
mandate = await _get_mandate(db, id)
|
||||||
|
|
||||||
update_data = payload.model_dump(exclude_unset=True)
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
for field, value in update_data.items():
|
|
||||||
setattr(mandate, field, value)
|
setattr(mandate, field, value)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(mandate)
|
|
||||||
|
|
||||||
# Reload with steps
|
|
||||||
mandate = await _get_mandate(db, mandate.id)
|
mandate = await _get_mandate(db, mandate.id)
|
||||||
return MandateOut.model_validate(mandate)
|
return _mandate_out(mandate)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
|
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
|
||||||
@@ -130,7 +147,6 @@ async def delete_mandate(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Delete a mandate (only if in draft status)."""
|
|
||||||
mandate = await _get_mandate(db, id)
|
mandate = await _get_mandate(db, id)
|
||||||
|
|
||||||
if mandate.status != "draft":
|
if mandate.status != "draft":
|
||||||
@@ -153,13 +169,9 @@ async def add_step(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
) -> MandateStepOut:
|
) -> MandateStepOut:
|
||||||
"""Add a step to a mandate process."""
|
|
||||||
mandate = await _get_mandate(db, id)
|
mandate = await _get_mandate(db, id)
|
||||||
|
|
||||||
step = MandateStep(
|
step = MandateStep(mandate_id=mandate.id, **payload.model_dump())
|
||||||
mandate_id=mandate.id,
|
|
||||||
**payload.model_dump(),
|
|
||||||
)
|
|
||||||
db.add(step)
|
db.add(step)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(step)
|
await db.refresh(step)
|
||||||
@@ -172,7 +184,6 @@ async def list_steps(
|
|||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> list[MandateStepOut]:
|
) -> list[MandateStepOut]:
|
||||||
"""List all steps for a mandate, ordered by step_order."""
|
|
||||||
mandate = await _get_mandate(db, id)
|
mandate = await _get_mandate(db, id)
|
||||||
return [MandateStepOut.model_validate(s) for s in mandate.steps]
|
return [MandateStepOut.model_validate(s) for s in mandate.steps]
|
||||||
|
|
||||||
@@ -186,17 +197,17 @@ async def advance_mandate_endpoint(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
) -> MandateAdvanceOut:
|
) -> MandateAdvanceOut:
|
||||||
"""Advance a mandate to its next step or status."""
|
|
||||||
try:
|
try:
|
||||||
mandate = await advance_mandate(id, db)
|
mandate = await advance_mandate(id, db)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(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)
|
mandate = await _get_mandate(db, mandate.id)
|
||||||
data = MandateOut.model_validate(mandate).model_dump()
|
out = _mandate_out(mandate)
|
||||||
data["message"] = f"Mandat avance au statut : {mandate.status}"
|
return MandateAdvanceOut(
|
||||||
return MandateAdvanceOut(**data)
|
**out.model_dump(),
|
||||||
|
message=f"Mandat avance au statut : {mandate.status}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{id}/assign", response_model=MandateOut)
|
@router.post("/{id}/assign", response_model=MandateOut)
|
||||||
@@ -206,15 +217,13 @@ async def assign_mandatee_endpoint(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
"""Assign a mandatee to a mandate."""
|
|
||||||
try:
|
try:
|
||||||
mandate = await assign_mandatee(id, payload.mandatee_id, db)
|
mandate = await assign_mandatee(id, payload.mandatee_id, db)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
# Reload with steps
|
|
||||||
mandate = await _get_mandate(db, mandate.id)
|
mandate = await _get_mandate(db, mandate.id)
|
||||||
return MandateOut.model_validate(mandate)
|
return _mandate_out(mandate)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{id}/revoke", response_model=MandateOut)
|
@router.post("/{id}/revoke", response_model=MandateOut)
|
||||||
@@ -223,15 +232,13 @@ async def revoke_mandate_endpoint(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
) -> MandateOut:
|
) -> MandateOut:
|
||||||
"""Revoke an active mandate."""
|
|
||||||
try:
|
try:
|
||||||
mandate = await revoke_mandate(id, db)
|
mandate = await revoke_mandate(id, db)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
# Reload with steps
|
|
||||||
mandate = await _get_mandate(db, mandate.id)
|
mandate = await _get_mandate(db, mandate.id)
|
||||||
return MandateOut.model_validate(mandate)
|
return _mandate_out(mandate)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -245,7 +252,6 @@ async def create_vote_session_for_step_endpoint(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
) -> VoteSessionOut:
|
) -> VoteSessionOut:
|
||||||
"""Create a vote session linked to a mandate step."""
|
|
||||||
try:
|
try:
|
||||||
session = await create_vote_session_for_step(id, step_id, db)
|
session = await create_vote_session_for_step(id, step_id, db)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -25,6 +25,7 @@ from app.schemas.protocol import (
|
|||||||
VotingProtocolOut,
|
VotingProtocolOut,
|
||||||
VotingProtocolUpdate,
|
VotingProtocolUpdate,
|
||||||
)
|
)
|
||||||
|
from app.dependencies.org import get_active_org_id
|
||||||
from app.services.auth_service import get_current_identity
|
from app.services.auth_service import get_current_identity
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -63,6 +64,7 @@ async def _get_formula(db: AsyncSession, formula_id: uuid.UUID) -> FormulaConfig
|
|||||||
@router.get("/", response_model=list[VotingProtocolOut])
|
@router.get("/", response_model=list[VotingProtocolOut])
|
||||||
async def list_protocols(
|
async def list_protocols(
|
||||||
db: AsyncSession = Depends(get_db),
|
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)"),
|
vote_type: str | None = Query(default=None, description="Filtrer par type de vote (binary, nuanced)"),
|
||||||
skip: int = Query(default=0, ge=0),
|
skip: int = Query(default=0, ge=0),
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
@@ -70,6 +72,8 @@ async def list_protocols(
|
|||||||
"""List all voting protocols with their formula configurations."""
|
"""List all voting protocols with their formula configurations."""
|
||||||
stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config))
|
stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config))
|
||||||
|
|
||||||
|
if org_id is not None:
|
||||||
|
stmt = stmt.where(VotingProtocol.organization_id == org_id)
|
||||||
if vote_type is not None:
|
if vote_type is not None:
|
||||||
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
|
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
|
||||||
|
|
||||||
@@ -85,6 +89,7 @@ async def create_protocol(
|
|||||||
payload: VotingProtocolCreate,
|
payload: VotingProtocolCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
identity: DuniterIdentity = Depends(get_current_identity),
|
identity: DuniterIdentity = Depends(get_current_identity),
|
||||||
|
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||||
) -> VotingProtocolOut:
|
) -> VotingProtocolOut:
|
||||||
"""Create a new voting protocol.
|
"""Create a new voting protocol.
|
||||||
|
|
||||||
@@ -100,7 +105,7 @@ async def create_protocol(
|
|||||||
detail="Configuration de formule introuvable",
|
detail="Configuration de formule introuvable",
|
||||||
)
|
)
|
||||||
|
|
||||||
protocol = VotingProtocol(**payload.model_dump())
|
protocol = VotingProtocol(**payload.model_dump(), organization_id=org_id)
|
||||||
db.add(protocol)
|
db.add(protocol)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(protocol)
|
await db.refresh(protocol)
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ async def platform_status(
|
|||||||
sanctuary_count = sanctuary_count_result.scalar() or 0
|
sanctuary_count = sanctuary_count_result.scalar() or 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"platform": "Glibredecision",
|
"platform": "libreDecision",
|
||||||
"documents_count": documents_count,
|
"documents_count": documents_count,
|
||||||
"decisions_count": decisions_count,
|
"decisions_count": decisions_count,
|
||||||
"active_votes_count": active_votes_count,
|
"active_votes_count": active_votes_count,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -10,21 +10,13 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
|
|
||||||
class MandateStepCreate(BaseModel):
|
class MandateStepCreate(BaseModel):
|
||||||
"""Payload for creating a step within a mandate process."""
|
|
||||||
|
|
||||||
step_order: int = Field(..., ge=0)
|
step_order: int = Field(..., ge=0)
|
||||||
step_type: str = Field(
|
step_type: str = Field(..., max_length=32)
|
||||||
...,
|
|
||||||
max_length=32,
|
|
||||||
description="formulation, candidacy, vote, assignment, reporting, completion, revocation",
|
|
||||||
)
|
|
||||||
title: str | None = Field(default=None, max_length=256)
|
title: str | None = Field(default=None, max_length=256)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class MandateStepOut(BaseModel):
|
class MandateStepOut(BaseModel):
|
||||||
"""Full mandate step representation."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: UUID
|
id: UUID
|
||||||
@@ -43,40 +35,45 @@ class MandateStepOut(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class MandateCreate(BaseModel):
|
class MandateCreate(BaseModel):
|
||||||
"""Payload for creating a new mandate."""
|
|
||||||
|
|
||||||
title: str = Field(..., min_length=1, max_length=256)
|
title: str = Field(..., min_length=1, max_length=256)
|
||||||
|
origin_id: UUID | None = None
|
||||||
description: str | 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
|
decision_id: UUID | None = None
|
||||||
|
starts_at: datetime | None = None
|
||||||
|
ends_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class MandateUpdate(BaseModel):
|
class MandateUpdate(BaseModel):
|
||||||
"""Partial update for a mandate."""
|
|
||||||
|
|
||||||
title: str | None = Field(default=None, max_length=256)
|
title: str | None = Field(default=None, max_length=256)
|
||||||
|
origin_id: UUID | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
mandate_type: str | None = Field(default=None, max_length=64)
|
mandate_type: str | None = Field(default=None, max_length=64)
|
||||||
decision_id: UUID | None = None
|
decision_id: UUID | None = None
|
||||||
|
starts_at: datetime | None = None
|
||||||
|
ends_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class MandateAssignRequest(BaseModel):
|
class MandateAssignRequest(BaseModel):
|
||||||
"""Request body for assigning a mandatee to a mandate."""
|
mandatee_id: UUID = Field(..., description="UUID de l'identite Duniter du mandataire")
|
||||||
|
|
||||||
mandatee_id: UUID = Field(..., description="ID de l'identite Duniter du mandataire")
|
|
||||||
|
|
||||||
|
|
||||||
class MandateOut(BaseModel):
|
class MandateOut(BaseModel):
|
||||||
"""Full mandate representation returned by the API."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
id: UUID
|
id: UUID
|
||||||
title: str
|
title: str
|
||||||
|
origin_id: UUID | None = None
|
||||||
|
origin_display_name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
mandate_type: str
|
mandate_type: str
|
||||||
status: str
|
status: str
|
||||||
mandatee_id: UUID | None = None
|
mandatee_id: UUID | None = None
|
||||||
|
mandatee_display_name: str | None = None
|
||||||
decision_id: UUID | None = None
|
decision_id: UUID | None = None
|
||||||
starts_at: datetime | None = None
|
starts_at: datetime | None = None
|
||||||
ends_at: datetime | None = None
|
ends_at: datetime | None = None
|
||||||
@@ -85,21 +82,5 @@ class MandateOut(BaseModel):
|
|||||||
steps: list[MandateStepOut] = Field(default_factory=list)
|
steps: list[MandateStepOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class MandateAdvanceOut(BaseModel):
|
class MandateAdvanceOut(MandateOut):
|
||||||
"""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)
|
|
||||||
message: str = Field(..., description="Message decrivant l'avancement effectue")
|
message: str = Field(..., description="Message decrivant l'avancement effectue")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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."
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Sanctuary service: immutable archival to IPFS + on-chain hash.
|
"""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),
|
document version, decision result, or vote tally is hashed (SHA-256),
|
||||||
stored on IPFS, and anchored on-chain via system.remark.
|
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 = substrate.compose_call(
|
||||||
call_module="System",
|
call_module="System",
|
||||||
call_function="remark",
|
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)
|
extrinsic = substrate.create_signed_extrinsic(call=call, keypair=keypair)
|
||||||
receipt = substrate.submit_extrinsic(extrinsic, wait_for_inclusion=True)
|
receipt = substrate.submit_extrinsic(extrinsic, wait_for_inclusion=True)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+373
-14
@@ -31,6 +31,9 @@ from app.database import async_session, engine, Base, init_db
|
|||||||
from app.models.protocol import FormulaConfig, VotingProtocol
|
from app.models.protocol import FormulaConfig, VotingProtocol
|
||||||
from app.models.document import Document, DocumentItem
|
from app.models.document import Document, DocumentItem
|
||||||
from app.models.decision import Decision, DecisionStep
|
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.user import DuniterIdentity
|
||||||
from app.models.vote import VoteSession, Vote
|
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(
|
async def seed_voting_protocols(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
formulas: dict[str, FormulaConfig],
|
formulas: dict[str, FormulaConfig],
|
||||||
|
org_id: uuid.UUID | None = None,
|
||||||
) -> dict[str, VotingProtocol]:
|
) -> dict[str, VotingProtocol]:
|
||||||
protocols: dict[str, dict] = {
|
protocols: dict[str, dict] = {
|
||||||
"Vote WoT standard": {
|
"Vote WoT standard": {
|
||||||
@@ -205,6 +209,7 @@ async def seed_voting_protocols(
|
|||||||
instance, created = await get_or_create(
|
instance, created = await get_or_create(
|
||||||
session, VotingProtocol, "name", name, **params,
|
session, VotingProtocol, "name", name, **params,
|
||||||
)
|
)
|
||||||
|
instance.organization_id = org_id
|
||||||
status = "created" if created else "exists"
|
status = "created" if created else "exists"
|
||||||
print(f" VotingProtocol '{name}': {status}")
|
print(f" VotingProtocol '{name}': {status}")
|
||||||
result[name] = instance
|
result[name] = instance
|
||||||
@@ -828,6 +833,7 @@ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
|
|||||||
async def seed_document_engagement_certification(
|
async def seed_document_engagement_certification(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
protocols: dict[str, VotingProtocol],
|
protocols: dict[str, VotingProtocol],
|
||||||
|
org_id: uuid.UUID | None = None,
|
||||||
) -> Document:
|
) -> Document:
|
||||||
genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2)
|
genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
@@ -849,6 +855,7 @@ async def seed_document_engagement_certification(
|
|||||||
),
|
),
|
||||||
genesis_json=genesis,
|
genesis_json=genesis,
|
||||||
)
|
)
|
||||||
|
doc.organization_id = org_id
|
||||||
print(f" Document 'Acte d'engagement Certification': {'created' if created else 'exists'}")
|
print(f" Document 'Acte d'engagement Certification': {'created' if created else 'exists'}")
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@@ -1892,6 +1899,7 @@ ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
|
|||||||
async def seed_document_engagement_forgeron(
|
async def seed_document_engagement_forgeron(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
protocols: dict[str, VotingProtocol],
|
protocols: dict[str, VotingProtocol],
|
||||||
|
org_id: uuid.UUID | None = None,
|
||||||
) -> Document:
|
) -> Document:
|
||||||
genesis = json.dumps(GENESIS_FORGERON, ensure_ascii=False, indent=2)
|
genesis = json.dumps(GENESIS_FORGERON, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
@@ -1915,6 +1923,7 @@ async def seed_document_engagement_forgeron(
|
|||||||
),
|
),
|
||||||
genesis_json=genesis,
|
genesis_json=genesis,
|
||||||
)
|
)
|
||||||
|
doc.organization_id = org_id
|
||||||
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
|
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
|
||||||
|
|
||||||
if created:
|
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(
|
decision, created = await get_or_create(
|
||||||
session,
|
session,
|
||||||
Decision,
|
Decision,
|
||||||
@@ -2008,6 +2017,7 @@ async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
|
|||||||
decision_type="runtime_upgrade",
|
decision_type="runtime_upgrade",
|
||||||
status="draft",
|
status="draft",
|
||||||
)
|
)
|
||||||
|
decision.organization_id = org_id
|
||||||
print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}")
|
print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}")
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@@ -2143,40 +2153,384 @@ async def seed_votes_on_items(
|
|||||||
print(f" VoteSession for '{item.title}': created (10 pour, 1 contre)")
|
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
|
# 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():
|
async def run_seed():
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("Glibredecision - Seed Database")
|
print("libreDecision - Seed Database")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
# Ensure tables exist
|
# Ensure tables exist
|
||||||
await init_db()
|
await init_db()
|
||||||
print("[0/8] Tables created.\n")
|
print("[0/10] Tables created.\n")
|
||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
async with session.begin():
|
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)
|
formulas = await seed_formula_configs(session)
|
||||||
|
|
||||||
print("\n[2/8] Voting Protocols...")
|
print("\n[2/10] Voting Protocols...")
|
||||||
protocols = await seed_voting_protocols(session, formulas)
|
protocols = await seed_voting_protocols(session, formulas, org_id=duniter_g1_id)
|
||||||
|
|
||||||
print("\n[3/8] Document: Acte d'engagement Certification...")
|
print("\n[3/10] Document: Acte d'engagement Certification...")
|
||||||
await seed_document_engagement_certification(session, protocols)
|
await seed_document_engagement_certification(session, protocols, org_id=duniter_g1_id)
|
||||||
|
|
||||||
print("\n[4/8] Document: Acte d'engagement forgeron v2.0.0...")
|
print("\n[4/10] Document: Acte d'engagement forgeron v2.0.0...")
|
||||||
doc_forgeron = await seed_document_engagement_forgeron(session, protocols)
|
doc_forgeron = await seed_document_engagement_forgeron(session, protocols, org_id=duniter_g1_id)
|
||||||
|
|
||||||
print("\n[5/7] Decision: Runtime Upgrade...")
|
print("\n[5/10] Decision: Runtime Upgrade...")
|
||||||
await seed_decision_runtime_upgrade(session)
|
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)
|
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(
|
await seed_votes_on_items(
|
||||||
session,
|
session,
|
||||||
doc_forgeron,
|
doc_forgeron,
|
||||||
@@ -2184,6 +2538,11 @@ async def run_seed():
|
|||||||
voters,
|
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("\n" + "=" * 60)
|
||||||
print("Seed complete.")
|
print("Seed complete.")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|||||||
@@ -41,4 +41,4 @@ COPY backend/requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
WORKDIR /app
|
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"]
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-glibredecision}
|
POSTGRES_DB: ${POSTGRES_DB:-libredecision}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-glibredecision}
|
POSTGRES_USER: ${POSTGRES_USER:-libredecision}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-glibredecision-dev}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-libredecision-dev}
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-glibredecision}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-libredecision}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
@@ -34,7 +34,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8002:8002"
|
- "8002:8002"
|
||||||
environment:
|
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
|
SECRET_KEY: dev-secret-key-not-for-production
|
||||||
DEBUG: "true"
|
DEBUG: "true"
|
||||||
ENVIRONMENT: development
|
ENVIRONMENT: development
|
||||||
|
|||||||
+38
-39
@@ -1,85 +1,84 @@
|
|||||||
version: "3.9"
|
name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
container_name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}-postgres
|
||||||
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-glibredecision}
|
POSTGRES_DB: ${POSTGRES_DB:-libredecision}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-glibredecision}
|
POSTGRES_USER: ${POSTGRES_USER:-libredecision}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-in-production}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
networks:
|
networks:
|
||||||
- glibredecision
|
- libredecision
|
||||||
|
# Pas de label SERVICE_* : postgres non exposé publiquement
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
image: libredecision-backend:latest
|
||||||
context: ../
|
container_name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}-backend
|
||||||
dockerfile: docker/backend.Dockerfile
|
restart: always
|
||||||
target: production
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-glibredecision}:${POSTGRES_PASSWORD:-change-me-in-production}@postgres:5432/${POSTGRES_DB:-glibredecision}
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-libredecision}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-libredecision}
|
||||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production-with-a-real-secret-key}
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
|
ENVIRONMENT: production
|
||||||
DEBUG: "false"
|
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}
|
DUNITER_RPC_URL: ${DUNITER_RPC_URL:-wss://gdev.p2p.legal/ws}
|
||||||
IPFS_API_URL: http://ipfs:5001
|
IPFS_API_URL: http://ipfs:5001
|
||||||
IPFS_GATEWAY_URL: http://ipfs:8080
|
IPFS_GATEWAY_URL: http://ipfs:8080
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
# Registrator enregistre dans Consul, Fabio route automatiquement
|
||||||
- "traefik.http.routers.glibredecision-api.rule=Host(`${DOMAIN:-glibredecision.org}`) && PathPrefix(`/api`)"
|
- SERVICE_8002_NAME=${COMPOSE_PROJECT_NAME:-ehv-decision-main}-backend-8002
|
||||||
- "traefik.http.routers.glibredecision-api.entrypoints=websecure"
|
- SERVICE_8002_TAGS=urlprefix-${APP_DOMAIN:-decision.librodrome.org}/api/*
|
||||||
- "traefik.http.routers.glibredecision-api.tls.certresolver=letsencrypt"
|
# TCP : HTTP check échoue si le service redirige (301/302)
|
||||||
- "traefik.http.services.glibredecision-api.loadbalancer.server.port=8002"
|
- SERVICE_8002_CHECK_TCP=true
|
||||||
networks:
|
networks:
|
||||||
- glibredecision
|
- libredecision
|
||||||
- traefik
|
- sonic
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
image: libredecision-frontend:latest
|
||||||
context: ../
|
container_name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}-frontend
|
||||||
dockerfile: docker/frontend.Dockerfile
|
restart: always
|
||||||
target: production
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
environment:
|
environment:
|
||||||
NUXT_PUBLIC_API_BASE: https://${DOMAIN:-glibredecision.org}/api/v1
|
NUXT_PUBLIC_API_BASE: https://${APP_DOMAIN:-decision.librodrome.org}/api/v1
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- SERVICE_3000_NAME=${COMPOSE_PROJECT_NAME:-ehv-decision-main}-frontend-3000
|
||||||
- "traefik.http.routers.glibredecision-front.rule=Host(`${DOMAIN:-glibredecision.org}`)"
|
- SERVICE_3000_TAGS=urlprefix-${APP_DOMAIN:-decision.librodrome.org}/*
|
||||||
- "traefik.http.routers.glibredecision-front.entrypoints=websecure"
|
- SERVICE_3000_CHECK_TCP=true
|
||||||
- "traefik.http.routers.glibredecision-front.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.services.glibredecision-front.loadbalancer.server.port=3000"
|
|
||||||
networks:
|
networks:
|
||||||
- glibredecision
|
- sonic
|
||||||
- traefik
|
|
||||||
|
|
||||||
ipfs:
|
ipfs:
|
||||||
image: ipfs/kubo:latest
|
image: ipfs/kubo:latest
|
||||||
restart: unless-stopped
|
container_name: ${COMPOSE_PROJECT_NAME:-ehv-decision-main}-ipfs
|
||||||
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ipfs-data:/data/ipfs
|
- ipfs-data:/data/ipfs
|
||||||
networks:
|
networks:
|
||||||
- glibredecision
|
- libredecision
|
||||||
|
# Pas de label SERVICE_* : ipfs non exposé publiquement
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
ipfs-data:
|
ipfs-data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
glibredecision:
|
libredecision:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
traefik:
|
sonic:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
---
|
---
|
||||||
title: Documentation technique
|
title: Documentation technique
|
||||||
description: Architecture, API et reference technique de Glibredecision
|
description: Architecture, API et reference technique de libreDecision
|
||||||
---
|
---
|
||||||
|
|
||||||
# Documentation technique
|
# 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
|
## 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
|
## Stack technique
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ Glibredecision est une plateforme de gouvernance decentralisee qui permet aux me
|
|||||||
|
|
||||||
- **Version** : 1.0.0-rc
|
- **Version** : 1.0.0-rc
|
||||||
- **Statut** : Release candidate -- Sprint 5 (documentation et stabilisation)
|
- **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
|
## Sections
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,16 @@
|
|||||||
---
|
---
|
||||||
title: Architecture
|
title: Architecture
|
||||||
description: Vue d'ensemble de l'architecture technique de Glibredecision
|
description: Vue d'ensemble de l'architecture technique de libreDecision
|
||||||
---
|
---
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
## Vue d'ensemble
|
## 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)
|
backend/ # API Python FastAPI (port 8002)
|
||||||
frontend/ # Application Nuxt 4 (port 3002)
|
frontend/ # Application Nuxt 4 (port 3002)
|
||||||
docker/ # Fichiers Docker et orchestration
|
docker/ # Fichiers Docker et orchestration
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Reference API
|
title: Reference API
|
||||||
description: Liste des endpoints de l'API Glibredecision
|
description: Liste des endpoints de l'API libreDecision
|
||||||
---
|
---
|
||||||
|
|
||||||
# Reference API
|
# Reference API
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: Tables et relations de la base de donnees PostgreSQL
|
|||||||
|
|
||||||
# Schema de base de donnees
|
# 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
|
## Tables
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: Formules mathematiques de seuil WoT, criteres Smith et TechComm, si
|
|||||||
|
|
||||||
# Formules de seuil
|
# 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
|
## Formule principale -- Seuil WoT
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ Les parametres de formule sont encodes dans une chaine compacte pour faciliter l
|
|||||||
|
|
||||||
## Vote nuance
|
## 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 |
|
| 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** :
|
**Exemple de requete** :
|
||||||
|
|
||||||
```bash
|
```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" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"wot_size": 7224,
|
"wot_size": 7224,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: Integration Duniter V2, IPFS et ancrage on-chain
|
|||||||
|
|
||||||
# Integration blockchain
|
# 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
|
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
|
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
|
### Format du remark
|
||||||
|
|
||||||
```
|
```
|
||||||
glibredecision:sanctuary:{content_hash_sha256}
|
libredecision:sanctuary:{content_hash_sha256}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Soumission
|
### Soumission
|
||||||
@@ -113,7 +113,7 @@ substrate = SubstrateInterface(url="wss://gdev.p2p.legal/ws")
|
|||||||
call = substrate.compose_call(
|
call = substrate.compose_call(
|
||||||
call_module="System",
|
call_module="System",
|
||||||
call_function="remark",
|
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)
|
extrinsic = substrate.create_signed_extrinsic(call=call, keypair=keypair)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
title: Contribution
|
title: Contribution
|
||||||
description: Guide de contribution au projet Glibredecision
|
description: Guide de contribution au projet libreDecision
|
||||||
---
|
---
|
||||||
|
|
||||||
# Guide de contribution
|
# 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
|
## Prerequis
|
||||||
|
|
||||||
@@ -21,8 +21,8 @@ Merci de votre interet pour contribuer a Glibredecision. Ce guide explique comme
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Cloner le depot
|
# Cloner le depot
|
||||||
git clone https://git.duniter.org/tools/glibredecision.git
|
git clone https://git.duniter.org/tools/libredecision.git
|
||||||
cd glibredecision
|
cd libredecision
|
||||||
|
|
||||||
# Copier le fichier d'environnement
|
# Copier le fichier d'environnement
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
title: Deploiement
|
title: Deploiement
|
||||||
description: Guide de deploiement en production de Glibredecision
|
description: Guide de deploiement en production de libreDecision
|
||||||
---
|
---
|
||||||
|
|
||||||
# Deploiement
|
# 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
|
## Prerequis
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ Ce guide couvre le deploiement complet de Glibredecision en production avec Dock
|
|||||||
| --------- | ---------------- | ----------- |
|
| --------- | ---------------- | ----------- |
|
||||||
| Docker | 24+ | Moteur de conteneurs |
|
| Docker | 24+ | Moteur de conteneurs |
|
||||||
| Docker Compose | 2.20+ | Orchestration multi-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 |
|
| Certificat TLS | -- | Genere automatiquement par Traefik via Let's Encrypt |
|
||||||
| Traefik | 2.10+ | Reverse proxy avec terminaison TLS (reseau externe `traefik`) |
|
| Traefik | 2.10+ | Reverse proxy avec terminaison TLS (reseau externe `traefik`) |
|
||||||
| Serveur | 2 vCPU, 4 Go RAM, 40 Go SSD | Ressources recommandees |
|
| 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 |
|
| Variable | Description | Valeur par defaut | Production |
|
||||||
| -------- | ----------- | ----------------- | ---------- |
|
| -------- | ----------- | ----------------- | ---------- |
|
||||||
| `POSTGRES_DB` | Nom de la base de donnees | `glibredecision` | `glibredecision` |
|
| `POSTGRES_DB` | Nom de la base de donnees | `libredecision` | `libredecision` |
|
||||||
| `POSTGRES_USER` | Utilisateur PostgreSQL | `glibredecision` | `glibredecision` |
|
| `POSTGRES_USER` | Utilisateur PostgreSQL | `libredecision` | `libredecision` |
|
||||||
| `POSTGRES_PASSWORD` | Mot de passe PostgreSQL | `change-me-in-production` | **Generer un mot de passe fort** (32+ caracteres) |
|
| `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 |
|
| `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`) |
|
| `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`** |
|
| `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 |
|
| `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_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) |
|
| `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` |
|
| `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 | `glibredecision.org` | Votre domaine |
|
| `DOMAIN` | Nom de domaine | `decision.librodrome.org` | Votre domaine |
|
||||||
|
|
||||||
### Generer les secrets
|
### Generer les secrets
|
||||||
|
|
||||||
@@ -73,10 +73,10 @@ Ne commitez jamais le fichier `.env` contenant les secrets de production. Ajoute
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Se placer dans le repertoire du projet
|
# Se placer dans le repertoire du projet
|
||||||
cd /opt/glibredecision
|
cd /opt/libredecision
|
||||||
|
|
||||||
# Cloner le depot
|
# Cloner le depot
|
||||||
git clone https://git.duniter.org/tools/glibredecision.git .
|
git clone https://git.duniter.org/tools/libredecision.git .
|
||||||
|
|
||||||
# Configurer l'environnement
|
# Configurer l'environnement
|
||||||
cp .env.example .env
|
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
|
docker compose -f docker/docker-compose.yml logs -f backend
|
||||||
|
|
||||||
# Health check de l'API
|
# 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)
|
## 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
|
### Appliquer les migrations
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ services:
|
|||||||
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
|
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
|
||||||
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
|
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
|
||||||
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
|
- "--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"
|
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
@@ -207,10 +207,10 @@ docker compose -f docker-compose.traefik.yml up -d
|
|||||||
|
|
||||||
### Routage
|
### 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
|
- **Frontend** : `Host(decision.librodrome.org)` sur le port 3000
|
||||||
- **Backend** : `Host(glibredecision.org) && PathPrefix(/api)` sur le port 8002
|
- **Backend** : `Host(decision.librodrome.org) && PathPrefix(/api)` sur le port 8002
|
||||||
- Les deux utilisent le certificat Let's Encrypt (`certresolver=letsencrypt`)
|
- Les deux utilisent le certificat Let's Encrypt (`certresolver=letsencrypt`)
|
||||||
- Redirection HTTP vers HTTPS automatique
|
- Redirection HTTP vers HTTPS automatique
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ Le service PostgreSQL dispose d'un health check integre (`pg_isready`). Le backe
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Health check de l'API
|
# Health check de l'API
|
||||||
curl -s https://glibredecision.org/api/health
|
curl -s https://decision.librodrome.org/api/health
|
||||||
# Reponse attendue : {"status": "healthy"}
|
# Reponse attendue : {"status": "healthy"}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -257,8 +257,8 @@ Surveillez les indicateurs suivants :
|
|||||||
| ---------- | -------- | --------------- |
|
| ---------- | -------- | --------------- |
|
||||||
| CPU/RAM conteneurs | `docker stats` | > 80% RAM |
|
| CPU/RAM conteneurs | `docker stats` | > 80% RAM |
|
||||||
| Espace disque | `df -h` | > 85% |
|
| Espace disque | `df -h` | > 85% |
|
||||||
| Connexions PostgreSQL | `docker exec postgres psql -U glibredecision -c "SELECT count(*) FROM pg_stat_activity;"` | > 80 |
|
| 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 glibredecision -c "SELECT pg_size_pretty(pg_database_size('glibredecision'));"` | Information |
|
| 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 |
|
| Statut IPFS | `docker exec ipfs ipfs id` | Erreur |
|
||||||
|
|
||||||
## Sauvegarde PostgreSQL
|
## Sauvegarde PostgreSQL
|
||||||
@@ -268,7 +268,7 @@ Surveillez les indicateurs suivants :
|
|||||||
```bash
|
```bash
|
||||||
# Dump complet de la base
|
# Dump complet de la base
|
||||||
docker compose -f docker/docker-compose.yml exec postgres \
|
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
|
### Restauration
|
||||||
@@ -276,7 +276,7 @@ docker compose -f docker/docker-compose.yml exec postgres \
|
|||||||
```bash
|
```bash
|
||||||
# Restaurer un dump
|
# Restaurer un dump
|
||||||
docker compose -f docker/docker-compose.yml exec -T postgres \
|
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)
|
### Sauvegarde automatique (cron)
|
||||||
@@ -288,7 +288,7 @@ Ajoutez un crontab pour des sauvegardes quotidiennes :
|
|||||||
crontab -e
|
crontab -e
|
||||||
|
|
||||||
# Ajouter une sauvegarde quotidienne a 3h du matin
|
# 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 :
|
Cette commande :
|
||||||
@@ -301,7 +301,7 @@ Cette commande :
|
|||||||
### Procedure standard
|
### Procedure standard
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /opt/glibredecision
|
cd /opt/libredecision
|
||||||
|
|
||||||
# 1. Tirer les dernieres images
|
# 1. Tirer les dernieres images
|
||||||
docker compose -f docker/docker-compose.yml pull
|
docker compose -f docker/docker-compose.yml pull
|
||||||
@@ -317,7 +317,7 @@ docker image prune -f
|
|||||||
|
|
||||||
# 5. Verifier le deploiement
|
# 5. Verifier le deploiement
|
||||||
docker compose -f docker/docker-compose.yml ps
|
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)
|
### 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.
|
**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'`
|
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`
|
3. Consulter les logs Traefik : `docker logs traefik 2>&1 | grep -i acme`
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
title: Securite
|
title: Securite
|
||||||
description: Politique de securite et mesures de protection de Glibredecision
|
description: Politique de securite et mesures de protection de libreDecision
|
||||||
---
|
---
|
||||||
|
|
||||||
# Securite
|
# 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)
|
## Authentification Duniter V2 (Ed25519 challenge-response)
|
||||||
|
|
||||||
### Principe
|
### 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
|
### Flux challenge-response
|
||||||
|
|
||||||
@@ -169,10 +169,10 @@ Contenu --> [SHA-256] --> hash
|
|||||||
### Format du remark on-chain
|
### 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
|
## WebSocket : authentification et securite
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ Les logs d'audit sont conserves de maniere permanente dans la base de donnees. L
|
|||||||
|
|
||||||
### Processus
|
### 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.
|
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).
|
2. **Contactez l'equipe** via le canal securise indique sur le depot Git Duniter ou via le forum Duniter (message prive aux mainteneurs).
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
---
|
---
|
||||||
title: Documentation utilisateur
|
title: Documentation utilisateur
|
||||||
description: Guide d'utilisation de la plateforme Glibredecision
|
description: Guide d'utilisation de la plateforme libreDecision
|
||||||
---
|
---
|
||||||
|
|
||||||
# Documentation utilisateur
|
# 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
|
- 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)
|
- 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 ?
|
## 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.
|
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.
|
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.
|
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 :
|
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
|
- Proposer une modification directement via une merge request
|
||||||
- En discuter sur le [forum Duniter](https://forum.duniter.org)
|
- En discuter sur le [forum Duniter](https://forum.duniter.org)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
title: Premiers pas
|
title: Premiers pas
|
||||||
description: Connexion et prise en main de Glibredecision
|
description: Connexion et prise en main de libreDecision
|
||||||
---
|
---
|
||||||
|
|
||||||
# Premiers pas
|
# 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
|
## 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.
|
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.
|
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.
|
- **Consultation** : tout visiteur peut consulter les documents, decisions et resultats de vote.
|
||||||
- **Participation** (voter, proposer) : reservee aux membres authentifies via leur identite Duniter.
|
- **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 :
|
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
|
- **Menu principal** : acces aux cinq sections
|
||||||
- **Bouton de connexion** / **Votre profil** (si connecte)
|
- **Bouton de connexion** / **Votre profil** (si connecte)
|
||||||
- **Indicateur temps reel** : point colore indiquant l'etat de la connexion WebSocket
|
- **Indicateur temps reel** : point colore indiquant l'etat de la connexion WebSocket
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Documents
|
title: Documents
|
||||||
description: Guide des documents de reference sur Glibredecision
|
description: Guide des documents de reference sur libreDecision
|
||||||
---
|
---
|
||||||
|
|
||||||
# Documents de reference
|
# 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.
|
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
|
## Types de documents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Decisions
|
title: Decisions
|
||||||
description: Guide des processus decisionnels sur Glibredecision
|
description: Guide des processus decisionnels sur libreDecision
|
||||||
---
|
---
|
||||||
|
|
||||||
# Decisions
|
# Decisions
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
title: Vote
|
title: Vote
|
||||||
description: Guide du systeme de vote sur Glibredecision
|
description: Guide du systeme de vote sur libreDecision
|
||||||
---
|
---
|
||||||
|
|
||||||
# Vote
|
# Vote
|
||||||
|
|
||||||
## Principe
|
## 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
|
## Types de vote
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ Le vote nuance est recommande pour :
|
|||||||
|
|
||||||
### L'analogie de l'inertie
|
### 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 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%).
|
- **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
|
## 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
|
### 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.
|
- L'horodatage.
|
||||||
- Un lien pour verifier la signature Ed25519.
|
- 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
|
## Meta-gouvernance : voter sur les regles du vote
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Mandats
|
title: Mandats
|
||||||
description: Guide des mandats sur Glibredecision
|
description: Guide des mandats sur libreDecision
|
||||||
---
|
---
|
||||||
|
|
||||||
# Mandats
|
# Mandats
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
---
|
---
|
||||||
title: Sanctuaire
|
title: Sanctuaire
|
||||||
description: Guide de l'archivage immuable sur Glibredecision
|
description: Guide de l'archivage immuable sur libreDecision
|
||||||
---
|
---
|
||||||
|
|
||||||
# Sanctuaire
|
# Sanctuaire
|
||||||
|
|
||||||
## Qu'est-ce que le 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
|
## 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.
|
- **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.
|
- **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 :
|
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.
|
**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.
|
- **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.
|
- **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
|
## Types d'entrees
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ Cette fonctionnalite permet de retracer l'historique complet d'archivage d'un do
|
|||||||
|
|
||||||
### Verification automatique
|
### 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.
|
1. Ouvrez l'entree a verifier dans le Sanctuaire.
|
||||||
2. Cliquez sur **Verifier l'integrite**.
|
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)
|
### 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
|
#### 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.
|
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"}
|
::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
|
## Comprendre les informations d'ancrage on-chain
|
||||||
|
|||||||
+11
-11
@@ -1,19 +1,19 @@
|
|||||||
---
|
---
|
||||||
title: FAQ
|
title: FAQ
|
||||||
description: Questions frequentes sur Glibredecision
|
description: Questions frequentes sur libreDecision
|
||||||
---
|
---
|
||||||
|
|
||||||
# Questions frequentes
|
# Questions frequentes
|
||||||
|
|
||||||
## Acces et authentification
|
## 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.
|
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 ?
|
### 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.
|
1. Vous fournissez votre adresse Duniter SS58.
|
||||||
2. Le serveur genere un texte aleatoire (le "challenge") de 64 caracteres hexadecimaux.
|
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 ?
|
### 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 ?
|
### 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 ?
|
### 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 ?
|
### 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 ?
|
### 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 ?
|
### 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 une decision est executee
|
||||||
- Quand un document est archive manuellement
|
- 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 :
|
Oui. Les contenus archives sont accessibles via :
|
||||||
|
|
||||||
@@ -202,9 +202,9 @@ Oui. Les contenus archives sont accessibles via :
|
|||||||
|
|
||||||
## Questions techniques
|
## 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 ?
|
### 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 ?
|
### 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
|
- Nouveaux votes soumis : la jauge de seuil est recalculee instantanement
|
||||||
- Votes modifies : la jauge reflette le changement immediatement
|
- Votes modifies : la jauge reflette le changement immediatement
|
||||||
- Sessions cloturees : le resultat final s'affiche
|
- 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
|
- 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
+150
-30
@@ -1,19 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const orgsStore = useOrganizationsStore()
|
||||||
|
const documentsStore = useDocumentsStore()
|
||||||
|
const decisionsStore = useDecisionsStore()
|
||||||
|
const protocolsStore = useProtocolsStore()
|
||||||
|
const mandatesStore = useMandatesStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { initMood } = useMood()
|
const { initMood } = useMood()
|
||||||
|
|
||||||
const navigationItems = [
|
const navigationItems = [
|
||||||
|
{
|
||||||
|
label: 'Décisions',
|
||||||
|
icon: 'i-lucide-scale',
|
||||||
|
to: '/decisions',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Documents',
|
label: 'Documents',
|
||||||
icon: 'i-lucide-book-open',
|
icon: 'i-lucide-book-open',
|
||||||
to: '/documents',
|
to: '/documents',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Decisions',
|
|
||||||
icon: 'i-lucide-scale',
|
|
||||||
to: '/decisions',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Mandats',
|
label: 'Mandats',
|
||||||
icon: 'i-lucide-user-check',
|
icon: 'i-lucide-user-check',
|
||||||
@@ -24,6 +29,11 @@ const navigationItems = [
|
|||||||
icon: 'i-lucide-settings',
|
icon: 'i-lucide-settings',
|
||||||
to: '/protocols',
|
to: '/protocols',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Outils',
|
||||||
|
icon: 'i-lucide-wrench',
|
||||||
|
to: '/tools',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Sanctuaire',
|
label: 'Sanctuaire',
|
||||||
icon: 'i-lucide-archive',
|
icon: 'i-lucide-archive',
|
||||||
@@ -34,27 +44,52 @@ const navigationItems = [
|
|||||||
/** Mobile drawer state. */
|
/** Mobile drawer state. */
|
||||||
const mobileMenuOpen = ref(false)
|
const mobileMenuOpen = ref(false)
|
||||||
|
|
||||||
|
/** Sidebar collapse state (icons-only mode). */
|
||||||
|
const sidebarCollapsed = ref(false)
|
||||||
|
|
||||||
/** Close mobile menu on route change. */
|
/** Close mobile menu on route change. */
|
||||||
watch(() => route.path, () => {
|
watch(() => route.path, () => {
|
||||||
mobileMenuOpen.value = false
|
mobileMenuOpen.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Refetch all content stores when the active workspace changes. */
|
||||||
|
watch(() => orgsStore.activeSlug, (newSlug, oldSlug) => {
|
||||||
|
if (oldSlug !== null && newSlug !== null && newSlug !== oldSlug) {
|
||||||
|
documentsStore.fetchAll()
|
||||||
|
decisionsStore.fetchAll()
|
||||||
|
protocolsStore.fetchProtocols()
|
||||||
|
mandatesStore.fetchAll()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/** WebSocket connection and notifications. */
|
/** WebSocket connection and notifications. */
|
||||||
const ws = useWebSocket()
|
const ws = useWebSocket()
|
||||||
const { setupWsNotifications } = useNotifications()
|
const { setupWsNotifications } = useNotifications()
|
||||||
|
|
||||||
|
watch(sidebarCollapsed, (val) => {
|
||||||
|
localStorage.setItem('libred-sidebar-collapsed', String(val))
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
initMood()
|
initMood()
|
||||||
|
const savedCollapsed = localStorage.getItem('libred-sidebar-collapsed')
|
||||||
|
if (savedCollapsed !== null) sidebarCollapsed.value = savedCollapsed === 'true'
|
||||||
auth.hydrateFromStorage()
|
auth.hydrateFromStorage()
|
||||||
if (auth.token) {
|
if (auth.token) {
|
||||||
try {
|
try {
|
||||||
await auth.fetchMe()
|
await auth.fetchMe()
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
auth.logout()
|
// Déconnexion seulement sur session réellement invalide (401/403)
|
||||||
|
// Erreur réseau ou backend temporairement indisponible → conserver la session
|
||||||
|
if (err?.status === 401 || err?.status === 403) {
|
||||||
|
auth.logout()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ws.connect()
|
ws.connect()
|
||||||
setupWsNotifications(ws)
|
setupWsNotifications(ws)
|
||||||
|
// Load organizations in parallel — non-blocking, no auth required
|
||||||
|
orgsStore.fetchOrganizations()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -83,7 +118,7 @@ function isActive(to: string) {
|
|||||||
<!-- Left: Hamburger (mobile) + Logo -->
|
<!-- Left: Hamburger (mobile) + Logo -->
|
||||||
<div class="app-header__left">
|
<div class="app-header__left">
|
||||||
<button
|
<button
|
||||||
class="app-header__menu-btn md:hidden"
|
class="app-header__menu-btn"
|
||||||
aria-label="Ouvrir le menu"
|
aria-label="Ouvrir le menu"
|
||||||
@click="mobileMenuOpen = true"
|
@click="mobileMenuOpen = true"
|
||||||
>
|
>
|
||||||
@@ -94,13 +129,16 @@ function isActive(to: string) {
|
|||||||
<UIcon name="i-lucide-gavel" class="app-header__logo-icon" />
|
<UIcon name="i-lucide-gavel" class="app-header__logo-icon" />
|
||||||
</span>
|
</span>
|
||||||
<span class="app-header__logo-text">
|
<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 class="app-header__logo-libre">libre</span><span class="app-header__logo-decision">Decision</span>
|
||||||
</span>
|
</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: Mood switcher (desktop) -->
|
<!-- Center: Workspace selector + Mood switcher (desktop) -->
|
||||||
<MoodSwitcher class="hidden sm:flex" />
|
<div class="app-header__center">
|
||||||
|
<WorkspaceSelector class="hidden sm:flex" />
|
||||||
|
<MoodSwitcher class="hidden sm:flex" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Right: Auth -->
|
<!-- Right: Auth -->
|
||||||
<div class="app-header__right">
|
<div class="app-header__right">
|
||||||
@@ -159,7 +197,11 @@ function isActive(to: string) {
|
|||||||
<span>{{ item.label }}</span>
|
<span>{{ item.label }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
<!-- Mood switcher in mobile drawer -->
|
<!-- Workspace + Mood in mobile drawer -->
|
||||||
|
<div class="app-mobile-mood">
|
||||||
|
<span class="app-mobile-mood__label">Espace</span>
|
||||||
|
<WorkspaceSelector />
|
||||||
|
</div>
|
||||||
<div class="app-mobile-mood">
|
<div class="app-mobile-mood">
|
||||||
<span class="app-mobile-mood__label">Ambiance</span>
|
<span class="app-mobile-mood__label">Ambiance</span>
|
||||||
<MoodSwitcher />
|
<MoodSwitcher />
|
||||||
@@ -170,7 +212,7 @@ function isActive(to: string) {
|
|||||||
<!-- Main content with sidebar -->
|
<!-- Main content with sidebar -->
|
||||||
<div class="app-body">
|
<div class="app-body">
|
||||||
<!-- Desktop sidebar -->
|
<!-- Desktop sidebar -->
|
||||||
<aside class="app-sidebar">
|
<aside class="app-sidebar" :class="{ 'app-sidebar--collapsed': sidebarCollapsed }">
|
||||||
<nav class="app-sidebar__nav">
|
<nav class="app-sidebar__nav">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="item in navigationItems"
|
v-for="item in navigationItems"
|
||||||
@@ -179,9 +221,21 @@ function isActive(to: string) {
|
|||||||
class="app-sidebar__link"
|
class="app-sidebar__link"
|
||||||
:class="{ 'app-sidebar__link--active': isActive(item.to) }"
|
:class="{ 'app-sidebar__link--active': isActive(item.to) }"
|
||||||
>
|
>
|
||||||
<UIcon :name="item.icon" class="text-lg" />
|
<UIcon :name="item.icon" class="text-lg flex-shrink-0" />
|
||||||
<span>{{ item.label }}</span>
|
<span class="app-sidebar__link-label">{{ item.label }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<div class="app-sidebar__divider" />
|
||||||
|
<button
|
||||||
|
class="app-sidebar__toggle"
|
||||||
|
:title="sidebarCollapsed ? 'Déplier le menu' : 'Replier le menu'"
|
||||||
|
@click="sidebarCollapsed = !sidebarCollapsed"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
:name="sidebarCollapsed ? 'i-lucide-panel-left-open' : 'i-lucide-panel-left-close'"
|
||||||
|
class="text-base flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="app-sidebar__link-label">Replier</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -229,7 +283,7 @@ function isActive(to: string) {
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<span>ğ(Decision) v0.1.0</span>
|
<span>libreDecision v0.1.0</span>
|
||||||
<span class="app-footer__sep">·</span>
|
<span class="app-footer__sep">·</span>
|
||||||
<span>Licence libre</span>
|
<span>Licence libre</span>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -260,9 +314,18 @@ function isActive(to: string) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
height: 3.5rem;
|
height: 3.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-header__center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.app-header__left {
|
.app-header__left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -285,6 +348,12 @@ function isActive(to: string) {
|
|||||||
background: var(--mood-accent-soft);
|
background: var(--mood-accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.app-header__menu-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.app-header__logo {
|
.app-header__logo {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -319,24 +388,17 @@ function isActive(to: string) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header__logo-g {
|
.app-header__logo-libre {
|
||||||
font-size: 1.5rem;
|
font-size: 1.0625rem;
|
||||||
font-weight: 800;
|
font-weight: 400;
|
||||||
color: var(--mood-accent);
|
|
||||||
line-height: 1;
|
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-paren {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--mood-text-muted);
|
color: var(--mood-text-muted);
|
||||||
opacity: 0.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header__logo-word {
|
.app-header__logo-decision {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--mood-text);
|
color: var(--mood-text);
|
||||||
@@ -443,6 +505,12 @@ function isActive(to: string) {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--mood-surface);
|
background: var(--mood-surface);
|
||||||
display: none;
|
display: none;
|
||||||
|
transition: width 0.22s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar--collapsed {
|
||||||
|
width: 3.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
@@ -454,7 +522,7 @@ function isActive(to: string) {
|
|||||||
.app-sidebar__nav {
|
.app-sidebar__nav {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 3.5rem;
|
top: 3.5rem;
|
||||||
padding: 1rem 0.75rem;
|
padding: 1rem 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -464,13 +532,15 @@ function isActive(to: string) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
padding: 0.625rem 0.875rem;
|
padding: 0.625rem 0.75rem;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--mood-text-muted);
|
color: var(--mood-text-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
transition: all 0.12s ease;
|
transition: all 0.12s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-sidebar__link:hover {
|
.app-sidebar__link:hover {
|
||||||
@@ -484,6 +554,56 @@ function isActive(to: string) {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-sidebar__link-label {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity 0.18s ease, max-width 0.22s ease;
|
||||||
|
max-width: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar--collapsed .app-sidebar__link-label {
|
||||||
|
opacity: 0;
|
||||||
|
max-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar--collapsed .app-sidebar__link {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar__divider {
|
||||||
|
height: 1px;
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
|
||||||
|
margin: 0.375rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar__toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 100%;
|
||||||
|
transition: all 0.12s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar__toggle:hover {
|
||||||
|
color: var(--mood-text);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar--collapsed .app-sidebar__toggle {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Mobile nav === */
|
/* === Mobile nav === */
|
||||||
.app-mobile-nav {
|
.app-mobile-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
ğ(Decision) — Mood / Ambiance System
|
libreDecision — Mood / Ambiance System
|
||||||
Palettes harmoniques variees, colores en lite, lumineux en dark.
|
Palettes harmoniques variees, colores en lite, lumineux en dark.
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
Global design tokens — Nunito, rounded, borderless
|
Global design tokens — Plus Jakarta Sans, rounded, borderless
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
*,
|
*,
|
||||||
@@ -154,12 +154,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-family: 'Nunito', system-ui, -apple-system, sans-serif;
|
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Nunito', system-ui, -apple-system, sans-serif;
|
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
@@ -234,7 +234,7 @@ input:focus, select:focus, textarea:focus {
|
|||||||
:root [class*="u-button"],
|
:root [class*="u-button"],
|
||||||
:root [data-variant] {
|
:root [data-variant] {
|
||||||
border: none !important;
|
border: none !important;
|
||||||
font-family: 'Nunito', system-ui, sans-serif !important;
|
font-family: 'Plus Jakarta Sans', system-ui, sans-serif !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root input,
|
:root input,
|
||||||
@@ -244,5 +244,5 @@ input:focus, select:focus, textarea:focus {
|
|||||||
:root [class*="USelect"],
|
:root [class*="USelect"],
|
||||||
:root [class*="UTextarea"] {
|
:root [class*="UTextarea"] {
|
||||||
border: none !important;
|
border: none !important;
|
||||||
font-family: 'Nunito', system-ui, sans-serif !important;
|
font-family: 'Plus Jakarta Sans', system-ui, sans-serif !important;
|
||||||
}
|
}
|
||||||
|
|||||||
+138
-83
@@ -2,8 +2,8 @@
|
|||||||
/**
|
/**
|
||||||
* SectionLayout — Mise en page pour sections.
|
* SectionLayout — Mise en page pour sections.
|
||||||
*
|
*
|
||||||
* Status pills inside the content block (not header).
|
* Desktop (≥1024px) : 2 colonnes, toolbox sticky à droite, toujours visible.
|
||||||
* Toolbox sidebar with condensed content.
|
* Mobile/tablette : toolbox en USlideover droit, bouton flottant.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface StatusFilter {
|
export interface StatusFilter {
|
||||||
@@ -30,11 +30,13 @@ const props = withDefaults(
|
|||||||
statuses: StatusFilter[]
|
statuses: StatusFilter[]
|
||||||
toolboxItems?: ToolboxItem[]
|
toolboxItems?: ToolboxItem[]
|
||||||
activeStatus?: string | null
|
activeStatus?: string | null
|
||||||
|
toolboxTitle?: string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
subtitle: undefined,
|
subtitle: undefined,
|
||||||
toolboxItems: undefined,
|
toolboxItems: undefined,
|
||||||
activeStatus: null,
|
activeStatus: null,
|
||||||
|
toolboxTitle: 'Boîte à outils',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -75,16 +77,27 @@ function toggleStatus(statusId: string) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<!-- Header: just title -->
|
<!-- Header -->
|
||||||
<div class="section__header">
|
<div class="section__header">
|
||||||
<h1 class="section__title">{{ title }}</h1>
|
<div class="section__header-left">
|
||||||
<p v-if="subtitle" class="section__subtitle">{{ subtitle }}</p>
|
<h1 class="section__title">{{ title }}</h1>
|
||||||
|
<p v-if="subtitle" class="section__subtitle">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<!-- Mobile toolbox trigger -->
|
||||||
|
<button
|
||||||
|
class="section__toolbox-fab lg:hidden"
|
||||||
|
:class="{ 'section__toolbox-fab--active': toolboxOpen }"
|
||||||
|
@click="toolboxOpen = true"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-wrench" />
|
||||||
|
<span>Outils</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body: content + toolbox -->
|
<!-- Body: content + toolbox -->
|
||||||
<div class="section__body">
|
<div class="section__body">
|
||||||
<div class="section__main">
|
<div class="section__main">
|
||||||
<!-- Status pills INSIDE the list block -->
|
<!-- Status pills -->
|
||||||
<div v-if="statuses.length > 0" class="section__pills">
|
<div v-if="statuses.length > 0" class="section__pills">
|
||||||
<button
|
<button
|
||||||
v-for="status in statuses"
|
v-for="status in statuses"
|
||||||
@@ -107,22 +120,17 @@ function toggleStatus(statusId: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop toolbox sidebar (≥1024px) -->
|
||||||
<aside class="section__toolbox">
|
<aside class="section__toolbox">
|
||||||
<button class="section__toolbox-head" @click="toolboxOpen = !toolboxOpen">
|
<div class="section__toolbox-head">
|
||||||
<div class="section__toolbox-head-left">
|
<UIcon name="i-lucide-wrench" class="section__toolbox-head-icon" />
|
||||||
<UIcon name="i-lucide-wrench" />
|
<span>{{ toolboxTitle }}</span>
|
||||||
<span>Boite a outils</span>
|
</div>
|
||||||
</div>
|
<div class="section__toolbox-body">
|
||||||
<UIcon
|
<div v-if="$slots.toolbox">
|
||||||
: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" />
|
<slot name="toolbox" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="toolboxItems && toolboxItems.length > 0" class="section__toolbox-body">
|
<div v-else-if="toolboxItems && toolboxItems.length > 0">
|
||||||
<ToolboxVignette
|
<ToolboxVignette
|
||||||
v-for="(item, idx) in toolboxItems"
|
v-for="(item, idx) in toolboxItems"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
@@ -135,6 +143,36 @@ function toggleStatus(statusId: string) {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile toolbox: USlideover from right -->
|
||||||
|
<USlideover
|
||||||
|
v-model:open="toolboxOpen"
|
||||||
|
side="right"
|
||||||
|
:title="toolboxTitle"
|
||||||
|
:ui="{
|
||||||
|
width: 'max-w-sm',
|
||||||
|
header: { padding: 'p-4' },
|
||||||
|
body: { padding: 'p-4' },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="section__toolbox-slideover">
|
||||||
|
<div v-if="$slots.toolbox">
|
||||||
|
<slot name="toolbox" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="toolboxItems && toolboxItems.length > 0">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
</USlideover>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -142,10 +180,24 @@ function toggleStatus(statusId: string) {
|
|||||||
.section {
|
.section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.section { gap: 1.5rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
.section__header {
|
.section__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section__header-left {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -156,33 +208,66 @@ function toggleStatus(statusId: string) {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--mood-text);
|
color: var(--mood-text);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.section__title {
|
.section__title { font-size: 1.75rem; }
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section__subtitle {
|
.section__subtitle {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--mood-text-muted);
|
color: var(--mood-text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.section__subtitle {
|
.section__subtitle { font-size: 1rem; }
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile toolbox trigger */
|
||||||
|
.section__toolbox-fab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section__toolbox-fab:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 3px 10px var(--mood-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section__toolbox-fab--active {
|
||||||
|
background: var(--mood-accent);
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body layout */
|
||||||
.section__body {
|
.section__body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 16rem;
|
grid-template-columns: 1fr;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.section__body {
|
||||||
|
grid-template-columns: 1fr 30rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.section__main {
|
.section__main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -190,6 +275,7 @@ function toggleStatus(statusId: string) {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Status pills */
|
||||||
.section__pills {
|
.section__pills {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -200,9 +286,7 @@ function toggleStatus(statusId: string) {
|
|||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section__pills::-webkit-scrollbar {
|
.section__pills::-webkit-scrollbar { display: none; }
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.section__pills {
|
.section__pills {
|
||||||
@@ -226,64 +310,51 @@ function toggleStatus(statusId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
@media (max-width: 639px) {
|
||||||
.section__search {
|
.section__search { flex-direction: column; }
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section__content {
|
.section__content { min-height: 12rem; }
|
||||||
min-height: 12rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* Desktop toolbox sidebar */
|
||||||
.section__toolbox {
|
.section__toolbox {
|
||||||
|
display: none;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 4.5rem;
|
top: 4.5rem;
|
||||||
display: flex;
|
align-self: start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--mood-surface);
|
background: var(--mood-surface);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
max-height: calc(100vh - 5.5rem);
|
||||||
|
box-shadow: 0 4px 24px var(--mood-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.section__toolbox { display: flex; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.section__toolbox-head {
|
.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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
font-size: 0.875rem;
|
padding: 0.875rem 1rem 0.625rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--mood-accent);
|
color: var(--mood-accent);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.05em;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section__toolbox-toggle {
|
.section__toolbox-head-icon {
|
||||||
color: var(--mood-text-muted);
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section__toolbox-content {
|
|
||||||
display: none;
|
|
||||||
padding: 0 1rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__toolbox-content--open {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__toolbox-body {
|
.section__toolbox-body {
|
||||||
|
padding: 0 0.75rem 0.875rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.625rem;
|
gap: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section__toolbox-empty {
|
.section__toolbox-empty {
|
||||||
@@ -293,26 +364,10 @@ function toggleStatus(statusId: string) {
|
|||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop: toolbox always open, no toggle */
|
/* Slideover content */
|
||||||
@media (min-width: 1024px) {
|
.section__toolbox-slideover {
|
||||||
.section__toolbox-head {
|
display: flex;
|
||||||
cursor: default;
|
flex-direction: column;
|
||||||
}
|
gap: 0.75rem;
|
||||||
.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>
|
</style>
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const orgsStore = useOrganizationsStore()
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const active = computed(() => orgsStore.active)
|
||||||
|
const organizations = computed(() => orgsStore.organizations)
|
||||||
|
|
||||||
|
function selectOrg(slug: string) {
|
||||||
|
orgsStore.setActive(slug)
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="containerRef" class="ws">
|
||||||
|
<button
|
||||||
|
class="ws__trigger"
|
||||||
|
:class="{ 'ws__trigger--open': isOpen }"
|
||||||
|
:disabled="orgsStore.loading || !active"
|
||||||
|
@click="isOpen = !isOpen"
|
||||||
|
>
|
||||||
|
<div v-if="orgsStore.loading" class="ws__icon ws__icon--muted">
|
||||||
|
<UIcon name="i-lucide-loader-2" class="animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="active" class="ws__icon" :style="{ background: active.color ? active.color + '22' : undefined, color: active.color || undefined }">
|
||||||
|
<UIcon :name="active.icon || 'i-lucide-building'" />
|
||||||
|
</div>
|
||||||
|
<span class="ws__name">{{ active?.name ?? '…' }}</span>
|
||||||
|
<UIcon name="i-lucide-chevrons-up-down" class="ws__caret" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition name="dropdown">
|
||||||
|
<div v-if="isOpen && organizations.length" class="ws__dropdown">
|
||||||
|
<div class="ws__dropdown-header">
|
||||||
|
Espace de travail
|
||||||
|
</div>
|
||||||
|
<div class="ws__items">
|
||||||
|
<button
|
||||||
|
v-for="org in organizations"
|
||||||
|
:key="org.id"
|
||||||
|
class="ws__item"
|
||||||
|
:class="{ 'ws__item--active': org.slug === orgsStore.activeSlug }"
|
||||||
|
@click="selectOrg(org.slug)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ws__item-icon"
|
||||||
|
:style="{ background: org.color ? org.color + '22' : undefined, color: org.color || undefined }"
|
||||||
|
>
|
||||||
|
<UIcon :name="org.icon || 'i-lucide-building'" />
|
||||||
|
</div>
|
||||||
|
<div class="ws__item-info">
|
||||||
|
<span class="ws__item-name">{{ org.name }}</span>
|
||||||
|
<span class="ws__item-role">{{ org.is_transparent ? 'Public' : 'Membres' }}</span>
|
||||||
|
</div>
|
||||||
|
<UIcon v-if="org.slug === orgsStore.activeSlug" name="i-lucide-check" class="ws__item-check" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="ws__dropdown-footer">
|
||||||
|
<button class="ws__new-btn" disabled>
|
||||||
|
<UIcon name="i-lucide-plus" />
|
||||||
|
Nouveau collectif
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ws {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s ease;
|
||||||
|
min-height: 2rem;
|
||||||
|
max-width: 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__trigger:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--mood-accent-soft) 80%, var(--mood-accent) 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__trigger--open {
|
||||||
|
background: color-mix(in srgb, var(--mood-accent-soft) 60%, var(--mood-accent) 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__trigger:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__icon {
|
||||||
|
width: 1.375rem;
|
||||||
|
height: 1.375rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__icon--muted {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__name {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__caret {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown */
|
||||||
|
.ws__dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.375rem);
|
||||||
|
left: 0;
|
||||||
|
min-width: 13rem;
|
||||||
|
background: var(--mood-surface);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 8px 32px var(--mood-shadow);
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__dropdown-header {
|
||||||
|
padding: 0.625rem 0.875rem 0.375rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__items {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.625rem 0.625rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__item:hover { background: var(--mood-accent-soft); }
|
||||||
|
.ws__item--active { background: var(--mood-accent-soft); }
|
||||||
|
|
||||||
|
.ws__item-icon {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__item-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__item-role {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__item-check {
|
||||||
|
color: var(--mood-accent);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__dropdown-footer {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-top: 1px solid var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws__new-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
background: none;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition */
|
||||||
|
.dropdown-enter-active, .dropdown-leave-active {
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
transform-origin: top left;
|
||||||
|
}
|
||||||
|
.dropdown-enter-from, .dropdown-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95) translateY(-4px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* ToolboxVignette — Carte compacte, bullet points, bouton Demarrer.
|
* ToolboxVignette — Carte compacte, collapsible, bullet points + actions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ToolboxAction {
|
export interface ToolboxAction {
|
||||||
@@ -16,10 +16,12 @@ const props = withDefaults(
|
|||||||
title: string
|
title: string
|
||||||
bullets?: string[]
|
bullets?: string[]
|
||||||
actions?: ToolboxAction[]
|
actions?: ToolboxAction[]
|
||||||
|
defaultOpen?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
bullets: undefined,
|
bullets: undefined,
|
||||||
actions: undefined,
|
actions: undefined,
|
||||||
|
defaultOpen: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,6 +29,8 @@ const emit = defineEmits<{
|
|||||||
action: [actionEmit: string]
|
action: [actionEmit: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const open = ref(props.defaultOpen)
|
||||||
|
|
||||||
const defaultActions: ToolboxAction[] = [
|
const defaultActions: ToolboxAction[] = [
|
||||||
{ label: 'Tutos', icon: 'i-lucide-graduation-cap', emit: 'tutos' },
|
{ label: 'Tutos', icon: 'i-lucide-graduation-cap', emit: 'tutos' },
|
||||||
{ label: 'Formules', icon: 'i-lucide-calculator', emit: 'formules' },
|
{ label: 'Formules', icon: 'i-lucide-calculator', emit: 'formules' },
|
||||||
@@ -46,22 +50,27 @@ function handleAction(action: ToolboxAction) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="vignette">
|
<div class="vignette" :class="{ 'vignette--open': open }">
|
||||||
<h4 class="vignette__title">{{ title }}</h4>
|
<button class="vignette__header" @click="open = !open">
|
||||||
<ul v-if="bullets && bullets.length > 0" class="vignette__bullets">
|
<h4 class="vignette__title">{{ title }}</h4>
|
||||||
<li v-for="(b, i) in bullets" :key="i">{{ b }}</li>
|
<UIcon name="i-lucide-chevron-down" class="vignette__chevron" />
|
||||||
</ul>
|
</button>
|
||||||
<div class="vignette__actions">
|
<div v-show="open" class="vignette__content">
|
||||||
<button
|
<ul v-if="bullets && bullets.length > 0" class="vignette__bullets">
|
||||||
v-for="action in resolvedActions"
|
<li v-for="(b, i) in bullets" :key="i">{{ b }}</li>
|
||||||
:key="action.label"
|
</ul>
|
||||||
class="vignette__btn"
|
<div class="vignette__actions">
|
||||||
:class="{ 'vignette__btn--primary': action.primary }"
|
<button
|
||||||
@click="handleAction(action)"
|
v-for="action in resolvedActions"
|
||||||
>
|
:key="action.label"
|
||||||
<UIcon v-if="action.icon" :name="action.icon" />
|
class="vignette__btn"
|
||||||
<span>{{ action.label }}</span>
|
:class="{ 'vignette__btn--primary': action.primary }"
|
||||||
</button>
|
@click="handleAction(action)"
|
||||||
|
>
|
||||||
|
<UIcon v-if="action.icon" :name="action.icon" />
|
||||||
|
<span>{{ action.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -70,19 +79,53 @@ function handleAction(action: ToolboxAction) {
|
|||||||
.vignette {
|
.vignette {
|
||||||
background: var(--mood-accent-soft);
|
background: var(--mood-accent-soft);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 0.75rem;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vignette__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
gap: 0.375rem;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vignette__header:hover {
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vignette__title {
|
.vignette__title {
|
||||||
|
flex: 1;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--mood-text);
|
color: var(--mood-text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vignette__chevron {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vignette--open .vignette__chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vignette__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0 0.75rem 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
.vignette__bullets {
|
.vignette__bullets {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 0 0 1rem;
|
padding: 0 0 0 1rem;
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* DocumentPreview — clean "PDF-like" viewer for a reference document.
|
||||||
|
*
|
||||||
|
* Two modes:
|
||||||
|
* - current: shows current_text for every item (document en vigueur)
|
||||||
|
* - projected: applies latest "vote" or "proposed" version per item,
|
||||||
|
* highlighting changed clauses (document tel qu'il serait si les
|
||||||
|
* votes en cours passaient)
|
||||||
|
*/
|
||||||
|
import type { Document, DocumentItem, ItemVersion } from '~/stores/documents'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
document: Document
|
||||||
|
items: DocumentItem[]
|
||||||
|
mode: 'current' | 'projected'
|
||||||
|
versionMap: Record<string, ItemVersion | null>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sortedItems = computed(() =>
|
||||||
|
[...props.items].sort((a, b) => a.sort_order - b.sort_order),
|
||||||
|
)
|
||||||
|
|
||||||
|
const changedCount = computed(() =>
|
||||||
|
props.mode === 'projected'
|
||||||
|
? Object.values(props.versionMap).filter(Boolean).length
|
||||||
|
: 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
function getDisplayText(item: DocumentItem): string {
|
||||||
|
if (props.mode === 'projected' && props.versionMap[item.id]) {
|
||||||
|
return props.versionMap[item.id]!.proposed_text
|
||||||
|
}
|
||||||
|
return item.current_text
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChanged(item: DocumentItem): boolean {
|
||||||
|
return props.mode === 'projected' && !!props.versionMap[item.id]
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemTypeLabel(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'clause': return 'Engagement'
|
||||||
|
case 'rule': return 'Variable'
|
||||||
|
case 'verification': return 'Application'
|
||||||
|
case 'preamble': return 'Préambule'
|
||||||
|
default: return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date().toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="doc-preview">
|
||||||
|
<!-- ══ Document header ══ -->
|
||||||
|
<div class="doc-preview__header">
|
||||||
|
<div class="doc-preview__watermark" aria-hidden="true">
|
||||||
|
{{ mode === 'projected' ? 'PROJECTION' : 'EN VIGUEUR' }}
|
||||||
|
</div>
|
||||||
|
<h1 class="doc-preview__title">{{ document.title }}</h1>
|
||||||
|
<div class="doc-preview__meta">
|
||||||
|
<span>Version {{ document.version }}</span>
|
||||||
|
<span class="doc-preview__sep">·</span>
|
||||||
|
<span>{{ document.items_count }} items</span>
|
||||||
|
<span v-if="mode === 'projected'" class="doc-preview__proj-badge">
|
||||||
|
<UIcon name="i-lucide-flask-conical" class="text-xs" />
|
||||||
|
{{ changedCount }} modification{{ changedCount > 1 ? 's' : '' }} projetée{{ changedCount > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="doc-preview__current-badge">
|
||||||
|
<UIcon name="i-lucide-circle-check" class="text-xs" />
|
||||||
|
Texte officiel
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ══ Items ══ -->
|
||||||
|
<div class="doc-preview__body">
|
||||||
|
<template v-for="item in sortedItems" :key="item.id">
|
||||||
|
<!-- Section heading -->
|
||||||
|
<div v-if="item.item_type === 'section'" class="doc-preview__section">
|
||||||
|
<h2 class="doc-preview__section-title">
|
||||||
|
<UIcon name="i-lucide-bookmark" class="text-sm" style="color: var(--mood-accent)" />
|
||||||
|
{{ item.title || item.current_text }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preamble -->
|
||||||
|
<div v-else-if="item.item_type === 'preamble'" class="doc-preview__preamble">
|
||||||
|
<MarkdownRenderer :content="getDisplayText(item)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Regular clause / rule / verification -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="doc-preview__item"
|
||||||
|
:class="{ 'doc-preview__item--changed': isChanged(item) }"
|
||||||
|
>
|
||||||
|
<div class="doc-preview__item-head">
|
||||||
|
<span class="doc-preview__item-pos">{{ item.position }}</span>
|
||||||
|
<span v-if="item.title" class="doc-preview__item-title">{{ item.title }}</span>
|
||||||
|
<span v-if="itemTypeLabel(item.item_type)" class="doc-preview__item-type">
|
||||||
|
{{ itemTypeLabel(item.item_type) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="isChanged(item)" class="doc-preview__change-chip">
|
||||||
|
<UIcon name="i-lucide-git-branch" class="text-xs" />
|
||||||
|
Vote en cours
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="doc-preview__item-text">
|
||||||
|
<MarkdownRenderer :content="getDisplayText(item)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ══ Footer ══ -->
|
||||||
|
<div class="doc-preview__footer">
|
||||||
|
<div class="doc-preview__footer-main">
|
||||||
|
<span>libreDecision · {{ document.title }} · v{{ document.version }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="mode === 'projected'" class="doc-preview__footer-note">
|
||||||
|
Projection non officielle — texte simulé selon {{ changedCount }} vote{{ changedCount > 1 ? 's' : '' }} en cours au {{ today }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.doc-preview {
|
||||||
|
position: relative;
|
||||||
|
background: var(--mood-surface);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: clamp(1.5rem, 4vw, 3rem);
|
||||||
|
box-shadow: 0 4px 32px var(--mood-shadow);
|
||||||
|
line-height: 1.75;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Watermark ── */
|
||||||
|
.doc-preview__watermark {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%) rotate(-35deg);
|
||||||
|
font-size: clamp(2rem, 8vw, 5rem);
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
opacity: 0.03;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.doc-preview__header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 2px solid color-mix(in srgb, var(--mood-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__title {
|
||||||
|
font-size: clamp(1.25rem, 3vw, 1.875rem);
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--mood-text);
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
margin-bottom: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__sep { opacity: 0.3; }
|
||||||
|
|
||||||
|
.doc-preview__proj-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 2px 0.625rem;
|
||||||
|
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 15%, transparent);
|
||||||
|
color: var(--mood-warning, #d97706);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__current-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 2px 0.625rem;
|
||||||
|
background: color-mix(in srgb, var(--mood-success, #16a34a) 12%, transparent);
|
||||||
|
color: var(--mood-success, #16a34a);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Body ── */
|
||||||
|
.doc-preview__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section heading */
|
||||||
|
.doc-preview__section {
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
||||||
|
padding-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preamble */
|
||||||
|
.doc-preview__preamble {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item */
|
||||||
|
.doc-preview__item {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--mood-bg) 35%, transparent);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__item--changed {
|
||||||
|
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 8%, var(--mood-surface));
|
||||||
|
border-left: 3px solid var(--mood-warning, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__item-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__item-pos {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
min-width: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__item-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__item-type {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__change-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 1px 0.5rem;
|
||||||
|
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 18%, transparent);
|
||||||
|
color: var(--mood-warning, #d97706);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__item-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
.doc-preview__footer {
|
||||||
|
margin-top: 3rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__footer-main {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__footer-note {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Print ── */
|
||||||
|
@media print {
|
||||||
|
.doc-preview {
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 2rem;
|
||||||
|
background: white;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.doc-preview__watermark { opacity: 0.05; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,659 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* ContextMapper — Recommandeur de méthode de décision.
|
||||||
|
* 4 questions de contexte → méthode optimale + justification.
|
||||||
|
* Basé sur : Smith (WoT G1), Laloux (advice process), sociocracie.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Option { value: string; label: string; icon: string }
|
||||||
|
interface Question { id: string; question: string; hint?: string; options: Option[] }
|
||||||
|
|
||||||
|
interface MethodRec {
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
tag: string
|
||||||
|
tagColor: string
|
||||||
|
description: string
|
||||||
|
formula?: string
|
||||||
|
when: string
|
||||||
|
pros: string[]
|
||||||
|
cons: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const questions: Question[] = [
|
||||||
|
{
|
||||||
|
id: 'urgency',
|
||||||
|
question: 'Quelle est l\'urgence ?',
|
||||||
|
hint: 'Le délai disponible avant que la décision soit nécessaire',
|
||||||
|
options: [
|
||||||
|
{ value: 'immediate', label: 'Immédiate', icon: 'i-lucide-zap' },
|
||||||
|
{ value: 'short', label: '< 48h', icon: 'i-lucide-clock' },
|
||||||
|
{ value: 'normal', label: 'Planifiable', icon: 'i-lucide-calendar' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stakes',
|
||||||
|
question: 'Quel est l\'enjeu ?',
|
||||||
|
hint: 'L\'impact et la réversibilité de la décision',
|
||||||
|
options: [
|
||||||
|
{ value: 'irreversible', label: 'Irréversible', icon: 'i-lucide-lock' },
|
||||||
|
{ value: 'major', label: 'Majeur', icon: 'i-lucide-alert-triangle' },
|
||||||
|
{ value: 'moderate', label: 'Modéré', icon: 'i-lucide-minus-circle' },
|
||||||
|
{ value: 'minor', label: 'Mineur', icon: 'i-lucide-info' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'groupSize',
|
||||||
|
question: 'Taille du groupe ?',
|
||||||
|
hint: 'Nombre de personnes concernées ou habilitées à voter',
|
||||||
|
options: [
|
||||||
|
{ value: 'small', label: '< 10', icon: 'i-lucide-user' },
|
||||||
|
{ value: 'medium', label: '10 – 100', icon: 'i-lucide-users' },
|
||||||
|
{ value: 'large', label: '100+', icon: 'i-lucide-globe' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nature',
|
||||||
|
question: 'Nature de la décision ?',
|
||||||
|
hint: 'Le type de compétence principalement sollicité',
|
||||||
|
options: [
|
||||||
|
{ value: 'technical', label: 'Technique', icon: 'i-lucide-cpu' },
|
||||||
|
{ value: 'political', label: 'Politique', icon: 'i-lucide-landmark' },
|
||||||
|
{ value: 'operational', label: 'Opérationnelle', icon: 'i-lucide-settings' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const answers = ref<Record<string, string>>({})
|
||||||
|
const step = ref(0)
|
||||||
|
const animating = ref(false)
|
||||||
|
|
||||||
|
const currentQuestion = computed(() => questions[step.value])
|
||||||
|
const isComplete = computed(() => Object.keys(answers.value).length === questions.length)
|
||||||
|
const progress = computed(() => (step.value / questions.length) * 100)
|
||||||
|
|
||||||
|
function selectAnswer(questionId: string, value: string) {
|
||||||
|
answers.value = { ...answers.value, [questionId]: value }
|
||||||
|
if (step.value < questions.length - 1) {
|
||||||
|
animating.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
step.value++
|
||||||
|
animating.value = false
|
||||||
|
}, 160)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (step.value > 0) step.value--
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
answers.value = {}
|
||||||
|
step.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommendation = computed((): MethodRec | null => {
|
||||||
|
if (!isComplete.value) return null
|
||||||
|
const { urgency, stakes, groupSize, nature } = answers.value
|
||||||
|
|
||||||
|
// Immediate → Advice process (Laloux)
|
||||||
|
if (urgency === 'immediate') {
|
||||||
|
return {
|
||||||
|
name: 'Processus de sollicitation d\'avis',
|
||||||
|
icon: 'i-lucide-message-circle',
|
||||||
|
tag: 'Laloux / Teal',
|
||||||
|
tagColor: 'teal',
|
||||||
|
description: 'Le décideur identifié consulte les personnes expertes et impactées, puis décide seul et en rend compte. Rapide, non-bloquant, responsabilisant.',
|
||||||
|
formula: 'Pas de vote — consultation libre → décision documentée → compte-rendu',
|
||||||
|
when: 'Urgence opérationnelle, décision réversible, responsable clairement identifié.',
|
||||||
|
pros: ['Rapide (< 2h)', 'Non-bloquant', 'Responsabilise le décideur'],
|
||||||
|
cons: ['Requiert confiance dans le décideur', 'Pas de validation collective'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Technical + medium/large → Smith WoT
|
||||||
|
if (nature === 'technical' && groupSize !== 'small') {
|
||||||
|
return {
|
||||||
|
name: 'Vote inertiel WoT + critère Smith',
|
||||||
|
icon: 'i-lucide-network',
|
||||||
|
tag: 'G1 standard',
|
||||||
|
tagColor: 'accent',
|
||||||
|
description: 'Vote communautaire avec seuil adaptatif à la participation. Le critère Smith garantit que la décision reflète l\'expertise des validateurs.',
|
||||||
|
formula: 'R = C + B^W + (M + (1−M)·(1−(T/W)^G))·max(0,T−C)\nSeuil Smith : ⌈SmithWoT^S⌉',
|
||||||
|
when: 'Décision technique nécessitant validation par les experts WoT (forgerons, CoTec).',
|
||||||
|
pros: ['Validé par expertise', 'Adaptatif à la participation', 'Tracé on-chain'],
|
||||||
|
cons: ['Durée minimum 7-30j', 'Complexité de la formule'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Irreversible + large → High threshold WoT
|
||||||
|
if (stakes === 'irreversible' && groupSize === 'large') {
|
||||||
|
return {
|
||||||
|
name: 'Vote inertiel WoT (inertie forte)',
|
||||||
|
icon: 'i-lucide-shield',
|
||||||
|
tag: 'G1 renforcé',
|
||||||
|
tagColor: 'secondary',
|
||||||
|
description: 'Pour les décisions irréversibles à fort impact : seuil de quasi-unanimité si faible participation, majorité qualifiée avec forte participation.',
|
||||||
|
formula: 'R = C + B^W + (M + (1−M)·(1−(T/W)^G))·max(0,T−C)\nParamètres : M=67%, G=0.3 (inertie forte)',
|
||||||
|
when: 'Textes fondateurs, modifications structurelles, décisions irréversibles pour 100+ membres.',
|
||||||
|
pros: ['Protection maximale', 'Légitimité forte', 'Résistant aux minorités actives'],
|
||||||
|
cons: ['Durée longue (30+ jours)', 'Peut bloquer les évolutions nécessaires'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small group → Sociocratic consent
|
||||||
|
if (groupSize === 'small') {
|
||||||
|
return {
|
||||||
|
name: 'Consentement sociocratique',
|
||||||
|
icon: 'i-lucide-check-circle-2',
|
||||||
|
tag: 'Sociocracie',
|
||||||
|
tagColor: 'tertiary',
|
||||||
|
description: 'Adoption si aucune objection grave n\'est soulevée. Une objection grave = la décision nuit à la mission commune, pas juste une préférence personnelle.',
|
||||||
|
formula: 'Adoptée si : aucune objection grave parmi les membres du cercle',
|
||||||
|
when: 'Cercle de travail (< 10 membres), enjeu modéré, décision réversible.',
|
||||||
|
pros: ['Rapide', 'Inclusif', 'Distingue objection grave et préférence'],
|
||||||
|
cons: ['Ne convient pas aux grands groupes', 'Risque de pression sociale'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Political + medium → WoT majority
|
||||||
|
if (nature === 'political') {
|
||||||
|
return {
|
||||||
|
name: 'Vote majoritaire WoT',
|
||||||
|
icon: 'i-lucide-vote',
|
||||||
|
tag: 'G1 standard',
|
||||||
|
tagColor: 'accent',
|
||||||
|
description: 'Vote binaire (Pour/Contre) avec seuil adaptatif à la participation WoT. Standard pour les décisions politiques de la communauté.',
|
||||||
|
formula: 'R = C + B^W + (M + (1−M)·(1−(T/W)^G))·max(0,T−C)',
|
||||||
|
when: 'Décision politique communautaire, participation variable, groupe >10.',
|
||||||
|
pros: ['Standard WoT', 'Adaptatif', 'Tracé on-chain'],
|
||||||
|
cons: ['Durée 7-30j', 'Participation faible possible'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: minor/operational
|
||||||
|
return {
|
||||||
|
name: 'Advice process + validation légère',
|
||||||
|
icon: 'i-lucide-thumbs-up',
|
||||||
|
tag: 'Léger',
|
||||||
|
tagColor: 'teal',
|
||||||
|
description: 'Pour les décisions mineures ou opérationnelles : consultation des parties concernées, décision par le responsable désigné, notification de la communauté.',
|
||||||
|
formula: 'Consultation → Décision → Notification (sans vote formel)',
|
||||||
|
when: 'Décision opérationnelle de faible impact, facilement réversible.',
|
||||||
|
pros: ['Très rapide', 'Non-bloquant', 'Adapté à l\'opérationnel'],
|
||||||
|
cons: ['Légitimité limitée', 'Ne convient pas aux enjeux majeurs'],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{ use: [name: string] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cmap">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="cmap__head">
|
||||||
|
<UIcon name="i-lucide-compass" class="cmap__head-icon" />
|
||||||
|
<div>
|
||||||
|
<h3 class="cmap__title">Choisir une méthode</h3>
|
||||||
|
<p class="cmap__subtitle">4 questions pour la méthode adaptée</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result -->
|
||||||
|
<Transition name="fade-up" mode="out-in">
|
||||||
|
<div v-if="isComplete" key="result" class="cmap__result">
|
||||||
|
<div class="cmap__result-header">
|
||||||
|
<div class="cmap__result-icon">
|
||||||
|
<UIcon :name="recommendation!.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="cmap__result-info">
|
||||||
|
<span class="cmap__result-tag" :class="`cmap__result-tag--${recommendation!.tagColor}`">
|
||||||
|
{{ recommendation!.tag }}
|
||||||
|
</span>
|
||||||
|
<h4 class="cmap__result-name">{{ recommendation!.name }}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="cmap__result-desc">{{ recommendation!.description }}</p>
|
||||||
|
|
||||||
|
<div v-if="recommendation!.formula" class="cmap__formula">
|
||||||
|
<span class="cmap__formula-label">Formule</span>
|
||||||
|
<pre class="cmap__formula-code">{{ recommendation!.formula }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cmap__pros-cons">
|
||||||
|
<div>
|
||||||
|
<span class="cmap__pros-label">Pour</span>
|
||||||
|
<ul class="cmap__list cmap__list--pro">
|
||||||
|
<li v-for="p in recommendation!.pros" :key="p">{{ p }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="cmap__cons-label">Contre</span>
|
||||||
|
<ul class="cmap__list cmap__list--con">
|
||||||
|
<li v-for="c in recommendation!.cons" :key="c">{{ c }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="cmap__when">
|
||||||
|
<UIcon name="i-lucide-lightbulb" />
|
||||||
|
{{ recommendation!.when }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="cmap__result-actions">
|
||||||
|
<button class="cmap__btn-reset" @click="reset">
|
||||||
|
<UIcon name="i-lucide-refresh-cw" />
|
||||||
|
Recommencer
|
||||||
|
</button>
|
||||||
|
<button class="cmap__btn-use" @click="emit('use', recommendation!.name)">
|
||||||
|
<UIcon name="i-lucide-play" />
|
||||||
|
Utiliser cette méthode
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quiz -->
|
||||||
|
<div v-else key="quiz" class="cmap__quiz">
|
||||||
|
<!-- Progress -->
|
||||||
|
<div class="cmap__progress">
|
||||||
|
<div class="cmap__progress-bar" :style="{ width: `${progress}%` }" />
|
||||||
|
</div>
|
||||||
|
<span class="cmap__step-label">{{ step + 1 }} / {{ questions.length }}</span>
|
||||||
|
|
||||||
|
<!-- Question -->
|
||||||
|
<Transition name="slide-right" mode="out-in">
|
||||||
|
<div :key="step" class="cmap__question-block">
|
||||||
|
<p class="cmap__question">{{ currentQuestion.question }}</p>
|
||||||
|
<p v-if="currentQuestion.hint" class="cmap__hint">{{ currentQuestion.hint }}</p>
|
||||||
|
|
||||||
|
<div class="cmap__options">
|
||||||
|
<button
|
||||||
|
v-for="opt in currentQuestion.options"
|
||||||
|
:key="opt.value"
|
||||||
|
class="cmap__option"
|
||||||
|
:class="{ 'cmap__option--selected': answers[currentQuestion.id] === opt.value }"
|
||||||
|
@click="selectAnswer(currentQuestion.id, opt.value)"
|
||||||
|
>
|
||||||
|
<div class="cmap__option-icon">
|
||||||
|
<UIcon :name="opt.icon" />
|
||||||
|
</div>
|
||||||
|
<span class="cmap__option-label">{{ opt.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<button v-if="step > 0" class="cmap__back" @click="goBack">
|
||||||
|
<UIcon name="i-lucide-chevron-left" />
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cmap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__head-icon {
|
||||||
|
font-size: 1.375rem;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__subtitle {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
.cmap__progress {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--mood-accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__step-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Question */
|
||||||
|
.cmap__question-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__question {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.1s ease;
|
||||||
|
text-align: left;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__option:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 3px 10px var(--mood-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__option:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
.cmap__option--selected {
|
||||||
|
background: var(--mood-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__option--selected .cmap__option-icon,
|
||||||
|
.cmap__option--selected .cmap__option-label {
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__option-icon {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--mood-surface);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__option--selected .cmap__option-icon {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__option-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.375rem 0;
|
||||||
|
transition: color 0.1s ease;
|
||||||
|
}
|
||||||
|
.cmap__back:hover { color: var(--mood-text); }
|
||||||
|
|
||||||
|
/* Result */
|
||||||
|
.cmap__result {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__result-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__result-icon {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__result-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__result-tag {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.cmap__result-tag--accent {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
}
|
||||||
|
.cmap__result-tag--teal {
|
||||||
|
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
|
||||||
|
color: var(--mood-success);
|
||||||
|
}
|
||||||
|
.cmap__result-tag--secondary {
|
||||||
|
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 15%, transparent);
|
||||||
|
color: var(--mood-secondary, var(--mood-accent));
|
||||||
|
}
|
||||||
|
.cmap__result-tag--tertiary {
|
||||||
|
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 15%, transparent);
|
||||||
|
color: var(--mood-tertiary, var(--mood-accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__result-name {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-text);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__result-desc {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__formula {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__formula-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__formula-code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text);
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__pros-cons {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__pros-label,
|
||||||
|
.cmap__cons-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.cmap__pros-label { color: var(--mood-success); }
|
||||||
|
.cmap__cons-label { color: var(--mood-error); }
|
||||||
|
|
||||||
|
.cmap__list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__list li {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
padding-left: 0.875rem;
|
||||||
|
position: relative;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__list--pro li::before {
|
||||||
|
content: '✓';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--mood-success);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
top: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__list--con li::before {
|
||||||
|
content: '·';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--mood-error);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__when {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__result-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmap__btn-reset {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
.cmap__btn-reset:hover { transform: translateY(-1px); color: var(--mood-text); }
|
||||||
|
|
||||||
|
.cmap__btn-use {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 1.125rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
background: var(--mood-accent);
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||||
|
}
|
||||||
|
.cmap__btn-use:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px var(--mood-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
.fade-up-enter-active, .fade-up-leave-active { transition: all 0.2s ease; }
|
||||||
|
.fade-up-enter-from { opacity: 0; transform: translateY(8px); }
|
||||||
|
.fade-up-leave-to { opacity: 0; transform: translateY(-4px); }
|
||||||
|
|
||||||
|
.slide-right-enter-active, .slide-right-leave-active { transition: all 0.16s ease; }
|
||||||
|
.slide-right-enter-from { opacity: 0; transform: translateX(12px); }
|
||||||
|
.slide-right-leave-to { opacity: 0; transform: translateX(-8px); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* SocioElection — Guide processus d'élection sociocratique.
|
||||||
|
* 6 étapes canoniques + advice process Laloux + clarté de rôle.
|
||||||
|
* Référence : "La Sociocracie" (Robertson), "Reinventing Organizations" (Laloux).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
num: number
|
||||||
|
title: string
|
||||||
|
actor: string
|
||||||
|
duration: string
|
||||||
|
icon: string
|
||||||
|
description: string
|
||||||
|
tips: string[]
|
||||||
|
pitfall?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps: Step[] = [
|
||||||
|
{
|
||||||
|
num: 1,
|
||||||
|
title: 'Clarifier le rôle',
|
||||||
|
actor: 'Facilitateur + cercle',
|
||||||
|
duration: '10-15 min',
|
||||||
|
icon: 'i-lucide-clipboard-list',
|
||||||
|
description: 'Définir ensemble la mission du rôle, ses domaines d\'autorité, ses redevabilités et la durée du mandat. Le rôle précède la personne.',
|
||||||
|
tips: [
|
||||||
|
'Distinguer redevabilités (obligations) et autorité (domaine de décision)',
|
||||||
|
'Fixer une durée standard (ex: 1 an renouvelable)',
|
||||||
|
'Identifier les compétences nécessaires — pas souhaitables',
|
||||||
|
],
|
||||||
|
pitfall: 'Ne pas définir le rôle sur mesure pour un candidat déjà imaginé.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 2,
|
||||||
|
title: 'Nommer en silence',
|
||||||
|
actor: 'Tous les membres',
|
||||||
|
duration: '3-5 min',
|
||||||
|
icon: 'i-lucide-pencil',
|
||||||
|
description: 'Chacun écrit sur papier le nom d\'une personne (y compris soi-même) et la raison principale de son choix. En silence, sans influence mutuelle.',
|
||||||
|
tips: [
|
||||||
|
'Pas de discussion pendant cette étape',
|
||||||
|
'S\'auto-nommer est bienvenu et valorisé',
|
||||||
|
'Une seule nomination par personne',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 3,
|
||||||
|
title: 'Recueillir les nominations',
|
||||||
|
actor: 'Facilitateur',
|
||||||
|
duration: '5-10 min',
|
||||||
|
icon: 'i-lucide-list-checks',
|
||||||
|
description: 'Le facilitateur lit chaque nomination à voix haute avec la raison. Pas de commentaire, pas de débat. Pure collecte.',
|
||||||
|
tips: [
|
||||||
|
'Lire nom + raison tels qu\'écrits',
|
||||||
|
'Le facilitateur lit aussi sa propre nomination',
|
||||||
|
'Compter et afficher les nominations',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 4,
|
||||||
|
title: 'Argumenter',
|
||||||
|
actor: 'Chaque membre',
|
||||||
|
duration: '1-2 min / personne',
|
||||||
|
icon: 'i-lucide-message-square',
|
||||||
|
description: 'Chaque membre peut changer sa nomination et expliquer pourquoi (brièvement). Tour de table structuré, pas de croisements.',
|
||||||
|
tips: [
|
||||||
|
'1 minute maximum par personne',
|
||||||
|
'Argumenter pour, pas contre',
|
||||||
|
'Les candidats s\'expriment aussi brièvement',
|
||||||
|
],
|
||||||
|
pitfall: 'Éviter les longues plaidoiries — la clarté du rôle doit guider.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 5,
|
||||||
|
title: 'Lever les objections',
|
||||||
|
actor: 'Facilitateur + cercle',
|
||||||
|
duration: '5-15 min',
|
||||||
|
icon: 'i-lucide-shield-check',
|
||||||
|
description: 'Le facilitateur propose l\'élection de la personne la plus nommée. Silence = consentement. Une objection grave peut être soulevée et traitée.',
|
||||||
|
tips: [
|
||||||
|
'Objection grave ≠ préférence — nuit-elle à la mission du cercle ?',
|
||||||
|
'Une objection peut mener à reconsidérer une candidature',
|
||||||
|
'L\'élu·e peut décliner — c\'est légitime',
|
||||||
|
],
|
||||||
|
pitfall: 'Une objection n\'est pas un veto — elle doit être travaillée collectivement.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 6,
|
||||||
|
title: 'Célébrer',
|
||||||
|
actor: 'Tous',
|
||||||
|
duration: '2-3 min',
|
||||||
|
icon: 'i-lucide-star',
|
||||||
|
description: 'L\'élection est proclamée. L\'élu·e remercie et s\'engage publiquement. La communauté accueille le nouveau rôle.',
|
||||||
|
tips: [
|
||||||
|
'Documenter l\'élection (date, durée, personnes présentes)',
|
||||||
|
'Annoncer à la communauté au sens large',
|
||||||
|
'Fixer la prochaine évaluation du rôle',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const expandedStep = ref<number | null>(null)
|
||||||
|
|
||||||
|
function toggleStep(num: number) {
|
||||||
|
expandedStep.value = expandedStep.value === num ? null : num
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advice process (Laloux)
|
||||||
|
const adviceSteps = [
|
||||||
|
{ icon: 'i-lucide-search', text: 'Identifier les personnes expertes ET impactées' },
|
||||||
|
{ icon: 'i-lucide-message-circle', text: 'Les consulter — écouter vraiment' },
|
||||||
|
{ icon: 'i-lucide-user-check', text: 'Décider seul·e, en intégrant les avis reçus' },
|
||||||
|
{ icon: 'i-lucide-file-text', text: 'Documenter et communiquer la décision + raisons' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Role clarity framework
|
||||||
|
interface RoleAxis {
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
question: string
|
||||||
|
example: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleAxes: RoleAxis[] = [
|
||||||
|
{
|
||||||
|
label: 'Mission',
|
||||||
|
icon: 'i-lucide-target',
|
||||||
|
question: 'Pourquoi ce rôle existe-t-il ?',
|
||||||
|
example: 'Assurer la disponibilité des nœuds validateurs 24h/24',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Domaine',
|
||||||
|
icon: 'i-lucide-shield',
|
||||||
|
question: 'Sur quoi a-t-il autorité exclusive ?',
|
||||||
|
example: 'Configuration des serveurs de forge, rotation des clés',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Redevabilités',
|
||||||
|
icon: 'i-lucide-check-square',
|
||||||
|
question: 'Quelles activités doit-il assurer ?',
|
||||||
|
example: 'Publier un rapport mensuel, alerter en cas d\'incident',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Durée',
|
||||||
|
icon: 'i-lucide-calendar',
|
||||||
|
question: 'Pour combien de temps ?',
|
||||||
|
example: '1 an, renouvelable une fois, réévaluation à 6 mois',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeTab = ref<'election' | 'advice' | 'role'>('election')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="se">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="se__tabs">
|
||||||
|
<button
|
||||||
|
class="se__tab"
|
||||||
|
:class="{ 'se__tab--active': activeTab === 'election' }"
|
||||||
|
@click="activeTab = 'election'"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-users" />
|
||||||
|
Élection
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="se__tab"
|
||||||
|
:class="{ 'se__tab--active': activeTab === 'advice' }"
|
||||||
|
@click="activeTab = 'advice'"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-message-circle" />
|
||||||
|
Conseil
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="se__tab"
|
||||||
|
:class="{ 'se__tab--active': activeTab === 'role' }"
|
||||||
|
@click="activeTab = 'role'"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-clipboard-list" />
|
||||||
|
Rôle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Election sociocratique -->
|
||||||
|
<div v-if="activeTab === 'election'" class="se__panel">
|
||||||
|
<p class="se__intro">
|
||||||
|
Processus en 6 étapes garantissant que l'élection repose sur la clarté du rôle
|
||||||
|
et le consentement collectif — pas sur la popularité.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="se__steps">
|
||||||
|
<div
|
||||||
|
v-for="s in steps"
|
||||||
|
:key="s.num"
|
||||||
|
class="se__step"
|
||||||
|
:class="{ 'se__step--open': expandedStep === s.num }"
|
||||||
|
>
|
||||||
|
<button class="se__step-head" @click="toggleStep(s.num)">
|
||||||
|
<div class="se__step-num">{{ s.num }}</div>
|
||||||
|
<div class="se__step-icon">
|
||||||
|
<UIcon :name="s.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="se__step-info">
|
||||||
|
<span class="se__step-title">{{ s.title }}</span>
|
||||||
|
<span class="se__step-meta">{{ s.actor }} · {{ s.duration }}</span>
|
||||||
|
</div>
|
||||||
|
<UIcon
|
||||||
|
:name="expandedStep === s.num ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||||
|
class="se__step-toggle"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition name="expand">
|
||||||
|
<div v-if="expandedStep === s.num" class="se__step-body">
|
||||||
|
<p class="se__step-desc">{{ s.description }}</p>
|
||||||
|
<ul class="se__step-tips">
|
||||||
|
<li v-for="tip in s.tips" :key="tip">{{ tip }}</li>
|
||||||
|
</ul>
|
||||||
|
<div v-if="s.pitfall" class="se__step-pitfall">
|
||||||
|
<UIcon name="i-lucide-alert-triangle" />
|
||||||
|
{{ s.pitfall }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advice process -->
|
||||||
|
<div v-if="activeTab === 'advice'" class="se__panel">
|
||||||
|
<div class="se__advice-header">
|
||||||
|
<span class="se__advice-tag">Laloux / Teal</span>
|
||||||
|
<h4 class="se__advice-title">Processus de sollicitation d'avis</h4>
|
||||||
|
<p class="se__advice-subtitle">
|
||||||
|
Toute personne peut prendre une décision — à condition d'avoir d'abord
|
||||||
|
consulté les experts et les impactés.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="se__advice-steps">
|
||||||
|
<div v-for="(as, i) in adviceSteps" :key="i" class="se__advice-step">
|
||||||
|
<div class="se__advice-dot">
|
||||||
|
<UIcon :name="as.icon" />
|
||||||
|
</div>
|
||||||
|
<span class="se__advice-text">{{ as.text }}</span>
|
||||||
|
<div v-if="i < adviceSteps.length - 1" class="se__advice-line" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="se__advice-rule">
|
||||||
|
<UIcon name="i-lucide-lightbulb" class="se__advice-rule-icon" />
|
||||||
|
<div>
|
||||||
|
<strong>Règle d'or :</strong> plus la décision est impactante, plus il faut
|
||||||
|
consulter largement. Mais la décision finale appartient toujours à celui ou
|
||||||
|
celle qui l'a initiée.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="se__advice-when">
|
||||||
|
<div class="se__advice-when-item se__advice-when-item--yes">
|
||||||
|
<span class="se__advice-when-label">Adapter pour</span>
|
||||||
|
<ul>
|
||||||
|
<li>Décisions urgentes</li>
|
||||||
|
<li>Rôles bien définis</li>
|
||||||
|
<li>Culture de confiance</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="se__advice-when-item se__advice-when-item--no">
|
||||||
|
<span class="se__advice-when-label">Éviter si</span>
|
||||||
|
<ul>
|
||||||
|
<li>Décision irréversible</li>
|
||||||
|
<li>Groupe > 100 personnes</li>
|
||||||
|
<li>Enjeu fondateur</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Role clarity -->
|
||||||
|
<div v-if="activeTab === 'role'" class="se__panel">
|
||||||
|
<p class="se__intro">
|
||||||
|
Un rôle bien défini évite les zones grises, les conflits d'autorité
|
||||||
|
et les mandats flous. Quatre axes suffisent.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="se__role-axes">
|
||||||
|
<div v-for="axis in roleAxes" :key="axis.label" class="se__role-axis">
|
||||||
|
<div class="se__role-axis-icon">
|
||||||
|
<UIcon :name="axis.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="se__role-axis-body">
|
||||||
|
<span class="se__role-axis-label">{{ axis.label }}</span>
|
||||||
|
<p class="se__role-axis-question">{{ axis.question }}</p>
|
||||||
|
<p class="se__role-axis-example">ex: {{ axis.example }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="se__role-tip">
|
||||||
|
<UIcon name="i-lucide-info" />
|
||||||
|
<span>Un rôle n'est pas une fiche de poste. Il peut évoluer au prochain cycle
|
||||||
|
de gouvernance sans changer la personne qui le tient.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.se { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.se__tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__tab {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__tab--active {
|
||||||
|
background: var(--mood-surface);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
box-shadow: 0 1px 4px var(--mood-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__panel { display: flex; flex-direction: column; gap: 0.875rem; }
|
||||||
|
|
||||||
|
.se__intro {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Steps */
|
||||||
|
.se__steps { display: flex; flex-direction: column; gap: 0.375rem; }
|
||||||
|
|
||||||
|
.se__step {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__step--open { background: var(--mood-surface); }
|
||||||
|
|
||||||
|
.se__step-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__step-num {
|
||||||
|
width: 1.375rem;
|
||||||
|
height: 1.375rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mood-accent);
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__step-icon {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--mood-surface);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__step-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__step-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__step-meta {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__step-toggle {
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__step-body {
|
||||||
|
padding: 0 0.875rem 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__step-desc {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__step-tips {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
list-style-type: disc;
|
||||||
|
line-height: 1.6;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
.se__step-tips li::marker { color: var(--mood-accent); }
|
||||||
|
|
||||||
|
.se__step-pitfall {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--mood-error);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Advice */
|
||||||
|
.se__advice-header { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
|
|
||||||
|
.se__advice-tag {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
|
||||||
|
color: var(--mood-success);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__advice-title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__advice-subtitle {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__advice-steps { display: flex; flex-direction: column; gap: 0; }
|
||||||
|
|
||||||
|
.se__advice-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.625rem;
|
||||||
|
position: relative;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__advice-dot {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__advice-text {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text);
|
||||||
|
padding-top: 0.375rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__advice-line {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(1rem - 1px);
|
||||||
|
top: calc(0.5rem + 2rem);
|
||||||
|
width: 2px;
|
||||||
|
height: calc(100% - 2rem + 0.5rem);
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__advice-rule {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.625rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.se__advice-rule-icon { color: var(--mood-accent); flex-shrink: 0; margin-top: 0.1rem; }
|
||||||
|
.se__advice-rule strong { color: var(--mood-text); }
|
||||||
|
|
||||||
|
.se__advice-when {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__advice-when-item {
|
||||||
|
padding: 0.625rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__advice-when-item--yes {
|
||||||
|
background: color-mix(in srgb, var(--mood-success) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__advice-when-item--no {
|
||||||
|
background: color-mix(in srgb, var(--mood-error) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__advice-when-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__advice-when-item--yes .se__advice-when-label { color: var(--mood-success); }
|
||||||
|
.se__advice-when-item--no .se__advice-when-label { color: var(--mood-error); }
|
||||||
|
|
||||||
|
.se__advice-when-item ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 0.875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
list-style-type: disc;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Role */
|
||||||
|
.se__role-axes { display: flex; flex-direction: column; gap: 0.625rem; }
|
||||||
|
|
||||||
|
.se__role-axis {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__role-axis-icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--mood-surface);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__role-axis-body { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.se__role-axis-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__role-axis-question {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__role-axis-example {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
margin: 0.125rem 0 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.se__role-tip {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expand transition */
|
||||||
|
.expand-enter-active, .expand-leave-active {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.expand-enter-from, .expand-leave-to {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.expand-enter-to, .expand-leave-from {
|
||||||
|
max-height: 500px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* ToolboxSection — Wrapper accordéon pour la boîte à outils.
|
||||||
|
* Toggle le contenu pour économiser la hauteur visible.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
icon?: string
|
||||||
|
defaultOpen?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
icon: undefined,
|
||||||
|
defaultOpen: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const open = ref(props.defaultOpen)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tsection" :class="{ 'tsection--open': open }">
|
||||||
|
<button class="tsection__header" @click="open = !open">
|
||||||
|
<UIcon v-if="icon" :name="icon" class="tsection__icon" />
|
||||||
|
<span class="tsection__title">{{ title }}</span>
|
||||||
|
<UIcon name="i-lucide-chevron-down" class="tsection__chevron" />
|
||||||
|
</button>
|
||||||
|
<div v-show="open" class="tsection__body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tsection {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsection__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsection__header:hover {
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsection__icon {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsection__title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsection__chevron {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsection--open .tsection__chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsection__body {
|
||||||
|
padding: 0 0.875rem 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* WorkflowMilestones — 11 jalons de protocole de fonctionnement.
|
||||||
|
* Sélectif et qualitatif : ce qui fait la différence entre un protocole
|
||||||
|
* qui tient et un qui dérive.
|
||||||
|
* Référence : g1vote, sociocracie, Laloux, Elinor Ostrom (gouvernance des communs).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Milestone {
|
||||||
|
num: number
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
actor: string
|
||||||
|
duration: { min: string; standard: string; major: string }
|
||||||
|
description: string
|
||||||
|
essential: boolean
|
||||||
|
tip?: string
|
||||||
|
ostrom?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const milestones: Milestone[] = [
|
||||||
|
{
|
||||||
|
num: 1,
|
||||||
|
name: 'Prise d\'initiative',
|
||||||
|
icon: 'i-lucide-lightbulb',
|
||||||
|
actor: 'Tout membre',
|
||||||
|
duration: { min: '—', standard: '1-2j', major: '1-2j' },
|
||||||
|
description: 'Formaliser l\'intention : quel problème, quel besoin, quelle cible visée. Nommer un·e porteur·euse responsable.',
|
||||||
|
essential: true,
|
||||||
|
tip: 'Une initiative sans porteur identifié ne décolle pas. La responsabilité individuelle est le premier jalon.',
|
||||||
|
ostrom: 'Principe 1 — Frontières claires : qui est concerné, pourquoi.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 2,
|
||||||
|
name: 'Processus d\'avis (advice)',
|
||||||
|
icon: 'i-lucide-message-circle',
|
||||||
|
actor: 'Porteur + experts + impactés',
|
||||||
|
duration: { min: '1j', standard: '3-7j', major: '7-14j' },
|
||||||
|
description: 'Consulter les personnes qui ont l\'expertise ET celles qui seront impactées. Écouter vraiment, intégrer ou expliquer pourquoi on n\'intègre pas.',
|
||||||
|
essential: true,
|
||||||
|
tip: 'Ce jalon est souvent escamoté. C\'est la principale cause d\'échec ou de résistance en implémentation.',
|
||||||
|
ostrom: 'Principe 5 — Résolution des conflits accessible et peu coûteuse.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 3,
|
||||||
|
name: 'Rédaction + amendements',
|
||||||
|
icon: 'i-lucide-file-edit',
|
||||||
|
actor: 'Porteur + communauté',
|
||||||
|
duration: { min: '1-2j', standard: '3-7j', major: '7-21j' },
|
||||||
|
description: 'Rédiger la proposition formelle. Ouvrir une période d\'amendements publics. Intégrer les modifications acceptées, rejeter les autres avec justification.',
|
||||||
|
essential: true,
|
||||||
|
tip: 'Distinguer amendements substantiels (re-vote possible) et de forme (porteur décide).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 4,
|
||||||
|
name: 'Qualification technique',
|
||||||
|
icon: 'i-lucide-shield-check',
|
||||||
|
actor: 'Comité technique (si applicable)',
|
||||||
|
duration: { min: '—', standard: '2-5j', major: '5-10j' },
|
||||||
|
description: 'Pour les décisions techniques : revue par les experts désignés. Évaluation de faisabilité, risques, impact. Avis formel (non bloquant, sauf veto défini).',
|
||||||
|
essential: false,
|
||||||
|
tip: 'Optionnel selon la nature de la décision. Systématique pour les Runtime Upgrades.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 5,
|
||||||
|
name: 'Ouverture du vote',
|
||||||
|
icon: 'i-lucide-vote',
|
||||||
|
actor: 'Porteur + plateforme',
|
||||||
|
duration: { min: '—', standard: '1j', major: '1j' },
|
||||||
|
description: 'Publier la proposition finale. Notifier la communauté. Ouvrir la session de vote avec les paramètres définis (protocole, formule, durée).',
|
||||||
|
essential: true,
|
||||||
|
tip: 'L\'ouverture doit être annoncée à l\'avance (délai de préavis selon règlement).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 6,
|
||||||
|
name: 'Phase de vote',
|
||||||
|
icon: 'i-lucide-bar-chart-2',
|
||||||
|
actor: 'Membres habilités',
|
||||||
|
duration: { min: '3j', standard: '7-14j', major: '21-30j' },
|
||||||
|
description: 'Les membres habilités votent selon le protocole. Seuil de participation minimal surveillé. Résultats intermédiaires visibles (ou non, selon le protocole).',
|
||||||
|
essential: true,
|
||||||
|
ostrom: 'Principe 3 — Choix collectifs : ceux qui sont concernés participent aux décisions.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 7,
|
||||||
|
name: 'Contrôle du quorum',
|
||||||
|
icon: 'i-lucide-check-circle',
|
||||||
|
actor: 'Plateforme + porteur',
|
||||||
|
duration: { min: '—', standard: '—', major: '—' },
|
||||||
|
description: 'Vérifier que le quorum minimum est atteint avant clôture. Si non atteint : prolonger, relancer, ou annuler selon les règles préétablies.',
|
||||||
|
essential: true,
|
||||||
|
tip: 'Définir à l\'avance le quorum et la procédure si non atteint — évite les ambiguïtés.',
|
||||||
|
ostrom: 'Principe 4 — Supervision des règles par les membres.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 8,
|
||||||
|
name: 'Proclamation des résultats',
|
||||||
|
icon: 'i-lucide-megaphone',
|
||||||
|
actor: 'Plateforme + porteur',
|
||||||
|
duration: { min: '—', standard: '1j', major: '1j' },
|
||||||
|
description: 'Annoncer le résultat officiel avec les chiffres détaillés (votes pour, contre, abstentions, taux participation, seuil requis). Archiver on-chain si adopté.',
|
||||||
|
essential: true,
|
||||||
|
tip: 'La transparence des résultats est aussi importante que le résultat lui-même.',
|
||||||
|
ostrom: 'Principe 8 — Gouvernance emboîtée : résultats remontés aux niveaux supérieurs.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 9,
|
||||||
|
name: 'Mise en application',
|
||||||
|
icon: 'i-lucide-play-circle',
|
||||||
|
actor: 'Porteur + implémenteurs',
|
||||||
|
duration: { min: '—', standard: 'Variable', major: 'Variable' },
|
||||||
|
description: 'Planifier l\'application effective de la décision. Désigner les responsables. Fixer des jalons d\'implémentation si complexe.',
|
||||||
|
essential: true,
|
||||||
|
tip: 'Une décision adoptée mais non implémentée érode la confiance dans le processus.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 10,
|
||||||
|
name: 'Suivi et accountability',
|
||||||
|
icon: 'i-lucide-activity',
|
||||||
|
actor: 'Porteur + communauté',
|
||||||
|
duration: { min: '—', standard: 'Continu', major: 'Continu' },
|
||||||
|
description: 'Rapports réguliers sur l\'avancement. Signalement des écarts. Mécanisme de remontée si la décision produit des effets inattendus.',
|
||||||
|
essential: false,
|
||||||
|
tip: 'Intégrer dans le prochain cycle de gouvernance si des ajustements s\'imposent.',
|
||||||
|
ostrom: 'Principe 4 — Surveillance continue des comportements et résultats.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
num: 11,
|
||||||
|
name: 'Rétrospective',
|
||||||
|
icon: 'i-lucide-rotate-ccw',
|
||||||
|
actor: 'Cercle concerné',
|
||||||
|
duration: { min: '—', standard: '1-2h', major: '1-2j' },
|
||||||
|
description: 'Évaluer : le processus a-t-il bien fonctionné ? La décision produit-elle les effets attendus ? Quoi améliorer pour la prochaine fois ?',
|
||||||
|
essential: false,
|
||||||
|
tip: 'La rétrospective est le moteur d\'amélioration du protocole lui-même (méta-gouvernance).',
|
||||||
|
ostrom: 'Principe 7 — Reconnaissance externe de l\'organisation par des autorités supérieures.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const showOstrom = ref(false)
|
||||||
|
const activeDecisionType = ref<'minor' | 'standard' | 'major'>('standard')
|
||||||
|
|
||||||
|
const decisionTypes = [
|
||||||
|
{ value: 'minor', label: 'Mineur', color: 'teal' },
|
||||||
|
{ value: 'standard', label: 'Standard', color: 'accent' },
|
||||||
|
{ value: 'major', label: 'Majeur', color: 'secondary' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const essentialMilestones = computed(() =>
|
||||||
|
milestones.filter(m => m.essential),
|
||||||
|
)
|
||||||
|
|
||||||
|
const optionalMilestones = computed(() =>
|
||||||
|
milestones.filter(m => !m.essential),
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalDuration = computed(() => {
|
||||||
|
const type = activeDecisionType.value
|
||||||
|
const durations = {
|
||||||
|
minor: '5-10 jours',
|
||||||
|
standard: '14-30 jours',
|
||||||
|
major: '45-90 jours',
|
||||||
|
}
|
||||||
|
return durations[type]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="wm">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="wm__header">
|
||||||
|
<h3 class="wm__title">Jalons de protocole</h3>
|
||||||
|
<p class="wm__subtitle">
|
||||||
|
11 jalons, dont 7 indispensables. Durées recommandées selon le type de décision.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Decision type selector -->
|
||||||
|
<div class="wm__type-selector">
|
||||||
|
<button
|
||||||
|
v-for="dt in decisionTypes"
|
||||||
|
:key="dt.value"
|
||||||
|
class="wm__type-btn"
|
||||||
|
:class="[
|
||||||
|
`wm__type-btn--${dt.color}`,
|
||||||
|
{ 'wm__type-btn--active': activeDecisionType === dt.value },
|
||||||
|
]"
|
||||||
|
@click="activeDecisionType = dt.value as 'minor' | 'standard' | 'major'"
|
||||||
|
>
|
||||||
|
{{ dt.label }}
|
||||||
|
</button>
|
||||||
|
<span class="wm__total-duration">≈ {{ totalDuration }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Essential milestones -->
|
||||||
|
<div class="wm__section">
|
||||||
|
<div class="wm__section-label">
|
||||||
|
<span class="wm__section-badge wm__section-badge--essential">7 essentiels</span>
|
||||||
|
</div>
|
||||||
|
<div class="wm__milestones">
|
||||||
|
<div
|
||||||
|
v-for="m in essentialMilestones"
|
||||||
|
:key="m.num"
|
||||||
|
class="wm__milestone wm__milestone--essential"
|
||||||
|
>
|
||||||
|
<div class="wm__milestone-left">
|
||||||
|
<div class="wm__milestone-num">{{ m.num }}</div>
|
||||||
|
<div v-if="m.num < milestones.length" class="wm__milestone-line" />
|
||||||
|
</div>
|
||||||
|
<div class="wm__milestone-icon">
|
||||||
|
<UIcon :name="m.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="wm__milestone-body">
|
||||||
|
<div class="wm__milestone-head">
|
||||||
|
<span class="wm__milestone-name">{{ m.name }}</span>
|
||||||
|
<span class="wm__milestone-duration">
|
||||||
|
{{ m.duration[activeDecisionType] || '—' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="wm__milestone-desc">{{ m.description }}</p>
|
||||||
|
<div v-if="m.tip" class="wm__milestone-tip">
|
||||||
|
<UIcon name="i-lucide-lightbulb" />
|
||||||
|
{{ m.tip }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Optional milestones -->
|
||||||
|
<div class="wm__section">
|
||||||
|
<div class="wm__section-label">
|
||||||
|
<span class="wm__section-badge wm__section-badge--optional">4 contextuels</span>
|
||||||
|
</div>
|
||||||
|
<div class="wm__milestones">
|
||||||
|
<div
|
||||||
|
v-for="m in optionalMilestones"
|
||||||
|
:key="m.num"
|
||||||
|
class="wm__milestone wm__milestone--optional"
|
||||||
|
>
|
||||||
|
<div class="wm__milestone-left">
|
||||||
|
<div class="wm__milestone-num wm__milestone-num--optional">{{ m.num }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="wm__milestone-icon wm__milestone-icon--optional">
|
||||||
|
<UIcon :name="m.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="wm__milestone-body">
|
||||||
|
<div class="wm__milestone-head">
|
||||||
|
<span class="wm__milestone-name">{{ m.name }}</span>
|
||||||
|
<span class="wm__milestone-duration">
|
||||||
|
{{ m.duration[activeDecisionType] || '—' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="wm__milestone-desc">{{ m.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ostrom toggle -->
|
||||||
|
<button class="wm__ostrom-toggle" @click="showOstrom = !showOstrom">
|
||||||
|
<UIcon name="i-lucide-book-open" />
|
||||||
|
<span>Principes Ostrom appliqués</span>
|
||||||
|
<UIcon :name="showOstrom ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition name="expand">
|
||||||
|
<div v-if="showOstrom" class="wm__ostrom">
|
||||||
|
<p class="wm__ostrom-intro">
|
||||||
|
Elinor Ostrom (Nobel 2009) a identifié 8 principes pour la gouvernance
|
||||||
|
durable des communs. Les jalons ci-dessus les incarnent.
|
||||||
|
</p>
|
||||||
|
<div class="wm__ostrom-items">
|
||||||
|
<div
|
||||||
|
v-for="m in milestones.filter(x => x.ostrom)"
|
||||||
|
:key="m.num"
|
||||||
|
class="wm__ostrom-item"
|
||||||
|
>
|
||||||
|
<span class="wm__ostrom-jalon">Jalon {{ m.num }}</span>
|
||||||
|
<span class="wm__ostrom-text">{{ m.ostrom }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wm { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
|
||||||
|
.wm__header { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
|
|
||||||
|
.wm__title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__subtitle {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type selector */
|
||||||
|
.wm__type-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__type-btn {
|
||||||
|
padding: 0.375rem 0.875rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
transition: all 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__type-btn--accent.wm__type-btn--active {
|
||||||
|
background: var(--mood-accent);
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__type-btn--teal.wm__type-btn--active {
|
||||||
|
background: color-mix(in srgb, var(--mood-success) 20%, transparent);
|
||||||
|
color: var(--mood-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__type-btn--secondary.wm__type-btn--active {
|
||||||
|
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 20%, transparent);
|
||||||
|
color: var(--mood-secondary, var(--mood-accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__total-duration {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section */
|
||||||
|
.wm__section { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
|
||||||
|
.wm__section-label { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
|
||||||
|
.wm__section-badge {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__section-badge--essential {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__section-badge--optional {
|
||||||
|
background: color-mix(in srgb, var(--mood-text-muted) 12%, transparent);
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Milestones */
|
||||||
|
.wm__milestones { display: flex; flex-direction: column; gap: 0; }
|
||||||
|
|
||||||
|
.wm__milestone {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__milestone-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__milestone-num {
|
||||||
|
width: 1.375rem;
|
||||||
|
height: 1.375rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mood-accent);
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 800;
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__milestone-num--optional {
|
||||||
|
background: color-mix(in srgb, var(--mood-text-muted) 20%, transparent);
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__milestone-line {
|
||||||
|
width: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 1.25rem;
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__milestone-icon {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__milestone-icon--optional {
|
||||||
|
background: color-mix(in srgb, var(--mood-text-muted) 10%, transparent);
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__milestone-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding-bottom: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__milestone-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__milestone-name {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__milestone--optional .wm__milestone-name {
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__milestone-duration {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__milestone-desc {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0.125rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__milestone-tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ostrom */
|
||||||
|
.wm__ostrom-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
transition: color 0.12s ease;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.wm__ostrom-toggle:hover { color: var(--mood-text); }
|
||||||
|
.wm__ostrom-toggle .i-lucide-book-open { color: var(--mood-accent); }
|
||||||
|
|
||||||
|
.wm__ostrom {
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__ostrom-intro {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__ostrom-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__ostrom-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__ostrom-jalon {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wm__ostrom-text { color: var(--mood-text-muted); }
|
||||||
|
|
||||||
|
/* Expand transition */
|
||||||
|
.expand-enter-active, .expand-leave-active { transition: all 0.2s ease; overflow: hidden; }
|
||||||
|
.expand-enter-from, .expand-leave-to { max-height: 0; opacity: 0; }
|
||||||
|
.expand-enter-to, .expand-leave-from { max-height: 1000px; opacity: 1; }
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Composable for making authenticated API calls to the Glibredecision backend.
|
* Composable for making authenticated API calls to the libreDecision backend.
|
||||||
*
|
*
|
||||||
* Uses the runtime config `apiBase` and automatically injects the Bearer token
|
* Uses the runtime config `apiBase` and automatically injects the Bearer token
|
||||||
* from the auth store when available.
|
* from the auth store when available.
|
||||||
@@ -73,6 +73,7 @@ function isRetryable(status: number): boolean {
|
|||||||
export function useApi() {
|
export function useApi() {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const orgsStore = useOrganizationsStore()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform a typed fetch against the backend API.
|
* Perform a typed fetch against the backend API.
|
||||||
@@ -94,6 +95,9 @@ export function useApi() {
|
|||||||
if (auth.token) {
|
if (auth.token) {
|
||||||
headers.Authorization = `Bearer ${auth.token}`
|
headers.Authorization = `Bearer ${auth.token}`
|
||||||
}
|
}
|
||||||
|
if (orgsStore.activeSlug) {
|
||||||
|
headers['X-Organization'] = orgsStore.activeSlug
|
||||||
|
}
|
||||||
|
|
||||||
const maxAttempts = noRetry ? 1 : MAX_RETRIES
|
const maxAttempts = noRetry ? 1 : MAX_RETRIES
|
||||||
let lastError: any = null
|
let lastError: any = null
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface Mood {
|
|||||||
isDark: boolean
|
isDark: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'glibredecision_mood'
|
const STORAGE_KEY = 'libredecision_mood'
|
||||||
|
|
||||||
const moods: Mood[] = [
|
const moods: Mood[] = [
|
||||||
{ id: 'peps', label: 'Peps', description: 'Chaud et tonique', icon: 'i-lucide-sun', color: '#d44a10', isDark: false },
|
{ id: 'peps', label: 'Peps', description: 'Chaud et tonique', icon: 'i-lucide-sun', color: '#d44a10', isDark: false },
|
||||||
|
|||||||
@@ -9,6 +9,31 @@ const decisions = useDecisionsStore()
|
|||||||
const protocols = useProtocolsStore()
|
const protocols = useProtocolsStore()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
// Toolbox state
|
||||||
|
const showConsentModal = ref(false)
|
||||||
|
const selectedMethod = ref<string | null>(null)
|
||||||
|
|
||||||
|
const consentSteps = [
|
||||||
|
'Présenter la proposition clairement (2 min)',
|
||||||
|
'Tour de clarification — questions de compréhension uniquement',
|
||||||
|
'Tour de réaction — chacun réagit brièvement',
|
||||||
|
'Porteur amende si nécessaire',
|
||||||
|
'Tour d\'objections — silence = consentement',
|
||||||
|
'Lever les objections valides par amendement',
|
||||||
|
'Adopter ou reporter',
|
||||||
|
]
|
||||||
|
|
||||||
|
function handleMethodSelect(method: string) {
|
||||||
|
selectedMethod.value = method
|
||||||
|
if (method.toLowerCase().includes('consentement')) {
|
||||||
|
showConsentModal.value = true
|
||||||
|
}
|
||||||
|
else if (method.toLowerCase().includes('avis')) {
|
||||||
|
// Navigate to advice process guide in mandates toolbox
|
||||||
|
navigateTo('/mandates')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const activeStatus = ref<string | null>(null)
|
const activeStatus = ref<string | null>(null)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const sortBy = ref<'date' | 'title' | 'status'>('date')
|
const sortBy = ref<'date' | 'title' | 'status'>('date')
|
||||||
@@ -212,29 +237,74 @@ function formatDate(dateStr: string): string {
|
|||||||
|
|
||||||
<!-- Toolbox sidebar -->
|
<!-- Toolbox sidebar -->
|
||||||
<template #toolbox>
|
<template #toolbox>
|
||||||
|
<!-- Context mapper -->
|
||||||
|
<ToolboxSection title="Quelle méthode ?" icon="i-lucide-compass">
|
||||||
|
<ContextMapper @use="handleMethodSelect" />
|
||||||
|
</ToolboxSection>
|
||||||
|
|
||||||
|
<!-- Vote inertiel WoT -->
|
||||||
<ToolboxVignette
|
<ToolboxVignette
|
||||||
title="Vote majoritaire WoT"
|
title="Vote inertiel WoT"
|
||||||
:bullets="['Seuil adaptatif à la participation', 'Formule g1vote inertielle']"
|
:bullets="[
|
||||||
|
'Seuil adaptatif à la participation',
|
||||||
|
'Faible participation → quasi-unanimité',
|
||||||
|
'Formule g1vote — tracé on-chain',
|
||||||
|
]"
|
||||||
:actions="[
|
:actions="[
|
||||||
{ label: 'Simuler', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
|
{ label: 'Simuler', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
|
||||||
|
{ label: 'Protocoles', icon: 'i-lucide-settings', to: '/protocols' },
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Consentement sociocratique -->
|
||||||
<ToolboxVignette
|
<ToolboxVignette
|
||||||
title="Vote nuancé"
|
title="Consentement sociocratique"
|
||||||
:bullets="['6 niveaux de préférence', 'Seuil de satisfaction 80%']"
|
:bullets="[
|
||||||
|
'Aucune objection grave = adopté',
|
||||||
|
'Rapide pour petits groupes',
|
||||||
|
'Distingue préférence et objection',
|
||||||
|
]"
|
||||||
:actions="[
|
:actions="[
|
||||||
{ label: 'Voir', icon: 'i-lucide-bar-chart-3', emit: 'nuance' },
|
{ label: 'Guide', icon: 'i-lucide-book-open', emit: 'consent', primary: true },
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Advice process -->
|
||||||
<ToolboxVignette
|
<ToolboxVignette
|
||||||
title="Mandature"
|
title="Processus d'avis (Laloux)"
|
||||||
:bullets="['Élection en binôme', 'Transparence et révocation']"
|
:bullets="[
|
||||||
|
'Décisions urgentes : < 2h',
|
||||||
|
'Consultant experts + impactés',
|
||||||
|
'Responsabilise le porteur',
|
||||||
|
]"
|
||||||
:actions="[
|
:actions="[
|
||||||
{ label: 'Mandats', icon: 'i-lucide-user-check', to: '/mandates', primary: true },
|
{ label: 'Guide', icon: 'i-lucide-message-circle', emit: 'advice', primary: true },
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</SectionLayout>
|
</SectionLayout>
|
||||||
|
|
||||||
|
<!-- Modal consent guide -->
|
||||||
|
<UModal v-model:open="showConsentModal">
|
||||||
|
<template #content>
|
||||||
|
<div class="decision-modal">
|
||||||
|
<h3 class="decision-modal__title">Consentement sociocratique</h3>
|
||||||
|
<p class="decision-modal__text">
|
||||||
|
Une décision est adoptée par consentement quand aucun membre ne soulève d'objection grave.
|
||||||
|
Une objection grave est une raison pour laquelle la proposition nuit à la mission commune —
|
||||||
|
pas une simple préférence.
|
||||||
|
</p>
|
||||||
|
<div class="decision-modal__steps">
|
||||||
|
<div v-for="(step, i) in consentSteps" :key="i" class="decision-modal__step">
|
||||||
|
<div class="decision-modal__step-num">{{ i + 1 }}</div>
|
||||||
|
<div class="decision-modal__step-text">{{ step }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="decision-modal__ref">Référence : "La Sociocracie" — Gerard Endenburg, Brian Robertson (Holacracy)</p>
|
||||||
|
<button class="decision-modal__close" @click="showConsentModal = false">Fermer</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -458,17 +528,85 @@ function formatDate(dateStr: string): string {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-section-title {
|
/* Decision modal */
|
||||||
font-size: 0.8125rem;
|
.decision-modal {
|
||||||
font-weight: 700;
|
padding: 1.25rem;
|
||||||
color: var(--mood-text-muted);
|
display: flex;
|
||||||
text-transform: uppercase;
|
flex-direction: column;
|
||||||
letter-spacing: 0.04em;
|
gap: 1rem;
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-empty-text {
|
@media (min-width: 640px) {
|
||||||
font-size: 0.8125rem;
|
.decision-modal { padding: 2rem; gap: 1.25rem; }
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.decision-modal__title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-modal__text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-modal__steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-modal__step {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-modal__step-num {
|
||||||
|
width: 1.375rem;
|
||||||
|
height: 1.375rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mood-accent);
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-modal__step-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text);
|
||||||
|
padding-top: 0.125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-modal__ref {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-modal__close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
background: var(--mood-accent);
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: flex-end;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
.decision-modal__close:hover { transform: translateY(-1px); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@
|
|||||||
* - Permanent vote signage
|
* - Permanent vote signage
|
||||||
* - Tuto overlay
|
* - Tuto overlay
|
||||||
*/
|
*/
|
||||||
import type { DocumentItem } from '~/stores/documents'
|
import type { DocumentItem, ItemVersion } from '~/stores/documents'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const documents = useDocumentsStore()
|
const documents = useDocumentsStore()
|
||||||
@@ -138,6 +138,40 @@ async function archiveToSanctuary() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── View mode (editorial vs preview) ────────────────────────
|
||||||
|
|
||||||
|
type ViewMode = 'editorial' | 'preview'
|
||||||
|
type PreviewMode = 'current' | 'projected'
|
||||||
|
|
||||||
|
const viewMode = ref<ViewMode>('editorial')
|
||||||
|
const previewMode = ref<PreviewMode>('current')
|
||||||
|
const versionsLoaded = ref(false)
|
||||||
|
|
||||||
|
async function activatePreview() {
|
||||||
|
viewMode.value = 'preview'
|
||||||
|
if (!versionsLoaded.value && documents.items.length > 0) {
|
||||||
|
const itemIds = documents.items.map(i => i.id)
|
||||||
|
await documents.fetchAllItemVersions(slug.value, itemIds)
|
||||||
|
versionsLoaded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map item_id → the active version under vote (or null). */
|
||||||
|
const activeVersionByItem = computed((): Record<string, ItemVersion | null> => {
|
||||||
|
const map: Record<string, ItemVersion | null> = {}
|
||||||
|
for (const item of documents.items) {
|
||||||
|
const versions = documents.allItemVersions[item.id] || []
|
||||||
|
map[item.id] = versions.find(v => v.status === 'vote')
|
||||||
|
|| versions.find(v => v.status === 'proposed')
|
||||||
|
|| null
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasProjectedChanges = computed(() =>
|
||||||
|
Object.values(activeVersionByItem.value).some(v => v !== null),
|
||||||
|
)
|
||||||
|
|
||||||
// ─── Active section (scroll spy) ──────────────────────────────
|
// ─── Active section (scroll spy) ──────────────────────────────
|
||||||
|
|
||||||
const activeSection = ref<string | null>(null)
|
const activeSection = ref<string | null>(null)
|
||||||
@@ -285,8 +319,69 @@ function toggleSection(tag: string) {
|
|||||||
:genesis-json="documents.current.genesis_json"
|
:genesis-json="documents.current.genesis_json"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- ═══ VIEW MODE TOGGLE ═══ -->
|
||||||
|
<div class="doc-page__view-toggle">
|
||||||
|
<div class="doc-page__view-tabs">
|
||||||
|
<button
|
||||||
|
class="doc-page__view-tab"
|
||||||
|
:class="{ 'doc-page__view-tab--active': viewMode === 'editorial' }"
|
||||||
|
@click="viewMode = 'editorial'"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-layout-list" class="text-sm" />
|
||||||
|
Vue structurée
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="doc-page__view-tab"
|
||||||
|
:class="{ 'doc-page__view-tab--active': viewMode === 'preview' }"
|
||||||
|
@click="activatePreview"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-file-text" class="text-sm" />
|
||||||
|
Aperçu document
|
||||||
|
<span v-if="documents.loadingVersions" class="doc-page__view-loading">
|
||||||
|
<UIcon name="i-lucide-loader-circle" class="text-xs animate-spin" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview sub-mode (shown only in preview mode) -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="viewMode === 'preview'" class="doc-page__preview-modes">
|
||||||
|
<button
|
||||||
|
class="doc-page__preview-mode"
|
||||||
|
:class="{ 'doc-page__preview-mode--active': previewMode === 'current' }"
|
||||||
|
@click="previewMode = 'current'"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-circle-check" class="text-xs" />
|
||||||
|
En vigueur
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="doc-page__preview-mode"
|
||||||
|
:class="{ 'doc-page__preview-mode--active': previewMode === 'projected' }"
|
||||||
|
:disabled="!hasProjectedChanges"
|
||||||
|
:title="!hasProjectedChanges ? 'Aucun vote en cours sur ce document' : 'Simuler les votes en cours'"
|
||||||
|
@click="previewMode = 'projected'"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-flask-conical" class="text-xs" />
|
||||||
|
Selon les votes
|
||||||
|
<span v-if="hasProjectedChanges" class="doc-page__preview-dot" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ DOCUMENT PREVIEW ═══ -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<DocumentPreview
|
||||||
|
v-if="viewMode === 'preview'"
|
||||||
|
:document="documents.current"
|
||||||
|
:items="documents.items"
|
||||||
|
:mode="previewMode"
|
||||||
|
:version-map="activeVersionByItem"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<!-- ═══ SECTION NAVIGATOR ═══ -->
|
<!-- ═══ SECTION NAVIGATOR ═══ -->
|
||||||
<div v-if="sections.length > 1" class="doc-page__section-nav">
|
<div v-if="sections.length > 1 && viewMode === 'editorial'" class="doc-page__section-nav">
|
||||||
<button
|
<button
|
||||||
v-for="section in sections"
|
v-for="section in sections"
|
||||||
:key="section.tag"
|
:key="section.tag"
|
||||||
@@ -301,7 +396,7 @@ function toggleSection(tag: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ SECTIONS WITH ITEMS ═══ -->
|
<!-- ═══ SECTIONS WITH ITEMS ═══ -->
|
||||||
<div class="doc-page__sections">
|
<div v-if="viewMode === 'editorial'" class="doc-page__sections">
|
||||||
<div
|
<div
|
||||||
v-for="section in sections"
|
v-for="section in sections"
|
||||||
:key="section.tag"
|
:key="section.tag"
|
||||||
@@ -590,6 +685,115 @@ function toggleSection(tag: string) {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* View mode toggle */
|
||||||
|
.doc-page__view-toggle {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__view-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--mood-surface);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 14px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__view-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
background: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__view-tab:hover {
|
||||||
|
color: var(--mood-text);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__view-tab--active {
|
||||||
|
color: var(--mood-accent);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__view-loading {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__preview-modes {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__preview-mode {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
background: var(--mood-surface);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__preview-mode:hover:not(:disabled) {
|
||||||
|
color: var(--mood-text);
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__preview-mode:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__preview-mode--active {
|
||||||
|
background: var(--mood-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__preview-mode--active:hover:not(:disabled) {
|
||||||
|
background: var(--mood-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__preview-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mood-warning, #f59e0b);
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade transition */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Section collapse transition */
|
/* Section collapse transition */
|
||||||
.section-collapse-enter-active,
|
.section-collapse-enter-active,
|
||||||
.section-collapse-leave-active {
|
.section-collapse-leave-active {
|
||||||
|
|||||||
@@ -11,6 +11,41 @@ const documents = useDocumentsStore()
|
|||||||
const protocols = useProtocolsStore()
|
const protocols = useProtocolsStore()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const inertiaLevels = [
|
||||||
|
{
|
||||||
|
id: 'light',
|
||||||
|
name: 'Léger',
|
||||||
|
color: 'teal',
|
||||||
|
params: 'B=0.05, G=0.1',
|
||||||
|
desc: 'Modification facile. Majorité simple suffit avec bonne participation.',
|
||||||
|
example: 'Clarifications rédactionnelles, notes de bas de page.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'standard',
|
||||||
|
name: 'Standard',
|
||||||
|
color: 'accent',
|
||||||
|
params: 'B=0.1, G=0.2',
|
||||||
|
desc: 'Seuil adaptatif standard. La formule g1vote dans son paramétrage habituel.',
|
||||||
|
example: 'Articles de fond, engagements opérationnels.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'strong',
|
||||||
|
name: 'Fort',
|
||||||
|
color: 'secondary',
|
||||||
|
params: 'B=0.15, G=0.3',
|
||||||
|
desc: 'Forte résistance. Faible participation → quasi-unanimité requise.',
|
||||||
|
example: 'Principes fondateurs, formules de vote, critères WoT.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'very-strong',
|
||||||
|
name: 'Très fort',
|
||||||
|
color: 'error',
|
||||||
|
params: 'B=0.2, G=0.4',
|
||||||
|
desc: 'Protection maximale. Seule une forte mobilisation peut modifier.',
|
||||||
|
example: 'Clause de licence, identité du projet, droits des membres.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const activeStatus = ref<string | null>(null)
|
const activeStatus = ref<string | null>(null)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const sortBy = ref<'date' | 'title' | 'status'>('date')
|
const sortBy = ref<'date' | 'title' | 'status'>('date')
|
||||||
@@ -251,25 +286,51 @@ async function createDocument() {
|
|||||||
|
|
||||||
<!-- Toolbox sidebar -->
|
<!-- Toolbox sidebar -->
|
||||||
<template #toolbox>
|
<template #toolbox>
|
||||||
|
<!-- Inertia guide -->
|
||||||
|
<ToolboxSection title="Niveaux d'inertie" icon="i-lucide-sliders-horizontal">
|
||||||
|
<div class="inertia-guide">
|
||||||
|
<div v-for="level in inertiaLevels" :key="level.id" class="inertia-level">
|
||||||
|
<div class="inertia-level__header">
|
||||||
|
<span class="inertia-level__name" :class="`inertia-level__name--${level.color}`">
|
||||||
|
{{ level.name }}
|
||||||
|
</span>
|
||||||
|
<span class="inertia-level__params">{{ level.params }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="inertia-level__desc">{{ level.desc }}</p>
|
||||||
|
<p class="inertia-level__example">{{ level.example }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NuxtLink to="/protocols/formulas" class="toolbox-link-btn">
|
||||||
|
<UIcon name="i-lucide-calculator" />
|
||||||
|
Simuler les formules
|
||||||
|
</NuxtLink>
|
||||||
|
</ToolboxSection>
|
||||||
|
|
||||||
|
<!-- Structure document -->
|
||||||
<ToolboxVignette
|
<ToolboxVignette
|
||||||
title="Modules"
|
title="Structure d'un document"
|
||||||
:bullets="['Structurer en sections et clauses', 'Vote indépendant par clause']"
|
:bullets="[
|
||||||
:actions="[
|
'Items = clauses individuelles',
|
||||||
{ label: 'Voir', icon: 'i-lucide-puzzle', emit: 'modules' },
|
'Sections = groupes thématiques',
|
||||||
|
'Chaque clause : vote indépendant',
|
||||||
|
'Genesis block : traçabilité d\'origine',
|
||||||
]"
|
]"
|
||||||
/>
|
|
||||||
<ToolboxVignette
|
|
||||||
title="Votes permanents"
|
|
||||||
:bullets="['Chaque clause est modifiable', 'Seuil adaptatif WoT']"
|
|
||||||
:actions="[
|
:actions="[
|
||||||
{ label: 'Formules', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
|
{ label: 'Nouveau doc', icon: 'i-lucide-file-plus', emit: 'new', primary: true },
|
||||||
]"
|
]"
|
||||||
|
@action="e => e === 'new' && openNewDocModal()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Sanctuaire -->
|
||||||
<ToolboxVignette
|
<ToolboxVignette
|
||||||
title="Inertie de remplacement"
|
title="Sanctuaire IPFS"
|
||||||
:bullets="['4 niveaux de difficulté', 'Protège les textes fondamentaux']"
|
:bullets="[
|
||||||
|
'Document adopté → archivé on-chain',
|
||||||
|
'Hash IPFS + system.remark Duniter',
|
||||||
|
'Immuable, vérifiable, décentralisé',
|
||||||
|
]"
|
||||||
:actions="[
|
:actions="[
|
||||||
{ label: 'Simuler', icon: 'i-lucide-sliders-horizontal', to: '/protocols/formulas', primary: true },
|
{ label: 'Sanctuaire', icon: 'i-lucide-archive', to: '/sanctuary', primary: true },
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -469,18 +530,83 @@ async function createDocument() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-section-title {
|
/* Inertia guide */
|
||||||
font-size: 0.8125rem;
|
.inertia-guide {
|
||||||
font-weight: 700;
|
display: flex;
|
||||||
color: var(--mood-text-muted);
|
flex-direction: column;
|
||||||
text-transform: uppercase;
|
gap: 0.5rem;
|
||||||
letter-spacing: 0.04em;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-empty-text {
|
.inertia-level {
|
||||||
font-size: 0.8125rem;
|
background: var(--mood-surface);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inertia-level__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inertia-level__name {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inertia-level__name--teal { color: var(--mood-success); }
|
||||||
|
.inertia-level__name--accent { color: var(--mood-accent); }
|
||||||
|
.inertia-level__name--secondary { color: var(--mood-secondary, var(--mood-accent)); }
|
||||||
|
.inertia-level__name--error { color: var(--mood-error); }
|
||||||
|
|
||||||
|
.inertia-level__params {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
color: var(--mood-text-muted);
|
color: var(--mood-text-muted);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inertia-level__desc {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inertia-level__example {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-link-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: 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: flex-start;
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||||
|
}
|
||||||
|
.toolbox-link-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 3px 10px var(--mood-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Modern search / sort / action --- */
|
/* --- Modern search / sort / action --- */
|
||||||
|
|||||||
+196
-70
@@ -2,6 +2,7 @@
|
|||||||
const documents = useDocumentsStore()
|
const documents = useDocumentsStore()
|
||||||
const decisions = useDecisionsStore()
|
const decisions = useDecisionsStore()
|
||||||
const protocols = useProtocolsStore()
|
const protocols = useProtocolsStore()
|
||||||
|
const mandates = useMandatesStore()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -13,6 +14,7 @@ onMounted(async () => {
|
|||||||
documents.fetchAll(),
|
documents.fetchAll(),
|
||||||
decisions.fetchAll(),
|
decisions.fetchAll(),
|
||||||
protocols.fetchProtocols(),
|
protocols.fetchProtocols(),
|
||||||
|
mandates.fetchAll(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
@@ -20,52 +22,61 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const entryCards = computed(() => [
|
const entryCards = computed(() => {
|
||||||
{
|
const decisionsTotal = decisions.list.length
|
||||||
key: 'decisions',
|
const decisionsActive = decisions.activeDecisions.length
|
||||||
title: 'Décisions structurantes',
|
|
||||||
icon: 'i-lucide-scale',
|
const docsActive = documents.activeDocuments.length
|
||||||
to: '/decisions',
|
const docsTotal = documents.list.length
|
||||||
count: decisions.activeDecisions.length,
|
|
||||||
countLabel: `${decisions.activeDecisions.length} en cours`,
|
const mandatesActive = mandates.list.filter(m => ['active', 'voting'].includes(m.status)).length
|
||||||
totalLabel: `${decisions.list.length} au total`,
|
const mandatesTotal = mandates.list.length
|
||||||
description: 'Processus de décision collectifs',
|
|
||||||
color: 'var(--mood-secondary, var(--mood-accent))',
|
const protocolsCount = protocols.protocols.length
|
||||||
},
|
|
||||||
{
|
return [
|
||||||
key: 'documents',
|
{
|
||||||
title: 'Documents de référence',
|
key: 'decisions',
|
||||||
icon: 'i-lucide-book-open',
|
title: 'Décisions et consultation d\'avis',
|
||||||
to: '/documents',
|
icon: 'i-lucide-scale',
|
||||||
count: documents.activeDocuments.length,
|
to: '/decisions',
|
||||||
countLabel: `${documents.activeDocuments.length} actif${documents.activeDocuments.length > 1 ? 's' : ''}`,
|
count: decisionsTotal,
|
||||||
totalLabel: `${documents.list.length} au total`,
|
countLabel: decisionsActive > 0 ? `${decisionsActive} en cours` : `${decisionsTotal} au total`,
|
||||||
description: 'Textes fondateurs sous vote permanent',
|
totalLabel: decisionsActive > 0 ? `${decisionsTotal} au total` : 'aucune en cours',
|
||||||
color: 'var(--mood-accent)',
|
color: 'var(--mood-secondary, var(--mood-accent))',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'mandats',
|
key: 'documents',
|
||||||
title: 'Mandats et nominations',
|
title: 'Documents de référence',
|
||||||
icon: 'i-lucide-user-check',
|
icon: 'i-lucide-book-open',
|
||||||
to: '/mandates',
|
to: '/documents',
|
||||||
count: null,
|
count: docsActive || docsTotal,
|
||||||
countLabel: null,
|
countLabel: docsActive > 0 ? `${docsActive} actif${docsActive > 1 ? 's' : ''}` : `${docsTotal} au total`,
|
||||||
totalLabel: null,
|
totalLabel: docsTotal > 0 ? `${docsTotal} document${docsTotal > 1 ? 's' : ''}` : 'textes fondateurs',
|
||||||
description: 'Missions déléguées avec nomination en binôme',
|
color: 'var(--mood-accent)',
|
||||||
color: 'var(--mood-success)',
|
},
|
||||||
},
|
{
|
||||||
{
|
key: 'mandats',
|
||||||
key: 'protocoles',
|
title: 'Mandats et nominations',
|
||||||
title: 'Protocoles et fonctionnement',
|
icon: 'i-lucide-user-check',
|
||||||
icon: 'i-lucide-settings',
|
to: '/mandates',
|
||||||
to: '/protocols',
|
count: mandatesTotal || null,
|
||||||
count: 2,
|
countLabel: mandatesTotal > 0 ? `${mandatesActive} en cours` : null,
|
||||||
countLabel: '2 protocoles',
|
totalLabel: mandatesTotal > 0 ? `${mandatesTotal} mandats` : 'missions déléguées',
|
||||||
totalLabel: `${protocols.protocols.length} modalités de vote`,
|
color: 'var(--mood-success)',
|
||||||
description: 'Modalités de vote, formules, workflows',
|
},
|
||||||
color: 'var(--mood-tertiary, var(--mood-accent))',
|
{
|
||||||
},
|
key: 'protocoles',
|
||||||
])
|
title: 'Protocoles et fonctionnement',
|
||||||
|
icon: 'i-lucide-settings',
|
||||||
|
to: '/protocols',
|
||||||
|
count: protocolsCount || null,
|
||||||
|
countLabel: protocolsCount > 0 ? `${protocolsCount} protocoles` : null,
|
||||||
|
totalLabel: 'modalités de vote',
|
||||||
|
color: 'var(--mood-tertiary, var(--mood-accent))',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const recentDecisions = computed(() => {
|
const recentDecisions = computed(() => {
|
||||||
return [...decisions.list]
|
return [...decisions.list]
|
||||||
@@ -98,13 +109,29 @@ function formatDate(dateStr: string): string {
|
|||||||
<!-- Welcome -->
|
<!-- Welcome -->
|
||||||
<div class="dash__welcome">
|
<div class="dash__welcome">
|
||||||
<h1 class="dash__title">
|
<h1 class="dash__title">
|
||||||
<span class="dash__title-g">ğ</span><span class="dash__title-paren">(</span>Decision<span class="dash__title-paren">)</span>
|
<span class="dash__title-libre">libre</span><span class="dash__title-decision">Decision</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="dash__subtitle">
|
<p class="dash__subtitle">
|
||||||
Décisions collectives pour la communauté Duniter / G1
|
Décisions collectives pour la communauté Duniter / G1
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Décider CTA -->
|
||||||
|
<NuxtLink to="/decisions/new" class="dash__decide">
|
||||||
|
<div class="dash__decide-left">
|
||||||
|
<div class="dash__decide-icon">
|
||||||
|
<UIcon name="i-lucide-scale" class="text-xl" />
|
||||||
|
</div>
|
||||||
|
<div class="dash__decide-text">
|
||||||
|
<span class="dash__decide-label">Prendre une décision</span>
|
||||||
|
<span class="dash__decide-sub">Individuelle · collective · déléguée — le parcours s'adapte</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dash__decide-arrow">
|
||||||
|
<UIcon name="i-lucide-arrow-right" class="text-base" />
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- Entry cards -->
|
<!-- Entry cards -->
|
||||||
<div class="dash__entries">
|
<div class="dash__entries">
|
||||||
<template v-if="loading">
|
<template v-if="loading">
|
||||||
@@ -127,7 +154,7 @@ function formatDate(dateStr: string): string {
|
|||||||
<span class="entry-card__total">{{ card.totalLabel }}</span>
|
<span class="entry-card__total">{{ card.totalLabel }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="entry-card__desc">{{ card.description }}</span>
|
<span class="entry-card__desc">{{ card.totalLabel }}</span>
|
||||||
</template>
|
</template>
|
||||||
<span class="entry-card__arrow">
|
<span class="entry-card__arrow">
|
||||||
<UIcon name="i-lucide-arrow-right" />
|
<UIcon name="i-lucide-arrow-right" />
|
||||||
@@ -151,7 +178,7 @@ function formatDate(dateStr: string): string {
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toolbox teaser (5th block, distinct look) -->
|
<!-- Toolbox teaser -->
|
||||||
<NuxtLink to="/tools" class="dash__toolbox-card">
|
<NuxtLink to="/tools" class="dash__toolbox-card">
|
||||||
<div class="dash__toolbox-card-inner">
|
<div class="dash__toolbox-card-inner">
|
||||||
<div class="dash__toolbox-card-icon">
|
<div class="dash__toolbox-card-icon">
|
||||||
@@ -166,7 +193,7 @@ function formatDate(dateStr: string): string {
|
|||||||
<span class="dash__toolbox-card-tag">Vote WoT</span>
|
<span class="dash__toolbox-card-tag">Vote WoT</span>
|
||||||
<span class="dash__toolbox-card-tag">Inertie</span>
|
<span class="dash__toolbox-card-tag">Inertie</span>
|
||||||
<span class="dash__toolbox-card-tag">Smith</span>
|
<span class="dash__toolbox-card-tag">Smith</span>
|
||||||
<span class="dash__toolbox-card-tag">Nuance</span>
|
<span class="dash__toolbox-card-tag">Élection</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UIcon name="i-lucide-arrow-right" class="dash__toolbox-card-arrow" />
|
<UIcon name="i-lucide-arrow-right" class="dash__toolbox-card-arrow" />
|
||||||
@@ -236,17 +263,19 @@ function formatDate(dateStr: string): string {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
max-width: 56rem;
|
max-width: 56rem;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Welcome --- */
|
/* --- Welcome --- */
|
||||||
.dash__welcome {
|
.dash__welcome {
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash__title {
|
.dash__title {
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
color: var(--mood-accent);
|
letter-spacing: -0.02em;
|
||||||
letter-spacing: -0.03em;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
@@ -254,14 +283,18 @@ function formatDate(dateStr: string): string {
|
|||||||
font-size: 2.25rem;
|
font-size: 2.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.dash__title-g {
|
|
||||||
|
.dash__title-libre {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
font-weight: 400;
|
||||||
.dash__title-paren {
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--mood-text-muted);
|
color: var(--mood-text-muted);
|
||||||
opacity: 0.4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dash__title-decision {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.dash__subtitle {
|
.dash__subtitle {
|
||||||
margin-top: 0.375rem;
|
margin-top: 0.375rem;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
@@ -275,6 +308,87 @@ function formatDate(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Décider CTA --- */
|
||||||
|
.dash__decide {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--mood-accent);
|
||||||
|
border-radius: 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.dash__decide:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 28px var(--mood-shadow);
|
||||||
|
}
|
||||||
|
.dash__decide:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
.dash__decide-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.875rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash__decide-icon {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255,255,255,0.18);
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash__decide-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash__decide-label {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.dash__decide-label { font-size: 1.125rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash__decide-sub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255,255,255,0.75);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.dash__decide-sub { font-size: 0.8125rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash__decide-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.18);
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
transition: transform 0.12s;
|
||||||
|
}
|
||||||
|
.dash__decide:hover .dash__decide-arrow {
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Entry cards --- */
|
/* --- Entry cards --- */
|
||||||
.dash__entries {
|
.dash__entries {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -284,7 +398,6 @@ function formatDate(dateStr: string): string {
|
|||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.dash__entries {
|
.dash__entries {
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,17 +444,30 @@ function formatDate(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.entry-card__title {
|
.entry-card__title {
|
||||||
font-size: 1.25rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
color: var(--mood-text);
|
color: var(--mood-text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.entry-card__title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-card__count {
|
.entry-card__count {
|
||||||
font-size: 1.5rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
color: var(--card-color, var(--mood-accent));
|
color: var(--card-color, var(--mood-accent));
|
||||||
line-height: 1;
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.entry-card__count {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-card__total {
|
.entry-card__total {
|
||||||
@@ -350,7 +476,7 @@ function formatDate(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.entry-card__desc {
|
.entry-card__desc {
|
||||||
font-size: 0.875rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--mood-text-muted);
|
color: var(--mood-text-muted);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
@@ -452,7 +578,7 @@ function formatDate(dateStr: string): string {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Toolbox card (5th block, distinct) --- */
|
/* --- Toolbox card --- */
|
||||||
.dash__toolbox-card {
|
.dash__toolbox-card {
|
||||||
display: block;
|
display: block;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -495,8 +621,8 @@ function formatDate(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dash__toolbox-card-title {
|
.dash__toolbox-card-title {
|
||||||
font-size: 1.125rem;
|
font-size: 1.0625rem;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
color: var(--mood-text);
|
color: var(--mood-text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -557,8 +683,8 @@ function formatDate(dateStr: string): string {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
color: var(--mood-text);
|
color: var(--mood-text);
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
font-size: 1.0625rem;
|
font-size: 1rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
.dash__activity-head h3 { margin: 0; }
|
.dash__activity-head h3 { margin: 0; }
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ async function loginAsProfile(p: DevProfile) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
step.value = 'signing'
|
step.value = 'signing'
|
||||||
await auth.login(p.address)
|
// Dev mode: bypass extension — backend accepte toute signature pour les profils dev
|
||||||
|
await auth.login(p.address, () => Promise.resolve('0x' + 'a'.repeat(128)))
|
||||||
step.value = 'success'
|
step.value = 'success'
|
||||||
setTimeout(() => router.push('/'), 800)
|
setTimeout(() => router.push('/'), 800)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -99,6 +100,8 @@ const activeStepIndex = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isProtoMode = computed(() => devProfiles.value.length > 0)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (auth.isAuthenticated) {
|
if (auth.isAuthenticated) {
|
||||||
router.push('/')
|
router.push('/')
|
||||||
@@ -165,45 +168,50 @@ onMounted(() => {
|
|||||||
<span>Connecte. Redirection...</span>
|
<span>Connecte. Redirection...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Button -->
|
<!-- Mode prototype : profils démo -->
|
||||||
<button
|
<template v-if="isProtoMode">
|
||||||
class="login-card__btn"
|
<div class="proto-panel">
|
||||||
:disabled="!address.trim() || step === 'success' || auth.loading"
|
<div class="proto-panel__header">
|
||||||
@click="handleLogin"
|
<UIcon name="i-lucide-flask-conical" />
|
||||||
>
|
<span>Mode prototype — sélectionnez un profil</span>
|
||||||
<UIcon v-if="auth.loading" name="i-lucide-loader-2" class="animate-spin" />
|
</div>
|
||||||
<UIcon v-else name="i-lucide-log-in" />
|
<div class="proto-panel__profiles">
|
||||||
<span>{{ auth.loading ? 'Verification...' : 'Se connecter' }}</span>
|
<button
|
||||||
</button>
|
v-for="p in devProfiles"
|
||||||
|
:key="p.address"
|
||||||
<!-- Dev Mode Panel -->
|
class="dev-profile"
|
||||||
<div v-if="devProfiles.length" class="dev-panel">
|
:disabled="devLoading || step === 'success'"
|
||||||
<div class="dev-panel__header">
|
@click="loginAsProfile(p)"
|
||||||
<UIcon name="i-lucide-bug" />
|
>
|
||||||
<span>Mode Dev — Connexion rapide</span>
|
<div class="dev-profile__dot" :style="{ background: statusColor(p) }" />
|
||||||
|
<div class="dev-profile__info">
|
||||||
|
<span class="dev-profile__name">{{ p.display_name }}</span>
|
||||||
|
<span class="dev-profile__status">{{ statusLabel(p) }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="dev-profile__addr">{{ p.address.slice(0, 8) }}...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="proto-panel__note">
|
||||||
|
Authentification trustWallet à venir — intégration librodrome
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dev-panel__profiles">
|
</template>
|
||||||
<button
|
|
||||||
v-for="p in devProfiles"
|
|
||||||
:key="p.address"
|
|
||||||
class="dev-profile"
|
|
||||||
:disabled="devLoading || step === 'success'"
|
|
||||||
@click="loginAsProfile(p)"
|
|
||||||
>
|
|
||||||
<div class="dev-profile__dot" :style="{ background: statusColor(p) }" />
|
|
||||||
<div class="dev-profile__info">
|
|
||||||
<span class="dev-profile__name">{{ p.display_name }}</span>
|
|
||||||
<span class="dev-profile__status">{{ statusLabel(p) }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="dev-profile__addr">{{ p.address.slice(0, 8) }}...</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Note -->
|
<!-- Mode production : formulaire + extension -->
|
||||||
<p class="login-card__note">
|
<template v-else>
|
||||||
Aucun mot de passe. Authentification par signature cryptographique.
|
<button
|
||||||
</p>
|
class="login-card__btn"
|
||||||
|
:disabled="!address.trim() || step === 'success' || auth.loading"
|
||||||
|
@click="handleLogin"
|
||||||
|
>
|
||||||
|
<UIcon v-if="auth.loading" name="i-lucide-loader-2" class="animate-spin" />
|
||||||
|
<UIcon v-else name="i-lucide-log-in" />
|
||||||
|
<span>{{ auth.loading ? 'Verification...' : 'Se connecter' }}</span>
|
||||||
|
</button>
|
||||||
|
<p class="login-card__note">
|
||||||
|
Aucun mot de passe. Authentification par signature cryptographique.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -452,32 +460,40 @@ onMounted(() => {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dev panel */
|
/* Proto panel */
|
||||||
.dev-panel {
|
.proto-panel {
|
||||||
border: 2px dashed var(--mood-warning, #f59e0b);
|
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: rgba(245, 158, 11, 0.04);
|
background: var(--mood-accent-soft);
|
||||||
|
box-shadow: 0 2px 12px var(--mood-shadow, rgba(0,0,0,0.06));
|
||||||
}
|
}
|
||||||
|
|
||||||
.dev-panel__header {
|
.proto-panel__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--mood-warning, #f59e0b);
|
color: var(--mood-accent);
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dev-panel__profiles {
|
.proto-panel__profiles {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.proto-panel__note {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.dev-profile {
|
.dev-profile {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const mandates = useMandatesStore()
|
const mandates = useMandatesStore()
|
||||||
|
const { $api } = useApi()
|
||||||
|
|
||||||
const mandateId = computed(() => route.params.id as string)
|
const mandateId = computed(() => route.params.id as string)
|
||||||
|
|
||||||
@@ -13,77 +14,95 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(mandateId, async (newId) => {
|
watch(mandateId, async (newId) => {
|
||||||
if (newId) {
|
if (newId) await mandates.fetchById(newId)
|
||||||
await mandates.fetchById(newId)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Status helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
const typeLabel = (mandateType: string) => {
|
const typeLabel = (t: string) => ({ statutory: 'Statutaire', functional: 'Fonctionnel' }[t] ?? t)
|
||||||
switch (mandateType) {
|
|
||||||
case 'techcomm': return 'Comite technique'
|
function formatDate(d: string | null): string {
|
||||||
case 'smith': return 'Forgeron'
|
if (!d) return '-'
|
||||||
case 'custom': return 'Personnalise'
|
return new Date(d).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
default: return mandateType
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string | null): string {
|
|
||||||
if (!dateStr) return '-'
|
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Terminal state check ---
|
|
||||||
|
|
||||||
const terminalStatuses = ['completed', 'revoked']
|
const terminalStatuses = ['completed', 'revoked']
|
||||||
const isTerminal = computed(() => {
|
const isTerminal = computed(() => !mandates.current || terminalStatuses.includes(mandates.current.status))
|
||||||
if (!mandates.current) return true
|
const canRevoke = computed(() => mandates.current?.status === 'active')
|
||||||
return terminalStatuses.includes(mandates.current.status)
|
const isDraft = computed(() => mandates.current?.status === 'draft')
|
||||||
})
|
|
||||||
|
|
||||||
const canRevoke = computed(() => {
|
// --- Advance ---
|
||||||
if (!mandates.current) return false
|
|
||||||
return mandates.current.status === 'active'
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Advance action ---
|
|
||||||
|
|
||||||
const advancing = ref(false)
|
const advancing = ref(false)
|
||||||
|
|
||||||
async function handleAdvance() {
|
async function handleAdvance() {
|
||||||
advancing.value = true
|
advancing.value = true
|
||||||
try {
|
try { await mandates.advance(mandateId.value) } catch { /* store holds error */ } finally { advancing.value = false }
|
||||||
await mandates.advance(mandateId.value)
|
}
|
||||||
} catch {
|
|
||||||
// Error handled by store
|
// --- Identity search (shared for assign + edit) ---
|
||||||
} finally {
|
|
||||||
advancing.value = false
|
interface IdentityResult { id: string; address: string; display_name: string | null }
|
||||||
|
|
||||||
|
function useIdentitySearch() {
|
||||||
|
const query = ref('')
|
||||||
|
const results = ref<IdentityResult[]>([])
|
||||||
|
const searching = ref(false)
|
||||||
|
const selectedId = ref<string | null>(null)
|
||||||
|
const selectedLabel = ref('')
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
async function search(q: string) {
|
||||||
|
if (q.length < 2) { results.value = []; return }
|
||||||
|
searching.value = true
|
||||||
|
try {
|
||||||
|
results.value = await $api<IdentityResult[]>('/auth/identities', { query: { q } })
|
||||||
|
} catch { results.value = [] } finally { searching.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onInput(q: string) {
|
||||||
|
query.value = q
|
||||||
|
selectedId.value = null
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => search(q), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(i: IdentityResult) {
|
||||||
|
selectedId.value = i.id
|
||||||
|
selectedLabel.value = i.display_name || i.address
|
||||||
|
query.value = i.display_name || i.address
|
||||||
|
results.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
query.value = ''
|
||||||
|
results.value = []
|
||||||
|
selectedId.value = null
|
||||||
|
selectedLabel.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return { query, results, searching, selectedId, selectedLabel, onInput, select, reset }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Assign mandatee ---
|
// --- Assign mandatee ---
|
||||||
|
|
||||||
const showAssignModal = ref(false)
|
const showAssignModal = ref(false)
|
||||||
const mandateeAddress = ref('')
|
|
||||||
const assigning = ref(false)
|
const assigning = ref(false)
|
||||||
|
const assignSearch = useIdentitySearch()
|
||||||
|
|
||||||
async function handleAssign() {
|
async function handleAssign() {
|
||||||
if (!mandateeAddress.value.trim()) return
|
if (!assignSearch.selectedId.value) return
|
||||||
assigning.value = true
|
assigning.value = true
|
||||||
try {
|
try {
|
||||||
await mandates.assignMandatee(mandateId.value, mandateeAddress.value.trim())
|
await mandates.assignMandatee(mandateId.value, assignSearch.selectedId.value)
|
||||||
showAssignModal.value = false
|
showAssignModal.value = false
|
||||||
mandateeAddress.value = ''
|
assignSearch.reset()
|
||||||
} catch {
|
} catch { /* store holds error */ } finally { assigning.value = false }
|
||||||
// Error handled by store
|
}
|
||||||
} finally {
|
|
||||||
assigning.value = false
|
function openAssign() {
|
||||||
}
|
assignSearch.reset()
|
||||||
|
showAssignModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Revoke ---
|
// --- Revoke ---
|
||||||
@@ -96,27 +115,28 @@ async function handleRevoke() {
|
|||||||
try {
|
try {
|
||||||
await mandates.revoke(mandateId.value)
|
await mandates.revoke(mandateId.value)
|
||||||
showRevokeConfirm.value = false
|
showRevokeConfirm.value = false
|
||||||
} catch {
|
} catch { /* store holds error */ } finally { revoking.value = false }
|
||||||
// Error handled by store
|
|
||||||
} finally {
|
|
||||||
revoking.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Edit modal ---
|
// --- Edit ---
|
||||||
|
|
||||||
const showEditModal = ref(false)
|
const showEditModal = ref(false)
|
||||||
const editData = ref({
|
const editData = ref({ title: '', origin_id: null as string | null, description: '' })
|
||||||
title: '',
|
const editOriginSearch = useIdentitySearch()
|
||||||
description: '' as string | null,
|
|
||||||
})
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
function openEdit() {
|
function openEdit() {
|
||||||
if (!mandates.current) return
|
if (!mandates.current) return
|
||||||
editData.value = {
|
editData.value = {
|
||||||
title: mandates.current.title,
|
title: mandates.current.title,
|
||||||
description: mandates.current.description,
|
origin_id: mandates.current.origin_id,
|
||||||
|
description: mandates.current.description ?? '',
|
||||||
|
}
|
||||||
|
if (mandates.current.origin_display_name) {
|
||||||
|
editOriginSearch.query.value = mandates.current.origin_display_name
|
||||||
|
editOriginSearch.selectedId.value = mandates.current.origin_id
|
||||||
|
} else {
|
||||||
|
editOriginSearch.reset()
|
||||||
}
|
}
|
||||||
showEditModal.value = true
|
showEditModal.value = true
|
||||||
}
|
}
|
||||||
@@ -124,50 +144,35 @@ function openEdit() {
|
|||||||
async function saveEdit() {
|
async function saveEdit() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await mandates.update(mandateId.value, editData.value)
|
await mandates.update(mandateId.value, {
|
||||||
|
title: editData.value.title,
|
||||||
|
origin_id: editOriginSearch.selectedId.value ?? editData.value.origin_id,
|
||||||
|
description: editData.value.description || null,
|
||||||
|
})
|
||||||
showEditModal.value = false
|
showEditModal.value = false
|
||||||
} catch {
|
} catch { /* store holds error */ } finally { saving.value = false }
|
||||||
// Error handled by store
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Delete ---
|
// --- Delete ---
|
||||||
|
|
||||||
const showDeleteConfirm = ref(false)
|
const showDeleteConfirm = ref(false)
|
||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
const isDraft = computed(() => mandates.current?.status === 'draft')
|
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
deleting.value = true
|
deleting.value = true
|
||||||
try {
|
try {
|
||||||
await mandates.delete(mandateId.value)
|
await mandates.delete(mandateId.value)
|
||||||
navigateTo('/mandates')
|
navigateTo('/mandates')
|
||||||
} catch {
|
} catch { /* store holds error */ } finally { deleting.value = false; showDeleteConfirm.value = false }
|
||||||
// Error handled by store
|
|
||||||
} finally {
|
|
||||||
deleting.value = false
|
|
||||||
showDeleteConfirm.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Back link -->
|
|
||||||
<div>
|
<div>
|
||||||
<UButton
|
<UButton to="/mandates" variant="ghost" color="neutral" icon="i-lucide-arrow-left" label="Retour aux mandats" size="sm" />
|
||||||
to="/mandates"
|
|
||||||
variant="ghost"
|
|
||||||
color="neutral"
|
|
||||||
icon="i-lucide-arrow-left"
|
|
||||||
label="Retour aux mandats"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading state -->
|
|
||||||
<template v-if="mandates.loading">
|
<template v-if="mandates.loading">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<USkeleton class="h-8 w-96" />
|
<USkeleton class="h-8 w-96" />
|
||||||
@@ -178,7 +183,6 @@ async function handleDelete() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Error state -->
|
|
||||||
<template v-else-if="mandates.error">
|
<template v-else-if="mandates.error">
|
||||||
<UCard>
|
<UCard>
|
||||||
<div class="flex items-center gap-3 text-red-500">
|
<div class="flex items-center gap-3 text-red-500">
|
||||||
@@ -188,79 +192,35 @@ async function handleDelete() {
|
|||||||
</UCard>
|
</UCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Mandate detail -->
|
|
||||||
<template v-else-if="mandates.current">
|
<template v-else-if="mandates.current">
|
||||||
<!-- Header with actions -->
|
<!-- Header -->
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ mandates.current.title }}</h1>
|
||||||
{{ mandates.current.title }}
|
|
||||||
</h1>
|
|
||||||
<div class="flex items-center gap-3 mt-2">
|
<div class="flex items-center gap-3 mt-2">
|
||||||
<UBadge variant="subtle" color="primary">
|
<UBadge variant="subtle" color="primary">{{ typeLabel(mandates.current.mandate_type) }}</UBadge>
|
||||||
{{ typeLabel(mandates.current.mandate_type) }}
|
|
||||||
</UBadge>
|
|
||||||
<StatusBadge :status="mandates.current.status" type="mandate" />
|
<StatusBadge :status="mandates.current.status" type="mandate" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action buttons -->
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<UButton
|
<UButton v-if="!isTerminal" icon="i-lucide-fast-forward" label="Avancer" color="primary" variant="soft" size="sm" :loading="advancing" @click="handleAdvance" />
|
||||||
v-if="!isTerminal"
|
<UButton v-if="!isTerminal && !mandates.current.mandatee_id" icon="i-lucide-user-plus" label="Assigner un mandataire" variant="soft" color="primary" size="sm" @click="openAssign" />
|
||||||
icon="i-lucide-fast-forward"
|
<UButton icon="i-lucide-pen-line" label="Modifier" variant="soft" color="neutral" size="sm" @click="openEdit" />
|
||||||
label="Avancer"
|
<UButton v-if="canRevoke" icon="i-lucide-shield-off" label="Revoquer" variant="soft" color="error" size="sm" @click="showRevokeConfirm = true" />
|
||||||
color="primary"
|
<UButton v-if="isDraft" icon="i-lucide-trash-2" label="Supprimer" variant="soft" color="error" size="sm" @click="showDeleteConfirm = true" />
|
||||||
variant="soft"
|
|
||||||
size="sm"
|
|
||||||
:loading="advancing"
|
|
||||||
@click="handleAdvance"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
v-if="!isTerminal && !mandates.current.mandatee_id"
|
|
||||||
icon="i-lucide-user-plus"
|
|
||||||
label="Assigner un mandataire"
|
|
||||||
variant="soft"
|
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
@click="showAssignModal = true"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-pen-line"
|
|
||||||
label="Modifier"
|
|
||||||
variant="soft"
|
|
||||||
color="neutral"
|
|
||||||
size="sm"
|
|
||||||
@click="openEdit"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
v-if="canRevoke"
|
|
||||||
icon="i-lucide-shield-off"
|
|
||||||
label="Revoquer"
|
|
||||||
variant="soft"
|
|
||||||
color="error"
|
|
||||||
size="sm"
|
|
||||||
@click="showRevokeConfirm = true"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
v-if="isDraft"
|
|
||||||
icon="i-lucide-trash-2"
|
|
||||||
label="Supprimer"
|
|
||||||
variant="soft"
|
|
||||||
color="error"
|
|
||||||
size="sm"
|
|
||||||
@click="showDeleteConfirm = true"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Error feedback -->
|
||||||
|
<div v-if="mandates.error" class="text-sm text-red-500 bg-red-50 dark:bg-red-950 px-4 py-2 rounded-lg">
|
||||||
|
{{ mandates.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<UCard v-if="mandates.current.description">
|
<UCard v-if="mandates.current.description">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Description</h3>
|
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Description</h3>
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{{ mandates.current.description }}</p>
|
||||||
{{ mandates.current.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
@@ -270,199 +230,204 @@ async function handleDelete() {
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500">Mandataire</p>
|
<p class="text-gray-500">Mandataire</p>
|
||||||
<p class="font-medium text-gray-900 dark:text-white">
|
<p class="font-medium text-gray-900 dark:text-white">
|
||||||
<template v-if="mandates.current.mandatee_id">
|
<template v-if="mandates.current.mandatee_display_name">{{ mandates.current.mandatee_display_name }}</template>
|
||||||
<span class="font-mono text-xs">{{ mandates.current.mandatee_id.slice(0, 12) }}...</span>
|
<template v-else-if="mandates.current.mandatee_id"><span class="font-mono text-xs">{{ mandates.current.mandatee_id.slice(0, 12) }}…</span></template>
|
||||||
</template>
|
<template v-else><span class="text-gray-400 italic">Non assigne</span></template>
|
||||||
<template v-else>
|
</p>
|
||||||
<span class="text-gray-400 italic">Non assigne</span>
|
</div>
|
||||||
</template>
|
<div>
|
||||||
|
<p class="text-gray-500">Origine</p>
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white">
|
||||||
|
<template v-if="mandates.current.origin_display_name">{{ mandates.current.origin_display_name }}</template>
|
||||||
|
<template v-else><span class="text-gray-400 italic">Non renseigné</span></template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500">Debut</p>
|
<p class="text-gray-500">Debut</p>
|
||||||
<p class="font-medium text-gray-900 dark:text-white">
|
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.starts_at) }}</p>
|
||||||
{{ formatDate(mandates.current.starts_at) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500">Fin</p>
|
<p class="text-gray-500">Fin</p>
|
||||||
<p class="font-medium text-gray-900 dark:text-white">
|
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.ends_at) }}</p>
|
||||||
{{ formatDate(mandates.current.ends_at) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-500">Nombre d'etapes</p>
|
|
||||||
<p class="font-medium text-gray-900 dark:text-white">
|
|
||||||
{{ mandates.current.steps.length }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<!-- Dates metadata -->
|
|
||||||
<UCard>
|
<UCard>
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500">Cree le</p>
|
<p class="text-gray-500">Cree le</p>
|
||||||
<p class="font-medium text-gray-900 dark:text-white">
|
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.created_at) }}</p>
|
||||||
{{ formatDate(mandates.current.created_at) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500">Mis a jour le</p>
|
<p class="text-gray-500">Mis a jour le</p>
|
||||||
<p class="font-medium text-gray-900 dark:text-white">
|
<p class="font-medium text-gray-900 dark:text-white">{{ formatDate(mandates.current.updated_at) }}</p>
|
||||||
{{ formatDate(mandates.current.updated_at) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<!-- Steps timeline -->
|
<!-- Steps -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Etapes du mandat</h2>
|
||||||
Etapes du mandat
|
<MandateTimeline :steps="mandates.current.steps" :current-status="mandates.current.status" />
|
||||||
</h2>
|
|
||||||
|
|
||||||
<MandateTimeline
|
|
||||||
:steps="mandates.current.steps"
|
|
||||||
:current-status="mandates.current.status"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Assign mandatee modal -->
|
<!-- Modal : Assigner un mandataire -->
|
||||||
<UModal v-model:open="showAssignModal">
|
<UModal v-model:open="showAssignModal">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-6 space-y-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Assigner un mandataire</h3>
|
||||||
Assigner un mandataire
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
Adresse du mandataire <span class="text-red-500">*</span>
|
Rechercher un membre <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<UInput
|
<div class="relative">
|
||||||
v-model="mandateeAddress"
|
<input
|
||||||
placeholder="Adresse Duniter (ex: 5Grw...)
|
:value="assignSearch.query.value"
|
||||||
"
|
type="text"
|
||||||
/>
|
class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
<p class="text-xs text-gray-500">
|
placeholder="Nom ou adresse Duniter…"
|
||||||
Adresse SS58 du membre de la toile de confiance
|
@input="assignSearch.onInput(($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="assignSearch.results.value.length"
|
||||||
|
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="r in assignSearch.results.value"
|
||||||
|
:key="r.id"
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
|
@click="assignSearch.select(r)"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-user" class="text-gray-400 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white">{{ r.display_name || r.address }}</p>
|
||||||
|
<p class="text-xs text-gray-500 font-mono">{{ r.address.slice(0, 20) }}…</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="assignSearch.selectedId.value" class="text-xs text-green-600 flex items-center gap-1">
|
||||||
|
<UIcon name="i-lucide-check-circle" /> {{ assignSearch.selectedLabel.value }} sélectionné
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<UButton
|
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showAssignModal = false">Annuler</button>
|
||||||
label="Annuler"
|
<button
|
||||||
variant="ghost"
|
class="px-4 py-2 text-sm font-medium bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
color="neutral"
|
:disabled="!assignSearch.selectedId.value || assigning"
|
||||||
@click="showAssignModal = false"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
label="Assigner"
|
|
||||||
icon="i-lucide-user-plus"
|
|
||||||
color="primary"
|
|
||||||
:loading="assigning"
|
|
||||||
:disabled="!mandateeAddress.trim()"
|
|
||||||
@click="handleAssign"
|
@click="handleAssign"
|
||||||
/>
|
>
|
||||||
|
<UIcon v-if="assigning" name="i-lucide-loader-2" class="animate-spin text-sm" />
|
||||||
|
Assigner
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
|
|
||||||
<!-- Revoke confirmation modal -->
|
<!-- Modal : Révoquer -->
|
||||||
<UModal v-model:open="showRevokeConfirm">
|
<UModal v-model:open="showRevokeConfirm">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-6 space-y-4">
|
||||||
<h3 class="text-lg font-semibold text-red-600">
|
<h3 class="text-lg font-semibold text-red-600">Confirmer la revocation</h3>
|
||||||
Confirmer la revocation
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Etes-vous sur de vouloir revoquer ce mandat ? Le mandataire perdra ses droits et responsabilites.
|
Etes-vous sur de vouloir revoquer ce mandat ? Le mandataire perdra ses droits et responsabilites.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<UButton
|
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showRevokeConfirm = false">Annuler</button>
|
||||||
label="Annuler"
|
<button
|
||||||
variant="ghost"
|
class="px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-xl hover:bg-red-700 flex items-center gap-2"
|
||||||
color="neutral"
|
:disabled="revoking"
|
||||||
@click="showRevokeConfirm = false"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
label="Revoquer"
|
|
||||||
icon="i-lucide-shield-off"
|
|
||||||
color="error"
|
|
||||||
:loading="revoking"
|
|
||||||
@click="handleRevoke"
|
@click="handleRevoke"
|
||||||
/>
|
>
|
||||||
|
<UIcon v-if="revoking" name="i-lucide-loader-2" class="animate-spin text-sm" />
|
||||||
|
Revoquer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
|
|
||||||
<!-- Edit modal -->
|
<!-- Modal : Modifier -->
|
||||||
<UModal v-model:open="showEditModal">
|
<UModal v-model:open="showEditModal">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-6 space-y-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Modifier le mandat</h3>
|
||||||
Modifier le mandat
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Titre</label>
|
||||||
<UInput v-model="editData.title" />
|
<input v-model="editData.title" type="text" class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Origine</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
:value="editOriginSearch.query.value"
|
||||||
|
type="text"
|
||||||
|
class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
placeholder="Rechercher un membre…"
|
||||||
|
@input="editOriginSearch.onInput(($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="editOriginSearch.results.value.length"
|
||||||
|
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="r in editOriginSearch.results.value"
|
||||||
|
:key="r.id"
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
|
@click="editOriginSearch.select(r)"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-user" class="text-gray-400 shrink-0" />
|
||||||
|
<span>{{ r.display_name || r.address }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
||||||
<UTextarea v-model="editData.description" :rows="4" />
|
<textarea v-model="editData.description" rows="4" class="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<UButton
|
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showEditModal = false">Annuler</button>
|
||||||
label="Annuler"
|
<button
|
||||||
variant="ghost"
|
class="px-4 py-2 text-sm font-medium bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
color="neutral"
|
:disabled="!editData.title?.trim() || saving"
|
||||||
@click="showEditModal = false"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
label="Enregistrer"
|
|
||||||
icon="i-lucide-save"
|
|
||||||
color="primary"
|
|
||||||
:loading="saving"
|
|
||||||
:disabled="!editData.title?.trim()"
|
|
||||||
@click="saveEdit"
|
@click="saveEdit"
|
||||||
/>
|
>
|
||||||
|
<UIcon v-if="saving" name="i-lucide-loader-2" class="animate-spin text-sm" />
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
|
|
||||||
<!-- Delete confirmation modal -->
|
<!-- Modal : Supprimer -->
|
||||||
<UModal v-model:open="showDeleteConfirm">
|
<UModal v-model:open="showDeleteConfirm">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-6 space-y-4">
|
||||||
<h3 class="text-lg font-semibold text-red-600">
|
<h3 class="text-lg font-semibold text-red-600">Confirmer la suppression</h3>
|
||||||
Confirmer la suppression
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
Etes-vous sur de vouloir supprimer ce mandat ? Cette action est irreversible.
|
Etes-vous sur de vouloir supprimer ce mandat ? Cette action est irreversible.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<UButton
|
<button class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" @click="showDeleteConfirm = false">Annuler</button>
|
||||||
label="Annuler"
|
<button
|
||||||
variant="ghost"
|
class="px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-xl hover:bg-red-700 flex items-center gap-2"
|
||||||
color="neutral"
|
:disabled="deleting"
|
||||||
@click="showDeleteConfirm = false"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
label="Supprimer"
|
|
||||||
icon="i-lucide-trash-2"
|
|
||||||
color="error"
|
|
||||||
:loading="deleting"
|
|
||||||
@click="handleDelete"
|
@click="handleDelete"
|
||||||
/>
|
>
|
||||||
|
<UIcon v-if="deleting" name="i-lucide-loader-2" class="animate-spin text-sm" />
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const filteredMandates = computed(() => {
|
|||||||
|
|
||||||
// Filter by status group
|
// Filter by status group
|
||||||
if (activeStatus.value && statusGroupMap[activeStatus.value]) {
|
if (activeStatus.value && statusGroupMap[activeStatus.value]) {
|
||||||
const allowedStatuses = statusGroupMap[activeStatus.value]
|
const allowedStatuses = statusGroupMap[activeStatus.value]!
|
||||||
list = list.filter(m => allowedStatuses.includes(m.status))
|
list = list.filter(m => allowedStatuses.includes(m.status))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +95,8 @@ const filteredMandates = computed(() => {
|
|||||||
|
|
||||||
const typeLabel = (mandateType: string) => {
|
const typeLabel = (mandateType: string) => {
|
||||||
switch (mandateType) {
|
switch (mandateType) {
|
||||||
|
case 'statutory': return 'Statutaire'
|
||||||
|
case 'functional': return 'Fonctionnel'
|
||||||
case 'techcomm': return 'Comité technique'
|
case 'techcomm': return 'Comité technique'
|
||||||
case 'smith': return 'Forgeron'
|
case 'smith': return 'Forgeron'
|
||||||
case 'custom': return 'Personnalisé'
|
case 'custom': return 'Personnalisé'
|
||||||
@@ -154,14 +156,14 @@ async function handleCreate() {
|
|||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<NuxtLink
|
||||||
v-if="auth.isAuthenticated"
|
v-if="auth.isAuthenticated"
|
||||||
|
to="/mandates/new"
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
@click="showCreateModal = true"
|
|
||||||
>
|
>
|
||||||
<UIcon name="i-lucide-plus" class="text-xs" />
|
<UIcon name="i-lucide-plus" class="text-xs" />
|
||||||
<span>Nouveau</span>
|
<span>Nouveau</span>
|
||||||
</button>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Main content: mandates list -->
|
<!-- Main content: mandates list -->
|
||||||
@@ -199,11 +201,11 @@ async function handleCreate() {
|
|||||||
<div class="mandate-onboarding__actions">
|
<div class="mandate-onboarding__actions">
|
||||||
<UButton
|
<UButton
|
||||||
v-if="auth.isAuthenticated"
|
v-if="auth.isAuthenticated"
|
||||||
|
to="/mandates/new"
|
||||||
label="Créer un premier mandat"
|
label="Créer un premier mandat"
|
||||||
icon="i-lucide-plus"
|
icon="i-lucide-plus"
|
||||||
color="primary"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@click="showCreateModal = true"
|
|
||||||
/>
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
to="/protocols"
|
to="/protocols"
|
||||||
@@ -272,32 +274,37 @@ async function handleCreate() {
|
|||||||
|
|
||||||
<!-- Toolbox sidebar -->
|
<!-- Toolbox sidebar -->
|
||||||
<template #toolbox>
|
<template #toolbox>
|
||||||
|
<!-- Sociocratic election guide -->
|
||||||
|
<ToolboxSection title="Nomination & Élection" icon="i-lucide-users">
|
||||||
|
<SocioElection />
|
||||||
|
</ToolboxSection>
|
||||||
|
|
||||||
|
<!-- Mandat cycle -->
|
||||||
<ToolboxVignette
|
<ToolboxVignette
|
||||||
title="Ouverture"
|
title="Cycle de mandat"
|
||||||
:bullets="['Définir mission et périmètre', 'Durée et objectifs clairs']"
|
:bullets="[
|
||||||
:actions="[
|
'1. Ouverture + définition du rôle',
|
||||||
{ label: 'Créer', icon: 'i-lucide-door-open', emit: 'create', primary: true },
|
'2. Candidatures (auto ou par pairs)',
|
||||||
|
'3. Élection sociocratique',
|
||||||
|
'4. Période active + rapports',
|
||||||
|
'5. Renouvellement ou clôture',
|
||||||
]"
|
]"
|
||||||
/>
|
|
||||||
<ToolboxVignette
|
|
||||||
title="Nomination"
|
|
||||||
:bullets="['Élection en binôme', 'Titulaire + suppléant']"
|
|
||||||
:actions="[
|
:actions="[
|
||||||
{ label: 'Voir', icon: 'i-lucide-users', emit: 'nomination' },
|
{ label: 'Nouveau mandat', icon: 'i-lucide-plus', emit: 'create', primary: true },
|
||||||
]"
|
]"
|
||||||
|
@action="e => e === 'create' && navigateTo('/mandates/new')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Révocation -->
|
||||||
<ToolboxVignette
|
<ToolboxVignette
|
||||||
title="Transparence"
|
title="Révocation"
|
||||||
:bullets="['Rapports d\'activité', 'Soumis au vote communautaire']"
|
:bullets="[
|
||||||
:actions="[
|
'Initiée par 3 membres ou plus',
|
||||||
{ label: 'Voir', icon: 'i-lucide-eye', emit: 'transparence' },
|
'Vote communautaire ordinaire',
|
||||||
|
'Bilan de clôture obligatoire',
|
||||||
]"
|
]"
|
||||||
/>
|
|
||||||
<ToolboxVignette
|
|
||||||
title="Cloture"
|
|
||||||
:bullets="['Fin de mandat ou révocation', 'Bilan et transmission']"
|
|
||||||
:actions="[
|
:actions="[
|
||||||
{ label: 'Voir', icon: 'i-lucide-lock', emit: 'cloture' },
|
{ label: 'Voir', icon: 'i-lucide-shield-off', emit: 'revoke' },
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -549,20 +556,6 @@ async function handleCreate() {
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-section-title {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbox-empty-text {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mandate-card__type-badge {
|
.mandate-card__type-badge {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ onMounted(async () => {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
protocols.fetchProtocols(),
|
protocols.fetchProtocols(),
|
||||||
protocols.fetchFormulas(),
|
protocols.fetchFormulas(),
|
||||||
|
groupsStore.fetchAll(),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -174,6 +175,73 @@ const operationalProtocols: OperationalProtocol[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// ── Groups ─────────────────────────────────────────────────────────────────
|
||||||
|
const groupsStore = useGroupsStore()
|
||||||
|
const showGroupModal = ref(false)
|
||||||
|
const newGroupName = ref('')
|
||||||
|
const newGroupDesc = ref('')
|
||||||
|
const creatingGroup = ref(false)
|
||||||
|
const expandedGroupId = ref<string | null>(null)
|
||||||
|
const expandedGroupDetail = ref<import('~/stores/groups').Group | null>(null)
|
||||||
|
const loadingGroupDetail = ref(false)
|
||||||
|
const newMemberName = ref('')
|
||||||
|
const addingMember = ref(false)
|
||||||
|
|
||||||
|
async function openGroupModal() {
|
||||||
|
newGroupName.value = ''
|
||||||
|
newGroupDesc.value = ''
|
||||||
|
showGroupModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGroup() {
|
||||||
|
if (!newGroupName.value.trim()) return
|
||||||
|
creatingGroup.value = true
|
||||||
|
try {
|
||||||
|
await groupsStore.create({ name: newGroupName.value.trim(), description: newGroupDesc.value.trim() || null })
|
||||||
|
showGroupModal.value = false
|
||||||
|
} finally {
|
||||||
|
creatingGroup.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleGroupDetail(groupId: string) {
|
||||||
|
if (expandedGroupId.value === groupId) {
|
||||||
|
expandedGroupId.value = null
|
||||||
|
expandedGroupDetail.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expandedGroupId.value = groupId
|
||||||
|
loadingGroupDetail.value = true
|
||||||
|
expandedGroupDetail.value = await groupsStore.getGroup(groupId)
|
||||||
|
loadingGroupDetail.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMember(groupId: string) {
|
||||||
|
if (!newMemberName.value.trim()) return
|
||||||
|
addingMember.value = true
|
||||||
|
const member = await groupsStore.addMember(groupId, { display_name: newMemberName.value.trim() })
|
||||||
|
if (member && expandedGroupDetail.value) {
|
||||||
|
expandedGroupDetail.value.members.push(member)
|
||||||
|
newMemberName.value = ''
|
||||||
|
}
|
||||||
|
addingMember.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMember(groupId: string, memberId: string) {
|
||||||
|
const ok = await groupsStore.removeMember(groupId, memberId)
|
||||||
|
if (ok && expandedGroupDetail.value) {
|
||||||
|
expandedGroupDetail.value.members = expandedGroupDetail.value.members.filter(m => m.id !== memberId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGroup(groupId: string) {
|
||||||
|
await groupsStore.remove(groupId)
|
||||||
|
if (expandedGroupId.value === groupId) {
|
||||||
|
expandedGroupId.value = null
|
||||||
|
expandedGroupDetail.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** n8n workflow demo items. */
|
/** n8n workflow demo items. */
|
||||||
const n8nWorkflows = [
|
const n8nWorkflows = [
|
||||||
{
|
{
|
||||||
@@ -330,6 +398,79 @@ const n8nWorkflows = [
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Groups ─────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="proto-groups">
|
||||||
|
<h3 class="proto-groups__title">
|
||||||
|
<UIcon name="i-lucide-users-round" class="text-sm" />
|
||||||
|
Groupes d'identités
|
||||||
|
<span class="proto-groups__count">{{ groupsStore.list.length }}</span>
|
||||||
|
<button v-if="auth.isAuthenticated" class="proto-groups__add-btn" @click="openGroupModal">
|
||||||
|
<UIcon name="i-lucide-plus" class="text-sm" />
|
||||||
|
Nouveau groupe
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div v-if="groupsStore.list.length === 0" class="proto-groups__empty">
|
||||||
|
<UIcon name="i-lucide-users" class="text-lg" />
|
||||||
|
<span>Aucun groupe défini. Les groupes permettent de pré-sélectionner des cercles dans les décisions.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="proto-groups__list">
|
||||||
|
<div v-for="g in groupsStore.list" :key="g.id" class="proto-groups__item">
|
||||||
|
<div class="proto-groups__item-head" @click="toggleGroupDetail(g.id)">
|
||||||
|
<div class="proto-groups__item-info">
|
||||||
|
<UIcon name="i-lucide-users" class="text-sm" />
|
||||||
|
<span class="proto-groups__item-name">{{ g.name }}</span>
|
||||||
|
<span class="proto-groups__item-count">{{ g.member_count }} membre{{ g.member_count > 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="proto-groups__item-actions">
|
||||||
|
<button v-if="auth.isAuthenticated" class="proto-groups__delete-btn" @click.stop="deleteGroup(g.id)">
|
||||||
|
<UIcon name="i-lucide-trash-2" class="text-xs" />
|
||||||
|
</button>
|
||||||
|
<UIcon :name="expandedGroupId === g.id ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" class="text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="slide-down">
|
||||||
|
<div v-if="expandedGroupId === g.id" class="proto-groups__detail">
|
||||||
|
<p v-if="g.description" class="proto-groups__detail-desc">{{ g.description }}</p>
|
||||||
|
<div v-if="loadingGroupDetail" class="proto-groups__members-loading">
|
||||||
|
<UIcon name="i-lucide-loader-2" class="animate-spin text-sm" />
|
||||||
|
</div>
|
||||||
|
<ul v-else-if="expandedGroupDetail" class="proto-groups__members">
|
||||||
|
<li v-for="m in expandedGroupDetail.members" :key="m.id" class="proto-groups__member">
|
||||||
|
<UIcon name="i-lucide-user" class="text-xs" />
|
||||||
|
<span>{{ m.display_name }}</span>
|
||||||
|
<button v-if="auth.isAuthenticated" class="proto-groups__member-remove" @click="removeMember(g.id, m.id)">
|
||||||
|
<UIcon name="i-lucide-x" class="text-xs" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li v-if="expandedGroupDetail.members.length === 0" class="proto-groups__member proto-groups__member--empty">
|
||||||
|
Aucun membre
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-if="auth.isAuthenticated" class="proto-groups__add-member">
|
||||||
|
<input
|
||||||
|
v-model="newMemberName"
|
||||||
|
type="text"
|
||||||
|
lang="fr"
|
||||||
|
spellcheck="true"
|
||||||
|
class="proto-groups__member-input"
|
||||||
|
placeholder="Nom ou adresse Duniter"
|
||||||
|
@keydown.enter="addMember(g.id)"
|
||||||
|
/>
|
||||||
|
<button class="proto-groups__member-btn" :disabled="addingMember || !newMemberName.trim()" @click="addMember(g.id)">
|
||||||
|
<UIcon v-if="addingMember" name="i-lucide-loader-2" class="animate-spin text-xs" />
|
||||||
|
<UIcon v-else name="i-lucide-user-plus" class="text-xs" />
|
||||||
|
Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Operational protocols (always visible, frontend-only data) -->
|
<!-- Operational protocols (always visible, frontend-only data) -->
|
||||||
<div class="proto-ops">
|
<div class="proto-ops">
|
||||||
<h3 class="proto-ops__title">
|
<h3 class="proto-ops__title">
|
||||||
@@ -391,26 +532,22 @@ const n8nWorkflows = [
|
|||||||
|
|
||||||
<!-- Toolbox sidebar -->
|
<!-- Toolbox sidebar -->
|
||||||
<template #toolbox>
|
<template #toolbox>
|
||||||
|
<!-- Workflow milestones -->
|
||||||
|
<ToolboxSection title="Jalons de protocole" icon="i-lucide-git-branch">
|
||||||
|
<WorkflowMilestones />
|
||||||
|
</ToolboxSection>
|
||||||
|
|
||||||
<!-- Simulateur -->
|
<!-- Simulateur -->
|
||||||
<ToolboxVignette
|
<ToolboxVignette
|
||||||
title="Simulateur de formules"
|
title="Simulateur de formules"
|
||||||
:bullets="['Testez WoT, Smith, TechComm', 'Ajustez les paramètres en temps réel', 'Visualisez les seuils']"
|
:bullets="['WoT, Smith, TechComm', 'Paramètres en temps réel', 'Visualise les seuils']"
|
||||||
:actions="[
|
:actions="[
|
||||||
{ label: 'Tutos', icon: 'i-lucide-graduation-cap', emit: 'tutos' },
|
|
||||||
{ label: 'Ouvrir', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
|
{ label: 'Ouvrir', icon: 'i-lucide-calculator', to: '/protocols/formulas', primary: true },
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- n8n Workflows -->
|
<!-- n8n Workflows -->
|
||||||
<div class="n8n-section">
|
<ToolboxSection title="Automatisations" icon="i-lucide-workflow">
|
||||||
<div class="n8n-section__head">
|
|
||||||
<UIcon name="i-lucide-workflow" class="text-xs" />
|
|
||||||
<span>Workflows n8n</span>
|
|
||||||
</div>
|
|
||||||
<p class="n8n-section__desc">
|
|
||||||
Automatisations reliées via MCP
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="n8n-workflows">
|
<div class="n8n-workflows">
|
||||||
<div
|
<div
|
||||||
v-for="wf in n8nWorkflows"
|
v-for="wf in n8nWorkflows"
|
||||||
@@ -434,15 +571,13 @@ const n8nWorkflows = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ToolboxSection>
|
||||||
|
|
||||||
<!-- Meta-gouvernance -->
|
<!-- Meta-gouvernance -->
|
||||||
<ToolboxVignette
|
<ToolboxVignette
|
||||||
title="Méta-gouvernance"
|
title="Méta-gouvernance"
|
||||||
:bullets="['Les formules sont soumises au vote', 'Modifier les seuils collectivement', 'Transparence totale']"
|
:bullets="['Les formules sont soumises au vote', 'Seuils modifiables collectivement', 'Transparence totale']"
|
||||||
:actions="[
|
:actions="[
|
||||||
{ label: 'Tutos', icon: 'i-lucide-graduation-cap', emit: 'tutos' },
|
|
||||||
{ label: 'Formules', icon: 'i-lucide-calculator', emit: 'formules' },
|
|
||||||
{ label: 'Démarrer', icon: 'i-lucide-play', emit: 'meta', primary: true },
|
{ label: 'Démarrer', icon: 'i-lucide-play', emit: 'meta', primary: true },
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
@@ -512,6 +647,51 @@ const n8nWorkflows = [
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UModal>
|
</UModal>
|
||||||
|
|
||||||
|
<!-- Create group modal -->
|
||||||
|
<UModal v-model:open="showGroupModal">
|
||||||
|
<template #content>
|
||||||
|
<div class="proto-modal">
|
||||||
|
<h3 class="proto-modal__title">Nouveau groupe d'identités</h3>
|
||||||
|
<div class="proto-modal__fields">
|
||||||
|
<div class="proto-modal__field">
|
||||||
|
<label class="proto-modal__label">Nom du groupe</label>
|
||||||
|
<input
|
||||||
|
v-model="newGroupName"
|
||||||
|
type="text"
|
||||||
|
lang="fr"
|
||||||
|
spellcheck="true"
|
||||||
|
class="proto-modal__input"
|
||||||
|
placeholder="Ex: Comité technique, Forgerons actifs…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="proto-modal__field">
|
||||||
|
<label class="proto-modal__label">Description <span class="proto-modal__optional">(optionnel)</span></label>
|
||||||
|
<textarea
|
||||||
|
v-model="newGroupDesc"
|
||||||
|
class="proto-modal__textarea"
|
||||||
|
lang="fr"
|
||||||
|
spellcheck="true"
|
||||||
|
placeholder="Rôle ou périmètre de ce groupe…"
|
||||||
|
rows="2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="proto-modal__actions">
|
||||||
|
<button class="proto-modal__cancel" @click="showGroupModal = false">Annuler</button>
|
||||||
|
<button
|
||||||
|
class="proto-modal__submit"
|
||||||
|
:disabled="!newGroupName.trim() || creatingGroup"
|
||||||
|
@click="createGroup"
|
||||||
|
>
|
||||||
|
<UIcon v-if="creatingGroup" name="i-lucide-loader-2" class="animate-spin" />
|
||||||
|
<UIcon v-else name="i-lucide-users-round" />
|
||||||
|
Créer le groupe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -830,31 +1010,6 @@ const n8nWorkflows = [
|
|||||||
font-family: inherit !important;
|
font-family: inherit !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- n8n Section --- */
|
|
||||||
.n8n-section {
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.n8n-section__head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-tertiary, var(--mood-accent));
|
|
||||||
}
|
|
||||||
|
|
||||||
.n8n-section__desc {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.n8n-workflows {
|
.n8n-workflows {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1216,4 +1371,175 @@ const n8nWorkflows = [
|
|||||||
box-shadow: 0 4px 12px var(--mood-shadow);
|
box-shadow: 0 4px 12px var(--mood-shadow);
|
||||||
}
|
}
|
||||||
.proto-modal__submit:disabled { opacity: 0.4; cursor: not-allowed; }
|
.proto-modal__submit:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.proto-modal__optional { font-size: 0.8125rem; opacity: 0.55; font-weight: 400; }
|
||||||
|
|
||||||
|
/* --- Groups --- */
|
||||||
|
.proto-groups {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.proto-groups__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.proto-groups__count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.375rem;
|
||||||
|
height: 1.375rem;
|
||||||
|
padding: 0 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
color: var(--mood-accent);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
.proto-groups__add-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
.proto-groups__add-btn:hover { transform: translateY(-1px); }
|
||||||
|
.proto-groups__empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
color: var(--mood-muted);
|
||||||
|
background: var(--mood-surface);
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.proto-groups__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.proto-groups__item {
|
||||||
|
background: var(--mood-surface);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.proto-groups__item-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
.proto-groups__item-head:hover { background: var(--mood-hover); }
|
||||||
|
.proto-groups__item-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
.proto-groups__item-name { font-weight: 600; font-size: 0.9375rem; }
|
||||||
|
.proto-groups__item-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-muted);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
}
|
||||||
|
.proto-groups__item-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--mood-muted);
|
||||||
|
}
|
||||||
|
.proto-groups__delete-btn {
|
||||||
|
color: var(--mood-danger, #e53e3e);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
.proto-groups__delete-btn:hover { opacity: 1; }
|
||||||
|
.proto-groups__detail {
|
||||||
|
padding: 0.75rem 1rem 1rem;
|
||||||
|
border-top: 1px solid var(--mood-border, rgba(0,0,0,0.06));
|
||||||
|
}
|
||||||
|
.proto-groups__detail-desc {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-muted);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.proto-groups__members-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
color: var(--mood-muted);
|
||||||
|
}
|
||||||
|
.proto-groups__members {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.proto-groups__member {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text);
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
background: var(--mood-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.proto-groups__member--empty { color: var(--mood-muted); font-style: italic; }
|
||||||
|
.proto-groups__member-remove {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mood-danger, #e53e3e);
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
.proto-groups__member-remove:hover { opacity: 1; }
|
||||||
|
.proto-groups__add-member {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.proto-groups__member-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.4375rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text);
|
||||||
|
background: var(--mood-bg);
|
||||||
|
border-radius: 10px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.proto-groups__member-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.4375rem 0.875rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-accent-text);
|
||||||
|
background: var(--mood-accent);
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.proto-groups__member-btn:hover:not(:disabled) { transform: translateY(-1px); }
|
||||||
|
.proto-groups__member-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ interface ToolSection {
|
|||||||
tools: Tool[]
|
tools: Tool[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expandSocio = ref(false)
|
||||||
|
|
||||||
const sections: ToolSection[] = [
|
const sections: ToolSection[] = [
|
||||||
{
|
{
|
||||||
key: 'documents',
|
key: 'documents',
|
||||||
title: 'Documents',
|
title: 'Documents de référence',
|
||||||
icon: 'i-lucide-book-open',
|
icon: 'i-lucide-book-open',
|
||||||
color: 'var(--mood-accent)',
|
color: 'var(--mood-accent)',
|
||||||
tools: [
|
tools: [
|
||||||
@@ -36,7 +38,7 @@ const sections: ToolSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'decisions',
|
key: 'decisions',
|
||||||
title: 'Décisions',
|
title: 'Décisions et consultation d\'avis',
|
||||||
icon: 'i-lucide-scale',
|
icon: 'i-lucide-scale',
|
||||||
color: 'var(--mood-secondary, var(--mood-accent))',
|
color: 'var(--mood-secondary, var(--mood-accent))',
|
||||||
tools: [
|
tools: [
|
||||||
@@ -49,7 +51,7 @@ const sections: ToolSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'mandats',
|
key: 'mandats',
|
||||||
title: 'Mandats',
|
title: 'Mandats et nominations',
|
||||||
icon: 'i-lucide-user-check',
|
icon: 'i-lucide-user-check',
|
||||||
color: 'var(--mood-success)',
|
color: 'var(--mood-success)',
|
||||||
tools: [
|
tools: [
|
||||||
@@ -61,7 +63,7 @@ const sections: ToolSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'protocoles',
|
key: 'protocoles',
|
||||||
title: 'Protocoles',
|
title: 'Protocoles et fonctionnement',
|
||||||
icon: 'i-lucide-settings',
|
icon: 'i-lucide-settings',
|
||||||
color: 'var(--mood-tertiary, var(--mood-accent))',
|
color: 'var(--mood-tertiary, var(--mood-accent))',
|
||||||
tools: [
|
tools: [
|
||||||
@@ -149,6 +151,29 @@ const sections: ToolSection[] = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Election sociocratique — modalité d'élection, accessible depuis mandats -->
|
||||||
|
<div v-if="section.key === 'mandats'" class="socio-expand">
|
||||||
|
<button class="socio-expand__trigger" @click="expandSocio = !expandSocio">
|
||||||
|
<div class="socio-expand__icon">
|
||||||
|
<UIcon name="i-lucide-users" class="text-sm" />
|
||||||
|
</div>
|
||||||
|
<div class="socio-expand__info">
|
||||||
|
<span class="socio-expand__title">Élection sociocratique</span>
|
||||||
|
<span class="socio-expand__meta">6 étapes · clarification · consentement collectif</span>
|
||||||
|
</div>
|
||||||
|
<span class="socio-expand__tag">Modalité d'élection</span>
|
||||||
|
<UIcon
|
||||||
|
:name="expandSocio ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||||
|
class="socio-expand__toggle"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<Transition name="socio-expand">
|
||||||
|
<div v-if="expandSocio" class="socio-expand__content">
|
||||||
|
<SocioElection />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,4 +354,99 @@ const sections: ToolSection[] = [
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
color: var(--section-color);
|
color: var(--section-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Élection sociocratique expandable --- */
|
||||||
|
.socio-expand {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--mood-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socio-expand__trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.socio-expand__trigger:hover { background: var(--mood-accent-soft); }
|
||||||
|
|
||||||
|
.socio-expand__icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--mood-success) 12%, transparent);
|
||||||
|
color: var(--mood-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socio-expand__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.0625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socio-expand__title {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socio-expand__meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socio-expand__tag {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: color-mix(in srgb, var(--mood-success) 12%, transparent);
|
||||||
|
color: var(--mood-success);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.socio-expand__tag { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.socio-expand__toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socio-expand__content {
|
||||||
|
padding: 0 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socio-expand-enter-active,
|
||||||
|
.socio-expand-leave-active {
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.socio-expand-enter-from,
|
||||||
|
.socio-expand-leave-to {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.socio-expand-enter-to,
|
||||||
|
.socio-expand-leave-from {
|
||||||
|
max-height: 2000px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+51
-12
@@ -5,6 +5,43 @@
|
|||||||
* The identity object mirrors the backend IdentityOut schema.
|
* The identity object mirrors the backend IdentityOut schema.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a challenge using the injected Duniter/Substrate wallet extension
|
||||||
|
* (Cesium2, polkadot.js extension, Talisman, etc.).
|
||||||
|
*
|
||||||
|
* The extension signs <Bytes>{challenge}</Bytes> to match the backend verifier.
|
||||||
|
*/
|
||||||
|
// TODO: trustWallet — remplacer par postMessage vers l'iframe trustWallet (librodrome)
|
||||||
|
// Protocole prévu : window.postMessage({ type: 'LD_SIGN_REQUEST', address, challenge })
|
||||||
|
// → trustWallet répond { type: 'LD_SIGN_RESPONSE', signature }
|
||||||
|
async function _signWithExtension(address: string, challenge: string): Promise<string> {
|
||||||
|
const { web3Enable, web3FromAddress } = await import('@polkadot/extension-dapp')
|
||||||
|
const { stringToHex } = await import('@polkadot/util')
|
||||||
|
|
||||||
|
const extensions = await web3Enable('libreDecision')
|
||||||
|
if (!extensions.length) {
|
||||||
|
throw new Error('Aucune extension Duniter détectée. Installez Cesium² ou Polkadot.js.')
|
||||||
|
}
|
||||||
|
|
||||||
|
let injector
|
||||||
|
try {
|
||||||
|
injector = await web3FromAddress(address)
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Adresse ${address.slice(0, 10)}… introuvable dans l'extension.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!injector.signer?.signRaw) {
|
||||||
|
throw new Error("L'extension ne supporte pas la signature de messages bruts.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { signature } = await injector.signer.signRaw({
|
||||||
|
address,
|
||||||
|
data: stringToHex(challenge),
|
||||||
|
type: 'bytes',
|
||||||
|
})
|
||||||
|
return signature
|
||||||
|
}
|
||||||
|
|
||||||
export interface DuniterIdentity {
|
export interface DuniterIdentity {
|
||||||
id: string
|
id: string
|
||||||
address: string
|
address: string
|
||||||
@@ -65,15 +102,12 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Step 2: Sign the challenge
|
// Step 2: Sign the challenge via polkadot.js / Cesium2 extension
|
||||||
// In production, signFn would use the Duniter keypair to produce an Ed25519 signature.
|
|
||||||
// For development, we use a placeholder signature.
|
|
||||||
let signature: string
|
let signature: string
|
||||||
if (signFn) {
|
if (signFn) {
|
||||||
signature = await signFn(challengeRes.challenge)
|
signature = await signFn(challengeRes.challenge)
|
||||||
} else {
|
} else {
|
||||||
// Development placeholder -- backend currently accepts any signature
|
signature = await _signWithExtension(address, challengeRes.challenge)
|
||||||
signature = 'dev_signature_placeholder'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Verify and get token
|
// Step 3: Verify and get token
|
||||||
@@ -118,10 +152,15 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
const identity = await $api<DuniterIdentity>('/auth/me')
|
const identity = await $api<DuniterIdentity>('/auth/me')
|
||||||
this.identity = identity
|
this.identity = identity
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.error = err?.data?.detail || err?.message || 'Session invalide'
|
const status = (err as any)?.status ?? 0
|
||||||
this.token = null
|
this.error = err?.message || 'Session invalide'
|
||||||
this.identity = null
|
// N'effacer le token que sur 401/403 (session réellement invalide)
|
||||||
this._clearToken()
|
// Les erreurs réseau ou 5xx sont transitoires — conserver la session
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
this.token = null
|
||||||
|
this.identity = null
|
||||||
|
this._clearToken()
|
||||||
|
}
|
||||||
throw err
|
throw err
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
@@ -153,7 +192,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
*/
|
*/
|
||||||
hydrateFromStorage() {
|
hydrateFromStorage() {
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
const stored = localStorage.getItem('glibredecision_token')
|
const stored = localStorage.getItem('libredecision_token')
|
||||||
if (stored) {
|
if (stored) {
|
||||||
this.token = stored
|
this.token = stored
|
||||||
}
|
}
|
||||||
@@ -163,14 +202,14 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
/** @internal Persist token to localStorage */
|
/** @internal Persist token to localStorage */
|
||||||
_persistToken() {
|
_persistToken() {
|
||||||
if (import.meta.client && this.token) {
|
if (import.meta.client && this.token) {
|
||||||
localStorage.setItem('glibredecision_token', this.token)
|
localStorage.setItem('libredecision_token', this.token)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** @internal Clear token from localStorage */
|
/** @internal Clear token from localStorage */
|
||||||
_clearToken() {
|
_clearToken() {
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
localStorage.removeItem('glibredecision_token')
|
localStorage.removeItem('libredecision_token')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ interface DocumentsState {
|
|||||||
current: Document | null
|
current: Document | null
|
||||||
items: DocumentItem[]
|
items: DocumentItem[]
|
||||||
versions: ItemVersion[]
|
versions: ItemVersion[]
|
||||||
|
allItemVersions: Record<string, ItemVersion[]>
|
||||||
|
loadingVersions: boolean
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
@@ -78,6 +80,8 @@ export const useDocumentsStore = defineStore('documents', {
|
|||||||
current: null,
|
current: null,
|
||||||
items: [],
|
items: [],
|
||||||
versions: [],
|
versions: [],
|
||||||
|
allItemVersions: {},
|
||||||
|
loadingVersions: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
}),
|
}),
|
||||||
@@ -247,6 +251,28 @@ export const useDocumentsStore = defineStore('documents', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all versions for every item in a document (parallel).
|
||||||
|
* Used to compute the "projected" view (document as-if all active votes passed).
|
||||||
|
*/
|
||||||
|
async fetchAllItemVersions(slug: string, itemIds: string[]) {
|
||||||
|
this.loadingVersions = true
|
||||||
|
try {
|
||||||
|
const { $api } = useApi()
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
itemIds.map(id => $api<ItemVersion[]>(`/documents/${slug}/items/${id}/versions`)),
|
||||||
|
)
|
||||||
|
const map: Record<string, ItemVersion[]> = {}
|
||||||
|
itemIds.forEach((id, i) => {
|
||||||
|
const r = results[i]
|
||||||
|
map[id] = r.status === 'fulfilled' ? r.value : []
|
||||||
|
})
|
||||||
|
this.allItemVersions = map
|
||||||
|
} finally {
|
||||||
|
this.loadingVersions = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Archive a document into the Sanctuary.
|
* Archive a document into the Sanctuary.
|
||||||
*/
|
*/
|
||||||
@@ -280,6 +306,8 @@ export const useDocumentsStore = defineStore('documents', {
|
|||||||
this.current = null
|
this.current = null
|
||||||
this.items = []
|
this.items = []
|
||||||
this.versions = []
|
this.versions = []
|
||||||
|
this.allItemVersions = {}
|
||||||
|
this.loadingVersions = false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
export interface GroupMember {
|
||||||
|
id: string
|
||||||
|
display_name: string
|
||||||
|
identity_id: string | null
|
||||||
|
added_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
organization_id: string | null
|
||||||
|
created_at: string
|
||||||
|
members: GroupMember[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupSummary {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
organization_id: string | null
|
||||||
|
member_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupCreate {
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupMemberCreate {
|
||||||
|
display_name: string
|
||||||
|
identity_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGroupsStore = defineStore('groups', () => {
|
||||||
|
const { $api } = useApi()
|
||||||
|
|
||||||
|
const list = ref<GroupSummary[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function fetchAll() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
list.value = await $api<GroupSummary[]>('/groups/')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message ?? 'Erreur chargement groupes'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGroup(id: string): Promise<Group | null> {
|
||||||
|
try {
|
||||||
|
return await $api<Group>(`/groups/${id}`)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(payload: GroupCreate): Promise<Group | null> {
|
||||||
|
try {
|
||||||
|
const group = await $api<Group>('/groups/', { method: 'POST', body: payload })
|
||||||
|
await fetchAll()
|
||||||
|
return group
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message ?? 'Erreur création groupe'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await $api(`/groups/${id}`, { method: 'DELETE' })
|
||||||
|
list.value = list.value.filter(g => g.id !== id)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMember(groupId: string, payload: GroupMemberCreate): Promise<GroupMember | null> {
|
||||||
|
try {
|
||||||
|
const member = await $api<GroupMember>(`/groups/${groupId}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
const g = list.value.find(g => g.id === groupId)
|
||||||
|
if (g) g.member_count++
|
||||||
|
return member
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMember(groupId: string, memberId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await $api(`/groups/${groupId}/members/${memberId}`, { method: 'DELETE' })
|
||||||
|
const g = list.value.find(g => g.id === groupId)
|
||||||
|
if (g) g.member_count--
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { list, loading, error, fetchAll, getGroup, create, remove, addMember, removeMember }
|
||||||
|
})
|
||||||
@@ -1,9 +1,3 @@
|
|||||||
/**
|
|
||||||
* Mandates store: governance mandates and their lifecycle steps.
|
|
||||||
*
|
|
||||||
* Maps to the backend /api/v1/mandates endpoints.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface MandateStep {
|
export interface MandateStep {
|
||||||
id: string
|
id: string
|
||||||
mandate_id: string
|
mandate_id: string
|
||||||
@@ -20,10 +14,13 @@ export interface MandateStep {
|
|||||||
export interface Mandate {
|
export interface Mandate {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
origin_id: string | null
|
||||||
|
origin_display_name: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
mandate_type: string
|
mandate_type: string
|
||||||
status: string
|
status: string
|
||||||
mandatee_id: string | null
|
mandatee_id: string | null
|
||||||
|
mandatee_display_name: string | null
|
||||||
decision_id: string | null
|
decision_id: string | null
|
||||||
starts_at: string | null
|
starts_at: string | null
|
||||||
ends_at: string | null
|
ends_at: string | null
|
||||||
@@ -34,8 +31,10 @@ export interface Mandate {
|
|||||||
|
|
||||||
export interface MandateCreate {
|
export interface MandateCreate {
|
||||||
title: string
|
title: string
|
||||||
|
origin_id?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
mandate_type: string
|
mandate_type: string
|
||||||
|
nomination_mode?: string
|
||||||
decision_id?: string | null
|
decision_id?: string | null
|
||||||
starts_at?: string | null
|
starts_at?: string | null
|
||||||
ends_at?: string | null
|
ends_at?: string | null
|
||||||
@@ -43,6 +42,7 @@ export interface MandateCreate {
|
|||||||
|
|
||||||
export interface MandateUpdate {
|
export interface MandateUpdate {
|
||||||
title?: string
|
title?: string
|
||||||
|
origin_id?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
mandate_type?: string
|
mandate_type?: string
|
||||||
starts_at?: string | null
|
starts_at?: string | null
|
||||||
@@ -50,6 +50,7 @@ export interface MandateUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MandateStepCreate {
|
export interface MandateStepCreate {
|
||||||
|
step_order: number
|
||||||
step_type: string
|
step_type: string
|
||||||
title?: string | null
|
title?: string | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
@@ -71,31 +72,20 @@ export const useMandatesStore = defineStore('mandates', {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
byStatus: (state) => {
|
byStatus: (state) => (status: string) => state.list.filter(m => m.status === status),
|
||||||
return (status: string) => state.list.filter(m => m.status === status)
|
activeMandates: (state): Mandate[] => state.list.filter(m => m.status === 'active'),
|
||||||
},
|
completedMandates: (state): Mandate[] => state.list.filter(m => m.status === 'completed'),
|
||||||
activeMandates: (state): Mandate[] => {
|
|
||||||
return state.list.filter(m => m.status === 'active')
|
|
||||||
},
|
|
||||||
completedMandates: (state): Mandate[] => {
|
|
||||||
return state.list.filter(m => m.status === 'completed')
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
/**
|
|
||||||
* Fetch all mandates with optional filters.
|
|
||||||
*/
|
|
||||||
async fetchAll(params?: { mandate_type?: string; status?: string }) {
|
async fetchAll(params?: { mandate_type?: string; status?: string }) {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.error = null
|
this.error = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { $api } = useApi()
|
const { $api } = useApi()
|
||||||
const query: Record<string, string> = {}
|
const query: Record<string, string> = {}
|
||||||
if (params?.mandate_type) query.mandate_type = params.mandate_type
|
if (params?.mandate_type) query.mandate_type = params.mandate_type
|
||||||
if (params?.status) query.status = params.status
|
if (params?.status) query.status = params.status
|
||||||
|
|
||||||
this.list = await $api<Mandate[]>('/mandates/', { query })
|
this.list = await $api<Mandate[]>('/mandates/', { query })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des mandats'
|
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des mandats'
|
||||||
@@ -104,13 +94,9 @@ export const useMandatesStore = defineStore('mandates', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a single mandate by ID with all its steps.
|
|
||||||
*/
|
|
||||||
async fetchById(id: string) {
|
async fetchById(id: string) {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.error = null
|
this.error = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { $api } = useApi()
|
const { $api } = useApi()
|
||||||
this.current = await $api<Mandate>(`/mandates/${id}`)
|
this.current = await $api<Mandate>(`/mandates/${id}`)
|
||||||
@@ -121,19 +107,12 @@ export const useMandatesStore = defineStore('mandates', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new mandate.
|
|
||||||
*/
|
|
||||||
async create(payload: MandateCreate) {
|
async create(payload: MandateCreate) {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.error = null
|
this.error = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { $api } = useApi()
|
const { $api } = useApi()
|
||||||
const mandate = await $api<Mandate>('/mandates/', {
|
const mandate = await $api<Mandate>('/mandates/', { method: 'POST', body: payload })
|
||||||
method: 'POST',
|
|
||||||
body: payload,
|
|
||||||
})
|
|
||||||
this.list.unshift(mandate)
|
this.list.unshift(mandate)
|
||||||
return mandate
|
return mandate
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -144,18 +123,11 @@ export const useMandatesStore = defineStore('mandates', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Update an existing mandate.
|
|
||||||
*/
|
|
||||||
async update(id: string, data: MandateUpdate) {
|
async update(id: string, data: MandateUpdate) {
|
||||||
this.error = null
|
this.error = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { $api } = useApi()
|
const { $api } = useApi()
|
||||||
const updated = await $api<Mandate>(`/mandates/${id}`, {
|
const updated = await $api<Mandate>(`/mandates/${id}`, { method: 'PUT', body: data })
|
||||||
method: 'PUT',
|
|
||||||
body: data,
|
|
||||||
})
|
|
||||||
if (this.current?.id === id) this.current = updated
|
if (this.current?.id === id) this.current = updated
|
||||||
const idx = this.list.findIndex(m => m.id === id)
|
const idx = this.list.findIndex(m => m.id === id)
|
||||||
if (idx >= 0) this.list[idx] = updated
|
if (idx >= 0) this.list[idx] = updated
|
||||||
@@ -166,12 +138,8 @@ export const useMandatesStore = defineStore('mandates', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a mandate.
|
|
||||||
*/
|
|
||||||
async delete(id: string) {
|
async delete(id: string) {
|
||||||
this.error = null
|
this.error = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { $api } = useApi()
|
const { $api } = useApi()
|
||||||
await $api(`/mandates/${id}`, { method: 'DELETE' })
|
await $api(`/mandates/${id}`, { method: 'DELETE' })
|
||||||
@@ -183,17 +151,11 @@ export const useMandatesStore = defineStore('mandates', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Advance the mandate to the next step in its workflow.
|
|
||||||
*/
|
|
||||||
async advance(id: string) {
|
async advance(id: string) {
|
||||||
this.error = null
|
this.error = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { $api } = useApi()
|
const { $api } = useApi()
|
||||||
const updated = await $api<Mandate>(`/mandates/${id}/advance`, {
|
const updated = await $api<Mandate>(`/mandates/${id}/advance`, { method: 'POST' })
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
if (this.current?.id === id) this.current = updated
|
if (this.current?.id === id) this.current = updated
|
||||||
const idx = this.list.findIndex(m => m.id === id)
|
const idx = this.list.findIndex(m => m.id === id)
|
||||||
if (idx >= 0) this.list[idx] = updated
|
if (idx >= 0) this.list[idx] = updated
|
||||||
@@ -204,21 +166,12 @@ export const useMandatesStore = defineStore('mandates', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a step to a mandate.
|
|
||||||
*/
|
|
||||||
async addStep(id: string, step: MandateStepCreate) {
|
async addStep(id: string, step: MandateStepCreate) {
|
||||||
this.error = null
|
this.error = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { $api } = useApi()
|
const { $api } = useApi()
|
||||||
const newStep = await $api<MandateStep>(`/mandates/${id}/steps`, {
|
const newStep = await $api<MandateStep>(`/mandates/${id}/steps`, { method: 'POST', body: step })
|
||||||
method: 'POST',
|
if (this.current?.id === id) this.current.steps.push(newStep)
|
||||||
body: step,
|
|
||||||
})
|
|
||||||
if (this.current?.id === id) {
|
|
||||||
this.current.steps.push(newStep)
|
|
||||||
}
|
|
||||||
return newStep
|
return newStep
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'ajout de l\'etape'
|
this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'ajout de l\'etape'
|
||||||
@@ -226,12 +179,8 @@ export const useMandatesStore = defineStore('mandates', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Assign a mandatee to the mandate.
|
|
||||||
*/
|
|
||||||
async assignMandatee(id: string, mandateeId: string) {
|
async assignMandatee(id: string, mandateeId: string) {
|
||||||
this.error = null
|
this.error = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { $api } = useApi()
|
const { $api } = useApi()
|
||||||
const updated = await $api<Mandate>(`/mandates/${id}/assign`, {
|
const updated = await $api<Mandate>(`/mandates/${id}/assign`, {
|
||||||
@@ -248,17 +197,11 @@ export const useMandatesStore = defineStore('mandates', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke the mandate.
|
|
||||||
*/
|
|
||||||
async revoke(id: string) {
|
async revoke(id: string) {
|
||||||
this.error = null
|
this.error = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { $api } = useApi()
|
const { $api } = useApi()
|
||||||
const updated = await $api<Mandate>(`/mandates/${id}/revoke`, {
|
const updated = await $api<Mandate>(`/mandates/${id}/revoke`, { method: 'POST' })
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
if (this.current?.id === id) this.current = updated
|
if (this.current?.id === id) this.current = updated
|
||||||
const idx = this.list.findIndex(m => m.id === id)
|
const idx = this.list.findIndex(m => m.id === id)
|
||||||
if (idx >= 0) this.list[idx] = updated
|
if (idx >= 0) this.list[idx] = updated
|
||||||
@@ -269,9 +212,6 @@ export const useMandatesStore = defineStore('mandates', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the current mandate.
|
|
||||||
*/
|
|
||||||
clearCurrent() {
|
clearCurrent() {
|
||||||
this.current = null
|
this.current = null
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
export interface Organization {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
org_type: string
|
||||||
|
is_transparent: boolean
|
||||||
|
color: string | null
|
||||||
|
icon: string | null
|
||||||
|
description: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrgState {
|
||||||
|
organizations: Organization[]
|
||||||
|
activeSlug: string | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOrganizationsStore = defineStore('organizations', {
|
||||||
|
state: (): OrgState => ({
|
||||||
|
organizations: [],
|
||||||
|
activeSlug: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
active: (state): Organization | null =>
|
||||||
|
state.organizations.find(o => o.slug === state.activeSlug) ?? state.organizations[0] ?? null,
|
||||||
|
|
||||||
|
hasOrganizations: (state): boolean => state.organizations.length > 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetchOrganizations() {
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
try {
|
||||||
|
const { $api } = useApi()
|
||||||
|
const orgs = await $api<Organization[]>('/organizations/')
|
||||||
|
// Duniter G1 first, then alphabetical
|
||||||
|
this.organizations = orgs.sort((a, b) => {
|
||||||
|
if (a.slug === 'duniter-g1') return -1
|
||||||
|
if (b.slug === 'duniter-g1') return 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
// Restore persisted active slug, or default to first org
|
||||||
|
const stored = import.meta.client ? localStorage.getItem('libredecision_org') : null
|
||||||
|
if (stored && this.organizations.some(o => o.slug === stored)) {
|
||||||
|
this.activeSlug = stored
|
||||||
|
} else if (this.organizations.length > 0) {
|
||||||
|
this.activeSlug = this.organizations[0].slug
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.error = err?.message || 'Erreur lors du chargement des organisations'
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setActive(slug: string) {
|
||||||
|
if (this.organizations.some(o => o.slug === slug)) {
|
||||||
|
this.activeSlug = slug
|
||||||
|
if (import.meta.client) {
|
||||||
|
localStorage.setItem('libredecision_org', slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
+13
-4
@@ -1,7 +1,7 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
ssr: false,
|
ssr: false,
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: false },
|
||||||
devServer: { port: 3002, host: '0.0.0.0' },
|
devServer: { port: 3002, host: '0.0.0.0' },
|
||||||
components: [{ path: '~/components', pathPrefix: false }],
|
components: [{ path: '~/components', pathPrefix: false }],
|
||||||
css: ['~/assets/css/moods.css'],
|
css: ['~/assets/css/moods.css'],
|
||||||
@@ -21,15 +21,15 @@ export default defineNuxtConfig({
|
|||||||
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
||||||
{ name: 'apple-mobile-web-app-status-bar-style', content: 'default' },
|
{ name: 'apple-mobile-web-app-status-bar-style', content: 'default' },
|
||||||
{ name: 'mobile-web-app-capable', content: 'yes' },
|
{ name: 'mobile-web-app-capable', content: 'yes' },
|
||||||
{ property: 'og:title', content: 'Glibredecision' },
|
{ property: 'og:title', content: 'libreDecision' },
|
||||||
{ property: 'og:description', content: 'Decisions collectives pour la communaute Duniter/G1' },
|
{ property: 'og:description', content: 'Decisions collectives pour la communaute Duniter/G1' },
|
||||||
{ property: 'og:type', content: 'website' },
|
{ property: 'og:type', content: 'website' },
|
||||||
],
|
],
|
||||||
title: 'Glibredecision',
|
title: 'libreDecision',
|
||||||
link: [
|
link: [
|
||||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||||
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
|
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
|
||||||
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,700;1,800&display=swap' },
|
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400;1,500;1,600;1,700&display=swap' },
|
||||||
{ rel: 'stylesheet', href: 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css' },
|
{ rel: 'stylesheet', href: 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css' },
|
||||||
],
|
],
|
||||||
script: [
|
script: [
|
||||||
@@ -45,4 +45,13 @@ export default defineNuxtConfig({
|
|||||||
nitro: {
|
nitro: {
|
||||||
compressPublicAssets: true,
|
compressPublicAssets: true,
|
||||||
},
|
},
|
||||||
|
vite: {
|
||||||
|
define: {
|
||||||
|
// Polkadot packages expect a Node-like global
|
||||||
|
global: 'globalThis',
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['@polkadot/extension-dapp', '@polkadot/util'],
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+2754
-14
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "glibredecision",
|
"name": "libredecision",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
"@nuxt/content": "^3.11.2",
|
"@nuxt/content": "^3.11.2",
|
||||||
"@nuxt/ui": "^3.1.0",
|
"@nuxt/ui": "^3.1.0",
|
||||||
"@pinia/nuxt": "^0.11.0",
|
"@pinia/nuxt": "^0.11.0",
|
||||||
|
"@polkadot/extension-dapp": "^0.46.9",
|
||||||
|
"@polkadot/util": "^13.5.9",
|
||||||
"@unocss/nuxt": "^66.6.0",
|
"@unocss/nuxt": "^66.6.0",
|
||||||
"@vueuse/nuxt": "^14.2.1",
|
"@vueuse/nuxt": "^14.2.1",
|
||||||
"nuxt": "^4.3.1",
|
"nuxt": "^4.3.1",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user