forked from yvv/decision
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc2de0947c | ||
|
|
decbc0ba7b | ||
|
|
cf634c25d2 | ||
|
|
422b45ab75 | ||
|
|
8ec0b48e8f | ||
|
|
744d157b01 | ||
|
|
850f9c46b0 | ||
|
|
8a3216037c | ||
|
|
753b52202b | ||
|
|
fd19dbf4ec | ||
|
|
c3d6bf88b9 | ||
|
|
fa8e8dc1f0 | ||
|
|
6c0754a5c3 | ||
|
|
1eac6f856a | ||
|
|
9203fd2501 | ||
|
|
ac9c904e7e | ||
|
|
94907afb20 | ||
|
|
1f9c241b41 | ||
|
|
c2669a3d0b | ||
|
|
7a022dd35e | ||
|
|
67ddee28b2 | ||
|
|
4b1732916d | ||
|
|
ef0347b6aa | ||
|
|
a80f82cb64 | ||
|
|
db156884c9 | ||
|
|
493836ff11 | ||
|
|
e9f064dc78 | ||
|
|
a8998f0624 | ||
|
|
3301b764f7 | ||
|
|
38fd053e87 | ||
|
|
cf59bbca0d | ||
|
|
f2829339cf | ||
|
|
7cfc7ea352 | ||
|
|
e164b5f6c1 | ||
|
|
d886302b59 | ||
|
|
6f2bf62295 | ||
|
|
f707b5f15d | ||
|
|
53d8752e40 | ||
|
|
172eab4c7c | ||
|
|
39883ee444 | ||
|
|
55020033ee | ||
|
|
a9e586cda1 | ||
|
|
59fff64f9e | ||
|
|
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 |
@@ -0,0 +1,19 @@
|
|||||||
|
# Build context of docker/app.Dockerfile (the repository root).
|
||||||
|
# The maquette builds from its own context (maquette/), not from here.
|
||||||
|
.git
|
||||||
|
.remember
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
data
|
||||||
|
target
|
||||||
|
maquette
|
||||||
|
docs
|
||||||
|
web/node_modules
|
||||||
|
web/build
|
||||||
|
web/.svelte-kit
|
||||||
|
web/test-results
|
||||||
|
web/playwright-report
|
||||||
|
**/*.db
|
||||||
|
**/*.db-wal
|
||||||
|
**/*.db-shm
|
||||||
+28
-43
@@ -1,49 +1,34 @@
|
|||||||
# PostgreSQL
|
# libreDecision v3 — variables du serveur (ld-server). Toutes facultatives en dev.
|
||||||
POSTGRES_DB=glibredecision
|
# Le serveur lit son environnement, pas de fichier : `set -a; . ./.env; set +a` avant
|
||||||
POSTGRES_USER=glibredecision
|
# `cargo run -p ld-server`. Le .env n'est jamais commité.
|
||||||
POSTGRES_PASSWORD=change-me-in-production
|
|
||||||
DATABASE_URL=postgresql+asyncpg://glibredecision:change-me-in-production@localhost:5432/glibredecision
|
|
||||||
|
|
||||||
# Backend
|
# Écoute et dossiers
|
||||||
SECRET_KEY=change-me-in-production-with-a-real-secret-key
|
LD_BIND=0.0.0.0:8002
|
||||||
DEBUG=true
|
LD_DATA_DIR=data # SQLite, clone du dépôt des textes, clé de publication — données runtime
|
||||||
|
LD_WEB_DIR=web/build # interface compilée (npm run build)
|
||||||
|
LD_SEED_DIR=seeds
|
||||||
|
LD_PUBLIC_URL=http://127.0.0.1:3002
|
||||||
|
|
||||||
# Environment: development, staging, production
|
# Connexion Ğ1 — client OAuth2 de connect.monnaie-libre.fr (les deux sont requis, sinon désactivée)
|
||||||
ENVIRONMENT=development
|
# LD_CONNECT_URL=https://connect.monnaie-libre.fr
|
||||||
|
# LD_CONNECT_CLIENT_ID=
|
||||||
|
# LD_CONNECT_CLIENT_SECRET=
|
||||||
|
|
||||||
# Logging: DEBUG, INFO, WARNING, ERROR
|
# Accès (trustinWallet)
|
||||||
LOG_LEVEL=INFO
|
LD_ADMIN_G1_IDS= # ex. idty:8352 — administrateurs des accès, séparés par des virgules
|
||||||
|
LD_DEV_LOGIN=1 # dev local uniquement : connexion sans clé, membre de tous les groupes
|
||||||
|
LD_BOOTSTRAP_ADMIN=0 # 1 = au premier démarrage sans admin, écrit data/bootstrap-admin.txt (0600)
|
||||||
|
|
||||||
# CORS
|
# Publication des textes en vigueur (dépôt git lu par les applis clientes)
|
||||||
CORS_ORIGINS=["http://localhost:3002"]
|
# LD_PUBLISH_REMOTE=gitea@git.open.us.org:yvv/decision-depot-textes-reference.git
|
||||||
|
# LD_PUBLISH_BRANCH=main
|
||||||
|
# LD_PUBLISH_KEY=data/keys/publish_ed25519 # générée au démarrage si absente
|
||||||
|
# LD_PUBLISH_AUTHOR=libreDecision
|
||||||
|
# LD_PUBLISH_EMAIL=decision@librodrome.org
|
||||||
|
|
||||||
# Database connection pool
|
# Taille de la toile de confiance (W) — lue sur la chaîne, repli sur l'indexeur puis sur la valeur fixe
|
||||||
DATABASE_POOL_SIZE=20
|
# LD_RPC_ENDPOINTS=https://rpc.duniter.org,https://g1.axiom-team.fr/ws/
|
||||||
DATABASE_MAX_OVERFLOW=10
|
# LD_INDEXERS=https://indexer.duniter.org/v1/graphql
|
||||||
|
# LD_WOT_FALLBACK=5745
|
||||||
|
|
||||||
# Auth / Sessions
|
RUST_LOG=info,sqlx=warn
|
||||||
SESSION_TTL_HOURS=24
|
|
||||||
CHALLENGE_EXPIRE_SECONDS=300
|
|
||||||
|
|
||||||
# Rate limiting (requests per minute)
|
|
||||||
RATE_LIMIT_DEFAULT=60
|
|
||||||
RATE_LIMIT_AUTH=10
|
|
||||||
RATE_LIMIT_VOTE=30
|
|
||||||
|
|
||||||
# Duniter V2 RPC
|
|
||||||
DUNITER_RPC_URL=wss://gdev.p2p.legal/ws
|
|
||||||
DUNITER_RPC_TIMEOUT_SECONDS=10
|
|
||||||
|
|
||||||
# Blockchain cache TTL (seconds)
|
|
||||||
BLOCKCHAIN_CACHE_TTL_SECONDS=3600
|
|
||||||
|
|
||||||
# IPFS
|
|
||||||
IPFS_API_URL=http://localhost:5001
|
|
||||||
IPFS_GATEWAY_URL=http://localhost:8080
|
|
||||||
IPFS_TIMEOUT_SECONDS=30
|
|
||||||
|
|
||||||
# Frontend
|
|
||||||
NUXT_PUBLIC_API_BASE=http://localhost:8002/api/v1
|
|
||||||
|
|
||||||
# Docker / Production
|
|
||||||
DOMAIN=glibredecision.org
|
|
||||||
|
|||||||
+13
@@ -25,6 +25,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
|
*.db.bak*
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|
||||||
# Docker volumes
|
# Docker volumes
|
||||||
@@ -40,3 +41,15 @@ htmlcov/
|
|||||||
|
|
||||||
# IPFS
|
# IPFS
|
||||||
ipfs-data/
|
ipfs-data/
|
||||||
|
docs/*.pdf
|
||||||
|
|
||||||
|
# v3 — Rust + Svelte
|
||||||
|
/target/
|
||||||
|
/data/
|
||||||
|
web/build/
|
||||||
|
web/.svelte-kit/
|
||||||
|
web/test-results/
|
||||||
|
web/playwright-report/
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
web/node_modules/
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
when:
|
|
||||||
branch: main
|
|
||||||
event: push
|
|
||||||
|
|
||||||
steps:
|
|
||||||
test-backend:
|
|
||||||
image: python:3.11-slim
|
|
||||||
commands:
|
|
||||||
- cd backend
|
|
||||||
- pip install --no-cache-dir -r requirements.txt
|
|
||||||
- pytest app/tests/ -v --tb=short
|
|
||||||
|
|
||||||
test-frontend:
|
|
||||||
image: node:20-slim
|
|
||||||
commands:
|
|
||||||
- cd frontend
|
|
||||||
- npm ci
|
|
||||||
- npm run build
|
|
||||||
|
|
||||||
docker-backend:
|
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
|
||||||
depends_on:
|
|
||||||
- test-backend
|
|
||||||
settings:
|
|
||||||
repo: ${CI_FORGE_URL}/${CI_REPO}
|
|
||||||
dockerfile: docker/backend.Dockerfile
|
|
||||||
context: .
|
|
||||||
tag:
|
|
||||||
- latest
|
|
||||||
- ${CI_COMMIT_SHA:0:8}
|
|
||||||
target: production
|
|
||||||
registry:
|
|
||||||
from_secret: docker_registry
|
|
||||||
username:
|
|
||||||
from_secret: docker_username
|
|
||||||
password:
|
|
||||||
from_secret: docker_password
|
|
||||||
|
|
||||||
docker-frontend:
|
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
|
||||||
depends_on:
|
|
||||||
- test-frontend
|
|
||||||
settings:
|
|
||||||
repo: ${CI_FORGE_URL}/${CI_REPO}
|
|
||||||
dockerfile: docker/frontend.Dockerfile
|
|
||||||
context: .
|
|
||||||
tag:
|
|
||||||
- latest
|
|
||||||
- ${CI_COMMIT_SHA:0:8}
|
|
||||||
target: production
|
|
||||||
registry:
|
|
||||||
from_secret: docker_registry
|
|
||||||
username:
|
|
||||||
from_secret: docker_username
|
|
||||||
password:
|
|
||||||
from_secret: docker_password
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
image: appleboy/drone-ssh
|
|
||||||
depends_on:
|
|
||||||
- docker-backend
|
|
||||||
- docker-frontend
|
|
||||||
settings:
|
|
||||||
host:
|
|
||||||
from_secret: deploy_host
|
|
||||||
username:
|
|
||||||
from_secret: deploy_username
|
|
||||||
key:
|
|
||||||
from_secret: deploy_key
|
|
||||||
port: 22
|
|
||||||
script:
|
|
||||||
- cd /opt/glibredecision
|
|
||||||
- docker compose -f docker/docker-compose.yml pull
|
|
||||||
- docker compose -f docker/docker-compose.yml up -d --remove-orphans
|
|
||||||
- docker image prune -f
|
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# libreDecision v3 — le produit (Rust + Svelte) sur decision.librodrome.org.
|
||||||
|
# Une seule image : la construction rejoue toute la boucle (fmt, clippy -D warnings,
|
||||||
|
# tests Rust, svelte-check, vitest) — un contrôle rouge, pas d'image.
|
||||||
|
# Données runtime : volume nommé lpk-libredecision-app_data (SQLite, clone du dépôt
|
||||||
|
# des textes, clé de publication). Jamais `down -v`, jamais recréé.
|
||||||
|
|
||||||
|
when:
|
||||||
|
- branch: main
|
||||||
|
event: push
|
||||||
|
|
||||||
|
# Le produit passe en premier : tant que son image n'est pas construite et testée, la
|
||||||
|
# maquette continue de servir la racine. La maquette (maquette.yml) attend ce workflow
|
||||||
|
# et ne part sur maquette.decision.librodrome.org qu'après un déploiement réussi.
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- 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"
|
||||||
|
|
||||||
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
|
- name: build-app
|
||||||
|
image: docker:27-cli
|
||||||
|
depends_on:
|
||||||
|
- security-check
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- BUILD=$(echo "$CI_COMMIT_SHA" | cut -c1-8)
|
||||||
|
- docker build -t libredecision-app:latest -f docker/app.Dockerfile --target production --build-arg LD_BUILD=$BUILD .
|
||||||
|
- echo "Image app construite ($BUILD)"
|
||||||
|
|
||||||
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
|
- name: sbom-generate
|
||||||
|
image: alpine:3.20
|
||||||
|
depends_on:
|
||||||
|
- build-app
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache curl
|
||||||
|
# Version épinglée : ce conteneur a le socket Docker, il n'exécute pas « main » de syft.
|
||||||
|
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/v1.55.0/install.sh | sh -s -- -b /usr/local/bin v1.55.0
|
||||||
|
- mkdir -p .reports
|
||||||
|
# spec 1.5 épinglée : syft « latest » émet plus récent que ce que le trivy du runner décode
|
||||||
|
- syft libredecision-app:latest -o cyclonedx-json@1.5 --file .reports/sbom-app.cyclonedx.json
|
||||||
|
- echo "SBOM genere"
|
||||||
|
|
||||||
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
|
- name: sbom-scan
|
||||||
|
image: aquasec/trivy:latest
|
||||||
|
depends_on:
|
||||||
|
- sbom-generate
|
||||||
|
volumes:
|
||||||
|
- /home/syoul/trivy-cache:/root/.cache/trivy
|
||||||
|
commands:
|
||||||
|
- trivy sbom --format json --output .reports/trivy-app.json .reports/sbom-app.cyclonedx.json
|
||||||
|
- echo "Scan CVE termine"
|
||||||
|
|
||||||
|
# NOTE: from_secret + pas de volumes : compatible
|
||||||
|
# failure: ignore — télémétrie, ne bloque pas le déploiement (cf. maquette.yml).
|
||||||
|
- name: sbom-publish
|
||||||
|
image: alpine/curl:latest
|
||||||
|
failure: ignore
|
||||||
|
depends_on:
|
||||||
|
- sbom-scan
|
||||||
|
environment:
|
||||||
|
DTRACK_TOKEN:
|
||||||
|
from_secret: dependency_track_token
|
||||||
|
DTRACK_DOMAIN:
|
||||||
|
from_secret: dtrack_domain
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
VERSION=$(date +%Y-%m-%d)-$(echo "$CI_COMMIT_SHA" | cut -c1-8)
|
||||||
|
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-app" \
|
||||||
|
-F "projectVersion=$VERSION" \
|
||||||
|
-F "bom=@.reports/sbom-app.cyclonedx.json")
|
||||||
|
echo "HTTP $HTTP (app) : $(cat /tmp/dtrack-resp.txt)"
|
||||||
|
[ "$HTTP" -ge 200 ] && [ "$HTTP" -lt 300 ] || exit 1
|
||||||
|
|
||||||
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
|
# Fabio route via les labels SERVICE_* de docker/app-compose.yml.
|
||||||
|
- name: deploy
|
||||||
|
image: docker:27-cli
|
||||||
|
depends_on:
|
||||||
|
- sbom-publish
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- /opt/libredecision-app:/opt/libredecision-app
|
||||||
|
commands:
|
||||||
|
- DOMAIN=decision.librodrome.org
|
||||||
|
# La version en service (celle qui tourne, validée par son propre healthcheck) est
|
||||||
|
# gardée sous « previous » : « retablir » la relance si ce déploiement échoue.
|
||||||
|
# Un témoin resté d'un pipeline annulé : ce qui tourne n'a pas été validé, on garde l'ancien « previous ».
|
||||||
|
- |
|
||||||
|
IMG=$(docker inspect --format '{{if .State.Running}}{{.Image}}{{end}}' lpk-libredecision-app 2>/dev/null || true)
|
||||||
|
if [ -n "$IMG" ] && [ ! -e /opt/libredecision-app/deploying ]; then
|
||||||
|
docker tag "$IMG" libredecision-app:previous && echo "Version en service gardee ($IMG)"
|
||||||
|
fi
|
||||||
|
# Témoin : à partir d'ici, un échec appelle « retablir ». Le healthcheck réussi l'efface.
|
||||||
|
- touch /opt/libredecision-app/deploying
|
||||||
|
- echo "APP_DOMAIN=$DOMAIN" > /opt/libredecision-app/.env
|
||||||
|
- cp docker/app-compose.yml /opt/libredecision-app/docker-compose.yml
|
||||||
|
# Tout conteneur, même arrêté, qui revendique encore la racine (la maquette avant son
|
||||||
|
# déplacement, d'anciennes stacks ehv-decision-main-*) est arrêté et sort de
|
||||||
|
# « restart: always » : sinon un redémarrage de Docker le ramènerait, et fabio lui
|
||||||
|
# donnerait /api/* (route la plus longue). Rien n'est supprimé ; la maquette est
|
||||||
|
# recréée par son propre workflow, sous son nouveau domaine.
|
||||||
|
# Ceux qui tournaient sont ajoutés à /opt/libredecision-app/retires (jamais vidé ici :
|
||||||
|
# un pipeline annulé ne perd pas sa liste) ; si la suite échoue, « retablir » les relance.
|
||||||
|
- |
|
||||||
|
RETIRES=/opt/libredecision-app/retires
|
||||||
|
touch "$RETIRES"
|
||||||
|
for TAG in "urlprefix-$DOMAIN/*" "urlprefix-$DOMAIN/api/*"; do
|
||||||
|
for C in $(docker ps -aq --filter "label=SERVICE_3000_TAGS=$TAG") $(docker ps -aq --filter "label=SERVICE_8002_TAGS=$TAG"); do
|
||||||
|
NAME=$(docker inspect --format '{{.Name}}' "$C")
|
||||||
|
[ "$NAME" = "/lpk-libredecision-app" ] && continue
|
||||||
|
echo "Retrait de $NAME de la racine (revendique $TAG)"
|
||||||
|
if [ "$(docker inspect --format '{{.State.Running}}' "$C")" = "true" ]; then
|
||||||
|
echo "$C" >> "$RETIRES"
|
||||||
|
fi
|
||||||
|
docker update --restart=no "$C" >/dev/null
|
||||||
|
docker stop "$C" >/dev/null
|
||||||
|
done
|
||||||
|
done
|
||||||
|
# Arrêter avant le challenge ACME : la route hôte /* masquerait le webroot de sonic-acme-1.
|
||||||
|
- cd /opt/libredecision-app && docker compose stop
|
||||||
|
- |
|
||||||
|
ACME_EXIT=0
|
||||||
|
docker exec sonic-acme-1 /app/acme.sh \
|
||||||
|
--home /etc/acme.sh \
|
||||||
|
--issue -d "$DOMAIN" \
|
||||||
|
--webroot /usr/share/nginx/html \
|
||||||
|
--server letsencrypt \
|
||||||
|
--accountemail support+acme@asycn.io || ACME_EXIT=$?
|
||||||
|
if [ "$ACME_EXIT" -eq 0 ] || [ "$ACME_EXIT" -eq 2 ]; then
|
||||||
|
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/fullchain.cer /host/certs/$DOMAIN-cert.pem
|
||||||
|
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/$DOMAIN.key /host/certs/$DOMAIN-key.pem
|
||||||
|
echo "TLS OK (acme exit $ACME_EXIT)"
|
||||||
|
elif docker exec sonic-acme-1 test -f "/host/certs/$DOMAIN-cert.pem"; then
|
||||||
|
echo "AVERTISSEMENT: emission ACME en echec (exit $ACME_EXIT) - certificat existant conserve"
|
||||||
|
else
|
||||||
|
echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT) et aucun certificat existant pour $DOMAIN"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Image construite localement dans la pipeline : pas de docker compose pull
|
||||||
|
- cd /opt/libredecision-app && docker compose up -d --remove-orphans --force-recreate
|
||||||
|
- cd /opt/libredecision-app && docker compose ps
|
||||||
|
|
||||||
|
- name: test-deploy
|
||||||
|
image: docker:27-cli
|
||||||
|
depends_on:
|
||||||
|
- deploy
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
STATUS=$(docker inspect --format '{{.State.Status}}' lpk-libredecision-app 2>/dev/null || echo "absent")
|
||||||
|
echo "lpk-libredecision-app : $STATUS"
|
||||||
|
[ "$STATUS" = "running" ] || { docker logs --tail 50 lpk-libredecision-app 2>&1; echo "FAIL: non running"; exit 1; }
|
||||||
|
echo "PASS: conteneur running"
|
||||||
|
|
||||||
|
# Le produit répond, et c'est bien ce commit qui répond.
|
||||||
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
|
- name: healthcheck
|
||||||
|
image: alpine:3.20
|
||||||
|
depends_on:
|
||||||
|
- test-deploy
|
||||||
|
volumes:
|
||||||
|
- /opt/libredecision-app:/opt/libredecision-app
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache --quiet curl
|
||||||
|
- |
|
||||||
|
BUILD=$(echo "$CI_COMMIT_SHA" | cut -c1-8)
|
||||||
|
TARGET="https://decision.librodrome.org/api/health"
|
||||||
|
MAX=60
|
||||||
|
i=0
|
||||||
|
until [ $i -ge $MAX ]; do
|
||||||
|
BODY=$(curl -sS -m 10 "$TARGET" 2>/dev/null || true)
|
||||||
|
echo "Tentative $((i+1))/$MAX - $BODY"
|
||||||
|
case "$BODY" in
|
||||||
|
*'"ok":true'*"\"build\":\"$BUILD\""*|*"\"build\":\"$BUILD\""*'"ok":true'*)
|
||||||
|
echo "PASS: $BUILD repond sur $TARGET"
|
||||||
|
: > /opt/libredecision-app/retires
|
||||||
|
rm -f /opt/libredecision-app/deploying
|
||||||
|
exit 0 ;;
|
||||||
|
esac
|
||||||
|
i=$((i+1))
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
echo "FAIL: $BUILD ne repond pas apres 10 minutes"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
# Échec avant le déploiement (construction, tests, SBOM) : rien n'a bougé, rien à faire.
|
||||||
|
# Échec après, au premier déploiement : le produit s'arrête et ce qu'il avait retiré de la
|
||||||
|
# racine repart (restart: always rendu). Échec après, plus tard : la version précédente du
|
||||||
|
# produit (image « previous ») est relancée.
|
||||||
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
|
- name: retablir
|
||||||
|
image: docker:27-cli
|
||||||
|
# En graphe (depends_on), une étape sans dépendance démarrerait au début : celle-ci
|
||||||
|
# attend la fin du healthcheck (fait, échoué ou sauté).
|
||||||
|
depends_on:
|
||||||
|
- healthcheck
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- /opt/libredecision-app:/opt/libredecision-app
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
D=/opt/libredecision-app
|
||||||
|
if [ ! -e "$D/deploying" ]; then echo "Echec avant le deploiement : rien a retablir"; exit 0; fi
|
||||||
|
if [ -s "$D/retires" ]; then
|
||||||
|
# Hors de « restart: always » aussi, sinon un redémarrage de Docker le ramènerait
|
||||||
|
# à côté de ce qui revient ; le prochain déploiement le recrée (--force-recreate).
|
||||||
|
docker update --restart=no lpk-libredecision-app >/dev/null 2>&1 || true
|
||||||
|
docker stop lpk-libredecision-app >/dev/null 2>&1 || true
|
||||||
|
for C in $(cat "$D/retires"); do
|
||||||
|
echo "Retour de $(docker inspect --format '{{.Name}}' "$C" 2>/dev/null || echo "$C")"
|
||||||
|
docker update --restart=always "$C" >/dev/null && docker start "$C" >/dev/null || echo " echec pour $C"
|
||||||
|
done
|
||||||
|
: > "$D/retires"
|
||||||
|
elif docker image inspect libredecision-app:previous >/dev/null 2>&1; then
|
||||||
|
echo "Retour a la version precedente du produit"
|
||||||
|
docker tag libredecision-app:previous libredecision-app:latest
|
||||||
|
cd "$D" && docker compose up -d --force-recreate
|
||||||
|
else
|
||||||
|
echo "Aucune version precedente a relancer"
|
||||||
|
fi
|
||||||
|
rm -f "$D/deploying"
|
||||||
|
when:
|
||||||
|
- status: failure
|
||||||
|
|
||||||
|
- name: notify-failure
|
||||||
|
image: alpine:3.20
|
||||||
|
depends_on:
|
||||||
|
- retablir
|
||||||
|
commands:
|
||||||
|
- 'echo "ECHEC pipeline app #$CI_PIPELINE_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
|
||||||
|
when:
|
||||||
|
- status: failure
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
# La maquette Nuxt v2 (FastAPI + Postgres + IPFS), conservée telle quelle pour
|
||||||
|
# présenter le projet. Depuis la v3 elle vit sur maquette.decision.librodrome.org ;
|
||||||
|
# le produit (.woodpecker/app.yml) prend la racine decision.librodrome.org.
|
||||||
|
# Ce workflow attend le produit : si le produit échoue, la maquette ne bouge pas.
|
||||||
|
# Le nom de projet compose reste lpk-decision-main : mêmes volumes, aucune donnée perdue.
|
||||||
|
when:
|
||||||
|
- branch: main
|
||||||
|
event: push
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: security-check
|
||||||
|
image: alpine:3.20
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
if [ -f .env ]; then
|
||||||
|
echo "ERREUR: .env ne doit pas etre commite dans le depot"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
- 'grep -q "^\.env$" .gitignore || (echo "ERREUR: .env manquant dans .gitignore" && exit 1)'
|
||||||
|
- echo "Security check OK"
|
||||||
|
|
||||||
|
- name: test-backend
|
||||||
|
image: python:3.11-slim
|
||||||
|
commands:
|
||||||
|
- cd maquette/backend
|
||||||
|
- pip install --no-cache-dir -r requirements.txt
|
||||||
|
- pytest app/tests/ -v --tb=short
|
||||||
|
|
||||||
|
- name: test-frontend
|
||||||
|
image: node:20-slim
|
||||||
|
commands:
|
||||||
|
# git requis : dépendance @yvv/nuxt-base en git+https (node:20-slim n'a pas git)
|
||||||
|
- apt-get update && apt-get install -y --no-install-recommends git ca-certificates
|
||||||
|
- cd maquette/frontend
|
||||||
|
- npm ci
|
||||||
|
- npm run build
|
||||||
|
|
||||||
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
|
- name: build-backend
|
||||||
|
image: docker:27-cli
|
||||||
|
depends_on:
|
||||||
|
- test-backend
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- docker build -t libredecision-backend:latest -f maquette/docker/backend.Dockerfile --target production maquette
|
||||||
|
- echo "Image backend construite"
|
||||||
|
|
||||||
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
|
- name: build-frontend
|
||||||
|
image: docker:27-cli
|
||||||
|
depends_on:
|
||||||
|
- test-frontend
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- docker build -t libredecision-frontend:latest -f maquette/docker/frontend.Dockerfile --target production maquette
|
||||||
|
- echo "Image frontend construite"
|
||||||
|
|
||||||
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
|
- name: sbom-generate
|
||||||
|
image: alpine:3.20
|
||||||
|
depends_on:
|
||||||
|
- build-backend
|
||||||
|
- build-frontend
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache curl
|
||||||
|
# Version épinglée : ce conteneur a le socket Docker, il n'exécute pas « main » de syft.
|
||||||
|
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/v1.55.0/install.sh | sh -s -- -b /usr/local/bin v1.55.0
|
||||||
|
- mkdir -p .reports
|
||||||
|
# spec 1.5 épinglée : syft « latest » émet plus récent que ce que le trivy du runner décode
|
||||||
|
- syft libredecision-backend:latest -o cyclonedx-json@1.5 --file .reports/sbom-backend.cyclonedx.json
|
||||||
|
- syft libredecision-frontend:latest -o cyclonedx-json@1.5 --file .reports/sbom-frontend.cyclonedx.json
|
||||||
|
- echo "SBOM generes"
|
||||||
|
|
||||||
|
# NOTE: volumes + pas de from_secret : compatible
|
||||||
|
- name: sbom-scan
|
||||||
|
image: aquasec/trivy:latest
|
||||||
|
depends_on:
|
||||||
|
- sbom-generate
|
||||||
|
volumes:
|
||||||
|
- /home/syoul/trivy-cache:/root/.cache/trivy
|
||||||
|
commands:
|
||||||
|
- trivy sbom --format json --output .reports/trivy-backend.json .reports/sbom-backend.cyclonedx.json
|
||||||
|
- trivy sbom --format json --output .reports/trivy-frontend.json .reports/sbom-frontend.cyclonedx.json
|
||||||
|
- echo "Scan CVE termine"
|
||||||
|
|
||||||
|
# NOTE: from_secret + pas de volumes : compatible
|
||||||
|
# failure: ignore — la publication vers Dependency-Track est de la télémétrie :
|
||||||
|
# elle ne doit pas bloquer le déploiement quand DTrack est éteint ou le secret
|
||||||
|
# dtrack_domain absent (le scan trivy, lui, reste bloquant). Vu en panne le 12/08.
|
||||||
|
- name: sbom-publish
|
||||||
|
image: alpine/curl:latest
|
||||||
|
failure: ignore
|
||||||
|
depends_on:
|
||||||
|
- sbom-scan
|
||||||
|
environment:
|
||||||
|
DTRACK_TOKEN:
|
||||||
|
from_secret: dependency_track_token
|
||||||
|
DTRACK_DOMAIN:
|
||||||
|
from_secret: dtrack_domain
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
VERSION=$(date +%Y-%m-%d)-$(echo "$CI_COMMIT_SHA" | cut -c1-8)
|
||||||
|
for COMPONENT in backend frontend; do
|
||||||
|
HTTP=$(curl -s -o /tmp/dtrack-resp.txt -w "%{http_code}" -X POST "https://$DTRACK_DOMAIN/api/v1/bom" \
|
||||||
|
-H "X-Api-Key: $DTRACK_TOKEN" \
|
||||||
|
-F "autoCreate=true" \
|
||||||
|
-F "projectName=libredecision-$COMPONENT" \
|
||||||
|
-F "projectVersion=$VERSION" \
|
||||||
|
-F "bom=@.reports/sbom-$COMPONENT.cyclonedx.json")
|
||||||
|
echo "HTTP $HTTP ($COMPONENT) : $(cat /tmp/dtrack-resp.txt)"
|
||||||
|
[ "$HTTP" -ge 200 ] && [ "$HTTP" -lt 300 ] || exit 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# NOTE: from_secret + pas de volumes : compatible
|
||||||
|
- name: write-env
|
||||||
|
image: alpine:3.20
|
||||||
|
depends_on:
|
||||||
|
- sbom-publish
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD:
|
||||||
|
from_secret: postgres_password
|
||||||
|
SECRET_KEY:
|
||||||
|
from_secret: secret_key
|
||||||
|
commands:
|
||||||
|
- echo "APP_DOMAIN=maquette.decision.librodrome.org" > .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 maquette/docker/docker-compose.yml /opt/libredecision/docker-compose.yml
|
||||||
|
# Arreter avant le challenge ACME : libere le webroot pour sonic-acme-1
|
||||||
|
# L'org forge a ete renommee (EHV -> LPK) : le nom de projet compose a change,
|
||||||
|
# on arrete donc aussi l'ancienne stack (ehv-decision-main-*) pour eviter la
|
||||||
|
# double inscription fabio et le trafic partage v1/v2.
|
||||||
|
- docker ps -q --filter "name=decision-main" | xargs -r docker stop
|
||||||
|
- cd /opt/libredecision && docker compose stop
|
||||||
|
- |
|
||||||
|
DOMAIN=$(grep '^APP_DOMAIN=' /opt/libredecision/.env | cut -d= -f2)
|
||||||
|
ACME_EXIT=0
|
||||||
|
docker exec sonic-acme-1 /app/acme.sh \
|
||||||
|
--home /etc/acme.sh \
|
||||||
|
--issue -d "$DOMAIN" \
|
||||||
|
--webroot /usr/share/nginx/html \
|
||||||
|
--server letsencrypt \
|
||||||
|
--accountemail support+acme@asycn.io || ACME_EXIT=$?
|
||||||
|
if [ "$ACME_EXIT" -eq 0 ] || [ "$ACME_EXIT" -eq 2 ]; then
|
||||||
|
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/fullchain.cer /host/certs/$DOMAIN-cert.pem
|
||||||
|
docker exec sonic-acme-1 cp /etc/acme.sh/$DOMAIN/$DOMAIN.key /host/certs/$DOMAIN-key.pem
|
||||||
|
echo "TLS OK (acme exit $ACME_EXIT)"
|
||||||
|
elif docker exec sonic-acme-1 test -f "/host/certs/$DOMAIN-cert.pem"; then
|
||||||
|
echo "AVERTISSEMENT: emission ACME en echec (exit $ACME_EXIT) - certificat existant conserve"
|
||||||
|
echo " Cause vue le 12/08 : challenge HTTP-01 invalide en IPv6 (AAAA present mais webroot"
|
||||||
|
echo " non servi en v6) - corriger frontal/DNS avant l'expiration du certificat."
|
||||||
|
else
|
||||||
|
echo "ERREUR: acme.sh a echoue (exit $ACME_EXIT) et aucun certificat existant pour $DOMAIN"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Images construites localement dans la pipeline : pas de docker compose pull
|
||||||
|
- cd /opt/libredecision && docker compose up -d --remove-orphans
|
||||||
|
- cd /opt/libredecision && docker compose ps
|
||||||
|
|
||||||
|
# Vérification que les containers sont running
|
||||||
|
- name: test-deploy
|
||||||
|
image: docker:27-cli
|
||||||
|
depends_on:
|
||||||
|
- deploy
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- /opt/libredecision:/opt/libredecision
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' /opt/libredecision/.env | cut -d= -f2)
|
||||||
|
for SVC in backend frontend; do
|
||||||
|
STATUS=$(docker inspect --format '{{.State.Status}}' "$PROJECT-$SVC" 2>/dev/null || echo "absent")
|
||||||
|
echo "$PROJECT-$SVC : $STATUS"
|
||||||
|
[ "$STATUS" = "running" ] || { echo "FAIL: $PROJECT-$SVC non running"; exit 1; }
|
||||||
|
done
|
||||||
|
echo "PASS: tous les containers running"
|
||||||
|
|
||||||
|
- name: seed
|
||||||
|
image: docker:27-cli
|
||||||
|
depends_on:
|
||||||
|
- test-deploy
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- /opt/libredecision:/opt/libredecision
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
PROJECT=$(grep '^COMPOSE_PROJECT_NAME=' /opt/libredecision/.env | cut -d= -f2)
|
||||||
|
docker exec "$PROJECT-backend" python seed.py
|
||||||
|
echo "Seed terminée"
|
||||||
|
|
||||||
|
- name: healthcheck
|
||||||
|
image: alpine:3.20
|
||||||
|
depends_on:
|
||||||
|
- seed
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache --quiet curl
|
||||||
|
- |
|
||||||
|
SITE=$(grep '^APP_DOMAIN=' .env.deploy | cut -d= -f2)
|
||||||
|
TARGET="https://$SITE"
|
||||||
|
echo "Healthcheck $TARGET..."
|
||||||
|
MAX=60
|
||||||
|
i=0
|
||||||
|
until [ $i -ge $MAX ]; do
|
||||||
|
CODE=$(curl -sSo /dev/null -w "%{http_code}" "$TARGET" 2>/dev/null)
|
||||||
|
echo "Tentative $((i+1))/$MAX - HTTP $CODE"
|
||||||
|
if [ "$CODE" = "200" ] || [ "$CODE" = "301" ] || [ "$CODE" = "302" ]; then
|
||||||
|
echo "PASS: app repond sur $TARGET"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
i=$((i+1))
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
echo "FAIL: app ne repond pas apres 10 minutes"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: notify-failure
|
||||||
|
image: alpine:3.20
|
||||||
|
commands:
|
||||||
|
- 'echo "ECHEC pipeline #$CI_PIPELINE_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
|
||||||
|
when:
|
||||||
|
- status: failure
|
||||||
@@ -1,25 +1,101 @@
|
|||||||
# Glibredecision
|
# libreDecision
|
||||||
|
|
||||||
Plateforme de decisions collectives pour la communaute Duniter/G1.
|
Rendre possible la décision collective — en fonction du contexte, processus et modalités,
|
||||||
|
boîte à outils. **v3 : Rust + Svelte**, un chantier à la fois, chacun livré en production avant
|
||||||
|
le suivant (`docs/chantiers/README.md`).
|
||||||
|
**Référence du chantier en cours : `docs/chantiers/acte-certification/CHANTIER.md`** — toute
|
||||||
|
décision de comportement s'y adosse ; les décisions durables dans `docs/adr/`.
|
||||||
|
La maquette v2 (Nuxt + FastAPI) vit dans `maquette/`, **intacte** (Yvv la présente) — ne pas y
|
||||||
|
développer.
|
||||||
|
|
||||||
|
## Protocole de début de session
|
||||||
|
|
||||||
|
1. `git pull --rebase origin main` (puis la branche du chantier : `chantier/acte-certification`)
|
||||||
|
2. Lire `HANDOFF.md` (où on en est, ce qui attend Yvv)
|
||||||
|
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
|
- **Serveur** : Rust edition 2024, toolchain **1.99** épinglée (`rust-toolchain.toml`) ;
|
||||||
- Backend: `cd backend && uvicorn app.main:app --port 8002 --reload`
|
rustup installé sans PATH → `export PATH=$HOME/.cargo/bin:$PATH`. axum 0.8, sqlx 0.8
|
||||||
- Backend tests: `cd backend && pytest tests/ -v`
|
(SQLite, requêtes runtime + `migrate!`), tokio, reqwest rustls.
|
||||||
- Frontend: `cd frontend && npm run dev`
|
- **Interface** : SvelteKit 2 / Svelte 5 (runes), adapter-static SPA, vite 7, vitest,
|
||||||
- Frontend build: `cd frontend && npm run build`
|
Playwright ; npm. Polices Literata (textes) + Nunito (interface), fontsource.
|
||||||
- Migrations: `cd backend && alembic upgrade head`
|
- **Ports** : dev interface **3002** (strict, proxy `/api` → 8002) · serveur **8002** ·
|
||||||
- Docker: `docker compose -f docker/docker-compose.yml up`
|
serveur jetable des e2e **8092**.
|
||||||
|
- **Déploiement** : une image (`docker/app.Dockerfile`), Woodpecker `ci.open.us.org` dépôt
|
||||||
|
LPK/decision → sonic (fabio). `.woodpecker/app.yml` = le produit sur
|
||||||
|
**decision.librodrome.org** ; `.woodpecker/maquette.yml` = la maquette sur
|
||||||
|
**maquette.decision.librodrome.org** (le produit passe en premier et retire de la racine tout conteneur qui la revendique ; la maquette attend un déploiement réussi du produit).
|
||||||
|
Push sur `prod` (`gitea@git.open.us.org:EHV/decision.git`) branche `main` = déploiement.
|
||||||
|
|
||||||
## Conventions
|
## Structure
|
||||||
- French for UI text and documentation
|
|
||||||
- English for code (variable names, comments, docstrings)
|
```
|
||||||
- API versioned under `/api/v1/`
|
crates/
|
||||||
- Pydantic v2 for all schemas
|
ld-engine/ # PUR (zéro I/O) : threshold (formule inertielle, presets), edit (coquille vs fond,
|
||||||
- Async everywhere (SQLAlchemy, FastAPI)
|
# diff), assist (découpage, similarité, longueur), compose (adoption, aperçus)
|
||||||
- Ed25519 signatures for vote integrity
|
trustin-access/ # règles d'accès de trustinWallet : sees / opens / manages_access, groupes par défaut
|
||||||
|
ld-server/
|
||||||
|
migrations/ # schéma SQLite — additif seulement
|
||||||
|
src/ # lib.rs (routes, csrf, run) · config.rs (LD_*) · auth.rs (connect OAuth2, invitations,
|
||||||
|
# dev, bootstrap) · team.rs (liens d'équipe) · docs.rs (lecture, aperçus) · proposals.rs (écrire, voter, signaler,
|
||||||
|
# traduire) · adoption.rs (balayage, verrou) · publish.rs (file → git) · wot.rs (W
|
||||||
|
# sur la chaîne) · seed.rs · web.rs (sert web/build)
|
||||||
|
tests/api.rs # scénarios bout en bout
|
||||||
|
seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur)
|
||||||
|
web/src/
|
||||||
|
lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée
|
||||||
|
lib/components/ # Sidebar, DocPanel, VoteView, ProposeView, UnitBlock, DeepList, Seal, Stamp…
|
||||||
|
routes/ # / · connexion · invitation · equipe · groupes · g/[group] · g/[group]/docs/[doc] (lecteur,
|
||||||
|
# panneau dans l'URL ?onglet=&article=) · acces (trustinWallet) · decisions/mandats/
|
||||||
|
# protocoles (à venir)
|
||||||
|
web/e2e/ # Playwright C1–C12 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092
|
||||||
|
docker/ # app.Dockerfile · app-compose.yml (sonic)
|
||||||
|
.woodpecker/ # app.yml · maquette.yml
|
||||||
|
docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/
|
||||||
|
maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v1)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Données runtime — jamais écrasées
|
||||||
|
|
||||||
|
- **Production** : volume `lpk-libredecision-app_data` (`/data` : `libredecision.db`, `textes/`
|
||||||
|
= clone du dépôt publié, `keys/` = clé de publication, `bootstrap-admin.txt`). Jamais
|
||||||
|
`down -v`, jamais recréé.
|
||||||
|
- **Maquette** : volumes `lpk-decision-main_postgres-data` / `_ipfs-data` (inchangés).
|
||||||
|
- **Local** : `data/` (gitignoré) — le supprimer repart du seed. Les e2e utilisent `/tmp/ld-e2e`.
|
||||||
|
- Un document seedé n'est **jamais** réinstancié : dès qu'il existe en base, c'est de la donnée.
|
||||||
|
|
||||||
|
## Commandes — la boucle (tout vert = fait)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH=$HOME/.cargo/bin:$PATH
|
||||||
|
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
||||||
|
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 13 contrôles
|
||||||
|
|
||||||
|
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
|
||||||
|
cd web && npm run dev # :3002 à chaud
|
||||||
|
docker build -f docker/app.Dockerfile --target production . # rejoue toute la boucle
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions / pièges
|
||||||
|
|
||||||
|
- **UI français, code anglais** ; **trilingue** : aucune chaîne affichée en dur, tout passe par
|
||||||
|
`web/src/lib/i18n/fr.ts` puis en/es (test de parité + `FORBIDDEN_UI_TERMS`). Tutoyer ; le sujet
|
||||||
|
est la personne ou le groupe, jamais le système. Le français du texte voté fait foi.
|
||||||
|
- **Moteur pur** : une règle de vote / édition / composition s'écrit et se teste dans
|
||||||
|
`ld-engine` avant d'être câblée. Le serveur ne décide jamais l'accès seul : `trustin-access`.
|
||||||
|
- **Invariants** : une seule voix ne fait jamais passer un texte (C = 50 pour Monnaie libre) ;
|
||||||
|
une coquille ne demande pas de vote, un changement de fond si ; un signalement de
|
||||||
|
contradiction bloque ; l'inertie se vote au cran au-dessus ; le groupe d'essai ne publie jamais.
|
||||||
|
- **Écritures** : en-tête `x-requested-with: libredecision` obligatoire (CSRF) ; cookie
|
||||||
|
`ld_session`. Les tests d'API passent par `Client` (`tests/api.rs`).
|
||||||
|
- **Pièges vus** : `pkill -f target/debug/ld-server` tue son propre shell → `pkill -x ld-server` ;
|
||||||
|
un `from_secret` absent fait refuser tout le pipeline Woodpecker ; la route hôte `/*` de fabio
|
||||||
|
masque le webroot ACME → arrêter le service avant `acme.sh --issue` ; Playwright en locale
|
||||||
|
`fr-FR` (sinon l'interface part en anglais).
|
||||||
|
- **Design** : sans bordures, arrondis 16/12/20/14, ambiances `pierre` / `nuit`
|
||||||
|
(`data-mood`, posé avant le premier rendu), sceau hexagramme 井 #48 en SVG (`Seal`, `Stamp` —
|
||||||
|
jamais le glyphe, qui s'affiche mal). Mouvement réduit respecté.
|
||||||
|
- **Domaine** : decision.librodrome.org ; dépôt des textes `git.open.us.org/yvv/decision-depot-textes-reference`
|
||||||
|
(provisoire : puis un dépôt pour l'ouverture, puis le dépôt officiel pour les textes en vigueur).
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Contribuer à libreDecision
|
||||||
|
|
||||||
|
On travaille **un chantier à la fois**, livré complet et en production avant le suivant
|
||||||
|
(`docs/chantiers/README.md`). Le chantier en cours a sa fiche :
|
||||||
|
`docs/chantiers/acte-certification/CHANTIER.md` — toute décision de comportement s'y adosse,
|
||||||
|
ou propose de l'amender, argumentée.
|
||||||
|
|
||||||
|
Première fois ? Commence par `docs/chantiers/acte-certification/ONBOARDING.md`.
|
||||||
|
|
||||||
|
## Les branches — travailler à deux
|
||||||
|
|
||||||
|
```
|
||||||
|
main = la production. Tout push sur prod/main déploie decision.librodrome.org.
|
||||||
|
chantier/acte-certification = la branche commune du binôme (Yvv · Chiara), toujours verte.
|
||||||
|
chiara/<sujet>, yvv/<sujet> = une branche courte par sujet, partie de la branche du chantier.
|
||||||
|
```
|
||||||
|
|
||||||
|
1. `git switch chantier/acte-certification && git pull --rebase origin chantier/acte-certification`
|
||||||
|
2. `git switch -c chiara/<sujet>` — un sujet, une branche, quelques commits.
|
||||||
|
3. Boucle verte (ci-dessous), puis push de la branche et demande de fusion vers
|
||||||
|
`chantier/acte-certification` sur la forge (`git.open.us.org/yvv/decision`).
|
||||||
|
4. Le chantier passe dans `main` quand la boucle est verte et les contrôles d'usage passés.
|
||||||
|
|
||||||
|
On ne pousse jamais directement sur `main`, et jamais de `--force` sur une branche partagée.
|
||||||
|
|
||||||
|
## La boucle — une tâche n'est faite que si tout est vert
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH=$HOME/.cargo/bin:$PATH
|
||||||
|
cargo fmt --all --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
cd web
|
||||||
|
npm run check # svelte-check, zéro avertissement
|
||||||
|
npm test # vitest : markdown, parité des catalogues i18n, anti-lexique
|
||||||
|
npm run build
|
||||||
|
npm run e2e # Playwright : C1–C12 (bureau) + M1 (mobile), serveur jetable sur :8092
|
||||||
|
```
|
||||||
|
|
||||||
|
L'image Docker rejoue les mêmes portes : un contrôle rouge, pas d'image, pas de déploiement.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Interface en français, code en anglais** (identifiants, commentaires, messages de log).
|
||||||
|
- **Trilingue dès le départ** : toute chaîne affichée vit dans `web/src/lib/i18n/fr.ts`
|
||||||
|
(la source), puis `en.ts` et `es.ts`. Le test de parité refuse une clé manquante ou un
|
||||||
|
paramètre `{x}` qui diffère. Le texte voté est le français ; les traductions sont éditoriales.
|
||||||
|
- **Ton** : tutoyer la personne ; le sujet est la personne ou le collectif, jamais le système.
|
||||||
|
Mots interdits à l'écran (`FORBIDDEN_UI_TERMS`) : « triage », « verdict », « déléguer »…
|
||||||
|
- **Moteur pur** : `crates/ld-engine` n'a ni I/O, ni base, ni réseau. Toute règle de vote,
|
||||||
|
d'édition ou de composition s'y écrit et s'y teste d'abord.
|
||||||
|
- **Accès** : le serveur ne décide jamais seul qui voit quoi — il demande à `trustin-access`.
|
||||||
|
- **Données runtime** (`data/`, volume `lpk-libredecision-app_data`) : jamais écrasées, jamais
|
||||||
|
réinitialisées ; une migration ajoute, elle ne réécrit pas.
|
||||||
|
- **Design** : sans bordures (profondeur par fond et ombre), arrondis 16 / 12 / 20 / 14 px,
|
||||||
|
deux ambiances (pierre, nuit), Literata pour les textes, Nunito pour l'interface.
|
||||||
|
- **Commits** : en français, une intention par commit, `git add` avec chemins explicites.
|
||||||
|
|
||||||
|
## Signaler
|
||||||
|
|
||||||
|
**« Faire un retour »** (dans le menu de gauche, sur chaque page) envoie une note datée avec la
|
||||||
|
page, la langue et la taille de l'écran ; elles se lisent dans trustinWallet (`/acces`).
|
||||||
|
Pour un bug reproductible, une issue sur la forge avec les étapes.
|
||||||
Generated
+2742
File diff suppressed because it is too large
Load Diff
+22
@@ -0,0 +1,22 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "3"
|
||||||
|
members = ["crates/ld-engine", "crates/trustin-access", "crates/ld-server"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.3.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.90"
|
||||||
|
repository = "https://git.open.us.org/LPK/decision"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[workspace.lints.rust]
|
||||||
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
|
[workspace.lints.clippy]
|
||||||
|
all = { level = "warn", priority = -1 }
|
||||||
|
unwrap_used = "warn"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
# HANDOFF — libreDecision
|
||||||
|
|
||||||
|
> Dernière mise à jour : 2026-10-23, session orch du chantier 1.
|
||||||
|
|
||||||
|
## Où on en est
|
||||||
|
|
||||||
|
**Chantier 1 — Acte d'engagement Certification** : **en ligne** sur decision.librodrome.org,
|
||||||
|
version `744d157` depuis le 2026-10-22 (pipeline Woodpecker n° 32 ; avant : `fa8e8dc`, n° 30) ;
|
||||||
|
la maquette est sur maquette.decision.librodrome.org (certificat jusqu'au 19/01/2027). En ligne :
|
||||||
|
liens d'équipe, nouveau lien de connexion, noms uniques et mention « sur invitation », règles de
|
||||||
|
coquilles des relectures 7 à 10, publication vers `yvv/decision-depot-textes-reference` (état et
|
||||||
|
« Publier maintenant » dans `/acces`). Première publication le 2026-10-22 à 03 h 22 (commit
|
||||||
|
`4cc0fdd` : `monnaie-libre/acte-engagement-certification/` fr, en, es, document.json, README).
|
||||||
|
|
||||||
|
**Prêt, pas encore en ligne** (2026-10-23) : les sessions durent **jusqu'à ce qu'on les ferme**
|
||||||
|
(400 jours recomptés à chaque visite, au lieu de 30 jours fixes) ; dans `/acces`, « Fermer ses
|
||||||
|
sessions » par identité (appareil perdu, départ), jamais celle d'où l'on agit, avec le lien de
|
||||||
|
connexion encore en attente pour elle. Raison : une personne invitée n'a pas de clé Ğ1 pour
|
||||||
|
revenir, une session expirée lui coûtait un nouveau lien. Les sessions ouvertes pour 30 jours
|
||||||
|
passent à 400 jours à leur prochaine visite.
|
||||||
|
|
||||||
|
Incident du 2026-10-21 : Docker a redémarré sur sonic à 21 h 47 ; l'agent Woodpecker
|
||||||
|
`aya-woodpeckerci-local-agent-1` gardait l'ancienne socket (pipeline n° 31 : « Cannot connect to
|
||||||
|
the Docker daemon », rien construit). Remède : `docker restart aya-woodpeckerci-local-agent-1`
|
||||||
|
(fait avec l'accord d'Yvv), puis « Redémarrer » le pipeline dans l'interface Woodpecker.
|
||||||
|
|
||||||
|
- v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`.
|
||||||
|
- Boucle : Rust (fmt, clippy `-D warnings`, 71 tests dont 17 scénarios d'API et la
|
||||||
|
publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) ·
|
||||||
|
e2e 13 / 13 (C1–C12, M1). L'image Docker rejoue tout et a été essayée localement :
|
||||||
|
santé, interface, volume non-root, clé de publication, lien admin initial (0600, absent
|
||||||
|
des journaux), publication immédiate au démarrage, redémarrage sans commit superflu.
|
||||||
|
- Déploiement : `.woodpecker/app.yml` d'abord (le produit à la racine, qui retire de la
|
||||||
|
racine tout conteneur qui la revendique), puis `.woodpecker/maquette.yml` (la maquette part
|
||||||
|
sur maquette.decision.librodrome.org, seulement si le produit a réussi).
|
||||||
|
- Relectures indépendantes avant mise en ligne, chacune traitée avant de livrer : `1f9c241 :
|
||||||
|
ok`, `94907af : ok`, puis quatre « à reprendre » (`ac9c904`, `9203fd2`, `1eac6f8`, `6c0754a`),
|
||||||
|
toutes sur la même question : une « coquille » (sans vote, publiée) qui change un sens, puis
|
||||||
|
`fa8e8dc : ok` (mis en ligne), `c3d6bf8 : ok`, `fd19dbf : ok`, `753b522 : ok`, `8a32160 : ok`, `850f9c4 : ok`. La
|
||||||
|
règle est désormais simple et explicable : **une coquille touche les lettres et les espaces,
|
||||||
|
rien d'autre** (CHANTIER.md, ADR 0003). Autres points traités : ordre des workflows, anciens
|
||||||
|
conteneurs en `restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une
|
||||||
|
personne, une voix), lien d'invitation jamais vers une identité Ğ1, réservation du lien avant
|
||||||
|
usage, redirection après connexion, en-têtes anti-encadrement, cookies `__Host-`, retour
|
||||||
|
arrière automatique du déploiement (maquette au premier, version précédente ensuite), courses
|
||||||
|
entre correction et entrée en vigueur, syft épinglé.
|
||||||
|
|
||||||
|
## Ce qui attend Yvv
|
||||||
|
|
||||||
|
1. **Envoyer la demande à poka** : `docs/chantiers/acte-certification/DEMANDE-CONNECT.md`.
|
||||||
|
2. ~~Dépôt des textes~~ : fait le 2026-10-22 — `yvv/decision-depot-textes-reference` (privé), clé de
|
||||||
|
déploiement en écriture ajoutée par Yvv. À l'ouverture : rendre le dépôt public (Paramètres ›
|
||||||
|
Dépôt), ou le transférer ; changer de dépôt : `LD_PUBLISH_REMOTE` dans `docker/app-compose.yml`.
|
||||||
|
3. **Ouvrir trustinWallet** avec le lien administrateur initial (remis hors conversation, un
|
||||||
|
seul usage : il crée l'identité administratrice). Pour l'équipe : un **lien d'équipe**
|
||||||
|
(`/acces` → Liens d'équipe ; chaque personne qui l'ouvre a sa propre identité), ou une
|
||||||
|
invitation par personne. Pour soi sur un autre appareil : « Nouveau lien de connexion ».
|
||||||
|
4. Donner à Chiara un compte **git.open.us.org** collaboratrice de `yvv/decision`.
|
||||||
|
5. Décider : la **licence** du code ; le texte F1.2 du seed (annonce C = 0 et 30 jours).
|
||||||
|
|
||||||
|
## Pour reprendre
|
||||||
|
|
||||||
|
- **Premier geste de la session suivante** (Yvv, 2026-10-22 : « redonne cette consigne quand je
|
||||||
|
relance ce chantier ») : rappeler à Yvv le compte git.open.us.org de Chiara (collaboratrice
|
||||||
|
de `yvv/decision`) et les deux choix en attente, la licence du code et le texte F1.2 du seed.
|
||||||
|
- Lire `docs/chantiers/acte-certification/CHANTIER.md` puis `CONTROLES.md`.
|
||||||
|
- Les retours de Chiara arrivent dans `/acces` → Retours : chacun est un contrôle à repasser.
|
||||||
|
- Quand les identifiants connect arrivent : `DEMANDE-CONNECT.md` § « À la réponse ».
|
||||||
|
- Chantier suivant (après la mise en production et les retours) : Acte d'engagement forgeron.
|
||||||
|
- Restes des relectures, non bloquants, pour la prochaine série :
|
||||||
|
- `publish.rs` : motif `ls-remote` en `refs/heads/<branche>` (une branche `x/main` le trompe) ;
|
||||||
|
« Dernière publication » rattachée au dépôt affiché ; compter les documents en attente, pas
|
||||||
|
les lignes de la file ;
|
||||||
|
- mention « sur invitation » : en `kind <> 'g1'` (tout type futur) ; tests de ce qui s'affiche
|
||||||
|
(révision visible, signalement, bout en bout) ; auteur des retours dans `/acces` avec son type ;
|
||||||
|
- noms : sosies latins étendus et ligatures (ı, ł, œ, ß…), invitations créées par un
|
||||||
|
administrateur sans contrôle de nom ;
|
||||||
|
- `sbom-publish` échoue (curl 60 : certificat du serveur Dependency-Track refusé) — étape en
|
||||||
|
`failure: ignore`, déjà le cas avant la v3.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# libreDecision
|
||||||
|
|
||||||
|
Rendre possible la décision collective — en fonction du contexte, processus et modalités,
|
||||||
|
boîte à outils.
|
||||||
|
|
||||||
|
En ligne : **https://decision.librodrome.org** (le produit, v3) · la maquette v2, conservée pour
|
||||||
|
présenter le projet : https://maquette.decision.librodrome.org (`maquette/`).
|
||||||
|
|
||||||
|
## Manifeste
|
||||||
|
|
||||||
|
> Personne n'a jamais vécu en démocratie — alors nous la rêvons de jour, et nous la commençons
|
||||||
|
> à notre échelle. libreDecision attrape chaque décision en une phrase et propose le chemin le
|
||||||
|
> plus court : décide seul et trace-le, décide sous mandat, écoute puis décide, ou décidez
|
||||||
|
> ensemble — jamais plus lourd que l'enjeu. Ici on ne délègue pas, on mandate, et on reprend ;
|
||||||
|
> on n'est pas consulté, on s'instruit, puis on se prononce ; on ne s'affronte pas en deux
|
||||||
|
> camps, on règle des curseurs — et la position de chacun reste visible dans le faisceau, la
|
||||||
|
> médiane n'écrase personne, la dernière main est toujours humaine. Les concernés en premier
|
||||||
|
> lieu ont voix par construction, chacun voit pourquoi ; lever la main « ça me concerne » est
|
||||||
|
> un droit, pas une demande. Chaque décision dit ce qu'elle engage, ce qu'elle cherche à
|
||||||
|
> changer, et repasse l'épreuve du réel : rien n'est définitif, toute décision en vigueur garde
|
||||||
|
> sa poignée, se tromper est une donnée qu'on consigne. Les règles du jeu elles-mêmes — le
|
||||||
|
> Pacte — s'écrivent au fil de l'eau, par ceux qu'elles gouvernent, sous les boussoles
|
||||||
|
> Autonomie, Équilibre, Liaison. Du trio d'amis à la toile Ğ1 : le même geste, dix secondes,
|
||||||
|
> et l'observatoire qui apprend de nous. La ville change, le puits demeure.
|
||||||
|
|
||||||
|
## Les quatre outils
|
||||||
|
|
||||||
|
| Outil | Ce qu'il fait | État |
|
||||||
|
|---|---|---|
|
||||||
|
| **Décisions** | funnel assisté pour choisir les modalités, journal des décisions | à venir |
|
||||||
|
| **Docs de référence** | acte d'engagement, licence, charte, constitution… sous vote permanent | **actif** |
|
||||||
|
| **Mandats** | création et révocation de tout mandat | à venir |
|
||||||
|
| **Protocoles** | workflows sans organe statutaire, rôles tournants, automatisations | à venir |
|
||||||
|
|
||||||
|
Le produit avance **un chantier à la fois**, chacun livré complet et en production avant le
|
||||||
|
suivant — feuille de route : [`docs/chantiers/README.md`](docs/chantiers/README.md).
|
||||||
|
Chantier en cours : **Acte d'engagement Certification** (Monnaie libre), en binôme Yvv + Chiara.
|
||||||
|
|
||||||
|
## Comment marche un doc de référence
|
||||||
|
|
||||||
|
- On arrive sur **le texte en vigueur**, seul, lisible. Trois gestes contextuels : consulter le
|
||||||
|
vote, voir les propositions, proposer une modification.
|
||||||
|
- Chaque article est sous **vote permanent** : une proposition qui atteint son seuil (formule
|
||||||
|
inertielle Ğ1, plancher C de votants « pour ») **remonte en vigueur** et remplace la version
|
||||||
|
en place — si elle tient une marge plus large. Les textes de départ sont des *seeds a minima*,
|
||||||
|
à zéro vote.
|
||||||
|
- **Corriger n'est pas voter** : une coquille se corrige sans vote ; un changement de fond
|
||||||
|
ouvre une nouvelle révision, dont les votes antérieurs sont à reconfirmer.
|
||||||
|
- Deux propositions qui **se contredisent** ne passent pas ensemble : un signalement bloque la
|
||||||
|
seconde tant qu'il n'est pas levé.
|
||||||
|
- L'**inertie** de chaque article se vote aussi (par défaut : un cran au-dessus).
|
||||||
|
- Tout ce qui entre en vigueur est **publié automatiquement** dans un dépôt git
|
||||||
|
(fr/en/es + JSON) lu par les applications clientes.
|
||||||
|
|
||||||
|
Le détail, les décisions et leurs raisons : [`docs/chantiers/acte-certification/CHANTIER.md`](docs/chantiers/acte-certification/CHANTIER.md)
|
||||||
|
et [`docs/adr/`](docs/adr/).
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Serveur** : Rust (edition 2024, toolchain 1.99 épinglée) — axum 0.8, sqlx/SQLite, tokio.
|
||||||
|
Trois crates : `ld-engine` (moteur pur : seuils, édition, assistant, composition),
|
||||||
|
`trustin-access` (règles d'accès de trustinWallet), `ld-server` (API + publication + service
|
||||||
|
de l'interface).
|
||||||
|
- **Interface** : SvelteKit 2 / Svelte 5 (runes), SPA statique servie par le serveur ;
|
||||||
|
trilingue fr · en · es dès le départ (le français fait foi).
|
||||||
|
- **Déploiement** : une seule image Docker, Woodpecker → sonic (fabio), volume de données nommé.
|
||||||
|
|
||||||
|
## Démarrer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH=$HOME/.cargo/bin:$PATH
|
||||||
|
cd web && npm ci && npm run build && cd .. # l'interface compilée
|
||||||
|
LD_DEV_LOGIN=1 cargo run -p ld-server # API + interface sur :8002
|
||||||
|
cd web && npm run dev # interface à chaud sur :3002 (proxy /api → :8002)
|
||||||
|
```
|
||||||
|
|
||||||
|
`LD_DEV_LOGIN=1` ouvre une connexion de développement (sans clé, membre de tous les groupes) —
|
||||||
|
jamais en production. Toutes les variables : [`.env.example`](.env.example).
|
||||||
|
|
||||||
|
## Vérifier (la boucle)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
||||||
|
cd web && npm run check && npm test && npm run build && npm run e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Les contrôles d'usage (C1–C12, M1) sont décrits dans
|
||||||
|
[`docs/chantiers/acte-certification/CONTROLES.md`](docs/chantiers/acte-certification/CONTROLES.md) ;
|
||||||
|
les tests de Chiara en sont la version humaine.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/
|
||||||
|
ld-engine/ # moteur pur, sans I/O : threshold, edit, assist, compose
|
||||||
|
trustin-access/ # qui voit, qui ouvre, qui gère les accès
|
||||||
|
ld-server/ # axum : auth Ğ1 + invitations, docs, propositions, votes, adoption, publication
|
||||||
|
migrations/ # schéma SQLite (données runtime : jamais réécrites)
|
||||||
|
tests/api.rs # scénarios bout en bout de l'API
|
||||||
|
seeds/ # documents de départ (JSON trilingue), instanciés une seule fois par groupe
|
||||||
|
web/
|
||||||
|
src/lib/ # api, session, i18n (fr source, en, es), composants
|
||||||
|
src/routes/ # accueil, groupes, lecteur du texte, trustinWallet (/acces)…
|
||||||
|
e2e/ # Playwright : C1–C12, M1
|
||||||
|
docker/ # app.Dockerfile + app-compose.yml (sonic)
|
||||||
|
.woodpecker/ # app.yml (le produit) · maquette.yml (la maquette)
|
||||||
|
docs/ # chantiers/, adr/, dev/ (blueprint v2), recherche/
|
||||||
|
maquette/ # la v2 Nuxt + FastAPI, telle quelle
|
||||||
|
```
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Logiciel libre — la licence reste à choisir (question ouverte à Yvv) ; le fichier LICENSE sera
|
||||||
|
posé avant toute publication du code.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "ld-engine"
|
||||||
|
description = "Pure decision engines of libreDecision: inertial threshold, edit classification, editing assistant, document composition."
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
unicode-normalization = "0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
//! Rule-based editing assistant — no AI yet (Yvv, 2026-10-21: « D'abord des
|
||||||
|
//! règles simples, ensuite on mettra de l'IA, mais locale »).
|
||||||
|
//!
|
||||||
|
//! It never decides. It says, while the author writes:
|
||||||
|
//! - « this text seems to carry several decisions — split it? » with the parts;
|
||||||
|
//! - « this text is close to <unit> — make it a variant instead? »;
|
||||||
|
//! - « this text is long ».
|
||||||
|
//!
|
||||||
|
//! The local AI planned later plugs in behind the same `Hint` type.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||||
|
pub enum Hint {
|
||||||
|
/// Several decisions in one text: the suggested parts, in order.
|
||||||
|
Split { parts: Vec<String> },
|
||||||
|
/// Close to an existing unit or proposal.
|
||||||
|
Similar { target: String, score: f64 },
|
||||||
|
/// Longer than a unit usually is.
|
||||||
|
Long { chars: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const LONG_UNIT_CHARS: usize = 700;
|
||||||
|
pub const SIMILARITY_HINT: f64 = 0.45;
|
||||||
|
|
||||||
|
/// Markers of a commitment or a rule, folded (lowercase, no accents).
|
||||||
|
const DECISION_MARKERS: &[&str] = &[
|
||||||
|
"je m'engage",
|
||||||
|
"je m engage",
|
||||||
|
"j'ai ",
|
||||||
|
"je suis ",
|
||||||
|
"je me suis",
|
||||||
|
"je verifie",
|
||||||
|
"je ne ",
|
||||||
|
"doit ",
|
||||||
|
"doivent ",
|
||||||
|
"s'engage",
|
||||||
|
"est tenu",
|
||||||
|
"est interdit",
|
||||||
|
"obligatoire",
|
||||||
|
"il est interdit",
|
||||||
|
"verifiez",
|
||||||
|
"ne certifiez",
|
||||||
|
"contactez",
|
||||||
|
"assurez",
|
||||||
|
"i commit",
|
||||||
|
"i have ",
|
||||||
|
"must ",
|
||||||
|
"shall ",
|
||||||
|
"me comprometo",
|
||||||
|
"debe ",
|
||||||
|
];
|
||||||
|
|
||||||
|
const STOPWORDS: &[&str] = &[
|
||||||
|
"le", "la", "les", "un", "une", "des", "de", "du", "d", "l", "et", "ou", "a", "au", "aux", "en", "que", "qui",
|
||||||
|
"je", "j", "me", "m", "se", "s", "ce", "cet", "cette", "ces", "par", "pour", "sur", "dans", "avec", "est", "sont",
|
||||||
|
"the", "of", "and", "to", "in", "is", "el", "los", "las", "y", "que", "con", "por", "para",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn fold(s: &str) -> String {
|
||||||
|
s.nfd()
|
||||||
|
.filter(|c| !matches!(*c as u32, 0x0300..=0x036F))
|
||||||
|
.flat_map(char::to_lowercase)
|
||||||
|
.map(|c| if c == '’' { '\'' } else { c })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_decision(segment: &str) -> bool {
|
||||||
|
let f = fold(segment);
|
||||||
|
DECISION_MARKERS.iter().any(|m| f.contains(m))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paragraphs, list items: the natural cut lines of a modular text.
|
||||||
|
fn segments(text: &str) -> Vec<String> {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let flush = |current: &mut String, out: &mut Vec<String>| {
|
||||||
|
let t = current.trim();
|
||||||
|
if !t.is_empty() {
|
||||||
|
out.push(t.to_owned());
|
||||||
|
}
|
||||||
|
current.clear();
|
||||||
|
};
|
||||||
|
for line in text.lines() {
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
let is_item = trimmed.starts_with("- ")
|
||||||
|
|| trimmed.starts_with("* ")
|
||||||
|
|| trimmed
|
||||||
|
.split_once(['.', ')'])
|
||||||
|
.is_some_and(|(n, _)| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()));
|
||||||
|
if trimmed.is_empty() || is_item {
|
||||||
|
flush(&mut current, &mut out);
|
||||||
|
}
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
if !current.is_empty() {
|
||||||
|
current.push('\n');
|
||||||
|
}
|
||||||
|
current.push_str(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush(&mut current, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sentences of a single paragraph (split after . ! ? followed by a space).
|
||||||
|
fn sentences(paragraph: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut chars = paragraph.chars().peekable();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
current.push(c);
|
||||||
|
if matches!(c, '.' | '!' | '?') && chars.peek().is_some_and(|n| n.is_whitespace()) {
|
||||||
|
out.push(current.trim().to_owned());
|
||||||
|
current.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current.trim().is_empty() {
|
||||||
|
out.push(current.trim().to_owned());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parts to propose separately, or `None` when the text holds one decision.
|
||||||
|
pub fn split_suggestion(text: &str) -> Option<Vec<String>> {
|
||||||
|
let segs = segments(text);
|
||||||
|
if segs.len() >= 2 && segs.iter().filter(|s| is_decision(s)).count() >= 2 {
|
||||||
|
return Some(segs);
|
||||||
|
}
|
||||||
|
if segs.len() == 1 {
|
||||||
|
let sents = sentences(&segs[0]);
|
||||||
|
let committing = sents.iter().filter(|s| is_decision(s)).count();
|
||||||
|
if sents.len() >= 2 && committing >= 2 {
|
||||||
|
return Some(sents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_words(text: &str) -> Vec<String> {
|
||||||
|
fold(text)
|
||||||
|
.split(|c: char| !c.is_alphanumeric())
|
||||||
|
.filter(|w| w.chars().count() > 2 && !STOPWORDS.contains(w))
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jaccard similarity over content words and their bigrams.
|
||||||
|
pub fn similarity(a: &str, b: &str) -> f64 {
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
let grams = |t: &str| -> BTreeSet<String> {
|
||||||
|
let w = content_words(t);
|
||||||
|
let mut set: BTreeSet<String> = w.iter().cloned().collect();
|
||||||
|
set.extend(w.windows(2).map(|p| format!("{} {}", p[0], p[1])));
|
||||||
|
set
|
||||||
|
};
|
||||||
|
let (x, y) = (grams(a), grams(b));
|
||||||
|
if x.is_empty() || y.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let inter = x.intersection(&y).count() as f64;
|
||||||
|
let union = x.union(&y).count() as f64;
|
||||||
|
inter / union
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All hints for a draft, given the texts it may collide with (`(id, text)`).
|
||||||
|
pub fn hints(text: &str, others: &[(String, String)]) -> Vec<Hint> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if let Some(parts) = split_suggestion(text) {
|
||||||
|
out.push(Hint::Split { parts });
|
||||||
|
}
|
||||||
|
let mut close: Vec<(String, f64)> =
|
||||||
|
others.iter().map(|(id, t)| (id.clone(), similarity(text, t))).filter(|(_, s)| *s >= SIMILARITY_HINT).collect();
|
||||||
|
close.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||||
|
out.extend(close.into_iter().take(3).map(|(target, score)| Hint::Similar { target, score }));
|
||||||
|
let chars = text.chars().count();
|
||||||
|
if chars > LONG_UNIT_CHARS {
|
||||||
|
out.push(Hint::Long { chars });
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_commitments_in_one_text_are_flagged() {
|
||||||
|
let text = "Je m'engage sur l'honneur à n'avoir qu'un seul compte.\n\nJe m'engage à ne certifier que des personnes qui respectent cet engagement.";
|
||||||
|
let parts = split_suggestion(text).expect("two decisions");
|
||||||
|
assert_eq!(parts.len(), 2);
|
||||||
|
assert!(parts[0].starts_with("Je m'engage sur"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_commitments_in_one_paragraph() {
|
||||||
|
let text = "Je m'engage à vérifier la clé. Je m'engage aussi à rester joignable.";
|
||||||
|
assert_eq!(split_suggestion(text).map(|p| p.len()), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_list_of_rules_is_flagged_but_one_explained_commitment_is_not() {
|
||||||
|
let list = "- Vérifiez la clé avec la personne.\n- Contactez les certifieurs existants.";
|
||||||
|
assert_eq!(split_suggestion(list).map(|p| p.len()), Some(2));
|
||||||
|
let single = "Je m'engage sur l'honneur à n'avoir qu'un seul compte. C'est le socle de la toile.";
|
||||||
|
assert_eq!(split_suggestion(single), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preamble_like_text_is_not_split() {
|
||||||
|
let text = "Le présent acte définit les obligations réciproques des membres. Cet acte est l'unique relation contractuelle de notre toile.";
|
||||||
|
assert_eq!(split_suggestion(text), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn similarity_finds_a_variant() {
|
||||||
|
let current = "Je m'engage sur l'honneur à n'avoir et n'avoir jamais qu'un seul et unique compte cocréateur de monnaie G1.";
|
||||||
|
let variant = "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie G1.";
|
||||||
|
let other = "J'ai vérifié avec la personne certifiée qu'elle a généré son document de révocation.";
|
||||||
|
assert!(similarity(current, variant) >= SIMILARITY_HINT);
|
||||||
|
assert!(similarity(current, other) < 0.2);
|
||||||
|
let h = hints(variant, &[("E1".into(), current.into()), ("E6".into(), other.into())]);
|
||||||
|
assert!(matches!(&h[..], [Hint::Similar { target, .. }] if target == "E1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_text() {
|
||||||
|
let text = "mot ".repeat(200);
|
||||||
|
assert!(hints(&text, &[]).iter().any(|h| matches!(h, Hint::Long { .. })));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
//! Composition of a modular document under permanent vote.
|
||||||
|
//!
|
||||||
|
//! A document is an ordered list of **units** (preamble, articles, headings).
|
||||||
|
//! A unit is a slot: it may hold one version in force, or none yet. Every
|
||||||
|
//! **proposal** targets exactly one unit — one proposal, one decision — and is
|
||||||
|
//! one of: a text for the unit (new or replacing), its removal, or a change of
|
||||||
|
//! its inertia. Proposals on the same unit and the same dimension (content or
|
||||||
|
//! inertia) are rivals: at most one of them enters into force at a time.
|
||||||
|
//!
|
||||||
|
//! A rival replaces the version in force only if it reached its threshold
|
||||||
|
//! **and** holds a wider margin than the version in force (for − required).
|
||||||
|
//! The displaced version goes back among the proposals with its votes; it can
|
||||||
|
//! only return by widening its margin again — no oscillation. A seed a minima
|
||||||
|
//! (in force without any vote) has the margin of zero votes, so the first
|
||||||
|
//! rival that reaches its threshold replaces it.
|
||||||
|
//!
|
||||||
|
//! This module answers three questions, purely:
|
||||||
|
//! - which proposals enter into force now (`adoptions`);
|
||||||
|
//! - what the document would read if the well-rated, or all, proposals passed
|
||||||
|
//! (`preview`);
|
||||||
|
//! - which formula a proposal is judged by (`params_for`).
|
||||||
|
|
||||||
|
use crate::threshold::{FormulaParams, InertiaPreset, Tally, ThresholdError, evaluate};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct VersionText {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
/// Votes still cast on the version in force (permanent vote).
|
||||||
|
#[serde(default)]
|
||||||
|
pub tally: Tally,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct UnitState {
|
||||||
|
pub id: String,
|
||||||
|
pub position: f64,
|
||||||
|
pub inertia: InertiaPreset,
|
||||||
|
pub in_force: Option<VersionText>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", content = "inertia", rename_all = "lowercase")]
|
||||||
|
pub enum ProposalKind {
|
||||||
|
/// A text for the unit: an addition if the unit is empty, a replacement otherwise.
|
||||||
|
Text,
|
||||||
|
/// Remove the unit from the text in force.
|
||||||
|
Remove,
|
||||||
|
/// Change the inertia of the unit.
|
||||||
|
Inertia(InertiaPreset),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProposalKind {
|
||||||
|
fn is_content(self) -> bool {
|
||||||
|
!matches!(self, Self::Inertia(_))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ProposalState {
|
||||||
|
pub id: String,
|
||||||
|
pub unit_id: String,
|
||||||
|
pub kind: ProposalKind,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub tally: Tally,
|
||||||
|
/// Sortable submission stamp (RFC 3339): the earlier wins a perfect tie.
|
||||||
|
pub submitted_at: String,
|
||||||
|
/// An active contradiction signal forbids entering into force.
|
||||||
|
pub blocked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A change of inertia is voted one level above the unit (« inertie + »).
|
||||||
|
pub fn params_for(unit: &UnitState, kind: ProposalKind, floor: f64) -> FormulaParams {
|
||||||
|
let preset = match kind {
|
||||||
|
ProposalKind::Inertia(_) => unit.inertia.raised(),
|
||||||
|
ProposalKind::Text | ProposalKind::Remove => unit.inertia,
|
||||||
|
};
|
||||||
|
FormulaParams::for_inertia(preset, floor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A proposal that changes nothing cannot enter into force.
|
||||||
|
fn is_effective(unit: &UnitState, p: &ProposalState) -> bool {
|
||||||
|
match p.kind {
|
||||||
|
ProposalKind::Text => unit.in_force.as_ref().is_none_or(|v| v.title != p.title || v.body != p.body),
|
||||||
|
ProposalKind::Remove => unit.in_force.is_some(),
|
||||||
|
ProposalKind::Inertia(target) => target != unit.inertia,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct Scored<'a> {
|
||||||
|
proposal: &'a ProposalState,
|
||||||
|
margin: i64,
|
||||||
|
reached: bool,
|
||||||
|
well_rated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score<'a>(
|
||||||
|
unit: &UnitState,
|
||||||
|
p: &'a ProposalState,
|
||||||
|
electorate: u32,
|
||||||
|
floor: f64,
|
||||||
|
) -> Result<Scored<'a>, ThresholdError> {
|
||||||
|
let e = evaluate(p.tally, electorate, ¶ms_for(unit, p.kind, floor))?;
|
||||||
|
Ok(Scored {
|
||||||
|
proposal: p,
|
||||||
|
margin: i64::from(p.tally.for_votes) - i64::from(e.required),
|
||||||
|
reached: e.reached,
|
||||||
|
well_rated: e.well_rated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best of rivals: widest margin, then most *for* votes, then earliest.
|
||||||
|
fn best<'a>(mut rivals: Vec<Scored<'a>>) -> Option<&'a ProposalState> {
|
||||||
|
rivals.sort_by(|a, b| {
|
||||||
|
b.margin
|
||||||
|
.cmp(&a.margin)
|
||||||
|
.then(b.proposal.tally.for_votes.cmp(&a.proposal.tally.for_votes))
|
||||||
|
.then(a.proposal.submitted_at.cmp(&b.proposal.submitted_at))
|
||||||
|
.then(a.proposal.id.cmp(&b.proposal.id))
|
||||||
|
});
|
||||||
|
rivals.first().map(|s| s.proposal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Margin of the version in force, judged like a text proposal on its unit.
|
||||||
|
fn in_force_margin(unit: &UnitState, electorate: u32, floor: f64) -> Result<Option<i64>, ThresholdError> {
|
||||||
|
let Some(v) = &unit.in_force else { return Ok(None) };
|
||||||
|
let e = evaluate(v.tally, electorate, ¶ms_for(unit, ProposalKind::Text, floor))?;
|
||||||
|
Ok(Some(i64::from(v.tally.for_votes) - i64::from(e.required)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The proposals that enter into force now: per unit and dimension, the best
|
||||||
|
/// of those that reached their threshold, are effective and not blocked — and,
|
||||||
|
/// for content, hold a wider margin than the version in force.
|
||||||
|
pub fn adoptions(
|
||||||
|
units: &[UnitState],
|
||||||
|
proposals: &[ProposalState],
|
||||||
|
electorate: u32,
|
||||||
|
floor: f64,
|
||||||
|
) -> Result<Vec<String>, ThresholdError> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for unit in units {
|
||||||
|
let incumbent = in_force_margin(unit, electorate, floor)?;
|
||||||
|
for content in [true, false] {
|
||||||
|
let mut rivals = Vec::new();
|
||||||
|
for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content() == content) {
|
||||||
|
if p.blocked || !is_effective(unit, p) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let s = score(unit, p, electorate, floor)?;
|
||||||
|
let beats_incumbent = !content || incumbent.is_none_or(|m| s.margin > m);
|
||||||
|
if s.reached && beats_incumbent {
|
||||||
|
rivals.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(winner) = best(rivals) {
|
||||||
|
out.push(winner.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum PreviewMode {
|
||||||
|
/// Only the in-force text.
|
||||||
|
InForce,
|
||||||
|
/// As if every well-rated proposal passed (« si les bien cotées passent »).
|
||||||
|
WellRated,
|
||||||
|
/// As if every proposal passed, whatever its rating (« si tout passe »).
|
||||||
|
All,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum PreviewMark {
|
||||||
|
Unchanged,
|
||||||
|
Added,
|
||||||
|
Replaced,
|
||||||
|
Removed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PreviewUnit {
|
||||||
|
pub unit_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub mark: PreviewMark,
|
||||||
|
/// The proposal that would bring the change.
|
||||||
|
pub proposal_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The document as it would read. Removed units are kept with their mark so
|
||||||
|
/// the screen can show them struck through.
|
||||||
|
pub fn preview(
|
||||||
|
units: &[UnitState],
|
||||||
|
proposals: &[ProposalState],
|
||||||
|
electorate: u32,
|
||||||
|
floor: f64,
|
||||||
|
mode: PreviewMode,
|
||||||
|
) -> Result<Vec<PreviewUnit>, ThresholdError> {
|
||||||
|
let mut ordered: Vec<&UnitState> = units.iter().collect();
|
||||||
|
ordered.sort_by(|a, b| a.position.total_cmp(&b.position).then(a.id.cmp(&b.id)));
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for unit in ordered {
|
||||||
|
let chosen = if mode == PreviewMode::InForce {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let mut rivals = Vec::new();
|
||||||
|
for p in proposals.iter().filter(|p| p.unit_id == unit.id && p.kind.is_content()) {
|
||||||
|
if !is_effective(unit, p) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let s = score(unit, p, electorate, floor)?;
|
||||||
|
if mode == PreviewMode::All || s.well_rated {
|
||||||
|
rivals.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == PreviewMode::All {
|
||||||
|
// Whatever its rating: the most supported, then the earliest.
|
||||||
|
for r in &mut rivals {
|
||||||
|
r.margin = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best(rivals)
|
||||||
|
};
|
||||||
|
match (chosen, &unit.in_force) {
|
||||||
|
(Some(p), current) => {
|
||||||
|
let mark = match (p.kind, current) {
|
||||||
|
(ProposalKind::Remove, _) => PreviewMark::Removed,
|
||||||
|
(_, Some(_)) => PreviewMark::Replaced,
|
||||||
|
(_, None) => PreviewMark::Added,
|
||||||
|
};
|
||||||
|
let (title, body) = match (p.kind, current) {
|
||||||
|
(ProposalKind::Remove, Some(v)) => (v.title.clone(), v.body.clone()),
|
||||||
|
_ => (p.title.clone(), p.body.clone()),
|
||||||
|
};
|
||||||
|
out.push(PreviewUnit { unit_id: unit.id.clone(), title, body, mark, proposal_id: Some(p.id.clone()) });
|
||||||
|
}
|
||||||
|
(None, Some(v)) => out.push(PreviewUnit {
|
||||||
|
unit_id: unit.id.clone(),
|
||||||
|
title: v.title.clone(),
|
||||||
|
body: v.body.clone(),
|
||||||
|
mark: PreviewMark::Unchanged,
|
||||||
|
proposal_id: None,
|
||||||
|
}),
|
||||||
|
(None, None) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn unit(id: &str, position: f64, in_force: Option<&str>) -> UnitState {
|
||||||
|
UnitState {
|
||||||
|
id: id.into(),
|
||||||
|
position,
|
||||||
|
inertia: InertiaPreset::Standard,
|
||||||
|
in_force: in_force.map(|b| VersionText {
|
||||||
|
id: format!("{id}-v"),
|
||||||
|
title: id.into(),
|
||||||
|
body: b.into(),
|
||||||
|
tally: Tally::default(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prop(id: &str, unit_id: &str, kind: ProposalKind, body: &str, for_votes: u32, against: u32) -> ProposalState {
|
||||||
|
ProposalState {
|
||||||
|
id: id.into(),
|
||||||
|
unit_id: unit_id.into(),
|
||||||
|
kind,
|
||||||
|
title: unit_id.into(),
|
||||||
|
body: body.into(),
|
||||||
|
tally: Tally { for_votes, against },
|
||||||
|
submitted_at: format!("2026-10-21T00:00:0{}Z", id.len() % 10),
|
||||||
|
blocked: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const W: u32 = 5745;
|
||||||
|
const C: f64 = 50.0;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_passes_at_zero_votes() {
|
||||||
|
let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None)];
|
||||||
|
let props = [prop("p1", "E3", ProposalKind::Text, "connaître", 0, 0)];
|
||||||
|
assert!(adoptions(&units, &props, W, C).expect("valid").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_floor_holds_then_releases() {
|
||||||
|
let units = [unit("E3", 7.0, None)];
|
||||||
|
let few = [prop("p1", "E3", ProposalKind::Text, "connaître", 49, 0)];
|
||||||
|
assert!(adoptions(&units, &few, W, C).expect("valid").is_empty());
|
||||||
|
let enough = [prop("p1", "E3", ProposalKind::Text, "connaître", 50, 0)];
|
||||||
|
assert_eq!(adoptions(&units, &enough, W, C).expect("valid"), vec!["p1".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rivals_on_one_unit_yield_one_winner() {
|
||||||
|
let units = [unit("E3", 7.0, None)];
|
||||||
|
let props = [
|
||||||
|
prop("a", "E3", ProposalKind::Text, "variante a", 60, 0),
|
||||||
|
prop("bb", "E3", ProposalKind::Text, "variante b", 70, 0),
|
||||||
|
];
|
||||||
|
assert_eq!(adoptions(&units, &props, W, C).expect("valid"), vec!["bb".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inertia_and_content_are_independent_dimensions() {
|
||||||
|
let units = [unit("E1", 4.0, Some("unicité"))];
|
||||||
|
let props = [
|
||||||
|
prop("t", "E1", ProposalKind::Text, "unicité reformulée", 60, 0),
|
||||||
|
prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 60, 0),
|
||||||
|
];
|
||||||
|
let won = adoptions(&units, &props, W, C).expect("valid");
|
||||||
|
assert_eq!(won, vec!["t".to_string(), "i".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inertia_change_is_voted_one_level_up() {
|
||||||
|
let u = unit("E1", 4.0, Some("x"));
|
||||||
|
let p = params_for(&u, ProposalKind::Inertia(InertiaPreset::Low), C);
|
||||||
|
assert_eq!(p.majority_pct, InertiaPreset::High.majority_pct());
|
||||||
|
assert_eq!(p.gradient, InertiaPreset::High.gradient());
|
||||||
|
assert_eq!(params_for(&u, ProposalKind::Text, C).gradient, InertiaPreset::Standard.gradient());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocked_or_ineffective_never_pass() {
|
||||||
|
let units = [unit("E1", 4.0, Some("unicité"))];
|
||||||
|
let mut blocked = prop("t", "E1", ProposalKind::Text, "autre", 80, 0);
|
||||||
|
blocked.blocked = true;
|
||||||
|
let same = prop("s", "E1", ProposalKind::Text, "unicité", 80, 0);
|
||||||
|
let same_inertia = prop("i", "E1", ProposalKind::Inertia(InertiaPreset::Standard), "", 80, 0);
|
||||||
|
assert!(adoptions(&units, &[blocked, same, same_inertia], W, C).expect("valid").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rival_must_beat_the_version_in_force() {
|
||||||
|
let mut units = [unit("E1", 4.0, Some("unicité"))];
|
||||||
|
// The seed a minima has no vote: the first rival that reaches its threshold wins.
|
||||||
|
let rival = [prop("r", "E1", ProposalKind::Text, "unicité reformulée", 50, 0)];
|
||||||
|
assert_eq!(adoptions(&units, &rival, W, C).expect("valid"), vec!["r".to_string()]);
|
||||||
|
// A version in force by vote, margin 60 − 50 = 10: a rival at margin 5 waits.
|
||||||
|
if let Some(v) = units[0].in_force.as_mut() {
|
||||||
|
v.tally = Tally { for_votes: 60, against: 0 };
|
||||||
|
}
|
||||||
|
let weaker = [prop("w", "E1", ProposalKind::Text, "autre", 55, 0)];
|
||||||
|
assert!(adoptions(&units, &weaker, W, C).expect("valid").is_empty());
|
||||||
|
let stronger = [prop("s", "E1", ProposalKind::Text, "autre", 70, 0)];
|
||||||
|
assert_eq!(adoptions(&units, &stronger, W, C).expect("valid"), vec!["s".to_string()]);
|
||||||
|
// The same rule holds for a removal; inertia does not compete with the text.
|
||||||
|
let removal = [prop("x", "E1", ProposalKind::Remove, "", 55, 0)];
|
||||||
|
assert!(adoptions(&units, &removal, W, C).expect("valid").is_empty());
|
||||||
|
let inertia = [prop("i", "E1", ProposalKind::Inertia(InertiaPreset::High), "", 55, 0)];
|
||||||
|
assert_eq!(adoptions(&units, &inertia, W, C).expect("valid"), vec!["i".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn previews() {
|
||||||
|
let units = [unit("I1", 1.0, Some("préambule")), unit("E3", 7.0, None), unit("E1", 4.0, Some("unicité"))];
|
||||||
|
let props = [
|
||||||
|
prop("well", "E3", ProposalKind::Text, "connaître", 3, 0),
|
||||||
|
prop("weak", "E1", ProposalKind::Remove, "", 1, 5),
|
||||||
|
];
|
||||||
|
let in_force = preview(&units, &props, W, C, PreviewMode::InForce).expect("valid");
|
||||||
|
assert_eq!(in_force.iter().map(|u| u.unit_id.as_str()).collect::<Vec<_>>(), ["I1", "E1"]);
|
||||||
|
|
||||||
|
let likely = preview(&units, &props, W, C, PreviewMode::WellRated).expect("valid");
|
||||||
|
assert_eq!(
|
||||||
|
likely.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(),
|
||||||
|
[("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Unchanged), ("E3", PreviewMark::Added),]
|
||||||
|
);
|
||||||
|
|
||||||
|
let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid");
|
||||||
|
assert_eq!(
|
||||||
|
all.iter().map(|u| (u.unit_id.as_str(), u.mark)).collect::<Vec<_>>(),
|
||||||
|
[("I1", PreviewMark::Unchanged), ("E1", PreviewMark::Removed), ("E3", PreviewMark::Added),]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_mode_prefers_the_most_supported_rival() {
|
||||||
|
let units = [unit("E3", 7.0, None)];
|
||||||
|
let props = [
|
||||||
|
prop("a", "E3", ProposalKind::Text, "variante a", 0, 0),
|
||||||
|
prop("bb", "E3", ProposalKind::Text, "variante b", 1, 3),
|
||||||
|
];
|
||||||
|
let all = preview(&units, &props, W, C, PreviewMode::All).expect("valid");
|
||||||
|
assert_eq!(all[0].proposal_id.as_deref(), Some("bb"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
//! Edit classification — a typo fix is not a decision.
|
||||||
|
//!
|
||||||
|
//! Once a proposal is under vote, its author may still correct it. The engine
|
||||||
|
//! tells two kinds of corrections apart:
|
||||||
|
//!
|
||||||
|
//! - **typographic**: letters and spaces only — accents, case, the style of
|
||||||
|
//! apostrophes and quotes, words glued or split, or at most two misspelt
|
||||||
|
//! words fixed by a single slip each (one letter added, removed or swapped
|
||||||
|
//! in the middle of the word, never on a negation or a quantity). Never a
|
||||||
|
//! punctuation mark, a figure, a sign, an emphasis, a formula or an address.
|
||||||
|
//! The votes stay; the correction is shown with its diff.
|
||||||
|
//! - **substantive**: anything else. It opens a new revision and every vote
|
||||||
|
//! cast on the previous one waits for its author to reconfirm it.
|
||||||
|
//!
|
||||||
|
//! The rule is deliberately conservative: when in doubt, it is substantive.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum EditKind {
|
||||||
|
Unchanged,
|
||||||
|
Typographic,
|
||||||
|
Substantive,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Words whose presence flips a meaning; touching one is never a typo.
|
||||||
|
const NEGATIONS: &[&str] = &[
|
||||||
|
// fr
|
||||||
|
"ne", "n", "pas", "jamais", "non", "sans", "aucun", "aucune", "ni", "plus", "rien", "nul", "nulle", "personne",
|
||||||
|
"guere", "point", // en
|
||||||
|
"not", "no", "never", "without", "none", "nor", "nothing", "nobody", "nowhere", "neither", // es
|
||||||
|
"nunca", "sin", "ningun", "ninguna", "ninguno", "tampoco", "nada", "nadie", "jamas", "ni",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Words that carry a quantity, a scope or a relation (folded); changing one is
|
||||||
|
/// never a typo.
|
||||||
|
#[rustfmt::skip]
|
||||||
|
const QUANTITIES: &[&str] = &[
|
||||||
|
// fr
|
||||||
|
"un", "une", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf", "dix", "onze", "douze", "treize",
|
||||||
|
"quatorze", "quinze", "seize", "vingt", "trente", "quarante", "cinquante", "soixante", "septante", "huitante",
|
||||||
|
"octante", "nonante", "cent", "cents", "mille", "million", "millions", "milliard", "milliards", "premier",
|
||||||
|
"premiere", "premiers", "premieres", "second", "seconde", "deuxieme", "troisieme", "dernier", "derniere", "double",
|
||||||
|
"triple", "moitie", "tiers", "quart", "moins", "minimum", "maximum", "majorite", "minorite", "unanimite", "tous",
|
||||||
|
"toutes", "tout", "toute", "chaque", "seul", "seule", "seuls", "seules", "dessus", "dessous", "superieur",
|
||||||
|
"superieure", "inferieur", "inferieure", "avant", "apres", "sous", "entre", "hors", "sauf", "inclus", "exclus",
|
||||||
|
// en
|
||||||
|
"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "twenty",
|
||||||
|
"thirty", "forty", "fifty", "sixty", "hundred", "thousand", "million", "first", "second", "third", "last", "half",
|
||||||
|
"twice", "less", "more", "least", "most", "majority", "minority", "all", "every", "each", "only", "above", "below",
|
||||||
|
"over", "under", "before", "after", "within", "except",
|
||||||
|
// es
|
||||||
|
"uno", "dos", "tres", "cuatro", "cinco", "seis", "siete", "ocho", "nueve", "diez", "once", "doce", "veinte",
|
||||||
|
"treinta", "cuarenta", "cincuenta", "cien", "ciento", "mil", "millon", "primero", "primera", "segundo", "segunda",
|
||||||
|
"ultimo", "ultima", "mitad", "doble", "menos", "mas", "minimo", "maximo", "mayoria", "minoria", "todos", "todas",
|
||||||
|
"todo", "toda", "cada", "solo", "sola", "encima", "debajo", "antes", "despues", "sobre", "bajo", "excepto",
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX_TYPO_WORDS: usize = 2;
|
||||||
|
/// Shorter words have too many neighbours one letter away ("can" / "cant").
|
||||||
|
const MIN_TYPO_WORD: usize = 5;
|
||||||
|
/// Letters at each end of a word that a slip never touches: the start carries
|
||||||
|
/// prefixes ("valide" / "invalide"), the end carries number, gender, tense and
|
||||||
|
/// mood ("certifierai" / "certifierais", "será" / "sería").
|
||||||
|
const FIXED_ENDS: usize = 2;
|
||||||
|
|
||||||
|
/// Lowercase, strip diacritics.
|
||||||
|
fn fold(s: &str) -> String {
|
||||||
|
s.nfd().filter(|c| !is_combining(*c)).flat_map(char::to_lowercase).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_combining(c: char) -> bool {
|
||||||
|
matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Letters and digits only, folded: what survives every typographic change.
|
||||||
|
fn skeleton(s: &str) -> String {
|
||||||
|
fold(s).chars().filter(|c| c.is_alphanumeric()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A letter of the Latin alphabet as French, English and Spanish write it,
|
||||||
|
/// precomposed accents included (after NFC, Latin-1 and Latin Extended-A): the
|
||||||
|
/// only letters a typo fix may touch. Any other letter may be a look-alike
|
||||||
|
/// (Cyrillic « а », « ǃ » for « ! », « Ʒ » for « 3 ») or reorder the display
|
||||||
|
/// (Hebrew, Arabic): it is kept as it is.
|
||||||
|
fn is_latin_letter(c: char) -> bool {
|
||||||
|
c.is_alphabetic() && matches!(c as u32, 0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x17F)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The only spaces free to change between words; any other (carriage return,
|
||||||
|
/// tab, hair, thin, ogham…) is kept as it is, and so are line breaks and the
|
||||||
|
/// indentation that opens a line: in Markdown they make lists and paragraphs.
|
||||||
|
const FREE_SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}'];
|
||||||
|
|
||||||
|
/// Invisible characters: the Unicode default-ignorable set (zero-width,
|
||||||
|
/// bidirectional overrides and isolates, soft hyphen, variation selectors,
|
||||||
|
/// tags, byte order mark…) and control characters other than line breaks and
|
||||||
|
/// tabs (an escape sequence rewrites a terminal). They can make "12" display as
|
||||||
|
/// "21" ("Trojan Source") or hide text in the published files. A text of
|
||||||
|
/// reference never needs them; they are refused.
|
||||||
|
pub fn has_hidden_controls(s: &str) -> bool {
|
||||||
|
s.chars().any(|c| {
|
||||||
|
(c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
|
||||||
|
|| matches!(
|
||||||
|
c as u32,
|
||||||
|
0x00AD
|
||||||
|
| 0x034F
|
||||||
|
| 0x061C
|
||||||
|
| 0x115F..=0x1160
|
||||||
|
| 0x17B4..=0x17B5
|
||||||
|
| 0x180B..=0x180F
|
||||||
|
| 0x200B..=0x200F
|
||||||
|
| 0x202A..=0x202E
|
||||||
|
| 0x2060..=0x206F
|
||||||
|
| 0x3164
|
||||||
|
| 0xFE00..=0xFE0F
|
||||||
|
| 0xFEFF
|
||||||
|
| 0xFFA0
|
||||||
|
| 0xFFF0..=0xFFF8
|
||||||
|
| 0x1BCA0..=0x1BCA3
|
||||||
|
| 0x1D173..=0x1D17A
|
||||||
|
| 0xE0000..=0xE0FFF
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apostrophes and quotation marks of every style are one and the same sign:
|
||||||
|
/// « Je m'engage » → « Je m’engage » is typography.
|
||||||
|
fn same_mark(c: char) -> char {
|
||||||
|
match c {
|
||||||
|
'’' | '‘' | 'ʼ' => '\'',
|
||||||
|
'“' | '”' | '«' | '»' => '"',
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shape of a text: every run of Latin letters becomes one mark, free spaces
|
||||||
|
/// vanish, and **everything else stays as it is, in order** — punctuation,
|
||||||
|
/// figures, signs, emphasis, brackets, look-alikes, other alphabets. A typo fix
|
||||||
|
/// keeps the shape: it touches letters and spaces only (2026-10-21, after six
|
||||||
|
/// independent reviews showed that any free punctuation can carry a meaning:
|
||||||
|
/// a minus sign before a bold number, a list merged into one condition…).
|
||||||
|
fn shape(s: &str) -> String {
|
||||||
|
let cs: Vec<char> = s.nfc().collect();
|
||||||
|
let is_figure = |i: Option<usize>| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric());
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut in_word = false;
|
||||||
|
let mut line_start = true;
|
||||||
|
for (i, &c) in cs.iter().enumerate() {
|
||||||
|
if c == '\n' || (line_start && c.is_whitespace()) {
|
||||||
|
// Line breaks and indentation are structure: « - a\n- b » is a list.
|
||||||
|
out.push(c);
|
||||||
|
in_word = false;
|
||||||
|
line_start = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
line_start = false;
|
||||||
|
if is_latin_letter(c) {
|
||||||
|
if !in_word {
|
||||||
|
out.push('a');
|
||||||
|
}
|
||||||
|
in_word = true;
|
||||||
|
} else if FREE_SPACES.contains(&c) && !is_figure(i.checked_sub(1)) && !is_figure(Some(i + 1)) {
|
||||||
|
// A space between letters may come and go: « latransmission » and
|
||||||
|
// « la transmission » share a shape (segmentation is checked apart).
|
||||||
|
// Next to a figure it stays: « 1 5 » is not « 15 ».
|
||||||
|
} else {
|
||||||
|
out.push(same_mark(c));
|
||||||
|
in_word = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What stands between backticks: formulas and identifiers, compared exactly.
|
||||||
|
fn code_spans(s: &str) -> Vec<&str> {
|
||||||
|
s.split('`').skip(1).step_by(2).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Addresses (links, domains, mails, paths) and identifiers that hold a figure
|
||||||
|
/// (keys, fingerprints, references): compared exactly, case included.
|
||||||
|
fn exact_tokens(s: &str) -> Vec<String> {
|
||||||
|
s.split_whitespace()
|
||||||
|
.map(|t| t.trim_matches(|c: char| !c.is_alphanumeric()))
|
||||||
|
.filter(|t| {
|
||||||
|
let cs: Vec<char> = t.chars().collect();
|
||||||
|
t.contains("://")
|
||||||
|
|| t.contains('@')
|
||||||
|
|| cs.iter().any(|c| c.is_numeric()) && cs.iter().any(|c| c.is_alphabetic())
|
||||||
|
|| (1..cs.len().saturating_sub(1))
|
||||||
|
.any(|i| matches!(cs[i], '.' | '/') && cs[i - 1].is_alphanumeric() && cs[i + 1].is_alphanumeric())
|
||||||
|
})
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single slip of the keyboard inside a word: one letter added, removed, or
|
||||||
|
/// two neighbours swapped — never at either end (see `FIXED_ENDS`), and never a
|
||||||
|
/// letter replaced by another ("deux" / "dix", "majorité" / "minorité").
|
||||||
|
fn one_slip(x: &str, y: &str) -> bool {
|
||||||
|
let (a, b): (Vec<char>, Vec<char>) = (x.chars().collect(), y.chars().collect());
|
||||||
|
if a.len().min(b.len()) < MIN_TYPO_WORD {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let inside = |i: usize, len: usize| i >= FIXED_ENDS && i + FIXED_ENDS < len;
|
||||||
|
match a.len().abs_diff(b.len()) {
|
||||||
|
1 => {
|
||||||
|
let (short, long) = if a.len() < b.len() { (&a, &b) } else { (&b, &a) };
|
||||||
|
(0..long.len()).any(|i| {
|
||||||
|
let mut l = long.clone();
|
||||||
|
l.remove(i);
|
||||||
|
inside(i, long.len()) && &l == short
|
||||||
|
})
|
||||||
|
}
|
||||||
|
0 => {
|
||||||
|
let at: Vec<usize> = (0..a.len()).filter(|&i| a[i] != b[i]).collect();
|
||||||
|
at.len() == 2
|
||||||
|
&& at[1] == at[0] + 1
|
||||||
|
&& a[at[0]] == b[at[1]]
|
||||||
|
&& a[at[1]] == b[at[0]]
|
||||||
|
&& inside(at[0], a.len())
|
||||||
|
&& inside(at[1], a.len())
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folded words (runs of letters/digits).
|
||||||
|
fn words(s: &str) -> Vec<String> {
|
||||||
|
fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same letters, at most two words glued or split, never a negation:
|
||||||
|
/// « latransmission » → « la transmission ». A boundary moved is not a typo
|
||||||
|
/// (« les avoir » → « le savoir »), nor « Si non » → « Sinon ».
|
||||||
|
fn only_glued_or_split(a: &[String], b: &[String]) -> bool {
|
||||||
|
// Neither the word glued or split nor its pieces may be a negation or a
|
||||||
|
// quantity (« ja mais », « sur tout » → « surtout », « par tout » → « partout »).
|
||||||
|
let joined = |w: &str, x: &str, y: &str| ![w, x, y].iter().any(|t| NEGATIONS.contains(t) || QUANTITIES.contains(t));
|
||||||
|
let (mut i, mut j, mut moves) = (0, 0, 0);
|
||||||
|
while i < a.len() && j < b.len() {
|
||||||
|
if a[i] == b[j] {
|
||||||
|
(i, j) = (i + 1, j + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if j + 1 < b.len() && a[i] == format!("{}{}", b[j], b[j + 1]) && joined(&a[i], &b[j], &b[j + 1]) {
|
||||||
|
(i, j) = (i + 1, j + 2);
|
||||||
|
} else if i + 1 < a.len() && b[j] == format!("{}{}", a[i], a[i + 1]) && joined(&b[j], &a[i], &a[i + 1]) {
|
||||||
|
(i, j) = (i + 2, j + 1);
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
moves += 1;
|
||||||
|
if moves > MAX_TYPO_WORDS {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i == a.len() && j == b.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify `new` against `old`. For a text under vote, `old` must be the text
|
||||||
|
/// that was **voted** (its last initial or substantive revision), not the
|
||||||
|
/// current one: typo fixes are measured together, so slips never add up.
|
||||||
|
pub fn classify(old: &str, new: &str) -> EditKind {
|
||||||
|
if old == new {
|
||||||
|
return EditKind::Unchanged;
|
||||||
|
}
|
||||||
|
if has_hidden_controls(new)
|
||||||
|
|| shape(old) != shape(new)
|
||||||
|
|| code_spans(old) != code_spans(new)
|
||||||
|
|| exact_tokens(old) != exact_tokens(new)
|
||||||
|
{
|
||||||
|
return EditKind::Substantive;
|
||||||
|
}
|
||||||
|
let (a, b) = (words(old), words(new));
|
||||||
|
if skeleton(old) == skeleton(new) {
|
||||||
|
// Accents, case, apostrophes; words glued or split, never re-cut.
|
||||||
|
return if only_glued_or_split(&a, &b) { EditKind::Typographic } else { EditKind::Substantive };
|
||||||
|
}
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return EditKind::Substantive;
|
||||||
|
}
|
||||||
|
let mut changed = 0;
|
||||||
|
for (x, y) in a.iter().zip(&b) {
|
||||||
|
if x == y {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
changed += 1;
|
||||||
|
let touches_meaning = changed > MAX_TYPO_WORDS
|
||||||
|
|| x.chars().any(char::is_numeric)
|
||||||
|
|| y.chars().any(char::is_numeric)
|
||||||
|
|| [x, y].iter().any(|w| NEGATIONS.contains(&w.as_str()) || QUANTITIES.contains(&w.as_str()))
|
||||||
|
|| !one_slip(x, y);
|
||||||
|
if touches_meaning {
|
||||||
|
return EditKind::Substantive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditKind::Typographic
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn levenshtein(a: &str, b: &str) -> usize {
|
||||||
|
let b: Vec<char> = b.chars().collect();
|
||||||
|
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||||
|
let mut cur = vec![0; b.len() + 1];
|
||||||
|
for (i, ca) in a.chars().enumerate() {
|
||||||
|
cur[0] = i + 1;
|
||||||
|
for (j, cb) in b.iter().enumerate() {
|
||||||
|
let cost = usize::from(ca != *cb);
|
||||||
|
cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1);
|
||||||
|
}
|
||||||
|
std::mem::swap(&mut prev, &mut cur);
|
||||||
|
}
|
||||||
|
prev[b.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum DiffOp {
|
||||||
|
Same,
|
||||||
|
Added,
|
||||||
|
Removed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DiffPart {
|
||||||
|
pub op: DiffOp,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tokens that keep every character: words, and the runs between them.
|
||||||
|
fn tokens(s: &str) -> Vec<&str> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut start = 0;
|
||||||
|
let mut in_word: Option<bool> = None;
|
||||||
|
for (i, c) in s.char_indices() {
|
||||||
|
let w = c.is_alphanumeric();
|
||||||
|
if in_word.is_some_and(|prev| prev != w) {
|
||||||
|
out.push(&s[start..i]);
|
||||||
|
start = i;
|
||||||
|
}
|
||||||
|
in_word = Some(w);
|
||||||
|
}
|
||||||
|
if start < s.len() {
|
||||||
|
out.push(&s[start..]);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DIFF_TOKENS: usize = 4000;
|
||||||
|
|
||||||
|
/// Word-level diff for display. Concatenating `Same` + `Added` gives `new`,
|
||||||
|
/// `Same` + `Removed` gives `old`.
|
||||||
|
pub fn diff(old: &str, new: &str) -> Vec<DiffPart> {
|
||||||
|
let (a, b) = (tokens(old), tokens(new));
|
||||||
|
if a.len() * b.len() > MAX_DIFF_TOKENS * MAX_DIFF_TOKENS / 4 {
|
||||||
|
return vec![
|
||||||
|
DiffPart { op: DiffOp::Removed, text: old.to_owned() },
|
||||||
|
DiffPart { op: DiffOp::Added, text: new.to_owned() },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// LCS table, suffix form.
|
||||||
|
let (n, m) = (a.len(), b.len());
|
||||||
|
let mut lcs = vec![vec![0u32; m + 1]; n + 1];
|
||||||
|
for i in (0..n).rev() {
|
||||||
|
for j in (0..m).rev() {
|
||||||
|
lcs[i][j] = if a[i] == b[j] { lcs[i + 1][j + 1] + 1 } else { lcs[i + 1][j].max(lcs[i][j + 1]) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut parts: Vec<DiffPart> = Vec::new();
|
||||||
|
let mut push = |op: DiffOp, text: &str| match parts.last_mut() {
|
||||||
|
Some(last) if last.op == op => last.text.push_str(text),
|
||||||
|
_ => parts.push(DiffPart { op, text: text.to_owned() }),
|
||||||
|
};
|
||||||
|
let (mut i, mut j) = (0, 0);
|
||||||
|
while i < n && j < m {
|
||||||
|
if a[i] == b[j] {
|
||||||
|
push(DiffOp::Same, a[i]);
|
||||||
|
i += 1;
|
||||||
|
j += 1;
|
||||||
|
} else if lcs[i + 1][j] >= lcs[i][j + 1] {
|
||||||
|
push(DiffOp::Removed, a[i]);
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
push(DiffOp::Added, b[j]);
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for t in &a[i..] {
|
||||||
|
push(DiffOp::Removed, t);
|
||||||
|
}
|
||||||
|
for t in &b[j..] {
|
||||||
|
push(DiffOp::Added, t);
|
||||||
|
}
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_glued_word_of_the_seed_is_a_typo() {
|
||||||
|
let old = "Toute Certification doit s'accompagner de latransmission de ce document";
|
||||||
|
let new = "Toute Certification doit s'accompagner de la transmission de ce document";
|
||||||
|
assert_eq!(classify(old, new), EditKind::Typographic);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn glued_or_split_words_stay_few_and_never_touch_a_negation() {
|
||||||
|
// Seventh independent review, 2026-10-21: a text in force disfigured at will.
|
||||||
|
for (old, new) in [
|
||||||
|
("à ne certifier que", "à necertifier que"),
|
||||||
|
("à ne certifier que", "àne certifier que"),
|
||||||
|
("Si non, la demande attend", "Sinon, la demande attend"),
|
||||||
|
("la transmission de ce document à chacun", "latransmission dece documentà chacun"),
|
||||||
|
] {
|
||||||
|
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
classify("de latransmission dece document", "de la transmission de ce document"),
|
||||||
|
EditKind::Typographic
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_negation_or_a_quantity_is_never_cut_nor_formed() {
|
||||||
|
// Eighth independent review, 2026-10-21: the pieces were checked, not the word.
|
||||||
|
for (old, new) in [
|
||||||
|
("n'avoir jamais qu'un seul compte", "n'avoir ja mais qu'un seul compte"),
|
||||||
|
("aucun compte", "au cun compte"),
|
||||||
|
("sans retour", "san s retour"),
|
||||||
|
("rien de plus", "ri en de plus"),
|
||||||
|
("nowhere else", "now here else"),
|
||||||
|
// Ninth review: a quantity glued changes a scope.
|
||||||
|
("Je vérifie sur tout le territoire", "Je vérifie surtout le territoire"),
|
||||||
|
("par tout membre", "partout membre"),
|
||||||
|
] {
|
||||||
|
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||||
|
assert_eq!(classify(new, old), EditKind::Substantive, "{new} → {old}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_breaks_and_indentation_are_structure() {
|
||||||
|
for (old, new) in [
|
||||||
|
("- une garantie\n- une autre", "- une garantie - une autre"),
|
||||||
|
("- une garantie\n - une précision", "- une garantie\n- une précision"),
|
||||||
|
("la\ntransmission", "la transmission"),
|
||||||
|
] {
|
||||||
|
assert_eq!(classify(old, new), EditKind::Substantive, "{old:?} → {new:?}");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
classify("- une garantie\n- latransmission", "- une garantie\n- la transmission"),
|
||||||
|
EditKind::Typographic
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn odd_spaces_and_look_alike_letters_are_kept() {
|
||||||
|
assert_eq!(classify("un texte\tde référence", "un texte de référence"), EditKind::Substantive);
|
||||||
|
assert_eq!(classify("un texte\rde référence", "un texte de référence"), EditKind::Substantive);
|
||||||
|
assert_eq!(classify("toute certification", "toute certiǃfication"), EditKind::Substantive);
|
||||||
|
assert_eq!(classify("toute certification", "toute certifiƷcation"), EditKind::Substantive);
|
||||||
|
assert_eq!(classify("le coeur de l'ŒUVRE, niño", "le Coeur de l'œuvre, Niño"), EditKind::Typographic);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accents_case_apostrophes_quotes() {
|
||||||
|
assert_eq!(classify("Je m'engage a verifier.", "Je m’engage à vérifier."), EditKind::Typographic);
|
||||||
|
assert_eq!(classify("le \"Pacte\" de la monnaie", "le « Pacte » de la Monnaie"), EditKind::Typographic);
|
||||||
|
assert_eq!(classify("same", "same"), EditKind::Unchanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn punctuation_and_emphasis_go_through_a_proposal() {
|
||||||
|
// Sixth independent review: free punctuation can always carry a meaning.
|
||||||
|
for (old, new) in [
|
||||||
|
("Je m'engage à vérifier.", "Je m'engage à vérifier !"),
|
||||||
|
("Je m'engage à vérifier.", "Je m'engage à vérifier ?"),
|
||||||
|
("un **être humain**", "un être humain"),
|
||||||
|
("tous les **5 jours**", "tous les -**5 jours**"),
|
||||||
|
("Seuil = C + B^W", "Seuil = C + B^-W"),
|
||||||
|
("max(0, T-C)", "max(0, TC)"),
|
||||||
|
("(Y = ceil(N))", "(Y = -ceil(N))"),
|
||||||
|
("- avoir un compte\n- être joignable", "- avoir un compte, être joignable"),
|
||||||
|
("(sauf le cas a) et le cas b", "(sauf le cas a et le cas b)"),
|
||||||
|
("libre - le texte", "libre — le texte"),
|
||||||
|
("- une liste", "* une liste"),
|
||||||
|
("clé DsEx1pS33abc", "clé dSeX1Ps33abc"),
|
||||||
|
("les avoir vérifiés", "le savoir vérifiés"),
|
||||||
|
] {
|
||||||
|
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||||
|
}
|
||||||
|
assert_eq!(classify("toute latransmission", "toute la transmission"), EditKind::Typographic);
|
||||||
|
assert_eq!(classify("la trans mission", "la transmission"), EditKind::Typographic);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_figure_a_sign_or_a_relation_is_never_a_typo() {
|
||||||
|
// Same letters, different meaning (independent reviews, 2026-10-21).
|
||||||
|
for (old, new) in [
|
||||||
|
("au moins 50 voix", "au moins 5,0 voix"),
|
||||||
|
("1 5 jours", "15 jours"),
|
||||||
|
("10 000 membres", "10,000 membres"),
|
||||||
|
("un écart de -5", "un écart de 5"),
|
||||||
|
("1/5 des voix", "1.5 des voix"),
|
||||||
|
("50 % des voix", "50 ‰ des voix"),
|
||||||
|
("avoir >= 5 certifications", "avoir <= 5 certifications"),
|
||||||
|
("DU(n) + c² × M(n)", "DU(n) - c² × M(n)"),
|
||||||
|
("182.625 jours", "182 625 jours"),
|
||||||
|
("c²", "c2"),
|
||||||
|
] {
|
||||||
|
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||||
|
}
|
||||||
|
assert_eq!(classify("50 voix pour", "50 voix Pour"), EditKind::Typographic);
|
||||||
|
// A bracket always counts, even as a list marker: conservative.
|
||||||
|
assert_eq!(classify("1. premier point", "1) premier point"), EditKind::Substantive);
|
||||||
|
assert_eq!(classify("1. premier point", "1. Premier point"), EditKind::Typographic);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_word_turned_around_is_never_a_typo() {
|
||||||
|
// Third independent review, 2026-10-21: one or two letters that invert a meaning.
|
||||||
|
for (old, new) in [
|
||||||
|
("ces deux présents engagements", "ces dix présents engagements"),
|
||||||
|
("Nouveau compte valide si", "Nouveau compte invalide si"),
|
||||||
|
("100 certifications possibles", "100 certifications impossibles"),
|
||||||
|
("à la majorité des voix", "à la minorité des voix"),
|
||||||
|
("un minimum de garanties", "un maximum de garanties"),
|
||||||
|
("les membres inclus", "les membres exclus"),
|
||||||
|
("une personne majeure", "une personne mineure"),
|
||||||
|
("une durée limitée", "une durée illimitée"),
|
||||||
|
("un mandat révocable", "un mandat irrévocable"),
|
||||||
|
("des comptes indépendants", "des comptes dépendants"),
|
||||||
|
("pendant six mois", "pendant dix mois"),
|
||||||
|
("sept certifications", "cent certifications"),
|
||||||
|
("seize jours", "treize jours"),
|
||||||
|
("five members", "nine members"),
|
||||||
|
("you can certify", "you cant certify"),
|
||||||
|
("Y = ceil(N^(1/5))", "Y = ceil(N^1/5)"),
|
||||||
|
("M(n)/N(n)", "M(n)N(n)"),
|
||||||
|
("DU(n) - c", "DU(n) / c"),
|
||||||
|
("la formule `a - b`", "la formule `a * b`"),
|
||||||
|
// Fourth independent review: look-alikes, overlays, addresses, endings.
|
||||||
|
("au moins 50 voix", "au moins 5\u{201a}0 voix"),
|
||||||
|
("**12 mois**", "**1\u{201a}2 mois**"),
|
||||||
|
("15 jours", "1\u{2044}5 jours"),
|
||||||
|
("2 ans", "\u{ff0d}2 ans"),
|
||||||
|
(">= 5", "\u{226f}= 5"),
|
||||||
|
("a = b", "a \u{2260} b"),
|
||||||
|
("a = b", "a =\u{0338} b"),
|
||||||
|
("ne jamais certifier", "ne j\u{0336}a\u{0336}m\u{0336}a\u{0336}i\u{0336}s\u{0336} certifier"),
|
||||||
|
("la clé est vérifiée", "la clé est ~~vérifiée~~"),
|
||||||
|
("une personne connue", "une personne conn\u{05d5}ue"),
|
||||||
|
("une personne connue", "une personne c\u{043e}nnue"),
|
||||||
|
(
|
||||||
|
"https://git.duniter.org/documents/g1_monetary_license",
|
||||||
|
"https://git.dunniter.org/documents/g1_monetary_license",
|
||||||
|
),
|
||||||
|
("au-dessus de 50 %", "au-dessous de 50 %"),
|
||||||
|
("je certifierai", "je certifierais"),
|
||||||
|
("será válido", "sería válido"),
|
||||||
|
("they must certify", "they mustn certify"),
|
||||||
|
// Fifth independent review: anything touching a figure, special spaces, signs across spaces.
|
||||||
|
("au moins 50 voix", "au moins 5,\u{202f}0 voix"),
|
||||||
|
("**12 mois**", "**1,\u{202f}2 mois**"),
|
||||||
|
("100 certifications", "10,\u{200a}0 certifications"),
|
||||||
|
("80%", "8,\u{200a}0%"),
|
||||||
|
("15 jours", "1:5 jours"),
|
||||||
|
("15 jours", "1…5 jours"),
|
||||||
|
("15 jours", "1*5 jours"),
|
||||||
|
("15 jours", "1;5 jours"),
|
||||||
|
("15 jours", "1‘5 jours"),
|
||||||
|
("un écart de 5", "un écart de -\u{200a}5"),
|
||||||
|
("un écart de 5", "un écart de - 5"),
|
||||||
|
("un écart de 5", "un écart de \u{1680}5"),
|
||||||
|
] {
|
||||||
|
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hidden_controls_are_spotted() {
|
||||||
|
assert!(has_hidden_controls("**\u{202e}12\u{202c} mois**"));
|
||||||
|
assert!(has_hidden_controls("5\u{200b}0"));
|
||||||
|
assert!(!has_hidden_controls("Je m’engage — « à 50 % »."));
|
||||||
|
assert!(!has_hidden_controls("une ligne\nune autre\tavec tabulation"));
|
||||||
|
// The whole default-ignorable set and the control characters (eighth review).
|
||||||
|
for hidden in ["\u{ad}", "\u{180e}", "\u{fe0f}", "\u{e0041}", "\u{206e}", "\u{1b}[2J", "\u{7f}", "\u{85}"] {
|
||||||
|
assert!(has_hidden_controls(&format!("cer{hidden}tification")), "{hidden:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn misspelt_words() {
|
||||||
|
// The end of a word carries number and tense: a plural fixed is a revision.
|
||||||
|
assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Substantive);
|
||||||
|
assert_eq!(classify("toute certifcation", "toute certification"), EditKind::Typographic);
|
||||||
|
assert_eq!(classify("un acceuil chaleureux", "un accueil chaleureux"), EditKind::Typographic);
|
||||||
|
// A letter replaced by another is not a slip: the author revises.
|
||||||
|
assert_eq!(classify("une certifixation", "une certification"), EditKind::Substantive);
|
||||||
|
// « personne » is also a negation (ne … personne): conservative.
|
||||||
|
assert_eq!(classify("la persone", "la personne"), EditKind::Substantive);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn meaning_changes() {
|
||||||
|
// A number.
|
||||||
|
assert_eq!(classify("tous les 5 jours", "tous les 6 jours"), EditKind::Substantive);
|
||||||
|
// A negation, even one letter apart.
|
||||||
|
assert_eq!(classify("je peux certifier", "je ne peux certifier"), EditKind::Substantive);
|
||||||
|
assert_eq!(classify("plus de deux", "pas de deux"), EditKind::Substantive);
|
||||||
|
// A different word.
|
||||||
|
assert_eq!(classify("au moins deux canaux", "au moins trois canaux"), EditKind::Substantive);
|
||||||
|
// Three fixes are no longer a typo.
|
||||||
|
assert_eq!(classify("aaaa bbbb cccc", "aaab bbbc cccd"), EditKind::Substantive);
|
||||||
|
// A word added.
|
||||||
|
assert_eq!(classify("en visio", "en visio directe"), EditKind::Substantive);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_reconstructs_both_sides() {
|
||||||
|
let old = "J'ai rencontré la personne physiquement (préférable).";
|
||||||
|
let new = "J'ai rencontré la personne en visio (préférable).";
|
||||||
|
let parts = diff(old, new);
|
||||||
|
let rebuild = |keep: DiffOp| -> String {
|
||||||
|
parts.iter().filter(|p| p.op == DiffOp::Same || p.op == keep).map(|p| p.text.as_str()).collect()
|
||||||
|
};
|
||||||
|
assert_eq!(rebuild(DiffOp::Removed), old);
|
||||||
|
assert_eq!(rebuild(DiffOp::Added), new);
|
||||||
|
assert!(parts.iter().any(|p| p.op == DiffOp::Removed && p.text.contains("physiquement")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn levenshtein_basics() {
|
||||||
|
assert_eq!(levenshtein("kitten", "sitting"), 3);
|
||||||
|
assert_eq!(levenshtein("", "abc"), 3);
|
||||||
|
assert_eq!(levenshtein("été", "ete"), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! Pure engines of libreDecision — no IO, no clock, no randomness.
|
||||||
|
//!
|
||||||
|
//! Everything that decides an outcome lives here so it can be tested in
|
||||||
|
//! isolation and ported verbatim: the inertial threshold (parity with the
|
||||||
|
//! Python/TypeScript reference of the v2 maquette), the edit classifier that
|
||||||
|
//! separates a typo fix from a change of meaning, the rule-based editing
|
||||||
|
//! assistant, and the composition of a document under permanent vote.
|
||||||
|
|
||||||
|
pub mod assist;
|
||||||
|
pub mod compose;
|
||||||
|
pub mod edit;
|
||||||
|
pub mod names;
|
||||||
|
pub mod threshold;
|
||||||
|
|
||||||
|
pub use threshold::{FormulaParams, InertiaPreset, Tally};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! Names people give themselves (team links): what is accepted, and the key two
|
||||||
|
//! names collide on.
|
||||||
|
//!
|
||||||
|
//! A name is shown as the author of proposals, revisions and signals, so nobody
|
||||||
|
//! may take a name already there, even dressed differently: « émile » for
|
||||||
|
//! « Émile », an accent written apart, a Cyrillic « в » or a Greek « Υ » for a
|
||||||
|
//! Latin letter, full-width « Yvv ». Hence a short alphabet — Latin letters
|
||||||
|
//! as French, English and Spanish write them, figures, space, hyphen,
|
||||||
|
//! apostrophe, dot — and a key that forgets case, accents and punctuation.
|
||||||
|
|
||||||
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
use unicode_normalization::char::is_combining_mark;
|
||||||
|
|
||||||
|
const MIN_NAME: usize = 2;
|
||||||
|
const MAX_NAME: usize = 60;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct Name {
|
||||||
|
/// As shown: NFC, spaces collapsed.
|
||||||
|
pub display: String,
|
||||||
|
/// What two names collide on.
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allowed(c: char) -> bool {
|
||||||
|
c.is_ascii_alphanumeric()
|
||||||
|
|| (c.is_alphabetic() && matches!(c as u32, 0xC0..=0x17F))
|
||||||
|
|| matches!(c, ' ' | '-' | '\'' | '’' | '.')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercase, no accents, letters and figures only: « Émile », « EMILE »,
|
||||||
|
/// « É-mile » share the key « emile ».
|
||||||
|
pub fn key(s: &str) -> String {
|
||||||
|
s.nfkd().filter(|c| !is_combining_mark(*c)).flat_map(char::to_lowercase).filter(|c| c.is_alphanumeric()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name a person may take, or `None`.
|
||||||
|
pub fn check(raw: &str) -> Option<Name> {
|
||||||
|
let raw = raw.trim();
|
||||||
|
// One line, ordinary spaces only (no line or paragraph separator).
|
||||||
|
if raw.chars().any(|c| c.is_whitespace() && c != ' ') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let display = raw.nfc().collect::<String>().split(' ').filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ");
|
||||||
|
let n = display.chars().count();
|
||||||
|
if !(MIN_NAME..=MAX_NAME).contains(&n) || !display.chars().all(allowed) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let key = key(&display);
|
||||||
|
if key.chars().count() < MIN_NAME {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Name { display, key })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordinary_names() {
|
||||||
|
assert_eq!(check(" Émile Zola ").map(|n| n.display), Some("Émile Zola".into()));
|
||||||
|
assert_eq!(check("Jean-Marc O'Neil").map(|n| n.key), Some("jeanmarconeil".into()));
|
||||||
|
assert_eq!(check("Núria").map(|n| n.key), Some("nuria".into()));
|
||||||
|
assert!(check("Chiara 2").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_same_name_dressed_differently_collides() {
|
||||||
|
let emile = key("Émile");
|
||||||
|
for other in ["émile", "EMILE", "E\u{301}mile", "É-mile", "Emile."] {
|
||||||
|
assert_eq!(check(other).map(|n| n.key), Some(emile.clone()), "{other}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn look_alikes_and_invisibles_are_refused() {
|
||||||
|
// Eighth and ninth independent reviews, 2026-10-21.
|
||||||
|
for name in ["Yvв", "Υvv", "Yvv", "Yv\u{2028}v", "Yvv\u{fff9}", "Yv\u{200b}v", "Y\nvv", "Y", "--", "Yvv 🙂"]
|
||||||
|
{
|
||||||
|
assert_eq!(check(name), None, "{name:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
//! Inertial threshold for binary votes — the Ğ1 "g1vote" formula.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! R = C + B^W + (M + (1 − M)(1 − (T/W)^G)) · max(0, T − C)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! R is the minimum number of *for* votes; a proposal is adopted when
|
||||||
|
//! `for ≥ ⌈R⌉`. W is the size of the electorate, T the votes cast (for +
|
||||||
|
//! against). At low participation the formula asks for near-unanimity, at full
|
||||||
|
//! participation the simple majority M suffices. C is the minimal number of
|
||||||
|
//! *for* votes below which nothing passes — the guard of a permanent vote
|
||||||
|
//! (Yvv, 2026-10-21: « Nombre minimal de votants »).
|
||||||
|
//!
|
||||||
|
//! Parity: identical to `maquette/frontend/app/engine/threshold.ts` and the
|
||||||
|
//! Python reference, with one deliberate guard — the participation ratio is
|
||||||
|
//! capped at 1, so a stale W can never lower the bar below M.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The four inertia levels of a modular text. Wiring of the v2 maquette:
|
||||||
|
/// low = M50 G0.1, standard = M50 G0.2, high = M60 G0.4, max = M66 G0.6 (B = 0.1).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum InertiaPreset {
|
||||||
|
Low,
|
||||||
|
Standard,
|
||||||
|
High,
|
||||||
|
Max,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InertiaPreset {
|
||||||
|
pub const ALL: [Self; 4] = [Self::Low, Self::Standard, Self::High, Self::Max];
|
||||||
|
|
||||||
|
pub fn majority_pct(self) -> f64 {
|
||||||
|
match self {
|
||||||
|
Self::Low | Self::Standard => 50.0,
|
||||||
|
Self::High => 60.0,
|
||||||
|
Self::Max => 66.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gradient(self) -> f64 {
|
||||||
|
match self {
|
||||||
|
Self::Low => 0.1,
|
||||||
|
Self::Standard => 0.2,
|
||||||
|
Self::High => 0.4,
|
||||||
|
Self::Max => 0.6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// « Inertie + » : one level up, capped at max. A change of inertia is
|
||||||
|
/// itself voted at the raised level of the unit it touches.
|
||||||
|
pub fn raised(self) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::Low => Self::Standard,
|
||||||
|
Self::Standard => Self::High,
|
||||||
|
Self::High | Self::Max => Self::Max,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Low => "low",
|
||||||
|
Self::Standard => "standard",
|
||||||
|
Self::High => "high",
|
||||||
|
Self::Max => "max",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(raw: &str) -> Option<Self> {
|
||||||
|
Self::ALL.into_iter().find(|p| p.as_str() == raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The letters of the formula, named.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct FormulaParams {
|
||||||
|
/// M, in percent (0–100).
|
||||||
|
pub majority_pct: f64,
|
||||||
|
/// G.
|
||||||
|
pub gradient: f64,
|
||||||
|
/// B.
|
||||||
|
pub base: f64,
|
||||||
|
/// C — minimal number of *for* votes.
|
||||||
|
pub constant: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FormulaParams {
|
||||||
|
pub const DEFAULT_BASE: f64 = 0.1;
|
||||||
|
|
||||||
|
pub fn for_inertia(preset: InertiaPreset, constant: f64) -> Self {
|
||||||
|
Self { majority_pct: preset.majority_pct(), gradient: preset.gradient(), base: Self::DEFAULT_BASE, constant }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ThresholdError {
|
||||||
|
EmptyElectorate,
|
||||||
|
MajorityOutOfRange,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The required ratio of *for* votes among the votes cast beyond C.
|
||||||
|
pub fn required_ratio(electorate: u32, cast: u32, params: &FormulaParams) -> f64 {
|
||||||
|
let m = params.majority_pct / 100.0;
|
||||||
|
let participation = (f64::from(cast) / f64::from(electorate.max(1))).min(1.0);
|
||||||
|
m + (1.0 - m) * (1.0 - participation.powf(params.gradient))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ⌈R⌉ — the minimum number of *for* votes for adoption.
|
||||||
|
pub fn wot_threshold(electorate: u32, cast: u32, params: &FormulaParams) -> Result<u32, ThresholdError> {
|
||||||
|
if electorate == 0 {
|
||||||
|
return Err(ThresholdError::EmptyElectorate);
|
||||||
|
}
|
||||||
|
if !(0.0..=100.0).contains(¶ms.majority_pct) {
|
||||||
|
return Err(ThresholdError::MajorityOutOfRange);
|
||||||
|
}
|
||||||
|
let c = params.constant;
|
||||||
|
let offset = params.base.powf(f64::from(electorate));
|
||||||
|
let raw = if cast == 0 {
|
||||||
|
c + offset
|
||||||
|
} else {
|
||||||
|
c + offset + required_ratio(electorate, cast, params) * (f64::from(cast) - c).max(0.0)
|
||||||
|
};
|
||||||
|
// `raw` is finite and non-negative for any valid input; saturate rather than wrap.
|
||||||
|
Ok(clamp_ceil(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_ceil(raw: f64) -> u32 {
|
||||||
|
let ceiled = raw.ceil();
|
||||||
|
if ceiled <= 0.0 {
|
||||||
|
0
|
||||||
|
} else if ceiled >= f64::from(u32::MAX) {
|
||||||
|
u32::MAX
|
||||||
|
} else {
|
||||||
|
// Exact: `ceiled` is an integer in (0, u32::MAX).
|
||||||
|
ceiled as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Votes counted on one proposal.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Tally {
|
||||||
|
#[serde(rename = "for")]
|
||||||
|
pub for_votes: u32,
|
||||||
|
pub against: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tally {
|
||||||
|
pub fn cast(&self) -> u32 {
|
||||||
|
self.for_votes.saturating_add(self.against)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a proposal stands, in words a screen can say.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Evaluation {
|
||||||
|
pub cast: u32,
|
||||||
|
/// ⌈R⌉ at the current participation.
|
||||||
|
pub required: u32,
|
||||||
|
/// `for ≥ ⌈R⌉` with at least one vote cast.
|
||||||
|
pub reached: bool,
|
||||||
|
/// *for* votes still missing (0 once reached).
|
||||||
|
pub missing: u32,
|
||||||
|
/// The majority the inertia asks for at this participation.
|
||||||
|
pub required_ratio: f64,
|
||||||
|
/// « Bien cotée » : it holds the majority its inertia asks for, it only
|
||||||
|
/// lacks voters (C ignored). Used by the "if the well-rated pass" preview.
|
||||||
|
pub well_rated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate(tally: Tally, electorate: u32, params: &FormulaParams) -> Result<Evaluation, ThresholdError> {
|
||||||
|
let cast = tally.cast();
|
||||||
|
let required = wot_threshold(electorate, cast, params)?;
|
||||||
|
let without_floor = FormulaParams { constant: 0.0, ..*params };
|
||||||
|
let required_without_floor = wot_threshold(electorate, cast, &without_floor)?;
|
||||||
|
let reached = cast > 0 && tally.for_votes >= required;
|
||||||
|
Ok(Evaluation {
|
||||||
|
cast,
|
||||||
|
required,
|
||||||
|
reached,
|
||||||
|
missing: required.saturating_sub(tally.for_votes),
|
||||||
|
required_ratio: required_ratio(electorate, cast, params),
|
||||||
|
well_rated: cast > 0 && tally.for_votes >= required_without_floor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn g1vote(m: f64, g: f64) -> FormulaParams {
|
||||||
|
FormulaParams { majority_pct: m, gradient: g, base: 0.1, constant: 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_case_of_the_smith_vote() {
|
||||||
|
// Engagement forgeron v2.0.0: W 7224, 97 for, 23 against, M50 B.1 G.2 → 94.
|
||||||
|
let p = g1vote(50.0, 0.2);
|
||||||
|
assert_eq!(wot_threshold(7224, 120, &p), Ok(94));
|
||||||
|
let e = evaluate(Tally { for_votes: 97, against: 23 }, 7224, &p).expect("valid");
|
||||||
|
assert!(e.reached);
|
||||||
|
assert_eq!(e.missing, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parity_with_the_typescript_reference() {
|
||||||
|
// Values computed by maquette/frontend/app/engine/threshold.ts.
|
||||||
|
let p = g1vote(50.0, 0.2);
|
||||||
|
assert_eq!(wot_threshold(7224, 0, &p), Ok(0));
|
||||||
|
assert_eq!(wot_threshold(7224, 1, &p), Ok(1));
|
||||||
|
assert_eq!(wot_threshold(100, 100, &p), Ok(50));
|
||||||
|
assert_eq!(wot_threshold(5, 5, &p), Ok(3));
|
||||||
|
let high = g1vote(60.0, 0.4);
|
||||||
|
assert_eq!(wot_threshold(7224, 120, &high), Ok(111));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_vote_passes_without_floor_and_never_with_it() {
|
||||||
|
// The trap of a permanent vote: a lone "for" meets the inertial bar.
|
||||||
|
let bare = FormulaParams::for_inertia(InertiaPreset::Standard, 0.0);
|
||||||
|
assert!(evaluate(Tally { for_votes: 1, against: 0 }, 5745, &bare).expect("valid").reached);
|
||||||
|
let guarded = FormulaParams::for_inertia(InertiaPreset::Standard, 50.0);
|
||||||
|
let e = evaluate(Tally { for_votes: 1, against: 0 }, 5745, &guarded).expect("valid");
|
||||||
|
assert!(!e.reached);
|
||||||
|
assert_eq!(e.required, 50);
|
||||||
|
assert_eq!(e.missing, 49);
|
||||||
|
assert!(e.well_rated, "unanimous, it only lacks voters");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn floor_is_a_minimum_of_for_votes() {
|
||||||
|
let p = FormulaParams::for_inertia(InertiaPreset::Standard, 50.0);
|
||||||
|
// 50 unanimous votes reach it, 49 do not.
|
||||||
|
assert!(evaluate(Tally { for_votes: 50, against: 0 }, 5745, &p).expect("valid").reached);
|
||||||
|
assert!(!evaluate(Tally { for_votes: 49, against: 0 }, 5745, &p).expect("valid").reached);
|
||||||
|
// Beyond the floor the inertial majority applies to the rest.
|
||||||
|
let e = evaluate(Tally { for_votes: 55, against: 5 }, 5745, &p).expect("valid");
|
||||||
|
assert!(!e.reached, "near-unanimity is asked of the votes beyond C");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_vote_is_never_reached() {
|
||||||
|
let p = FormulaParams::for_inertia(InertiaPreset::Low, 0.0);
|
||||||
|
let e = evaluate(Tally::default(), 3, &p).expect("valid");
|
||||||
|
assert!(!e.reached);
|
||||||
|
assert!(!e.well_rated);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn small_group_needs_everyone() {
|
||||||
|
// Test group of two people, C = 1: both must vote for.
|
||||||
|
let p = FormulaParams::for_inertia(InertiaPreset::Standard, 1.0);
|
||||||
|
assert!(!evaluate(Tally { for_votes: 1, against: 0 }, 2, &p).expect("valid").reached);
|
||||||
|
assert!(evaluate(Tally { for_votes: 2, against: 0 }, 2, &p).expect("valid").reached);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_electorate_cannot_lower_the_bar_below_majority() {
|
||||||
|
let p = g1vote(50.0, 0.2);
|
||||||
|
// 30 votes cast for an electorate believed to be 10: the reference
|
||||||
|
// would ask 12 (ratio below M), the cap keeps the simple majority:
|
||||||
|
// ⌈B^W + 0.5 × 30⌉ = 16, the tiny B^W offset rounding up.
|
||||||
|
let r = wot_threshold(10, 30, &p).expect("valid");
|
||||||
|
assert_eq!(r, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inertia_levels_rise_and_cap() {
|
||||||
|
assert_eq!(InertiaPreset::Low.raised(), InertiaPreset::Standard);
|
||||||
|
assert_eq!(InertiaPreset::Standard.raised(), InertiaPreset::High);
|
||||||
|
assert_eq!(InertiaPreset::Max.raised(), InertiaPreset::Max);
|
||||||
|
for p in InertiaPreset::ALL {
|
||||||
|
assert_eq!(InertiaPreset::parse(p.as_str()), Some(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn errors() {
|
||||||
|
let p = g1vote(50.0, 0.2);
|
||||||
|
assert_eq!(wot_threshold(0, 1, &p), Err(ThresholdError::EmptyElectorate));
|
||||||
|
let bad = g1vote(120.0, 0.2);
|
||||||
|
assert_eq!(wot_threshold(10, 1, &bad), Err(ThresholdError::MajorityOutOfRange));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
[package]
|
||||||
|
name = "ld-server"
|
||||||
|
description = "libreDecision server: API, Ğ1 sign-in, storage, publication of adopted texts, static web."
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ld-engine = { path = "../ld-engine" }
|
||||||
|
trustin-access = { path = "../trustin-access" }
|
||||||
|
|
||||||
|
anyhow = "1"
|
||||||
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
hex = "0.4"
|
||||||
|
rand = "0.9"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] }
|
||||||
|
thiserror = "2"
|
||||||
|
time = { version = "0.3", features = ["formatting", "parsing", "macros"] }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time", "process", "fs"] }
|
||||||
|
tower-http = { version = "0.6", features = ["fs", "trace", "compression-gzip", "set-header", "limit"] }
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
twox-hash = { version = "2", default-features = false, features = ["xxhash64"] }
|
||||||
|
url = "2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
http-body-util = "0.1"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
-- libreDecision v3 — initial schema. Runtime data: never dropped, never rewritten.
|
||||||
|
-- Dates are RFC 3339 UTC strings (sortable).
|
||||||
|
|
||||||
|
CREATE TABLE identities (
|
||||||
|
id TEXT PRIMARY KEY, -- idty:<index> | inv:<hex> | dev:<name>
|
||||||
|
kind TEXT NOT NULL, -- g1 | invite | dev
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
address TEXT, -- SS58 seen at the last Ğ1 sign-in, display only
|
||||||
|
g1_member INTEGER NOT NULL DEFAULT 0,
|
||||||
|
admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
identity_id TEXT NOT NULL REFERENCES identities(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE invitations (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
groups TEXT NOT NULL DEFAULT '[]', -- JSON array of group slugs granted on acceptance
|
||||||
|
created_by TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
identity_id TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE groups (
|
||||||
|
slug TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
parent TEXT,
|
||||||
|
policy TEXT NOT NULL, -- g1-members | managed
|
||||||
|
hidden INTEGER NOT NULL DEFAULT 0,
|
||||||
|
position INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE memberships (
|
||||||
|
group_slug TEXT NOT NULL REFERENCES groups(slug),
|
||||||
|
identity_id TEXT NOT NULL REFERENCES identities(id),
|
||||||
|
granted_by TEXT,
|
||||||
|
granted_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (group_slug, identity_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE documents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
group_slug TEXT NOT NULL REFERENCES groups(slug),
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL, -- JSON {lang: text}
|
||||||
|
summary TEXT NOT NULL, -- JSON {lang: text}
|
||||||
|
source_lang TEXT NOT NULL,
|
||||||
|
vote_floor REAL NOT NULL, -- C, minimal number of "for" votes
|
||||||
|
publish_path TEXT, -- path in the texts repository; NULL = never published
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE (group_slug, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE units (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
section TEXT, -- grouping key of the seed (introduction, technique…)
|
||||||
|
position REAL NOT NULL,
|
||||||
|
inertia TEXT NOT NULL, -- low | standard | high | max
|
||||||
|
in_force_id TEXT, -- proposals.id of the version in force, NULL = empty slot
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX units_doc ON units(doc_id);
|
||||||
|
|
||||||
|
-- A proposal is a version of a unit: proposed, in force, or past.
|
||||||
|
CREATE TABLE proposals (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
unit_id TEXT NOT NULL REFERENCES units(id),
|
||||||
|
kind TEXT NOT NULL, -- text | remove | inertia
|
||||||
|
inertia_target TEXT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL, -- draft | open | in_force | superseded | withdrawn
|
||||||
|
origin TEXT NOT NULL, -- seed | member
|
||||||
|
label TEXT,
|
||||||
|
author_id TEXT REFERENCES identities(id),
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
submitted_at TEXT,
|
||||||
|
entered_at TEXT, -- entered into force by vote; NULL for a seed a minima
|
||||||
|
left_at TEXT,
|
||||||
|
confirmed_at TEXT -- a seed a minima that later reached its threshold
|
||||||
|
);
|
||||||
|
CREATE INDEX proposals_unit ON proposals(unit_id);
|
||||||
|
|
||||||
|
CREATE TABLE revisions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL, -- initial | typo | substantive
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
author_id TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX revisions_proposal ON revisions(proposal_id);
|
||||||
|
|
||||||
|
CREATE TABLE votes (
|
||||||
|
proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||||
|
identity_id TEXT NOT NULL REFERENCES identities(id),
|
||||||
|
choice TEXT NOT NULL, -- for | against
|
||||||
|
revision INTEGER NOT NULL, -- counted only while equal to proposals.revision
|
||||||
|
cast_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (proposal_id, identity_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE signals (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||||
|
target_proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
author_id TEXT NOT NULL REFERENCES identities(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
lifted_at TEXT,
|
||||||
|
lifted_by TEXT -- identity id, or 'revision' when the text changed
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE translations (
|
||||||
|
proposal_id TEXT NOT NULL REFERENCES proposals(id),
|
||||||
|
lang TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
source_hash TEXT NOT NULL, -- hash of the source title + body it translates
|
||||||
|
origin TEXT NOT NULL, -- seed | member
|
||||||
|
updated_by TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (proposal_id, lang)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE adoptions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
proposal_id TEXT NOT NULL,
|
||||||
|
unit_id TEXT NOT NULL,
|
||||||
|
doc_id TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
votes_for INTEGER NOT NULL,
|
||||||
|
votes_against INTEGER NOT NULL,
|
||||||
|
electorate INTEGER NOT NULL,
|
||||||
|
required INTEGER NOT NULL,
|
||||||
|
params TEXT NOT NULL,
|
||||||
|
at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE outbox (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
doc_id TEXT NOT NULL,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
next_at TEXT NOT NULL,
|
||||||
|
done_at TEXT,
|
||||||
|
commit_sha TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE feedback (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
identity_id TEXT,
|
||||||
|
route TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
context TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'new'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Team links (2026-10-21): one link shared with a team until a date; each opening
|
||||||
|
-- makes its own invited identity, never an administrator. Additive only.
|
||||||
|
|
||||||
|
CREATE TABLE team_links (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
label TEXT NOT NULL, -- what the link is for, shown to whoever opens it
|
||||||
|
groups TEXT NOT NULL DEFAULT '[]', -- JSON array of group slugs granted on each opening
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
max_uses INTEGER NOT NULL,
|
||||||
|
uses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
revoked_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The team link an identity came from, if any (shown to administrators).
|
||||||
|
ALTER TABLE identities ADD COLUMN team_link TEXT;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- The key a name collides on (ld_engine::names), set for the names people give
|
||||||
|
-- themselves through a team link: two people never share a name. Additive only.
|
||||||
|
|
||||||
|
ALTER TABLE identities ADD COLUMN name_key TEXT;
|
||||||
|
CREATE UNIQUE INDEX identities_name_key ON identities(name_key) WHERE name_key IS NOT NULL;
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
//! trustinWallet — access management, for its administrators only — and the
|
||||||
|
//! « Retour » channel that turns usage tests into controls.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::{self, IDENTITY_COLUMNS, IdentityRow, User};
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::util;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn admin_only(user: &IdentityRow) -> AppResult<()> {
|
||||||
|
if trustin_access::manages_access(&user.to_identity()) { Ok(()) } else { Err(AppError::Forbidden) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn overview(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
User(user): User,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
admin_only(&user)?;
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Member {
|
||||||
|
group_slug: String,
|
||||||
|
identity_id: String,
|
||||||
|
granted_at: String,
|
||||||
|
}
|
||||||
|
let groups: Vec<(String, String, String, bool)> =
|
||||||
|
sqlx::query_as("SELECT slug, name, policy, hidden FROM groups ORDER BY position").fetch_all(&state.db).await?;
|
||||||
|
let members = sqlx::query_as::<_, Member>("SELECT group_slug, identity_id, granted_at FROM memberships")
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let identities = sqlx::query_as::<_, IdentityRow>(&format!(
|
||||||
|
"SELECT {IDENTITY_COLUMNS} FROM identities ORDER BY admin DESC, display_name COLLATE NOCASE"
|
||||||
|
))
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let last_seen: Vec<(String, Option<String>)> =
|
||||||
|
sqlx::query_as("SELECT id, last_seen_at FROM identities").fetch_all(&state.db).await?;
|
||||||
|
// Open sessions, but the one this page is read with.
|
||||||
|
let sessions: Vec<(String, i64)> = sqlx::query_as(
|
||||||
|
"SELECT identity_id, COUNT(*) FROM sessions WHERE expires_at > ? AND token_hash != ? GROUP BY identity_id",
|
||||||
|
)
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(auth::session_hash(&state, &headers).unwrap_or_default())
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let came_by: Vec<(String, String)> =
|
||||||
|
sqlx::query_as("SELECT i.id, t.label FROM identities i JOIN team_links t ON t.token_hash = i.team_link")
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let invitations: Vec<(String, String, bool, String, String, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT token_hash, label, admin, groups, expires_at, identity_id FROM invitations
|
||||||
|
WHERE used_at IS NULL AND expires_at > ? ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(util::now())
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let mut publication = json!({
|
||||||
|
"remote": state.cfg.publish.as_ref().map(|p| p.remote.clone()),
|
||||||
|
"publicKey": crate::publish::public_key(&state),
|
||||||
|
});
|
||||||
|
if state.cfg.publish.is_some() {
|
||||||
|
publication["status"] = crate::publish::status(&state).await?;
|
||||||
|
}
|
||||||
|
Ok(Json(json!({
|
||||||
|
"publication": publication,
|
||||||
|
"groups": groups.iter().map(|(slug, name, policy, hidden)| json!({
|
||||||
|
"slug": slug, "name": name, "policy": policy, "hidden": hidden,
|
||||||
|
"members": members.iter().filter(|m| &m.group_slug == slug)
|
||||||
|
.map(|m| json!({ "identityId": m.identity_id, "grantedAt": m.granted_at })).collect::<Vec<_>>(),
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
"identities": identities.iter().map(|i| json!({
|
||||||
|
"id": i.id, "kind": i.kind, "displayName": i.display_name, "g1Member": i.g1_member, "admin": i.admin,
|
||||||
|
"lastSeenAt": last_seen.iter().find(|(id, _)| id == &i.id).and_then(|(_, at)| at.clone()),
|
||||||
|
"teamLink": came_by.iter().find(|(id, _)| id == &i.id).map(|(_, label)| label.clone()),
|
||||||
|
"sessions": sessions.iter().find(|(id, _)| id == &i.id).map_or(0, |(_, n)| *n),
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
"teamLinks": crate::team::list(&state).await?,
|
||||||
|
"invitations": invitations.iter().map(|(hash, label, admin, groups, expires, identity)| json!({
|
||||||
|
"id": hash, "label": label, "admin": admin,
|
||||||
|
"groups": serde_json::from_str::<Vec<String>>(groups).unwrap_or_default(),
|
||||||
|
"expiresAt": expires, "identityId": identity,
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GrantBody {
|
||||||
|
identity_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn grant(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
Json(b): Json<GrantBody>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
admin_only(&user)?;
|
||||||
|
auth::load_identity(&state, &b.identity_id).await?;
|
||||||
|
let done = sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO memberships (group_slug, identity_id, granted_by, granted_at)
|
||||||
|
SELECT slug, ?, ?, ? FROM groups WHERE slug = ?",
|
||||||
|
)
|
||||||
|
.bind(&b.identity_id)
|
||||||
|
.bind(&user.id)
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(&slug)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(group = %slug, identity = %b.identity_id, by = %user.id, "access granted");
|
||||||
|
Ok(Json(json!({ "granted": done.rows_affected() == 1 })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path((slug, identity)): Path<(String, String)>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
admin_only(&user)?;
|
||||||
|
let done = sqlx::query("DELETE FROM memberships WHERE group_slug = ? AND identity_id = ?")
|
||||||
|
.bind(&slug)
|
||||||
|
.bind(&identity)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(group = %slug, identity = %identity, by = %user.id, "access revoked");
|
||||||
|
Ok(Json(json!({ "revoked": done.rows_affected() == 1 })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InviteBody {
|
||||||
|
label: String,
|
||||||
|
#[serde(default)]
|
||||||
|
admin: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
groups: Vec<String>,
|
||||||
|
identity_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The link is returned once and never stored in clear.
|
||||||
|
pub async fn invite(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Json(b): Json<InviteBody>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
admin_only(&user)?;
|
||||||
|
let label = b.label.trim();
|
||||||
|
if label.is_empty()
|
||||||
|
|| label.chars().count() > 60
|
||||||
|
|| label.chars().any(char::is_control)
|
||||||
|
|| ld_engine::edit::has_hidden_controls(label)
|
||||||
|
{
|
||||||
|
return Err(AppError::Invalid("label"));
|
||||||
|
}
|
||||||
|
// A new link for an existing person is for invitation identities only: a link
|
||||||
|
// to a Ğ1 identity would let an administrator open a session in its name.
|
||||||
|
if let Some(id) = &b.identity_id
|
||||||
|
&& auth::load_identity(&state, id).await?.kind != "invite"
|
||||||
|
{
|
||||||
|
return Err(AppError::Invalid("identity_kind"));
|
||||||
|
}
|
||||||
|
let token =
|
||||||
|
auth::create_invitation(&state, label, b.admin, &b.groups, Some(&user.id), b.identity_id.as_deref()).await?;
|
||||||
|
Ok(Json(json!({ "link": auth::invitation_link(&state, &token) })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cancel_invitation(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(hash): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
admin_only(&user)?;
|
||||||
|
sqlx::query("UPDATE invitations SET expires_at = ? WHERE token_hash = ? AND used_at IS NULL")
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(&hash)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// « Publier maintenant » : a key just added, a remote just created — no need
|
||||||
|
/// to wait for the backoff.
|
||||||
|
pub async fn publish_now(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
admin_only(&user)?;
|
||||||
|
if state.cfg.publish.is_none() {
|
||||||
|
return Err(AppError::Conflict("publication_off"));
|
||||||
|
}
|
||||||
|
crate::publish::retry_now(&state).await?;
|
||||||
|
tracing::info!(by = %user.id, "publication retried now");
|
||||||
|
Ok(Json(json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AdminBody {
|
||||||
|
admin: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_admin(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(b): Json<AdminBody>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
admin_only(&user)?;
|
||||||
|
auth::load_identity(&state, &id).await?;
|
||||||
|
if !b.admin {
|
||||||
|
let others: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE admin = 1 AND id != ?")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
if others == 0 {
|
||||||
|
return Err(AppError::Conflict("last_admin"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sqlx::query("UPDATE identities SET admin = ? WHERE id = ?").bind(b.admin).bind(&id).execute(&state.db).await?;
|
||||||
|
tracing::info!(identity = %id, admin = b.admin, by = %user.id, "administrator changed");
|
||||||
|
Ok(Json(json!({ "admin": b.admin })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A lost device, a person who left: every session of this identity closes,
|
||||||
|
/// but the one this request comes with, and so does a sign-in link still
|
||||||
|
/// waiting for it (it may sit on the lost device).
|
||||||
|
pub async fn close_sessions(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
admin_only(&user)?;
|
||||||
|
auth::load_identity(&state, &id).await?;
|
||||||
|
let current = auth::session_hash(&state, &headers).unwrap_or_default();
|
||||||
|
// The waiting link first: opened in between, it would open a session that stays.
|
||||||
|
let links = sqlx::query(
|
||||||
|
"UPDATE invitations SET expires_at = ? WHERE identity_id = ? AND used_at IS NULL AND expires_at > ?",
|
||||||
|
)
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(&id)
|
||||||
|
.bind(util::now())
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
// Counted as the overview counts them; the expired ones go too.
|
||||||
|
let open = sqlx::query("DELETE FROM sessions WHERE identity_id = ? AND token_hash != ? AND expires_at > ?")
|
||||||
|
.bind(&id)
|
||||||
|
.bind(¤t)
|
||||||
|
.bind(util::now())
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
sqlx::query("DELETE FROM sessions WHERE identity_id = ? AND token_hash != ?")
|
||||||
|
.bind(&id)
|
||||||
|
.bind(¤t)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(identity = %id, closed = open, links, by = %user.id, "sessions closed");
|
||||||
|
Ok(Json(json!({ "closed": open, "links": links })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct FeedbackBody {
|
||||||
|
route: String,
|
||||||
|
body: String,
|
||||||
|
context: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_feedback(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Json(b): Json<FeedbackBody>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let body = b.body.trim();
|
||||||
|
if body.is_empty() || body.chars().count() > 4000 || b.route.len() > 300 {
|
||||||
|
return Err(AppError::Invalid("body"));
|
||||||
|
}
|
||||||
|
let context = b.context.map(|c| c.to_string()).filter(|c| c.len() < 4000);
|
||||||
|
sqlx::query("INSERT INTO feedback (identity_id, route, body, context, created_at) VALUES (?, ?, ?, ?, ?)")
|
||||||
|
.bind(&user.id)
|
||||||
|
.bind(&b.route)
|
||||||
|
.bind(body)
|
||||||
|
.bind(context)
|
||||||
|
.bind(util::now())
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn feedback_list(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
admin_only(&user)?;
|
||||||
|
#[derive(sqlx::FromRow, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Row {
|
||||||
|
id: i64,
|
||||||
|
author: Option<String>,
|
||||||
|
route: String,
|
||||||
|
body: String,
|
||||||
|
context: Option<String>,
|
||||||
|
created_at: String,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
let rows = sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT f.id, i.display_name AS author, f.route, f.body, f.context, f.created_at, f.status
|
||||||
|
FROM feedback f LEFT JOIN identities i ON i.id = f.identity_id ORDER BY f.id DESC LIMIT 500",
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "feedback": rows })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct StatusBody {
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn feedback_status(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(b): Json<StatusBody>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
admin_only(&user)?;
|
||||||
|
if !matches!(b.status.as_str(), "new" | "seen" | "done") {
|
||||||
|
return Err(AppError::Invalid("status"));
|
||||||
|
}
|
||||||
|
sqlx::query("UPDATE feedback SET status = ? WHERE id = ?").bind(&b.status).bind(id).execute(&state.db).await?;
|
||||||
|
Ok(Json(json!({ "ok": true })))
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
//! Entering into force — automatic, as soon as a proposal reaches its
|
||||||
|
//! threshold (Yvv, 2026-10-21: « Dès qu'une proposition atteint le score, elle
|
||||||
|
//! remonte en vigueur »). The human gesture is the vote itself; the engine
|
||||||
|
//! only counts. Every entry is recorded with its numbers and queued for
|
||||||
|
//! publication to the texts repository.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::docs::{self, DocRow, DocState};
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use crate::util;
|
||||||
|
use ld_engine::compose::{self, ProposalKind, UnitState};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
/// One adoption pass at a time per process: SQLite has a single writer, and
|
||||||
|
/// two concurrent votes must not both promote rivals of the same unit.
|
||||||
|
static PASS: Mutex<()> = Mutex::const_new(());
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Entered {
|
||||||
|
pub proposal_id: String,
|
||||||
|
pub unit_id: String,
|
||||||
|
pub code: String,
|
||||||
|
pub kind: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn enqueue_publication(state: &AppState, doc: &DocRow, reason: &str) -> AppResult<()> {
|
||||||
|
if doc.publish_path.is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let now = util::now();
|
||||||
|
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
||||||
|
.bind(&doc.id)
|
||||||
|
.bind(reason)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
state.publish_wake.notify_one();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seeds a minima that later reach their own threshold are marked confirmed.
|
||||||
|
async fn confirm_seeds(state: &AppState, st: &DocState) -> AppResult<()> {
|
||||||
|
for unit in &st.units {
|
||||||
|
let Some(fid) = &unit.in_force_id else { continue };
|
||||||
|
let Some(p) = st.proposals.iter().find(|p| &p.id == fid) else { continue };
|
||||||
|
if p.entered_at.is_some() || p.confirmed_at.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if st.evaluation(unit, p).is_some_and(|e| e.reached) {
|
||||||
|
sqlx::query("UPDATE proposals SET confirmed_at = ? WHERE id = ? AND confirmed_at IS NULL")
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(&p.id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(state: &AppState, doc_id: &str) -> AppResult<Vec<Entered>> {
|
||||||
|
let _pass = PASS.lock().await;
|
||||||
|
let st = docs::load_state(state, doc_id).await?;
|
||||||
|
confirm_seeds(state, &st).await?;
|
||||||
|
let units = st.unit_states();
|
||||||
|
let winners = compose::adoptions(&units, &st.proposal_states(), st.electorate, st.doc.vote_floor)
|
||||||
|
.map_err(|_| crate::error::AppError::Invalid("electorate"))?;
|
||||||
|
let mut entered = Vec::new();
|
||||||
|
for id in winners {
|
||||||
|
let Some(p) = st.proposals.iter().find(|p| p.id == id) else { continue };
|
||||||
|
let Some(unit) = st.units.iter().find(|u| u.id == p.unit_id) else { continue };
|
||||||
|
let us = units.iter().find(|u| u.id == unit.id);
|
||||||
|
let tally = st.tally(&p.id);
|
||||||
|
let eval = st.evaluation(unit, p);
|
||||||
|
let params = us.map(|u| compose::params_for(u, p.kind(), st.doc.vote_floor));
|
||||||
|
let now = util::now();
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
// The text that enters is the text that was counted: a revision that landed
|
||||||
|
// since the state was read stops it (the next pass will judge it again).
|
||||||
|
let unchanged = sqlx::query(
|
||||||
|
"UPDATE proposals SET updated_at = updated_at
|
||||||
|
WHERE id = ? AND revision = ? AND title = ? AND body = ? AND status = ?",
|
||||||
|
)
|
||||||
|
.bind(&p.id)
|
||||||
|
.bind(p.revision)
|
||||||
|
.bind(&p.title)
|
||||||
|
.bind(&p.body)
|
||||||
|
.bind(&p.status)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if unchanged.rows_affected() != 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let kind = match p.kind() {
|
||||||
|
ProposalKind::Text | ProposalKind::Remove => {
|
||||||
|
// The displaced version goes back among the proposals, with its votes.
|
||||||
|
if let Some(old) = &unit.in_force_id {
|
||||||
|
sqlx::query("UPDATE proposals SET status = 'open', left_at = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(old)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
if p.kind() == ProposalKind::Text {
|
||||||
|
sqlx::query("UPDATE proposals SET status = 'in_force', entered_at = ?, left_at = NULL, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&p.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?")
|
||||||
|
.bind(&p.id)
|
||||||
|
.bind(&unit.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
"text"
|
||||||
|
} else {
|
||||||
|
sqlx::query("UPDATE proposals SET status = 'applied', entered_at = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&p.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("UPDATE units SET in_force_id = NULL WHERE id = ?")
|
||||||
|
.bind(&unit.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
"remove"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProposalKind::Inertia(target) => {
|
||||||
|
sqlx::query("UPDATE proposals SET status = 'applied', entered_at = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&p.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("UPDATE units SET inertia = ? WHERE id = ?")
|
||||||
|
.bind(target.as_str())
|
||||||
|
.bind(&unit.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
"inertia"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO adoptions (proposal_id, unit_id, doc_id, kind, votes_for, votes_against, electorate, required, params, at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&p.id)
|
||||||
|
.bind(&unit.id)
|
||||||
|
.bind(&st.doc.id)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(i64::from(tally.for_votes))
|
||||||
|
.bind(i64::from(tally.against))
|
||||||
|
.bind(i64::from(st.electorate))
|
||||||
|
.bind(eval.map_or(0, |e| i64::from(e.required)))
|
||||||
|
.bind(serde_json::to_string(¶ms).unwrap_or_default())
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
tracing::info!(doc = %st.doc.id, unit = %unit.code, proposal = %p.id, kind, "entered into force");
|
||||||
|
entered.push(Entered { proposal_id: p.id.clone(), unit_id: unit.id.clone(), code: unit.code.clone(), kind });
|
||||||
|
}
|
||||||
|
if !entered.is_empty() {
|
||||||
|
let codes: Vec<&str> = entered.iter().map(|e| e.code.as_str()).collect();
|
||||||
|
enqueue_publication(state, &st.doc, &format!("entrée en vigueur : {}", codes.join(", "))).await?;
|
||||||
|
}
|
||||||
|
Ok(entered)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The electorate changes (the web of trust grows or shrinks): every few
|
||||||
|
/// minutes, every document is passed through the engine again.
|
||||||
|
pub async fn sweeper(state: AppState) {
|
||||||
|
let mut tick = tokio::time::interval(Duration::from_secs(300));
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
let ids: Vec<String> = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await {
|
||||||
|
Ok(ids) => ids,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "sweeper");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for id in ids {
|
||||||
|
if let Err(e) = run(&state, &id).await {
|
||||||
|
tracing::warn!(doc = %id, error = %e, "sweeper pass");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unit states, re-exported for tests.
|
||||||
|
pub fn unit_states(st: &DocState) -> Vec<UnitState> {
|
||||||
|
st.unit_states()
|
||||||
|
}
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
//! Sign-in: the Ğ1 access of the Monnaie libre forum, invitation links, and a
|
||||||
|
//! local development login.
|
||||||
|
//!
|
||||||
|
//! **Ğ1.** libreDecision is an OAuth2 client of `connect.monnaie-libre.fr`
|
||||||
|
//! (sso-connect): the person signs a one-time challenge in Ğecko, the service
|
||||||
|
//! reads the chain and answers with the on-chain identity. The account is
|
||||||
|
//! keyed on `idty:<index>`, which survives a change of wallet key. Members of
|
||||||
|
//! the web of trust only — sso-connect refuses the others before we see them.
|
||||||
|
//!
|
||||||
|
//! **Invitation.** A single-use link created in trustinWallet by an access
|
||||||
|
//! administrator; it signs a person in as an invited identity (tests, or a
|
||||||
|
//! person without Ğ1 sign-in yet), with the groups the link grants.
|
||||||
|
//!
|
||||||
|
//! **Dev.** `LD_DEV_LOGIN=1` only, never in production.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::util;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{FromRequestParts, Query, State};
|
||||||
|
use axum::http::header::{COOKIE, LOCATION, SET_COOKIE};
|
||||||
|
use axum::http::request::Parts;
|
||||||
|
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use trustin_access::{Identity, IdentityKind};
|
||||||
|
|
||||||
|
pub const SESSION_COOKIE: &str = "ld_session";
|
||||||
|
const OAUTH_COOKIE: &str = "ld_oauth";
|
||||||
|
|
||||||
|
/// Over https, the prefixed names: a neighbouring site under the same parent
|
||||||
|
/// domain cannot plant a `__Host-` cookie (session fixation), nor a
|
||||||
|
/// `__Secure-` one without https.
|
||||||
|
fn session_cookie(state: &AppState) -> &'static str {
|
||||||
|
if state.cfg.secure_cookies() { "__Host-ld_session" } else { SESSION_COOKIE }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn oauth_cookie(state: &AppState) -> &'static str {
|
||||||
|
if state.cfg.secure_cookies() { "__Secure-ld_oauth" } else { OAUTH_COOKIE }
|
||||||
|
}
|
||||||
|
/// A session lasts until it is closed (sign-out, or an administrator): 400 days
|
||||||
|
/// is the longest a browser keeps a cookie, and each visit starts them again.
|
||||||
|
const SESSION_DAYS: i64 = 400;
|
||||||
|
const INVITATION_DAYS: i64 = 14;
|
||||||
|
|
||||||
|
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get_all(COOKIE)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.to_str().ok())
|
||||||
|
.flat_map(|v| v.split(';'))
|
||||||
|
.filter_map(|pair| pair.trim().split_once('='))
|
||||||
|
.find(|(k, _)| *k == name)
|
||||||
|
.map(|(_, v)| v.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cookie(state: &AppState, name: &str, value: &str, path: &str, max_age: i64) -> HeaderValue {
|
||||||
|
let secure = if state.cfg.secure_cookies() { "; Secure" } else { "" };
|
||||||
|
let raw = format!("{name}={value}; Path={path}; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}");
|
||||||
|
HeaderValue::from_str(&raw).unwrap_or_else(|_| HeaderValue::from_static(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct IdentityRow {
|
||||||
|
pub id: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub address: Option<String>,
|
||||||
|
pub g1_member: bool,
|
||||||
|
pub admin: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdentityRow {
|
||||||
|
pub fn to_identity(&self) -> Identity {
|
||||||
|
Identity {
|
||||||
|
id: self.id.clone(),
|
||||||
|
kind: IdentityKind::parse(&self.kind).unwrap_or(IdentityKind::Invite),
|
||||||
|
display_name: self.display_name.clone(),
|
||||||
|
g1_member: self.g1_member,
|
||||||
|
admin: self.admin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const IDENTITY_COLUMNS: &str = "id, kind, display_name, address, g1_member, admin";
|
||||||
|
|
||||||
|
/// The signed-in person, if any.
|
||||||
|
pub struct Viewer(pub Option<IdentityRow>);
|
||||||
|
|
||||||
|
/// The signed-in person; 401 otherwise.
|
||||||
|
pub struct User(pub IdentityRow);
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for Viewer {
|
||||||
|
type Rejection = AppError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||||
|
let Some(token) = cookie_value(&parts.headers, session_cookie(state)) else {
|
||||||
|
return Ok(Self(None));
|
||||||
|
};
|
||||||
|
let row = sqlx::query_as::<_, IdentityRow>(&format!(
|
||||||
|
"SELECT i.{} FROM sessions s JOIN identities i ON i.id = s.identity_id
|
||||||
|
WHERE s.token_hash = ? AND s.expires_at > ?",
|
||||||
|
IDENTITY_COLUMNS.replace(", ", ", i.")
|
||||||
|
))
|
||||||
|
.bind(util::sha256_hex(&token))
|
||||||
|
.bind(util::now())
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(Self(row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for User {
|
||||||
|
type Rejection = AppError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||||
|
match Viewer::from_request_parts(parts, state).await?.0 {
|
||||||
|
Some(row) => Ok(Self(row)),
|
||||||
|
None => Err(AppError::Unauthenticated),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn open_session(state: &AppState, identity_id: &str) -> AppResult<HeaderValue> {
|
||||||
|
let token = util::token();
|
||||||
|
sqlx::query("INSERT INTO sessions (token_hash, identity_id, created_at, expires_at) VALUES (?, ?, ?, ?)")
|
||||||
|
.bind(util::sha256_hex(&token))
|
||||||
|
.bind(identity_id)
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(util::in_days(SESSION_DAYS))
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = ?")
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(identity_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(cookie(state, session_cookie(state), &token, "/", SESSION_DAYS * 86_400))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_identity(state: &AppState, id: &str) -> AppResult<IdentityRow> {
|
||||||
|
sqlx::query_as::<_, IdentityRow>(&format!("SELECT {IDENTITY_COLUMNS} FROM identities WHERE id = ?"))
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn grant_groups(
|
||||||
|
state: &AppState,
|
||||||
|
identity_id: &str,
|
||||||
|
groups: &[String],
|
||||||
|
by: Option<&str>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
for slug in groups {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO memberships (group_slug, identity_id, granted_by, granted_at)
|
||||||
|
SELECT slug, ?, ?, ? FROM groups WHERE slug = ?",
|
||||||
|
)
|
||||||
|
.bind(identity_id)
|
||||||
|
.bind(by)
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(slug)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AuthConfig {
|
||||||
|
g1: bool,
|
||||||
|
dev: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn config(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||||
|
Json(json!(AuthConfig { g1: state.cfg.connect.is_some(), dev: state.cfg.dev_login }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hash of the session this request comes with, if any.
|
||||||
|
pub(crate) fn session_hash(state: &AppState, headers: &HeaderMap) -> Option<String> {
|
||||||
|
cookie_value(headers, session_cookie(state)).map(|token| util::sha256_hex(&token))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who is signed in. Once a day at most, the visit starts the session's days
|
||||||
|
/// again, in the database and in the browser. A renewal that fails is logged,
|
||||||
|
/// never answered as signed out.
|
||||||
|
pub async fn me(State(state): State<AppState>, headers: HeaderMap, Viewer(viewer): Viewer) -> Response {
|
||||||
|
let mut res = Json(json!({ "me": viewer })).into_response();
|
||||||
|
if let (Some(me), Some(token)) = (&viewer, cookie_value(&headers, session_cookie(&state))) {
|
||||||
|
match renew(&state, &token).await {
|
||||||
|
Ok(true) => {
|
||||||
|
res.headers_mut()
|
||||||
|
.append(SET_COOKIE, cookie(&state, session_cookie(&state), &token, "/", SESSION_DAYS * 86_400));
|
||||||
|
let seen = sqlx::query("UPDATE identities SET last_seen_at = ? WHERE id = ?")
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(&me.id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
if let Err(e) = seen {
|
||||||
|
tracing::warn!(error = %e, "last visit not recorded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "session not renewed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the session was renewed: still open, and renewed more than a day ago.
|
||||||
|
async fn renew(state: &AppState, token: &str) -> AppResult<bool> {
|
||||||
|
let renewed =
|
||||||
|
sqlx::query("UPDATE sessions SET expires_at = ? WHERE token_hash = ? AND expires_at > ? AND expires_at < ?")
|
||||||
|
.bind(util::in_days(SESSION_DAYS))
|
||||||
|
.bind(util::sha256_hex(token))
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(util::in_days(SESSION_DAYS - 1))
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(renewed.rows_affected() == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only local paths, never `//host` nor a scheme.
|
||||||
|
fn safe_return(raw: Option<&str>) -> String {
|
||||||
|
match raw {
|
||||||
|
// Browsers drop tabs and newlines in URLs: "/\t/evil" would become "//evil".
|
||||||
|
Some(p)
|
||||||
|
if p.starts_with('/')
|
||||||
|
&& !p.starts_with("//")
|
||||||
|
&& !p.contains('\\')
|
||||||
|
&& !p.chars().any(char::is_control)
|
||||||
|
&& p.len() < 512 =>
|
||||||
|
{
|
||||||
|
p.to_owned()
|
||||||
|
}
|
||||||
|
_ => "/groupes".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct StartQuery {
|
||||||
|
#[serde(rename = "return")]
|
||||||
|
return_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redirect(to: &str, cookies: Vec<HeaderValue>) -> Response {
|
||||||
|
let mut res = StatusCode::FOUND.into_response();
|
||||||
|
if let Ok(v) = HeaderValue::from_str(to) {
|
||||||
|
res.headers_mut().insert(LOCATION, v);
|
||||||
|
}
|
||||||
|
for c in cookies {
|
||||||
|
res.headers_mut().append(SET_COOKIE, c);
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 1: send the browser to the approval page of connect.monnaie-libre.fr.
|
||||||
|
pub async fn g1_start(State(state): State<AppState>, Query(q): Query<StartQuery>) -> AppResult<Response> {
|
||||||
|
let connect = state.cfg.connect.as_ref().ok_or(AppError::Unavailable("g1_not_configured"))?;
|
||||||
|
let csrf = util::token();
|
||||||
|
let back = safe_return(q.return_to.as_deref());
|
||||||
|
let mut url = url::Url::parse(&format!("{}/oauth/authorize", connect.base_url)).map_err(anyhow::Error::from)?;
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("response_type", "code")
|
||||||
|
.append_pair("client_id", &connect.client_id)
|
||||||
|
.append_pair("redirect_uri", &state.cfg.connect_callback())
|
||||||
|
.append_pair("state", &csrf);
|
||||||
|
let stash = format!("{csrf}.{}", url::form_urlencoded::byte_serialize(back.as_bytes()).collect::<String>());
|
||||||
|
Ok(redirect(url.as_str(), vec![cookie(&state, oauth_cookie(&state), &stash, "/api/auth/g1", 600)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CallbackQuery {
|
||||||
|
code: Option<String>,
|
||||||
|
state: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TokenResponse {
|
||||||
|
access_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What sso-connect's `/oauth/userinfo` answers — and nothing else.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UserInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub address: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub member: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 3: check the state, exchange the code, read the identity, open a session.
|
||||||
|
pub async fn g1_callback(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(q): Query<CallbackQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let clear = cookie(&state, oauth_cookie(&state), "", "/api/auth/g1", 0);
|
||||||
|
let stash = cookie_value(&headers, oauth_cookie(&state)).unwrap_or_default();
|
||||||
|
let (expected, back) = stash.split_once('.').unwrap_or(("", ""));
|
||||||
|
let back =
|
||||||
|
safe_return(url::form_urlencoded::parse(format!("p={back}").as_bytes()).next().map(|(_, v)| v).as_deref());
|
||||||
|
let fail = |code: &str| redirect(&format!("/connexion?erreur={code}"), vec![clear.clone()]);
|
||||||
|
if let Some(err) = q.error.as_deref() {
|
||||||
|
tracing::warn!(error = err, "connect returned an error");
|
||||||
|
return fail("refus");
|
||||||
|
}
|
||||||
|
let (Some(code), Some(got)) = (q.code, q.state) else { return fail("incomplet") };
|
||||||
|
if expected.is_empty() || expected != got {
|
||||||
|
return fail("etat");
|
||||||
|
}
|
||||||
|
match exchange(&state, &code).await {
|
||||||
|
Ok(info) => match sign_in_g1(&state, info).await {
|
||||||
|
Ok(session) => redirect(&back, vec![clear, session]),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "g1 sign-in");
|
||||||
|
fail("interne")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "code exchange");
|
||||||
|
fail("echange")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exchange(state: &AppState, code: &str) -> anyhow::Result<UserInfo> {
|
||||||
|
let connect = state.cfg.connect.as_ref().ok_or_else(|| anyhow::anyhow!("not configured"))?;
|
||||||
|
let token: TokenResponse = state
|
||||||
|
.http
|
||||||
|
.post(format!("{}/oauth/token", connect.base_url))
|
||||||
|
.basic_auth(&connect.client_id, Some(&connect.client_secret))
|
||||||
|
.form(&[("grant_type", "authorization_code"), ("code", code), ("redirect_uri", &state.cfg.connect_callback())])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
let info: UserInfo = state
|
||||||
|
.http
|
||||||
|
.get(format!("{}/oauth/userinfo", connect.base_url))
|
||||||
|
.bearer_auth(token.access_token)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
anyhow::ensure!(info.id.starts_with("idty:"), "unexpected identity key");
|
||||||
|
Ok(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sign_in_g1(state: &AppState, info: UserInfo) -> AppResult<HeaderValue> {
|
||||||
|
let admin = state.cfg.admin_g1_ids.iter().any(|a| a == &info.id);
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO identities (id, kind, display_name, address, g1_member, admin, created_at)
|
||||||
|
VALUES (?, 'g1', ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET display_name = excluded.display_name, address = excluded.address,
|
||||||
|
g1_member = excluded.g1_member, admin = MAX(identities.admin, excluded.admin)",
|
||||||
|
)
|
||||||
|
.bind(&info.id)
|
||||||
|
.bind(&info.username)
|
||||||
|
.bind(&info.address)
|
||||||
|
.bind(info.member)
|
||||||
|
.bind(admin)
|
||||||
|
.bind(util::now())
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
open_session(state, &info.id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct InvitationBody {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct InvitationRow {
|
||||||
|
token_hash: String,
|
||||||
|
label: String,
|
||||||
|
admin: bool,
|
||||||
|
groups: String,
|
||||||
|
created_by: Option<String>,
|
||||||
|
identity_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn accept_invitation(State(state): State<AppState>, Json(body): Json<InvitationBody>) -> AppResult<Response> {
|
||||||
|
let hash = util::sha256_hex(body.token.trim());
|
||||||
|
let now = util::now();
|
||||||
|
// Reserve the link first: of two simultaneous acceptances, one only goes on.
|
||||||
|
let reserved =
|
||||||
|
sqlx::query("UPDATE invitations SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?")
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&hash)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
if reserved.rows_affected() != 1 {
|
||||||
|
return Err(AppError::Invalid("invitation_invalid"));
|
||||||
|
}
|
||||||
|
let inv = sqlx::query_as::<_, InvitationRow>(
|
||||||
|
"SELECT token_hash, label, admin, groups, created_by, identity_id FROM invitations WHERE token_hash = ?",
|
||||||
|
)
|
||||||
|
.bind(&hash)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::Invalid("invitation_invalid"))?;
|
||||||
|
|
||||||
|
let identity_id = match inv.identity_id.clone() {
|
||||||
|
Some(existing) => {
|
||||||
|
if load_identity(&state, &existing).await?.kind != "invite" {
|
||||||
|
return Err(AppError::Invalid("invitation_invalid"));
|
||||||
|
}
|
||||||
|
existing
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let id = format!("inv:{}", util::id());
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at) VALUES (?, 'invite', ?, 0, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&inv.label)
|
||||||
|
.bind(inv.admin)
|
||||||
|
.bind(util::now())
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if inv.admin {
|
||||||
|
sqlx::query("UPDATE identities SET admin = 1 WHERE id = ?").bind(&identity_id).execute(&state.db).await?;
|
||||||
|
}
|
||||||
|
let groups: Vec<String> = serde_json::from_str(&inv.groups).unwrap_or_default();
|
||||||
|
grant_groups(&state, &identity_id, &groups, inv.created_by.as_deref()).await?;
|
||||||
|
sqlx::query("UPDATE invitations SET identity_id = ? WHERE token_hash = ?")
|
||||||
|
.bind(&identity_id)
|
||||||
|
.bind(&inv.token_hash)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
let session = open_session(&state, &identity_id).await?;
|
||||||
|
let me = load_identity(&state, &identity_id).await?;
|
||||||
|
let mut res = Json(json!({ "me": me })).into_response();
|
||||||
|
res.headers_mut().append(SET_COOKIE, session);
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an invitation; returns the raw token, shown once.
|
||||||
|
pub async fn create_invitation(
|
||||||
|
state: &AppState,
|
||||||
|
label: &str,
|
||||||
|
admin: bool,
|
||||||
|
groups: &[String],
|
||||||
|
created_by: Option<&str>,
|
||||||
|
identity_id: Option<&str>,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
let token = util::token();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO invitations (token_hash, label, admin, groups, created_by, created_at, expires_at, identity_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(util::sha256_hex(&token))
|
||||||
|
.bind(label)
|
||||||
|
.bind(admin)
|
||||||
|
.bind(serde_json::to_string(groups).unwrap_or_else(|_| "[]".into()))
|
||||||
|
.bind(created_by)
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(util::in_days(INVITATION_DAYS))
|
||||||
|
.bind(identity_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn invitation_link(state: &AppState, token: &str) -> String {
|
||||||
|
format!("{}/invitation#{token}", state.cfg.public_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DevBody {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
admin: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local development: any name, member of every group. Refused unless LD_DEV_LOGIN=1.
|
||||||
|
pub async fn dev_login(State(state): State<AppState>, Json(body): Json<DevBody>) -> AppResult<Response> {
|
||||||
|
if !state.cfg.dev_login {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
let name = body.name.trim();
|
||||||
|
if name.is_empty() || name.len() > 40 {
|
||||||
|
return Err(AppError::Invalid("name"));
|
||||||
|
}
|
||||||
|
let slug: String = name.to_lowercase().chars().map(|c| if c.is_alphanumeric() { c } else { '-' }).collect();
|
||||||
|
let id = format!("dev:{slug}");
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at) VALUES (?, 'dev', ?, 0, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET admin = excluded.admin",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(body.admin)
|
||||||
|
.bind(util::now())
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
let all: Vec<String> = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?;
|
||||||
|
grant_groups(&state, &id, &all, Some("dev")).await?;
|
||||||
|
let session = open_session(&state, &id).await?;
|
||||||
|
let me = load_identity(&state, &id).await?;
|
||||||
|
let mut res = Json(json!({ "me": me })).into_response();
|
||||||
|
res.headers_mut().append(SET_COOKIE, session);
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Response> {
|
||||||
|
if let Some(token) = cookie_value(&headers, session_cookie(&state)) {
|
||||||
|
sqlx::query("DELETE FROM sessions WHERE token_hash = ?")
|
||||||
|
.bind(util::sha256_hex(&token))
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
let mut res = Json(json!({ "me": null })).into_response();
|
||||||
|
res.headers_mut().append(SET_COOKIE, cookie(&state, session_cookie(&state), "", "/", 0));
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First start with LD_BOOTSTRAP_ADMIN=1 and no administrator yet: write a
|
||||||
|
/// single-use administrator invitation to `<data>/bootstrap-admin.txt` (0600).
|
||||||
|
/// Nothing is printed to the logs.
|
||||||
|
pub async fn bootstrap(state: &AppState) -> anyhow::Result<()> {
|
||||||
|
if !state.cfg.bootstrap_admin {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let admins: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE admin = 1").fetch_one(&state.db).await?;
|
||||||
|
let pending: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM invitations WHERE admin = 1 AND used_at IS NULL AND expires_at > ?")
|
||||||
|
.bind(util::now())
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
if admins > 0 || pending > 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let groups: Vec<String> = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?;
|
||||||
|
let token = create_invitation(state, "Yvv", true, &groups, None, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("bootstrap invitation: {e}"))?;
|
||||||
|
let path = state.cfg.data_dir.join("bootstrap-admin.txt");
|
||||||
|
write_private(&path, &format!("{}\n", invitation_link(state, &token)))?;
|
||||||
|
tracing::info!(path = %path.display(), "administrator invitation written");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_private(path: &std::path::Path, content: &str) -> std::io::Result<()> {
|
||||||
|
use std::io::Write;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(path)?;
|
||||||
|
f.write_all(content.as_bytes())
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
std::fs::write(path, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn return_paths_stay_local() {
|
||||||
|
assert_eq!(safe_return(Some("/g/monnaie-libre")), "/g/monnaie-libre");
|
||||||
|
assert_eq!(safe_return(Some("//evil.example")), "/groupes");
|
||||||
|
assert_eq!(safe_return(Some("https://evil.example")), "/groupes");
|
||||||
|
assert_eq!(safe_return(Some("/\\evil")), "/groupes");
|
||||||
|
assert_eq!(safe_return(Some("/\t/evil.example")), "/groupes");
|
||||||
|
assert_eq!(safe_return(Some("/\n/evil.example")), "/groupes");
|
||||||
|
assert_eq!(safe_return(None), "/groupes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_session_over_is_never_renewed() {
|
||||||
|
let db = crate::open_db("sqlite::memory:").await.expect("db");
|
||||||
|
let state = AppState::new(crate::Config::for_tests(), db).await.expect("state");
|
||||||
|
sqlx::query("INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at) VALUES ('dev:a', 'dev', 'A', 0, 0, ?)")
|
||||||
|
.bind(util::now())
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("identity");
|
||||||
|
for (token, expires) in [("over", util::in_seconds(-1)), ("open", util::in_days(10))] {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO sessions (token_hash, identity_id, created_at, expires_at) VALUES (?, 'dev:a', ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(util::sha256_hex(token))
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(expires)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("session");
|
||||||
|
}
|
||||||
|
assert!(!renew(&state, "over").await.expect("renew"));
|
||||||
|
assert!(renew(&state, "open").await.expect("renew"));
|
||||||
|
assert!(!renew(&state, "open").await.expect("renew"), "once a day at most");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cookie_parsing() {
|
||||||
|
let mut h = HeaderMap::new();
|
||||||
|
h.insert(COOKIE, HeaderValue::from_static("a=1; ld_session=abc; b=2"));
|
||||||
|
assert_eq!(cookie_value(&h, SESSION_COOKIE).as_deref(), Some("abc"));
|
||||||
|
assert_eq!(cookie_value(&h, "zz"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! Configuration, from the environment only. Secrets never have a default.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Connect {
|
||||||
|
/// Base URL of the sso-connect instance (no trailing slash).
|
||||||
|
pub base_url: String,
|
||||||
|
pub client_id: String,
|
||||||
|
pub client_secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Publish {
|
||||||
|
/// Remote of the texts repository, e.g. `gitea@git.open.us.org:yvv/decision-depot-textes-reference.git`.
|
||||||
|
pub remote: String,
|
||||||
|
pub branch: String,
|
||||||
|
/// Private SSH key allowed to push there (deploy key).
|
||||||
|
pub key: PathBuf,
|
||||||
|
pub author_name: String,
|
||||||
|
pub author_email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
pub bind: String,
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
pub web_dir: PathBuf,
|
||||||
|
pub seed_dir: PathBuf,
|
||||||
|
/// Public origin, e.g. `https://decision.librodrome.org` — callbacks and cookies.
|
||||||
|
pub public_url: String,
|
||||||
|
pub connect: Option<Connect>,
|
||||||
|
pub admin_g1_ids: Vec<String>,
|
||||||
|
pub dev_login: bool,
|
||||||
|
pub bootstrap_admin: bool,
|
||||||
|
pub publish: Option<Publish>,
|
||||||
|
pub rpc_endpoints: Vec<String>,
|
||||||
|
pub indexers: Vec<String>,
|
||||||
|
pub wot_fallback: u32,
|
||||||
|
pub build: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn var(name: &str) -> Option<String> {
|
||||||
|
std::env::var(name).ok().map(|v| v.trim().to_owned()).filter(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flag(name: &str) -> bool {
|
||||||
|
var(name).is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list(name: &str, default: &[&str]) -> Vec<String> {
|
||||||
|
match var(name) {
|
||||||
|
Some(v) => v.split(',').map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()).collect(),
|
||||||
|
None => default.iter().map(|s| (*s).to_owned()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let data_dir = PathBuf::from(var("LD_DATA_DIR").unwrap_or_else(|| "data".into()));
|
||||||
|
let connect = match (var("LD_CONNECT_CLIENT_ID"), var("LD_CONNECT_CLIENT_SECRET")) {
|
||||||
|
(Some(client_id), Some(client_secret)) => Some(Connect {
|
||||||
|
base_url: var("LD_CONNECT_URL")
|
||||||
|
.unwrap_or_else(|| "https://connect.monnaie-libre.fr".into())
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_owned(),
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let publish = var("LD_PUBLISH_REMOTE").map(|remote| Publish {
|
||||||
|
remote,
|
||||||
|
branch: var("LD_PUBLISH_BRANCH").unwrap_or_else(|| "main".into()),
|
||||||
|
key: var("LD_PUBLISH_KEY").map(PathBuf::from).unwrap_or_else(|| data_dir.join("keys/publish_ed25519")),
|
||||||
|
author_name: var("LD_PUBLISH_AUTHOR").unwrap_or_else(|| "libreDecision".into()),
|
||||||
|
author_email: var("LD_PUBLISH_EMAIL").unwrap_or_else(|| "decision@librodrome.org".into()),
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
bind: var("LD_BIND").unwrap_or_else(|| "0.0.0.0:8002".into()),
|
||||||
|
web_dir: PathBuf::from(var("LD_WEB_DIR").unwrap_or_else(|| "web/build".into())),
|
||||||
|
seed_dir: PathBuf::from(var("LD_SEED_DIR").unwrap_or_else(|| "seeds".into())),
|
||||||
|
public_url: var("LD_PUBLIC_URL")
|
||||||
|
.unwrap_or_else(|| "http://127.0.0.1:3002".into())
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_owned(),
|
||||||
|
connect,
|
||||||
|
admin_g1_ids: list("LD_ADMIN_G1_IDS", &[]),
|
||||||
|
dev_login: flag("LD_DEV_LOGIN"),
|
||||||
|
bootstrap_admin: flag("LD_BOOTSTRAP_ADMIN"),
|
||||||
|
publish,
|
||||||
|
rpc_endpoints: list(
|
||||||
|
"LD_RPC_ENDPOINTS",
|
||||||
|
&[
|
||||||
|
"https://rpc.duniter.org",
|
||||||
|
"https://g1.axiom-team.fr/ws/",
|
||||||
|
"https://g1.asycn.io/ws/",
|
||||||
|
"https://g1.coinduf.eu",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
indexers: list(
|
||||||
|
"LD_INDEXERS",
|
||||||
|
&["https://indexer.duniter.org/v1/graphql", "https://g1-squid.axiom-team.fr/v1/graphql"],
|
||||||
|
),
|
||||||
|
wot_fallback: var("LD_WOT_FALLBACK").and_then(|v| v.parse().ok()).unwrap_or(5745),
|
||||||
|
build: var("LD_BUILD").unwrap_or_else(|| "dev".into()),
|
||||||
|
data_dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cookies are `Secure` as soon as the public origin is https.
|
||||||
|
pub fn secure_cookies(&self) -> bool {
|
||||||
|
self.public_url.starts_with("https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn connect_callback(&self) -> String {
|
||||||
|
format!("{}/api/auth/g1/callback", self.public_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For tests: an in-memory configuration with dev login.
|
||||||
|
pub fn for_tests() -> Self {
|
||||||
|
Self {
|
||||||
|
bind: "127.0.0.1:0".into(),
|
||||||
|
data_dir: std::env::temp_dir().join("ld-tests"),
|
||||||
|
web_dir: PathBuf::from("web/build"),
|
||||||
|
seed_dir: PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../seeds")),
|
||||||
|
public_url: "http://127.0.0.1:3002".into(),
|
||||||
|
connect: None,
|
||||||
|
admin_g1_ids: vec!["idty:8352".into()],
|
||||||
|
dev_login: true,
|
||||||
|
bootstrap_admin: false,
|
||||||
|
publish: None,
|
||||||
|
rpc_endpoints: vec![],
|
||||||
|
indexers: vec![],
|
||||||
|
wot_fallback: 5745,
|
||||||
|
build: "test".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
//! Reading: groups, tools, documents, previews.
|
||||||
|
//!
|
||||||
|
//! The state of a document is loaded once (`DocState`) and turned into the
|
||||||
|
//! engine's pure types; every number shown on screen comes from the engine.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::{IdentityRow, User};
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::util::{self, LangMap};
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, Query, State};
|
||||||
|
use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText};
|
||||||
|
use ld_engine::threshold::{Evaluation, InertiaPreset, Tally, evaluate};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
|
use trustin_access::{Group, GroupPolicy};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||||
|
pub struct GroupRow {
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
pub parent: Option<String>,
|
||||||
|
pub policy: String,
|
||||||
|
pub hidden: bool,
|
||||||
|
pub position: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GroupRow {
|
||||||
|
pub fn to_group(&self) -> Group {
|
||||||
|
Group {
|
||||||
|
slug: self.slug.clone(),
|
||||||
|
name: self.name.clone(),
|
||||||
|
parent: self.parent.clone(),
|
||||||
|
policy: GroupPolicy::parse(&self.policy).unwrap_or(GroupPolicy::Managed),
|
||||||
|
hidden: self.hidden,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn granted(state: &AppState, group: &str, identity: &str) -> AppResult<bool> {
|
||||||
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM memberships WHERE group_slug = ? AND identity_id = ?")
|
||||||
|
.bind(group)
|
||||||
|
.bind(identity)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(n > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_group(state: &AppState, slug: &str) -> AppResult<GroupRow> {
|
||||||
|
sqlx::query_as::<_, GroupRow>("SELECT slug, name, parent, policy, hidden, position FROM groups WHERE slug = ?")
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 404 for a group one cannot see, 403 for one seen but not open.
|
||||||
|
pub async fn require_open(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<()> {
|
||||||
|
let id = user.to_identity();
|
||||||
|
let g = group.to_group();
|
||||||
|
let has = granted(state, &group.slug, &user.id).await?;
|
||||||
|
if !trustin_access::sees(&id, &g, has) {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
if !trustin_access::opens(&id, &g, has) {
|
||||||
|
return Err(AppError::Forbidden);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// May this identity vote in the group (see `trustin_access::votes_in`)?
|
||||||
|
pub async fn may_vote(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<bool> {
|
||||||
|
let has = granted(state, &group.slug, &user.id).await?;
|
||||||
|
Ok(trustin_access::votes_in(&user.to_identity(), &group.to_group(), has))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct GroupView {
|
||||||
|
slug: String,
|
||||||
|
name: String,
|
||||||
|
parent: Option<String>,
|
||||||
|
policy: String,
|
||||||
|
open: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn groups(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let rows = sqlx::query_as::<_, GroupRow>(
|
||||||
|
"SELECT slug, name, parent, policy, hidden, position FROM groups ORDER BY position",
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let id = user.to_identity();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let has = granted(&state, &row.slug, &user.id).await?;
|
||||||
|
let g = row.to_group();
|
||||||
|
if trustin_access::sees(&id, &g, has) {
|
||||||
|
out.push(GroupView {
|
||||||
|
open: trustin_access::opens(&id, &g, has),
|
||||||
|
slug: row.slug,
|
||||||
|
name: row.name,
|
||||||
|
parent: row.parent,
|
||||||
|
policy: row.policy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "groups": out })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DocListRow {
|
||||||
|
id: String,
|
||||||
|
slug: String,
|
||||||
|
#[sqlx(rename = "title")]
|
||||||
|
#[serde(skip)]
|
||||||
|
title_raw: String,
|
||||||
|
#[sqlx(rename = "summary")]
|
||||||
|
#[serde(skip)]
|
||||||
|
summary_raw: String,
|
||||||
|
in_force: i64,
|
||||||
|
open: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tools of a group. Only "Docs de référence" is active for now.
|
||||||
|
pub const TOOLS: [(&str, bool); 4] = [("decisions", false), ("docs", true), ("mandats", false), ("protocoles", false)];
|
||||||
|
|
||||||
|
pub async fn group(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let row = load_group(&state, &slug).await?;
|
||||||
|
let id = user.to_identity();
|
||||||
|
let has = granted(&state, &slug, &user.id).await?;
|
||||||
|
let g = row.to_group();
|
||||||
|
if !trustin_access::sees(&id, &g, has) {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
let open = trustin_access::opens(&id, &g, has);
|
||||||
|
let docs = if open {
|
||||||
|
sqlx::query_as::<_, DocListRow>(
|
||||||
|
"SELECT d.id, d.slug, d.title, d.summary,
|
||||||
|
(SELECT COUNT(*) FROM units u WHERE u.doc_id = d.id AND u.in_force_id IS NOT NULL) AS in_force,
|
||||||
|
(SELECT COUNT(*) FROM proposals p JOIN units u ON u.id = p.unit_id
|
||||||
|
WHERE u.doc_id = d.id AND p.status = 'open') AS open
|
||||||
|
FROM documents d WHERE d.group_slug = ? ORDER BY d.created_at",
|
||||||
|
)
|
||||||
|
.bind(&slug)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|d| {
|
||||||
|
json!({
|
||||||
|
"id": d.id, "slug": d.slug,
|
||||||
|
"title": util::parse_lang(&d.title_raw), "summary": util::parse_lang(&d.summary_raw),
|
||||||
|
"inForce": d.in_force, "open": d.open,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let tools: Vec<_> = TOOLS.iter().map(|(key, active)| json!({ "key": key, "active": active })).collect();
|
||||||
|
Ok(Json(json!({
|
||||||
|
"group": { "slug": row.slug, "name": row.name, "parent": row.parent, "policy": row.policy, "open": open },
|
||||||
|
"tools": tools,
|
||||||
|
"docs": docs,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Document state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||||
|
pub struct DocRow {
|
||||||
|
pub id: String,
|
||||||
|
pub group_slug: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub title: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub source_lang: String,
|
||||||
|
pub vote_floor: f64,
|
||||||
|
pub publish_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||||
|
pub struct UnitRow {
|
||||||
|
pub id: String,
|
||||||
|
pub code: String,
|
||||||
|
pub section: Option<String>,
|
||||||
|
pub position: f64,
|
||||||
|
pub inertia: String,
|
||||||
|
pub in_force_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||||
|
pub struct ProposalRow {
|
||||||
|
pub id: String,
|
||||||
|
pub unit_id: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub inertia_target: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub status: String,
|
||||||
|
pub origin: String,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub author_id: Option<String>,
|
||||||
|
pub author_name: Option<String>,
|
||||||
|
/// The author came by invitation, not with a Ğ1 key: shown next to the name,
|
||||||
|
/// so a name taken ahead of its owner, or a look-alike, does not pass for them.
|
||||||
|
pub author_invited: Option<bool>,
|
||||||
|
pub revision: i64,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub submitted_at: Option<String>,
|
||||||
|
pub entered_at: Option<String>,
|
||||||
|
pub confirmed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const PROPOSAL_SELECT: &str =
|
||||||
|
"SELECT p.id, p.unit_id, p.kind, p.inertia_target, p.title, p.body, p.status, p.origin,
|
||||||
|
p.label, p.author_id, i.display_name AS author_name, i.kind = 'invite' AS author_invited, p.revision, p.created_at, p.updated_at, p.submitted_at,
|
||||||
|
p.entered_at, p.confirmed_at
|
||||||
|
FROM proposals p LEFT JOIN identities i ON i.id = p.author_id";
|
||||||
|
|
||||||
|
impl ProposalRow {
|
||||||
|
pub fn kind(&self) -> ProposalKind {
|
||||||
|
match self.kind.as_str() {
|
||||||
|
"remove" => ProposalKind::Remove,
|
||||||
|
"inertia" => ProposalKind::Inertia(
|
||||||
|
self.inertia_target.as_deref().and_then(InertiaPreset::parse).unwrap_or(InertiaPreset::Standard),
|
||||||
|
),
|
||||||
|
_ => ProposalKind::Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inertia_of(unit: &UnitRow) -> InertiaPreset {
|
||||||
|
InertiaPreset::parse(&unit.inertia).unwrap_or(InertiaPreset::Standard)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DocState {
|
||||||
|
pub doc: DocRow,
|
||||||
|
pub group: GroupRow,
|
||||||
|
pub units: Vec<UnitRow>,
|
||||||
|
pub proposals: Vec<ProposalRow>,
|
||||||
|
pub tallies: HashMap<String, Tally>,
|
||||||
|
pub blocked: HashSet<String>,
|
||||||
|
pub signal_counts: HashMap<String, i64>,
|
||||||
|
pub electorate: u32,
|
||||||
|
pub electorate_source: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_doc(state: &AppState, doc_id: &str) -> AppResult<DocRow> {
|
||||||
|
sqlx::query_as::<_, DocRow>(
|
||||||
|
"SELECT id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path FROM documents WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(doc_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// W: the Ğ1 web of trust for Monnaie libre, the members of the group otherwise.
|
||||||
|
pub async fn electorate(state: &AppState, group: &GroupRow) -> AppResult<(u32, &'static str)> {
|
||||||
|
if group.policy == GroupPolicy::G1Members.as_str() {
|
||||||
|
let cached: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT value FROM settings WHERE key = 'wot_size'").fetch_optional(&state.db).await?;
|
||||||
|
let w = cached.and_then(|v| v.parse().ok()).unwrap_or(state.cfg.wot_fallback);
|
||||||
|
return Ok((w.max(1), "wot"));
|
||||||
|
}
|
||||||
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM memberships WHERE group_slug = ?")
|
||||||
|
.bind(&group.slug)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok((u32::try_from(n).unwrap_or(u32::MAX).max(1), "group"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
||||||
|
let doc = load_doc(state, doc_id).await?;
|
||||||
|
let group = load_group(state, &doc.group_slug).await?;
|
||||||
|
let units = sqlx::query_as::<_, UnitRow>(
|
||||||
|
"SELECT id, code, section, position, inertia, in_force_id FROM units WHERE doc_id = ? ORDER BY position, id",
|
||||||
|
)
|
||||||
|
.bind(doc_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let proposals = sqlx::query_as::<_, ProposalRow>(&format!(
|
||||||
|
"{PROPOSAL_SELECT} WHERE p.unit_id IN (SELECT id FROM units WHERE doc_id = ?)
|
||||||
|
AND p.status IN ('open', 'in_force', 'draft') ORDER BY p.submitted_at, p.created_at, p.id"
|
||||||
|
))
|
||||||
|
.bind(doc_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct TallyRow {
|
||||||
|
proposal_id: String,
|
||||||
|
choice: String,
|
||||||
|
n: i64,
|
||||||
|
}
|
||||||
|
let rows = sqlx::query_as::<_, TallyRow>(
|
||||||
|
"SELECT v.proposal_id, v.choice, COUNT(*) AS n FROM votes v JOIN proposals p ON p.id = v.proposal_id
|
||||||
|
JOIN units u ON u.id = p.unit_id
|
||||||
|
WHERE u.doc_id = ? AND v.revision = p.revision GROUP BY v.proposal_id, v.choice",
|
||||||
|
)
|
||||||
|
.bind(doc_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let mut tallies: HashMap<String, Tally> = HashMap::new();
|
||||||
|
for r in rows {
|
||||||
|
let t = tallies.entry(r.proposal_id).or_default();
|
||||||
|
let n = u32::try_from(r.n).unwrap_or(0);
|
||||||
|
match r.choice.as_str() {
|
||||||
|
"for" => t.for_votes = n,
|
||||||
|
"against" => t.against = n,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct SignalRow {
|
||||||
|
proposal_id: String,
|
||||||
|
target_proposal_id: String,
|
||||||
|
}
|
||||||
|
let signals = sqlx::query_as::<_, SignalRow>(
|
||||||
|
"SELECT s.proposal_id, s.target_proposal_id FROM signals s JOIN proposals p ON p.id = s.proposal_id
|
||||||
|
JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ? AND s.lifted_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(doc_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let in_force: HashSet<&str> = units.iter().filter_map(|u| u.in_force_id.as_deref()).collect();
|
||||||
|
let mut blocked = HashSet::new();
|
||||||
|
let mut signal_counts: HashMap<String, i64> = HashMap::new();
|
||||||
|
for s in &signals {
|
||||||
|
*signal_counts.entry(s.proposal_id.clone()).or_default() += 1;
|
||||||
|
*signal_counts.entry(s.target_proposal_id.clone()).or_default() += 1;
|
||||||
|
// Two texts that contradict each other never stand in force together.
|
||||||
|
if in_force.contains(s.target_proposal_id.as_str()) {
|
||||||
|
blocked.insert(s.proposal_id.clone());
|
||||||
|
}
|
||||||
|
if in_force.contains(s.proposal_id.as_str()) {
|
||||||
|
blocked.insert(s.target_proposal_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (electorate, electorate_source) = electorate(state, &group).await?;
|
||||||
|
Ok(DocState { doc, group, units, proposals, tallies, blocked, signal_counts, electorate, electorate_source })
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DocState {
|
||||||
|
pub fn tally(&self, id: &str) -> Tally {
|
||||||
|
self.tallies.get(id).copied().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unit_states(&self) -> Vec<UnitState> {
|
||||||
|
let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect();
|
||||||
|
self.units
|
||||||
|
.iter()
|
||||||
|
.map(|u| UnitState {
|
||||||
|
id: u.id.clone(),
|
||||||
|
position: u.position,
|
||||||
|
inertia: inertia_of(u),
|
||||||
|
in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| VersionText {
|
||||||
|
id: p.id.clone(),
|
||||||
|
title: p.title.clone(),
|
||||||
|
body: p.body.clone(),
|
||||||
|
tally: self.tally(&p.id),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open proposals only — the in-force versions compete as incumbents.
|
||||||
|
pub fn proposal_states(&self) -> Vec<ProposalState> {
|
||||||
|
self.proposals
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.status == "open")
|
||||||
|
.map(|p| ProposalState {
|
||||||
|
id: p.id.clone(),
|
||||||
|
unit_id: p.unit_id.clone(),
|
||||||
|
kind: p.kind(),
|
||||||
|
title: p.title.clone(),
|
||||||
|
body: p.body.clone(),
|
||||||
|
tally: self.tally(&p.id),
|
||||||
|
submitted_at: p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()),
|
||||||
|
blocked: self.blocked.contains(&p.id),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
|
||||||
|
let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None };
|
||||||
|
evaluate(self.tally(&p.id), self.electorate, &compose::params_for(&us, p.kind(), self.doc.vote_floor)).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Document view
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct TranslationRow {
|
||||||
|
proposal_id: String,
|
||||||
|
lang: String,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
source_hash: String,
|
||||||
|
origin: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn translations_for(
|
||||||
|
state: &AppState,
|
||||||
|
ids: &[&ProposalRow],
|
||||||
|
) -> AppResult<HashMap<String, BTreeMap<String, serde_json::Value>>> {
|
||||||
|
let mut out: HashMap<String, BTreeMap<String, serde_json::Value>> = HashMap::new();
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
let marks = vec!["?"; ids.len()].join(",");
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT proposal_id, lang, title, body, source_hash, origin FROM translations WHERE proposal_id IN ({marks})"
|
||||||
|
);
|
||||||
|
let mut q = sqlx::query_as::<_, TranslationRow>(&sql);
|
||||||
|
for p in ids {
|
||||||
|
q = q.bind(&p.id);
|
||||||
|
}
|
||||||
|
let rows = q.fetch_all(&state.db).await?;
|
||||||
|
let current: HashMap<&str, String> =
|
||||||
|
ids.iter().map(|p| (p.id.as_str(), util::source_hash(&p.title, &p.body))).collect();
|
||||||
|
for r in rows {
|
||||||
|
let stale = current.get(r.proposal_id.as_str()).is_none_or(|h| *h != r.source_hash);
|
||||||
|
out.entry(r.proposal_id)
|
||||||
|
.or_default()
|
||||||
|
.insert(r.lang, json!({ "title": r.title, "body": r.body, "stale": stale, "origin": r.origin }));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct MyVote {
|
||||||
|
proposal_id: String,
|
||||||
|
choice: String,
|
||||||
|
revision: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proposal_json(
|
||||||
|
st: &DocState,
|
||||||
|
unit: &UnitRow,
|
||||||
|
p: &ProposalRow,
|
||||||
|
mine: Option<&MyVote>,
|
||||||
|
translations: Option<&BTreeMap<String, serde_json::Value>>,
|
||||||
|
me: &str,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let eval = st.evaluation(unit, p);
|
||||||
|
let (kind, target) = match p.kind() {
|
||||||
|
ProposalKind::Text => ("text", None),
|
||||||
|
ProposalKind::Remove => ("remove", None),
|
||||||
|
ProposalKind::Inertia(t) => ("inertia", Some(t.as_str())),
|
||||||
|
};
|
||||||
|
let voted_at = match p.kind() {
|
||||||
|
ProposalKind::Inertia(_) => inertia_of(unit).raised(),
|
||||||
|
_ => inertia_of(unit),
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"id": p.id, "unitId": p.unit_id, "kind": kind, "inertiaTarget": target,
|
||||||
|
"title": p.title, "body": p.body, "status": p.status, "origin": p.origin, "label": p.label,
|
||||||
|
"author": p.author_id.as_ref().map(|id| json!({
|
||||||
|
"id": id, "name": p.author_name, "invited": p.author_invited.unwrap_or(false),
|
||||||
|
})),
|
||||||
|
"mine": p.author_id.as_deref() == Some(me),
|
||||||
|
"revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at,
|
||||||
|
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
|
||||||
|
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
|
||||||
|
"tally": st.tally(&p.id),
|
||||||
|
"evaluation": eval,
|
||||||
|
"votedAtInertia": voted_at.as_str(),
|
||||||
|
"blocked": st.blocked.contains(&p.id),
|
||||||
|
"signals": st.signal_counts.get(&p.id).copied().unwrap_or(0),
|
||||||
|
"myVote": mine.map(|v| v.choice.as_str()),
|
||||||
|
"myVoteStale": mine.is_some_and(|v| v.revision != p.revision),
|
||||||
|
"translations": translations.cloned().unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DocQuery {}
|
||||||
|
|
||||||
|
pub async fn document(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let st = load_state(&state, &id).await?;
|
||||||
|
require_open(&state, &user, &st.group).await?;
|
||||||
|
let visible: Vec<&ProposalRow> =
|
||||||
|
st.proposals.iter().filter(|p| p.status != "draft" || p.author_id.as_deref() == Some(&user.id)).collect();
|
||||||
|
let translations = translations_for(&state, &visible).await?;
|
||||||
|
let mine: HashMap<String, MyVote> = sqlx::query_as::<_, MyVote>(
|
||||||
|
"SELECT v.proposal_id, v.choice, v.revision FROM votes v JOIN proposals p ON p.id = v.proposal_id
|
||||||
|
JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ? AND v.identity_id = ?",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&user.id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|v| (v.proposal_id.clone(), v))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let units: Vec<_> = st
|
||||||
|
.units
|
||||||
|
.iter()
|
||||||
|
.map(|u| {
|
||||||
|
let of_unit: Vec<_> = visible
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.unit_id == u.id)
|
||||||
|
.map(|p| proposal_json(&st, u, p, mine.get(&p.id), translations.get(&p.id), &user.id))
|
||||||
|
.collect();
|
||||||
|
let in_force = u
|
||||||
|
.in_force_id
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned());
|
||||||
|
let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect();
|
||||||
|
json!({
|
||||||
|
"id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": u.inertia,
|
||||||
|
"inForce": in_force, "proposals": proposals,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let publication = publication_status(&state, &id).await?;
|
||||||
|
let can_vote = may_vote(&state, &user, &st.group).await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"canVote": can_vote,
|
||||||
|
"doc": {
|
||||||
|
"id": st.doc.id, "slug": st.doc.slug, "groupSlug": st.group.slug, "groupName": st.group.name,
|
||||||
|
"title": util::parse_lang(&st.doc.title), "summary": util::parse_lang(&st.doc.summary),
|
||||||
|
"sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor,
|
||||||
|
"electorate": st.electorate, "electorateSource": st.electorate_source,
|
||||||
|
"formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE },
|
||||||
|
"publication": publication,
|
||||||
|
},
|
||||||
|
"units": units,
|
||||||
|
"inertia": InertiaPreset::ALL.iter().map(|p| json!({
|
||||||
|
"key": p.as_str(), "majorityPct": p.majority_pct(), "gradient": p.gradient(),
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult<serde_json::Value> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
created_at: String,
|
||||||
|
done_at: Option<String>,
|
||||||
|
commit_sha: Option<String>,
|
||||||
|
last_error: Option<String>,
|
||||||
|
attempts: i64,
|
||||||
|
}
|
||||||
|
let last = sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT created_at, done_at, commit_sha, last_error, attempts FROM outbox WHERE doc_id = ? ORDER BY id DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(doc_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(match last {
|
||||||
|
None => json!({ "state": "none", "configured": state.cfg.publish.is_some() }),
|
||||||
|
Some(r) => json!({
|
||||||
|
"state": if r.done_at.is_some() { "done" } else { "pending" },
|
||||||
|
"configured": state.cfg.publish.is_some(),
|
||||||
|
"since": r.created_at, "doneAt": r.done_at, "commit": r.commit_sha,
|
||||||
|
"attempts": r.attempts, "error": r.last_error.map(|_| "retrying"),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PreviewQuery {
|
||||||
|
mode: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn preview(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Query(q): Query<PreviewQuery>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let st = load_state(&state, &id).await?;
|
||||||
|
require_open(&state, &user, &st.group).await?;
|
||||||
|
let mode = match q.mode.as_deref() {
|
||||||
|
Some("all") => PreviewMode::All,
|
||||||
|
Some("wellrated") => PreviewMode::WellRated,
|
||||||
|
_ => PreviewMode::InForce,
|
||||||
|
};
|
||||||
|
let units = compose::preview(&st.unit_states(), &st.proposal_states(), st.electorate, st.doc.vote_floor, mode)
|
||||||
|
.map_err(|_| AppError::Invalid("electorate"))?;
|
||||||
|
// Translations of the versions shown.
|
||||||
|
let shown: HashSet<String> = units
|
||||||
|
.iter()
|
||||||
|
.map(|u| {
|
||||||
|
u.proposal_id.clone().unwrap_or_else(|| {
|
||||||
|
st.units.iter().find(|x| x.id == u.unit_id).and_then(|x| x.in_force_id.clone()).unwrap_or_default()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let rows: Vec<&ProposalRow> = st.proposals.iter().filter(|p| shown.contains(&p.id)).collect();
|
||||||
|
let tr = translations_for(&state, &rows).await?;
|
||||||
|
let out: Vec<_> = units
|
||||||
|
.into_iter()
|
||||||
|
.map(|u| {
|
||||||
|
let vid = u
|
||||||
|
.proposal_id
|
||||||
|
.clone()
|
||||||
|
.or_else(|| st.units.iter().find(|x| x.id == u.unit_id).and_then(|x| x.in_force_id.clone()));
|
||||||
|
let translations = vid.as_ref().and_then(|v| tr.get(v)).cloned().unwrap_or_default();
|
||||||
|
json!({
|
||||||
|
"unitId": u.unit_id, "title": u.title, "body": u.body, "mark": u.mark,
|
||||||
|
"proposalId": u.proposal_id, "versionId": vid, "translations": translations,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(json!({ "mode": q.mode.unwrap_or_else(|| "inforce".into()), "units": out })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unused for now; keeps the language list close to the documents.
|
||||||
|
pub fn source_langs() -> LangMap {
|
||||||
|
util::LANGS.iter().map(|l| ((*l).to_owned(), String::new())).collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
//! API errors: a stable machine code the interface translates, never a stack.
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("unauthenticated")]
|
||||||
|
Unauthenticated,
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
#[error("not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("invalid: {0}")]
|
||||||
|
Invalid(&'static str),
|
||||||
|
#[error("conflict: {0}")]
|
||||||
|
Conflict(&'static str),
|
||||||
|
#[error("unavailable: {0}")]
|
||||||
|
Unavailable(&'static str),
|
||||||
|
#[error(transparent)]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
Other(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AppResult<T> = Result<T, AppError>;
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, code) = match &self {
|
||||||
|
Self::Unauthenticated => (StatusCode::UNAUTHORIZED, "unauthenticated"),
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
||||||
|
Self::NotFound => (StatusCode::NOT_FOUND, "not_found"),
|
||||||
|
Self::Invalid(code) => (StatusCode::BAD_REQUEST, *code),
|
||||||
|
Self::Conflict(code) => (StatusCode::CONFLICT, *code),
|
||||||
|
Self::Unavailable(code) => (StatusCode::SERVICE_UNAVAILABLE, *code),
|
||||||
|
Self::Db(e) => {
|
||||||
|
tracing::error!(error = %e, "database");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
|
||||||
|
}
|
||||||
|
Self::Other(e) => {
|
||||||
|
tracing::error!(error = %e, "internal");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(json!({ "error": code }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
//! libreDecision server.
|
||||||
|
//!
|
||||||
|
//! One binary serves the JSON API under `/api`, the compiled Svelte interface,
|
||||||
|
//! and runs two background workers: the electorate refresher (size of the Ğ1
|
||||||
|
//! web of trust) and the publisher (adopted texts pushed to the git repository
|
||||||
|
//! that client applications read).
|
||||||
|
|
||||||
|
pub mod access;
|
||||||
|
pub mod adoption;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod config;
|
||||||
|
pub mod docs;
|
||||||
|
pub mod error;
|
||||||
|
pub mod proposals;
|
||||||
|
pub mod publish;
|
||||||
|
pub mod seed;
|
||||||
|
pub mod team;
|
||||||
|
pub mod util;
|
||||||
|
pub mod web;
|
||||||
|
pub mod wot;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use axum::extract::{Request, State};
|
||||||
|
use axum::http::{Method, StatusCode};
|
||||||
|
use axum::middleware::{self, Next};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::{delete, get, post, put};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
|
pub use config::Config;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: SqlitePool,
|
||||||
|
pub cfg: Arc<Config>,
|
||||||
|
pub http: reqwest::Client,
|
||||||
|
/// Wakes the publisher as soon as an adoption is recorded.
|
||||||
|
pub publish_wake: Arc<Notify>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub async fn new(cfg: Config, db: SqlitePool) -> anyhow::Result<Self> {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(15))
|
||||||
|
.user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.build()?;
|
||||||
|
Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open (or create) the database and apply migrations.
|
||||||
|
pub async fn open_db(url: &str) -> anyhow::Result<SqlitePool> {
|
||||||
|
let options = SqliteConnectOptions::from_str(url)?
|
||||||
|
.create_if_missing(true)
|
||||||
|
.journal_mode(SqliteJournalMode::Wal)
|
||||||
|
.foreign_keys(true)
|
||||||
|
.busy_timeout(Duration::from_secs(5));
|
||||||
|
let max = if url.contains(":memory:") { 1 } else { 8 };
|
||||||
|
let pool = SqlitePoolOptions::new().max_connections(max).connect_with(options).await?;
|
||||||
|
// A previous image (rollback) must still start on a database a newer one has
|
||||||
|
// migrated: migrations are additive, the unknown ones are tolerated.
|
||||||
|
let mut migrator = sqlx::migrate!("./migrations");
|
||||||
|
migrator.set_ignore_missing(true);
|
||||||
|
migrator.run(&pool).await?;
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutating API calls must carry this header: a cross-site form cannot set it,
|
||||||
|
/// and no CORS is ever granted, so it closes CSRF together with SameSite=Lax.
|
||||||
|
pub const CSRF_HEADER: &str = "x-requested-with";
|
||||||
|
pub const CSRF_VALUE: &str = "libredecision";
|
||||||
|
|
||||||
|
async fn csrf_guard(req: Request, next: Next) -> Response {
|
||||||
|
let safe = matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS);
|
||||||
|
let has_header = req.headers().get(CSRF_HEADER).and_then(|v| v.to_str().ok()) == Some(CSRF_VALUE);
|
||||||
|
if safe || has_header {
|
||||||
|
next.run(req).await
|
||||||
|
} else {
|
||||||
|
(StatusCode::FORBIDDEN, axum::Json(serde_json::json!({ "error": "csrf" }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
|
let db_ok = sqlx::query_scalar::<_, i64>("SELECT 1").fetch_one(&state.db).await.is_ok();
|
||||||
|
axum::Json(serde_json::json!({ "ok": db_ok, "build": state.cfg.build }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn api_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/health", get(health))
|
||||||
|
.route("/auth/config", get(auth::config))
|
||||||
|
.route("/auth/g1/start", get(auth::g1_start))
|
||||||
|
.route("/auth/g1/callback", get(auth::g1_callback))
|
||||||
|
.route("/auth/invitation", post(auth::accept_invitation))
|
||||||
|
.route("/auth/team", post(team::join))
|
||||||
|
.route("/auth/team/peek", post(team::peek))
|
||||||
|
.route("/auth/dev", post(auth::dev_login))
|
||||||
|
.route("/auth/logout", post(auth::logout))
|
||||||
|
.route("/me", get(auth::me))
|
||||||
|
.route("/groups", get(docs::groups))
|
||||||
|
.route("/groups/{slug}", get(docs::group))
|
||||||
|
.route("/docs/{id}", get(docs::document))
|
||||||
|
.route("/docs/{id}/preview", get(docs::preview))
|
||||||
|
.route("/docs/{id}/assist", post(proposals::assist))
|
||||||
|
.route("/docs/{id}/proposals", post(proposals::create))
|
||||||
|
.route("/proposals/{id}", get(proposals::detail).patch(proposals::edit))
|
||||||
|
.route("/proposals/{id}/submit", post(proposals::submit))
|
||||||
|
.route("/proposals/{id}/withdraw", post(proposals::withdraw))
|
||||||
|
.route("/proposals/{id}/split", post(proposals::split))
|
||||||
|
.route("/proposals/{id}/vote", post(proposals::vote))
|
||||||
|
.route("/proposals/{id}/signals", post(proposals::signal))
|
||||||
|
.route("/proposals/{id}/translations/{lang}", put(proposals::translate))
|
||||||
|
.route("/signals/{id}/lift", post(proposals::lift_signal))
|
||||||
|
.route("/units/{id}/inertia", post(proposals::propose_inertia))
|
||||||
|
.route("/feedback", post(access::send_feedback))
|
||||||
|
.route("/access", get(access::overview))
|
||||||
|
.route("/access/groups/{slug}/members", post(access::grant))
|
||||||
|
.route("/access/groups/{slug}/members/{identity}", delete(access::revoke))
|
||||||
|
.route("/access/invitations", post(access::invite))
|
||||||
|
.route("/access/invitations/{hash}", delete(access::cancel_invitation))
|
||||||
|
.route("/access/publication/retry", post(access::publish_now))
|
||||||
|
.route("/access/team-links", post(team::create))
|
||||||
|
.route("/access/team-links/{hash}", delete(team::revoke))
|
||||||
|
.route("/access/identities/{id}/admin", post(access::set_admin))
|
||||||
|
.route("/access/identities/{id}/sessions", delete(access::close_sessions))
|
||||||
|
.route("/access/feedback", get(access::feedback_list))
|
||||||
|
.route("/access/feedback/{id}", post(access::feedback_status))
|
||||||
|
.layer(middleware::from_fn(csrf_guard))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app(state: AppState) -> Router {
|
||||||
|
use axum::http::{HeaderName, HeaderValue, header};
|
||||||
|
use tower_http::set_header::SetResponseHeaderLayer;
|
||||||
|
// Neighbouring sites under *.librodrome.org are same-site: none may frame us.
|
||||||
|
let security: [(HeaderName, &'static str); 4] = [
|
||||||
|
(header::X_FRAME_OPTIONS, "DENY"),
|
||||||
|
(header::CONTENT_SECURITY_POLICY, "frame-ancestors 'none'"),
|
||||||
|
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
|
||||||
|
(header::REFERRER_POLICY, "same-origin"),
|
||||||
|
];
|
||||||
|
let mut router = Router::new().nest("/api", api_router()).fallback_service(web::service(&state.cfg.web_dir));
|
||||||
|
for (name, value) in security {
|
||||||
|
router = router.layer(SetResponseHeaderLayer::if_not_present(name, HeaderValue::from_static(value)));
|
||||||
|
}
|
||||||
|
router
|
||||||
|
.layer(tower_http::limit::RequestBodyLimitLayer::new(256 * 1024))
|
||||||
|
.layer(tower_http::compression::CompressionLayer::new())
|
||||||
|
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ctrl-C, or SIGTERM from `docker stop`.
|
||||||
|
async fn shutdown() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
};
|
||||||
|
#[cfg(unix)]
|
||||||
|
let term = async {
|
||||||
|
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||||
|
Ok(mut s) => {
|
||||||
|
s.recv().await;
|
||||||
|
}
|
||||||
|
Err(_) => std::future::pending::<()>().await,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let term = std::future::pending::<()>();
|
||||||
|
tokio::select! {
|
||||||
|
() = ctrl_c => {}
|
||||||
|
() = term => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run() -> anyhow::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "info,sqlx=warn,tower_http=warn".into()),
|
||||||
|
)
|
||||||
|
.with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout()))
|
||||||
|
.init();
|
||||||
|
let cfg = Config::from_env();
|
||||||
|
tokio::fs::create_dir_all(&cfg.data_dir).await?;
|
||||||
|
let db_url = format!("sqlite://{}", cfg.data_dir.join("libredecision.db").display());
|
||||||
|
let db = open_db(&db_url).await?;
|
||||||
|
let state = AppState::new(cfg, db).await?;
|
||||||
|
|
||||||
|
seed::ensure(&state).await?;
|
||||||
|
auth::bootstrap(&state).await?;
|
||||||
|
if let Err(e) = publish::ensure_key(&state).await {
|
||||||
|
tracing::warn!(error = %e, "publication key");
|
||||||
|
}
|
||||||
|
publish::resync(&state).await?;
|
||||||
|
tokio::spawn(wot::refresher(state.clone()));
|
||||||
|
tokio::spawn(publish::worker(state.clone()));
|
||||||
|
tokio::spawn(adoption::sweeper(state.clone()));
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(&state.cfg.bind).await?;
|
||||||
|
tracing::info!(bind = %state.cfg.bind, build = %state.cfg.build, "libreDecision listening");
|
||||||
|
axum::serve(listener, app(state).into_make_service_with_connect_info::<std::net::SocketAddr>())
|
||||||
|
.with_graceful_shutdown(shutdown())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//! libreDecision server — entry point. Everything else lives in the library.
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
ld_server::run().await
|
||||||
|
}
|
||||||
@@ -0,0 +1,889 @@
|
|||||||
|
//! Writing: drafts, submission, corrections, votes, contradiction signals,
|
||||||
|
//! inertia changes, editorial translations.
|
||||||
|
//!
|
||||||
|
//! Editing is not voting (Yvv, 2026-10-21: « il ne faut pas que ça devienne un
|
||||||
|
//! système qui nécessite un vote pour corriger une coquille »):
|
||||||
|
//! - a **draft** is free: its author edits it at will, nobody votes on it;
|
||||||
|
//! - once **submitted**, a correction classified *typographic* by the engine
|
||||||
|
//! keeps every vote and is shown with its diff; a *substantive* one opens a
|
||||||
|
//! new revision and every earlier vote waits for its author to reconfirm;
|
||||||
|
//! - a text **in force** accepts typographic corrections from any member of
|
||||||
|
//! the group, recorded with their author and diff, and republished — a
|
||||||
|
//! change of meaning goes through a proposal.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::adoption;
|
||||||
|
use crate::auth::{IdentityRow, User};
|
||||||
|
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow, UnitRow};
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::util;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use ld_engine::assist::{self, Hint};
|
||||||
|
use ld_engine::edit::{self, EditKind};
|
||||||
|
use ld_engine::threshold::InertiaPreset;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
const MAX_TITLE: usize = 120;
|
||||||
|
const MAX_BODY: usize = 4000;
|
||||||
|
const MIN_REASON: usize = 10;
|
||||||
|
const MAX_REASON: usize = 600;
|
||||||
|
|
||||||
|
async fn load_proposal(state: &AppState, id: &str) -> AppResult<ProposalRow> {
|
||||||
|
sqlx::query_as::<_, ProposalRow>(&format!("{PROPOSAL_SELECT} WHERE p.id = ?"))
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn doc_of_unit(state: &AppState, unit_id: &str) -> AppResult<String> {
|
||||||
|
sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?")
|
||||||
|
.bind(unit_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the document of a proposal and checks the person may open its group.
|
||||||
|
async fn guarded(state: &AppState, user: &IdentityRow, proposal_id: &str) -> AppResult<(ProposalRow, DocState)> {
|
||||||
|
let p = load_proposal(state, proposal_id).await?;
|
||||||
|
let doc_id = doc_of_unit(state, &p.unit_id).await?;
|
||||||
|
let st = docs::load_state(state, &doc_id).await?;
|
||||||
|
docs::require_open(state, user, &st.group).await?;
|
||||||
|
if p.status == "draft" && p.author_id.as_deref() != Some(user.id.as_str()) {
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
Ok((p, st))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
|
||||||
|
if edit::has_hidden_controls(title) || edit::has_hidden_controls(body) {
|
||||||
|
return Err(AppError::Invalid("hidden_characters"));
|
||||||
|
}
|
||||||
|
if title.trim().is_empty() || title.chars().count() > MAX_TITLE || title.contains(['\n', '\r']) {
|
||||||
|
return Err(AppError::Invalid("title"));
|
||||||
|
}
|
||||||
|
if (needs_body && body.trim().is_empty()) || body.chars().count() > MAX_BODY {
|
||||||
|
return Err(AppError::Invalid("body"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text the votes were cast on: its last initial or substantive revision.
|
||||||
|
async fn voted_text(state: &AppState, p: &ProposalRow) -> AppResult<(String, String)> {
|
||||||
|
let row: Option<(String, String)> = sqlx::query_as(
|
||||||
|
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive')
|
||||||
|
ORDER BY id DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(&p.id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(row.unwrap_or_else(|| (p.title.clone(), p.body.clone())))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_revision(
|
||||||
|
state: &AppState,
|
||||||
|
proposal_id: &str,
|
||||||
|
revision: i64,
|
||||||
|
kind: &str,
|
||||||
|
title: &str,
|
||||||
|
body: &str,
|
||||||
|
author: Option<&str>,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(proposal_id)
|
||||||
|
.bind(revision)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(title)
|
||||||
|
.bind(body)
|
||||||
|
.bind(author)
|
||||||
|
.bind(util::now())
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Assistant
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AssistBody {
|
||||||
|
text: String,
|
||||||
|
proposal_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn assist(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(doc_id): Path<String>,
|
||||||
|
Json(body): Json<AssistBody>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let st = docs::load_state(&state, &doc_id).await?;
|
||||||
|
docs::require_open(&state, &user, &st.group).await?;
|
||||||
|
let others: Vec<(String, String)> = st
|
||||||
|
.proposals
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.status != "draft" && Some(&p.id) != body.proposal_id.as_ref() && p.kind == "text")
|
||||||
|
.map(|p| (p.id.clone(), format!("{}\n{}", p.title, p.body)))
|
||||||
|
.collect();
|
||||||
|
let hints: Vec<_> = assist::hints(&body.text, &others)
|
||||||
|
.into_iter()
|
||||||
|
.map(|h| match h {
|
||||||
|
Hint::Similar { target, score } => {
|
||||||
|
let p = st.proposals.iter().find(|p| p.id == target);
|
||||||
|
let unit = p.and_then(|p| st.units.iter().find(|u| u.id == p.unit_id));
|
||||||
|
json!({
|
||||||
|
"kind": "similar", "score": score, "proposalId": target,
|
||||||
|
"unitId": unit.map(|u| &u.id), "code": unit.map(|u| &u.code),
|
||||||
|
"title": p.map(|p| &p.title), "inForce": p.is_some_and(|p| p.status == "in_force"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
other => serde_json::to_value(other).unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(json!({ "hints": hints })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Create, edit, submit, withdraw, split
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreateBody {
|
||||||
|
kind: Option<String>,
|
||||||
|
unit_id: Option<String>,
|
||||||
|
after_unit_id: Option<String>,
|
||||||
|
title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
body: String,
|
||||||
|
#[serde(default)]
|
||||||
|
submit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Position right after `after` (or at the end), between it and the next unit.
|
||||||
|
fn position_after(units: &[UnitRow], after: Option<&str>) -> f64 {
|
||||||
|
let mut positions: Vec<f64> = units.iter().map(|u| u.position).collect();
|
||||||
|
positions.sort_by(f64::total_cmp);
|
||||||
|
let Some(anchor) = after.and_then(|a| units.iter().find(|u| u.id == a)).map(|u| u.position) else {
|
||||||
|
return positions.last().copied().unwrap_or(0.0) + 1.0;
|
||||||
|
};
|
||||||
|
match positions.iter().find(|p| **p > anchor) {
|
||||||
|
Some(next) => (anchor + next) / 2.0,
|
||||||
|
None => anchor + 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppResult<String> {
|
||||||
|
let id = util::id();
|
||||||
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE doc_id = ? AND code LIKE 'N%'")
|
||||||
|
.bind(&st.doc.id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
// A new article belongs to the section of the one it follows.
|
||||||
|
let section = after.and_then(|a| st.units.iter().find(|u| u.id == a)).and_then(|u| u.section.clone());
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'standard', NULL, ?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&st.doc.id)
|
||||||
|
.bind(format!("N{}", n + 1))
|
||||||
|
.bind(section)
|
||||||
|
.bind(position_after(&st.units, after))
|
||||||
|
.bind(util::now())
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewProposal<'a> {
|
||||||
|
unit_id: &'a str,
|
||||||
|
kind: &'a str,
|
||||||
|
inertia_target: Option<&'a str>,
|
||||||
|
title: &'a str,
|
||||||
|
body: &'a str,
|
||||||
|
author: &'a str,
|
||||||
|
submit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_proposal(state: &AppState, n: NewProposal<'_>) -> AppResult<String> {
|
||||||
|
let NewProposal { unit_id, kind, inertia_target, title, body, author, submit } = n;
|
||||||
|
let id = util::id();
|
||||||
|
let now = util::now();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO proposals (id, unit_id, kind, inertia_target, title, body, status, origin, author_id, revision,
|
||||||
|
created_at, updated_at, submitted_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'member', ?, 1, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(unit_id)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(inertia_target)
|
||||||
|
.bind(title.trim())
|
||||||
|
.bind(body.trim())
|
||||||
|
.bind(if submit { "open" } else { "draft" })
|
||||||
|
.bind(author)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(submit.then_some(&now))
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
record_revision(state, &id, 1, "initial", title.trim(), body.trim(), Some(author)).await?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(doc_id): Path<String>,
|
||||||
|
Json(b): Json<CreateBody>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let st = docs::load_state(&state, &doc_id).await?;
|
||||||
|
docs::require_open(&state, &user, &st.group).await?;
|
||||||
|
let kind = match b.kind.as_deref().unwrap_or("text") {
|
||||||
|
"text" => "text",
|
||||||
|
"remove" => "remove",
|
||||||
|
_ => return Err(AppError::Invalid("kind")),
|
||||||
|
};
|
||||||
|
check_text(&b.title, &b.body, kind == "text")?;
|
||||||
|
let unit_id = match b.unit_id.as_deref() {
|
||||||
|
Some(u) => {
|
||||||
|
let unit = st.units.iter().find(|x| x.id == u).ok_or(AppError::NotFound)?;
|
||||||
|
if kind == "remove" && unit.in_force_id.is_none() {
|
||||||
|
return Err(AppError::Invalid("nothing_to_remove"));
|
||||||
|
}
|
||||||
|
unit.id.clone()
|
||||||
|
}
|
||||||
|
None if kind == "text" => new_unit(&state, &st, b.after_unit_id.as_deref()).await?,
|
||||||
|
None => return Err(AppError::Invalid("unit")),
|
||||||
|
};
|
||||||
|
let id = insert_proposal(
|
||||||
|
&state,
|
||||||
|
NewProposal {
|
||||||
|
unit_id: &unit_id,
|
||||||
|
kind,
|
||||||
|
inertia_target: None,
|
||||||
|
title: &b.title,
|
||||||
|
body: &b.body,
|
||||||
|
author: &user.id,
|
||||||
|
submit: b.submit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "id": id, "unitId": unit_id })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct EditBody {
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn edit(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(b): Json<EditBody>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let (p, st) = guarded(&state, &user, &id).await?;
|
||||||
|
check_text(&b.title, &b.body, p.kind == "text")?;
|
||||||
|
let (title, body) = (b.title.trim(), b.body.trim());
|
||||||
|
let is_author = p.author_id.as_deref() == Some(user.id.as_str());
|
||||||
|
let now = util::now();
|
||||||
|
|
||||||
|
if p.status == "draft" {
|
||||||
|
if !is_author {
|
||||||
|
return Err(AppError::Forbidden);
|
||||||
|
}
|
||||||
|
sqlx::query("UPDATE proposals SET title = ?, body = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(title)
|
||||||
|
.bind(body)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
return Ok(Json(json!({ "edit": "draft" })));
|
||||||
|
}
|
||||||
|
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||||
|
return Err(AppError::Conflict("closed"));
|
||||||
|
}
|
||||||
|
// Measured against the text that was voted, not the current one: successive
|
||||||
|
// typo fixes are judged together and never add up to a change of meaning.
|
||||||
|
// Title and body are judged apart (a title never absorbs the start of the
|
||||||
|
// body) and together (two slips at most in all).
|
||||||
|
let next = format!("{title}\n{body}");
|
||||||
|
let kind = if title == p.title && body == p.body {
|
||||||
|
EditKind::Unchanged
|
||||||
|
} else {
|
||||||
|
let (voted_title, voted_body) = voted_text(&state, &p).await?;
|
||||||
|
let kinds = [
|
||||||
|
edit::classify(&voted_title, title),
|
||||||
|
edit::classify(&voted_body, body),
|
||||||
|
edit::classify(&format!("{voted_title}\n{voted_body}"), &next),
|
||||||
|
];
|
||||||
|
// All unchanged: back to the voted text, itself a correction.
|
||||||
|
if kinds.contains(&EditKind::Substantive) { EditKind::Substantive } else { EditKind::Typographic }
|
||||||
|
};
|
||||||
|
match kind {
|
||||||
|
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
|
||||||
|
EditKind::Typographic => {
|
||||||
|
// Anyone in the group may fix a typo of a seed text or of a text in force.
|
||||||
|
if !(is_author || p.origin == "seed" || p.status == "in_force") {
|
||||||
|
return Err(AppError::Forbidden);
|
||||||
|
}
|
||||||
|
// Written only over the text that was judged: an entry into force or another
|
||||||
|
// correction in between makes it a conflict, never a silent change.
|
||||||
|
let done = sqlx::query(
|
||||||
|
"UPDATE proposals SET title = ?, body = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND revision = ? AND status = ? AND title = ? AND body = ?",
|
||||||
|
)
|
||||||
|
.bind(title)
|
||||||
|
.bind(body)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(p.revision)
|
||||||
|
.bind(&p.status)
|
||||||
|
.bind(&p.title)
|
||||||
|
.bind(&p.body)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
if done.rows_affected() != 1 {
|
||||||
|
return Err(AppError::Conflict("changed"));
|
||||||
|
}
|
||||||
|
record_revision(&state, &id, p.revision, "typo", title, body, Some(&user.id)).await?;
|
||||||
|
// A typo fix does not change the meaning: up-to-date translations stay up to date.
|
||||||
|
sqlx::query("UPDATE translations SET source_hash = ? WHERE proposal_id = ? AND source_hash = ?")
|
||||||
|
.bind(util::source_hash(title, body))
|
||||||
|
.bind(&id)
|
||||||
|
.bind(util::source_hash(&p.title, &p.body))
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
if p.status == "in_force" {
|
||||||
|
adoption::enqueue_publication(&state, &st.doc, "correction typographique").await?;
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "edit": "typo", "diff": edit::diff(&format!("{}\n{}", p.title, p.body), &next) })))
|
||||||
|
}
|
||||||
|
EditKind::Substantive => {
|
||||||
|
if p.status == "in_force" {
|
||||||
|
return Err(AppError::Conflict("substantive_in_force"));
|
||||||
|
}
|
||||||
|
if !is_author {
|
||||||
|
return Err(AppError::Conflict("substantive_not_author"));
|
||||||
|
}
|
||||||
|
let revision = p.revision + 1;
|
||||||
|
// Only on the open proposal that was read: never on a text that entered into
|
||||||
|
// force in the meantime.
|
||||||
|
let done = sqlx::query(
|
||||||
|
"UPDATE proposals SET title = ?, body = ?, revision = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND revision = ? AND status = 'open' AND title = ? AND body = ?",
|
||||||
|
)
|
||||||
|
.bind(title)
|
||||||
|
.bind(body)
|
||||||
|
.bind(revision)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(p.revision)
|
||||||
|
.bind(&p.title)
|
||||||
|
.bind(&p.body)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
if done.rows_affected() != 1 {
|
||||||
|
return Err(AppError::Conflict("changed"));
|
||||||
|
}
|
||||||
|
record_revision(&state, &id, revision, "substantive", title, body, Some(&user.id)).await?;
|
||||||
|
// Signals were about the former text: they lapse, their authors may signal again.
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE signals SET lifted_at = ?, lifted_by = 'revision' WHERE proposal_id = ? AND lifted_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "edit": "substantive", "revision": revision })))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn submit(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let (p, st) = guarded(&state, &user, &id).await?;
|
||||||
|
if p.author_id.as_deref() != Some(user.id.as_str()) {
|
||||||
|
return Err(AppError::Forbidden);
|
||||||
|
}
|
||||||
|
if p.status != "draft" {
|
||||||
|
return Err(AppError::Conflict("not_draft"));
|
||||||
|
}
|
||||||
|
// Drafts written under older rules are checked again before the vote opens.
|
||||||
|
check_text(&p.title, &p.body, p.kind == "text")?;
|
||||||
|
let now = util::now();
|
||||||
|
sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
// A draft is edited freely: the text submitted is the one votes will be cast on.
|
||||||
|
let initial = sqlx::query("UPDATE revisions SET title = ?, body = ? WHERE proposal_id = ? AND kind = 'initial'")
|
||||||
|
.bind(&p.title)
|
||||||
|
.bind(&p.body)
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
if initial.rows_affected() == 0 {
|
||||||
|
record_revision(&state, &id, p.revision, "initial", &p.title, &p.body, Some(&user.id)).await?;
|
||||||
|
}
|
||||||
|
let entered = adoption::run(&state, &st.doc.id).await?;
|
||||||
|
Ok(Json(json!({ "status": "open", "entered": entered })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn withdraw(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let (p, _) = guarded(&state, &user, &id).await?;
|
||||||
|
if p.author_id.as_deref() != Some(user.id.as_str()) {
|
||||||
|
return Err(AppError::Forbidden);
|
||||||
|
}
|
||||||
|
if !matches!(p.status.as_str(), "draft" | "open") {
|
||||||
|
return Err(AppError::Conflict("closed"));
|
||||||
|
}
|
||||||
|
sqlx::query("UPDATE proposals SET status = 'withdrawn', left_at = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "status": "withdrawn" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SplitBody {
|
||||||
|
parts: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One draft becomes several: the first part stays, each other part becomes a
|
||||||
|
/// draft on a new unit placed right after. Drafts only — nobody voted yet.
|
||||||
|
pub async fn split(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(b): Json<SplitBody>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let (p, st) = guarded(&state, &user, &id).await?;
|
||||||
|
if p.author_id.as_deref() != Some(user.id.as_str()) {
|
||||||
|
return Err(AppError::Forbidden);
|
||||||
|
}
|
||||||
|
if p.status != "draft" || p.kind != "text" {
|
||||||
|
return Err(AppError::Conflict("not_draft"));
|
||||||
|
}
|
||||||
|
let parts: Vec<&str> = b.parts.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||||
|
if parts.len() < 2 || parts.len() > 12 || parts.iter().any(|s| s.chars().count() > MAX_BODY) {
|
||||||
|
return Err(AppError::Invalid("parts"));
|
||||||
|
}
|
||||||
|
if parts.iter().any(|s| edit::has_hidden_controls(s)) {
|
||||||
|
return Err(AppError::Invalid("hidden_characters"));
|
||||||
|
}
|
||||||
|
sqlx::query("UPDATE proposals SET body = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(parts[0])
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
let mut created = vec![id.clone()];
|
||||||
|
let mut after = p.unit_id.clone();
|
||||||
|
for (n, part) in parts.iter().enumerate().skip(1) {
|
||||||
|
let fresh = docs::load_state(&state, &st.doc.id).await?;
|
||||||
|
let unit = new_unit(&state, &fresh, Some(&after)).await?;
|
||||||
|
let title = format!("{} ({})", p.title, n + 1);
|
||||||
|
let title: String = title.chars().take(MAX_TITLE).collect();
|
||||||
|
created.push(
|
||||||
|
insert_proposal(
|
||||||
|
&state,
|
||||||
|
NewProposal {
|
||||||
|
unit_id: &unit,
|
||||||
|
kind: "text",
|
||||||
|
inertia_target: None,
|
||||||
|
title: &title,
|
||||||
|
body: part,
|
||||||
|
author: &user.id,
|
||||||
|
submit: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
after = unit;
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "drafts": created })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Detail
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn detail(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let (p, st) = guarded(&state, &user, &id).await?;
|
||||||
|
let unit = st.units.iter().find(|u| u.id == p.unit_id).ok_or(AppError::NotFound)?;
|
||||||
|
let in_force = match &unit.in_force_id {
|
||||||
|
Some(fid) if *fid != p.id => Some(load_proposal(&state, fid).await?),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Rev {
|
||||||
|
revision: i64,
|
||||||
|
kind: String,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
author_name: Option<String>,
|
||||||
|
author_invited: Option<bool>,
|
||||||
|
created_at: String,
|
||||||
|
}
|
||||||
|
let revs = sqlx::query_as::<_, Rev>(
|
||||||
|
"SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name,
|
||||||
|
i.kind = 'invite' AS author_invited, r.created_at
|
||||||
|
FROM revisions r LEFT JOIN identities i ON i.id = r.author_id WHERE r.proposal_id = ? ORDER BY r.id",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
// Each correction with the diff from the text before it.
|
||||||
|
let mut history = Vec::new();
|
||||||
|
let mut previous: Option<&Rev> = None;
|
||||||
|
for r in &revs {
|
||||||
|
let diff = previous
|
||||||
|
.map(|prev| edit::diff(&format!("{}\n{}", prev.title, prev.body), &format!("{}\n{}", r.title, r.body)));
|
||||||
|
history.push(json!({
|
||||||
|
"revision": r.revision, "kind": r.kind, "author": r.author_name,
|
||||||
|
"authorInvited": r.author_invited.unwrap_or(false), "at": r.created_at, "diff": diff,
|
||||||
|
}));
|
||||||
|
previous = Some(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Sig {
|
||||||
|
id: String,
|
||||||
|
proposal_id: String,
|
||||||
|
target_proposal_id: String,
|
||||||
|
reason: String,
|
||||||
|
author_id: String,
|
||||||
|
author_name: String,
|
||||||
|
author_invited: bool,
|
||||||
|
created_at: String,
|
||||||
|
lifted_at: Option<String>,
|
||||||
|
lifted_by: Option<String>,
|
||||||
|
}
|
||||||
|
let sigs = sqlx::query_as::<_, Sig>(
|
||||||
|
"SELECT s.id, s.proposal_id, s.target_proposal_id, s.reason, s.author_id, i.display_name AS author_name,
|
||||||
|
i.kind = 'invite' AS author_invited, s.created_at, s.lifted_at, s.lifted_by
|
||||||
|
FROM signals s JOIN identities i ON i.id = s.author_id
|
||||||
|
WHERE s.proposal_id = ? OR s.target_proposal_id = ? ORDER BY s.created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let mut signals = Vec::new();
|
||||||
|
for s in sigs {
|
||||||
|
let other_id = if s.proposal_id == id { &s.target_proposal_id } else { &s.proposal_id };
|
||||||
|
let other = load_proposal(&state, other_id).await.ok();
|
||||||
|
let other_code = match &other {
|
||||||
|
Some(o) => st.units.iter().find(|u| u.id == o.unit_id).map(|u| u.code.clone()),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
signals.push(json!({
|
||||||
|
"id": s.id, "reason": s.reason, "author": s.author_name, "authorInvited": s.author_invited,
|
||||||
|
"mine": s.author_id == user.id,
|
||||||
|
"at": s.created_at, "liftedAt": s.lifted_at, "liftedBy": s.lifted_by,
|
||||||
|
"other": other.map(|o| json!({ "id": o.id, "title": o.title, "status": o.status, "code": other_code })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let my_vote: Option<(String, i64)> =
|
||||||
|
sqlx::query_as("SELECT choice, revision FROM votes WHERE proposal_id = ? AND identity_id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&user.id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
let diff = match (&in_force, p.kind.as_str()) {
|
||||||
|
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let eval = st.evaluation(unit, &p);
|
||||||
|
let voted_at = if p.kind == "inertia" { docs::inertia_of(unit).raised() } else { docs::inertia_of(unit) };
|
||||||
|
let tr = docs::translations_for(&state, &[&p]).await?;
|
||||||
|
Ok(Json(json!({
|
||||||
|
"proposal": {
|
||||||
|
"id": p.id, "kind": p.kind, "inertiaTarget": p.inertia_target, "title": p.title, "body": p.body,
|
||||||
|
"status": p.status, "origin": p.origin, "label": p.label, "revision": p.revision,
|
||||||
|
"author": p.author_name, "authorInvited": p.author_invited.unwrap_or(false),
|
||||||
|
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
|
||||||
|
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
|
||||||
|
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
|
||||||
|
"tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at.as_str(),
|
||||||
|
"blocked": st.blocked.contains(&p.id),
|
||||||
|
"myVote": my_vote.as_ref().map(|v| &v.0), "myVoteStale": my_vote.as_ref().is_some_and(|v| v.1 != p.revision),
|
||||||
|
"translations": tr.get(&p.id).cloned().unwrap_or_default(),
|
||||||
|
},
|
||||||
|
"unit": { "id": unit.id, "code": unit.code, "inertia": unit.inertia },
|
||||||
|
"inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })),
|
||||||
|
"diff": diff,
|
||||||
|
"history": history,
|
||||||
|
"signals": signals,
|
||||||
|
"electorate": st.electorate,
|
||||||
|
"voteFloor": st.doc.vote_floor,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Votes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct VoteBody {
|
||||||
|
choice: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn vote(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(b): Json<VoteBody>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let (p, st) = guarded(&state, &user, &id).await?;
|
||||||
|
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||||
|
return Err(AppError::Conflict("not_votable"));
|
||||||
|
}
|
||||||
|
// Withdrawing one's vote is always possible; casting one follows trustinWallet.
|
||||||
|
if b.choice.is_some() && !docs::may_vote(&state, &user, &st.group).await? {
|
||||||
|
return Err(AppError::Conflict("wot_members_only"));
|
||||||
|
}
|
||||||
|
match b.choice.as_deref() {
|
||||||
|
Some(choice @ ("for" | "against")) => {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(proposal_id, identity_id) DO UPDATE SET choice = excluded.choice,
|
||||||
|
revision = excluded.revision, cast_at = excluded.cast_at",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&user.id)
|
||||||
|
.bind(choice)
|
||||||
|
.bind(p.revision)
|
||||||
|
.bind(util::now())
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
sqlx::query("DELETE FROM votes WHERE proposal_id = ? AND identity_id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&user.id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Some(_) => return Err(AppError::Invalid("choice")),
|
||||||
|
}
|
||||||
|
let entered = adoption::run(&state, &st.doc.id).await?;
|
||||||
|
Ok(Json(json!({ "entered": entered })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Contradiction signals
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SignalBody {
|
||||||
|
target_id: String,
|
||||||
|
reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// « These two texts contradict each other. » While the signal stands, the two
|
||||||
|
/// can never be in force together: the one not yet in force is blocked.
|
||||||
|
pub async fn signal(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(b): Json<SignalBody>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let (p, st) = guarded(&state, &user, &id).await?;
|
||||||
|
let reason = b.reason.trim();
|
||||||
|
if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON || edit::has_hidden_controls(reason) {
|
||||||
|
return Err(AppError::Invalid("reason"));
|
||||||
|
}
|
||||||
|
if b.target_id == id {
|
||||||
|
return Err(AppError::Invalid("target"));
|
||||||
|
}
|
||||||
|
let target = st.proposals.iter().find(|t| t.id == b.target_id).ok_or(AppError::Invalid("target"))?;
|
||||||
|
if target.status == "draft" || !matches!(p.status.as_str(), "open" | "in_force") {
|
||||||
|
return Err(AppError::Invalid("target"));
|
||||||
|
}
|
||||||
|
if target.unit_id == p.unit_id && target.kind == "text" && p.kind == "text" {
|
||||||
|
// Rivals on one unit never stand together anyway.
|
||||||
|
return Err(AppError::Invalid("same_unit"));
|
||||||
|
}
|
||||||
|
let sid = util::id();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&sid)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&b.target_id)
|
||||||
|
.bind(reason)
|
||||||
|
.bind(&user.id)
|
||||||
|
.bind(util::now())
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "id": sid })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn lift_signal(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let (proposal_id, author): (String, String) =
|
||||||
|
sqlx::query_as("SELECT proposal_id, author_id FROM signals WHERE id = ? AND lifted_at IS NULL")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
let (_, st) = guarded(&state, &user, &proposal_id).await?;
|
||||||
|
if author != user.id && !user.admin {
|
||||||
|
return Err(AppError::Forbidden);
|
||||||
|
}
|
||||||
|
sqlx::query("UPDATE signals SET lifted_at = ?, lifted_by = ? WHERE id = ?")
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(&user.id)
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
let entered = adoption::run(&state, &st.doc.id).await?;
|
||||||
|
Ok(Json(json!({ "entered": entered })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inertia
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct InertiaBody {
|
||||||
|
target: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Propose a change of inertia; it is voted one level above the unit. An open
|
||||||
|
/// proposal for the same target is reused rather than duplicated.
|
||||||
|
pub async fn propose_inertia(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(unit_id): Path<String>,
|
||||||
|
Json(b): Json<InertiaBody>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let target = InertiaPreset::parse(&b.target).ok_or(AppError::Invalid("inertia"))?;
|
||||||
|
let doc_id = doc_of_unit(&state, &unit_id).await?;
|
||||||
|
let st = docs::load_state(&state, &doc_id).await?;
|
||||||
|
docs::require_open(&state, &user, &st.group).await?;
|
||||||
|
let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?;
|
||||||
|
if docs::inertia_of(unit) == target {
|
||||||
|
return Err(AppError::Invalid("same_inertia"));
|
||||||
|
}
|
||||||
|
if let Some(existing) = st.proposals.iter().find(|p| {
|
||||||
|
p.unit_id == unit_id
|
||||||
|
&& p.kind == "inertia"
|
||||||
|
&& p.status == "open"
|
||||||
|
&& p.inertia_target.as_deref() == Some(target.as_str())
|
||||||
|
}) {
|
||||||
|
return Ok(Json(json!({ "id": existing.id, "existing": true })));
|
||||||
|
}
|
||||||
|
let title = format!("inertia:{}", target.as_str());
|
||||||
|
let id = insert_proposal(
|
||||||
|
&state,
|
||||||
|
NewProposal {
|
||||||
|
unit_id: &unit_id,
|
||||||
|
kind: "inertia",
|
||||||
|
inertia_target: Some(target.as_str()),
|
||||||
|
title: &title,
|
||||||
|
body: "",
|
||||||
|
author: &user.id,
|
||||||
|
submit: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "id": id, "existing": false })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Editorial translations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn translate(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path((id, lang)): Path<(String, String)>,
|
||||||
|
Json(b): Json<EditBody>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let (p, st) = guarded(&state, &user, &id).await?;
|
||||||
|
if !util::is_lang(&lang) || lang == st.doc.source_lang {
|
||||||
|
return Err(AppError::Invalid("lang"));
|
||||||
|
}
|
||||||
|
check_text(&b.title, &b.body, p.kind == "text")?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'member', ?, ?)
|
||||||
|
ON CONFLICT(proposal_id, lang) DO UPDATE SET title = excluded.title, body = excluded.body,
|
||||||
|
source_hash = excluded.source_hash, origin = 'member', updated_by = excluded.updated_by,
|
||||||
|
updated_at = excluded.updated_at",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&lang)
|
||||||
|
.bind(b.title.trim())
|
||||||
|
.bind(b.body.trim())
|
||||||
|
.bind(util::source_hash(&p.title, &p.body))
|
||||||
|
.bind(&user.id)
|
||||||
|
.bind(util::now())
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
if p.status == "in_force" {
|
||||||
|
adoption::enqueue_publication(&state, &st.doc, "traduction").await?;
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn unit(id: &str, position: f64) -> UnitRow {
|
||||||
|
UnitRow {
|
||||||
|
id: id.into(),
|
||||||
|
code: id.into(),
|
||||||
|
section: None,
|
||||||
|
position,
|
||||||
|
inertia: "standard".into(),
|
||||||
|
in_force_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_units_slot_between_neighbours() {
|
||||||
|
let units = [unit("a", 1.0), unit("b", 2.0), unit("c", 4.0)];
|
||||||
|
assert_eq!(position_after(&units, Some("a")), 1.5);
|
||||||
|
assert_eq!(position_after(&units, Some("c")), 5.0);
|
||||||
|
assert_eq!(position_after(&units, None), 5.0);
|
||||||
|
assert_eq!(position_after(&[], None), 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
//! Publication of the texts in force to the git repository that client
|
||||||
|
//! applications read to update themselves (Yvv, 2026-10-21: « Le tout est
|
||||||
|
//! automatique poussé sur le dépôt git exploité par les applis clientes »).
|
||||||
|
//!
|
||||||
|
//! Every adoption, typo fix or translation of a text in force queues an
|
||||||
|
//! outbox row. The worker renders the document — one Markdown file per
|
||||||
|
//! language plus a JSON file — commits it in the server's own clone and pushes
|
||||||
|
//! with a deploy key. A failure is retried with backoff; the vote never waits
|
||||||
|
//! for the network.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::docs::{self, DocState};
|
||||||
|
use crate::util;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
const IDLE: Duration = Duration::from_secs(60);
|
||||||
|
const MAX_BACKOFF_SECS: i64 = 6 * 3600;
|
||||||
|
|
||||||
|
/// The deploy key of the server: generated on first start, never leaves the
|
||||||
|
/// data volume. Its public half is shown to access administrators, who add it
|
||||||
|
/// as a deploy key (write) on the texts repository.
|
||||||
|
pub async fn ensure_key(state: &AppState) -> anyhow::Result<()> {
|
||||||
|
let Some(publish) = &state.cfg.publish else { return Ok(()) };
|
||||||
|
if publish.key.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(dir) = publish.key.parent() {
|
||||||
|
tokio::fs::create_dir_all(dir).await?;
|
||||||
|
}
|
||||||
|
let out = Command::new("ssh-keygen")
|
||||||
|
.args(["-q", "-t", "ed25519", "-N", "", "-C", "libredecision-publication", "-f"])
|
||||||
|
.arg(&publish.key)
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
anyhow::ensure!(out.status.success(), "ssh-keygen: {}", String::from_utf8_lossy(&out.stderr).trim());
|
||||||
|
tracing::info!(key = %publish.key.display(), "publication key generated");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn public_key(state: &AppState) -> Option<String> {
|
||||||
|
let publish = state.cfg.publish.as_ref()?;
|
||||||
|
let mut path = publish.key.clone().into_os_string();
|
||||||
|
path.push(".pub");
|
||||||
|
std::fs::read_to_string(path).ok().map(|k| k.trim().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn worker(state: AppState) {
|
||||||
|
loop {
|
||||||
|
// A pass first: what was queued before the start goes out without waiting.
|
||||||
|
if state.cfg.publish.is_some()
|
||||||
|
&& let Err(e) = pass(&state).await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "publication pass");
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
() = state.publish_wake.notified() => {}
|
||||||
|
() = tokio::time::sleep(IDLE) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Due {
|
||||||
|
id: i64,
|
||||||
|
doc_id: String,
|
||||||
|
attempts: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One publication pass, now (tests, and the worker).
|
||||||
|
pub async fn pass(state: &AppState) -> anyhow::Result<()> {
|
||||||
|
let due = sqlx::query_as::<_, Due>(
|
||||||
|
"SELECT id, doc_id, attempts FROM outbox WHERE done_at IS NULL AND next_at <= ? ORDER BY id",
|
||||||
|
)
|
||||||
|
.bind(util::now())
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let mut docs_due: Vec<String> = due.iter().map(|d| d.doc_id.clone()).collect();
|
||||||
|
docs_due.dedup();
|
||||||
|
for doc_id in docs_due {
|
||||||
|
let rows: Vec<&Due> = due.iter().filter(|d| d.doc_id == doc_id).collect();
|
||||||
|
let last = rows.iter().map(|d| d.id).max().unwrap_or(0);
|
||||||
|
let attempts = rows.iter().map(|d| d.attempts).max().unwrap_or(0);
|
||||||
|
match publish_doc(state, &doc_id).await {
|
||||||
|
Ok(sha) => {
|
||||||
|
sqlx::query("UPDATE outbox SET done_at = ?, commit_sha = ?, last_error = NULL WHERE doc_id = ? AND done_at IS NULL AND id <= ?")
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(&sha)
|
||||||
|
.bind(&doc_id)
|
||||||
|
.bind(last)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(doc = %doc_id, commit = sha.as_deref().unwrap_or("unchanged"), "published");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let wait = (60_i64 << attempts.min(12)).min(MAX_BACKOFF_SECS);
|
||||||
|
sqlx::query("UPDATE outbox SET attempts = attempts + 1, last_error = ?, next_at = ? WHERE doc_id = ? AND done_at IS NULL")
|
||||||
|
.bind(format!("{e:#}").chars().take(500).collect::<String>())
|
||||||
|
.bind(util::in_seconds(wait))
|
||||||
|
.bind(&doc_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
tracing::warn!(doc = %doc_id, error = %e, retry_in = wait, "publication failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn git(dir: &Path, state: &AppState, args: &[&str]) -> anyhow::Result<String> {
|
||||||
|
let publish = state.cfg.publish.as_ref().ok_or_else(|| anyhow::anyhow!("publication not configured"))?;
|
||||||
|
let known_hosts = state.cfg.data_dir.join("keys/known_hosts");
|
||||||
|
let ssh = format!(
|
||||||
|
"ssh -i {} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile={}",
|
||||||
|
publish.key.display(),
|
||||||
|
known_hosts.display()
|
||||||
|
);
|
||||||
|
let out = Command::new("git")
|
||||||
|
.args([
|
||||||
|
"-c",
|
||||||
|
&format!("user.name={}", publish.author_name),
|
||||||
|
"-c",
|
||||||
|
&format!("user.email={}", publish.author_email),
|
||||||
|
])
|
||||||
|
.args(args)
|
||||||
|
.current_dir(dir)
|
||||||
|
.env("GIT_SSH_COMMAND", ssh)
|
||||||
|
.env("GIT_TERMINAL_PROMPT", "0")
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
if !out.status.success() {
|
||||||
|
anyhow::bail!("git {}: {}", args.first().unwrap_or(&""), String::from_utf8_lossy(&out.stderr).trim());
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server's own clone: a cache of the remote, realigned on it before each
|
||||||
|
/// commit so a push never needs a merge. Returns the clone and the commit the
|
||||||
|
/// remote branch points to, if it exists.
|
||||||
|
async fn checkout(state: &AppState) -> anyhow::Result<(PathBuf, Option<String>)> {
|
||||||
|
let publish = state.cfg.publish.as_ref().ok_or_else(|| anyhow::anyhow!("publication not configured"))?;
|
||||||
|
let dir = state.cfg.data_dir.join("textes");
|
||||||
|
if !dir.join(".git").exists() {
|
||||||
|
tokio::fs::create_dir_all(&dir).await?;
|
||||||
|
git(&dir, state, &["init", "-q"]).await?;
|
||||||
|
git(&dir, state, &["remote", "add", "origin", &publish.remote]).await?;
|
||||||
|
}
|
||||||
|
git(&dir, state, &["remote", "set-url", "origin", &publish.remote]).await?;
|
||||||
|
let has_remote_branch = git(&dir, state, &["ls-remote", "--heads", "origin", &publish.branch]).await?;
|
||||||
|
let remote_head = has_remote_branch.split_whitespace().next().map(str::to_owned);
|
||||||
|
if remote_head.is_none() {
|
||||||
|
git(&dir, state, &["checkout", "-q", "-f", "-B", &publish.branch]).await?;
|
||||||
|
} else {
|
||||||
|
// This clone is a cache: whatever a failed pass left is re-rendered from the database.
|
||||||
|
git(&dir, state, &["fetch", "-q", "origin", &publish.branch]).await?;
|
||||||
|
git(&dir, state, &["checkout", "-q", "-f", "-B", &publish.branch, &format!("origin/{}", publish.branch)])
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok((dir, remote_head))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn publish_doc(state: &AppState, doc_id: &str) -> anyhow::Result<Option<String>> {
|
||||||
|
let st = docs::load_state(state, doc_id).await.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
let Some(path) = st.doc.publish_path.clone() else { return Ok(None) };
|
||||||
|
let rendered = render(state, &st).await?;
|
||||||
|
let (dir, remote_head) = checkout(state).await?;
|
||||||
|
let branch = state.cfg.publish.as_ref().map(|p| p.branch.clone()).unwrap_or_else(|| "main".into());
|
||||||
|
let target = dir.join(&path);
|
||||||
|
tokio::fs::create_dir_all(&target).await?;
|
||||||
|
for (name, content) in &rendered {
|
||||||
|
tokio::fs::write(target.join(name), content).await?;
|
||||||
|
}
|
||||||
|
let readme = dir.join("README.md");
|
||||||
|
if !readme.exists() {
|
||||||
|
tokio::fs::write(&readme, README).await?;
|
||||||
|
}
|
||||||
|
git(&dir, state, &["add", "-A", "--", &path, "README.md"]).await?;
|
||||||
|
let staged = git(&dir, state, &["diff", "--cached", "--name-only"]).await?;
|
||||||
|
if staged.is_empty() {
|
||||||
|
// Nothing new — but a commit the remote never received still goes out: a
|
||||||
|
// first push refused (a key without write access), or a new, empty
|
||||||
|
// repository after a change of remote.
|
||||||
|
let Ok(head) = git(&dir, state, &["rev-parse", "--verify", "-q", "HEAD"]).await else { return Ok(None) };
|
||||||
|
if remote_head.as_deref() == Some(head.as_str()) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
git(&dir, state, &["push", "-q", "origin", &format!("HEAD:{branch}")]).await?;
|
||||||
|
return Ok(Some(head));
|
||||||
|
}
|
||||||
|
let reasons: Vec<String> =
|
||||||
|
sqlx::query_scalar("SELECT reason FROM outbox WHERE doc_id = ? AND done_at IS NULL ORDER BY id")
|
||||||
|
.bind(doc_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let title = util::parse_lang(&st.doc.title).get(&st.doc.source_lang).cloned().unwrap_or_default();
|
||||||
|
let mut message = format!("{title} — {}", reasons.last().cloned().unwrap_or_else(|| "mise à jour".into()));
|
||||||
|
if reasons.len() > 1 {
|
||||||
|
message.push_str("\n\n");
|
||||||
|
for r in &reasons {
|
||||||
|
message.push_str(&format!("- {r}\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
git(&dir, state, &["commit", "-q", "-m", &message]).await?;
|
||||||
|
git(&dir, state, &["push", "-q", "origin", &format!("HEAD:{branch}")]).await?;
|
||||||
|
Ok(Some(git(&dir, state, &["rev-parse", "HEAD"]).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
const README: &str = "# Textes en vigueur — libreDecision\n\n\
|
||||||
|
Ce dépôt est écrit par libreDecision (https://decision.librodrome.org) : chaque fois qu'un article \
|
||||||
|
entre en vigueur par le vote, qu'une coquille est corrigée ou qu'une traduction change, le texte en \
|
||||||
|
vigueur est republié ici. Les applications clientes le lisent pour se mettre à jour.\n\n\
|
||||||
|
- `<groupe>/<document>/fr.md` : le texte qui fait foi (langue source) ;\n\
|
||||||
|
- `en.md`, `es.md` : traductions éditoriales, sans vote ;\n\
|
||||||
|
- `document.json` : le même contenu, structuré, avec l'état du vote de chaque article.\n\n\
|
||||||
|
Ne pas modifier à la main : le prochain passage de libreDecision réécrit ces fichiers.\n";
|
||||||
|
|
||||||
|
/// The date of the last change to what is published (an entry into force, a
|
||||||
|
/// typo fix, a translation) — never "now", so that publishing an unchanged
|
||||||
|
/// document changes nothing and commits nothing.
|
||||||
|
async fn last_change(state: &AppState, doc_id: &str) -> anyhow::Result<String> {
|
||||||
|
let at: Option<String> = sqlx::query_scalar(
|
||||||
|
"SELECT MAX(d) FROM (
|
||||||
|
SELECT MAX(at) AS d FROM adoptions WHERE doc_id = ?1
|
||||||
|
UNION ALL SELECT MAX(p.updated_at) FROM proposals p JOIN units u ON u.in_force_id = p.id WHERE u.doc_id = ?1
|
||||||
|
UNION ALL SELECT MAX(t.updated_at) FROM translations t JOIN units u ON u.in_force_id = t.proposal_id WHERE u.doc_id = ?1
|
||||||
|
UNION ALL SELECT created_at FROM documents WHERE id = ?1)",
|
||||||
|
)
|
||||||
|
.bind(doc_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(at.unwrap_or_else(util::now))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On start, every published document is queued once more: a change that
|
||||||
|
/// entered into force just before a restart, or a new rendering format, reaches
|
||||||
|
/// the repository. Unchanged documents produce no commit. What was waiting
|
||||||
|
/// after failures is tried again at once (a key just added, a new remote).
|
||||||
|
pub async fn resync(state: &AppState) -> anyhow::Result<()> {
|
||||||
|
if state.cfg.publish.is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
retry_now(state).await?;
|
||||||
|
let now = util::now();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO outbox (doc_id, reason, created_at, next_at)
|
||||||
|
SELECT d.id, 'reprise au démarrage', ?1, ?1 FROM documents d
|
||||||
|
WHERE d.publish_path IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM outbox o WHERE o.doc_id = d.id AND o.done_at IS NULL)",
|
||||||
|
)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
state.publish_wake.notify_one();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything still waiting goes out at the next pass, backoff forgotten.
|
||||||
|
pub async fn retry_now(state: &AppState) -> anyhow::Result<()> {
|
||||||
|
sqlx::query("UPDATE outbox SET next_at = ?, attempts = 0 WHERE done_at IS NULL")
|
||||||
|
.bind(util::now())
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
state.publish_wake.notify_one();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What administrators see of the publication in trustinWallet.
|
||||||
|
pub async fn status(state: &AppState) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let pending: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL").fetch_one(&state.db).await?;
|
||||||
|
let error: Option<String> = sqlx::query_scalar(
|
||||||
|
"SELECT last_error FROM outbox WHERE done_at IS NULL AND last_error IS NOT NULL ORDER BY id DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
let last: Option<(String, Option<String>)> = sqlx::query_as(
|
||||||
|
"SELECT done_at, commit_sha FROM outbox WHERE done_at IS NOT NULL AND commit_sha IS NOT NULL
|
||||||
|
ORDER BY done_at DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(json!({
|
||||||
|
"pending": pending,
|
||||||
|
"lastError": error,
|
||||||
|
"lastPublishedAt": last.as_ref().map(|(at, _)| at.clone()),
|
||||||
|
"lastCommit": last.and_then(|(_, sha)| sha),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Markdown per language + JSON. Only the units in force, in order.
|
||||||
|
pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result<Vec<(String, String)>> {
|
||||||
|
let titles = util::parse_lang(&st.doc.title);
|
||||||
|
let src = st.doc.source_lang.clone();
|
||||||
|
let in_force: Vec<_> = st
|
||||||
|
.units
|
||||||
|
.iter()
|
||||||
|
.filter_map(|u| {
|
||||||
|
let fid = u.in_force_id.as_ref()?;
|
||||||
|
st.proposals.iter().find(|p| &p.id == fid).map(|p| (u, p))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let refs: Vec<&docs::ProposalRow> = in_force.iter().map(|(_, p)| *p).collect();
|
||||||
|
let tr = docs::translations_for(state, &refs).await.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
let updated = last_change(state, &st.doc.id).await?;
|
||||||
|
let source_url = format!("{}/g/{}/docs/{}", state.cfg.public_url, st.group.slug, st.doc.slug);
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for lang in util::LANGS {
|
||||||
|
let title = titles.get(lang).or_else(|| titles.get(&src)).cloned().unwrap_or_default();
|
||||||
|
let mut md = format!(
|
||||||
|
"---\ndocument: {}\nlangue: {lang}\nsource: {source_url}\nmise_a_jour: {updated}\n{}---\n\n# {title}\n",
|
||||||
|
st.doc.slug,
|
||||||
|
if lang == src { "fait_foi: oui\n".to_owned() } else { "traduction: editoriale\n".to_owned() },
|
||||||
|
);
|
||||||
|
for (_, p) in &in_force {
|
||||||
|
let t = tr.get(&p.id).and_then(|m| m.get(lang));
|
||||||
|
let (ut, ub, fallback) = match t {
|
||||||
|
Some(t) if lang != src => (
|
||||||
|
t["title"].as_str().unwrap_or(&p.title).to_owned(),
|
||||||
|
t["body"].as_str().unwrap_or(&p.body).to_owned(),
|
||||||
|
t["stale"].as_bool().unwrap_or(false),
|
||||||
|
),
|
||||||
|
_ => (p.title.clone(), p.body.clone(), lang != src),
|
||||||
|
};
|
||||||
|
md.push_str(&format!("\n## {ut}\n\n{ub}\n"));
|
||||||
|
if fallback {
|
||||||
|
md.push_str(&format!(
|
||||||
|
"\n<!-- {lang} : traduction à revoir ou absente, texte source affiché si absente -->\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files.push((format!("{lang}.md"), md));
|
||||||
|
}
|
||||||
|
let units: Vec<_> = in_force
|
||||||
|
.iter()
|
||||||
|
.map(|(u, p)| {
|
||||||
|
let mut title = serde_json::Map::new();
|
||||||
|
let mut body = serde_json::Map::new();
|
||||||
|
title.insert(src.clone(), json!(p.title));
|
||||||
|
body.insert(src.clone(), json!(p.body));
|
||||||
|
if let Some(m) = tr.get(&p.id) {
|
||||||
|
for (lang, t) in m {
|
||||||
|
title.insert(lang.clone(), t["title"].clone());
|
||||||
|
body.insert(lang.clone(), t["body"].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tally = st.tally(&p.id);
|
||||||
|
json!({
|
||||||
|
"code": u.code, "title": title, "body": body, "inertia": u.inertia,
|
||||||
|
"status": if p.entered_at.is_some() { "adopted" } else if p.confirmed_at.is_some() { "confirmed" } else { "seed-minimal" },
|
||||||
|
"enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let json = json!({
|
||||||
|
"document": st.doc.slug, "group": st.group.slug, "title": titles, "sourceLang": src,
|
||||||
|
"source": source_url, "updatedAt": updated, "electorate": st.electorate,
|
||||||
|
"voteFloor": st.doc.vote_floor, "units": units,
|
||||||
|
});
|
||||||
|
files.push(("document.json".into(), format!("{}\n", serde_json::to_string_pretty(&json)?)));
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
//! Seeds — so as not to start from a blank page.
|
||||||
|
//!
|
||||||
|
//! On start, missing groups are created and every `seeds/*.json` document is
|
||||||
|
//! instantiated in each group it names, **only if that group does not hold it
|
||||||
|
//! yet**. An existing document is never touched: from its first vote on, it
|
||||||
|
//! is runtime data.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::util::{self, LangMap};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SeedDoc {
|
||||||
|
pub slug: String,
|
||||||
|
pub title: LangMap,
|
||||||
|
pub summary: LangMap,
|
||||||
|
pub source_lang: String,
|
||||||
|
pub instances: Vec<SeedInstance>,
|
||||||
|
pub units: Vec<SeedUnit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SeedInstance {
|
||||||
|
pub group: String,
|
||||||
|
pub vote_floor: f64,
|
||||||
|
pub publish_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SeedUnit {
|
||||||
|
pub code: String,
|
||||||
|
pub section: Option<String>,
|
||||||
|
pub position: f64,
|
||||||
|
pub inertia: String,
|
||||||
|
pub versions: Vec<SeedVersion>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SeedVersion {
|
||||||
|
pub label: Option<String>,
|
||||||
|
/// `in_force` (seed a minima) or `open` (proposal at zero votes).
|
||||||
|
pub status: String,
|
||||||
|
pub title: LangMap,
|
||||||
|
pub body: LangMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> {
|
||||||
|
for (position, g) in trustin_access::default_groups().into_iter().enumerate() {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO groups (slug, name, parent, policy, hidden, position) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&g.slug)
|
||||||
|
.bind(&g.name)
|
||||||
|
.bind(&g.parent)
|
||||||
|
.bind(g.policy.as_str())
|
||||||
|
.bind(g.hidden)
|
||||||
|
.bind(i64::try_from(position).unwrap_or(0))
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
|
||||||
|
ensure_groups(state).await?;
|
||||||
|
let dir = state.cfg.seed_dir.clone();
|
||||||
|
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(dir = %dir.display(), error = %e, "no seed directory");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut files = Vec::new();
|
||||||
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().is_some_and(|x| x == "json") {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files.sort();
|
||||||
|
for path in files {
|
||||||
|
load_file(state, &path).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<()> {
|
||||||
|
let raw = tokio::fs::read_to_string(path).await?;
|
||||||
|
let seed: SeedDoc = serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
|
||||||
|
for inst in &seed.instances {
|
||||||
|
let group_exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM groups WHERE slug = ?")
|
||||||
|
.bind(&inst.group)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
if group_exists == 0 {
|
||||||
|
tracing::warn!(group = %inst.group, slug = %seed.slug, "seed names an unknown group");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let present: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents WHERE group_slug = ? AND slug = ?")
|
||||||
|
.bind(&inst.group)
|
||||||
|
.bind(&seed.slug)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
if present > 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
instantiate(state, &seed, inst).await?;
|
||||||
|
tracing::info!(group = %inst.group, slug = %seed.slug, "seed instantiated");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> anyhow::Result<()> {
|
||||||
|
let src = seed.source_lang.as_str();
|
||||||
|
let pick = |m: &LangMap| m.get(src).cloned().unwrap_or_default();
|
||||||
|
let now = util::now();
|
||||||
|
let doc_id = util::id();
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO documents (id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&doc_id)
|
||||||
|
.bind(&inst.group)
|
||||||
|
.bind(&seed.slug)
|
||||||
|
.bind(util::lang_json(&seed.title))
|
||||||
|
.bind(util::lang_json(&seed.summary))
|
||||||
|
.bind(src)
|
||||||
|
.bind(inst.vote_floor)
|
||||||
|
.bind(&inst.publish_path)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
for unit in &seed.units {
|
||||||
|
let unit_id = util::id();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)",
|
||||||
|
)
|
||||||
|
.bind(&unit_id)
|
||||||
|
.bind(&doc_id)
|
||||||
|
.bind(&unit.code)
|
||||||
|
.bind(&unit.section)
|
||||||
|
.bind(unit.position)
|
||||||
|
.bind(&unit.inertia)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
for v in &unit.versions {
|
||||||
|
let pid = util::id();
|
||||||
|
let (title, body) = (pick(&v.title), pick(&v.body));
|
||||||
|
let status = if v.status == "in_force" { "in_force" } else { "open" };
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO proposals (id, unit_id, kind, title, body, status, origin, label, author_id, revision,
|
||||||
|
created_at, updated_at, submitted_at)
|
||||||
|
VALUES (?, ?, 'text', ?, ?, ?, 'seed', ?, NULL, 1, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&pid)
|
||||||
|
.bind(&unit_id)
|
||||||
|
.bind(&title)
|
||||||
|
.bind(&body)
|
||||||
|
.bind(status)
|
||||||
|
.bind(&v.label)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) VALUES (?, 1, 'initial', ?, ?, NULL, ?)",
|
||||||
|
)
|
||||||
|
.bind(&pid)
|
||||||
|
.bind(&title)
|
||||||
|
.bind(&body)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if status == "in_force" {
|
||||||
|
sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?")
|
||||||
|
.bind(&pid)
|
||||||
|
.bind(&unit_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
let hash = util::source_hash(&title, &body);
|
||||||
|
for lang in util::LANGS.iter().filter(|l| **l != src) {
|
||||||
|
let (Some(t), Some(b)) = (v.title.get(*lang), v.body.get(*lang)) else { continue };
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'seed', NULL, ?)",
|
||||||
|
)
|
||||||
|
.bind(&pid)
|
||||||
|
.bind(lang)
|
||||||
|
.bind(t)
|
||||||
|
.bind(b)
|
||||||
|
.bind(&hash)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inst.publish_path.is_some() {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, 'publication initiale', ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&doc_id)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
//! Team links — one link an administrator shares with a team, until a date and
|
||||||
|
//! for a number of openings. Each opening asks the person's name and makes
|
||||||
|
//! **their own** invited identity, never an administrator: one person, one
|
||||||
|
//! voice, and access management stays with trustinWallet's administrators.
|
||||||
|
//! Someone already signed in keeps their identity and receives the groups; it
|
||||||
|
//! takes a place only if it adds a group.
|
||||||
|
//!
|
||||||
|
//! Limit, assumed: a person who opens the link twice under two names has two
|
||||||
|
//! identities. Where invited identities vote (the test group), administrators
|
||||||
|
//! see in trustinWallet which link each identity came from.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::{self, IdentityRow, User, Viewer};
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::util;
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::header::SET_COOKIE;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use ld_engine::{edit, names};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
const MAX_DAYS: i64 = 90;
|
||||||
|
const MAX_USES: i64 = 500;
|
||||||
|
const MAX_LABEL: usize = 60;
|
||||||
|
|
||||||
|
pub fn link(state: &AppState, token: &str) -> String {
|
||||||
|
format!("{}/equipe#{token}", state.cfg.public_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct TeamLink {
|
||||||
|
token_hash: String,
|
||||||
|
label: String,
|
||||||
|
groups: String,
|
||||||
|
created_by: String,
|
||||||
|
expires_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A link that still opens: not revoked, not expired, openings left.
|
||||||
|
async fn open_link(state: &AppState, token: &str) -> AppResult<TeamLink> {
|
||||||
|
sqlx::query_as::<_, TeamLink>(
|
||||||
|
"SELECT token_hash, label, groups, created_by, expires_at FROM team_links
|
||||||
|
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? AND uses < max_uses",
|
||||||
|
)
|
||||||
|
.bind(util::sha256_hex(token.trim()))
|
||||||
|
.bind(util::now())
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppError::Invalid("invitation_invalid"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slugs(raw: &str) -> Vec<String> {
|
||||||
|
serde_json::from_str(raw).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PeekBody {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the link opens, before the person gives a name.
|
||||||
|
pub async fn peek(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Viewer(viewer): Viewer,
|
||||||
|
Json(b): Json<PeekBody>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let l = open_link(&state, &b.token).await?;
|
||||||
|
let mut names = Vec::new();
|
||||||
|
for slug in slugs(&l.groups) {
|
||||||
|
if let Some(name) = sqlx::query_scalar::<_, String>("SELECT name FROM groups WHERE slug = ?")
|
||||||
|
.bind(&slug)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(json!({
|
||||||
|
"label": l.label,
|
||||||
|
"groups": names,
|
||||||
|
"expiresAt": l.expires_at,
|
||||||
|
"signedInAs": viewer.map(|v| v.display_name),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct JoinBody {
|
||||||
|
token: String,
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nobody joins under the name of someone already there, however it is dressed
|
||||||
|
/// (case, accents, punctuation): compared on `names::key`. The unique index on
|
||||||
|
/// `name_key` settles two openings at the same instant.
|
||||||
|
async fn free_name(state: &AppState, raw: &str) -> AppResult<names::Name> {
|
||||||
|
let name = names::check(raw).ok_or(AppError::Invalid("name"))?;
|
||||||
|
// People already there, and those an invitation still waits for.
|
||||||
|
let mut taken: Vec<String> = sqlx::query_scalar("SELECT display_name FROM identities").fetch_all(&state.db).await?;
|
||||||
|
taken.extend(
|
||||||
|
sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT label FROM invitations WHERE used_at IS NULL AND identity_id IS NULL AND expires_at > ?",
|
||||||
|
)
|
||||||
|
.bind(util::now())
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
if taken.iter().any(|t| names::key(t) == name.key) {
|
||||||
|
return Err(AppError::Conflict("name_taken"));
|
||||||
|
}
|
||||||
|
Ok(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The groups of the link this identity does not have yet.
|
||||||
|
async fn missing_groups(state: &AppState, identity_id: &str, groups: &[String]) -> AppResult<usize> {
|
||||||
|
let mine: Vec<String> = sqlx::query_scalar("SELECT group_slug FROM memberships WHERE identity_id = ?")
|
||||||
|
.bind(identity_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(groups.iter().filter(|g| !mine.contains(g)).count())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn join(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Viewer(viewer): Viewer,
|
||||||
|
Json(b): Json<JoinBody>,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
let l = open_link(&state, &b.token).await?;
|
||||||
|
let groups = slugs(&l.groups);
|
||||||
|
// Checked before an opening is counted.
|
||||||
|
let name = match &viewer {
|
||||||
|
Some(me) => {
|
||||||
|
// Back with the session already open: nothing to add, no place taken.
|
||||||
|
if missing_groups(&state, &me.id, &groups).await? == 0 {
|
||||||
|
return Ok(Json(json!({ "me": me })).into_response());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
None => Some(free_name(&state, b.name.as_deref().unwrap_or_default()).await?),
|
||||||
|
};
|
||||||
|
// Count the opening first: of two at the last place, one only goes on.
|
||||||
|
let counted = sqlx::query(
|
||||||
|
"UPDATE team_links SET uses = uses + 1
|
||||||
|
WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? AND uses < max_uses",
|
||||||
|
)
|
||||||
|
.bind(&l.token_hash)
|
||||||
|
.bind(util::now())
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
if counted.rows_affected() != 1 {
|
||||||
|
return Err(AppError::Invalid("invitation_invalid"));
|
||||||
|
}
|
||||||
|
let (identity_id, session) = match (viewer, name) {
|
||||||
|
(Some(me), _) => (me.id, None),
|
||||||
|
(None, Some(name)) => {
|
||||||
|
let id = format!("inv:{}", util::id());
|
||||||
|
let inserted = sqlx::query(
|
||||||
|
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at, team_link, name_key)
|
||||||
|
VALUES (?, 'invite', ?, 0, 0, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&name.display)
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(&l.token_hash)
|
||||||
|
.bind(&name.key)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
if let Err(sqlx::Error::Database(e)) = &inserted
|
||||||
|
&& e.is_unique_violation()
|
||||||
|
{
|
||||||
|
// Same name at the same instant: give the place back.
|
||||||
|
sqlx::query("UPDATE team_links SET uses = uses - 1 WHERE token_hash = ?")
|
||||||
|
.bind(&l.token_hash)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
return Err(AppError::Conflict("name_taken"));
|
||||||
|
}
|
||||||
|
inserted?;
|
||||||
|
let session = auth::open_session(&state, &id).await?;
|
||||||
|
(id, Some(session))
|
||||||
|
}
|
||||||
|
(None, None) => return Err(AppError::Invalid("name")),
|
||||||
|
};
|
||||||
|
auth::grant_groups(&state, &identity_id, &groups, Some(&l.created_by)).await?;
|
||||||
|
tracing::info!(link = %&l.token_hash[..8], identity = %identity_id, "team link opened");
|
||||||
|
let me = auth::load_identity(&state, &identity_id).await?;
|
||||||
|
let mut res = Json(json!({ "me": me })).into_response();
|
||||||
|
if let Some(cookie) = session {
|
||||||
|
res.headers_mut().append(SET_COOKIE, cookie);
|
||||||
|
}
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_only(user: &IdentityRow) -> AppResult<()> {
|
||||||
|
if trustin_access::manages_access(&user.to_identity()) { Ok(()) } else { Err(AppError::Forbidden) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreateBody {
|
||||||
|
label: String,
|
||||||
|
groups: Vec<String>,
|
||||||
|
days: i64,
|
||||||
|
max_uses: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The link is returned once and never stored in clear.
|
||||||
|
pub async fn create(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Json(b): Json<CreateBody>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
admin_only(&user)?;
|
||||||
|
let label = b.label.trim();
|
||||||
|
if label.is_empty()
|
||||||
|
|| label.chars().count() > MAX_LABEL
|
||||||
|
|| label.chars().any(char::is_control)
|
||||||
|
|| edit::has_hidden_controls(label)
|
||||||
|
{
|
||||||
|
return Err(AppError::Invalid("label"));
|
||||||
|
}
|
||||||
|
if !(1..=MAX_DAYS).contains(&b.days) || !(1..=MAX_USES).contains(&b.max_uses) {
|
||||||
|
return Err(AppError::Invalid("team_limits"));
|
||||||
|
}
|
||||||
|
let known: Vec<String> = sqlx::query_scalar("SELECT slug FROM groups").fetch_all(&state.db).await?;
|
||||||
|
if b.groups.is_empty() || b.groups.iter().any(|g| !known.contains(g)) {
|
||||||
|
return Err(AppError::Invalid("groups"));
|
||||||
|
}
|
||||||
|
let token = util::token();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO team_links (token_hash, label, groups, created_by, created_at, expires_at, max_uses)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(util::sha256_hex(&token))
|
||||||
|
.bind(label)
|
||||||
|
.bind(serde_json::to_string(&b.groups).unwrap_or_else(|_| "[]".into()))
|
||||||
|
.bind(&user.id)
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(util::in_days(b.days))
|
||||||
|
.bind(b.max_uses)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(by = %user.id, days = b.days, max_uses = b.max_uses, "team link created");
|
||||||
|
Ok(Json(json!({ "link": link(&state, &token) })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(hash): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
admin_only(&user)?;
|
||||||
|
sqlx::query("UPDATE team_links SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL")
|
||||||
|
.bind(util::now())
|
||||||
|
.bind(&hash)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(by = %user.id, "team link revoked");
|
||||||
|
Ok(Json(json!({ "ok": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The links still open, for trustinWallet.
|
||||||
|
pub async fn list(state: &AppState) -> AppResult<Vec<serde_json::Value>> {
|
||||||
|
let rows: Vec<(String, String, String, String, i64, i64)> = sqlx::query_as(
|
||||||
|
"SELECT token_hash, label, groups, expires_at, max_uses, uses FROM team_links
|
||||||
|
WHERE revoked_at IS NULL AND expires_at > ? ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(util::now())
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, label, groups, expires, max_uses, uses)| {
|
||||||
|
json!({
|
||||||
|
"id": id, "label": label, "groups": slugs(&groups),
|
||||||
|
"expiresAt": expires, "maxUses": max_uses, "uses": uses,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
//! Small shared helpers: clock, randomness, hashing, language maps.
|
||||||
|
|
||||||
|
use rand::RngCore;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
|
||||||
|
/// Languages of the interface and of the editorial translations (Yvv, 2026-10-21).
|
||||||
|
pub const LANGS: [&str; 3] = ["fr", "en", "es"];
|
||||||
|
|
||||||
|
pub fn now() -> String {
|
||||||
|
stamp(OffsetDateTime::now_utc())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stamp(at: OffsetDateTime) -> String {
|
||||||
|
at.replace_nanosecond(0).unwrap_or(at).format(&Rfc3339).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn in_days(days: i64) -> String {
|
||||||
|
stamp(OffsetDateTime::now_utc() + time::Duration::days(days))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn in_seconds(secs: i64) -> String {
|
||||||
|
stamp(OffsetDateTime::now_utc() + time::Duration::seconds(secs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 32 random bytes, hex — session tokens, invitation tokens, OAuth state.
|
||||||
|
pub fn token() -> String {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
rand::rng().fill_bytes(&mut bytes);
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 16 random bytes, hex — row identifiers.
|
||||||
|
pub fn id() -> String {
|
||||||
|
let mut bytes = [0u8; 16];
|
||||||
|
rand::rng().fill_bytes(&mut bytes);
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sha256_hex(data: &str) -> String {
|
||||||
|
hex::encode(Sha256::digest(data.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hash a translation is pinned to: it goes stale when the source changes.
|
||||||
|
pub fn source_hash(title: &str, body: &str) -> String {
|
||||||
|
sha256_hex(&format!("{title}\n\u{1f}\n{body}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type LangMap = BTreeMap<String, String>;
|
||||||
|
|
||||||
|
pub fn lang_json(map: &LangMap) -> String {
|
||||||
|
serde_json::to_string(map).unwrap_or_else(|_| "{}".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_lang(raw: &str) -> LangMap {
|
||||||
|
serde_json::from_str(raw).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_lang(lang: &str) -> bool {
|
||||||
|
LANGS.contains(&lang)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//! The compiled Svelte interface: static files, SPA fallback, cache policy.
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::Request;
|
||||||
|
use axum::http::header::{CACHE_CONTROL, HeaderValue};
|
||||||
|
use axum::response::Response;
|
||||||
|
use std::path::Path;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use tower::util::BoxCloneSyncService;
|
||||||
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
|
|
||||||
|
pub type WebService = BoxCloneSyncService<Request<Body>, Response, std::convert::Infallible>;
|
||||||
|
|
||||||
|
/// Hashed assets are immutable; everything else (index.html) is revalidated.
|
||||||
|
pub fn service(dir: &Path) -> WebService {
|
||||||
|
let index = dir.join("index.html");
|
||||||
|
let serve = ServeDir::new(dir).precompressed_gzip().fallback(ServeFile::new(index));
|
||||||
|
let svc = tower::service_fn(move |req: Request<Body>| {
|
||||||
|
let immutable = req.uri().path().starts_with("/_app/immutable/");
|
||||||
|
let serve = serve.clone();
|
||||||
|
async move {
|
||||||
|
let res = serve.oneshot(req).await.unwrap_or_else(|e| match e {});
|
||||||
|
let mut res = res.map(Body::new);
|
||||||
|
let policy = if immutable { "public, max-age=31536000, immutable" } else { "no-cache" };
|
||||||
|
res.headers_mut().insert(CACHE_CONTROL, HeaderValue::from_static(policy));
|
||||||
|
Ok::<_, std::convert::Infallible>(res)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
BoxCloneSyncService::new(svc)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
//! W — the size of the Ğ1 web of trust, the electorate of Monnaie libre.
|
||||||
|
//!
|
||||||
|
//! Read on chain first (`Membership.CounterForMembership`, JSON-RPC
|
||||||
|
//! `state_getStorage` on a Duniter v2s node), from an indexer otherwise, and
|
||||||
|
//! kept in `settings`. A failed refresh keeps the last known value; the very
|
||||||
|
//! first start falls back to `LD_WOT_FALLBACK`.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::util;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const REFRESH: Duration = Duration::from_secs(6 * 3600);
|
||||||
|
const PLAUSIBLE: std::ops::RangeInclusive<u32> = 100..=10_000_000;
|
||||||
|
|
||||||
|
fn twox_128(data: &[u8]) -> [u8; 16] {
|
||||||
|
let mut out = [0u8; 16];
|
||||||
|
out[..8].copy_from_slice(&twox_hash::XxHash64::oneshot(0, data).to_le_bytes());
|
||||||
|
out[8..].copy_from_slice(&twox_hash::XxHash64::oneshot(1, data).to_le_bytes());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Storage key of a plain storage value: `twox_128(pallet) ++ twox_128(item)`.
|
||||||
|
pub fn storage_key(pallet: &str, item: &str) -> String {
|
||||||
|
let mut key = Vec::with_capacity(32);
|
||||||
|
key.extend_from_slice(&twox_128(pallet.as_bytes()));
|
||||||
|
key.extend_from_slice(&twox_128(item.as_bytes()));
|
||||||
|
format!("0x{}", hex::encode(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SCALE `u32`, little endian.
|
||||||
|
pub fn decode_u32(hex_value: &str) -> Option<u32> {
|
||||||
|
let bytes = hex::decode(hex_value.trim_start_matches("0x")).ok()?;
|
||||||
|
let arr: [u8; 4] = bytes.get(..4)?.try_into().ok()?;
|
||||||
|
Some(u32::from_le_bytes(arr))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn from_chain(state: &AppState) -> Option<(u32, String)> {
|
||||||
|
let key = storage_key("Membership", "CounterForMembership");
|
||||||
|
for node in &state.cfg.rpc_endpoints {
|
||||||
|
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": "state_getStorage", "params": [key] });
|
||||||
|
let res = state.http.post(node).json(&body).send().await;
|
||||||
|
let value: Option<serde_json::Value> = match res {
|
||||||
|
Ok(r) if r.status().is_success() => r.json().await.ok(),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(n) = value.as_ref().and_then(|v| v["result"].as_str()).and_then(decode_u32)
|
||||||
|
&& PLAUSIBLE.contains(&n)
|
||||||
|
{
|
||||||
|
return Some((n, format!("chain:{node}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn from_indexer(state: &AppState) -> Option<(u32, String)> {
|
||||||
|
let query = json!({ "query": "{ identities(filter:{isMember:{equalTo:true}}) { totalCount } }" });
|
||||||
|
for url in &state.cfg.indexers {
|
||||||
|
let res = state.http.post(url).json(&query).send().await;
|
||||||
|
let value: Option<serde_json::Value> = match res {
|
||||||
|
Ok(r) if r.status().is_success() => r.json().await.ok(),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let n = value
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v["data"]["identities"]["totalCount"].as_u64())
|
||||||
|
.and_then(|n| u32::try_from(n).ok());
|
||||||
|
if let Some(n) = n.filter(|n| PLAUSIBLE.contains(n)) {
|
||||||
|
return Some((n, format!("indexer:{url}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh(state: &AppState) -> anyhow::Result<Option<u32>> {
|
||||||
|
let found = match from_chain(state).await {
|
||||||
|
Some(v) => Some(v),
|
||||||
|
None => from_indexer(state).await,
|
||||||
|
};
|
||||||
|
let Some((n, source)) = found else {
|
||||||
|
tracing::warn!("web of trust size: no node nor indexer answered, keeping the last value");
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let now = util::now();
|
||||||
|
for (key, value) in [("wot_size", n.to_string()), ("wot_source", source.clone())] {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
|
||||||
|
)
|
||||||
|
.bind(key)
|
||||||
|
.bind(value)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
tracing::info!(size = n, %source, "web of trust size");
|
||||||
|
Ok(Some(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresher(state: AppState) {
|
||||||
|
let mut tick = tokio::time::interval(REFRESH);
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
if let Err(e) = refresh(&state).await {
|
||||||
|
tracing::warn!(error = %e, "web of trust refresh");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn storage_keys_match_substrate() {
|
||||||
|
// Well-known key of System.Number (block number) on any Substrate chain.
|
||||||
|
assert_eq!(
|
||||||
|
storage_key("System", "Number"),
|
||||||
|
"0x26aa394eea5630e07c48ae0c9558cef702a5c1b19ab7a04f536c519aca4983ac"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scale_u32() {
|
||||||
|
assert_eq!(decode_u32("0x71160000"), Some(5745));
|
||||||
|
assert_eq!(decode_u32("0x01"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,864 @@
|
|||||||
|
//! End-to-end API scenarios on an in-memory database seeded like production.
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode, header};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
struct Client {
|
||||||
|
router: axum::Router,
|
||||||
|
cookie: Option<String>,
|
||||||
|
/// The `Set-Cookie` of the last answer, whole.
|
||||||
|
set_cookie: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
async fn new() -> (Self, AppState) {
|
||||||
|
let db = open_db("sqlite::memory:").await.expect("db");
|
||||||
|
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
||||||
|
seed::ensure(&state).await.expect("seed");
|
||||||
|
(Self { router: app(state.clone()), cookie: None, set_cookie: None }, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with(&self, cookie: Option<String>) -> Self {
|
||||||
|
Self { router: self.router.clone(), cookie, set_cookie: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
|
||||||
|
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
|
||||||
|
if let Some(c) = &self.cookie {
|
||||||
|
req = req.header(header::COOKIE, c);
|
||||||
|
}
|
||||||
|
let req = match body {
|
||||||
|
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
|
||||||
|
None => req.body(Body::empty()),
|
||||||
|
}
|
||||||
|
.expect("request");
|
||||||
|
let res = self.router.clone().oneshot(req).await.expect("response");
|
||||||
|
let status = res.status();
|
||||||
|
self.set_cookie = res.headers().get(header::SET_COOKIE).map(|v| v.to_str().expect("cookie").to_owned());
|
||||||
|
if let Some(set) = res.headers().get(header::SET_COOKIE) {
|
||||||
|
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
|
||||||
|
if raw.starts_with("ld_session=") {
|
||||||
|
self.cookie = Some(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
||||||
|
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(&mut self, name: &str, admin: bool) {
|
||||||
|
let (s, v) = self.call("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn doc_id(c: &mut Client, group: &str) -> String {
|
||||||
|
let (s, v) = c.call("GET", &format!("/api/groups/{group}"), None).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
v["docs"][0]["id"].as_str().expect("doc").to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
||||||
|
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn anonymous_sees_nothing_and_csrf_is_enforced() {
|
||||||
|
let (mut c, _) = Client::new().await;
|
||||||
|
let (s, _) = c.call("GET", "/api/groups", None).await;
|
||||||
|
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||||
|
let (s, v) = c.call("GET", "/api/me", None).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert!(v["me"].is_null());
|
||||||
|
// A mutating call without the header is refused.
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/auth/dev")
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(r#"{"name":"x"}"#))
|
||||||
|
.expect("request");
|
||||||
|
let res = c.router.clone().oneshot(req).await.expect("response");
|
||||||
|
assert_eq!(res.status(), StatusCode::FORBIDDEN);
|
||||||
|
// No neighbour may frame the product.
|
||||||
|
assert_eq!(res.headers().get(header::X_FRAME_OPTIONS).map(|v| v.as_bytes()), Some(&b"DENY"[..]));
|
||||||
|
assert!(res.headers().contains_key(header::X_CONTENT_TYPE_OPTIONS));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_seed_a_minima_and_the_open_proposals() {
|
||||||
|
let (mut c, _) = Client::new().await;
|
||||||
|
c.login("Chiara", false).await;
|
||||||
|
let (_, groups) = c.call("GET", "/api/groups", None).await;
|
||||||
|
let slugs: Vec<&str> =
|
||||||
|
groups["groups"].as_array().expect("groups").iter().filter_map(|g| g["slug"].as_str()).collect();
|
||||||
|
assert_eq!(slugs, ["monnaie-libre", "axiom", "librodrome", "vallee-drome-crestois", "essai"]);
|
||||||
|
|
||||||
|
let id = doc_id(&mut c, "monnaie-libre").await;
|
||||||
|
let (s, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let in_force: Vec<&str> = doc["units"]
|
||||||
|
.as_array()
|
||||||
|
.expect("units")
|
||||||
|
.iter()
|
||||||
|
.filter(|u| !u["inForce"].is_null())
|
||||||
|
.filter_map(|u| u["code"].as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(in_force, ["I1", "E1", "E2"]);
|
||||||
|
let e1 = &unit(&doc, "E1")["inForce"];
|
||||||
|
assert_eq!(e1["seedMinimal"], true);
|
||||||
|
assert_eq!(e1["tally"]["for"], 0);
|
||||||
|
assert_eq!(doc["doc"]["voteFloor"], 50.0);
|
||||||
|
assert_eq!(doc["doc"]["electorate"], 5745);
|
||||||
|
// Every other article is an open proposal at zero votes, standard inertia.
|
||||||
|
let e3 = unit(&doc, "E3");
|
||||||
|
assert!(e3["inForce"].is_null());
|
||||||
|
assert_eq!(e3["proposals"].as_array().expect("p").len(), 2, "E3 and its 0.4.0 variant");
|
||||||
|
assert_eq!(e3["inertia"], "standard");
|
||||||
|
// Translations seeded.
|
||||||
|
assert!(e1["translations"]["en"]["body"].as_str().expect("en").contains("commit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn one_vote_never_adopts_in_monnaie_libre() {
|
||||||
|
let (mut c, _) = Client::new().await;
|
||||||
|
c.login("Yvv", false).await;
|
||||||
|
let id = doc_id(&mut c, "monnaie-libre").await;
|
||||||
|
let (_, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||||
|
let p = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("p").to_owned();
|
||||||
|
let (s, v) = c.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
assert_eq!(v["entered"].as_array().expect("e").len(), 0);
|
||||||
|
let (_, d) = c.call("GET", &format!("/api/proposals/{p}"), None).await;
|
||||||
|
assert_eq!(d["proposal"]["evaluation"]["required"], 50);
|
||||||
|
assert_eq!(d["proposal"]["evaluation"]["wellRated"], true);
|
||||||
|
assert_eq!(d["proposal"]["myVote"], "for");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adoption_in_the_test_group_and_publication_queued_only_where_published() {
|
||||||
|
let (mut a, state) = Client::new().await;
|
||||||
|
a.login("Yvv", false).await;
|
||||||
|
let mut b = a.with(None);
|
||||||
|
b.login("Chiara", false).await;
|
||||||
|
// Dev identities are members of every group: W(essai) = 2, C = 1.
|
||||||
|
let id = doc_id(&mut a, "essai").await;
|
||||||
|
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||||
|
let e3 = unit(&doc, "E3");
|
||||||
|
let p = e3["proposals"][0]["id"].as_str().expect("p").to_owned();
|
||||||
|
let (_, v) = a.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
assert!(v["entered"].as_array().expect("e").is_empty(), "one of two is not enough");
|
||||||
|
let (_, v) = b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
assert_eq!(v["entered"][0]["code"], "E3");
|
||||||
|
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||||
|
let e3 = unit(&doc, "E3");
|
||||||
|
assert_eq!(e3["inForce"]["id"], p.as_str());
|
||||||
|
assert!(e3["inForce"]["enteredAt"].is_string());
|
||||||
|
// The test group publishes nothing.
|
||||||
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE doc_id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("count");
|
||||||
|
assert_eq!(n, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_rival_replaces_the_seed_and_the_seed_returns_among_proposals() {
|
||||||
|
let (mut a, _) = Client::new().await;
|
||||||
|
a.login("Yvv", false).await;
|
||||||
|
let mut b = a.with(None);
|
||||||
|
b.login("Chiara", false).await;
|
||||||
|
let id = doc_id(&mut a, "essai").await;
|
||||||
|
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||||
|
let e1 = unit(&doc, "E1");
|
||||||
|
let (uid, seed_id) =
|
||||||
|
(e1["id"].as_str().expect("u").to_owned(), e1["inForce"]["id"].as_str().expect("v").to_owned());
|
||||||
|
let (s, v) = a
|
||||||
|
.call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({
|
||||||
|
"unitId": uid, "title": "Unicité du compte",
|
||||||
|
"body": "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie Ğ1.", "submit": true,
|
||||||
|
})))
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
let rival = v["id"].as_str().expect("id").to_owned();
|
||||||
|
a.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
let (_, v) = b.call("POST", &format!("/api/proposals/{rival}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
assert_eq!(v["entered"][0]["kind"], "text");
|
||||||
|
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||||
|
let e1 = unit(&doc, "E1");
|
||||||
|
assert_eq!(e1["inForce"]["id"], rival.as_str());
|
||||||
|
assert!(
|
||||||
|
e1["proposals"].as_array().expect("p").iter().any(|p| p["id"] == seed_id.as_str() && p["status"] == "open")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn editing_is_not_voting() {
|
||||||
|
let (mut a, _) = Client::new().await;
|
||||||
|
a.login("Yvv", false).await;
|
||||||
|
let mut b = a.with(None);
|
||||||
|
b.login("Chiara", false).await;
|
||||||
|
let id = doc_id(&mut a, "monnaie-libre").await;
|
||||||
|
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||||
|
let i1 = unit(&doc, "I1")["inForce"].clone();
|
||||||
|
let pid = i1["id"].as_str().expect("id").to_owned();
|
||||||
|
// Anyone may fix the glued word of the preamble in force, without a vote.
|
||||||
|
let fixed = i1["body"].as_str().expect("body").replace("latransmission", "la transmission");
|
||||||
|
let (s, v) =
|
||||||
|
b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": fixed }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
assert_eq!(v["edit"], "typo");
|
||||||
|
// A change of meaning in force is refused: it goes through a proposal.
|
||||||
|
let (s, v) = b
|
||||||
|
.call(
|
||||||
|
"PATCH",
|
||||||
|
&format!("/api/proposals/{pid}"),
|
||||||
|
Some(json!({ "title": i1["title"], "body": "Tout autre chose." })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::CONFLICT);
|
||||||
|
assert_eq!(v["error"], "substantive_in_force");
|
||||||
|
// Flipping a relation in a seed text is not a typo, even with the same letters.
|
||||||
|
let k1 = unit(&doc, "K1")["proposals"][0].clone();
|
||||||
|
let flipped = k1["body"].as_str().expect("body").replace(">= 5 certifications", "<= 5 certifications");
|
||||||
|
let (s, v) = b
|
||||||
|
.call(
|
||||||
|
"PATCH",
|
||||||
|
&format!("/api/proposals/{}", k1["id"].as_str().expect("id")),
|
||||||
|
Some(json!({ "title": k1["title"], "body": flipped })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::CONFLICT, "{v}");
|
||||||
|
assert_eq!(v["error"], "substantive_not_author");
|
||||||
|
// One word turned around in a text in force, and an invisible bidi override: refused.
|
||||||
|
let e2 = unit(&doc, "E2")["inForce"].clone();
|
||||||
|
let e2_id = e2["id"].as_str().expect("id").to_owned();
|
||||||
|
let body = e2["body"].as_str().expect("body").to_owned();
|
||||||
|
assert!(body.contains("ces deux présents"));
|
||||||
|
let (s, v) = b
|
||||||
|
.call(
|
||||||
|
"PATCH",
|
||||||
|
&format!("/api/proposals/{e2_id}"),
|
||||||
|
Some(json!({ "title": e2["title"], "body": body.replacen("deux", "dix", 1) })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")));
|
||||||
|
let (s, v) = b
|
||||||
|
.call(
|
||||||
|
"PATCH",
|
||||||
|
&format!("/api/proposals/{e2_id}"),
|
||||||
|
Some(json!({ "title": e2["title"], "body": format!("\u{202e}{body}") })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
|
||||||
|
// A title is one line.
|
||||||
|
let (s, v) = b
|
||||||
|
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(json!({ "title": "Récipro\ncité", "body": body })))
|
||||||
|
.await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("title")));
|
||||||
|
// The title never absorbs the start of the body.
|
||||||
|
let title = e2["title"].as_str().expect("title");
|
||||||
|
let (head, rest) = body.split_once(' ').expect("two words");
|
||||||
|
let (s, v) = b
|
||||||
|
.call(
|
||||||
|
"PATCH",
|
||||||
|
&format!("/api/proposals/{e2_id}"),
|
||||||
|
Some(json!({ "title": format!("{title} {head}"), "body": rest })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")), "{v}");
|
||||||
|
// Typo fixes are measured against the voted text: slips never add up.
|
||||||
|
let patch = |body: String| json!({ "title": e2["title"], "body": body });
|
||||||
|
let (_, v) = b
|
||||||
|
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "repectent", 1))))
|
||||||
|
.await;
|
||||||
|
assert_eq!(v["edit"], "typo", "one slip is a typo");
|
||||||
|
let (s, v) = b
|
||||||
|
.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.replacen("respectent", "reectent", 1))))
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
(s, v["error"].as_str()),
|
||||||
|
(StatusCode::CONFLICT, Some("substantive_in_force")),
|
||||||
|
"a second slip on top is not"
|
||||||
|
);
|
||||||
|
let (_, v) = b.call("PATCH", &format!("/api/proposals/{e2_id}"), Some(patch(body.clone()))).await;
|
||||||
|
assert_eq!(v["edit"], "typo", "back to the voted text");
|
||||||
|
// An address is compared exactly.
|
||||||
|
let x11 = unit(&doc, "X1.1")["proposals"][0].clone();
|
||||||
|
let x11_body = x11["body"].as_str().expect("body");
|
||||||
|
assert!(x11_body.contains("git.duniter.org"));
|
||||||
|
let (s, v) = b
|
||||||
|
.call(
|
||||||
|
"PATCH",
|
||||||
|
&format!("/api/proposals/{}", x11["id"].as_str().expect("id")),
|
||||||
|
Some(json!({ "title": x11["title"], "body": x11_body.replacen("git.duniter.org", "git.dunniter.org", 1) })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_not_author")));
|
||||||
|
|
||||||
|
// A member proposal: typo keeps votes, substance asks to reconfirm.
|
||||||
|
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
|
||||||
|
let (_, v) = a
|
||||||
|
.call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({
|
||||||
|
"unitId": uid, "title": "Réciprocité", "body": "Je m'engage à ne certifier que des persones qui respectent ces engagements.", "submit": true,
|
||||||
|
})))
|
||||||
|
.await;
|
||||||
|
let p = v["id"].as_str().expect("id").to_owned();
|
||||||
|
b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
// A misspelt word fixed: the vote stays.
|
||||||
|
let (_, v) = a
|
||||||
|
.call("PATCH", &format!("/api/proposals/{p}"), Some(json!({
|
||||||
|
"title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes qui respectent ces engagements.",
|
||||||
|
})))
|
||||||
|
.await;
|
||||||
|
assert_eq!(v["edit"], "typo");
|
||||||
|
let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await;
|
||||||
|
assert_eq!(d["proposal"]["tally"]["for"], 1, "a typo keeps the votes");
|
||||||
|
// Words added: a new revision, the vote waits for reconfirmation.
|
||||||
|
let (_, v) = a
|
||||||
|
.call("PATCH", &format!("/api/proposals/{p}"), Some(json!({
|
||||||
|
"title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes qui respectent ces deux engagements fondamentaux.",
|
||||||
|
})))
|
||||||
|
.await;
|
||||||
|
assert_eq!(v["edit"], "substantive");
|
||||||
|
let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await;
|
||||||
|
assert_eq!(d["proposal"]["myVoteStale"], true);
|
||||||
|
assert_eq!(d["proposal"]["tally"]["for"], 0);
|
||||||
|
b.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
let (_, d) = b.call("GET", &format!("/api/proposals/{p}"), None).await;
|
||||||
|
assert_eq!(d["proposal"]["myVoteStale"], false);
|
||||||
|
assert_eq!(d["proposal"]["tally"]["for"], 1);
|
||||||
|
assert_eq!(d["history"].as_array().expect("h").len(), 3);
|
||||||
|
// Someone else cannot change the substance.
|
||||||
|
let (s, _) = b
|
||||||
|
.call(
|
||||||
|
"PATCH",
|
||||||
|
&format!("/api/proposals/{p}"),
|
||||||
|
Some(json!({ "title": "Réciprocité", "body": "Autre chose entièrement." })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_contradiction_signal_blocks_entering_into_force() {
|
||||||
|
let (mut a, _) = Client::new().await;
|
||||||
|
a.login("Yvv", false).await;
|
||||||
|
let mut b = a.with(None);
|
||||||
|
b.login("Chiara", false).await;
|
||||||
|
let id = doc_id(&mut a, "essai").await;
|
||||||
|
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||||
|
let e2 = unit(&doc, "E2")["inForce"]["id"].as_str().expect("e2").to_owned();
|
||||||
|
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
|
||||||
|
let (s, v) = b
|
||||||
|
.call(
|
||||||
|
"POST",
|
||||||
|
&format!("/api/proposals/{k1}/signals"),
|
||||||
|
Some(json!({ "targetId": e2, "reason": "Ces règles contredisent la réciprocité en vigueur." })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
let sid = v["id"].as_str().expect("sid").to_owned();
|
||||||
|
a.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
let (_, v) = b.call("POST", &format!("/api/proposals/{k1}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
assert!(v["entered"].as_array().expect("e").is_empty(), "blocked while the signal stands");
|
||||||
|
// Only its author lifts it; then the pass lets the text in.
|
||||||
|
let (s, _) = a.call("POST", &format!("/api/signals/{sid}/lift"), None).await;
|
||||||
|
assert_eq!(s, StatusCode::FORBIDDEN);
|
||||||
|
let (_, v) = b.call("POST", &format!("/api/signals/{sid}/lift"), None).await;
|
||||||
|
assert_eq!(v["entered"][0]["code"], "K1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn inertia_change_is_voted_one_level_up() {
|
||||||
|
let (mut a, _) = Client::new().await;
|
||||||
|
a.login("Yvv", false).await;
|
||||||
|
let id = doc_id(&mut a, "monnaie-libre").await;
|
||||||
|
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||||
|
let uid = unit(&doc, "E1")["id"].as_str().expect("u").to_owned();
|
||||||
|
let (s, v) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "high" }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
let p = v["id"].as_str().expect("id").to_owned();
|
||||||
|
let (_, again) = a.call("POST", &format!("/api/units/{uid}/inertia"), Some(json!({ "target": "high" }))).await;
|
||||||
|
assert_eq!(again["id"], p.as_str());
|
||||||
|
let (_, d) = a.call("GET", &format!("/api/proposals/{p}"), None).await;
|
||||||
|
assert_eq!(d["proposal"]["votedAtInertia"], "high");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn previews_and_assistant() {
|
||||||
|
let (mut a, _) = Client::new().await;
|
||||||
|
a.login("Yvv", false).await;
|
||||||
|
let id = doc_id(&mut a, "monnaie-libre").await;
|
||||||
|
let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await;
|
||||||
|
assert_eq!(v["units"].as_array().expect("u").len(), 33);
|
||||||
|
let (_, v) = a.call("GET", &format!("/api/docs/{id}/preview?mode=wellrated"), None).await;
|
||||||
|
assert_eq!(v["units"].as_array().expect("u").len(), 3, "nothing is well rated at zero votes");
|
||||||
|
let (_, v) = a
|
||||||
|
.call("POST", &format!("/api/docs/{id}/assist"), Some(json!({
|
||||||
|
"text": "Je m'engage sur l'honneur à n'avoir jamais qu'un seul compte cocréateur de monnaie G1.\n\nJe m'engage à rester joignable par mes certifieurs.",
|
||||||
|
})))
|
||||||
|
.await;
|
||||||
|
let kinds: Vec<&str> = v["hints"].as_array().expect("h").iter().filter_map(|h| h["kind"].as_str()).collect();
|
||||||
|
assert!(kinds.contains(&"split"), "{v}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn split_a_draft() {
|
||||||
|
let (mut a, _) = Client::new().await;
|
||||||
|
a.login("Yvv", false).await;
|
||||||
|
let id = doc_id(&mut a, "essai").await;
|
||||||
|
let (_, v) = a
|
||||||
|
.call("POST", &format!("/api/docs/{id}/proposals"), Some(json!({ "title": "Deux choses", "body": "a\n\nb" })))
|
||||||
|
.await;
|
||||||
|
let p = v["id"].as_str().expect("id").to_owned();
|
||||||
|
let (s, v) = a
|
||||||
|
.call(
|
||||||
|
"POST",
|
||||||
|
&format!("/api/proposals/{p}/split"),
|
||||||
|
Some(json!({ "parts": ["Première.", "Au \u{202e}01\u{202c}."] })),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("hidden_characters")));
|
||||||
|
let (s, v) =
|
||||||
|
a.call("POST", &format!("/api/proposals/{p}/split"), Some(json!({ "parts": ["Première.", "Seconde."] }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
assert_eq!(v["drafts"].as_array().expect("d").len(), 2);
|
||||||
|
let (s, _) = a.call("POST", &format!("/api/proposals/{p}/submit"), None).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn access_management_is_for_administrators_only() {
|
||||||
|
let (mut user, _) = Client::new().await;
|
||||||
|
user.login("Chiara", false).await;
|
||||||
|
let (s, _) = user.call("GET", "/api/access", None).await;
|
||||||
|
assert_eq!(s, StatusCode::FORBIDDEN);
|
||||||
|
let mut admin = user.with(None);
|
||||||
|
admin.login("Yvv", true).await;
|
||||||
|
let (s, v) =
|
||||||
|
admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Chiara", "groups": ["essai"] }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
let link = v["link"].as_str().expect("link").to_owned();
|
||||||
|
let token = link.split('#').nth(1).expect("token").to_owned();
|
||||||
|
let mut invited = user.with(None);
|
||||||
|
let (s, v) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
assert_eq!(v["me"]["kind"], "invite");
|
||||||
|
let (_, g) = invited.call("GET", "/api/groups", None).await;
|
||||||
|
let open: Vec<&str> = g["groups"]
|
||||||
|
.as_array()
|
||||||
|
.expect("g")
|
||||||
|
.iter()
|
||||||
|
.filter(|g| g["open"] == true)
|
||||||
|
.filter_map(|g| g["slug"].as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(open, ["essai"]);
|
||||||
|
// Single use.
|
||||||
|
let mut again = user.with(None);
|
||||||
|
let (s, _) = again.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
// Monnaie libre is seen but closed to an invited identity.
|
||||||
|
let (s, _) = invited.call("GET", "/api/groups/axiom", None).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let (_, ov) = admin.call("GET", "/api/access", None).await;
|
||||||
|
assert!(ov["identities"].as_array().expect("i").iter().any(|i| i["displayName"] == "Chiara"));
|
||||||
|
// « Publier maintenant » is for administrators, and needs a configured publication.
|
||||||
|
let (s, _) = user.call("POST", "/api/access/publication/retry", None).await;
|
||||||
|
assert_eq!(s, StatusCode::FORBIDDEN);
|
||||||
|
let (s, v) = admin.call("POST", "/api/access/publication/retry", None).await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("publication_off")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_session_lasts_until_it_is_closed() {
|
||||||
|
const FOUR_HUNDRED_DAYS: &str = "Max-Age=34560000";
|
||||||
|
let (mut chiara, state) = Client::new().await;
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
assert!(chiara.set_cookie.as_deref().is_some_and(|c| c.contains(FOUR_HUNDRED_DAYS)), "{:?}", chiara.set_cookie);
|
||||||
|
// Renewed once a day at most: a visit right after changes nothing.
|
||||||
|
chiara.call("GET", "/api/me", None).await;
|
||||||
|
assert_eq!(chiara.set_cookie, None);
|
||||||
|
// Ten days left: the next visit starts the 400 days again, here and in the browser.
|
||||||
|
sqlx::query("UPDATE sessions SET expires_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+10 days')")
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("age the session");
|
||||||
|
sqlx::query("UPDATE identities SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-10 days')")
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("and the last visit");
|
||||||
|
let (_, v) = chiara.call("GET", "/api/me", None).await;
|
||||||
|
assert_eq!(v["me"]["displayName"], "Chiara");
|
||||||
|
assert!(chiara.set_cookie.as_deref().is_some_and(|c| c.contains(FOUR_HUNDRED_DAYS)), "{:?}", chiara.set_cookie);
|
||||||
|
let far: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM sessions WHERE expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+399 days')",
|
||||||
|
)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("count");
|
||||||
|
assert_eq!(far, 1);
|
||||||
|
let seen: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM identities WHERE id = 'dev:chiara' AND last_seen_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 minute')",
|
||||||
|
)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("count");
|
||||||
|
assert_eq!(seen, 1, "the renewal says when the person was last seen");
|
||||||
|
// A session already over does not come back.
|
||||||
|
let mut old = chiara.with(None);
|
||||||
|
old.login("Chiara", false).await;
|
||||||
|
let old_cookie = old.cookie.clone();
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE sessions SET expires_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 day')
|
||||||
|
WHERE rowid = (SELECT MAX(rowid) FROM sessions)",
|
||||||
|
)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("end the session");
|
||||||
|
let (_, v) = old.call("GET", "/api/me", None).await;
|
||||||
|
assert_eq!((v["me"].clone(), old.set_cookie.clone()), (Value::Null, None));
|
||||||
|
assert_eq!(old.cookie, old_cookie);
|
||||||
|
|
||||||
|
// Until an administrator closes them: a lost device, a person who left.
|
||||||
|
let mut phone = chiara.with(None);
|
||||||
|
phone.login("Chiara", false).await;
|
||||||
|
let (s, _) = chiara.call("DELETE", "/api/access/identities/dev:chiara/sessions", None).await;
|
||||||
|
assert_eq!(s, StatusCode::FORBIDDEN);
|
||||||
|
let mut admin = chiara.with(None);
|
||||||
|
admin.login("Yvv", true).await;
|
||||||
|
let sessions_of = |ov: &Value, id: &str| {
|
||||||
|
ov["identities"].as_array().expect("i").iter().find(|i| i["id"] == id).expect("identity")["sessions"].clone()
|
||||||
|
};
|
||||||
|
// A sign-in link sent to the lost device goes with the sessions.
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO invitations (token_hash, label, admin, groups, created_by, created_at, expires_at, identity_id)
|
||||||
|
VALUES ('waiting', 'Chiara', 0, '[]', 'dev:yvv', strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+14 days'), 'dev:chiara')",
|
||||||
|
)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("a sign-in link waiting");
|
||||||
|
let (_, ov) = admin.call("GET", "/api/access", None).await;
|
||||||
|
assert_eq!(sessions_of(&ov, "dev:chiara"), 2, "the session already over is not counted");
|
||||||
|
assert_eq!(sessions_of(&ov, "dev:yvv"), 0, "the session this page is read with is not counted");
|
||||||
|
let (s, v) = admin.call("DELETE", "/api/access/identities/dev:chiara/sessions", None).await;
|
||||||
|
assert_eq!((s, v["closed"].as_u64(), v["links"].as_u64()), (StatusCode::OK, Some(2), Some(1)));
|
||||||
|
for c in [&mut chiara, &mut phone] {
|
||||||
|
let (_, v) = c.call("GET", "/api/me", None).await;
|
||||||
|
assert_eq!(v["me"], Value::Null);
|
||||||
|
}
|
||||||
|
let waiting: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM invitations WHERE token_hash = 'waiting' AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')",
|
||||||
|
)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("count");
|
||||||
|
assert_eq!(waiting, 0);
|
||||||
|
// An administrator's own: the others close, this one stays open.
|
||||||
|
let mut laptop = admin.with(None);
|
||||||
|
laptop.login("Yvv", true).await;
|
||||||
|
let (s, v) = admin.call("DELETE", "/api/access/identities/dev:yvv/sessions", None).await;
|
||||||
|
assert_eq!((s, v["closed"].as_u64()), (StatusCode::OK, Some(1)));
|
||||||
|
let (_, v) = admin.call("GET", "/api/me", None).await;
|
||||||
|
assert_eq!(v["me"]["displayName"], "Yvv");
|
||||||
|
let (_, v) = laptop.call("GET", "/api/me", None).await;
|
||||||
|
assert_eq!(v["me"], Value::Null);
|
||||||
|
let (s, _) = admin.call("DELETE", "/api/access/identities/nobody/sessions", None).await;
|
||||||
|
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_team_link_makes_one_identity_per_opening() {
|
||||||
|
let (mut admin, _) = Client::new().await;
|
||||||
|
admin.login("Yvv", true).await;
|
||||||
|
let mut user = admin.with(None);
|
||||||
|
user.login("Chiara", false).await;
|
||||||
|
let team = |groups: Value, days: i64, max: i64| json!({ "label": "Équipe dev", "groups": groups, "days": days, "maxUses": max });
|
||||||
|
let (s, _) = user.call("POST", "/api/access/team-links", Some(team(json!(["essai"]), 30, 2))).await;
|
||||||
|
assert_eq!(s, StatusCode::FORBIDDEN, "administrators only");
|
||||||
|
for (body, code) in [
|
||||||
|
(team(json!([]), 30, 2), "groups"),
|
||||||
|
(team(json!(["nope"]), 30, 2), "groups"),
|
||||||
|
(team(json!(["essai"]), 0, 2), "team_limits"),
|
||||||
|
(team(json!(["essai"]), 30, 501), "team_limits"),
|
||||||
|
] {
|
||||||
|
let (s, v) = admin.call("POST", "/api/access/team-links", Some(body)).await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)));
|
||||||
|
}
|
||||||
|
let (s, v) = admin.call("POST", "/api/access/team-links", Some(team(json!(["essai"]), 30, 2))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
let link = v["link"].as_str().expect("link").to_owned();
|
||||||
|
assert!(link.contains("/equipe#"), "{link}");
|
||||||
|
let token = link.split('#').nth(1).expect("token").to_owned();
|
||||||
|
|
||||||
|
let mut first = admin.with(None);
|
||||||
|
let (s, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
assert_eq!(v["label"], "Équipe dev");
|
||||||
|
assert_eq!(v["groups"].as_array().expect("g").len(), 1);
|
||||||
|
assert_eq!(v["signedInAs"], Value::Null);
|
||||||
|
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Lu\u{202e}na" }))).await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name")));
|
||||||
|
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": " Luna " }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
assert_eq!((v["me"]["kind"].as_str(), v["me"]["admin"].as_bool()), (Some("invite"), Some(false)));
|
||||||
|
assert_eq!(v["me"]["displayName"], "Luna");
|
||||||
|
let luna = v["me"]["id"].as_str().expect("id").to_owned();
|
||||||
|
let (_, g) = first.call("GET", "/api/groups", None).await;
|
||||||
|
assert!(g["groups"].as_array().expect("g").iter().any(|g| g["slug"] == "essai" && g["open"] == true));
|
||||||
|
// What an invited person writes says so next to their name.
|
||||||
|
let essai = doc_id(&mut first, "essai").await;
|
||||||
|
let (_, doc) = first.call("GET", &format!("/api/docs/{essai}"), None).await;
|
||||||
|
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
|
||||||
|
let (_, v) = first
|
||||||
|
.call("POST", &format!("/api/docs/{essai}/proposals"), Some(json!({
|
||||||
|
"unitId": uid, "title": "Réciprocité", "body": "Je m'engage à ne certifier que des personnes connues.", "submit": true,
|
||||||
|
})))
|
||||||
|
.await;
|
||||||
|
let (_, d) = admin.call("GET", &format!("/api/proposals/{}", v["id"].as_str().expect("id")), None).await;
|
||||||
|
assert_eq!(
|
||||||
|
(d["proposal"]["author"].as_str(), d["proposal"]["authorInvited"].as_bool()),
|
||||||
|
(Some("Luna"), Some(true))
|
||||||
|
);
|
||||||
|
assert_eq!(d["history"][0]["authorInvited"], true);
|
||||||
|
|
||||||
|
// Nobody joins under a name already there; the refusal does not count as an opening.
|
||||||
|
let mut second = admin.with(None);
|
||||||
|
for taken in ["luna", "LÚNA", "Lu-na", "Yvv"] {
|
||||||
|
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": taken }))).await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")), "{taken}");
|
||||||
|
}
|
||||||
|
// Nor under the name an invitation still waits for.
|
||||||
|
admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Chiara Rossi", "groups": ["essai"] }))).await;
|
||||||
|
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "chiara rossi" }))).await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")));
|
||||||
|
// Look-alike letters from other alphabets are not a name.
|
||||||
|
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Lunа" }))).await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name")));
|
||||||
|
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Paul" }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
assert_ne!(v["me"]["id"].as_str(), Some(luna.as_str()), "one identity per opening");
|
||||||
|
// Two openings allowed: the third is refused.
|
||||||
|
let mut third = admin.with(None);
|
||||||
|
let (s, v) = third.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Zoé" }))).await;
|
||||||
|
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("invitation_invalid")));
|
||||||
|
|
||||||
|
// Someone signed in keeps their identity and receives the groups.
|
||||||
|
let (_, v) = admin.call("POST", "/api/access/team-links", Some(team(json!(["monnaie-libre"]), 7, 5))).await;
|
||||||
|
let token2 = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
|
||||||
|
let (_, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token2 }))).await;
|
||||||
|
assert_eq!(v["signedInAs"], "Luna");
|
||||||
|
for _ in 0..3 {
|
||||||
|
// Back again with the session open: no place taken once the groups are there.
|
||||||
|
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token2 }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
assert_eq!(v["me"]["id"].as_str(), Some(luna.as_str()));
|
||||||
|
}
|
||||||
|
let (s, _) = first.call("GET", "/api/groups/monnaie-libre", None).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
|
||||||
|
// Administrators see the links still open and where each identity came from; a link revokes.
|
||||||
|
let (_, ov) = admin.call("GET", "/api/access", None).await;
|
||||||
|
let links = ov["teamLinks"].as_array().expect("links");
|
||||||
|
assert_eq!(links.len(), 2, "{ov}");
|
||||||
|
assert!(
|
||||||
|
ov["identities"]
|
||||||
|
.as_array()
|
||||||
|
.expect("i")
|
||||||
|
.iter()
|
||||||
|
.any(|i| i["displayName"] == "Paul" && i["teamLink"] == "Équipe dev")
|
||||||
|
);
|
||||||
|
let open = links.iter().find(|l| l["uses"] == 1).expect("second link");
|
||||||
|
let (s, _) =
|
||||||
|
admin.call("DELETE", &format!("/api/access/team-links/{}", open["id"].as_str().expect("id")), None).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let mut late = admin.with(None);
|
||||||
|
let (s, _) = late.call("POST", "/api/auth/team", Some(json!({ "token": token2, "name": "Tardif" }))).await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn in_monnaie_libre_only_web_of_trust_members_vote() {
|
||||||
|
let (mut admin, _) = Client::new().await;
|
||||||
|
admin.login("Yvv", true).await;
|
||||||
|
let (_, v) = admin
|
||||||
|
.call("POST", "/api/access/invitations", Some(json!({ "label": "Invitée", "groups": ["monnaie-libre"] })))
|
||||||
|
.await;
|
||||||
|
let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
|
||||||
|
let mut invited = admin.with(None);
|
||||||
|
let (s, _) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
// She reads the text and may propose, but her vote would not be a web-of-trust vote.
|
||||||
|
let id = doc_id(&mut invited, "monnaie-libre").await;
|
||||||
|
let (s, doc) = invited.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert_eq!(doc["canVote"], false);
|
||||||
|
let p = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("p").to_owned();
|
||||||
|
let (s, v) = invited.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||||
|
assert_eq!(s, StatusCode::CONFLICT, "{v}");
|
||||||
|
assert_eq!(v["error"], "wot_members_only");
|
||||||
|
// In the test group, every member votes.
|
||||||
|
let essai = doc_id(&mut admin, "essai").await;
|
||||||
|
let (_, d) = admin.call("GET", &format!("/api/docs/{essai}"), None).await;
|
||||||
|
assert_eq!(d["canVote"], true);
|
||||||
|
// A new link may only target an invitation identity, never another kind.
|
||||||
|
let (_, me) = admin.call("GET", "/api/me", None).await;
|
||||||
|
let dev_id = me["me"]["id"].as_str().expect("id").to_owned();
|
||||||
|
let (s, _) =
|
||||||
|
admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Yvv", "identityId": dev_id }))).await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn feedback_reaches_administrators() {
|
||||||
|
let (mut user, _) = Client::new().await;
|
||||||
|
user.login("Chiara", false).await;
|
||||||
|
let (s, _) = user
|
||||||
|
.call("POST", "/api/feedback", Some(json!({ "route": "/g/essai", "body": "Le bouton est trop discret." })))
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let mut admin = user.with(None);
|
||||||
|
admin.login("Yvv", true).await;
|
||||||
|
let (_, v) = admin.call("GET", "/api/access/feedback", None).await;
|
||||||
|
assert_eq!(v["feedback"][0]["body"], "Le bouton est trop discret.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn publication_writes_the_texts_in_force_to_the_repository() {
|
||||||
|
use std::process::Command;
|
||||||
|
let dir = std::env::temp_dir().join(format!("ld-pub-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).expect("dir");
|
||||||
|
let remote = dir.join("textes.git");
|
||||||
|
assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&remote).status().expect("git").success());
|
||||||
|
let mut cfg = Config::for_tests();
|
||||||
|
cfg.data_dir = dir.join("data");
|
||||||
|
cfg.publish = Some(ld_server::config::Publish {
|
||||||
|
remote: remote.display().to_string(),
|
||||||
|
branch: "main".into(),
|
||||||
|
key: dir.join("data/keys/publish_ed25519"),
|
||||||
|
author_name: "libreDecision".into(),
|
||||||
|
author_email: "decision@example.org".into(),
|
||||||
|
});
|
||||||
|
let db = open_db("sqlite::memory:").await.expect("db");
|
||||||
|
let state = AppState::new(cfg, db).await.expect("state");
|
||||||
|
seed::ensure(&state).await.expect("seed");
|
||||||
|
ld_server::publish::pass(&state).await.expect("pass");
|
||||||
|
let show = |path: &str| {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&remote)
|
||||||
|
.args(["show", &format!("main:{path}")])
|
||||||
|
.output()
|
||||||
|
.expect("git show");
|
||||||
|
String::from_utf8(out.stdout).expect("utf8")
|
||||||
|
};
|
||||||
|
let fr = show("monnaie-libre/acte-engagement-certification/fr.md");
|
||||||
|
assert!(fr.contains("# Acte d'engagement Certification"), "{fr}");
|
||||||
|
assert!(fr.contains("## Préambule") && fr.contains("## Unicité du compte") && fr.contains("## Réciprocité"));
|
||||||
|
assert!(!fr.contains("Connaissance suffisante"), "only the texts in force are published");
|
||||||
|
assert!(show("monnaie-libre/acte-engagement-certification/en.md").contains("traduction: editoriale"));
|
||||||
|
let json: Value =
|
||||||
|
serde_json::from_str(&show("monnaie-libre/acte-engagement-certification/document.json")).expect("json");
|
||||||
|
assert_eq!(json["units"].as_array().expect("units").len(), 3);
|
||||||
|
assert_eq!(json["units"][0]["status"], "seed-minimal");
|
||||||
|
assert!(show("README.md").contains("libreDecision"));
|
||||||
|
let done: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL")
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("count");
|
||||||
|
assert_eq!(done, 0);
|
||||||
|
// A restart queues the document again; nothing changed, so nothing is committed.
|
||||||
|
let head = || {
|
||||||
|
let out = Command::new("git").arg("-C").arg(&remote).args(["rev-parse", "main"]).output().expect("rev-parse");
|
||||||
|
String::from_utf8(out.stdout).expect("utf8")
|
||||||
|
};
|
||||||
|
let before = head();
|
||||||
|
ld_server::publish::resync(&state).await.expect("resync");
|
||||||
|
ld_server::publish::pass(&state).await.expect("pass");
|
||||||
|
assert_eq!(head(), before, "an unchanged document produces no commit");
|
||||||
|
let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL")
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.expect("count");
|
||||||
|
assert_eq!(pending, 0);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_refused_first_push_goes_out_again_and_a_new_empty_repository_receives_it() {
|
||||||
|
// Tenth independent review (850f9c4), 2026-10-21: a first push refused (a key
|
||||||
|
// without write access) left the commit in the server's clone, and the next pass,
|
||||||
|
// finding nothing to commit, marked the queue done with the repository still empty.
|
||||||
|
use std::process::Command;
|
||||||
|
let dir = std::env::temp_dir().join(format!("ld-pub-retry-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).expect("dir");
|
||||||
|
let bare = |name: &str| {
|
||||||
|
let path = dir.join(name);
|
||||||
|
assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&path).status().expect("git").success());
|
||||||
|
path
|
||||||
|
};
|
||||||
|
let config = |remote: &std::path::Path| {
|
||||||
|
let mut cfg = Config::for_tests();
|
||||||
|
cfg.data_dir = dir.join("data");
|
||||||
|
cfg.publish = Some(ld_server::config::Publish {
|
||||||
|
remote: remote.display().to_string(),
|
||||||
|
branch: "main".into(),
|
||||||
|
key: dir.join("data/keys/publish_ed25519"),
|
||||||
|
author_name: "libreDecision".into(),
|
||||||
|
author_email: "decision@example.org".into(),
|
||||||
|
});
|
||||||
|
cfg
|
||||||
|
};
|
||||||
|
let has_main = |remote: &std::path::Path| {
|
||||||
|
Command::new("git")
|
||||||
|
.arg("-C")
|
||||||
|
.arg(remote)
|
||||||
|
.args(["rev-parse", "--verify", "-q", "main"])
|
||||||
|
.output()
|
||||||
|
.expect("git")
|
||||||
|
.status
|
||||||
|
.success()
|
||||||
|
};
|
||||||
|
let pending = |db: &sqlx::SqlitePool| {
|
||||||
|
let db = db.clone();
|
||||||
|
async move {
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM outbox WHERE done_at IS NULL")
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await
|
||||||
|
.expect("count")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let first = bare("first.git");
|
||||||
|
let hook = first.join("hooks/pre-receive");
|
||||||
|
std::fs::write(&hook, "#!/bin/sh\nexit 1\n").expect("hook");
|
||||||
|
assert!(Command::new("chmod").arg("755").arg(&hook).status().expect("chmod").success());
|
||||||
|
let db = open_db("sqlite::memory:").await.expect("db");
|
||||||
|
let state = AppState::new(config(&first), db.clone()).await.expect("state");
|
||||||
|
seed::ensure(&state).await.expect("seed");
|
||||||
|
ld_server::publish::pass(&state).await.expect("pass");
|
||||||
|
assert!(!has_main(&first), "the push was refused");
|
||||||
|
assert_eq!(pending(&db).await, 1, "the document still waits");
|
||||||
|
|
||||||
|
// The key gets its write access: the next start sends what waited, at once.
|
||||||
|
std::fs::remove_file(&hook).expect("unhook");
|
||||||
|
ld_server::publish::resync(&state).await.expect("resync");
|
||||||
|
ld_server::publish::pass(&state).await.expect("pass");
|
||||||
|
assert!(has_main(&first), "the commit kept in the clone went out");
|
||||||
|
assert_eq!(pending(&db).await, 0);
|
||||||
|
|
||||||
|
// A change of remote to a new, empty repository: it receives the history.
|
||||||
|
let second = bare("second.git");
|
||||||
|
let moved = AppState::new(config(&second), db.clone()).await.expect("state");
|
||||||
|
ld_server::publish::resync(&moved).await.expect("resync");
|
||||||
|
ld_server::publish::pass(&moved).await.expect("pass");
|
||||||
|
assert!(has_main(&second), "the new repository received the texts");
|
||||||
|
assert_eq!(pending(&db).await, 0);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "trustin-access"
|
||||||
|
description = "Access core of trustinWallet as libreDecision uses it today: identities, groups, who sees and who opens what."
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
//! trustin-access — the access core of trustinWallet, as libreDecision uses it
|
||||||
|
//! today.
|
||||||
|
//!
|
||||||
|
//! trustinWallet (formerly trustWallet, a name already taken) is the identity
|
||||||
|
//! and rights layer of the ecosystem. Until it runs as its own service, this
|
||||||
|
//! crate holds its rules so that libreDecision never decides access by itself:
|
||||||
|
//! the server asks `sees` / `opens` / `manages_access`, and stores grants.
|
||||||
|
//!
|
||||||
|
//! Rules (Yvv, 2026-10-21):
|
||||||
|
//! - anyone signed in with a Ğ1 key sees the groups that use the same key —
|
||||||
|
//! Monnaie libre, Axiom, Librodrome, the local group Vallée Drôme Crestois;
|
||||||
|
//! - a group opens only to its members; Monnaie libre's members are the
|
||||||
|
//! members of the Ğ1 web of trust, the others are granted one by one;
|
||||||
|
//! - everything is open to a member except access management, which belongs
|
||||||
|
//! to trustinWallet's administrators;
|
||||||
|
//! - where the electorate is the web of trust (Monnaie libre), only its members
|
||||||
|
//! vote (2026-10-21, independent review: one person, one vote).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum IdentityKind {
|
||||||
|
/// Signed in through connect.monnaie-libre.fr: a Ğ1 identity (`idty:<index>`).
|
||||||
|
G1,
|
||||||
|
/// Signed in with an invitation link (tests, before Ğ1 sign-in is wired).
|
||||||
|
Invite,
|
||||||
|
/// Local development only.
|
||||||
|
Dev,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdentityKind {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::G1 => "g1",
|
||||||
|
Self::Invite => "invite",
|
||||||
|
Self::Dev => "dev",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(raw: &str) -> Option<Self> {
|
||||||
|
[Self::G1, Self::Invite, Self::Dev].into_iter().find(|k| k.as_str() == raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Identity {
|
||||||
|
pub id: String,
|
||||||
|
pub kind: IdentityKind,
|
||||||
|
pub display_name: String,
|
||||||
|
/// Member of the Ğ1 web of trust at the last sign-in.
|
||||||
|
pub g1_member: bool,
|
||||||
|
/// Administrator of access (trustinWallet).
|
||||||
|
pub admin: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum GroupPolicy {
|
||||||
|
/// Open to every member of the Ğ1 web of trust, plus explicit grants.
|
||||||
|
G1Members,
|
||||||
|
/// Open to explicit grants only.
|
||||||
|
Managed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GroupPolicy {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::G1Members => "g1-members",
|
||||||
|
Self::Managed => "managed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(raw: &str) -> Option<Self> {
|
||||||
|
[Self::G1Members, Self::Managed].into_iter().find(|p| p.as_str() == raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Group {
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
pub parent: Option<String>,
|
||||||
|
pub policy: GroupPolicy,
|
||||||
|
/// Hidden groups are seen by their members only (the test group).
|
||||||
|
pub hidden: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this identity belong to the group? `granted` = an explicit grant exists.
|
||||||
|
pub fn is_member(identity: &Identity, group: &Group, granted: bool) -> bool {
|
||||||
|
granted || (group.policy == GroupPolicy::G1Members && identity.kind == IdentityKind::G1 && identity.g1_member)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the group listed for this identity?
|
||||||
|
pub fn sees(identity: &Identity, group: &Group, granted: bool) -> bool {
|
||||||
|
!group.hidden || is_member(identity, group, granted) || identity.admin
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Can this identity open the group and use its tools (read, propose, vote)?
|
||||||
|
pub fn opens(identity: &Identity, group: &Group, granted: bool) -> bool {
|
||||||
|
is_member(identity, group, granted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Can this identity vote in the group? Where the electorate is the Ğ1 web of
|
||||||
|
/// trust (policy `G1Members`), only its members vote: the threshold is computed
|
||||||
|
/// on the size of the web, and an invitation identity next to a Ğ1 identity
|
||||||
|
/// would give one person two votes. Development identities stand in for members
|
||||||
|
/// (local only: the dev login is never enabled in production).
|
||||||
|
pub fn votes_in(identity: &Identity, group: &Group, granted: bool) -> bool {
|
||||||
|
opens(identity, group, granted)
|
||||||
|
&& (group.policy != GroupPolicy::G1Members
|
||||||
|
|| identity.kind == IdentityKind::Dev
|
||||||
|
|| (identity.kind == IdentityKind::G1 && identity.g1_member))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access management stays with trustinWallet's administrators.
|
||||||
|
pub fn manages_access(identity: &Identity) -> bool {
|
||||||
|
identity.admin
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The groups a fresh deployment starts with. Ordered as displayed.
|
||||||
|
pub fn default_groups() -> Vec<Group> {
|
||||||
|
let g = |slug: &str, name: &str, parent: Option<&str>, policy, hidden| Group {
|
||||||
|
slug: slug.into(),
|
||||||
|
name: name.into(),
|
||||||
|
parent: parent.map(Into::into),
|
||||||
|
policy,
|
||||||
|
hidden,
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
g("monnaie-libre", "Monnaie libre", None, GroupPolicy::G1Members, false),
|
||||||
|
g("axiom", "Axiom", Some("monnaie-libre"), GroupPolicy::Managed, false),
|
||||||
|
g("librodrome", "Librodrome", Some("monnaie-libre"), GroupPolicy::Managed, false),
|
||||||
|
g(
|
||||||
|
"vallee-drome-crestois",
|
||||||
|
"Groupe local Vallée Drôme Crestois",
|
||||||
|
Some("monnaie-libre"),
|
||||||
|
GroupPolicy::Managed,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
g("essai", "Groupe d'essai", None, GroupPolicy::Managed, true),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn who(kind: IdentityKind, g1_member: bool, admin: bool) -> Identity {
|
||||||
|
Identity { id: "x".into(), kind, display_name: "x".into(), g1_member, admin }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group(slug: &str) -> Group {
|
||||||
|
default_groups().into_iter().find(|g| g.slug == slug).expect("default group")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_wot_member_sees_all_four_and_opens_monnaie_libre_only() {
|
||||||
|
let member = who(IdentityKind::G1, true, false);
|
||||||
|
for slug in ["monnaie-libre", "axiom", "librodrome", "vallee-drome-crestois"] {
|
||||||
|
assert!(sees(&member, &group(slug), false), "{slug}");
|
||||||
|
}
|
||||||
|
assert!(opens(&member, &group("monnaie-libre"), false));
|
||||||
|
assert!(!opens(&member, &group("axiom"), false));
|
||||||
|
assert!(opens(&member, &group("axiom"), true));
|
||||||
|
assert!(!manages_access(&member));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_test_group_is_hidden_from_non_members() {
|
||||||
|
let member = who(IdentityKind::G1, true, false);
|
||||||
|
assert!(!sees(&member, &group("essai"), false));
|
||||||
|
assert!(sees(&member, &group("essai"), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_member_key_or_an_invitation_does_not_open_monnaie_libre_by_itself() {
|
||||||
|
let former = who(IdentityKind::G1, false, false);
|
||||||
|
assert!(!opens(&former, &group("monnaie-libre"), false));
|
||||||
|
let invited = who(IdentityKind::Invite, false, false);
|
||||||
|
assert!(!opens(&invited, &group("monnaie-libre"), false));
|
||||||
|
assert!(opens(&invited, &group("monnaie-libre"), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn administrators_manage_access_and_see_hidden_groups_but_open_nothing_by_right() {
|
||||||
|
let admin = who(IdentityKind::Invite, false, true);
|
||||||
|
assert!(manages_access(&admin));
|
||||||
|
assert!(sees(&admin, &group("essai"), false));
|
||||||
|
assert!(!opens(&admin, &group("essai"), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_wot_members_vote_where_the_electorate_is_the_web_of_trust() {
|
||||||
|
let ml = group("monnaie-libre");
|
||||||
|
assert!(votes_in(&who(IdentityKind::G1, true, false), &ml, false));
|
||||||
|
let invited = who(IdentityKind::Invite, false, true);
|
||||||
|
assert!(opens(&invited, &ml, true), "an invitation may open Monnaie libre to read and propose");
|
||||||
|
assert!(!votes_in(&invited, &ml, true), "but its votes would not be web-of-trust votes");
|
||||||
|
assert!(!votes_in(&who(IdentityKind::G1, false, false), &ml, true), "a former member reads, does not vote");
|
||||||
|
assert!(votes_in(&invited, &group("essai"), true), "managed groups: every member votes");
|
||||||
|
assert!(!votes_in(&invited, &group("essai"), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_atelier_du_canal() {
|
||||||
|
assert!(default_groups().iter().all(|g| !g.slug.contains("atelier")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trips() {
|
||||||
|
for k in [IdentityKind::G1, IdentityKind::Invite, IdentityKind::Dev] {
|
||||||
|
assert_eq!(IdentityKind::parse(k.as_str()), Some(k));
|
||||||
|
}
|
||||||
|
for p in [GroupPolicy::G1Members, GroupPolicy::Managed] {
|
||||||
|
assert_eq!(GroupPolicy::parse(p.as_str()), Some(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# libreDecision v3 on sonic — decision.librodrome.org.
|
||||||
|
# Runtime data lives in the named volume `data` (SQLite, the server's clone of
|
||||||
|
# the texts repository, its deploy key): never `down -v`, never recreated.
|
||||||
|
# Settings that are not secret are here; secrets, if any, in /opt/libredecision-app/secrets.env.
|
||||||
|
name: lpk-libredecision-app
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: libredecision-app:latest
|
||||||
|
container_name: lpk-libredecision-app
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- path: /opt/libredecision-app/secrets.env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
LD_PUBLIC_URL: https://${APP_DOMAIN:-decision.librodrome.org}
|
||||||
|
LD_ADMIN_G1_IDS: ${LD_ADMIN_G1_IDS:-idty:8352}
|
||||||
|
LD_BOOTSTRAP_ADMIN: "1"
|
||||||
|
LD_PUBLISH_REMOTE: ${LD_PUBLISH_REMOTE:-gitea@git.open.us.org:yvv/decision-depot-textes-reference.git}
|
||||||
|
LD_PUBLISH_BRANCH: main
|
||||||
|
RUST_LOG: info,sqlx=warn,tower_http=warn
|
||||||
|
volumes:
|
||||||
|
- data:/data
|
||||||
|
labels:
|
||||||
|
# Registrator → Consul → Fabio.
|
||||||
|
- SERVICE_8002_NAME=lpk-libredecision-app-8002
|
||||||
|
- SERVICE_8002_TAGS=urlprefix-${APP_DOMAIN:-decision.librodrome.org}/*
|
||||||
|
- SERVICE_8002_CHECK_TCP=true
|
||||||
|
networks:
|
||||||
|
- sonic
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
sonic:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# libreDecision v3 — one image: the Rust server serves the API and the compiled
|
||||||
|
# Svelte interface. Every gate of the loop runs inside the build: a red check
|
||||||
|
# means no image.
|
||||||
|
|
||||||
|
# --- Interface -----------------------------------------------------------------
|
||||||
|
FROM node:22-bookworm-slim AS web
|
||||||
|
WORKDIR /src/web
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
COPY web/ ./
|
||||||
|
ARG LD_BUILD=dev
|
||||||
|
ENV LD_BUILD=${LD_BUILD}
|
||||||
|
RUN npx svelte-kit sync \
|
||||||
|
&& npx svelte-check --tsconfig ./tsconfig.json --fail-on-warnings \
|
||||||
|
&& npx vitest run \
|
||||||
|
&& npx vite build
|
||||||
|
|
||||||
|
# --- Server --------------------------------------------------------------------
|
||||||
|
FROM rust:1.99-slim-bookworm AS server
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /src
|
||||||
|
COPY rust-toolchain.toml ./
|
||||||
|
RUN rustup show active-toolchain || rustup toolchain install
|
||||||
|
COPY Cargo.toml Cargo.lock rustfmt.toml ./
|
||||||
|
COPY crates crates
|
||||||
|
COPY seeds seeds
|
||||||
|
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
|
--mount=type=cache,target=/src/target \
|
||||||
|
cargo fmt --all --check \
|
||||||
|
&& cargo clippy --workspace --all-targets --locked -- -D warnings \
|
||||||
|
&& cargo test --workspace --locked \
|
||||||
|
&& cargo build --release --locked -p ld-server \
|
||||||
|
&& cp target/release/ld-server /usr/local/bin/ld-server
|
||||||
|
|
||||||
|
# --- Runtime -------------------------------------------------------------------
|
||||||
|
FROM debian:bookworm-slim AS production
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates git openssh-client tini \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& useradd --system --uid 10001 --create-home --home-dir /home/app app \
|
||||||
|
&& mkdir -p /data && chown app:app /data
|
||||||
|
COPY --from=server /usr/local/bin/ld-server /usr/local/bin/ld-server
|
||||||
|
COPY --from=web /src/web/build /app/web
|
||||||
|
COPY seeds /app/seeds
|
||||||
|
ARG LD_BUILD=dev
|
||||||
|
ENV LD_BUILD=${LD_BUILD} \
|
||||||
|
LD_BIND=0.0.0.0:8002 \
|
||||||
|
LD_DATA_DIR=/data \
|
||||||
|
LD_WEB_DIR=/app/web \
|
||||||
|
LD_SEED_DIR=/app/seeds
|
||||||
|
USER app
|
||||||
|
EXPOSE 8002
|
||||||
|
VOLUME /data
|
||||||
|
ENTRYPOINT ["tini", "--"]
|
||||||
|
CMD ["ld-server"]
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-glibredecision}
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-glibredecision}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-in-production}
|
|
||||||
volumes:
|
|
||||||
- postgres-data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-glibredecision} -d ${POSTGRES_DB:-glibredecision}"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 30s
|
|
||||||
networks:
|
|
||||||
- glibredecision
|
|
||||||
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
context: ../
|
|
||||||
dockerfile: docker/backend.Dockerfile
|
|
||||||
target: production
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-glibredecision}:${POSTGRES_PASSWORD:-change-me-in-production}@postgres:5432/${POSTGRES_DB:-glibredecision}
|
|
||||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production-with-a-real-secret-key}
|
|
||||||
DEBUG: "false"
|
|
||||||
CORS_ORIGINS: '["https://${DOMAIN:-glibredecision.org}"]'
|
|
||||||
DUNITER_RPC_URL: ${DUNITER_RPC_URL:-wss://gdev.p2p.legal/ws}
|
|
||||||
IPFS_API_URL: http://ipfs:5001
|
|
||||||
IPFS_GATEWAY_URL: http://ipfs:8080
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.glibredecision-api.rule=Host(`${DOMAIN:-glibredecision.org}`) && PathPrefix(`/api`)"
|
|
||||||
- "traefik.http.routers.glibredecision-api.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.glibredecision-api.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.services.glibredecision-api.loadbalancer.server.port=8002"
|
|
||||||
networks:
|
|
||||||
- glibredecision
|
|
||||||
- traefik
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
context: ../
|
|
||||||
dockerfile: docker/frontend.Dockerfile
|
|
||||||
target: production
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
environment:
|
|
||||||
NUXT_PUBLIC_API_BASE: https://${DOMAIN:-glibredecision.org}/api/v1
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.glibredecision-front.rule=Host(`${DOMAIN:-glibredecision.org}`)"
|
|
||||||
- "traefik.http.routers.glibredecision-front.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.glibredecision-front.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.services.glibredecision-front.loadbalancer.server.port=3000"
|
|
||||||
networks:
|
|
||||||
- glibredecision
|
|
||||||
- traefik
|
|
||||||
|
|
||||||
ipfs:
|
|
||||||
image: ipfs/kubo:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- ipfs-data:/data/ipfs
|
|
||||||
networks:
|
|
||||||
- glibredecision
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres-data:
|
|
||||||
ipfs-data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
glibredecision:
|
|
||||||
driver: bridge
|
|
||||||
traefik:
|
|
||||||
external: true
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 0001 — Rust + Svelte, une image, SQLite
|
||||||
|
|
||||||
|
**Contexte.** La v2 (Nuxt local-first + FastAPI) a servi de maquette. Yvv veut « un dev rust -
|
||||||
|
svelte pour la longue durée, la performance, la modularité et la compilation de haute qualité ».
|
||||||
|
Le premier chantier a besoin d'un serveur (votes partagés, publication), pas d'un stockage
|
||||||
|
navigateur.
|
||||||
|
|
||||||
|
**Décision.**
|
||||||
|
- Serveur en Rust : `ld-engine` (règles pures, sans I/O), `trustin-access` (accès),
|
||||||
|
`ld-server` (axum + sqlx). Toolchain épinglée, `clippy -D warnings`, `unsafe` interdit.
|
||||||
|
- Interface SvelteKit / Svelte 5 en SPA statique, compilée et servie par le même binaire.
|
||||||
|
- SQLite (WAL) dans un volume nommé : une instance, des milliers de votants, aucune base à
|
||||||
|
administrer. Migrations additives seulement.
|
||||||
|
- Une seule image ; sa construction rejoue toute la boucle.
|
||||||
|
- La maquette v2 reste dans `maquette/`, intacte, déployée à part.
|
||||||
|
|
||||||
|
**Coût.** Deux langages dans le dépôt ; une instance unique (pas de réplication). Si la
|
||||||
|
synchronisation entre instances devient nécessaire, elle passera par le dépôt des textes et une
|
||||||
|
API, pas par la base.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 0002 — Vote permanent : marge du texte en place et plancher C
|
||||||
|
|
||||||
|
**Contexte.** Chaque article est sous vote permanent ; « dès qu'une proposition atteint le score,
|
||||||
|
elle remonte en vigueur ». La formule inertielle Ğ1 donne un seuil faible quand peu de gens
|
||||||
|
votent : sans garde-fou, une seule voix ferait passer un texte. Sans règle de départage, deux
|
||||||
|
versions qui atteignent leur seuil s'échangeraient la place à chaque vote.
|
||||||
|
|
||||||
|
**Décision.**
|
||||||
|
- Plancher **C = nombre minimal de votants « pour »** (Monnaie libre : 50 ; essai : 1), dans
|
||||||
|
la formule (`R = C + …`) — choix d'Yvv.
|
||||||
|
- Une proposition entre en vigueur si elle atteint son seuil **et** tient une marge
|
||||||
|
(pour − requis) strictement plus large que la version en place. La version déplacée redevient
|
||||||
|
une proposition avec ses votes : rien n'est perdu, tout peut revenir.
|
||||||
|
- Les textes de départ sont des *seeds a minima* (0 vote, marge −C) : visibles, non adoptés.
|
||||||
|
- Changer l'inertie d'un article est une proposition votée à l'inertie du cran au-dessus.
|
||||||
|
- Le balayage tourne après chaque geste et toutes les 5 minutes, sous un verrou global.
|
||||||
|
|
||||||
|
**Coût.** Une version en place très soutenue est difficile à déloger — c'est voulu. La taille
|
||||||
|
de la toile (W) bouge : un texte peut passer ou repasser sans nouveau vote ; chaque entrée en
|
||||||
|
vigueur est datée et chiffrée dans `adoptions`.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# 0003 — Corriger n'est pas voter
|
||||||
|
|
||||||
|
**Contexte.** « Ça ne peut pas devenir un système qui nécessite un vote pour corriger une
|
||||||
|
coquille. » Mais une correction ne doit pas pouvoir changer le sens d'un texte voté — et tout
|
||||||
|
membre peut corriger un texte en vigueur, qui est republié aussitôt.
|
||||||
|
|
||||||
|
**Décision.** Une coquille touche **les lettres et les espaces, rien d'autre**. Le moteur
|
||||||
|
(`ld-engine/src/edit.rs`) la juge toujours **contre le texte voté** (dernière révision initiale
|
||||||
|
ou de fond), jamais contre la correction précédente :
|
||||||
|
- la **forme** du texte — tout ce qui n'est ni lettre latine ni espace, dans l'ordre, avec les
|
||||||
|
espaces qui touchent un chiffre — reste identique (apostrophes et guillemets de tous styles
|
||||||
|
comptent pour un même signe) ;
|
||||||
|
- les adresses, les identifiants qui contiennent un chiffre et le contenu entre accents graves
|
||||||
|
restent identiques à la lettre ;
|
||||||
|
- les espaces (espace, insécable, fine insécable) ne font que coller ou couper des mots, deux
|
||||||
|
au plus, jamais en touchant une négation ou une quantité (ses morceaux ou le mot entier), jamais en déplaçant
|
||||||
|
une frontière ; sauts de ligne et indentation font partie de la forme ;
|
||||||
|
- le titre et le corps sont jugés chacun pour soi, puis ensemble ;
|
||||||
|
- au plus deux mots changent, chacun d'un seul glissement de clavier au cœur du mot (une lettre
|
||||||
|
ajoutée, retirée ou deux voisines inversées ; jamais aux deux premières ni aux deux dernières
|
||||||
|
lettres ; jamais une lettre remplacée ; jamais sur un mot de négation, de quantité ou de
|
||||||
|
position) ;
|
||||||
|
- les caractères invisibles (ensemble Unicode des ignorables, contrôles autres que saut de
|
||||||
|
ligne et tabulation) sont refusés partout où l'on écrit ; un titre tient sur une ligne.
|
||||||
|
|
||||||
|
Sinon c'est **du fond** : nouvelle révision d'une proposition ouverte (par son auteur), votes
|
||||||
|
antérieurs « à reconfirmer », signalements levés ; refusé sur un texte en vigueur ou de départ.
|
||||||
|
L'écriture ne vaut que sur le texte jugé : un texte entré en vigueur ou changé entre-temps
|
||||||
|
donne un conflit. Les brouillons s'éditent librement.
|
||||||
|
|
||||||
|
**Historique.** La première version tolérait la ponctuation et deux mots « à deux lettres
|
||||||
|
près ». Six relectures indépendantes (2026-10-21) ont trouvé, l'une après l'autre, des
|
||||||
|
coquilles qui changeaient le sens : mots retournés (« valide » → « invalide »), caractères
|
||||||
|
invisibles, sosies de chiffres, corrections enchaînées, adresse typosquattée, signe moins
|
||||||
|
devant un nombre en gras. D'où le passage d'une liste noire à cette règle. La septième
|
||||||
|
(`fa8e8dc : ok`) a laissé trois points, corrigés aussitôt : mots collés ou coupés sans limite
|
||||||
|
(« Si non » → « Sinon »), titre jugé avec le corps (le titre en absorbait le début), caractères
|
||||||
|
invisibles non contrôlés au découpage d'un brouillon ; et deux remarques : tabulation et retour
|
||||||
|
chariot ne sont plus des espaces libres, les lettres latines rares font partie de la forme.
|
||||||
|
La huitième (`c3d6bf8 : ok`) a laissé deux points, corrigés ensuite : une négation coupée en
|
||||||
|
morceaux qui n'en sont pas (« ja mais »), des invisibles hors de la liste (trait d'union
|
||||||
|
conditionnel, étiquettes) ; et deux remarques : un saut de ligne dans un titre, une liste
|
||||||
|
Markdown fusionnée par les espaces. La neuvième (`fd19dbf : ok`) : un mot de quantité collé
|
||||||
|
(« sur tout » → « surtout », « par tout » → « partout ») — corrigé.
|
||||||
|
|
||||||
|
**Coût.** Une ponctuation oubliée, une emphase, un pluriel mal accordé demandent une
|
||||||
|
proposition (ou une révision par l'auteur). On préfère ce sens d'erreur : un texte de référence
|
||||||
|
publié vaut plus qu'un point final.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 0004 — Contradictions : signalées par les personnes, bloquantes
|
||||||
|
|
||||||
|
**Contexte.** « Il faut que 2 unités modulaires ne puissent pas passer en vigueur si elles se
|
||||||
|
contredisent. » La contradiction est une question de sens : aucune règle ne la détecte sans
|
||||||
|
se tromper, et l'IA est écartée pour l'instant.
|
||||||
|
|
||||||
|
**Décision.** Tout membre signale une contradiction entre deux propositions, avec un motif.
|
||||||
|
Tant que le signalement tient, celle qui n'est pas en vigueur ne peut pas y entrer. Il se lève
|
||||||
|
par son auteur ou un administrateur, et tombe si la proposition signalée change sur le fond.
|
||||||
|
L'assistant, lui, prévient (sans bloquer) quand un texte porte plusieurs décisions ou ressemble
|
||||||
|
à une proposition existante.
|
||||||
|
|
||||||
|
**Coût.** Un signalement abusif bloque une proposition jusqu'à sa levée. Les signalements sont
|
||||||
|
publics, motivés, et l'administration peut les lever.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 0005 — Publication par file d'envoi vers un dépôt git
|
||||||
|
|
||||||
|
**Contexte.** « Le tout est automatiquement poussé sur le dépôt git exploité par les applis
|
||||||
|
clientes. » Le réseau ou la forge peuvent être indisponibles ; un vote ne doit jamais échouer
|
||||||
|
pour autant.
|
||||||
|
|
||||||
|
**Décision.** Chaque entrée en vigueur (ou correction de coquille, ou traduction d'un texte en
|
||||||
|
vigueur) ajoute une ligne à une file d'envoi en base ; au démarrage, chaque document publié y
|
||||||
|
est remis une fois (rien ne se perd sur un redémarrage, un document inchangé ne fait pas de
|
||||||
|
commit : sa date est celle du dernier changement, jamais « maintenant »). Un travailleur rend `fr.md`, `en.md`,
|
||||||
|
`es.md` et `document.json` dans le clone du serveur, commit, pousse avec une clé de déploiement
|
||||||
|
générée par le serveur (sa partie publique s'affiche dans trustinWallet), et réessaie avec un
|
||||||
|
délai croissant. Dépôt : `git.open.us.org/yvv/decision-depot-textes-reference`, un dossier par groupe et
|
||||||
|
par document — provisoire (Yvv, 2026-10-21) : viendront un dépôt pour l'ouverture, par
|
||||||
|
transfert, puis le dépôt officiel pour les textes en vigueur ; changer de dépôt tient en une
|
||||||
|
ligne (`LD_PUBLISH_REMOTE`) : le serveur réaligne son clone, et un dépôt neuf et vide reçoit
|
||||||
|
l'historique. Un commit que le dépôt n'a jamais reçu (premier envoi refusé, clé sans droit
|
||||||
|
d'écriture) repart au passage suivant ; au démarrage, ce qui attendait repart aussitôt ;
|
||||||
|
trustinWallet montre l'état (en attente, dernier échec, dernier commit) et offre « Publier
|
||||||
|
maintenant ». Le français fait foi ; les traductions sont marquées éditoriales.
|
||||||
|
|
||||||
|
**Coût.** La publication est asynchrone (quelques secondes, plus en cas de panne) ; l'état
|
||||||
|
de la file se lit dans trustinWallet.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 0006 — Accès : connect.monnaie-libre.fr, invitations, trustinWallet
|
||||||
|
|
||||||
|
**Contexte.** « Le même accès que pour le forum Monnaie libre », « toute personne avec une clé
|
||||||
|
Duniter peut se loger et tout faire — sauf la gestion des accès », qui revient à trustinWallet
|
||||||
|
(nouveau nom de trustWallet, déjà pris).
|
||||||
|
|
||||||
|
**Décision.**
|
||||||
|
- Connexion Ğ1 : libreDecision est client OAuth2 de connect.monnaie-libre.fr (comme le forum).
|
||||||
|
Le compte est la clé d'identité (`idty:N`) ; on garde le nom affiché et le statut de membre.
|
||||||
|
- En attendant l'inscription du client : liens d'invitation à usage unique (14 jours), le
|
||||||
|
secret dans le fragment de l'URL (jamais dans les journaux du serveur). Un administrateur
|
||||||
|
peut redonner un lien à une personne invitée (autre appareil, session perdue).
|
||||||
|
- **Liens d'équipe** (Yvv, 2026-10-21 : « je vais l'utiliser plein de fois, pendant tout le
|
||||||
|
dev, et la partager avec une équipe ») : un lien réutilisable jusqu'à une date (90 jours au
|
||||||
|
plus) et pour un nombre de personnes (500 au plus), révocable. Chaque ouverture demande un
|
||||||
|
nom et crée **sa propre** identité d'invitation, jamais administratrice. Un nom déjà pris est
|
||||||
|
refusé, quelle que soit sa tenue (casse, accents, ponctuation : `ld_engine::names`, index
|
||||||
|
unique) ; un nom s'écrit en lettres latines, chiffres, espace, trait d'union, apostrophe ou
|
||||||
|
point (pas de sosie cyrillique, grec ou pleine chasse). Une personne déjà connectée garde son
|
||||||
|
identité et reçoit les groupes ; elle ne prend une place que si le lien lui en ajoute un ; sur
|
||||||
|
un navigateur partagé, « Ce n'est pas moi » la déconnecte. Un nom qu'une invitation attend
|
||||||
|
est protégé aussi.
|
||||||
|
- Tout auteur venu par invitation porte la mention **« sur invitation »** à côté de son nom
|
||||||
|
(propositions, révisions, signalements) : un nom pris avant l'arrivée de son titulaire Ğ1, ou
|
||||||
|
une lettre sosie, ne passe jamais pour un membre de la toile.
|
||||||
|
trustinWallet montre par quel lien chaque identité est venue. Limite assumée : une même
|
||||||
|
personne qui l'ouvre deux fois, sous deux noms, a deux identités — sans effet dans Monnaie
|
||||||
|
libre (seule la toile vote), à surveiller dans le groupe d'essai.
|
||||||
|
- Sessions : cookie HttpOnly, SameSite=Lax, Secure en https (`__Host-`), jeton haché en base ;
|
||||||
|
en-tête anti-CSRF sur toute écriture. Une session dure **jusqu'à ce qu'on la ferme** : 400 jours
|
||||||
|
(ce qu'un navigateur garde au plus), recomptés à chaque visite, une fois par jour au plus. On la
|
||||||
|
ferme en se déconnectant. Un administrateur peut aussi, dans trustinWallet, fermer les sessions
|
||||||
|
d'une identité (appareil perdu, départ), sauf celle d'où il agit ; il annule du même geste le
|
||||||
|
lien de connexion encore en attente pour cette identité. Une personne non administratrice qui
|
||||||
|
perd un appareil le demande à un administrateur. Jusqu'au 2026-10-23, les sessions duraient
|
||||||
|
30 jours fixes ; on a changé parce qu'une personne invitée n'a pas de clé Ğ1 pour revenir, et
|
||||||
|
qu'une session expirée lui coûtait un nouveau lien.
|
||||||
|
- Les règles (qui voit, qui ouvre, qui administre) vivent dans `trustin-access`, futur cœur du
|
||||||
|
service trustinWallet ; le serveur ne décide jamais seul.
|
||||||
|
- **Une personne, une voix** : là où l'électorat est la toile de confiance (Monnaie libre),
|
||||||
|
seuls ses membres votent ; une identité d'invitation y lit et propose. Un nouveau lien ne
|
||||||
|
vise jamais une identité Ğ1 (un administrateur ouvrirait une session à sa place).
|
||||||
|
- Premier administrateur : un lien d'invitation écrit au premier démarrage dans le volume de
|
||||||
|
données (0600), jamais dans les journaux.
|
||||||
|
|
||||||
|
**Coût.** Tant que connect n'est pas branché, chaque testeuse a besoin d'un lien. Quand
|
||||||
|
trustinWallet deviendra un service, `trustin-access` deviendra son client.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Décisions d'architecture
|
||||||
|
|
||||||
|
Une décision durable par fichier : le contexte, la décision, ce qu'elle coûte. On n'efface
|
||||||
|
pas une décision : on en écrit une nouvelle qui la remplace, et on le note dans l'ancienne.
|
||||||
|
|
||||||
|
| # | Décision | Date |
|
||||||
|
|---|---|---|
|
||||||
|
| [0001](0001-rust-svelte.md) | Rust + Svelte, une image, SQLite | 2026-10-21 |
|
||||||
|
| [0002](0002-vote-permanent.md) | Vote permanent : marge du texte en place et plancher C | 2026-10-21 |
|
||||||
|
| [0003](0003-corriger-n-est-pas-voter.md) | Corriger n'est pas voter | 2026-10-21 |
|
||||||
|
| [0004](0004-contradictions-signalees.md) | Contradictions : signalées par les personnes, bloquantes | 2026-10-21 |
|
||||||
|
| [0005](0005-publication-git.md) | Publication par file d'envoi vers un dépôt git | 2026-10-21 |
|
||||||
|
| [0006](0006-acces-connect-trustinwallet.md) | Accès : connect.monnaie-libre.fr, invitations, trustinWallet | 2026-10-21 |
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Les chantiers
|
||||||
|
|
||||||
|
Un chantier à la fois, **livré complet et en production** avant d'ouvrir le suivant. Chaque
|
||||||
|
chantier a sa fiche (`CHANTIER.md` : ce qu'on construit et pourquoi), ses contrôles d'usage
|
||||||
|
(`CONTROLES.md`) et sa branche commune `chantier/<nom>`.
|
||||||
|
|
||||||
|
| # | Chantier | Outil | Ce qu'il ajoute | État |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | [Acte d'engagement Certification](acte-certification/CHANTIER.md) | Docs de référence | le texte sous vote permanent, l'édition distinguée du vote, les contradictions, l'inertie votée, la publication git, l'accès Ğ1 + trustinWallet, le trilingue | **en cours** — binôme Yvv · Chiara |
|
||||||
|
| 2 | Acte d'engagement forgeron | Docs de référence | la **qualification d'une compétence** (au-delà de l'engagement personnel) | à ouvrir après la mise en production du 1 |
|
||||||
|
| 3 | Runtime Upgrade | Protocoles | un **protocole** : décision on-chain d'une mise à jour du runtime Duniter | après le 2 |
|
||||||
|
|
||||||
|
Les outils Décisions et Mandats restent grisés tant qu'aucun chantier ne les ouvre.
|
||||||
|
|
||||||
|
## Ce qu'un chantier doit laisser derrière lui
|
||||||
|
|
||||||
|
- la boucle verte (Rust, interface, e2e) et l'image déployée sur decision.librodrome.org ;
|
||||||
|
- les contrôles d'usage passés par Chiara (ses retours valent contrôles) ;
|
||||||
|
- la fiche à jour : décisions prises, questions ouvertes, ce qui reste ;
|
||||||
|
- les décisions d'architecture durables consignées dans `docs/adr/`.
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# Chantier 1 — Acte d'engagement Certification
|
||||||
|
|
||||||
|
> Binôme : **Yvv** (porteur) · **Chiara** (testeuse et codeuse — ses tests d'usage valent
|
||||||
|
> contrôles). Branche commune : `chantier/acte-certification`. Ouvert le 2026-10-21.
|
||||||
|
> Production : https://decision.librodrome.org
|
||||||
|
|
||||||
|
## Le besoin, en une phrase
|
||||||
|
|
||||||
|
La communauté Monnaie libre tient son Acte d'engagement Certification comme un **texte vivant** :
|
||||||
|
lisible d'abord, modifiable par article, chaque modification passant par un vote dont le seuil
|
||||||
|
suit la formule inertielle de la Ğ1 — sans qu'une coquille demande un vote, sans que deux
|
||||||
|
articles contradictoires soient en vigueur ensemble, et avec une publication automatique que
|
||||||
|
les applications clientes (Cesium, Ğecko…) peuvent suivre.
|
||||||
|
|
||||||
|
## Ce que voit la personne
|
||||||
|
|
||||||
|
1. **Accueil sobre** : « Rendre possible la décision collective » / « en fonction du contexte,
|
||||||
|
processus et modalités, boîte à outils », les quatre outils en une ligne chacun, puis la
|
||||||
|
connexion. Le menu complet à gauche : Décisions · Docs de référence · Mandats · Protocoles
|
||||||
|
(seul Docs de référence est actif).
|
||||||
|
2. **Connexion Ğ1** — la même que le forum Monnaie libre (connect.monnaie-libre.fr). En attendant
|
||||||
|
l'inscription du client OAuth2, des **liens d'invitation** à usage unique (une personne),
|
||||||
|
et des **liens d'équipe** réutilisables jusqu'à une date (chaque ouverture crée sa propre
|
||||||
|
identité, jamais administratrice).
|
||||||
|
3. **Groupes** : avec une clé Ğ1, on voit Monnaie libre, Axiom, Librodrome et le Groupe local
|
||||||
|
Vallée Drôme Crestois — pas l'Atelier du canal. Un groupe et son contenu ne s'ouvrent qu'à
|
||||||
|
ses membres ; Monnaie libre s'ouvre à tout membre de la toile de confiance, les autres
|
||||||
|
groupes sur accord dans trustinWallet.
|
||||||
|
4. **Le texte en vigueur**, seul, en grand, typographie de lecture (Literata) : au départ le
|
||||||
|
Préambule et les deux engagements fondamentaux (Unicité du compte, Réciprocité), marqués
|
||||||
|
*seed a minima · 0 vote*. Au survol (ou toujours visibles sur mobile), trois gestes discrets :
|
||||||
|
**Le vote** (en cours, inertie, formule, annexes) · **Les propositions** · **Proposer**. Ils
|
||||||
|
ouvrent un panneau latéral (une feuille sur mobile) dont l'état vit dans l'URL.
|
||||||
|
5. **En dessous**, les propositions en cours — les 30 autres articles du texte source, à zéro
|
||||||
|
vote, inertie standard — rangées par section (introduction, fondamental, technique,
|
||||||
|
conclusion, annexes, formule, inertie, ordonnancement), les variantes signalées.
|
||||||
|
6. **Deux aperçus** : « si les bien cotées passent » et « si tout passe ».
|
||||||
|
7. **Trois langues** : fr · en · es, pour l'interface comme pour le texte. Le français fait foi ;
|
||||||
|
une traduction devenue ancienne (le français a changé depuis) est signalée.
|
||||||
|
|
||||||
|
## Les règles
|
||||||
|
|
||||||
|
### Le seuil — formule inertielle Ğ1
|
||||||
|
|
||||||
|
```
|
||||||
|
R = C + B^W + (M + (1 − M)·(1 − (T/W)^G)) · max(0, T − C)
|
||||||
|
```
|
||||||
|
|
||||||
|
W = électorat (Monnaie libre : taille de la toile, lue sur la chaîne toutes les 6 h ; un groupe
|
||||||
|
géré : ses membres), T = votes exprimés, M = majorité, G = gradient, B = 0,1, **C = plancher de
|
||||||
|
votants « pour »** (Monnaie libre : **50** ; groupe d'essai : 1). Adoptée si « pour » ≥ ⌈R⌉.
|
||||||
|
|
||||||
|
| Inertie | M | G |
|
||||||
|
|---|---|---|
|
||||||
|
| basse | 50 % | 0,1 |
|
||||||
|
| standard (au départ, partout) | 50 % | 0,2 |
|
||||||
|
| haute | 60 % | 0,4 |
|
||||||
|
| maximale | 66 % | 0,6 |
|
||||||
|
|
||||||
|
Moteur : `crates/ld-engine/src/threshold.rs` (testé : 7224 / 120 votants → 94 voix requises).
|
||||||
|
|
||||||
|
### Le vote permanent
|
||||||
|
|
||||||
|
- Toute version d'un article (proposition ou texte en vigueur) se vote à tout moment.
|
||||||
|
- Une proposition **remonte en vigueur** dès qu'elle atteint son seuil **et** tient une marge
|
||||||
|
(pour − requis) plus large que la version en place. La version déplacée redevient une
|
||||||
|
proposition, avec ses votes. Un *seed a minima* a pour marge −C : la première proposition qui
|
||||||
|
atteint son seuil le remplace.
|
||||||
|
- Retirer un article est une proposition comme une autre (« retirer cet article »).
|
||||||
|
- Le balayage tourne à chaque vote et toutes les 5 minutes (la taille de la toile bouge).
|
||||||
|
|
||||||
|
### L'inertie se vote aussi
|
||||||
|
|
||||||
|
Chaque article porte son inertie. La changer est une proposition, **votée elle-même à
|
||||||
|
l'inertie du cran au-dessus** (« inertie + ») : alourdir ou alléger demande plus que modifier.
|
||||||
|
|
||||||
|
### Corriger n'est pas voter
|
||||||
|
|
||||||
|
**Une coquille touche les lettres et les espaces, rien d'autre.** Accents, majuscules, le style
|
||||||
|
des apostrophes et des guillemets, un mot collé ou coupé (« latransmission » → « la
|
||||||
|
transmission »), ou au plus deux mots mal tapés — chacun un seul glissement de clavier au cœur du
|
||||||
|
mot (« certifcation » → « certification »). Tout le reste passe par une proposition : la
|
||||||
|
ponctuation, les chiffres, les signes, l'emphase, les formules, les adresses, et tout changement
|
||||||
|
de sens.
|
||||||
|
|
||||||
|
| Geste | Qui | Effet sur les votes |
|
||||||
|
|---|---|---|
|
||||||
|
| Corriger une **coquille** | tout membre sur un texte de départ ou en vigueur ; l'auteur sur sa proposition | aucun — les votes restent, les traductions restent à jour |
|
||||||
|
| Modifier **le fond** d'une proposition | son auteur | nouvelle révision : les votes antérieurs sont « à reconfirmer », les signalements tombent |
|
||||||
|
| Modifier le fond d'un texte **en vigueur** ou d'un texte de départ | personne | refusé : on propose une nouvelle version |
|
||||||
|
|
||||||
|
Pourquoi si strict : six relectures indépendantes ont montré qu'une ponctuation laissée libre
|
||||||
|
finit toujours par porter un sens — un signe moins devant un nombre en gras, deux conditions
|
||||||
|
d'une liste fusionnées en une, une virgule glissée dans « 50 », une parenthèse déplacée qui
|
||||||
|
change la portée d'une exception. La règle est donc construite à l'envers d'une liste noire :
|
||||||
|
|
||||||
|
- la **forme** du texte (tout ce qui n'est ni lettre latine ni espace, dans l'ordre : ponctuation,
|
||||||
|
chiffres de toute écriture, signes, emphase, parenthèses, sosies, marques superposées, autres
|
||||||
|
alphabets) ne bouge pas ; une espace à côté d'un chiffre en fait partie (« 1 5 » n'est pas
|
||||||
|
« 15 ») ; les apostrophes et guillemets de tous styles comptent pour un même signe ; « lettre
|
||||||
|
latine » s'entend de celles du français, de l'anglais et de l'espagnol (les lettres latines
|
||||||
|
rares qui imitent un signe, « ǃ » ou « Ʒ », font partie de la forme) ; « espace » s'entend de
|
||||||
|
l'espace, de l'insécable et de la fine insécable entre deux mots ; le **saut de ligne** et
|
||||||
|
l'**indentation** qui ouvre une ligne font partie de la forme (en Markdown, ils font les
|
||||||
|
listes et les paragraphes) ; un titre tient sur une ligne ;
|
||||||
|
- les **adresses** (liens, domaines, courriels) et les **identifiants qui contiennent un
|
||||||
|
chiffre** (clés publiques, références) sont comparés à l'identique, casse comprise ; le contenu
|
||||||
|
entre accents graves aussi ;
|
||||||
|
- les espaces ne font que **coller ou couper** des mots, deux au plus, jamais en touchant une
|
||||||
|
négation ou une quantité — ni ses morceaux ni le mot entier (« Si non » → « Sinon », « ne
|
||||||
|
certifier » → « necertifier », « jamais » → « ja mais », « sur tout » → « surtout » sont du
|
||||||
|
fond) —, et jamais déplacer une frontière (« les avoir » → « le savoir » est du fond) ;
|
||||||
|
- le **titre** et le **corps** sont jugés chacun pour soi (un titre n'absorbe pas le début du
|
||||||
|
corps), puis ensemble (deux mots au plus en tout) ;
|
||||||
|
- un **glissement** = une lettre ajoutée, retirée, ou deux voisines inversées, jamais aux deux
|
||||||
|
premières ni aux deux dernières lettres (préfixes « valide » → « invalide », terminaisons
|
||||||
|
« certifierai » → « certifierais »), jamais une lettre remplacée (« deux » → « dix »), jamais sur
|
||||||
|
un mot de négation, de quantité ou de position (ne, jamais, deux, majorité, tous, dessus…) ;
|
||||||
|
- chaque correction est mesurée contre le **texte voté**, jamais contre la correction
|
||||||
|
précédente : les glissements ne s'additionnent pas ;
|
||||||
|
- les caractères invisibles (tout l'ensemble Unicode des caractères ignorables : inversion du
|
||||||
|
sens d'écriture, largeur nulle, trait d'union conditionnel, sélecteurs de variante,
|
||||||
|
étiquettes… ; et les caractères de contrôle autres que saut de ligne et tabulation) sont
|
||||||
|
refusés partout où l'on écrit ;
|
||||||
|
- une correction ne s'écrit que sur le texte qui a été jugé : si le texte est entré en vigueur
|
||||||
|
ou a changé entre-temps, c'est un conflit (« recharge, puis recommence »).
|
||||||
|
|
||||||
|
Moteur : `crates/ld-engine/src/edit.rs` ; règle d'écriture : `crates/ld-server/src/proposals.rs`.
|
||||||
|
|
||||||
|
### Deux articles qui se contredisent
|
||||||
|
|
||||||
|
Tout membre peut **signaler une contradiction** entre deux propositions, motif à l'appui. Tant
|
||||||
|
que le signalement tient, celle des deux qui n'est pas en vigueur **ne peut pas entrer en
|
||||||
|
vigueur**. Il se lève par son auteur (ou un administrateur), ou tombe quand la proposition
|
||||||
|
signalée est révisée sur le fond.
|
||||||
|
|
||||||
|
### Une décision par proposition
|
||||||
|
|
||||||
|
L'éditeur a un **assistant à règles** (pas d'IA pour l'instant ; plus tard, une IA locale, sans
|
||||||
|
abonnement) : il repère un texte qui porte plusieurs décisions et propose de le **découper en
|
||||||
|
brouillons**, signale une proposition très proche d'une existante, et un texte long. Moteur :
|
||||||
|
`crates/ld-engine/src/assist.rs`.
|
||||||
|
|
||||||
|
### Brouillon → proposition
|
||||||
|
|
||||||
|
On écrit en brouillon (libre, invisible des autres), on soumet ; la proposition est alors
|
||||||
|
ouverte au vote. Son auteur peut la retirer.
|
||||||
|
|
||||||
|
### Publication
|
||||||
|
|
||||||
|
Chaque entrée en vigueur (et la publication initiale) passe par une file d'envoi : le serveur
|
||||||
|
écrit `fr.md`, `en.md`, `es.md` (front matter : document, langue, source, mise à jour ; les
|
||||||
|
traductions marquées éditoriales) et `document.json`, commit et pousse sur
|
||||||
|
`git.open.us.org/yvv/decision-depot-textes-reference`, chemin `monnaie-libre/acte-engagement-certification/`.
|
||||||
|
Échec → nouvel essai avec délai croissant. La clé de publication est générée par le serveur ;
|
||||||
|
sa partie publique s'affiche dans trustinWallet, à ajouter comme clé de déploiement (écriture)
|
||||||
|
du dépôt. Le groupe d'essai ne publie jamais.
|
||||||
|
|
||||||
|
### Accès — trustinWallet
|
||||||
|
|
||||||
|
Tout le monde connecté peut tout faire dans les groupes qu'il ouvre (lire, proposer, voter,
|
||||||
|
corriger, signaler) **sauf la gestion des accès**, réservée aux administrateurs de trustinWallet
|
||||||
|
(`/acces`) : membres des groupes, invitations, administrateurs, retours, clé de publication.
|
||||||
|
Le nom trustinWallet remplace trustWallet (déjà pris). Règles : `crates/trustin-access`.
|
||||||
|
|
||||||
|
**Une personne, une voix.** Dans Monnaie libre, dont l'électorat W est la toile de confiance,
|
||||||
|
**seuls les membres de la toile votent** (connexion Ğ1). Une identité d'invitation y lit,
|
||||||
|
propose, corrige et signale, mais ne vote pas : sinon la même personne, invitée puis connectée
|
||||||
|
avec sa clé, voterait deux fois. Dans les groupes gérés (essai, Axiom…), tout membre vote.
|
||||||
|
|
||||||
|
## Le groupe d'essai
|
||||||
|
|
||||||
|
Caché, plancher C = 1, sans publication : on y éprouve tout (adoption, coquilles, découpage)
|
||||||
|
sans toucher au texte de la communauté. Chiara et Yvv en sont membres.
|
||||||
|
|
||||||
|
## Décisions prises (2026-10-21, avec Yvv)
|
||||||
|
|
||||||
|
| Question | Décision |
|
||||||
|
|---|---|
|
||||||
|
| Stack | Rust (serveur, moteur) + Svelte (interface), pour la durée, la performance, la modularité |
|
||||||
|
| Domaine | le produit à la racine `decision.librodrome.org` ; la maquette sur `maquette.decision.librodrome.org` |
|
||||||
|
| Connexion | client OAuth2 de connect.monnaie-libre.fr (demande à poka) ; liens d'invitation en attendant |
|
||||||
|
| Dépôt des textes | `git.open.us.org/yvv/decision-depot-textes-reference` pour l'instant (2026-10-21) ; puis un dépôt pour l'ouverture, par transfert, et le dépôt officiel pour les textes en vigueur |
|
||||||
|
| Garde-fou du vote permanent | nombre minimal de votants « pour » (C) |
|
||||||
|
| Langues | fr, en, es ; le français est voté, les traductions sont éditoriales |
|
||||||
|
| Chiara | testeuse et codeuse |
|
||||||
|
| Assistant | à règles d'abord ; IA locale plus tard, sans dépendance d'abonnement |
|
||||||
|
|
||||||
|
Les raisons longues sont dans `docs/adr/`.
|
||||||
|
|
||||||
|
## Limites connues (assumées pour ce premier chantier)
|
||||||
|
|
||||||
|
- Le droit de vote se vérifie **au moment du vote** : un membre sorti de la toile, ou dont
|
||||||
|
l'accès est révoqué, garde les voix déjà exprimées.
|
||||||
|
- Dans un groupe géré (essai, Axiom…), une même personne admise sous une identité d'invitation
|
||||||
|
**et** sous sa clé Ğ1 aurait deux voix : c'est à l'administration de n'en admettre qu'une.
|
||||||
|
- Un administrateur peut créer un nouveau lien pour une identité d'invitation existante
|
||||||
|
(pour la dépanner) — jamais pour une identité Ğ1.
|
||||||
|
- Une identité d'invitation peut porter le nom d'un membre Ğ1 qui ne s'est pas encore
|
||||||
|
connecté (la connexion Ğ1 ne vérifie pas les noms) : la mention « sur invitation » à côté de
|
||||||
|
chaque auteur qui n'est pas venu avec sa clé Ğ1 les distingue.
|
||||||
|
- Des accents ou une ponctuation qui portent du sens restent des coquilles (« ou » / « où »,
|
||||||
|
« a » / « à », une virgule autour d'une relative) : le diff de chaque correction est visible
|
||||||
|
dans l'historique de l'article.
|
||||||
|
- Les traductions (en, es) sont éditoriales : tout membre les corrige sans vote, et elles sont
|
||||||
|
republiées — le français fait foi.
|
||||||
|
|
||||||
|
## Questions ouvertes (à Yvv)
|
||||||
|
|
||||||
|
- **Licence** du code — non choisie.
|
||||||
|
- **Texte « Paramètres par défaut » du seed** (F1.2) : il annonce C = 0 et une durée de 30 jours,
|
||||||
|
alors que le vote est permanent avec C = 50. À reformuler par proposition (c'est leur rôle),
|
||||||
|
ou à corriger dans le seed avant la première publication si c'est une erreur de transcription.
|
||||||
|
- Anglais : l'interface est en anglais américain, le seed en anglais britannique.
|
||||||
|
|
||||||
|
## Ce qui reste après la livraison
|
||||||
|
|
||||||
|
- L'inscription du client OAuth2 (poka) : `DEMANDE-CONNECT.md`, puis les deux secrets dans la CI.
|
||||||
|
- Les retours de Chiara → contrôles → corrections, sur la branche du chantier.
|
||||||
|
- Ensuite : chantier 2, l'Acte d'engagement forgeron (qualification d'une compétence).
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Contrôles d'usage — Acte d'engagement Certification
|
||||||
|
|
||||||
|
Chaque contrôle existe en deux versions : **automatique** (Playwright, `web/e2e/`, rejoué par
|
||||||
|
`npm run e2e`) et **humaine** (Chiara, sur la production ou en local). Une remarque de Chiara
|
||||||
|
sur un contrôle vaut échec du contrôle tant qu'elle n'est pas traitée.
|
||||||
|
|
||||||
|
Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe d'essai**
|
||||||
|
(caché, plancher C = 1, jamais publié) — jamais sur le texte de la communauté.
|
||||||
|
|
||||||
|
| # | Contrôle | Geste humain | Attendu |
|
||||||
|
|---|---|---|---|
|
||||||
|
| C1 | L'accueil | ouvrir `/` sans être connectée | « Rendre possible la décision collective », la phrase d'outils, les quatre sections ; le menu à gauche avec les quatre outils, trois grisés ; « Se connecter avec sa clé Ğ1 » |
|
||||||
|
| C2 | Les groupes | se connecter, ouvrir « Groupes » | Monnaie libre, Axiom, Librodrome, Groupe local Vallée Drôme Crestois ; **pas** l'Atelier du canal |
|
||||||
|
| C3 | Le texte en vigueur | Monnaie libre → Docs de référence → Acte d'engagement Certification | trois articles seulement (Préambule, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » ; en dessous, « Propositions en cours » (32 lignes) |
|
||||||
|
| C4 | Gestes contextuels | survoler le Préambule → « Le vote » | un panneau : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie ; l'URL garde le panneau ; Échap le ferme |
|
||||||
|
| C5 | Entrée en vigueur | groupe d'essai : à deux, voter « Pour » sur « Les deux garanties réciproques » | au second vote, « … est entré en vigueur » ; le texte en vigueur compte quatre articles |
|
||||||
|
| C6 | Coquille sans vote | groupe d'essai : Préambule → « Le vote » → « Corriger une coquille », remplacer « latransmission » par « la transmission » | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » |
|
||||||
|
| C7 | Une décision par proposition | groupe d'essai : « Proposer un nouvel article », deux paragraphes « Je m'engage à… » | l'assistant : « Ce texte semble porter 2 décisions » ; « Découper en 2 brouillons » ; « Tes brouillons » apparaît |
|
||||||
|
| C8 | Trois langues | choisir EN puis ES pour le texte | « Certification Commitment Act », « Acta de compromiso de Certificación » ; l'interface suit |
|
||||||
|
| C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis 3 |
|
||||||
|
| C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) |
|
||||||
|
| C11 | Une personne, une voix | avec un lien d'invitation, ouvrir le vote d'un article de Monnaie libre | « Ici, seuls les membres de la toile de confiance Ğ1 votent… » à la place des boutons ; dans le groupe d'essai, les boutons sont là |
|
||||||
|
| C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien |
|
||||||
|
| M1 | Mobile | téléphone (ou fenêtre étroite) | menu en tiroir, les trois gestes visibles sans survol, le panneau s'ouvre en feuille |
|
||||||
|
|
||||||
|
## Au-delà de la liste
|
||||||
|
|
||||||
|
Ce que l'automate ne voit pas et que Chiara regarde :
|
||||||
|
|
||||||
|
- **la lecture** : le texte est-il beau, aéré, sans bruit ? lit-on un article sans effort ?
|
||||||
|
- **la fatigue** : un geste de trop, une étape inutile, un mot qui arrête ;
|
||||||
|
- **le vocabulaire** : un libellé qui parle du système au lieu de parler de toi ou du groupe ;
|
||||||
|
- **les deux ambiances** (pierre / nuit) et le clavier seul (Tab, Entrée, Échap) ;
|
||||||
|
- **les langues** : une traduction maladroite, une chaîne restée en français.
|
||||||
|
|
||||||
|
Chaque remarque passe par « Faire un retour » (menu de gauche) : elle arrive datée, avec la
|
||||||
|
page, la langue et la taille de l'écran. Les retours se traitent dans trustinWallet
|
||||||
|
(Vu → Traité) et, s'ils changent une règle, dans `CHANTIER.md`.
|
||||||
|
|
||||||
|
## Journal des passages
|
||||||
|
|
||||||
|
| Date | Qui | Où | Contrôles | Résultat |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 2026-10-21 | automate | local, serveur jetable :8092 | C1–C10, M1 | 11 / 11 verts |
|
||||||
|
| 2026-10-21 | automate | local, après relecture indépendante | C1–C11, M1 | 12 / 12 verts |
|
||||||
|
| 2026-10-21 | automate | local, après la septième relecture (corrections) | C1–C11, M1 | 12 / 12 verts |
|
||||||
|
| 2026-10-21 | automate | local, liens d'équipe et huitième relecture | C1–C12, M1 | 13 / 13 verts |
|
||||||
|
| 2026-10-21 | automate | local, neuvième relecture (noms, places, quantités) | C1–C12, M1 | 13 / 13 verts |
|
||||||
|
| 2026-10-21 | automate | local, mention « sur invitation » (dixième relecture) | C1–C12, M1 | 13 / 13 verts |
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Demande à poka — client OAuth2 sur connect.monnaie-libre.fr
|
||||||
|
|
||||||
|
Brouillon prêt à envoyer (par Yvv, en message privé à poka sur forum.monnaie-libre.fr ou par
|
||||||
|
le canal habituel). Rien de secret dedans ; la réponse, elle, contiendra un secret.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Bonjour poka,
|
||||||
|
|
||||||
|
On ouvre libreDecision en production sur **https://decision.librodrome.org** : la communauté
|
||||||
|
y tient l'**Acte d'engagement Certification** comme un texte vivant, voté article par article
|
||||||
|
(formule inertielle Ğ1). On voudrait la même connexion que le forum : connect.monnaie-libre.fr.
|
||||||
|
|
||||||
|
Peux-tu inscrire un client OAuth2 ?
|
||||||
|
|
||||||
|
- **Nom affiché** : libreDecision
|
||||||
|
- **Site** : https://decision.librodrome.org
|
||||||
|
- **URI de redirection** : `https://decision.librodrome.org/api/auth/g1/callback`
|
||||||
|
- **Flux** : authorization code (`/oauth/authorize` → `/oauth/token` en basic auth →
|
||||||
|
`/oauth/userinfo`), sans scope particulier
|
||||||
|
- **Ce qu'on lit de `/oauth/userinfo`** : `id` (idty:N), `username`, `address`, `member` —
|
||||||
|
rien d'autre n'est stocké ; seuls les membres de la toile ouvrent le groupe Monnaie libre.
|
||||||
|
|
||||||
|
Il nous faudrait en retour le `client_id` et le `client_secret` (le secret par un canal privé,
|
||||||
|
pas sur le forum).
|
||||||
|
|
||||||
|
Merci !
|
||||||
|
Yvv
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## À la réponse
|
||||||
|
|
||||||
|
1. Ne pas coller le secret dans une conversation : protocole `/tmp/pwd.txt` (l'assistant crée
|
||||||
|
et ouvre le fichier, Yvv colle).
|
||||||
|
2. Ajouter dans Woodpecker (dépôt LPK/decision) deux secrets : `connect_client_id`,
|
||||||
|
`connect_client_secret`.
|
||||||
|
3. **Ensuite seulement** (un `from_secret` absent fait refuser tout le pipeline) : dans
|
||||||
|
`.woodpecker/app.yml`, étape `deploy`, écrire `/opt/libredecision-app/secrets.env` avec
|
||||||
|
`LD_CONNECT_CLIENT_ID` et `LD_CONNECT_CLIENT_SECRET` (chmod 600) — le compose le lit déjà.
|
||||||
|
4. Contrôle : « Se connecter avec sa clé Ğ1 » mène à connect.monnaie-libre.fr et revient
|
||||||
|
connecté ; un membre de la toile ouvre Monnaie libre.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Bienvenue sur le chantier — Chiara
|
||||||
|
|
||||||
|
Tu es **testeuse et codeuse** sur ce chantier : tes tests d'usage sont nos contrôles (une
|
||||||
|
remarque de toi = un contrôle à repasser), et tu peux corriger ou construire toi-même.
|
||||||
|
|
||||||
|
## 1. Ce qu'on construit
|
||||||
|
|
||||||
|
Lis d'abord `CHANTIER.md` (10 minutes) : le texte vivant, le vote permanent, coquille ≠ vote,
|
||||||
|
les contradictions, l'inertie votée, la publication. Puis `CONTROLES.md` : ce que tu vérifies.
|
||||||
|
|
||||||
|
## 2. Tes accès (préparés par Yvv)
|
||||||
|
|
||||||
|
| Quoi | Pour quoi | Qui le fait |
|
||||||
|
|---|---|---|
|
||||||
|
| Un **lien d'invitation** à decision.librodrome.org (groupes Monnaie libre + groupe d'essai) | tester en production avant la connexion Ğ1 | Yvv, dans trustinWallet (`/acces`) |
|
||||||
|
| Ta **clé Ğ1** | une fois connect.monnaie-libre.fr branché, tu te connectes comme sur le forum | — |
|
||||||
|
| Un compte sur **git.open.us.org**, collaboratrice de `yvv/decision` | pousser tes branches, ouvrir des demandes de fusion | Yvv |
|
||||||
|
|
||||||
|
Le lien d'invitation s'ouvre **une seule fois** et expire en 14 jours ; il ouvre ta session sur
|
||||||
|
ce navigateur. Elle reste ouverte tant que tu reviens au moins une fois par an, jusqu'à ce que
|
||||||
|
tu te déconnectes ou qu'un administrateur la ferme (appareil perdu, par exemple). Sur un autre
|
||||||
|
appareil, demande à Yvv un nouveau lien de connexion. Si Yvv t'envoie plutôt un **lien d'équipe** (`/equipe#…`), il te demande ton nom et
|
||||||
|
te crée ta propre identité ; ne l'ouvre qu'une fois, puis reviens par ta session.
|
||||||
|
|
||||||
|
## 3. Tester sans rien installer
|
||||||
|
|
||||||
|
1. Ouvre ton lien d'invitation → tu arrives connectée.
|
||||||
|
2. Les contrôles qui écrivent (voter, corriger, découper) se font dans le **groupe d'essai** :
|
||||||
|
il est caché, son plancher est d'une voix, et rien n'en sort. À deux membres (Yvv et toi),
|
||||||
|
une proposition passe avec vos deux voix « pour » — la formule demande la majorité de
|
||||||
|
l'électorat quand il est si petit. Le texte de Monnaie libre, lui, est le vrai : avec ton lien d'invitation tu le lis,
|
||||||
|
tu proposes, tu signales ; le vote y est réservé aux membres de la toile (avec ta clé Ğ1).
|
||||||
|
3. Passe les contrôles C1–C12 et M1 (sur ton téléphone aussi), puis tout ce qui te gêne.
|
||||||
|
4. Chaque remarque : **« Faire un retour »** dans le menu de gauche. Écris ce que tu as fait, ce
|
||||||
|
que tu attendais, ce qui s'est passé. La page, la langue et la taille de l'écran sont jointes.
|
||||||
|
|
||||||
|
## 4. Coder en local
|
||||||
|
|
||||||
|
Prérequis : git, Node 22, Rust (rustup — la version 1.99 s'installe toute seule grâce à
|
||||||
|
`rust-toolchain.toml`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone gitea@git.open.us.org:yvv/decision.git libreDecision && cd libreDecision
|
||||||
|
git switch chantier/acte-certification
|
||||||
|
curl --proto '=https' -sSf https://sh.rustup.rs | sh -s -- -y # si Rust n'est pas installé
|
||||||
|
cd web && npm ci && npm run build && cd ..
|
||||||
|
LD_DEV_LOGIN=1 cargo run -p ld-server # http://127.0.0.1:8002 — API + interface
|
||||||
|
# dans un second terminal, pour l'interface à chaud :
|
||||||
|
cd web && npm run dev # http://127.0.0.1:3002
|
||||||
|
```
|
||||||
|
|
||||||
|
`LD_DEV_LOGIN=1` affiche une connexion « développement » : tu tapes un nom, tu es membre de
|
||||||
|
tous les groupes (y compris l'essai). La base locale vit dans `data/` — supprime ce dossier pour
|
||||||
|
repartir du texte de départ.
|
||||||
|
|
||||||
|
Où sont les choses :
|
||||||
|
|
||||||
|
- les **règles** (seuil, coquille, découpage, aperçus) : `crates/ld-engine/` — pur, testé ;
|
||||||
|
- les **accès** : `crates/trustin-access/` ;
|
||||||
|
- l'**API** : `crates/ld-server/src/` (une route = une fonction ; scénarios dans `tests/api.rs`) ;
|
||||||
|
- l'**interface** : `web/src/routes/` (pages) et `web/src/lib/components/` ;
|
||||||
|
- les **textes affichés** : `web/src/lib/i18n/fr.ts` (puis `en.ts`, `es.ts`) — jamais en dur ;
|
||||||
|
- le **texte de départ** : `seeds/acte-engagement-certification.json`.
|
||||||
|
|
||||||
|
## 5. Ta boucle avant de pousser
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
||||||
|
cd web && npm run check && npm test && npm run build && npm run e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Tout vert, puis : `git switch -c chiara/<sujet>`, commits, `git push origin chiara/<sujet>`, et une
|
||||||
|
demande de fusion vers `chantier/acte-certification`. Le détail : `CONTRIBUTING.md`.
|
||||||
|
|
||||||
|
## 6. Si tu bloques
|
||||||
|
|
||||||
|
Écris à Yvv, ou laisse un retour dans l'outil en commençant par « BLOQUÉE : ».
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Recherche Forum Duniter -- Donnees de reference pour Glibredecision
|
# Recherche Forum Duniter -- Donnees de reference pour libreDecision
|
||||||
|
|
||||||
Date de recherche : 2026-02-28
|
Date de recherche : 2026-02-28
|
||||||
|
|
||||||
@@ -1,620 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const auth = useAuthStore()
|
|
||||||
const route = useRoute()
|
|
||||||
const { initMood } = useMood()
|
|
||||||
|
|
||||||
const navigationItems = [
|
|
||||||
{
|
|
||||||
label: 'Documents',
|
|
||||||
icon: 'i-lucide-book-open',
|
|
||||||
to: '/documents',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Decisions',
|
|
||||||
icon: 'i-lucide-scale',
|
|
||||||
to: '/decisions',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Mandats',
|
|
||||||
icon: 'i-lucide-user-check',
|
|
||||||
to: '/mandates',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Protocoles',
|
|
||||||
icon: 'i-lucide-settings',
|
|
||||||
to: '/protocols',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Sanctuaire',
|
|
||||||
icon: 'i-lucide-archive',
|
|
||||||
to: '/sanctuary',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Mobile drawer state. */
|
|
||||||
const mobileMenuOpen = ref(false)
|
|
||||||
|
|
||||||
/** Close mobile menu on route change. */
|
|
||||||
watch(() => route.path, () => {
|
|
||||||
mobileMenuOpen.value = false
|
|
||||||
})
|
|
||||||
|
|
||||||
/** WebSocket connection and notifications. */
|
|
||||||
const ws = useWebSocket()
|
|
||||||
const { setupWsNotifications } = useNotifications()
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
initMood()
|
|
||||||
auth.hydrateFromStorage()
|
|
||||||
if (auth.token) {
|
|
||||||
try {
|
|
||||||
await auth.fetchMe()
|
|
||||||
} catch {
|
|
||||||
auth.logout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ws.connect()
|
|
||||||
setupWsNotifications(ws)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
ws.disconnect()
|
|
||||||
})
|
|
||||||
|
|
||||||
function isActive(to: string) {
|
|
||||||
return route.path === to || route.path.startsWith(to + '/')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UApp>
|
|
||||||
<OfflineBanner />
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="app-shell"
|
|
||||||
:style="{
|
|
||||||
backgroundColor: 'var(--mood-bg)',
|
|
||||||
color: 'var(--mood-text)',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<!-- Header -->
|
|
||||||
<header class="app-header">
|
|
||||||
<div class="app-header__inner">
|
|
||||||
<!-- Left: Hamburger (mobile) + Logo -->
|
|
||||||
<div class="app-header__left">
|
|
||||||
<button
|
|
||||||
class="app-header__menu-btn md:hidden"
|
|
||||||
aria-label="Ouvrir le menu"
|
|
||||||
@click="mobileMenuOpen = true"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-menu" class="text-xl" />
|
|
||||||
</button>
|
|
||||||
<NuxtLink to="/" class="app-header__logo">
|
|
||||||
<span class="app-header__logo-stamp">
|
|
||||||
<UIcon name="i-lucide-gavel" class="app-header__logo-icon" />
|
|
||||||
</span>
|
|
||||||
<span class="app-header__logo-text">
|
|
||||||
<span class="app-header__logo-g">ğ</span><span class="app-header__logo-paren">(</span><span class="app-header__logo-word">Decision</span><span class="app-header__logo-paren">)</span>
|
|
||||||
</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Center: Mood switcher (desktop) -->
|
|
||||||
<MoodSwitcher class="hidden sm:flex" />
|
|
||||||
|
|
||||||
<!-- Right: Auth -->
|
|
||||||
<div class="app-header__right">
|
|
||||||
<template v-if="auth.isAuthenticated">
|
|
||||||
<span class="app-header__identity">
|
|
||||||
{{ auth.identity?.display_name || auth.identity?.address?.slice(0, 10) + '...' }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="auth.identity?.is_smith"
|
|
||||||
class="app-header__role app-header__role--smith"
|
|
||||||
>
|
|
||||||
Forgeron
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="auth.identity?.is_techcomm"
|
|
||||||
class="app-header__role app-header__role--tech"
|
|
||||||
>
|
|
||||||
CoTec
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
class="app-header__icon-btn"
|
|
||||||
aria-label="Se deconnecter"
|
|
||||||
@click="auth.logout()"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-log-out" />
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<NuxtLink to="/login" class="app-header__connect-btn">
|
|
||||||
<UIcon name="i-lucide-log-in" />
|
|
||||||
<span>Connexion</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Mobile navigation drawer -->
|
|
||||||
<USlideover
|
|
||||||
v-model:open="mobileMenuOpen"
|
|
||||||
side="left"
|
|
||||||
title="Navigation"
|
|
||||||
:ui="{ width: 'max-w-xs' }"
|
|
||||||
>
|
|
||||||
<template #body>
|
|
||||||
<nav class="app-mobile-nav">
|
|
||||||
<NuxtLink
|
|
||||||
v-for="item in navigationItems"
|
|
||||||
:key="item.to"
|
|
||||||
:to="item.to"
|
|
||||||
class="app-mobile-nav__link"
|
|
||||||
:class="{ 'app-mobile-nav__link--active': isActive(item.to) }"
|
|
||||||
@click="mobileMenuOpen = false"
|
|
||||||
>
|
|
||||||
<UIcon :name="item.icon" class="text-lg" />
|
|
||||||
<span>{{ item.label }}</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</nav>
|
|
||||||
<!-- Mood switcher in mobile drawer -->
|
|
||||||
<div class="app-mobile-mood">
|
|
||||||
<span class="app-mobile-mood__label">Ambiance</span>
|
|
||||||
<MoodSwitcher />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</USlideover>
|
|
||||||
|
|
||||||
<!-- Main content with sidebar -->
|
|
||||||
<div class="app-body">
|
|
||||||
<!-- Desktop sidebar -->
|
|
||||||
<aside class="app-sidebar">
|
|
||||||
<nav class="app-sidebar__nav">
|
|
||||||
<NuxtLink
|
|
||||||
v-for="item in navigationItems"
|
|
||||||
:key="item.to"
|
|
||||||
:to="item.to"
|
|
||||||
class="app-sidebar__link"
|
|
||||||
:class="{ 'app-sidebar__link--active': isActive(item.to) }"
|
|
||||||
>
|
|
||||||
<UIcon :name="item.icon" class="text-lg" />
|
|
||||||
<span>{{ item.label }}</span>
|
|
||||||
</NuxtLink>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Page content -->
|
|
||||||
<main class="app-main">
|
|
||||||
<ErrorBoundary>
|
|
||||||
<NuxtPage />
|
|
||||||
</ErrorBoundary>
|
|
||||||
|
|
||||||
<!-- 井 Tsing — sceau (proportions calées sur avatars Yvv) -->
|
|
||||||
<svg class="app-seal" viewBox="0 0 130 100" fill="currentColor" aria-hidden="true">
|
|
||||||
<!-- Line 6 (top) — yin -->
|
|
||||||
<rect x="5" y="5" width="49" height="5" rx="1"/>
|
|
||||||
<rect x="76" y="5" width="49" height="5" rx="1"/>
|
|
||||||
<!-- Line 5 — yang -->
|
|
||||||
<rect x="5" y="22" width="120" height="5" rx="1"/>
|
|
||||||
<!-- Line 4 — yin -->
|
|
||||||
<rect x="5" y="39" width="49" height="5" rx="1"/>
|
|
||||||
<rect x="76" y="39" width="49" height="5" rx="1"/>
|
|
||||||
<!-- Line 3 — yang -->
|
|
||||||
<rect x="5" y="56" width="120" height="5" rx="1"/>
|
|
||||||
<!-- Line 2 — yang -->
|
|
||||||
<rect x="5" y="73" width="120" height="5" rx="1"/>
|
|
||||||
<!-- Line 1 (bottom) — yin -->
|
|
||||||
<rect x="5" y="90" width="49" height="5" rx="1"/>
|
|
||||||
<rect x="76" y="90" width="49" height="5" rx="1"/>
|
|
||||||
</svg>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- WebSocket error banner -->
|
|
||||||
<Transition name="slide-up">
|
|
||||||
<div
|
|
||||||
v-if="ws.error.value"
|
|
||||||
class="app-ws-banner"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-plug-zap" class="text-lg" />
|
|
||||||
<span>{{ ws.error.value }}</span>
|
|
||||||
<button class="app-ws-banner__btn" @click="ws.disconnect(); ws.connect()">
|
|
||||||
Reconnecter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer class="app-footer">
|
|
||||||
<span>ğ(Decision) v0.1.0</span>
|
|
||||||
<span class="app-footer__sep">·</span>
|
|
||||||
<span>Licence libre</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</UApp>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* === Shell === */
|
|
||||||
.app-shell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Header === */
|
|
||||||
.app-header {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 30;
|
|
||||||
background: var(--mood-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__inner {
|
|
||||||
max-width: 80rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 3.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__menu-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 2.25rem;
|
|
||||||
background: none;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
.app-header__menu-btn:hover {
|
|
||||||
color: var(--mood-text);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo {
|
|
||||||
text-decoration: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-stamp {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
background: var(--mood-accent);
|
|
||||||
color: var(--mood-accent-text);
|
|
||||||
border-radius: 8px;
|
|
||||||
transform: rotate(-10deg);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo:hover .app-header__logo-stamp {
|
|
||||||
transform: rotate(-16deg) scale(1.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-icon {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
transform: scaleX(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-text {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-g {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
line-height: 1;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-paren {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__logo-word {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__identity {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text);
|
|
||||||
max-width: 6rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.app-header__identity {
|
|
||||||
max-width: 12rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__role {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.app-header__role {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__role--smith {
|
|
||||||
background: rgba(24, 132, 59, 0.15);
|
|
||||||
color: var(--mood-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__role--tech {
|
|
||||||
background: rgba(24, 86, 168, 0.15);
|
|
||||||
color: var(--mood-status-vote, #1856a8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__icon-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 2.25rem;
|
|
||||||
background: none;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
.app-header__icon-btn:hover {
|
|
||||||
color: var(--mood-text);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header__connect-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.5rem 0.875rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-accent-text);
|
|
||||||
background: var(--mood-accent);
|
|
||||||
border-radius: 24px;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.app-header__connect-btn {
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1.25rem;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.app-header__connect-btn:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px var(--mood-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Sidebar === */
|
|
||||||
.app-body {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar {
|
|
||||||
width: 14rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--mood-surface);
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.app-sidebar {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__nav {
|
|
||||||
position: sticky;
|
|
||||||
top: 3.5rem;
|
|
||||||
padding: 1rem 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
padding: 0.625rem 0.875rem;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
transition: all 0.12s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__link:hover {
|
|
||||||
color: var(--mood-text);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-sidebar__link--active {
|
|
||||||
color: var(--mood-accent);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Mobile nav === */
|
|
||||||
.app-mobile-nav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-mobile-nav__link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.875rem;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
font-size: 1.0625rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 14px;
|
|
||||||
min-height: 3rem;
|
|
||||||
}
|
|
||||||
.app-mobile-nav__link:hover,
|
|
||||||
.app-mobile-nav__link:active {
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
color: var(--mood-text);
|
|
||||||
}
|
|
||||||
.app-mobile-nav__link--active {
|
|
||||||
color: var(--mood-accent);
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-mobile-mood {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
border-top: 1px solid var(--mood-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-mobile-mood__label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Main === */
|
|
||||||
.app-main {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 1.5rem 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.app-main {
|
|
||||||
padding: 2rem 1.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.app-main {
|
|
||||||
padding: 2rem 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === WS Banner === */
|
|
||||||
.app-ws-banner {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 40;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.625rem;
|
|
||||||
padding: 0.625rem 1rem;
|
|
||||||
background: var(--mood-error);
|
|
||||||
color: white;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-ws-banner__btn {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
background: rgba(255,255,255,0.2);
|
|
||||||
border-radius: 20px;
|
|
||||||
color: white;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.app-ws-banner__btn:hover {
|
|
||||||
background: rgba(255,255,255,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Footer === */
|
|
||||||
.app-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-footer__sep {
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Seal — 井 Tsing === */
|
|
||||||
.app-seal {
|
|
||||||
display: block;
|
|
||||||
width: 44px;
|
|
||||||
margin: 1.5rem 0 0.5rem auto;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
opacity: 0.28;
|
|
||||||
filter: drop-shadow(1px 1px 0.5px rgba(0,0,0,0.25))
|
|
||||||
drop-shadow(-0.5px -0.5px 0.5px rgba(255,255,255,0.15));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Transitions === */
|
|
||||||
.slide-up-enter-active,
|
|
||||||
.slide-up-leave-active {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
.slide-up-enter-from,
|
|
||||||
.slide-up-leave-to {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
/* ==========================================================================
|
|
||||||
ğ(Decision) — Mood / Ambiance System
|
|
||||||
Palettes harmoniques variees, colores en lite, lumineux en dark.
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
Peps — Chaud, colore, tonique (Light)
|
|
||||||
Palette: corail vif / ocre / indigo / vert franc
|
|
||||||
-------------------------------------------------------------------------- */
|
|
||||||
.mood-peps {
|
|
||||||
--mood-bg: #faf8f5;
|
|
||||||
--mood-surface: #ffffff;
|
|
||||||
--mood-surface-hover: #fdf2ec;
|
|
||||||
--mood-text: #1e1410;
|
|
||||||
--mood-text-muted: #7a5e48;
|
|
||||||
--mood-accent: #d44a10;
|
|
||||||
--mood-accent-soft: rgba(212, 74, 16, 0.08);
|
|
||||||
--mood-accent-text: #ffffff;
|
|
||||||
--mood-border: #e8d8c8;
|
|
||||||
--mood-secondary: #4a5ec0;
|
|
||||||
--mood-tertiary: #c07820;
|
|
||||||
--mood-success: #1a8c3e;
|
|
||||||
--mood-warning: #c47d0a;
|
|
||||||
--mood-error: #c23028;
|
|
||||||
--mood-gradient: linear-gradient(145deg, #fdf8f0 0%, #ffffff 40%, #f0f4fb 100%);
|
|
||||||
--mood-shadow: rgba(180, 80, 20, 0.07);
|
|
||||||
--mood-input-bg: #ffffff;
|
|
||||||
--mood-input-border: #d4c0aa;
|
|
||||||
--mood-input-focus: #d44a10;
|
|
||||||
|
|
||||||
--mood-status-prepa: #b8600e;
|
|
||||||
--mood-status-prepa-bg: rgba(184, 96, 14, 0.12);
|
|
||||||
--mood-status-vote: #4a5ec0;
|
|
||||||
--mood-status-vote-bg: rgba(74, 94, 192, 0.10);
|
|
||||||
--mood-status-vigueur: #1a8c3e;
|
|
||||||
--mood-status-vigueur-bg: rgba(26, 140, 62, 0.10);
|
|
||||||
--mood-status-clos: #6e5844;
|
|
||||||
--mood-status-clos-bg: rgba(110, 88, 68, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
Zen — Vegetal lumineux, nature vivante (Light)
|
|
||||||
Palette: vert franc / terre sienne / bleu canard / bois chaud
|
|
||||||
-------------------------------------------------------------------------- */
|
|
||||||
.mood-zen {
|
|
||||||
--mood-bg: #f4f6f0;
|
|
||||||
--mood-surface: #fbfcf8;
|
|
||||||
--mood-surface-hover: #eaf0e4;
|
|
||||||
--mood-text: #1a2418;
|
|
||||||
--mood-text-muted: #5a7050;
|
|
||||||
--mood-accent: #2e8b48;
|
|
||||||
--mood-accent-soft: rgba(46, 139, 72, 0.09);
|
|
||||||
--mood-accent-text: #ffffff;
|
|
||||||
--mood-border: #b8d0a8;
|
|
||||||
--mood-secondary: #8a6030;
|
|
||||||
--mood-tertiary: #2a7888;
|
|
||||||
--mood-success: #268040;
|
|
||||||
--mood-warning: #a87818;
|
|
||||||
--mood-error: #b83838;
|
|
||||||
--mood-gradient: linear-gradient(145deg, #f0f5ea 0%, #fbfcf8 40%, #f4efe6 100%);
|
|
||||||
--mood-shadow: rgba(40, 100, 50, 0.07);
|
|
||||||
--mood-input-bg: #fbfcf8;
|
|
||||||
--mood-input-border: #a8c898;
|
|
||||||
--mood-input-focus: #2e8b48;
|
|
||||||
|
|
||||||
--mood-status-prepa: #a87818;
|
|
||||||
--mood-status-prepa-bg: rgba(168, 120, 24, 0.11);
|
|
||||||
--mood-status-vote: #2a7888;
|
|
||||||
--mood-status-vote-bg: rgba(42, 120, 136, 0.10);
|
|
||||||
--mood-status-vigueur: #268040;
|
|
||||||
--mood-status-vigueur-bg: rgba(38, 128, 64, 0.10);
|
|
||||||
--mood-status-clos: #607858;
|
|
||||||
--mood-status-clos-bg: rgba(96, 120, 88, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
Chagrine — Nuit profonde, reflets varies (Dark)
|
|
||||||
Palette: bleu lavande / ambre chaud / rose ancien / vert d'eau
|
|
||||||
-------------------------------------------------------------------------- */
|
|
||||||
.mood-chagrine {
|
|
||||||
--mood-bg: #0e1018;
|
|
||||||
--mood-surface: #151a28;
|
|
||||||
--mood-surface-hover: #1c2640;
|
|
||||||
--mood-text: #dce0ec;
|
|
||||||
--mood-text-muted: #8898b8;
|
|
||||||
--mood-accent: #6488d8;
|
|
||||||
--mood-accent-soft: rgba(100, 136, 216, 0.12);
|
|
||||||
--mood-accent-text: #ffffff;
|
|
||||||
--mood-border: #222e48;
|
|
||||||
--mood-secondary: #d0a040;
|
|
||||||
--mood-tertiary: #c87090;
|
|
||||||
--mood-success: #48c87a;
|
|
||||||
--mood-warning: #d8a838;
|
|
||||||
--mood-error: #d86060;
|
|
||||||
--mood-gradient: linear-gradient(145deg, #0e1018 0%, #151a28 40%, #101420 100%);
|
|
||||||
--mood-shadow: rgba(60, 100, 180, 0.12);
|
|
||||||
--mood-input-bg: #151a28;
|
|
||||||
--mood-input-border: #2a3858;
|
|
||||||
--mood-input-focus: #6488d8;
|
|
||||||
|
|
||||||
--mood-status-prepa: #d8a838;
|
|
||||||
--mood-status-prepa-bg: rgba(216, 168, 56, 0.14);
|
|
||||||
--mood-status-vote: #6488d8;
|
|
||||||
--mood-status-vote-bg: rgba(100, 136, 216, 0.14);
|
|
||||||
--mood-status-vigueur: #48c87a;
|
|
||||||
--mood-status-vigueur-bg: rgba(72, 200, 122, 0.14);
|
|
||||||
--mood-status-clos: #7888a8;
|
|
||||||
--mood-status-clos-bg: rgba(120, 136, 168, 0.10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
Grave — Ambre mineral, ocre lumineux (Dark)
|
|
||||||
Palette: ambre dore / ocre rouge / bleu ardoise / vert mousse
|
|
||||||
-------------------------------------------------------------------------- */
|
|
||||||
.mood-grave {
|
|
||||||
--mood-bg: #131210;
|
|
||||||
--mood-surface: #1c1a16;
|
|
||||||
--mood-surface-hover: #28241c;
|
|
||||||
--mood-text: #ece4d4;
|
|
||||||
--mood-text-muted: #a89870;
|
|
||||||
--mood-accent: #d8a030;
|
|
||||||
--mood-accent-soft: rgba(216, 160, 48, 0.11);
|
|
||||||
--mood-accent-text: #131210;
|
|
||||||
--mood-border: #342e24;
|
|
||||||
--mood-secondary: #6898b8;
|
|
||||||
--mood-tertiary: #c07040;
|
|
||||||
--mood-success: #58c870;
|
|
||||||
--mood-warning: #d0a030;
|
|
||||||
--mood-error: #d05050;
|
|
||||||
--mood-gradient: linear-gradient(145deg, #131210 0%, #1c1a16 40%, #16181c 100%);
|
|
||||||
--mood-shadow: rgba(180, 130, 40, 0.10);
|
|
||||||
--mood-input-bg: #1c1a16;
|
|
||||||
--mood-input-border: #3c3428;
|
|
||||||
--mood-input-focus: #d8a030;
|
|
||||||
|
|
||||||
--mood-status-prepa: #c07040;
|
|
||||||
--mood-status-prepa-bg: rgba(192, 112, 64, 0.15);
|
|
||||||
--mood-status-vote: #6898b8;
|
|
||||||
--mood-status-vote-bg: rgba(104, 152, 184, 0.14);
|
|
||||||
--mood-status-vigueur: #58c870;
|
|
||||||
--mood-status-vigueur-bg: rgba(88, 200, 112, 0.14);
|
|
||||||
--mood-status-clos: #807860;
|
|
||||||
--mood-status-clos-bg: rgba(128, 120, 96, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==========================================================================
|
|
||||||
Global design tokens — Nunito, rounded, borderless
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
font-family: 'Nunito', system-ui, -apple-system, sans-serif;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Nunito', system-ui, -apple-system, sans-serif;
|
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- No borders anywhere --- */
|
|
||||||
button, input, select, textarea {
|
|
||||||
border: none !important;
|
|
||||||
outline: none;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus, select:focus, textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 0 2.5px var(--mood-accent-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Status pills --- */
|
|
||||||
.status-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 5px 14px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
user-select: none;
|
|
||||||
border: none !important;
|
|
||||||
}
|
|
||||||
.status-pill:hover {
|
|
||||||
filter: brightness(1.08);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
.status-pill.active {
|
|
||||||
box-shadow: 0 0 0 2.5px var(--mood-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-prepa {
|
|
||||||
background: var(--mood-status-prepa-bg);
|
|
||||||
color: var(--mood-status-prepa);
|
|
||||||
}
|
|
||||||
.status-vote {
|
|
||||||
background: var(--mood-status-vote-bg);
|
|
||||||
color: var(--mood-status-vote);
|
|
||||||
}
|
|
||||||
.status-vigueur {
|
|
||||||
background: var(--mood-status-vigueur-bg);
|
|
||||||
color: var(--mood-status-vigueur);
|
|
||||||
}
|
|
||||||
.status-clos {
|
|
||||||
background: var(--mood-status-clos-bg);
|
|
||||||
color: var(--mood-status-clos);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==========================================================================
|
|
||||||
Nuxt UI overrides — borderless everything
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
:root .mood-peps,
|
|
||||||
:root .mood-zen,
|
|
||||||
:root .mood-chagrine,
|
|
||||||
:root .mood-grave {
|
|
||||||
--ui-border: transparent;
|
|
||||||
--ui-bg: var(--mood-input-bg);
|
|
||||||
--ui-text-highlighted: var(--mood-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root button,
|
|
||||||
:root [class*="UButton"],
|
|
||||||
:root [class*="u-button"],
|
|
||||||
:root [data-variant] {
|
|
||||||
border: none !important;
|
|
||||||
font-family: 'Nunito', system-ui, sans-serif !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root input,
|
|
||||||
:root textarea,
|
|
||||||
:root select,
|
|
||||||
:root [class*="UInput"],
|
|
||||||
:root [class*="USelect"],
|
|
||||||
:root [class*="UTextarea"] {
|
|
||||||
border: none !important;
|
|
||||||
font-family: 'Nunito', system-ui, sans-serif !important;
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
diff: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
interface DiffLine {
|
|
||||||
text: string
|
|
||||||
type: 'added' | 'removed' | 'header' | 'context'
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedLines = computed((): DiffLine[] => {
|
|
||||||
if (!props.diff) return []
|
|
||||||
|
|
||||||
return props.diff.split('\n').map((line) => {
|
|
||||||
if (line.startsWith('@@')) {
|
|
||||||
return { text: line, type: 'header' as const }
|
|
||||||
}
|
|
||||||
if (line.startsWith('+')) {
|
|
||||||
return { text: line, type: 'added' as const }
|
|
||||||
}
|
|
||||||
if (line.startsWith('-')) {
|
|
||||||
return { text: line, type: 'removed' as const }
|
|
||||||
}
|
|
||||||
return { text: line, type: 'context' as const }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function lineClass(type: DiffLine['type']): string {
|
|
||||||
switch (type) {
|
|
||||||
case 'added':
|
|
||||||
return 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
|
||||||
case 'removed':
|
|
||||||
return 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
|
||||||
case 'header':
|
|
||||||
return 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-semibold'
|
|
||||||
default:
|
|
||||||
return 'text-gray-700 dark:text-gray-300'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
||||||
<pre class="text-xs font-mono leading-relaxed overflow-x-auto"><template
|
|
||||||
v-for="(line, index) in parsedLines"
|
|
||||||
:key="index"
|
|
||||||
><div
|
|
||||||
:class="['px-4 py-0.5', lineClass(line.type)]"
|
|
||||||
>{{ line.text }}</div></template></pre>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Error boundary component.
|
|
||||||
*
|
|
||||||
* Wraps slot content with NuxtErrorBoundary and displays a user-friendly
|
|
||||||
* error message in French when child components crash.
|
|
||||||
* Logs error details to console and emits error event for monitoring.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
error: [error: any]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const hasError = ref(false)
|
|
||||||
const errorDetails = ref<string | null>(null)
|
|
||||||
|
|
||||||
function handleError(error: any) {
|
|
||||||
hasError.value = true
|
|
||||||
errorDetails.value = error?.message || error?.toString() || 'Erreur inconnue'
|
|
||||||
|
|
||||||
// Log to console for debugging
|
|
||||||
console.error('[ErrorBoundary] Erreur capturee:', error)
|
|
||||||
|
|
||||||
// Emit for external monitoring
|
|
||||||
emit('error', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
function retry() {
|
|
||||||
hasError.value = false
|
|
||||||
errorDetails.value = null
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NuxtErrorBoundary @error="handleError">
|
|
||||||
<template v-if="!hasError">
|
|
||||||
<slot />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
|
||||||
<div class="max-w-md space-y-4">
|
|
||||||
<UIcon
|
|
||||||
name="i-lucide-alert-triangle"
|
|
||||||
class="text-5xl text-warning mx-auto"
|
|
||||||
/>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
Une erreur est survenue
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Un probleme inattendu s'est produit lors du chargement de ce contenu.
|
|
||||||
Vous pouvez essayer de recharger cette section.
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-if="errorDetails"
|
|
||||||
class="text-xs text-gray-400 dark:text-gray-500 font-mono bg-gray-100 dark:bg-gray-800 p-2 rounded"
|
|
||||||
>
|
|
||||||
{{ errorDetails }}
|
|
||||||
</p>
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-refresh-cw"
|
|
||||||
label="Reessayer"
|
|
||||||
color="primary"
|
|
||||||
variant="soft"
|
|
||||||
@click="retry"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NuxtErrorBoundary>
|
|
||||||
</template>
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Reusable skeleton loader component.
|
|
||||||
*
|
|
||||||
* Provides multiple skeleton variants for loading states:
|
|
||||||
* - Card: card layout with title and content lines
|
|
||||||
* - List: multiple rows with optional avatar
|
|
||||||
* - Detail: detailed view with mixed content
|
|
||||||
*
|
|
||||||
* Uses Nuxt UI USkeleton components.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
/** Number of skeleton lines to display (default: 3). */
|
|
||||||
lines?: number
|
|
||||||
/** Show an avatar circle placeholder. */
|
|
||||||
avatar?: boolean
|
|
||||||
/** Render as a card skeleton with border and padding. */
|
|
||||||
card?: boolean
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
lines: 3,
|
|
||||||
avatar: false,
|
|
||||||
card: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Generate varying line widths for a natural appearance. */
|
|
||||||
const lineWidths = computed(() => {
|
|
||||||
const widths = ['w-full', 'w-3/4', 'w-5/6', 'w-2/3', 'w-4/5']
|
|
||||||
return Array.from({ length: props.lines }, (_, i) => widths[i % widths.length])
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- Card variant -->
|
|
||||||
<UCard v-if="card" class="animate-pulse">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Optional avatar row -->
|
|
||||||
<div v-if="avatar" class="flex items-center gap-3">
|
|
||||||
<USkeleton class="h-10 w-10 rounded-full" />
|
|
||||||
<div class="flex-1 space-y-2">
|
|
||||||
<USkeleton class="h-4 w-1/3" />
|
|
||||||
<USkeleton class="h-3 w-1/4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Title line -->
|
|
||||||
<USkeleton class="h-5 w-2/3" />
|
|
||||||
|
|
||||||
<!-- Content lines -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<USkeleton
|
|
||||||
v-for="(width, i) in lineWidths"
|
|
||||||
:key="i"
|
|
||||||
class="h-3"
|
|
||||||
:class="width"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<!-- List / default variant -->
|
|
||||||
<div v-else class="space-y-3 animate-pulse">
|
|
||||||
<div
|
|
||||||
v-for="(width, i) in lineWidths"
|
|
||||||
:key="i"
|
|
||||||
class="flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<!-- Optional avatar per line -->
|
|
||||||
<USkeleton v-if="avatar" class="h-8 w-8 rounded-full flex-shrink-0" />
|
|
||||||
|
|
||||||
<!-- Line content -->
|
|
||||||
<div class="flex-1 space-y-1">
|
|
||||||
<USkeleton class="h-3" :class="width" />
|
|
||||||
<USkeleton v-if="i === 0" class="h-2 w-1/4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const { currentMood, moods, setMood } = useMood()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="mood-switcher" role="radiogroup" aria-label="Ambiance visuelle">
|
|
||||||
<UTooltip
|
|
||||||
v-for="mood in moods"
|
|
||||||
:key="mood.id"
|
|
||||||
:text="mood.label"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
:aria-checked="currentMood === mood.id"
|
|
||||||
:aria-label="`Ambiance ${mood.label}`"
|
|
||||||
class="mood-dot"
|
|
||||||
:class="{ 'mood-dot--active': currentMood === mood.id }"
|
|
||||||
:style="{ '--dot-color': mood.color }"
|
|
||||||
@click="setMood(mood.id)"
|
|
||||||
/>
|
|
||||||
</UTooltip>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mood-switcher {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mood-dot {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
background: var(--dot-color, var(--mood-text-muted));
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mood-dot:hover {
|
|
||||||
transform: scale(1.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mood-dot--active {
|
|
||||||
border-color: var(--mood-text);
|
|
||||||
box-shadow: 0 0 0 2px var(--mood-bg), 0 0 0 3px var(--mood-text-muted);
|
|
||||||
transform: scale(1.15);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Offline detection banner.
|
|
||||||
*
|
|
||||||
* Uses navigator.onLine and online/offline events to detect
|
|
||||||
* network connectivity changes. Shows a warning banner when
|
|
||||||
* offline and a brief success message when back online.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const isOnline = ref(true)
|
|
||||||
const showReconnected = ref(false)
|
|
||||||
|
|
||||||
let reconnectedTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
function handleOffline() {
|
|
||||||
isOnline.value = false
|
|
||||||
showReconnected.value = false
|
|
||||||
if (reconnectedTimer) {
|
|
||||||
clearTimeout(reconnectedTimer)
|
|
||||||
reconnectedTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOnline() {
|
|
||||||
isOnline.value = true
|
|
||||||
showReconnected.value = true
|
|
||||||
|
|
||||||
// Show "reconnected" message briefly then hide
|
|
||||||
reconnectedTimer = setTimeout(() => {
|
|
||||||
showReconnected.value = false
|
|
||||||
reconnectedTimer = null
|
|
||||||
}, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
isOnline.value = navigator.onLine
|
|
||||||
window.addEventListener('offline', handleOffline)
|
|
||||||
window.addEventListener('online', handleOnline)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('offline', handleOffline)
|
|
||||||
window.removeEventListener('online', handleOnline)
|
|
||||||
if (reconnectedTimer) {
|
|
||||||
clearTimeout(reconnectedTimer)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Transition name="slide-down">
|
|
||||||
<div
|
|
||||||
v-if="!isOnline"
|
|
||||||
class="bg-warning-100 dark:bg-warning-900/50 border-b border-warning-300 dark:border-warning-700 px-4 py-2 text-center"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-center gap-2 text-sm text-warning-800 dark:text-warning-200">
|
|
||||||
<UIcon name="i-lucide-wifi-off" class="text-lg flex-shrink-0" />
|
|
||||||
<span>Vous etes hors ligne. Certaines fonctionnalites sont indisponibles.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<Transition name="slide-down">
|
|
||||||
<div
|
|
||||||
v-if="showReconnected && isOnline"
|
|
||||||
class="bg-success-100 dark:bg-success-900/50 border-b border-success-300 dark:border-success-700 px-4 py-2 text-center"
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-center gap-2 text-sm text-success-800 dark:text-success-200">
|
|
||||||
<UIcon name="i-lucide-wifi" class="text-lg flex-shrink-0" />
|
|
||||||
<span>Connexion retablie</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.slide-down-enter-active,
|
|
||||||
.slide-down-leave-active {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-down-enter-from,
|
|
||||||
.slide-down-leave-to {
|
|
||||||
transform: translateY(-100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-down-enter-to,
|
|
||||||
.slide-down-leave-from {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* SectionLayout — Mise en page pour sections.
|
|
||||||
*
|
|
||||||
* Status pills inside the content block (not header).
|
|
||||||
* Toolbox sidebar with condensed content.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface StatusFilter {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
count: number
|
|
||||||
cssClass?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolboxItem {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
actions: Array<{
|
|
||||||
label: string
|
|
||||||
to?: string
|
|
||||||
onClick?: () => void
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
title: string
|
|
||||||
subtitle?: string
|
|
||||||
statuses: StatusFilter[]
|
|
||||||
toolboxItems?: ToolboxItem[]
|
|
||||||
activeStatus?: string | null
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
subtitle: undefined,
|
|
||||||
toolboxItems: undefined,
|
|
||||||
activeStatus: null,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:activeStatus': [status: string | null]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const toolboxOpen = ref(false)
|
|
||||||
|
|
||||||
const statusCssMap: Record<string, string> = {
|
|
||||||
draft: 'status-prepa',
|
|
||||||
qualification: 'status-prepa',
|
|
||||||
candidacy: 'status-prepa',
|
|
||||||
voting: 'status-vote',
|
|
||||||
review: 'status-vote',
|
|
||||||
active: 'status-vigueur',
|
|
||||||
executed: 'status-vigueur',
|
|
||||||
completed: 'status-vigueur',
|
|
||||||
closed: 'status-clos',
|
|
||||||
archived: 'status-clos',
|
|
||||||
revoked: 'status-clos',
|
|
||||||
reporting: 'status-vote',
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusClass(status: StatusFilter): string {
|
|
||||||
return status.cssClass || statusCssMap[status.id] || 'status-prepa'
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleStatus(statusId: string) {
|
|
||||||
if (props.activeStatus === statusId) {
|
|
||||||
emit('update:activeStatus', null)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
emit('update:activeStatus', statusId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="section">
|
|
||||||
<!-- Header: just title -->
|
|
||||||
<div class="section__header">
|
|
||||||
<h1 class="section__title">{{ title }}</h1>
|
|
||||||
<p v-if="subtitle" class="section__subtitle">{{ subtitle }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body: content + toolbox -->
|
|
||||||
<div class="section__body">
|
|
||||||
<div class="section__main">
|
|
||||||
<!-- Status pills INSIDE the list block -->
|
|
||||||
<div v-if="statuses.length > 0" class="section__pills">
|
|
||||||
<button
|
|
||||||
v-for="status in statuses"
|
|
||||||
:key="status.id"
|
|
||||||
type="button"
|
|
||||||
class="status-pill"
|
|
||||||
:class="[getStatusClass(status), { active: activeStatus === status.id }]"
|
|
||||||
@click="toggleStatus(status.id)"
|
|
||||||
>
|
|
||||||
{{ status.label }}
|
|
||||||
<span v-if="status.count > 0" class="section__pill-count">{{ status.count }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="$slots.search" class="section__search">
|
|
||||||
<slot name="search" />
|
|
||||||
</div>
|
|
||||||
<div class="section__content">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="section__toolbox">
|
|
||||||
<button class="section__toolbox-head" @click="toolboxOpen = !toolboxOpen">
|
|
||||||
<div class="section__toolbox-head-left">
|
|
||||||
<UIcon name="i-lucide-wrench" />
|
|
||||||
<span>Boite a outils</span>
|
|
||||||
</div>
|
|
||||||
<UIcon
|
|
||||||
:name="toolboxOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
||||||
class="section__toolbox-toggle"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div class="section__toolbox-content" :class="{ 'section__toolbox-content--open': toolboxOpen }">
|
|
||||||
<div v-if="$slots.toolbox" class="section__toolbox-body">
|
|
||||||
<slot name="toolbox" />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="toolboxItems && toolboxItems.length > 0" class="section__toolbox-body">
|
|
||||||
<ToolboxVignette
|
|
||||||
v-for="(item, idx) in toolboxItems"
|
|
||||||
:key="idx"
|
|
||||||
:title="item.title"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else class="section__toolbox-empty">
|
|
||||||
Aucun outil disponible
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__title {
|
|
||||||
font-size: 1.375rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--mood-text);
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.section__title {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__subtitle {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.section__subtitle {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__body {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 16rem;
|
|
||||||
gap: 1.5rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__pills {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
scrollbar-width: none;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__pills::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.section__pills {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
overflow-x: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__pill-count {
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 800;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__search {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
|
||||||
.section__search {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__content {
|
|
||||||
min-height: 12rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__toolbox {
|
|
||||||
position: sticky;
|
|
||||||
top: 4.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--mood-surface);
|
|
||||||
border-radius: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__toolbox-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
padding: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__toolbox-head-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__toolbox-toggle {
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__toolbox-content {
|
|
||||||
display: none;
|
|
||||||
padding: 0 1rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__toolbox-content--open {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__toolbox-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section__toolbox-empty {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
text-align: center;
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Desktop: toolbox always open, no toggle */
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.section__toolbox-head {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.section__toolbox-toggle {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.section__toolbox-content {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.section__body {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.section__toolbox {
|
|
||||||
position: static;
|
|
||||||
order: 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
status: string
|
|
||||||
type?: 'document' | 'decision' | 'mandate' | 'vote' | 'version'
|
|
||||||
clickable?: boolean
|
|
||||||
active?: boolean
|
|
||||||
}>(), {
|
|
||||||
clickable: true,
|
|
||||||
active: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
click: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { label: string; cssClass: string }> = {
|
|
||||||
// Universal statuses
|
|
||||||
draft: { label: 'En prepa', cssClass: 'status-prepa' },
|
|
||||||
active: { label: 'En vigueur', cssClass: 'status-vigueur' },
|
|
||||||
closed: { label: 'Clos', cssClass: 'status-clos' },
|
|
||||||
|
|
||||||
// Decision/vote specific
|
|
||||||
qualification: { label: 'En prepa', cssClass: 'status-prepa' },
|
|
||||||
review: { label: 'En prepa', cssClass: 'status-prepa' },
|
|
||||||
voting: { label: 'En vote', cssClass: 'status-vote' },
|
|
||||||
open: { label: 'En vote', cssClass: 'status-vote' },
|
|
||||||
executed: { label: 'En vigueur', cssClass: 'status-vigueur' },
|
|
||||||
|
|
||||||
// Version specific
|
|
||||||
pending: { label: 'En prepa', cssClass: 'status-prepa' },
|
|
||||||
accepted: { label: 'En vigueur', cssClass: 'status-vigueur' },
|
|
||||||
rejected: { label: 'Clos', cssClass: 'status-clos' },
|
|
||||||
|
|
||||||
// Mandate specific
|
|
||||||
formulation: { label: 'En prepa', cssClass: 'status-prepa' },
|
|
||||||
candidature: { label: 'En prepa', cssClass: 'status-prepa' },
|
|
||||||
candidacy: { label: 'En prepa', cssClass: 'status-prepa' },
|
|
||||||
investiture: { label: 'En vote', cssClass: 'status-vote' },
|
|
||||||
revoked: { label: 'Clos', cssClass: 'status-clos' },
|
|
||||||
completed: { label: 'Clos', cssClass: 'status-clos' },
|
|
||||||
archived: { label: 'Clos', cssClass: 'status-clos' },
|
|
||||||
reporting: { label: 'En vote', cssClass: 'status-vote' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = computed(() => {
|
|
||||||
return STATUS_MAP[props.status] ?? { label: props.status, cssClass: 'status-prepa' }
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
if (props.clickable) {
|
|
||||||
emit('click')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<button
|
|
||||||
v-if="clickable"
|
|
||||||
type="button"
|
|
||||||
class="status-pill"
|
|
||||||
:class="[resolved.cssClass, { active: active }]"
|
|
||||||
@click="handleClick"
|
|
||||||
>
|
|
||||||
{{ resolved.label }}
|
|
||||||
</button>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="status-pill"
|
|
||||||
:class="[resolved.cssClass]"
|
|
||||||
>
|
|
||||||
{{ resolved.label }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* ToolboxVignette — Carte compacte, bullet points, bouton Demarrer.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ToolboxAction {
|
|
||||||
label: string
|
|
||||||
icon?: string
|
|
||||||
to?: string
|
|
||||||
emit?: string
|
|
||||||
primary?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
title: string
|
|
||||||
bullets?: string[]
|
|
||||||
actions?: ToolboxAction[]
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
bullets: undefined,
|
|
||||||
actions: undefined,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
action: [actionEmit: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const defaultActions: ToolboxAction[] = [
|
|
||||||
{ label: 'Tutos', icon: 'i-lucide-graduation-cap', emit: 'tutos' },
|
|
||||||
{ label: 'Formules', icon: 'i-lucide-calculator', emit: 'formules' },
|
|
||||||
{ label: 'Demarrer', icon: 'i-lucide-play', emit: 'demarrer', primary: true },
|
|
||||||
]
|
|
||||||
|
|
||||||
const resolvedActions = computed(() => props.actions ?? defaultActions)
|
|
||||||
|
|
||||||
function handleAction(action: ToolboxAction) {
|
|
||||||
if (action.to) {
|
|
||||||
navigateTo(action.to)
|
|
||||||
}
|
|
||||||
else if (action.emit) {
|
|
||||||
emit('action', action.emit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="vignette">
|
|
||||||
<h4 class="vignette__title">{{ title }}</h4>
|
|
||||||
<ul v-if="bullets && bullets.length > 0" class="vignette__bullets">
|
|
||||||
<li v-for="(b, i) in bullets" :key="i">{{ b }}</li>
|
|
||||||
</ul>
|
|
||||||
<div class="vignette__actions">
|
|
||||||
<button
|
|
||||||
v-for="action in resolvedActions"
|
|
||||||
:key="action.label"
|
|
||||||
class="vignette__btn"
|
|
||||||
:class="{ 'vignette__btn--primary': action.primary }"
|
|
||||||
@click="handleAction(action)"
|
|
||||||
>
|
|
||||||
<UIcon v-if="action.icon" :name="action.icon" />
|
|
||||||
<span>{{ action.label }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.vignette {
|
|
||||||
background: var(--mood-accent-soft);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__title {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-text);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__bullets {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 0 0 1rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
list-style-type: disc;
|
|
||||||
}
|
|
||||||
.vignette__bullets li::marker {
|
|
||||||
color: var(--mood-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.375rem;
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.5rem 0.875rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
background: var(--mood-surface);
|
|
||||||
border-radius: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
|
||||||
min-height: 2.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.vignette__btn {
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
min-height: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__btn:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 2px 8px var(--mood-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette__btn--primary {
|
|
||||||
color: var(--mood-accent-text);
|
|
||||||
background: var(--mood-accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Cadrage form component for creating or editing a decision.
|
|
||||||
*
|
|
||||||
* Provides all fields needed for the initial decision setup:
|
|
||||||
* title, description, context, decision type, and voting protocol.
|
|
||||||
*/
|
|
||||||
import type { DecisionCreate } from '~/stores/decisions'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: DecisionCreate
|
|
||||||
submitting?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: DecisionCreate]
|
|
||||||
'submit': []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const decisionTypeOptions = [
|
|
||||||
{ label: 'Runtime upgrade', value: 'runtime_upgrade' },
|
|
||||||
{ label: 'Modification de document', value: 'document_change' },
|
|
||||||
{ label: 'Vote de mandat', value: 'mandate_vote' },
|
|
||||||
{ label: 'Changement de parametre', value: 'parameter_change' },
|
|
||||||
{ label: 'Autre', value: 'other' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function updateField<K extends keyof DecisionCreate>(field: K, value: DecisionCreate[K]) {
|
|
||||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = computed(() => {
|
|
||||||
return props.modelValue.title?.trim() && props.modelValue.decision_type
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSubmit() {
|
|
||||||
if (isValid.value) {
|
|
||||||
emit('submit')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<form class="space-y-6" @submit.prevent="onSubmit">
|
|
||||||
<!-- Titre -->
|
|
||||||
<div class="space-y-1">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Titre <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<UInput
|
|
||||||
:model-value="modelValue.title"
|
|
||||||
placeholder="Titre de la decision..."
|
|
||||||
required
|
|
||||||
@update:model-value="updateField('title', $event as string)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<div class="space-y-1">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Description <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<UTextarea
|
|
||||||
:model-value="modelValue.description ?? ''"
|
|
||||||
placeholder="Decrivez l'objet de cette decision..."
|
|
||||||
:rows="4"
|
|
||||||
@update:model-value="updateField('description', $event as string)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contexte -->
|
|
||||||
<div class="space-y-1">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Contexte
|
|
||||||
</label>
|
|
||||||
<UTextarea
|
|
||||||
:model-value="modelValue.context ?? ''"
|
|
||||||
placeholder="Contexte, motivations, liens utiles..."
|
|
||||||
:rows="3"
|
|
||||||
@update:model-value="updateField('context', $event as string)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Type de decision -->
|
|
||||||
<div class="space-y-1">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Type de decision <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<USelect
|
|
||||||
:model-value="modelValue.decision_type"
|
|
||||||
:items="decisionTypeOptions"
|
|
||||||
placeholder="Selectionnez un type..."
|
|
||||||
@update:model-value="updateField('decision_type', $event as string)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Protocole de vote -->
|
|
||||||
<div class="space-y-1">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Protocole de vote
|
|
||||||
</label>
|
|
||||||
<ProtocolPicker
|
|
||||||
:model-value="modelValue.voting_protocol_id ?? null"
|
|
||||||
@update:model-value="updateField('voting_protocol_id', $event)"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
Optionnel. Peut etre defini ulterieurement pour chaque etape.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submit -->
|
|
||||||
<div class="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<UButton
|
|
||||||
type="submit"
|
|
||||||
label="Creer la decision"
|
|
||||||
icon="i-lucide-plus"
|
|
||||||
color="primary"
|
|
||||||
:loading="submitting"
|
|
||||||
:disabled="!isValid"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Card component for displaying a decision in a list.
|
|
||||||
*
|
|
||||||
* Shows title, type badge, status badge, step count, and creation date.
|
|
||||||
* Navigates to the decision detail page on click.
|
|
||||||
*/
|
|
||||||
import type { Decision } from '~/stores/decisions'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
decision: Decision
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const typeLabel = (decisionType: string) => {
|
|
||||||
switch (decisionType) {
|
|
||||||
case 'runtime_upgrade': return 'Runtime upgrade'
|
|
||||||
case 'document_change': return 'Modif. document'
|
|
||||||
case 'mandate_vote': return 'Vote de mandat'
|
|
||||||
case 'parameter_change': return 'Param. change'
|
|
||||||
case 'other': return 'Autre'
|
|
||||||
default: return decisionType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigate() {
|
|
||||||
navigateTo(`/decisions/${props.decision.id}`)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UCard
|
|
||||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 hover:shadow-md transition-all"
|
|
||||||
@click="navigate"
|
|
||||||
>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon name="i-lucide-scale" class="text-gray-400" />
|
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
|
||||||
{{ decision.title }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<StatusBadge :status="decision.status" type="decision" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="decision.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
|
||||||
{{ decision.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
|
||||||
<UBadge variant="subtle" color="primary" size="xs">
|
|
||||||
{{ typeLabel(decision.decision_type) }}
|
|
||||||
</UBadge>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
{{ decision.steps.length }} etape(s)
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
{{ formatDate(decision.created_at) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</template>
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Visual stepper/timeline showing the decision workflow.
|
|
||||||
*
|
|
||||||
* Displays each step with its type icon, status badge, and dates.
|
|
||||||
* The active step is highlighted, completed steps show a checkmark.
|
|
||||||
*/
|
|
||||||
import type { DecisionStep } from '~/stores/decisions'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
steps: DecisionStep[]
|
|
||||||
currentStatus: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'create-vote-session': [step: DecisionStep]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const sortedSteps = computed(() => {
|
|
||||||
return [...props.steps].sort((a, b) => a.step_order - b.step_order)
|
|
||||||
})
|
|
||||||
|
|
||||||
const stepTypeLabel = (stepType: string) => {
|
|
||||||
switch (stepType) {
|
|
||||||
case 'qualification': return 'Qualification'
|
|
||||||
case 'review': return 'Revue'
|
|
||||||
case 'vote': return 'Vote'
|
|
||||||
case 'execution': return 'Execution'
|
|
||||||
case 'reporting': return 'Compte rendu'
|
|
||||||
default: return stepType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stepTypeIcon = (stepType: string) => {
|
|
||||||
switch (stepType) {
|
|
||||||
case 'qualification': return 'i-lucide-check-square'
|
|
||||||
case 'review': return 'i-lucide-eye'
|
|
||||||
case 'vote': return 'i-lucide-vote'
|
|
||||||
case 'execution': return 'i-lucide-play'
|
|
||||||
case 'reporting': return 'i-lucide-file-text'
|
|
||||||
default: return 'i-lucide-circle'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div v-if="sortedSteps.length === 0" class="text-center py-8">
|
|
||||||
<UIcon name="i-lucide-list-checks" class="text-4xl text-gray-400 mb-3" />
|
|
||||||
<p class="text-gray-500">Aucune etape definie pour cette decision</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="relative">
|
|
||||||
<!-- Timeline line -->
|
|
||||||
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
|
|
||||||
|
|
||||||
<!-- Steps -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="step in sortedSteps"
|
|
||||||
:key="step.id"
|
|
||||||
class="relative pl-12"
|
|
||||||
>
|
|
||||||
<!-- Timeline dot -->
|
|
||||||
<div
|
|
||||||
class="absolute left-2 w-5 h-5 rounded-full border-2 flex items-center justify-center"
|
|
||||||
:class="{
|
|
||||||
'bg-green-500 border-green-500': step.status === 'completed',
|
|
||||||
'bg-primary border-primary': step.status === 'active' || step.status === 'in_progress',
|
|
||||||
'bg-yellow-400 border-yellow-400': step.status === 'pending',
|
|
||||||
'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600': step.status === 'draft',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<UIcon
|
|
||||||
v-if="step.status === 'completed'"
|
|
||||||
name="i-lucide-check"
|
|
||||||
class="text-white text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UCard>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon :name="stepTypeIcon(step.step_type)" class="text-gray-500" />
|
|
||||||
<span class="text-sm font-mono text-gray-400">Etape {{ step.step_order }}</span>
|
|
||||||
<UBadge variant="subtle" color="neutral" size="xs">
|
|
||||||
{{ stepTypeLabel(step.step_type) }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
<StatusBadge :status="step.status" type="decision" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white">
|
|
||||||
{{ step.title }}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p v-if="step.description" class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{{ step.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="text-xs text-gray-500">
|
|
||||||
Cree le {{ formatDate(step.created_at) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="step.outcome" class="flex items-center gap-2 mt-2">
|
|
||||||
<UIcon name="i-lucide-flag" class="text-gray-400" />
|
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Resultat : {{ step.outcome }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Vote session actions -->
|
|
||||||
<div class="flex items-center gap-2 mt-2">
|
|
||||||
<UButton
|
|
||||||
v-if="step.vote_session_id"
|
|
||||||
size="xs"
|
|
||||||
variant="soft"
|
|
||||||
color="primary"
|
|
||||||
icon="i-lucide-vote"
|
|
||||||
label="Voir la session de vote"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
v-else-if="step.step_type === 'vote' && (step.status === 'active' || step.status === 'pending')"
|
|
||||||
size="xs"
|
|
||||||
variant="soft"
|
|
||||||
color="primary"
|
|
||||||
icon="i-lucide-plus"
|
|
||||||
label="Creer une session de vote"
|
|
||||||
@click.stop="emit('create-vote-session', step)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { Document } from '~/stores/documents'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
documents: Document[]
|
|
||||||
loading?: boolean
|
|
||||||
}>(), {
|
|
||||||
loading: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const typeLabel = (docType: string): string => {
|
|
||||||
switch (docType) {
|
|
||||||
case 'licence': return 'Licence'
|
|
||||||
case 'engagement': return 'Engagement'
|
|
||||||
case 'reglement': return 'Reglement'
|
|
||||||
case 'constitution': return 'Constitution'
|
|
||||||
default: return docType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- Loading state -->
|
|
||||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<USkeleton v-for="i in 6" :key="i" class="h-48 w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
|
||||||
<UCard v-else-if="documents.length === 0">
|
|
||||||
<div class="text-center py-8">
|
|
||||||
<UIcon name="i-lucide-book-open" class="text-4xl text-gray-400 mb-3" />
|
|
||||||
<p class="text-gray-500">Aucun document de reference pour le moment</p>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
|
|
||||||
<!-- Document grid -->
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<UCard
|
|
||||||
v-for="doc in documents"
|
|
||||||
:key="doc.id"
|
|
||||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
|
||||||
@click="navigateTo(`/documents/${doc.slug}`)"
|
|
||||||
>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white text-sm leading-tight">
|
|
||||||
{{ doc.title }}
|
|
||||||
</h3>
|
|
||||||
<StatusBadge :status="doc.status" type="document" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Type + Version -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UBadge variant="subtle" color="primary" size="xs">
|
|
||||||
{{ typeLabel(doc.doc_type) }}
|
|
||||||
</UBadge>
|
|
||||||
<span class="text-xs text-gray-500 font-mono">v{{ doc.version }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<p
|
|
||||||
v-if="doc.description"
|
|
||||||
class="text-xs text-gray-600 dark:text-gray-400 line-clamp-2"
|
|
||||||
>
|
|
||||||
{{ doc.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div class="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
|
|
||||||
<div class="flex items-center gap-1 text-xs text-gray-500">
|
|
||||||
<UIcon name="i-lucide-list" class="text-sm" />
|
|
||||||
<span>{{ doc.items_count }} items</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-gray-400">{{ formatDate(doc.updated_at) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* DocumentTuto — Quick tutorial overlay explaining how the document works.
|
|
||||||
* Shows how permanent voting, inertia, counter-proposals, and thresholds work.
|
|
||||||
*/
|
|
||||||
const open = ref(false)
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{
|
|
||||||
icon: 'i-lucide-infinity',
|
|
||||||
title: 'Vote permanent',
|
|
||||||
text: 'Chaque engagement est sous vote permanent. À tout moment, vous pouvez proposer une alternative ou voter pour/contre le texte en vigueur.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'i-lucide-sliders-horizontal',
|
|
||||||
title: 'Inertie variable',
|
|
||||||
text: 'Les engagements fondamentaux ont une inertie standard (difficulté de remplacement modérée). Les annexes sont plus faciles à modifier. La formule et ses réglages sont très protégés.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'i-lucide-scale',
|
|
||||||
title: 'Seuil adaptatif',
|
|
||||||
text: 'La formule WoT adapte le seuil à la participation : peu de votants = quasi-unanimité requise ; beaucoup de votants = majorité simple suffit.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'i-lucide-pen-line',
|
|
||||||
title: 'Contre-propositions',
|
|
||||||
text: 'Cliquez sur « Proposer une alternative » pour soumettre un texte de remplacement. Il sera soumis au vote et devra atteindre le seuil d\'adoption pour remplacer le texte en vigueur.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: 'i-lucide-git-branch',
|
|
||||||
title: 'Dépôt automatique',
|
|
||||||
text: 'Quand une alternative est adoptée, le document officiel est mis à jour, ancré sur IPFS et on-chain, puis déployé dans les applications (Cesium, Gecko).',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-circle-help"
|
|
||||||
variant="ghost"
|
|
||||||
color="neutral"
|
|
||||||
size="sm"
|
|
||||||
@click="open = true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UModal v-model:open="open" :ui="{ content: 'max-w-lg' }">
|
|
||||||
<template #content>
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="flex items-center justify-between mb-5">
|
|
||||||
<h2 class="text-lg font-bold" style="color: var(--mood-text)">
|
|
||||||
Comment ça marche ?
|
|
||||||
</h2>
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-x"
|
|
||||||
variant="ghost"
|
|
||||||
color="neutral"
|
|
||||||
size="xs"
|
|
||||||
@click="open = false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
v-for="(step, idx) in steps"
|
|
||||||
:key="idx"
|
|
||||||
class="tuto-step"
|
|
||||||
>
|
|
||||||
<div class="tuto-step__icon">
|
|
||||||
<UIcon :name="step.icon" class="text-base" />
|
|
||||||
</div>
|
|
||||||
<div class="tuto-step__content">
|
|
||||||
<h4 class="text-sm font-bold" style="color: var(--mood-text)">
|
|
||||||
{{ step.title }}
|
|
||||||
</h4>
|
|
||||||
<p class="text-xs leading-relaxed" style="color: var(--mood-text-muted)">
|
|
||||||
{{ step.text }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 pt-4 border-t" style="border-color: color-mix(in srgb, var(--mood-text) 8%, transparent)">
|
|
||||||
<p class="text-xs text-center" style="color: var(--mood-text-muted)">
|
|
||||||
Référence : formule g1vote —
|
|
||||||
<a
|
|
||||||
href="https://g1vote-view-237903.pages.duniter.org/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
style="color: var(--mood-accent)"
|
|
||||||
>
|
|
||||||
g1vote-view
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UModal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tuto-step {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tuto-step__icon {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
|
|
||||||
color: var(--mood-accent);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tuto-step__content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.125rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* EngagementCard — Enhanced item card with inline mini vote board,
|
|
||||||
* inertia indicator, and action buttons.
|
|
||||||
*
|
|
||||||
* Replaces the basic ItemCard for the document detail view.
|
|
||||||
*/
|
|
||||||
import type { DocumentItem } from '~/stores/documents'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
item: DocumentItem
|
|
||||||
documentSlug: string
|
|
||||||
showActions?: boolean
|
|
||||||
showVoteBoard?: boolean
|
|
||||||
}>(), {
|
|
||||||
showActions: false,
|
|
||||||
showVoteBoard: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
propose: [item: DocumentItem]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isSection = computed(() => props.item.item_type === 'section')
|
|
||||||
const isPreamble = computed(() => props.item.item_type === 'preamble')
|
|
||||||
|
|
||||||
const itemTypeIcon = computed(() => {
|
|
||||||
switch (props.item.item_type) {
|
|
||||||
case 'clause': return 'i-lucide-shield-check'
|
|
||||||
case 'rule': return 'i-lucide-scale'
|
|
||||||
case 'verification': return 'i-lucide-check-circle'
|
|
||||||
case 'preamble': return 'i-lucide-scroll-text'
|
|
||||||
case 'section': return 'i-lucide-layout-list'
|
|
||||||
default: return 'i-lucide-file-text'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const itemTypeLabel = computed(() => {
|
|
||||||
switch (props.item.item_type) {
|
|
||||||
case 'clause': return 'Engagement'
|
|
||||||
case 'rule': return 'Variables'
|
|
||||||
case 'verification': return 'Application'
|
|
||||||
case 'preamble': return 'Préambule'
|
|
||||||
case 'section': return 'Titre'
|
|
||||||
default: return props.item.item_type
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mock vote data varies by item for demo — items in "bonnes pratiques" (E8-E11) get lower/mixed votes
|
|
||||||
const mockVotes = computed(() => {
|
|
||||||
const order = props.item.sort_order
|
|
||||||
const pos = props.item.position
|
|
||||||
|
|
||||||
// Conseils et bonnes pratiques: varied votes, some non-adopted
|
|
||||||
if (pos === 'E8') return { votesFor: 4, votesAgainst: 3 } // contested
|
|
||||||
if (pos === 'E9') return { votesFor: 2, votesAgainst: 5 } // rejected
|
|
||||||
if (pos === 'E10') return { votesFor: 6, votesAgainst: 2 } // borderline
|
|
||||||
if (pos === 'E11') return { votesFor: 3, votesAgainst: 4 } // rejected
|
|
||||||
|
|
||||||
// Default: well-adopted items
|
|
||||||
const base = ((order * 7 + 13) % 5) + 8 // 8-12
|
|
||||||
const against = (order % 3) // 0-2
|
|
||||||
return { votesFor: base, votesAgainst: against }
|
|
||||||
})
|
|
||||||
|
|
||||||
function navigateToItem() {
|
|
||||||
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- Section header (visual separator, not a card) -->
|
|
||||||
<div v-if="isSection" class="engagement-section">
|
|
||||||
<div class="engagement-section__line" />
|
|
||||||
<div class="engagement-section__content">
|
|
||||||
<h3 class="engagement-section__title">
|
|
||||||
{{ item.title }}
|
|
||||||
</h3>
|
|
||||||
<p class="engagement-section__text">
|
|
||||||
{{ item.current_text }}
|
|
||||||
</p>
|
|
||||||
<InertiaSlider
|
|
||||||
:preset="item.inertia_preset"
|
|
||||||
compact
|
|
||||||
class="mt-2 max-w-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Regular item card -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="engagement-card"
|
|
||||||
:class="{
|
|
||||||
'engagement-card--preamble': isPreamble,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<!-- Card header -->
|
|
||||||
<div class="engagement-card__header" @click="navigateToItem">
|
|
||||||
<div class="flex items-center gap-2.5 min-w-0">
|
|
||||||
<div class="engagement-card__position">
|
|
||||||
{{ item.position }}
|
|
||||||
</div>
|
|
||||||
<UIcon :name="itemTypeIcon" class="text-sm shrink-0" style="color: var(--mood-accent)" />
|
|
||||||
<span v-if="item.title" class="engagement-card__title">
|
|
||||||
{{ item.title }}
|
|
||||||
</span>
|
|
||||||
<span class="engagement-card__type-label">
|
|
||||||
{{ itemTypeLabel }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Item text -->
|
|
||||||
<div class="engagement-card__body" @click="navigateToItem">
|
|
||||||
<MarkdownRenderer :content="item.current_text" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mini vote board -->
|
|
||||||
<div v-if="showVoteBoard" class="engagement-card__vote">
|
|
||||||
<MiniVoteBoard
|
|
||||||
:votes-for="mockVotes.votesFor"
|
|
||||||
:votes-against="mockVotes.votesAgainst"
|
|
||||||
:wot-size="7224"
|
|
||||||
:is-permanent="item.is_permanent_vote"
|
|
||||||
:inertia-preset="item.inertia_preset"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Inertia indicator -->
|
|
||||||
<div class="engagement-card__inertia">
|
|
||||||
<InertiaSlider
|
|
||||||
:preset="item.inertia_preset"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div v-if="showActions" class="engagement-card__actions">
|
|
||||||
<UButton
|
|
||||||
label="Proposer une alternative"
|
|
||||||
icon="i-lucide-pen-line"
|
|
||||||
variant="soft"
|
|
||||||
color="primary"
|
|
||||||
size="xs"
|
|
||||||
@click.stop="emit('propose', item)"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
label="Voter"
|
|
||||||
icon="i-lucide-vote"
|
|
||||||
variant="soft"
|
|
||||||
color="success"
|
|
||||||
size="xs"
|
|
||||||
@click.stop="navigateToItem"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Section separator */
|
|
||||||
.engagement-section {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1.5rem 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-section__line {
|
|
||||||
width: 4px;
|
|
||||||
background: var(--mood-accent);
|
|
||||||
border-radius: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-section__content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-section__title {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--mood-text);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-section__text {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card */
|
|
||||||
.engagement-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--mood-surface);
|
|
||||||
border-radius: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: box-shadow 0.15s, transform 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card:hover {
|
|
||||||
box-shadow: 0 2px 12px color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card--preamble {
|
|
||||||
border-left: 4px solid color-mix(in srgb, var(--mood-accent) 40%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.875rem 1rem 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card__position {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 2rem;
|
|
||||||
height: 1.625rem;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--mood-accent);
|
|
||||||
color: white;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card__title {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mood-text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card__type-label {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
opacity: 0.5;
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card__body {
|
|
||||||
padding: 0.5rem 1rem 0.625rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--mood-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.engagement-card__body {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card__vote {
|
|
||||||
padding: 0 1rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
transform: scale(0.92);
|
|
||||||
transform-origin: left center;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card:hover .engagement-card__vote {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card__inertia {
|
|
||||||
padding: 0.25rem 1rem 0.5rem;
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card:hover .engagement-card__inertia {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.engagement-card__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.625rem 1rem;
|
|
||||||
border-top: 1px solid color-mix(in srgb, var(--mood-text) 6%, transparent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,489 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Genesis block: displays source documents, repos, forum synthesis, and formula trigger
|
|
||||||
* for a reference document. Main block collapsible, each sub-section independently collapsible.
|
|
||||||
*/
|
|
||||||
const props = defineProps<{
|
|
||||||
genesisJson: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const expanded = ref(false)
|
|
||||||
|
|
||||||
// Individual section toggles
|
|
||||||
const sectionOpen = reactive<Record<string, boolean>>({
|
|
||||||
source: true,
|
|
||||||
tools: false,
|
|
||||||
forum: true,
|
|
||||||
process: false,
|
|
||||||
contributors: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
function toggleSection(key: string) {
|
|
||||||
sectionOpen[key] = !sectionOpen[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GenesisData {
|
|
||||||
source_document: {
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
repo: string
|
|
||||||
}
|
|
||||||
reference_tools: Record<string, string>
|
|
||||||
forum_synthesis: Array<{
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
status: string
|
|
||||||
posts?: number
|
|
||||||
}>
|
|
||||||
formula_trigger: string
|
|
||||||
contributors: Array<{
|
|
||||||
name: string
|
|
||||||
role: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
const genesis = computed((): GenesisData | null => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(props.genesisJson)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const statusLabel = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'rejected': return 'Rejetée'
|
|
||||||
case 'in_progress': return 'En cours'
|
|
||||||
case 'reference': return 'Référence'
|
|
||||||
default: return status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusClass = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'rejected': return 'genesis-status--rejected'
|
|
||||||
case 'in_progress': return 'genesis-status--progress'
|
|
||||||
case 'reference': return 'genesis-status--reference'
|
|
||||||
default: return 'genesis-status--default'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="genesis" class="genesis-block">
|
|
||||||
<!-- Header (always visible) -->
|
|
||||||
<button
|
|
||||||
class="genesis-block__header"
|
|
||||||
@click="expanded = !expanded"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="genesis-block__icon">
|
|
||||||
<UIcon name="i-lucide-file-archive" class="text-lg" />
|
|
||||||
</div>
|
|
||||||
<div class="text-left">
|
|
||||||
<h3 class="text-sm font-bold uppercase tracking-wide genesis-accent">
|
|
||||||
Bloc de genèse
|
|
||||||
</h3>
|
|
||||||
<p class="text-xs genesis-text-muted">
|
|
||||||
Sources, références et formule de dépôt
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<UIcon
|
|
||||||
:name="expanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
||||||
class="text-lg genesis-muted-icon"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Expandable content -->
|
|
||||||
<Transition name="genesis-expand">
|
|
||||||
<div v-if="expanded" class="genesis-block__body">
|
|
||||||
<!-- Source document -->
|
|
||||||
<div class="genesis-section">
|
|
||||||
<button class="genesis-section__toggle" @click="toggleSection('source')">
|
|
||||||
<h4 class="genesis-section__title">
|
|
||||||
<UIcon name="i-lucide-file-text" />
|
|
||||||
Document source
|
|
||||||
</h4>
|
|
||||||
<UIcon
|
|
||||||
:name="sectionOpen.source ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
||||||
class="text-sm genesis-muted-icon"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div v-if="sectionOpen.source" class="genesis-section__content">
|
|
||||||
<div class="genesis-card">
|
|
||||||
<p class="font-medium text-sm genesis-text">
|
|
||||||
{{ genesis.source_document.title }}
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-col gap-1 mt-2">
|
|
||||||
<a
|
|
||||||
:href="genesis.source_document.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="genesis-link"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-external-link" class="text-xs" />
|
|
||||||
Texte officiel
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
:href="genesis.source_document.repo"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="genesis-link"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-git-branch" class="text-xs" />
|
|
||||||
Dépôt git
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reference tools -->
|
|
||||||
<div class="genesis-section">
|
|
||||||
<button class="genesis-section__toggle" @click="toggleSection('tools')">
|
|
||||||
<h4 class="genesis-section__title">
|
|
||||||
<UIcon name="i-lucide-wrench" />
|
|
||||||
Outils de référence
|
|
||||||
</h4>
|
|
||||||
<UIcon
|
|
||||||
:name="sectionOpen.tools ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
||||||
class="text-sm genesis-muted-icon"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div v-if="sectionOpen.tools" class="genesis-section__content">
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<a
|
|
||||||
v-for="(url, name) in genesis.reference_tools"
|
|
||||||
:key="name"
|
|
||||||
:href="url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="genesis-card genesis-card--tool"
|
|
||||||
>
|
|
||||||
<span class="text-xs font-semibold capitalize genesis-text">
|
|
||||||
{{ name.replace(/_/g, ' ') }}
|
|
||||||
</span>
|
|
||||||
<UIcon name="i-lucide-external-link" class="text-xs genesis-text-muted" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Forum synthesis -->
|
|
||||||
<div class="genesis-section">
|
|
||||||
<button class="genesis-section__toggle" @click="toggleSection('forum')">
|
|
||||||
<h4 class="genesis-section__title">
|
|
||||||
<UIcon name="i-lucide-messages-square" />
|
|
||||||
Synthèse des discussions
|
|
||||||
</h4>
|
|
||||||
<UIcon
|
|
||||||
:name="sectionOpen.forum ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
||||||
class="text-sm genesis-muted-icon"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div v-if="sectionOpen.forum" class="genesis-section__content">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<a
|
|
||||||
v-for="topic in genesis.forum_synthesis"
|
|
||||||
:key="topic.url"
|
|
||||||
:href="topic.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="genesis-card genesis-card--forum"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<span class="text-xs font-medium genesis-text">
|
|
||||||
{{ topic.title }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="genesis-status shrink-0"
|
|
||||||
:class="statusClass(topic.status)"
|
|
||||||
>
|
|
||||||
{{ statusLabel(topic.status) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span v-if="topic.posts" class="text-xs genesis-text-muted">
|
|
||||||
{{ topic.posts }} messages
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Formula trigger -->
|
|
||||||
<div class="genesis-section">
|
|
||||||
<button class="genesis-section__toggle" @click="toggleSection('process')">
|
|
||||||
<h4 class="genesis-section__title">
|
|
||||||
<UIcon name="i-lucide-zap" />
|
|
||||||
Processus de dépôt
|
|
||||||
</h4>
|
|
||||||
<UIcon
|
|
||||||
:name="sectionOpen.process ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
||||||
class="text-sm genesis-muted-icon"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div v-if="sectionOpen.process" class="genesis-section__content">
|
|
||||||
<div class="genesis-card">
|
|
||||||
<p class="text-xs leading-relaxed genesis-text">
|
|
||||||
{{ genesis.formula_trigger }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contributors -->
|
|
||||||
<div class="genesis-section">
|
|
||||||
<button class="genesis-section__toggle" @click="toggleSection('contributors')">
|
|
||||||
<h4 class="genesis-section__title">
|
|
||||||
<UIcon name="i-lucide-users" />
|
|
||||||
Contributeurs
|
|
||||||
</h4>
|
|
||||||
<UIcon
|
|
||||||
:name="sectionOpen.contributors ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
||||||
class="text-sm genesis-muted-icon"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div v-if="sectionOpen.contributors" class="genesis-section__content">
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<div
|
|
||||||
v-for="c in genesis.contributors"
|
|
||||||
:key="c.name"
|
|
||||||
class="genesis-contributor"
|
|
||||||
>
|
|
||||||
<span class="font-semibold text-xs genesis-text">{{ c.name }}</span>
|
|
||||||
<span class="text-xs genesis-text-muted">{{ c.role }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.genesis-block {
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 8%, var(--mood-surface));
|
|
||||||
border: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
|
||||||
border-radius: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-block__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
cursor: pointer;
|
|
||||||
background: none;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-block__header:hover {
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 4%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-block__header h3 {
|
|
||||||
color: var(--mood-accent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-block__header p {
|
|
||||||
color: var(--mood-text-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-block__icon {
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 2.25rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
|
||||||
color: var(--mood-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-block__body {
|
|
||||||
padding: 0 1.25rem 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-section {
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 4%, var(--mood-bg));
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-section__toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-section__toggle:hover {
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 6%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-section__title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-section__toggle .text-sm {
|
|
||||||
color: var(--mood-text-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-section__content {
|
|
||||||
padding: 0 0.75rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-card {
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 5%, var(--mood-surface));
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-card .font-medium,
|
|
||||||
.genesis-card .text-xs,
|
|
||||||
.genesis-text {
|
|
||||||
color: var(--mood-text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-card--tool {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-card--tool .text-xs {
|
|
||||||
color: var(--mood-text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-card--tool:hover {
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-card--forum {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-card--forum:hover {
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-link {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--mood-accent);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-contributor {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 5%, var(--mood-surface));
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-contributor .font-semibold {
|
|
||||||
color: var(--mood-text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-contributor .text-xs:not(.font-semibold) {
|
|
||||||
color: var(--mood-text-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status badges — palette-aware */
|
|
||||||
.genesis-status {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-status--reference {
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
|
|
||||||
color: var(--mood-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-status--progress {
|
|
||||||
background: color-mix(in srgb, var(--mood-warning) 20%, transparent);
|
|
||||||
color: var(--mood-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-status--rejected {
|
|
||||||
background: color-mix(in srgb, var(--mood-error) 20%, transparent);
|
|
||||||
color: var(--mood-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-status--default {
|
|
||||||
background: color-mix(in srgb, var(--mood-text) 8%, transparent);
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Genesis-context text utilities */
|
|
||||||
.genesis-accent {
|
|
||||||
color: var(--mood-accent) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-text {
|
|
||||||
color: var(--mood-text) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-text-muted {
|
|
||||||
color: var(--mood-text-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-muted-icon {
|
|
||||||
color: var(--mood-text-muted) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Expand/collapse transition */
|
|
||||||
.genesis-expand-enter-active,
|
|
||||||
.genesis-expand-leave-active {
|
|
||||||
transition: all 0.25s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.genesis-expand-enter-from,
|
|
||||||
.genesis-expand-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
max-height: 0;
|
|
||||||
padding-top: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Inertia slider — displays the inertia preset level for a section.
|
|
||||||
* Read-only indicator (voting on the preset uses the standard vote flow).
|
|
||||||
* In full mode: shows formula diagram with simplified curve visualization.
|
|
||||||
*/
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
preset: string
|
|
||||||
compact?: boolean
|
|
||||||
mini?: boolean
|
|
||||||
}>(), {
|
|
||||||
compact: false,
|
|
||||||
mini: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
interface InertiaLevel {
|
|
||||||
label: string
|
|
||||||
gradient: number
|
|
||||||
majority: number
|
|
||||||
color: string
|
|
||||||
position: number // 0-100 for slider position
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const LEVELS: Record<string, InertiaLevel> = {
|
|
||||||
low: {
|
|
||||||
label: 'Remplacement facile',
|
|
||||||
gradient: 0.1,
|
|
||||||
majority: 50,
|
|
||||||
color: '#22c55e',
|
|
||||||
position: 10,
|
|
||||||
description: 'Majorité simple suffit, même à faible participation',
|
|
||||||
},
|
|
||||||
standard: {
|
|
||||||
label: 'Inertie pour le remplacement',
|
|
||||||
gradient: 0.2,
|
|
||||||
majority: 50,
|
|
||||||
color: '#3b82f6',
|
|
||||||
position: 37,
|
|
||||||
description: 'Équilibre : consensus croissant avec la participation',
|
|
||||||
},
|
|
||||||
high: {
|
|
||||||
label: 'Remplacement difficile',
|
|
||||||
gradient: 0.4,
|
|
||||||
majority: 60,
|
|
||||||
color: '#f59e0b',
|
|
||||||
position: 63,
|
|
||||||
description: 'Forte mobilisation et super-majorité requises',
|
|
||||||
},
|
|
||||||
very_high: {
|
|
||||||
label: 'Remplacement très difficile',
|
|
||||||
gradient: 0.6,
|
|
||||||
majority: 66,
|
|
||||||
color: '#ef4444',
|
|
||||||
position: 90,
|
|
||||||
description: 'Quasi-unanimité requise à toute participation',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const level = computed((): InertiaLevel => LEVELS[props.preset] ?? LEVELS.standard!)
|
|
||||||
|
|
||||||
// Generate SVG curve points for the inertia function
|
|
||||||
// Formula simplified: Seuil% = M + (1-M) × (1 - (T/W)^G)
|
|
||||||
// Where T/W = participation rate, so Seuil% goes from ~100% at low participation to M at full participation
|
|
||||||
const curvePath = computed(() => {
|
|
||||||
const G = level.value.gradient
|
|
||||||
const M = level.value.majority / 100
|
|
||||||
const points: string[] = []
|
|
||||||
const steps = 40
|
|
||||||
|
|
||||||
for (let i = 0; i <= steps; i++) {
|
|
||||||
const participation = i / steps // T/W ratio 0..1
|
|
||||||
const threshold = M + (1 - M) * (1 - Math.pow(participation, G))
|
|
||||||
// SVG coordinates: x = participation (0..200), y = threshold inverted (0=100%, 80=20%)
|
|
||||||
const x = 30 + participation * 170
|
|
||||||
const y = 10 + (1 - threshold) * 70
|
|
||||||
points.push(`${x.toFixed(1)},${y.toFixed(1)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return `M ${points.join(' L ')}`
|
|
||||||
})
|
|
||||||
|
|
||||||
// The 4 curve paths for the diagram overlay
|
|
||||||
const allCurves = computed(() => {
|
|
||||||
return Object.entries(LEVELS).map(([key, lvl]) => {
|
|
||||||
const G = lvl.gradient
|
|
||||||
const M = lvl.majority / 100
|
|
||||||
const points: string[] = []
|
|
||||||
const steps = 40
|
|
||||||
|
|
||||||
for (let i = 0; i <= steps; i++) {
|
|
||||||
const participation = i / steps
|
|
||||||
const threshold = M + (1 - M) * (1 - Math.pow(participation, G))
|
|
||||||
const x = 30 + participation * 170
|
|
||||||
const y = 10 + (1 - threshold) * 70
|
|
||||||
points.push(`${x.toFixed(1)},${y.toFixed(1)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
color: lvl.color,
|
|
||||||
path: `M ${points.join(' L ')}`,
|
|
||||||
active: key === props.preset,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="inertia" :class="{ 'inertia--compact': compact, 'inertia--mini': mini }">
|
|
||||||
<!-- Slider track -->
|
|
||||||
<div class="inertia__track">
|
|
||||||
<div class="inertia__fill" :style="{ width: `${level.position}%`, background: level.color }" />
|
|
||||||
<div
|
|
||||||
class="inertia__thumb"
|
|
||||||
:style="{ left: `${level.position}%`, borderColor: level.color }"
|
|
||||||
/>
|
|
||||||
<!-- Level marks -->
|
|
||||||
<div
|
|
||||||
v-for="(lvl, key) in LEVELS"
|
|
||||||
:key="key"
|
|
||||||
class="inertia__mark"
|
|
||||||
:class="{ 'inertia__mark--active': key === preset }"
|
|
||||||
:style="{ left: `${lvl.position}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Label row -->
|
|
||||||
<div v-if="mini" class="inertia__info">
|
|
||||||
<span class="inertia__label inertia__label--mini" :style="{ color: level.color }">
|
|
||||||
Inertie
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-else class="inertia__info">
|
|
||||||
<span class="inertia__label" :style="{ color: level.color }">
|
|
||||||
{{ level.label }}
|
|
||||||
</span>
|
|
||||||
<span v-if="!compact" class="inertia__params">
|
|
||||||
G={{ level.gradient }} M={{ level.majority }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description (not in compact mode) -->
|
|
||||||
<p v-if="!compact" class="inertia__desc">
|
|
||||||
{{ level.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Formula diagram (not in compact mode) -->
|
|
||||||
<div v-if="!compact" class="inertia__diagram">
|
|
||||||
<svg viewBox="0 0 220 100" class="inertia__svg">
|
|
||||||
<!-- Grid -->
|
|
||||||
<line x1="30" y1="10" x2="30" y2="80" class="inertia__axis" />
|
|
||||||
<line x1="30" y1="80" x2="200" y2="80" class="inertia__axis" />
|
|
||||||
|
|
||||||
<!-- Grid lines -->
|
|
||||||
<line x1="30" y1="10" x2="200" y2="10" class="inertia__grid" />
|
|
||||||
<line x1="30" y1="45" x2="200" y2="45" class="inertia__grid" />
|
|
||||||
|
|
||||||
<!-- Majority line M -->
|
|
||||||
<line
|
|
||||||
x1="30"
|
|
||||||
:y1="10 + (1 - level.majority / 100) * 70"
|
|
||||||
x2="200"
|
|
||||||
:y2="10 + (1 - level.majority / 100) * 70"
|
|
||||||
class="inertia__majority-line"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x="203"
|
|
||||||
:y="13 + (1 - level.majority / 100) * 70"
|
|
||||||
class="inertia__axis-label"
|
|
||||||
style="fill: var(--mood-accent)"
|
|
||||||
>M={{ level.majority }}%</text>
|
|
||||||
|
|
||||||
<!-- Background curves (ghosted) -->
|
|
||||||
<path
|
|
||||||
v-for="curve in allCurves"
|
|
||||||
:key="curve.key"
|
|
||||||
:d="curve.path"
|
|
||||||
fill="none"
|
|
||||||
:stroke="curve.color"
|
|
||||||
:stroke-width="curve.active ? 0 : 1"
|
|
||||||
:opacity="curve.active ? 0 : 0.15"
|
|
||||||
stroke-dasharray="3 3"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Active curve -->
|
|
||||||
<path
|
|
||||||
:d="curvePath"
|
|
||||||
fill="none"
|
|
||||||
:stroke="level.color"
|
|
||||||
stroke-width="2.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Axis labels -->
|
|
||||||
<text x="15" y="14" class="inertia__axis-label">100%</text>
|
|
||||||
<text x="15" y="49" class="inertia__axis-label">50%</text>
|
|
||||||
<text x="15" y="84" class="inertia__axis-label">0%</text>
|
|
||||||
|
|
||||||
<text x="28" y="95" class="inertia__axis-label">0%</text>
|
|
||||||
<text x="105" y="95" class="inertia__axis-label">50%</text>
|
|
||||||
<text x="185" y="95" class="inertia__axis-label">100%</text>
|
|
||||||
|
|
||||||
<!-- Axis titles -->
|
|
||||||
<text x="3" y="50" class="inertia__axis-title" transform="rotate(-90, 6, 50)">Seuil</text>
|
|
||||||
<text x="110" y="100" class="inertia__axis-title">Participation (T/W)</text>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Simplified formula -->
|
|
||||||
<div class="inertia__formula">
|
|
||||||
<span class="inertia__formula-label">Formule :</span>
|
|
||||||
<code class="inertia__formula-code">Seuil = M + (1-M) × (1 - (T/W)<sup>G</sup>)</code>
|
|
||||||
</div>
|
|
||||||
<div class="inertia__formula-legend">
|
|
||||||
<span><strong>T</strong> = votes exprimés</span>
|
|
||||||
<span><strong>W</strong> = taille WoT</span>
|
|
||||||
<span><strong>M</strong> = majorité cible</span>
|
|
||||||
<span><strong>G</strong> = gradient d'inertie</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.inertia {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia--compact {
|
|
||||||
gap: 0.25rem;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia--mini {
|
|
||||||
gap: 0.125rem;
|
|
||||||
width: fit-content;
|
|
||||||
min-width: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia--mini .inertia__track {
|
|
||||||
height: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia--mini .inertia__thumb {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-width: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__track {
|
|
||||||
position: relative;
|
|
||||||
height: 6px;
|
|
||||||
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia--compact .inertia__track {
|
|
||||||
height: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__fill {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
right: auto;
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__thumb {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--mood-bg);
|
|
||||||
border: 3px solid;
|
|
||||||
transition: left 0.3s ease;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia--compact .inertia__thumb {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-width: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__mark {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: color-mix(in srgb, var(--mood-text) 20%, transparent);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__mark--active {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__label {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia--compact .inertia__label {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__label--mini {
|
|
||||||
font-size: 0.5625rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__params {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-family: monospace;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__desc {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Diagram */
|
|
||||||
.inertia__diagram {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__svg {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 320px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__axis {
|
|
||||||
stroke: color-mix(in srgb, var(--mood-text) 25%, transparent);
|
|
||||||
stroke-width: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__grid {
|
|
||||||
stroke: color-mix(in srgb, var(--mood-text) 8%, transparent);
|
|
||||||
stroke-width: 0.5;
|
|
||||||
stroke-dasharray: 2 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__majority-line {
|
|
||||||
stroke: var(--mood-accent);
|
|
||||||
stroke-width: 0.75;
|
|
||||||
stroke-dasharray: 4 3;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__axis-label {
|
|
||||||
font-size: 5px;
|
|
||||||
fill: var(--mood-text-muted);
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__axis-title {
|
|
||||||
font-size: 5px;
|
|
||||||
fill: var(--mood-text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__formula {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__formula-label {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__formula-code {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-family: monospace;
|
|
||||||
color: var(--mood-text);
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 6%, var(--mood-bg));
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__formula-legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
font-size: 0.5625rem;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inertia__formula-legend strong {
|
|
||||||
color: var(--mood-text);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { DocumentItem } from '~/stores/documents'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
item: DocumentItem
|
|
||||||
documentSlug: string
|
|
||||||
showActions?: boolean
|
|
||||||
}>(), {
|
|
||||||
showActions: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
propose: [item: DocumentItem]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const itemTypeLabel = (itemType: string): string => {
|
|
||||||
switch (itemType) {
|
|
||||||
case 'clause': return 'Clause'
|
|
||||||
case 'rule': return 'Regle'
|
|
||||||
case 'verification': return 'Verification'
|
|
||||||
case 'preamble': return 'Preambule'
|
|
||||||
case 'section': return 'Section'
|
|
||||||
default: return itemType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateToItem() {
|
|
||||||
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UCard
|
|
||||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
|
||||||
@click="navigateToItem"
|
|
||||||
>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<!-- Item header -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UBadge variant="solid" color="primary" size="xs">
|
|
||||||
{{ item.position }}
|
|
||||||
</UBadge>
|
|
||||||
<span v-if="item.title" class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
||||||
{{ item.title }}
|
|
||||||
</span>
|
|
||||||
<UBadge variant="subtle" color="neutral" size="xs">
|
|
||||||
{{ itemTypeLabel(item.item_type) }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UBadge
|
|
||||||
v-if="item.voting_protocol_id"
|
|
||||||
color="info"
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
Sous vote
|
|
||||||
</UBadge>
|
|
||||||
<UBadge
|
|
||||||
v-else
|
|
||||||
color="neutral"
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
Pas de vote
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Item text -->
|
|
||||||
<div class="pl-2">
|
|
||||||
<MarkdownRenderer :content="item.current_text" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div v-if="showActions" class="flex justify-end pt-2 border-t border-gray-100 dark:border-gray-800">
|
|
||||||
<UButton
|
|
||||||
label="Proposer une modification"
|
|
||||||
icon="i-lucide-pen-line"
|
|
||||||
variant="soft"
|
|
||||||
color="primary"
|
|
||||||
size="xs"
|
|
||||||
@click.stop="emit('propose', item)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</template>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ItemVersion } from '~/stores/documents'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
version: ItemVersion
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
accept: [versionId: string]
|
|
||||||
reject: [versionId: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateAddress(address: string | null): string {
|
|
||||||
if (!address) return 'Inconnu'
|
|
||||||
if (address.length <= 16) return address
|
|
||||||
return address.slice(0, 8) + '...' + address.slice(-6)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UCard>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<StatusBadge :status="version.status" type="version" />
|
|
||||||
<span class="text-sm text-gray-500">
|
|
||||||
Propose par
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300 font-mono text-xs">
|
|
||||||
{{ truncateAddress(version.proposed_by) }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-gray-400">
|
|
||||||
{{ formatDate(version.created_at) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Rationale -->
|
|
||||||
<div v-if="version.rationale" class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3">
|
|
||||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-1">Justification</p>
|
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ version.rationale }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Diff view -->
|
|
||||||
<div v-if="version.diff">
|
|
||||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Modifications</p>
|
|
||||||
<DiffView :diff="version.diff" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Proposed text (fallback if no diff) -->
|
|
||||||
<div v-else-if="version.proposed_text">
|
|
||||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Texte propose</p>
|
|
||||||
<div class="bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
|
||||||
<MarkdownRenderer :content="version.proposed_text" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions for authenticated users -->
|
|
||||||
<div
|
|
||||||
v-if="auth.isAuthenticated && version.status === 'proposed'"
|
|
||||||
class="flex items-center gap-3 pt-2 border-t border-gray-100 dark:border-gray-800"
|
|
||||||
>
|
|
||||||
<UButton
|
|
||||||
label="Accepter"
|
|
||||||
icon="i-lucide-check"
|
|
||||||
color="success"
|
|
||||||
variant="soft"
|
|
||||||
size="xs"
|
|
||||||
@click="emit('accept', version.id)"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
label="Rejeter"
|
|
||||||
icon="i-lucide-x"
|
|
||||||
color="error"
|
|
||||||
variant="soft"
|
|
||||||
size="xs"
|
|
||||||
@click="emit('reject', version.id)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</template>
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* MiniVoteBoard — compact inline vote status for an engagement item.
|
|
||||||
*
|
|
||||||
* Shows: vote bar, counts, threshold, pass/fail, and vote buttons.
|
|
||||||
* Uses mock data when no vote session is linked (dev mode).
|
|
||||||
*/
|
|
||||||
import { useVoteFormula } from '~/composables/useVoteFormula'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
votesFor?: number
|
|
||||||
votesAgainst?: number
|
|
||||||
wotSize?: number
|
|
||||||
isPermanent?: boolean
|
|
||||||
inertiaPreset?: string
|
|
||||||
startsAt?: string | null
|
|
||||||
endsAt?: string | null
|
|
||||||
}>(), {
|
|
||||||
votesFor: 0,
|
|
||||||
votesAgainst: 0,
|
|
||||||
wotSize: 7224,
|
|
||||||
isPermanent: true,
|
|
||||||
inertiaPreset: 'standard',
|
|
||||||
startsAt: null,
|
|
||||||
endsAt: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { computeThreshold } = useVoteFormula()
|
|
||||||
|
|
||||||
const INERTIA_PARAMS: Record<string, { majority_pct: number; base_exponent: number; gradient_exponent: number; constant_base: number }> = {
|
|
||||||
low: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.1, constant_base: 0 },
|
|
||||||
standard: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.2, constant_base: 0 },
|
|
||||||
high: { majority_pct: 60, base_exponent: 0.1, gradient_exponent: 0.4, constant_base: 0 },
|
|
||||||
very_high: { majority_pct: 66, base_exponent: 0.1, gradient_exponent: 0.6, constant_base: 0 },
|
|
||||||
}
|
|
||||||
|
|
||||||
const formulaParams = computed(() => INERTIA_PARAMS[props.inertiaPreset] ?? INERTIA_PARAMS.standard!)
|
|
||||||
|
|
||||||
const totalVotes = computed(() => props.votesFor + props.votesAgainst)
|
|
||||||
|
|
||||||
const threshold = computed(() => {
|
|
||||||
if (totalVotes.value === 0) return 1
|
|
||||||
return computeThreshold(props.wotSize, totalVotes.value, formulaParams.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isPassing = computed(() => props.votesFor >= threshold.value)
|
|
||||||
|
|
||||||
const forPct = computed(() => {
|
|
||||||
if (totalVotes.value === 0) return 0
|
|
||||||
return (props.votesFor / totalVotes.value) * 100
|
|
||||||
})
|
|
||||||
|
|
||||||
const againstPct = computed(() => {
|
|
||||||
if (totalVotes.value === 0) return 0
|
|
||||||
return (props.votesAgainst / totalVotes.value) * 100
|
|
||||||
})
|
|
||||||
|
|
||||||
const thresholdPct = computed(() => {
|
|
||||||
if (totalVotes.value === 0) return 50
|
|
||||||
return Math.min((threshold.value / totalVotes.value) * 100, 100)
|
|
||||||
})
|
|
||||||
|
|
||||||
const participationRate = computed(() => {
|
|
||||||
if (props.wotSize === 0) return 0
|
|
||||||
return (totalVotes.value / props.wotSize) * 100
|
|
||||||
})
|
|
||||||
|
|
||||||
const remaining = computed(() => {
|
|
||||||
const diff = threshold.value - props.votesFor
|
|
||||||
return diff > 0 ? diff : 0
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatDate(d: string): string {
|
|
||||||
return new Date(d).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="mini-board">
|
|
||||||
<!-- Vote type + status on same line -->
|
|
||||||
<div class="mini-board__header">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<template v-if="isPermanent">
|
|
||||||
<UIcon name="i-lucide-infinity" class="text-xs" style="color: var(--mood-accent)" />
|
|
||||||
<span class="text-xs font-semibold" style="color: var(--mood-text-muted)">Vote permanent :</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<UIcon name="i-lucide-clock" class="text-xs" style="color: var(--mood-accent)" />
|
|
||||||
<span class="text-xs font-semibold" style="color: var(--mood-text-muted)">Vote temporaire :</span>
|
|
||||||
<span v-if="startsAt && endsAt" class="text-xs" style="color: var(--mood-text-muted)">
|
|
||||||
{{ formatDate(startsAt) }} - {{ formatDate(endsAt) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<UBadge
|
|
||||||
:color="isPassing ? 'success' : 'warning'"
|
|
||||||
:variant="isPassing ? 'solid' : 'subtle'"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
{{ isPassing ? 'Adopté' : 'En attente' }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Progress bar -->
|
|
||||||
<div class="mini-board__bar">
|
|
||||||
<div
|
|
||||||
class="mini-board__bar-for"
|
|
||||||
:style="{ width: `${forPct}%` }"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="mini-board__bar-against"
|
|
||||||
:style="{ left: `${forPct}%`, width: `${againstPct}%` }"
|
|
||||||
/>
|
|
||||||
<!-- Threshold marker -->
|
|
||||||
<div
|
|
||||||
v-if="totalVotes > 0"
|
|
||||||
class="mini-board__bar-threshold"
|
|
||||||
:style="{ left: `${thresholdPct}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats row -->
|
|
||||||
<div class="mini-board__stats">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="mini-board__stat mini-board__stat--for">
|
|
||||||
{{ votesFor }} pour
|
|
||||||
</span>
|
|
||||||
<span class="mini-board__stat mini-board__stat--against">
|
|
||||||
{{ votesAgainst }} contre
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="mini-board__stat">
|
|
||||||
{{ votesFor }}/{{ threshold }} requis
|
|
||||||
</span>
|
|
||||||
<span v-if="remaining > 0" class="mini-board__stat mini-board__stat--remaining">
|
|
||||||
{{ remaining }} manquant{{ remaining > 1 ? 's' : '' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Participation -->
|
|
||||||
<div class="mini-board__footer">
|
|
||||||
<span class="text-xs" style="color: var(--mood-text-muted)">
|
|
||||||
{{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }} / {{ wotSize }} membres
|
|
||||||
({{ participationRate.toFixed(2) }}%)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mini-board {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: color-mix(in srgb, var(--mood-accent) 3%, var(--mood-bg));
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__bar {
|
|
||||||
position: relative;
|
|
||||||
height: 6px;
|
|
||||||
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__bar-for {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
right: auto;
|
|
||||||
background: #22c55e;
|
|
||||||
border-radius: 3px 0 0 3px;
|
|
||||||
transition: width 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__bar-against {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
right: auto;
|
|
||||||
background: #ef4444;
|
|
||||||
transition: width 0.4s ease, left 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__bar-threshold {
|
|
||||||
position: absolute;
|
|
||||||
top: -3px;
|
|
||||||
bottom: -3px;
|
|
||||||
width: 2px;
|
|
||||||
background: #facc15;
|
|
||||||
border-radius: 1px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
transition: left 0.4s ease;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__stats {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__stat {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mood-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__stat--for {
|
|
||||||
color: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__stat--against {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__stat--remaining {
|
|
||||||
color: #f59e0b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-board__footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Card component for displaying a mandate in a list.
|
|
||||||
*
|
|
||||||
* Shows title, type badge, status badge, mandatee, date range.
|
|
||||||
* Navigates to the mandate detail page on click.
|
|
||||||
*/
|
|
||||||
import type { Mandate } from '~/stores/mandates'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
mandate: Mandate
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const typeLabel = (mandateType: string) => {
|
|
||||||
switch (mandateType) {
|
|
||||||
case 'techcomm': return 'Comite technique'
|
|
||||||
case 'smith': return 'Forgeron'
|
|
||||||
case 'custom': return 'Personnalise'
|
|
||||||
default: return mandateType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string | null): string {
|
|
||||||
if (!dateStr) return '-'
|
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigate() {
|
|
||||||
navigateTo(`/mandates/${props.mandate.id}`)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UCard
|
|
||||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 hover:shadow-md transition-all"
|
|
||||||
@click="navigate"
|
|
||||||
>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon name="i-lucide-user-check" class="text-gray-400" />
|
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
|
||||||
{{ mandate.title }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<StatusBadge :status="mandate.status" type="mandate" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="mandate.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
|
||||||
{{ mandate.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
|
||||||
<UBadge variant="subtle" color="primary" size="xs">
|
|
||||||
{{ typeLabel(mandate.mandate_type) }}
|
|
||||||
</UBadge>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
{{ mandate.steps.length }} etape(s)
|
|
||||||
</span>
|
|
||||||
<span v-if="mandate.mandatee_id" class="text-xs text-gray-500 flex items-center gap-1">
|
|
||||||
<UIcon name="i-lucide-user" class="text-xs" />
|
|
||||||
{{ mandate.mandatee_id.slice(0, 8) }}...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-2 text-xs text-gray-500">
|
|
||||||
<div>
|
|
||||||
<span class="block font-medium">Debut</span>
|
|
||||||
{{ formatDate(mandate.starts_at) }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="block font-medium">Fin</span>
|
|
||||||
{{ formatDate(mandate.ends_at) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</template>
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Visual timeline for mandate lifecycle steps.
|
|
||||||
*
|
|
||||||
* Displays each step with its type, status, and visual indicators.
|
|
||||||
* Similar pattern to DecisionWorkflow but with mandate-specific step types.
|
|
||||||
*/
|
|
||||||
import type { MandateStep } from '~/stores/mandates'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
steps: MandateStep[]
|
|
||||||
currentStatus: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const sortedSteps = computed(() => {
|
|
||||||
return [...props.steps].sort((a, b) => a.step_order - b.step_order)
|
|
||||||
})
|
|
||||||
|
|
||||||
const stepTypeLabel = (stepType: string) => {
|
|
||||||
switch (stepType) {
|
|
||||||
case 'candidacy': return 'Candidature'
|
|
||||||
case 'voting': return 'Vote'
|
|
||||||
case 'active': return 'Actif'
|
|
||||||
case 'reporting': return 'Rapport'
|
|
||||||
case 'completed': return 'Termine'
|
|
||||||
default: return stepType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stepTypeIcon = (stepType: string) => {
|
|
||||||
switch (stepType) {
|
|
||||||
case 'candidacy': return 'i-lucide-user-plus'
|
|
||||||
case 'voting': return 'i-lucide-vote'
|
|
||||||
case 'active': return 'i-lucide-shield-check'
|
|
||||||
case 'reporting': return 'i-lucide-file-text'
|
|
||||||
case 'completed': return 'i-lucide-check-circle'
|
|
||||||
default: return 'i-lucide-circle'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div v-if="sortedSteps.length === 0" class="text-center py-8">
|
|
||||||
<UIcon name="i-lucide-list-checks" class="text-4xl text-gray-400 mb-3" />
|
|
||||||
<p class="text-gray-500">Aucune etape definie pour ce mandat</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="relative">
|
|
||||||
<!-- Timeline line -->
|
|
||||||
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
|
|
||||||
|
|
||||||
<!-- Steps -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="step in sortedSteps"
|
|
||||||
:key="step.id"
|
|
||||||
class="relative pl-12"
|
|
||||||
>
|
|
||||||
<!-- Timeline dot -->
|
|
||||||
<div
|
|
||||||
class="absolute left-2 w-5 h-5 rounded-full border-2 flex items-center justify-center"
|
|
||||||
:class="{
|
|
||||||
'bg-green-500 border-green-500': step.status === 'completed',
|
|
||||||
'bg-primary border-primary': step.status === 'active' || step.status === 'in_progress',
|
|
||||||
'bg-yellow-400 border-yellow-400': step.status === 'pending',
|
|
||||||
'bg-red-500 border-red-500': step.status === 'revoked',
|
|
||||||
'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600': step.status === 'draft',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<UIcon
|
|
||||||
v-if="step.status === 'completed'"
|
|
||||||
name="i-lucide-check"
|
|
||||||
class="text-white text-xs"
|
|
||||||
/>
|
|
||||||
<UIcon
|
|
||||||
v-else-if="step.status === 'revoked'"
|
|
||||||
name="i-lucide-x"
|
|
||||||
class="text-white text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UCard>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon :name="stepTypeIcon(step.step_type)" class="text-gray-500" />
|
|
||||||
<span class="text-sm font-mono text-gray-400">Etape {{ step.step_order }}</span>
|
|
||||||
<UBadge variant="subtle" color="neutral" size="xs">
|
|
||||||
{{ stepTypeLabel(step.step_type) }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
<StatusBadge :status="step.status" type="mandate" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white">
|
|
||||||
{{ step.title }}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p v-if="step.description" class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{{ step.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="text-xs text-gray-500">
|
|
||||||
Cree le {{ formatDate(step.created_at) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="step.outcome" class="flex items-center gap-2 mt-2">
|
|
||||||
<UIcon name="i-lucide-flag" class="text-gray-400" />
|
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Resultat : {{ step.outcome }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="step.vote_session_id" class="mt-2">
|
|
||||||
<UButton
|
|
||||||
size="xs"
|
|
||||||
variant="soft"
|
|
||||||
color="primary"
|
|
||||||
icon="i-lucide-vote"
|
|
||||||
label="Voir la session de vote"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Interactive formula parameter editor.
|
|
||||||
*
|
|
||||||
* Provides sliders and inputs for adjusting all formula parameters,
|
|
||||||
* emitting the updated config on each change.
|
|
||||||
*/
|
|
||||||
import type { FormulaConfig } from '~/stores/protocols'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: FormulaConfig
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: FormulaConfig]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
/** Local reactive copy to avoid direct prop mutation. */
|
|
||||||
const local = reactive({ ...props.modelValue })
|
|
||||||
|
|
||||||
/** Sync incoming prop changes. */
|
|
||||||
watch(() => props.modelValue, (newVal) => {
|
|
||||||
Object.assign(local, newVal)
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
/** Emit on any local change. */
|
|
||||||
watch(local, () => {
|
|
||||||
emit('update:modelValue', { ...local })
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
/** Track optional fields. */
|
|
||||||
const showSmith = ref(local.smith_exponent !== null)
|
|
||||||
const showTechcomm = ref(local.techcomm_exponent !== null)
|
|
||||||
const showNuancedMin = ref(local.nuanced_min_participants !== null)
|
|
||||||
const showNuancedThreshold = ref(local.nuanced_threshold_pct !== null)
|
|
||||||
|
|
||||||
watch(showSmith, (v) => {
|
|
||||||
local.smith_exponent = v ? 0.5 : null
|
|
||||||
})
|
|
||||||
watch(showTechcomm, (v) => {
|
|
||||||
local.techcomm_exponent = v ? 0.5 : null
|
|
||||||
})
|
|
||||||
watch(showNuancedMin, (v) => {
|
|
||||||
local.nuanced_min_participants = v ? 10 : null
|
|
||||||
})
|
|
||||||
watch(showNuancedThreshold, (v) => {
|
|
||||||
local.nuanced_threshold_pct = v ? 66 : null
|
|
||||||
})
|
|
||||||
|
|
||||||
interface ParamDef {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
description: string
|
|
||||||
type: 'input' | 'slider'
|
|
||||||
min: number
|
|
||||||
max: number
|
|
||||||
step: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainParams: ParamDef[] = [
|
|
||||||
{
|
|
||||||
key: 'duration_days',
|
|
||||||
label: 'Duree (jours)',
|
|
||||||
description: 'Duree du vote en jours',
|
|
||||||
type: 'input',
|
|
||||||
min: 1,
|
|
||||||
max: 365,
|
|
||||||
step: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'majority_pct',
|
|
||||||
label: 'Majorite (%)',
|
|
||||||
description: 'Ratio de majorite cible a haute participation',
|
|
||||||
type: 'slider',
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'base_exponent',
|
|
||||||
label: 'Exposant de base (B)',
|
|
||||||
description: 'B^W tend vers 0 si B < 1 ; plancher dynamique',
|
|
||||||
type: 'slider',
|
|
||||||
min: 0.01,
|
|
||||||
max: 1.0,
|
|
||||||
step: 0.01,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'gradient_exponent',
|
|
||||||
label: 'Gradient d\'inertie (G)',
|
|
||||||
description: 'Controle la vitesse de transition vers la majorite simple',
|
|
||||||
type: 'slider',
|
|
||||||
min: 0.01,
|
|
||||||
max: 2.0,
|
|
||||||
step: 0.01,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'constant_base',
|
|
||||||
label: 'Constante de base (C)',
|
|
||||||
description: 'Plancher fixe de votes requis',
|
|
||||||
type: 'input',
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
step: 1,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Main parameters -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div v-for="param in mainParams" :key="param.key" class="space-y-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
{{ param.label }}
|
|
||||||
</label>
|
|
||||||
<span class="text-sm font-mono font-bold text-primary">
|
|
||||||
{{ (local as any)[param.key] }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="param.type === 'slider'">
|
|
||||||
<URange
|
|
||||||
v-model="(local as any)[param.key]"
|
|
||||||
:min="param.min"
|
|
||||||
:max="param.max"
|
|
||||||
:step="param.step"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<UInput
|
|
||||||
v-model.number="(local as any)[param.key]"
|
|
||||||
type="number"
|
|
||||||
:min="param.min"
|
|
||||||
:max="param.max"
|
|
||||||
:step="param.step"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<p class="text-xs text-gray-500">{{ param.description }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Optional parameters -->
|
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">
|
|
||||||
Parametres optionnels
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<!-- Smith exponent -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UCheckbox v-model="showSmith" />
|
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Critere Smith (S)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<template v-if="showSmith && local.smith_exponent !== null">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs text-gray-500">Exposant Smith</span>
|
|
||||||
<span class="text-sm font-mono font-bold text-primary">{{ local.smith_exponent }}</span>
|
|
||||||
</div>
|
|
||||||
<URange
|
|
||||||
v-model="local.smith_exponent"
|
|
||||||
:min="0.01"
|
|
||||||
:max="1.0"
|
|
||||||
:step="0.01"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500">ceil(SmithWotSize^S) votes Smith requis</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TechComm exponent -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UCheckbox v-model="showTechcomm" />
|
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Critere TechComm (T)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<template v-if="showTechcomm && local.techcomm_exponent !== null">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs text-gray-500">Exposant TechComm</span>
|
|
||||||
<span class="text-sm font-mono font-bold text-primary">{{ local.techcomm_exponent }}</span>
|
|
||||||
</div>
|
|
||||||
<URange
|
|
||||||
v-model="local.techcomm_exponent"
|
|
||||||
:min="0.01"
|
|
||||||
:max="1.0"
|
|
||||||
:step="0.01"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500">ceil(CoTecSize^T) votes TechComm requis</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nuanced min participants -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UCheckbox v-model="showNuancedMin" />
|
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Participants minimum (nuance)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<template v-if="showNuancedMin && local.nuanced_min_participants !== null">
|
|
||||||
<UInput
|
|
||||||
v-model.number="local.nuanced_min_participants"
|
|
||||||
type="number"
|
|
||||||
:min="1"
|
|
||||||
:max="1000"
|
|
||||||
:step="1"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500">Nombre minimum de participants pour un vote nuance</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nuanced threshold pct -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UCheckbox v-model="showNuancedThreshold" />
|
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Seuil nuance (%)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<template v-if="showNuancedThreshold && local.nuanced_threshold_pct !== null">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-xs text-gray-500">Pourcentage du seuil</span>
|
|
||||||
<span class="text-sm font-mono font-bold text-primary">{{ local.nuanced_threshold_pct }}%</span>
|
|
||||||
</div>
|
|
||||||
<URange
|
|
||||||
v-model="local.nuanced_threshold_pct"
|
|
||||||
:min="50"
|
|
||||||
:max="100"
|
|
||||||
:step="1"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-gray-500">Seuil de score moyen pour adoption en vote nuance</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Display decoded mode params string as labeled badges/chips.
|
|
||||||
*
|
|
||||||
* Parses the compact mode params string and renders each parameter
|
|
||||||
* as a human-readable chip.
|
|
||||||
*/
|
|
||||||
const props = defineProps<{
|
|
||||||
modeParams: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
interface ParamChip {
|
|
||||||
code: string
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const chips = computed((): ParamChip[] => {
|
|
||||||
if (!props.modeParams) return []
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = parseModeParams(props.modeParams)
|
|
||||||
const result: ParamChip[] = []
|
|
||||||
|
|
||||||
result.push({ code: 'D', label: 'Duree', value: `${parsed.duration_days}j`, color: 'primary' })
|
|
||||||
result.push({ code: 'M', label: 'Majorite', value: `${parsed.majority_pct}%`, color: 'info' })
|
|
||||||
result.push({ code: 'B', label: 'Base', value: String(parsed.base_exponent), color: 'neutral' })
|
|
||||||
result.push({ code: 'G', label: 'Gradient', value: String(parsed.gradient_exponent), color: 'neutral' })
|
|
||||||
|
|
||||||
if (parsed.constant_base > 0) {
|
|
||||||
result.push({ code: 'C', label: 'Constante', value: String(parsed.constant_base), color: 'warning' })
|
|
||||||
}
|
|
||||||
if (parsed.smith_exponent !== null) {
|
|
||||||
result.push({ code: 'S', label: 'Smith', value: String(parsed.smith_exponent), color: 'success' })
|
|
||||||
}
|
|
||||||
if (parsed.techcomm_exponent !== null) {
|
|
||||||
result.push({ code: 'T', label: 'TechComm', value: String(parsed.techcomm_exponent), color: 'purple' })
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<span class="font-mono text-xs font-bold text-primary mr-1">{{ modeParams }}</span>
|
|
||||||
<UBadge
|
|
||||||
v-for="chip in chips"
|
|
||||||
:key="chip.code"
|
|
||||||
:color="(chip.color as any)"
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
{{ chip.label }}: {{ chip.value }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Dropdown/select to pick a voting protocol.
|
|
||||||
*
|
|
||||||
* Fetches protocols from the store and renders them in a USelect
|
|
||||||
* with protocol name, mode params, and vote type badge.
|
|
||||||
*/
|
|
||||||
import type { VotingProtocol } from '~/stores/protocols'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: string | null
|
|
||||||
voteType?: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: string | null]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const protocols = useProtocolsStore()
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (protocols.protocols.length === 0) {
|
|
||||||
await protocols.fetchProtocols(props.voteType ? { vote_type: props.voteType } : undefined)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredProtocols = computed(() => {
|
|
||||||
if (!props.voteType) return protocols.protocols
|
|
||||||
return protocols.protocols.filter(p => p.vote_type === props.voteType)
|
|
||||||
})
|
|
||||||
|
|
||||||
const options = computed(() => {
|
|
||||||
return filteredProtocols.value.map(p => ({
|
|
||||||
label: buildLabel(p),
|
|
||||||
value: p.id,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
function buildLabel(p: VotingProtocol): string {
|
|
||||||
const typeLabel = p.vote_type === 'binary' ? 'Binaire' : 'Nuance'
|
|
||||||
const params = p.mode_params ? ` [${p.mode_params}]` : ''
|
|
||||||
return `${p.name} - ${typeLabel}${params}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSelect(value: string) {
|
|
||||||
emit('update:modelValue', value || null)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<USelect
|
|
||||||
:model-value="modelValue ?? undefined"
|
|
||||||
:items="options"
|
|
||||||
placeholder="Selectionnez un protocole..."
|
|
||||||
:loading="protocols.loading"
|
|
||||||
value-key="value"
|
|
||||||
@update:model-value="onSelect"
|
|
||||||
/>
|
|
||||||
<p v-if="protocols.error" class="text-xs text-red-500 mt-1">
|
|
||||||
{{ protocols.error }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
txHash: string | null
|
|
||||||
block: number | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const truncatedHash = computed(() => {
|
|
||||||
if (!props.txHash) return null
|
|
||||||
if (props.txHash.length <= 20) return props.txHash
|
|
||||||
return props.txHash.slice(0, 10) + '...' + props.txHash.slice(-6)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<template v-if="txHash">
|
|
||||||
<div class="inline-flex items-center gap-2">
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<UIcon name="i-lucide-link" class="text-sm text-gray-500" />
|
|
||||||
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">
|
|
||||||
{{ truncatedHash }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<UBadge v-if="block" color="neutral" variant="subtle" size="xs">
|
|
||||||
Bloc #{{ block.toLocaleString('fr-FR') }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<UBadge color="warning" variant="subtle" size="xs">
|
|
||||||
Non ancre
|
|
||||||
</UBadge>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
cid: string | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const IPFS_GATEWAY = 'https://ipfs.io/ipfs/'
|
|
||||||
|
|
||||||
const truncatedCid = computed(() => {
|
|
||||||
if (!props.cid) return null
|
|
||||||
if (props.cid.length <= 20) return props.cid
|
|
||||||
return props.cid.slice(0, 12) + '...' + props.cid.slice(-6)
|
|
||||||
})
|
|
||||||
|
|
||||||
const gatewayUrl = computed(() => {
|
|
||||||
if (!props.cid) return null
|
|
||||||
return `${IPFS_GATEWAY}${props.cid}`
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<template v-if="cid">
|
|
||||||
<a
|
|
||||||
:href="gatewayUrl!"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex items-center gap-1.5 font-mono text-xs text-primary hover:underline"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-hard-drive" class="text-sm" />
|
|
||||||
<span>{{ truncatedCid }}</span>
|
|
||||||
<UIcon name="i-lucide-external-link" class="text-sm" />
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<UBadge color="neutral" variant="subtle" size="xs">
|
|
||||||
Non disponible
|
|
||||||
</UBadge>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
export interface SanctuaryEntryOut {
|
|
||||||
id: string
|
|
||||||
entry_type: string
|
|
||||||
reference_id: string
|
|
||||||
title: string | null
|
|
||||||
content_hash: string
|
|
||||||
ipfs_cid: string | null
|
|
||||||
chain_tx_hash: string | null
|
|
||||||
chain_block: number | null
|
|
||||||
metadata_json: string | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
entry: SanctuaryEntryOut
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
verify: [id: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const typeLabel = (entryType: string): string => {
|
|
||||||
switch (entryType) {
|
|
||||||
case 'document': return 'Document'
|
|
||||||
case 'decision': return 'Decision'
|
|
||||||
case 'vote_result': return 'Resultat de vote'
|
|
||||||
default: return entryType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeColor = (entryType: string): string => {
|
|
||||||
switch (entryType) {
|
|
||||||
case 'document': return 'primary'
|
|
||||||
case 'decision': return 'success'
|
|
||||||
case 'vote_result': return 'info'
|
|
||||||
default: return 'neutral'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateHash(hash: string | null, length: number = 16): string {
|
|
||||||
if (!hash) return '-'
|
|
||||||
if (hash.length <= length * 2) return hash
|
|
||||||
return hash.slice(0, length) + '...' + hash.slice(-8)
|
|
||||||
}
|
|
||||||
|
|
||||||
const copied = ref(false)
|
|
||||||
|
|
||||||
async function copyHash() {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(props.entry.content_hash)
|
|
||||||
copied.value = true
|
|
||||||
setTimeout(() => { copied.value = false }, 2000)
|
|
||||||
} catch {
|
|
||||||
// Clipboard API not available
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UCard
|
|
||||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
|
||||||
@click="navigateTo(`/sanctuary/${entry.id}`)"
|
|
||||||
>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Entry header -->
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UIcon name="i-lucide-shield-check" class="text-xl text-primary" />
|
|
||||||
<div>
|
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
|
||||||
{{ entry.title || 'Entree sans titre' }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
{{ formatDate(entry.created_at) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<UBadge :color="(typeColor(entry.entry_type) as any)" variant="subtle" size="xs">
|
|
||||||
{{ typeLabel(entry.entry_type) }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hashes and anchors -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<!-- Content hash -->
|
|
||||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
|
||||||
<div class="flex items-center justify-between mb-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UIcon name="i-lucide-hash" class="text-gray-400 text-sm" />
|
|
||||||
<span class="text-xs font-semibold text-gray-500 uppercase">SHA-256</span>
|
|
||||||
</div>
|
|
||||||
<UButton
|
|
||||||
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
|
|
||||||
variant="ghost"
|
|
||||||
color="neutral"
|
|
||||||
size="xs"
|
|
||||||
class="p-0"
|
|
||||||
@click.stop="copyHash"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
|
|
||||||
{{ truncateHash(entry.content_hash) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- IPFS CID -->
|
|
||||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<UIcon name="i-lucide-hard-drive" class="text-gray-400 text-sm" />
|
|
||||||
<span class="text-xs font-semibold text-gray-500 uppercase">IPFS CID</span>
|
|
||||||
</div>
|
|
||||||
<div @click.stop>
|
|
||||||
<IPFSLink :cid="entry.ipfs_cid" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chain anchor -->
|
|
||||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<UIcon name="i-lucide-link" class="text-gray-400 text-sm" />
|
|
||||||
<span class="text-xs font-semibold text-gray-500 uppercase">On-chain</span>
|
|
||||||
</div>
|
|
||||||
<ChainAnchor :tx-hash="entry.chain_tx_hash" :block="entry.chain_block" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Verification status indicators -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-4 text-xs">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<UIcon
|
|
||||||
:name="entry.ipfs_cid ? 'i-lucide-check-circle' : 'i-lucide-clock'"
|
|
||||||
:class="entry.ipfs_cid ? 'text-green-500' : 'text-gray-400'"
|
|
||||||
/>
|
|
||||||
<span :class="entry.ipfs_cid ? 'text-green-600' : 'text-gray-400'">
|
|
||||||
IPFS {{ entry.ipfs_cid ? 'epingle' : 'en attente' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<UIcon
|
|
||||||
:name="entry.chain_tx_hash ? 'i-lucide-check-circle' : 'i-lucide-clock'"
|
|
||||||
:class="entry.chain_tx_hash ? 'text-green-500' : 'text-gray-400'"
|
|
||||||
/>
|
|
||||||
<span :class="entry.chain_tx_hash ? 'text-green-600' : 'text-gray-400'">
|
|
||||||
Chain {{ entry.chain_tx_hash ? 'ancre' : 'en attente' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<UButton
|
|
||||||
label="Verifier"
|
|
||||||
icon="i-lucide-shield-check"
|
|
||||||
variant="soft"
|
|
||||||
color="primary"
|
|
||||||
size="xs"
|
|
||||||
@click.stop="emit('verify', entry.id)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UCard>
|
|
||||||
</template>
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Display vote formula with KaTeX rendering.
|
|
||||||
*
|
|
||||||
* Renders the WoT threshold formula using KaTeX when available,
|
|
||||||
* falling back to a code display. Shows parameter values and
|
|
||||||
* optional Smith/TechComm criteria formulas.
|
|
||||||
*/
|
|
||||||
import type { FormulaConfig } from '~/stores/protocols'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
formulaConfig: FormulaConfig
|
|
||||||
showExplanation?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const showExplain = ref(props.showExplanation ?? false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a LaTeX string to HTML using KaTeX, with code fallback.
|
|
||||||
*/
|
|
||||||
function renderFormula(tex: string): string {
|
|
||||||
if (typeof window !== 'undefined' && (window as any).katex) {
|
|
||||||
return (window as any).katex.renderToString(tex, { throwOnError: false, displayMode: true })
|
|
||||||
}
|
|
||||||
return `<code class="text-sm font-mono">${tex}</code>`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Main threshold formula in LaTeX. */
|
|
||||||
const mainFormulaTeX = 'Seuil = C + B^W + \\left(M + (1-M) \\cdot \\left(1 - \\left(\\frac{T}{W}\\right)^G\\right)\\right) \\cdot \\max(0,\\, T - C)'
|
|
||||||
|
|
||||||
/** Smith criterion formula. */
|
|
||||||
const smithFormulaTeX = computed(() => {
|
|
||||||
if (props.formulaConfig.smith_exponent === null) return null
|
|
||||||
return `Seuil_{Smith} = \\lceil W_{Smith}^{${props.formulaConfig.smith_exponent}} \\rceil`
|
|
||||||
})
|
|
||||||
|
|
||||||
/** TechComm criterion formula. */
|
|
||||||
const techcommFormulaTeX = computed(() => {
|
|
||||||
if (props.formulaConfig.techcomm_exponent === null) return null
|
|
||||||
return `Seuil_{TechComm} = \\lceil W_{TechComm}^{${props.formulaConfig.techcomm_exponent}} \\rceil`
|
|
||||||
})
|
|
||||||
|
|
||||||
const mainFormulaHtml = computed(() => renderFormula(mainFormulaTeX))
|
|
||||||
const smithFormulaHtml = computed(() => smithFormulaTeX.value ? renderFormula(smithFormulaTeX.value) : null)
|
|
||||||
const techcommFormulaHtml = computed(() => techcommFormulaTeX.value ? renderFormula(techcommFormulaTeX.value) : null)
|
|
||||||
|
|
||||||
const parameters = computed(() => [
|
|
||||||
{ label: 'Duree', code: 'D', value: `${props.formulaConfig.duration_days} jours`, description: 'Duree du vote en jours' },
|
|
||||||
{ label: 'Majorite', code: 'M', value: `${props.formulaConfig.majority_pct}%`, description: 'Ratio de majorite cible a haute participation' },
|
|
||||||
{ label: 'Base', code: 'B', value: String(props.formulaConfig.base_exponent), description: 'Exposant de base (B^W tend vers 0 si B < 1)' },
|
|
||||||
{ label: 'Gradient', code: 'G', value: String(props.formulaConfig.gradient_exponent), description: 'Exposant du gradient d\'inertie' },
|
|
||||||
{ label: 'Constante', code: 'C', value: String(props.formulaConfig.constant_base), description: 'Plancher fixe de votes requis' },
|
|
||||||
...(props.formulaConfig.smith_exponent !== null ? [{
|
|
||||||
label: 'Smith', code: 'S', value: String(props.formulaConfig.smith_exponent), description: 'Exposant du critere Smith',
|
|
||||||
}] : []),
|
|
||||||
...(props.formulaConfig.techcomm_exponent !== null ? [{
|
|
||||||
label: 'TechComm', code: 'T', value: String(props.formulaConfig.techcomm_exponent), description: 'Exposant du critere TechComm',
|
|
||||||
}] : []),
|
|
||||||
])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Main formula -->
|
|
||||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg overflow-x-auto">
|
|
||||||
<div v-html="mainFormulaHtml" class="text-center" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Smith criterion -->
|
|
||||||
<div v-if="smithFormulaHtml" class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg overflow-x-auto">
|
|
||||||
<p class="text-xs font-semibold text-blue-600 dark:text-blue-400 mb-2">Critere Smith</p>
|
|
||||||
<div v-html="smithFormulaHtml" class="text-center" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TechComm criterion -->
|
|
||||||
<div v-if="techcommFormulaHtml" class="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg overflow-x-auto">
|
|
||||||
<p class="text-xs font-semibold text-purple-600 dark:text-purple-400 mb-2">Critere TechComm</p>
|
|
||||||
<div v-html="techcommFormulaHtml" class="text-center" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Parameters grid -->
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
|
||||||
<div
|
|
||||||
v-for="param in parameters"
|
|
||||||
:key="param.code"
|
|
||||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<span class="font-mono font-bold text-primary text-sm">{{ param.code }}</span>
|
|
||||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ param.label }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ param.value }}</div>
|
|
||||||
<p v-if="showExplain" class="text-xs text-gray-500 mt-1">{{ param.description }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Explanation toggle -->
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<UButton
|
|
||||||
variant="ghost"
|
|
||||||
color="neutral"
|
|
||||||
size="xs"
|
|
||||||
:icon="showExplain ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
|
||||||
@click="showExplain = !showExplain"
|
|
||||||
>
|
|
||||||
{{ showExplain ? 'Masquer les explications' : 'Afficher les explications' }}
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Visual gauge showing votes vs threshold.
|
|
||||||
*
|
|
||||||
* Displays a horizontal progress bar with green (pour) and red (contre) fills,
|
|
||||||
* a vertical threshold marker, participation statistics, and a pass/fail badge.
|
|
||||||
*/
|
|
||||||
const props = defineProps<{
|
|
||||||
votesFor: number
|
|
||||||
votesAgainst: number
|
|
||||||
threshold: number
|
|
||||||
wotSize: number
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const totalVotes = computed(() => props.votesFor + props.votesAgainst)
|
|
||||||
|
|
||||||
/** Percentage of "pour" votes relative to total votes. */
|
|
||||||
const forPct = computed(() => {
|
|
||||||
if (totalVotes.value === 0) return 0
|
|
||||||
return (props.votesFor / totalVotes.value) * 100
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Percentage of "contre" votes relative to total votes. */
|
|
||||||
const againstPct = computed(() => {
|
|
||||||
if (totalVotes.value === 0) return 0
|
|
||||||
return (props.votesAgainst / totalVotes.value) * 100
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Position of the threshold marker as a percentage of total votes. */
|
|
||||||
const thresholdPosition = computed(() => {
|
|
||||||
if (totalVotes.value === 0) return 50
|
|
||||||
// Threshold as a percentage of total votes
|
|
||||||
const pct = (props.threshold / totalVotes.value) * 100
|
|
||||||
return Math.min(pct, 100)
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Whether the vote passes (votes_for >= threshold). */
|
|
||||||
const isPassing = computed(() => props.votesFor >= props.threshold)
|
|
||||||
|
|
||||||
/** Participation rate. */
|
|
||||||
const participationRate = computed(() => {
|
|
||||||
if (props.wotSize === 0) return 0
|
|
||||||
return (totalVotes.value / props.wotSize) * 100
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<!-- Progress bar -->
|
|
||||||
<div class="relative h-8 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
|
||||||
<!-- Green fill (pour) -->
|
|
||||||
<div
|
|
||||||
class="absolute inset-y-0 left-0 bg-green-500 transition-all duration-500"
|
|
||||||
:style="{ width: `${forPct}%` }"
|
|
||||||
/>
|
|
||||||
<!-- Red fill (contre) -->
|
|
||||||
<div
|
|
||||||
class="absolute inset-y-0 bg-red-500 transition-all duration-500"
|
|
||||||
:style="{ left: `${forPct}%`, width: `${againstPct}%` }"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Threshold marker -->
|
|
||||||
<div
|
|
||||||
v-if="totalVotes > 0"
|
|
||||||
class="absolute inset-y-0 w-0.5 bg-yellow-400 z-10"
|
|
||||||
:style="{ left: `${thresholdPosition}%` }"
|
|
||||||
>
|
|
||||||
<div class="absolute -top-5 left-1/2 -translate-x-1/2 text-xs font-bold text-yellow-600 dark:text-yellow-400 whitespace-nowrap">
|
|
||||||
Seuil
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Percentage labels inside the bar -->
|
|
||||||
<div class="absolute inset-0 flex items-center px-3 text-xs font-bold text-white">
|
|
||||||
<span v-if="forPct > 10">{{ forPct.toFixed(1) }}%</span>
|
|
||||||
<span class="flex-1" />
|
|
||||||
<span v-if="againstPct > 10">{{ againstPct.toFixed(1) }}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Counts and threshold text -->
|
|
||||||
<div class="flex items-center justify-between text-sm">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<span class="text-green-600 dark:text-green-400 font-medium">
|
|
||||||
{{ votesFor }} pour
|
|
||||||
</span>
|
|
||||||
<span class="text-red-600 dark:text-red-400 font-medium">
|
|
||||||
{{ votesAgainst }} contre
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="font-medium" :class="isPassing ? 'text-green-600 dark:text-green-400' : 'text-gray-600 dark:text-gray-400'">
|
|
||||||
{{ votesFor }} / {{ threshold }} requis
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Participation rate -->
|
|
||||||
<div class="flex items-center justify-between text-xs text-gray-500">
|
|
||||||
<span>
|
|
||||||
{{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }} sur {{ wotSize }} membres
|
|
||||||
({{ participationRate.toFixed(2) }}%)
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Pass/fail badge -->
|
|
||||||
<UBadge
|
|
||||||
:color="isPassing ? 'success' : 'error'"
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
>
|
|
||||||
{{ isPassing ? 'Adopte' : 'Non adopte' }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Binary vote component: Pour / Contre.
|
|
||||||
*
|
|
||||||
* Displays two large buttons for binary voting with confirmation modal.
|
|
||||||
* Integrates with the votes store and auth store for submission and access control.
|
|
||||||
*/
|
|
||||||
const props = defineProps<{
|
|
||||||
sessionId: string
|
|
||||||
disabled?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
|
||||||
const votes = useVotesStore()
|
|
||||||
|
|
||||||
const submitting = ref(false)
|
|
||||||
const pendingVote = ref<'pour' | 'contre' | null>(null)
|
|
||||||
const showConfirm = ref(false)
|
|
||||||
|
|
||||||
/** Check if the current user has already voted in this session. */
|
|
||||||
const userVote = computed(() => {
|
|
||||||
if (!auth.identity) return null
|
|
||||||
return votes.votes.find(v => v.voter_id === auth.identity!.id && v.is_active)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isDisabled = computed(() => {
|
|
||||||
return props.disabled || !auth.isAuthenticated || !votes.isSessionOpen || submitting.value
|
|
||||||
})
|
|
||||||
|
|
||||||
function requestVote(value: 'pour' | 'contre') {
|
|
||||||
if (isDisabled.value) return
|
|
||||||
pendingVote.value = value
|
|
||||||
showConfirm.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmVote() {
|
|
||||||
if (!pendingVote.value) return
|
|
||||||
showConfirm.value = false
|
|
||||||
submitting.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
await votes.submitVote({
|
|
||||||
session_id: props.sessionId,
|
|
||||||
vote_value: pendingVote.value,
|
|
||||||
signature: 'pending',
|
|
||||||
signed_payload: 'pending',
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
pendingVote.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelVote() {
|
|
||||||
showConfirm.value = false
|
|
||||||
pendingVote.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmLabel = computed(() => {
|
|
||||||
return pendingVote.value === 'pour'
|
|
||||||
? 'Confirmer le vote POUR'
|
|
||||||
: 'Confirmer le vote CONTRE'
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Vote buttons -->
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<UButton
|
|
||||||
size="xl"
|
|
||||||
:color="userVote?.vote_value === 'pour' ? 'success' : 'neutral'"
|
|
||||||
:variant="userVote?.vote_value === 'pour' ? 'solid' : 'outline'"
|
|
||||||
:disabled="isDisabled"
|
|
||||||
:loading="submitting && pendingVote === 'pour'"
|
|
||||||
icon="i-lucide-thumbs-up"
|
|
||||||
class="flex-1 justify-center py-6 text-lg"
|
|
||||||
@click="requestVote('pour')"
|
|
||||||
>
|
|
||||||
Pour
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
<UButton
|
|
||||||
size="xl"
|
|
||||||
:color="userVote?.vote_value === 'contre' ? 'error' : 'neutral'"
|
|
||||||
:variant="userVote?.vote_value === 'contre' ? 'solid' : 'outline'"
|
|
||||||
:disabled="isDisabled"
|
|
||||||
:loading="submitting && pendingVote === 'contre'"
|
|
||||||
icon="i-lucide-thumbs-down"
|
|
||||||
class="flex-1 justify-center py-6 text-lg"
|
|
||||||
@click="requestVote('contre')"
|
|
||||||
>
|
|
||||||
Contre
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status messages -->
|
|
||||||
<div v-if="!auth.isAuthenticated" class="text-sm text-amber-600 dark:text-amber-400 text-center">
|
|
||||||
Connectez-vous pour voter
|
|
||||||
</div>
|
|
||||||
<div v-else-if="!votes.isSessionOpen" class="text-sm text-gray-500 text-center">
|
|
||||||
Cette session de vote est fermee
|
|
||||||
</div>
|
|
||||||
<div v-else-if="userVote" class="text-sm text-green-600 dark:text-green-400 text-center">
|
|
||||||
Vous avez vote : {{ userVote.vote_value === 'pour' ? 'Pour' : 'Contre' }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error display -->
|
|
||||||
<div v-if="votes.error" class="text-sm text-red-500 text-center">
|
|
||||||
{{ votes.error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Confirmation modal -->
|
|
||||||
<UModal v-model:open="showConfirm">
|
|
||||||
<template #content>
|
|
||||||
<div class="p-6 space-y-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
Confirmation du vote
|
|
||||||
</h3>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
|
||||||
Vous etes sur le point de voter
|
|
||||||
<strong :class="pendingVote === 'pour' ? 'text-green-600' : 'text-red-600'">
|
|
||||||
{{ pendingVote === 'pour' ? 'POUR' : 'CONTRE' }}
|
|
||||||
</strong>.
|
|
||||||
Cette action est definitive.
|
|
||||||
</p>
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<UButton variant="ghost" color="neutral" @click="cancelVote">
|
|
||||||
Annuler
|
|
||||||
</UButton>
|
|
||||||
<UButton
|
|
||||||
:color="pendingVote === 'pour' ? 'success' : 'error'"
|
|
||||||
@click="confirmVote"
|
|
||||||
>
|
|
||||||
{{ confirmLabel }}
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UModal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* Vote history list for a session.
|
|
||||||
*
|
|
||||||
* Displays a table of all votes cast in a session, sorted by date descending.
|
|
||||||
* For nuanced votes, shows the level label and color. Shows Smith/TechComm badges.
|
|
||||||
*/
|
|
||||||
import type { Vote } from '~/stores/votes'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
votes: Vote[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
/** Nuanced level labels matching VoteNuanced component. */
|
|
||||||
const nuancedLabels: Record<number, { label: string; color: string }> = {
|
|
||||||
0: { label: 'CONTRE', color: 'error' },
|
|
||||||
1: { label: 'PAS DU TOUT D\'ACCORD', color: 'warning' },
|
|
||||||
2: { label: 'PAS D\'ACCORD', color: 'warning' },
|
|
||||||
3: { label: 'NEUTRE', color: 'neutral' },
|
|
||||||
4: { label: 'D\'ACCORD', color: 'success' },
|
|
||||||
5: { label: 'TOUT A FAIT D\'ACCORD', color: 'success' },
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sorted votes by date descending. */
|
|
||||||
const sortedVotes = computed(() => {
|
|
||||||
return [...props.votes].sort((a, b) => {
|
|
||||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function truncateAddress(address: string): string {
|
|
||||||
if (address.length <= 16) return address
|
|
||||||
return `${address.slice(0, 8)}...${address.slice(-6)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function voteLabel(vote: Vote): string {
|
|
||||||
if (vote.nuanced_level !== null && vote.nuanced_level !== undefined) {
|
|
||||||
return nuancedLabels[vote.nuanced_level]?.label ?? `Niveau ${vote.nuanced_level}`
|
|
||||||
}
|
|
||||||
return vote.vote_value === 'pour' ? 'Pour' : 'Contre'
|
|
||||||
}
|
|
||||||
|
|
||||||
function voteColor(vote: Vote): string {
|
|
||||||
if (vote.nuanced_level !== null && vote.nuanced_level !== undefined) {
|
|
||||||
return nuancedLabels[vote.nuanced_level]?.color ?? 'neutral'
|
|
||||||
}
|
|
||||||
return vote.vote_value === 'pour' ? 'success' : 'error'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div v-if="sortedVotes.length === 0" class="text-center py-8">
|
|
||||||
<UIcon name="i-lucide-vote" class="text-4xl text-gray-400 mb-3" />
|
|
||||||
<p class="text-gray-500">Aucun vote enregistre</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Votant</th>
|
|
||||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Vote</th>
|
|
||||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Statut</th>
|
|
||||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Date</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
v-for="vote in sortedVotes"
|
|
||||||
:key="vote.id"
|
|
||||||
class="border-b border-gray-100 dark:border-gray-800"
|
|
||||||
>
|
|
||||||
<!-- Voter address -->
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">
|
|
||||||
{{ truncateAddress(vote.voter_id) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Vote value -->
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<UBadge :color="(voteColor(vote) as any)" variant="subtle" size="xs">
|
|
||||||
{{ voteLabel(vote) }}
|
|
||||||
</UBadge>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Smith / TechComm badges -->
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<UBadge v-if="vote.voter_is_smith" color="info" variant="subtle" size="xs">
|
|
||||||
Smith
|
|
||||||
</UBadge>
|
|
||||||
<UBadge v-if="vote.voter_is_techcomm" color="purple" variant="subtle" size="xs">
|
|
||||||
TechComm
|
|
||||||
</UBadge>
|
|
||||||
<span v-if="!vote.voter_is_smith && !vote.voter_is_techcomm" class="text-xs text-gray-400">
|
|
||||||
Membre
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Date -->
|
|
||||||
<td class="px-4 py-3 text-xs text-gray-500">
|
|
||||||
{{ formatDate(vote.created_at) }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* 6-level nuanced vote component.
|
|
||||||
*
|
|
||||||
* Displays 6 vote levels from CONTRE (0) to TOUT A FAIT D'ACCORD (5),
|
|
||||||
* each with a distinctive color. Negative votes (0-2) optionally include
|
|
||||||
* a comment textarea.
|
|
||||||
*/
|
|
||||||
const props = defineProps<{
|
|
||||||
sessionId: string
|
|
||||||
disabled?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
|
||||||
const votes = useVotesStore()
|
|
||||||
|
|
||||||
const submitting = ref(false)
|
|
||||||
const selectedLevel = ref<number | null>(null)
|
|
||||||
const comment = ref('')
|
|
||||||
const showConfirm = ref(false)
|
|
||||||
|
|
||||||
interface NuancedLevel {
|
|
||||||
level: number
|
|
||||||
label: string
|
|
||||||
color: string
|
|
||||||
bgClass: string
|
|
||||||
textClass: string
|
|
||||||
ringClass: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const levels: NuancedLevel[] = [
|
|
||||||
{ level: 0, label: 'CONTRE', color: 'red', bgClass: 'bg-red-500', textClass: 'text-red-600 dark:text-red-400', ringClass: 'ring-red-500' },
|
|
||||||
{ level: 1, label: 'PAS DU TOUT D\'ACCORD', color: 'orange-red', bgClass: 'bg-orange-600', textClass: 'text-orange-700 dark:text-orange-400', ringClass: 'ring-orange-600' },
|
|
||||||
{ level: 2, label: 'PAS D\'ACCORD', color: 'orange', bgClass: 'bg-orange-400', textClass: 'text-orange-600 dark:text-orange-300', ringClass: 'ring-orange-400' },
|
|
||||||
{ level: 3, label: 'NEUTRE', color: 'gray', bgClass: 'bg-gray-400', textClass: 'text-gray-600 dark:text-gray-400', ringClass: 'ring-gray-400' },
|
|
||||||
{ level: 4, label: 'D\'ACCORD', color: 'light-green', bgClass: 'bg-green-400', textClass: 'text-green-600 dark:text-green-400', ringClass: 'ring-green-400' },
|
|
||||||
{ level: 5, label: 'TOUT A FAIT D\'ACCORD', color: 'green', bgClass: 'bg-green-600', textClass: 'text-green-700 dark:text-green-300', ringClass: 'ring-green-600' },
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Check if the current user has already voted in this session. */
|
|
||||||
const userVote = computed(() => {
|
|
||||||
if (!auth.identity) return null
|
|
||||||
return votes.votes.find(v => v.voter_id === auth.identity!.id && v.is_active)
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Initialize selected level from existing vote. */
|
|
||||||
watchEffect(() => {
|
|
||||||
if (userVote.value?.nuanced_level !== undefined && userVote.value?.nuanced_level !== null) {
|
|
||||||
selectedLevel.value = userVote.value.nuanced_level
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const isDisabled = computed(() => {
|
|
||||||
return props.disabled || !auth.isAuthenticated || !votes.isSessionOpen || submitting.value
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Whether the comment field should be shown (negative votes). */
|
|
||||||
const showComment = computed(() => {
|
|
||||||
return selectedLevel.value !== null && selectedLevel.value <= 2
|
|
||||||
})
|
|
||||||
|
|
||||||
function selectLevel(level: number) {
|
|
||||||
if (isDisabled.value) return
|
|
||||||
selectedLevel.value = level
|
|
||||||
showConfirm.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmVote() {
|
|
||||||
if (selectedLevel.value === null) return
|
|
||||||
showConfirm.value = false
|
|
||||||
submitting.value = true
|
|
||||||
|
|
||||||
const voteValue = selectedLevel.value >= 3 ? 'pour' : 'contre'
|
|
||||||
|
|
||||||
try {
|
|
||||||
await votes.submitVote({
|
|
||||||
session_id: props.sessionId,
|
|
||||||
vote_value: voteValue,
|
|
||||||
nuanced_level: selectedLevel.value,
|
|
||||||
comment: showComment.value && comment.value.trim() ? comment.value.trim() : null,
|
|
||||||
signature: 'pending',
|
|
||||||
signed_payload: 'pending',
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelVote() {
|
|
||||||
showConfirm.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLevelLabel(level: number): string {
|
|
||||||
return levels.find(l => l.level === level)?.label ?? ''
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Level buttons -->
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
|
||||||
<button
|
|
||||||
v-for="lvl in levels"
|
|
||||||
:key="lvl.level"
|
|
||||||
:disabled="isDisabled"
|
|
||||||
class="relative flex flex-col items-center p-4 rounded-lg border-2 transition-all cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
:class="[
|
|
||||||
selectedLevel === lvl.level || userVote?.nuanced_level === lvl.level
|
|
||||||
? `ring-3 ${lvl.ringClass} border-transparent`
|
|
||||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600',
|
|
||||||
]"
|
|
||||||
@click="selectLevel(lvl.level)"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-lg mb-2"
|
|
||||||
:class="lvl.bgClass"
|
|
||||||
>
|
|
||||||
{{ lvl.level }}
|
|
||||||
</div>
|
|
||||||
<span class="text-xs font-medium text-center leading-tight" :class="lvl.textClass">
|
|
||||||
{{ lvl.label }}
|
|
||||||
</span>
|
|
||||||
<!-- Selected indicator -->
|
|
||||||
<div
|
|
||||||
v-if="selectedLevel === lvl.level || userVote?.nuanced_level === lvl.level"
|
|
||||||
class="absolute -top-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center text-white text-xs"
|
|
||||||
:class="lvl.bgClass"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-check" class="w-3 h-3" />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Comment for negative votes -->
|
|
||||||
<div v-if="showComment" class="space-y-2">
|
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Commentaire (optionnel pour les votes negatifs)
|
|
||||||
</label>
|
|
||||||
<UTextarea
|
|
||||||
v-model="comment"
|
|
||||||
placeholder="Expliquez votre position..."
|
|
||||||
:rows="3"
|
|
||||||
:disabled="isDisabled"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status messages -->
|
|
||||||
<div v-if="!auth.isAuthenticated" class="text-sm text-amber-600 dark:text-amber-400 text-center">
|
|
||||||
Connectez-vous pour voter
|
|
||||||
</div>
|
|
||||||
<div v-else-if="!votes.isSessionOpen" class="text-sm text-gray-500 text-center">
|
|
||||||
Cette session de vote est fermee
|
|
||||||
</div>
|
|
||||||
<div v-else-if="userVote" class="text-sm text-green-600 dark:text-green-400 text-center">
|
|
||||||
Vous avez vote : {{ getLevelLabel(userVote.nuanced_level ?? 0) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error display -->
|
|
||||||
<div v-if="votes.error" class="text-sm text-red-500 text-center">
|
|
||||||
{{ votes.error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Confirmation modal -->
|
|
||||||
<UModal v-model:open="showConfirm">
|
|
||||||
<template #content>
|
|
||||||
<div class="p-6 space-y-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
Confirmation du vote
|
|
||||||
</h3>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">
|
|
||||||
Vous etes sur le point de voter :
|
|
||||||
<strong>{{ getLevelLabel(selectedLevel ?? 0) }}</strong> (niveau {{ selectedLevel }}).
|
|
||||||
Cette action est definitive.
|
|
||||||
</p>
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<UButton variant="ghost" color="neutral" @click="cancelVote">
|
|
||||||
Annuler
|
|
||||||
</UButton>
|
|
||||||
<UButton color="primary" :loading="submitting" @click="confirmVote">
|
|
||||||
Confirmer le vote
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UModal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
/**
|
|
||||||
* Composable for making authenticated API calls to the Glibredecision backend.
|
|
||||||
*
|
|
||||||
* Uses the runtime config `apiBase` and automatically injects the Bearer token
|
|
||||||
* from the auth store when available.
|
|
||||||
*
|
|
||||||
* Production-grade features:
|
|
||||||
* - Error interceptor with user-friendly French messages
|
|
||||||
* - Retry logic with exponential backoff for 5xx errors
|
|
||||||
* - Request timeout (30s default)
|
|
||||||
* - AbortController support for cancellation
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Map of HTTP status codes to user-friendly French error messages. */
|
|
||||||
const HTTP_ERROR_MESSAGES: Record<number, string> = {
|
|
||||||
401: 'Session expirée, veuillez vous reconnecter',
|
|
||||||
403: 'Accès non autorisé',
|
|
||||||
404: 'Ressource introuvable',
|
|
||||||
409: 'Conflit avec une ressource existante',
|
|
||||||
422: 'Données invalides',
|
|
||||||
429: 'Trop de requêtes, veuillez patienter',
|
|
||||||
500: 'Erreur serveur, veuillez réessayer',
|
|
||||||
502: 'Service temporairement indisponible',
|
|
||||||
503: 'Service en maintenance, veuillez réessayer',
|
|
||||||
504: 'Délai de réponse dépassé',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Default request timeout in milliseconds. */
|
|
||||||
const DEFAULT_TIMEOUT_MS = 30_000
|
|
||||||
|
|
||||||
/** Maximum number of retry attempts for 5xx errors. */
|
|
||||||
const MAX_RETRIES = 3
|
|
||||||
|
|
||||||
/** Base delay for exponential backoff in milliseconds. */
|
|
||||||
const BASE_BACKOFF_MS = 1_000
|
|
||||||
|
|
||||||
export interface ApiOptions extends Record<string, any> {
|
|
||||||
/** Custom timeout in milliseconds (default: 30000). */
|
|
||||||
timeout?: number
|
|
||||||
/** External AbortController for request cancellation. */
|
|
||||||
signal?: AbortSignal
|
|
||||||
/** Disable automatic retry for this request. */
|
|
||||||
noRetry?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiError {
|
|
||||||
status: number
|
|
||||||
message: string
|
|
||||||
detail?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a user-friendly error message from an HTTP status code.
|
|
||||||
*/
|
|
||||||
function resolveErrorMessage(status: number, fallback?: string): string {
|
|
||||||
return HTTP_ERROR_MESSAGES[status] || fallback || 'Une erreur inattendue est survenue'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for the specified duration in milliseconds.
|
|
||||||
*/
|
|
||||||
function delay(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether an HTTP status code is retryable (5xx server errors).
|
|
||||||
*/
|
|
||||||
function isRetryable(status: number): boolean {
|
|
||||||
return status >= 500 && status < 600
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useApi() {
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Perform a typed fetch against the backend API.
|
|
||||||
*
|
|
||||||
* @param path - API path relative to apiBase, e.g. "/documents"
|
|
||||||
* @param options - $fetch options (method, body, query, headers, timeout, signal, noRetry)
|
|
||||||
* @returns Typed response
|
|
||||||
* @throws ApiError with status, message, and optional detail
|
|
||||||
*/
|
|
||||||
async function $api<T>(path: string, options: ApiOptions = {}): Promise<T> {
|
|
||||||
const {
|
|
||||||
timeout = DEFAULT_TIMEOUT_MS,
|
|
||||||
signal: externalSignal,
|
|
||||||
noRetry = false,
|
|
||||||
...fetchOptions
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {}
|
|
||||||
if (auth.token) {
|
|
||||||
headers.Authorization = `Bearer ${auth.token}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxAttempts = noRetry ? 1 : MAX_RETRIES
|
|
||||||
let lastError: any = null
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
||||||
// Create an AbortController for timeout management
|
|
||||||
const controller = new AbortController()
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
|
||||||
|
|
||||||
// If an external signal is provided, forward its abort
|
|
||||||
let externalAbortHandler: (() => void) | null = null
|
|
||||||
if (externalSignal) {
|
|
||||||
if (externalSignal.aborted) {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
throw _createApiError(0, 'Requête annulée')
|
|
||||||
}
|
|
||||||
externalAbortHandler = () => controller.abort()
|
|
||||||
externalSignal.addEventListener('abort', externalAbortHandler, { once: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await $fetch<T>(`${config.public.apiBase}${path}`, {
|
|
||||||
...fetchOptions,
|
|
||||||
headers: { ...headers, ...fetchOptions.headers },
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
} catch (err: any) {
|
|
||||||
lastError = err
|
|
||||||
|
|
||||||
const status = err?.response?.status || err?.status || 0
|
|
||||||
|
|
||||||
// Handle 401: auto-logout for expired sessions
|
|
||||||
if (status === 401 && auth.token) {
|
|
||||||
auth.token = null
|
|
||||||
auth.identity = null
|
|
||||||
auth._clearToken()
|
|
||||||
navigateTo('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle abort (timeout or external cancellation)
|
|
||||||
if (err?.name === 'AbortError' || controller.signal.aborted) {
|
|
||||||
if (externalSignal?.aborted) {
|
|
||||||
throw _createApiError(0, 'Requête annulée')
|
|
||||||
}
|
|
||||||
throw _createApiError(0, 'Délai de réponse dépassé')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry only for 5xx errors and if we have remaining attempts
|
|
||||||
if (isRetryable(status) && attempt < maxAttempts) {
|
|
||||||
const backoffMs = BASE_BACKOFF_MS * Math.pow(2, attempt - 1)
|
|
||||||
await delay(backoffMs)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build and throw a structured error
|
|
||||||
const detail = err?.data?.detail || err?.message || undefined
|
|
||||||
const message = resolveErrorMessage(status, detail)
|
|
||||||
throw _createApiError(status, message, detail)
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
if (externalAbortHandler && externalSignal) {
|
|
||||||
externalSignal.removeEventListener('abort', externalAbortHandler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: should not reach here, but handle gracefully
|
|
||||||
const fallbackStatus = lastError?.response?.status || lastError?.status || 0
|
|
||||||
const fallbackDetail = lastError?.data?.detail || lastError?.message || undefined
|
|
||||||
throw _createApiError(fallbackStatus, resolveErrorMessage(fallbackStatus, fallbackDetail), fallbackDetail)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a structured ApiError object.
|
|
||||||
*/
|
|
||||||
function _createApiError(status: number, message: string, detail?: string): ApiError {
|
|
||||||
const error: ApiError = { status, message }
|
|
||||||
if (detail) error.detail = detail
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an AbortController for manual request cancellation.
|
|
||||||
* Useful for component unmount cleanup.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const { controller, signal } = createAbortController()
|
|
||||||
* await $api('/documents', { signal })
|
|
||||||
* // On unmount:
|
|
||||||
* controller.abort()
|
|
||||||
*/
|
|
||||||
function createAbortController() {
|
|
||||||
const controller = new AbortController()
|
|
||||||
return { controller, signal: controller.signal }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { $api, createAbortController }
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import type { Ref } from 'vue'
|
|
||||||
|
|
||||||
export interface Mood {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
description: string
|
|
||||||
icon: string
|
|
||||||
color: string
|
|
||||||
isDark: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'glibredecision_mood'
|
|
||||||
|
|
||||||
const moods: Mood[] = [
|
|
||||||
{ id: 'peps', label: 'Peps', description: 'Chaud et tonique', icon: 'i-lucide-sun', color: '#d44a10', isDark: false },
|
|
||||||
{ id: 'zen', label: 'Zen', description: 'Nature vivante', icon: 'i-lucide-leaf', color: '#2e8b48', isDark: false },
|
|
||||||
{ id: 'chagrine', label: 'Chagrine', description: 'Nuit profonde', icon: 'i-lucide-moon', color: '#6488d8', isDark: true },
|
|
||||||
{ id: 'grave', label: 'Grave', description: 'Ambre mineral', icon: 'i-lucide-shield', color: '#d8a030', isDark: true },
|
|
||||||
]
|
|
||||||
|
|
||||||
const currentMood: Ref<string> = ref('peps')
|
|
||||||
|
|
||||||
function applyMood(moodId: string) {
|
|
||||||
if (import.meta.server) return
|
|
||||||
|
|
||||||
const mood = moods.find(m => m.id === moodId)
|
|
||||||
if (!mood) return
|
|
||||||
|
|
||||||
const html = document.documentElement
|
|
||||||
|
|
||||||
// Remove all existing mood classes
|
|
||||||
moods.forEach(m => html.classList.remove(`mood-${m.id}`))
|
|
||||||
|
|
||||||
// Add the new mood class
|
|
||||||
html.classList.add(`mood-${moodId}`)
|
|
||||||
|
|
||||||
// Sync color-mode (light/dark) via Nuxt's useColorMode
|
|
||||||
const colorMode = useColorMode()
|
|
||||||
colorMode.preference = mood.isDark ? 'dark' : 'light'
|
|
||||||
|
|
||||||
// Persist choice
|
|
||||||
localStorage.setItem(STORAGE_KEY, moodId)
|
|
||||||
|
|
||||||
currentMood.value = moodId
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMood(moodId: string) {
|
|
||||||
applyMood(moodId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function initMood() {
|
|
||||||
if (import.meta.server) return
|
|
||||||
|
|
||||||
const saved = localStorage.getItem(STORAGE_KEY)
|
|
||||||
const moodId = saved && moods.some(m => m.id === saved) ? saved : 'peps'
|
|
||||||
applyMood(moodId)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMood() {
|
|
||||||
return {
|
|
||||||
currentMood: readonly(currentMood),
|
|
||||||
moods,
|
|
||||||
setMood,
|
|
||||||
initMood,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* Composable for toast notifications using Nuxt UI.
|
|
||||||
*
|
|
||||||
* Provides typed notification helpers with French messages and
|
|
||||||
* integration with WebSocket events for real-time notifications.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type NotificationType = 'success' | 'error' | 'warning' | 'info'
|
|
||||||
|
|
||||||
interface NotifyOptions {
|
|
||||||
/** Toast title. */
|
|
||||||
title: string
|
|
||||||
/** Toast description (optional). */
|
|
||||||
description?: string
|
|
||||||
/** Notification type. */
|
|
||||||
type?: NotificationType
|
|
||||||
/** Auto-close duration in milliseconds (default: 5000). */
|
|
||||||
duration?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map notification types to Nuxt UI toast color props. */
|
|
||||||
const TYPE_COLORS: Record<NotificationType, string> = {
|
|
||||||
success: 'success',
|
|
||||||
error: 'error',
|
|
||||||
warning: 'warning',
|
|
||||||
info: 'info',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map notification types to Lucide icon names. */
|
|
||||||
const TYPE_ICONS: Record<NotificationType, string> = {
|
|
||||||
success: 'i-lucide-check-circle',
|
|
||||||
error: 'i-lucide-alert-circle',
|
|
||||||
warning: 'i-lucide-alert-triangle',
|
|
||||||
info: 'i-lucide-info',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Default duration for toasts (ms). */
|
|
||||||
const DEFAULT_DURATION = 5_000
|
|
||||||
|
|
||||||
export function useNotifications() {
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show a toast notification.
|
|
||||||
*
|
|
||||||
* @param options - Notification options (title, description, type, duration)
|
|
||||||
*/
|
|
||||||
function notify(options: NotifyOptions): void
|
|
||||||
function notify(title: string, description?: string, type?: NotificationType): void
|
|
||||||
function notify(
|
|
||||||
titleOrOptions: string | NotifyOptions,
|
|
||||||
description?: string,
|
|
||||||
type?: NotificationType,
|
|
||||||
): void {
|
|
||||||
let opts: NotifyOptions
|
|
||||||
|
|
||||||
if (typeof titleOrOptions === 'string') {
|
|
||||||
opts = {
|
|
||||||
title: titleOrOptions,
|
|
||||||
description,
|
|
||||||
type: type || 'info',
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
opts = titleOrOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifType = opts.type || 'info'
|
|
||||||
|
|
||||||
toast.add({
|
|
||||||
title: opts.title,
|
|
||||||
description: opts.description,
|
|
||||||
icon: TYPE_ICONS[notifType],
|
|
||||||
color: TYPE_COLORS[notifType] as any,
|
|
||||||
duration: opts.duration ?? DEFAULT_DURATION,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show a success toast.
|
|
||||||
*/
|
|
||||||
function notifySuccess(message: string, description?: string): void {
|
|
||||||
notify({
|
|
||||||
title: message,
|
|
||||||
description,
|
|
||||||
type: 'success',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show an error toast.
|
|
||||||
*/
|
|
||||||
function notifyError(message: string, description?: string): void {
|
|
||||||
notify({
|
|
||||||
title: message,
|
|
||||||
description,
|
|
||||||
type: 'error',
|
|
||||||
duration: 8_000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show a warning toast.
|
|
||||||
*/
|
|
||||||
function notifyWarning(message: string, description?: string): void {
|
|
||||||
notify({
|
|
||||||
title: message,
|
|
||||||
description,
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show an info toast.
|
|
||||||
*/
|
|
||||||
function notifyInfo(message: string, description?: string): void {
|
|
||||||
notify({
|
|
||||||
title: message,
|
|
||||||
description,
|
|
||||||
type: 'info',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup WebSocket event listeners that auto-show notifications.
|
|
||||||
* Call this once in app.vue or a layout component.
|
|
||||||
*/
|
|
||||||
function setupWsNotifications(wsComposable: ReturnType<typeof useWebSocket>): void {
|
|
||||||
wsComposable.onVoteSubmitted((data) => {
|
|
||||||
notifyInfo(
|
|
||||||
'Nouveau vote enregistre',
|
|
||||||
data?.session_title || 'Un vote a ete soumis dans une session active.',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
wsComposable.onDecisionAdvanced((data) => {
|
|
||||||
notifySuccess(
|
|
||||||
'Decision avancee',
|
|
||||||
data?.title
|
|
||||||
? `La decision "${data.title}" est passee a l'etape suivante.`
|
|
||||||
: 'Une decision a progresse dans son processus.',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
wsComposable.onMandateUpdated((data) => {
|
|
||||||
notifyInfo(
|
|
||||||
'Mandat mis a jour',
|
|
||||||
data?.title
|
|
||||||
? `Le mandat "${data.title}" a ete modifie.`
|
|
||||||
: 'Un mandat a ete mis a jour.',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
wsComposable.onDocumentChanged((data) => {
|
|
||||||
notifyInfo(
|
|
||||||
'Document modifie',
|
|
||||||
data?.title
|
|
||||||
? `Le document "${data.title}" a ete modifie.`
|
|
||||||
: 'Un document de reference a ete modifie.',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
wsComposable.onSanctuaryArchived((data) => {
|
|
||||||
notifySuccess(
|
|
||||||
'Document archive au sanctuaire',
|
|
||||||
data?.title
|
|
||||||
? `"${data.title}" a ete ancre sur IPFS.`
|
|
||||||
: 'Un document a ete archive de maniere immuable.',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
notify,
|
|
||||||
notifySuccess,
|
|
||||||
notifyError,
|
|
||||||
notifyWarning,
|
|
||||||
notifyInfo,
|
|
||||||
setupWsNotifications,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user