From 7cfc7ea35217e2b7c9f74a82a49e72d2fe0a4a0a Mon Sep 17 00:00:00 2001 From: Yvv Date: Tue, 11 Aug 2026 14:09:05 +0200 Subject: [PATCH] v2 : nettoyage des orphelins v1 + documentation livrable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 43 fichiers v1 supprimés (composants documents/protocols/sanctuary/toolbox, stores auth/documents/groups/mandates/organizations/protocols/votes, composables api/notifications/formula/websocket, utils doublons du moteur) - nuxt.config épuré (polkadot retiré, KaTeX et apiBase gardés), meta v2 - README.md, CONTRIBUTING.md, CLAUDE.md réécrits pour la v2 - Build zéro erreur, 342/342 tests verts Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 122 ++-- CONTRIBUTING.md | 97 ++- README.md | 117 ++- docs/Mon nom est personne.pdf | Bin 0 -> 1502902 bytes docs/Une économie du don.pdf | Bin 0 -> 780341 bytes frontend/app/components/SectionLayout.vue | 373 ---------- frontend/app/components/WorkspaceSelector.vue | 252 ------- frontend/app/components/common/DiffView.vue | 51 -- .../app/components/common/ErrorBoundary.vue | 70 -- .../app/components/common/LoadingSkeleton.vue | 81 --- .../app/components/common/MoodSwitcher.vue | 53 -- .../app/components/common/OfflineBanner.vue | 95 --- .../app/components/common/StatusBadge.vue | 73 -- .../app/components/common/ToolboxVignette.vue | 183 ----- .../app/components/documents/DocumentList.vue | 88 --- .../components/documents/DocumentPreview.vue | 347 --------- .../app/components/documents/DocumentTuto.vue | 126 ---- .../components/documents/EngagementCard.vue | 303 -------- .../app/components/documents/GenesisBlock.vue | 489 ------------- .../components/documents/InertiaSlider.vue | 419 ----------- .../app/components/documents/ItemCard.vue | 89 --- .../components/documents/ItemVersionDiff.vue | 95 --- .../components/documents/MiniVoteBoard.vue | 240 ------- .../components/protocols/FormulaEditor.vue | 243 ------- .../protocols/ModeParamsDisplay.vue | 61 -- .../components/protocols/ProtocolPicker.vue | 64 -- .../app/components/sanctuary/ChainAnchor.vue | 33 - .../app/components/sanctuary/IPFSLink.vue | 38 - .../components/sanctuary/SanctuaryEntry.vue | 171 ----- .../app/components/toolbox/ContextMapper.vue | 659 ----------------- .../app/components/toolbox/SocioElection.vue | 666 ------------------ .../app/components/toolbox/ToolboxSection.vue | 90 --- .../components/toolbox/WorkflowMilestones.vue | 551 --------------- .../app/components/votes/FormulaDisplay.vue | 110 --- .../app/components/votes/ThresholdGauge.vue | 114 --- frontend/app/composables/useApi.ts | 200 ------ frontend/app/composables/useNotifications.ts | 180 ----- frontend/app/composables/useVoteFormula.ts | 98 --- frontend/app/composables/useWebSocket.ts | 363 ---------- frontend/app/stores/auth.ts | 219 ------ frontend/app/stores/documents.ts | 313 -------- frontend/app/stores/groups.ts | 109 --- frontend/app/stores/mandates.ts | 219 ------ frontend/app/stores/organizations.ts | 71 -- frontend/app/stores/protocols.ts | 228 ------ frontend/app/stores/votes.ts | 297 -------- frontend/app/utils/mode-params.ts | 164 ----- frontend/app/utils/threshold.ts | 84 --- frontend/nuxt.config.ts | 13 +- 49 files changed, 236 insertions(+), 8885 deletions(-) create mode 100644 docs/Mon nom est personne.pdf create mode 100644 docs/Une économie du don.pdf delete mode 100644 frontend/app/components/SectionLayout.vue delete mode 100644 frontend/app/components/WorkspaceSelector.vue delete mode 100644 frontend/app/components/common/DiffView.vue delete mode 100644 frontend/app/components/common/ErrorBoundary.vue delete mode 100644 frontend/app/components/common/LoadingSkeleton.vue delete mode 100644 frontend/app/components/common/MoodSwitcher.vue delete mode 100644 frontend/app/components/common/OfflineBanner.vue delete mode 100644 frontend/app/components/common/StatusBadge.vue delete mode 100644 frontend/app/components/common/ToolboxVignette.vue delete mode 100644 frontend/app/components/documents/DocumentList.vue delete mode 100644 frontend/app/components/documents/DocumentPreview.vue delete mode 100644 frontend/app/components/documents/DocumentTuto.vue delete mode 100644 frontend/app/components/documents/EngagementCard.vue delete mode 100644 frontend/app/components/documents/GenesisBlock.vue delete mode 100644 frontend/app/components/documents/InertiaSlider.vue delete mode 100644 frontend/app/components/documents/ItemCard.vue delete mode 100644 frontend/app/components/documents/ItemVersionDiff.vue delete mode 100644 frontend/app/components/documents/MiniVoteBoard.vue delete mode 100644 frontend/app/components/protocols/FormulaEditor.vue delete mode 100644 frontend/app/components/protocols/ModeParamsDisplay.vue delete mode 100644 frontend/app/components/protocols/ProtocolPicker.vue delete mode 100644 frontend/app/components/sanctuary/ChainAnchor.vue delete mode 100644 frontend/app/components/sanctuary/IPFSLink.vue delete mode 100644 frontend/app/components/sanctuary/SanctuaryEntry.vue delete mode 100644 frontend/app/components/toolbox/ContextMapper.vue delete mode 100644 frontend/app/components/toolbox/SocioElection.vue delete mode 100644 frontend/app/components/toolbox/ToolboxSection.vue delete mode 100644 frontend/app/components/toolbox/WorkflowMilestones.vue delete mode 100644 frontend/app/components/votes/FormulaDisplay.vue delete mode 100644 frontend/app/components/votes/ThresholdGauge.vue delete mode 100644 frontend/app/composables/useApi.ts delete mode 100644 frontend/app/composables/useNotifications.ts delete mode 100644 frontend/app/composables/useVoteFormula.ts delete mode 100644 frontend/app/composables/useWebSocket.ts delete mode 100644 frontend/app/stores/auth.ts delete mode 100644 frontend/app/stores/documents.ts delete mode 100644 frontend/app/stores/groups.ts delete mode 100644 frontend/app/stores/mandates.ts delete mode 100644 frontend/app/stores/organizations.ts delete mode 100644 frontend/app/stores/protocols.ts delete mode 100644 frontend/app/stores/votes.ts delete mode 100644 frontend/app/utils/mode-params.ts delete mode 100644 frontend/app/utils/threshold.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8c01bfb..f0a41b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,75 +1,73 @@ # libreDecision -Boîte à outils de gouvernance collective pour la communauté Duniter/G1. -Documents modulaires sous vote permanent + protocoles de vote + mandats. -Architecture marque blanche — vocation à être intégré dans sweethomeCloud et librodrome. +Boîte à outils de la première démocratie — décider ensemble, du trio à la population. +v2 local-first : 6 routes de décision, 5 modalités, documents sous vote permanent, mandats, +observatoire. Marque blanche par construction (seeds Ğ1 + Atelier du Canal). +**Référence produit : `docs/dev/BLUEPRINT-V2.md`** — toute décision de comportement s'y adosse. ## Protocole de début de session 1. `git pull --rebase origin main` -2. Si des migrations DB sont attendues : `cd backend && alembic upgrade head` -3. Si l'objectif de la session n'est pas précisé, le demander +2. Si l'objectif de la session n'est pas précisé, le demander ## Stack -- **Frontend** : Nuxt 4 (Vue 3, TypeScript) + Nuxt UI v3 + Pinia + UnoCSS ; package manager : npm -- **Backend** : Python FastAPI + SQLAlchemy 2.0 async + PostgreSQL asyncpg ; migrations Alembic -- **Auth** : Duniter V2 Ed25519 challenge-response (substrate-interface — stub en dev) -- **Sanctuaire** : IPFS kubo + hash on-chain (system.remark) — TODO sprint 2 -- Déploiement : Docker multi-stage + Traefik (postgres + backend + frontend + ipfs) ; CI Woodpecker +- **Frontend** : Nuxt 4 (Vue 3, TypeScript, SPA — SSR off) + Nuxt UI v3 + Pinia + UnoCSS ; + package manager : npm ; port **3002** strict +- **Local-first** : IndexedDB (idb-keyval) via `app/data/persistence.ts` — une clé + `ld2:` par collectif, export/import de bundles JSON, seeds par le même chemin +- **Backend v1** (FastAPI + SQLAlchemy async, port 8002) : conservé pour la **future synchro** + — non requis pour le dev frontend ; `runtimeConfig.public.apiBase` gardé dans nuxt.config + en prévision +- Déploiement : Docker multi-stage + Traefik ; CI Woodpecker ## Structure ``` frontend/ app/ - components/ # composants Vue - layouts/ # layouts Nuxt - pages/ # routing file-based (9 pages sprint 1) - composables/ # (1 composable sprint 1) - stores/ # 5 Pinia stores (auth, ...) - assets/css/ - moods.css # système de palettes (.mood-* sur ) - utils/ # (2 utils sprint 1) - nuxt.config.ts # port 3002, host 0.0.0.0, apiBase via NUXT_PUBLIC_API_BASE -backend/ - app/ - routers/ # 8 routers : auth, communes, documents, protocols, votes, ... - services/ # 6 services - engine/ # 5 modules : formule inertie, critères Smith/TechComm, médiane - models/ # 14 tables SQLAlchemy - alembic/versions/ # migrations - tests/ # 186 tests (63 intégration TDD sprint 1) - seed.py # Engagement Certification (33 items) + Forgeron (51 items) + Runtime Upgrade -docker/ - docker-compose.yml # postgres + backend + frontend + ipfs - backend.Dockerfile - frontend.Dockerfile -docs/content/ # 7 docs dev + 8 docs user -public/ - hexagram-tsing.svg # sceau 井 (embossed) - hexagram-tsing-flat.svg + engine/ # moteurs PURS (zéro dépendance Nuxt) : threshold (+electionResult), + # nuanced, modeParams, parametric (+crystallize), settings, state (canTransition), + # triage, impact — import UNIQUEMENT via le barrel ~/engine + data/ + persistence.ts # IndexedDB local-first + templates.ts # 7 gabarits de collectif (invariant : protocole Consentement partout) + seeds/ # duniter-g1.bundle.json (324 Ko) + atelier-du-canal.bundle.json + stores/ + collective.ts # le tenant : collectif actif, état complet, cycle de vie des bundles + decisions.ts # façade d'actions sans état propre (LWW updatedAt, persist debounced) + composables/ # useMood (useLibreMood), useFeed (le Fil = sélecteur pur), useSearch + components/ # par domaine : chemin/ decisions/ votes/ texts/ mandates/ feed/ + # onboarding/ common/ (primitives Ld*) + pages/ # 14 routes : / · decider · decisions (+[id], vote, observatoire) · + # textes (+[slug], formules) · mandats (+[id], nouveau) · + # creer (layout bare) · donnees + lexicon.ts # SOURCE UNIQUE des libellés FR + FORBIDDEN_UI_TERMS + types/domain.ts # le noyau du modèle + tests/ # 342 tests vitest : engine/, stores/, seeds/ (non-perte), lexicon.spec.ts +backend/ # v1 FastAPI conservé pour la synchro future — ne pas développer dessus + scripts/export_seed_bundle.py # extrait seed.py → duniter-g1.bundle.json (source vivante = bundle) +docker/ # compose + Dockerfiles +docs/dev/BLUEPRINT-V2.md # la référence produit v2 +public/hexagram-tsing*.svg # sceau 井 (#48 Tsing) ``` ## Données runtime -- **postgres-data** : volume Docker PostgreSQL — jamais écrasé par les builds -- **ipfs-data** : volume Docker IPFS kubo -- `.env` à la racine : `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `SECRET_KEY`, `DOMAIN`, `DUNITER_RPC_URL` +- **postgres-data** / **ipfs-data** : volumes Docker du backend v1 — jamais écrasés +- `.env` racine : secrets backend — jamais commité ## Commandes ```bash -# Backend -cd backend && . venv/bin/activate -uvicorn app.main:app --port 8002 --host 0.0.0.0 --reload -pytest tests/ -v -alembic upgrade head -python seed.py # reseed Engagement Certification + Forgeron + Runtime Upgrade - -# Frontend +# Frontend (tout le dev v2) cd frontend && npm run dev # :3002 -npm run build +npm test # vitest — 342 tests, référence à maintenir +npm run build # zéro erreur exigé avant commit + +# Seeds : le bundle est la source vivante ; seed.py (backend) est remplacé par +cd backend && .venv/bin/python scripts/export_seed_bundle.py # régénère duniter-g1.bundle.json +# → test de non-perte bloquant : tests/seeds/duniter-g1.spec.ts # Docker docker compose -f docker/docker-compose.yml up @@ -78,11 +76,25 @@ docker compose -f docker/docker-compose.yml up ## Conventions / pièges - **UI français, code anglais** (variables, commentaires, docstrings) -- **API** : préfixe `/api/v1/`, Pydantic v2 pour tous les schémas, async partout (SQLAlchemy + FastAPI) -- **Auth** : `get_current_admin` (24h), `get_current_citizen` (4h), `require_super_admin` -- **Formule inertie** : `Result = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)` — voir `backend/app/engine/` -- **Mood system** : `useMood.ts` synchronise `colorMode.preference` avec la palette — **jamais** de `:global()` dans ` diff --git a/frontend/app/components/WorkspaceSelector.vue b/frontend/app/components/WorkspaceSelector.vue deleted file mode 100644 index 0ba4393..0000000 --- a/frontend/app/components/WorkspaceSelector.vue +++ /dev/null @@ -1,252 +0,0 @@ - - - - - diff --git a/frontend/app/components/common/DiffView.vue b/frontend/app/components/common/DiffView.vue deleted file mode 100644 index e8e93c5..0000000 --- a/frontend/app/components/common/DiffView.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - diff --git a/frontend/app/components/common/ErrorBoundary.vue b/frontend/app/components/common/ErrorBoundary.vue deleted file mode 100644 index 94c5ed0..0000000 --- a/frontend/app/components/common/ErrorBoundary.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - diff --git a/frontend/app/components/common/LoadingSkeleton.vue b/frontend/app/components/common/LoadingSkeleton.vue deleted file mode 100644 index 07ccaa5..0000000 --- a/frontend/app/components/common/LoadingSkeleton.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - diff --git a/frontend/app/components/common/MoodSwitcher.vue b/frontend/app/components/common/MoodSwitcher.vue deleted file mode 100644 index 21d8870..0000000 --- a/frontend/app/components/common/MoodSwitcher.vue +++ /dev/null @@ -1,53 +0,0 @@ - - - - - diff --git a/frontend/app/components/common/OfflineBanner.vue b/frontend/app/components/common/OfflineBanner.vue deleted file mode 100644 index 507d227..0000000 --- a/frontend/app/components/common/OfflineBanner.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - - - diff --git a/frontend/app/components/common/StatusBadge.vue b/frontend/app/components/common/StatusBadge.vue deleted file mode 100644 index c367239..0000000 --- a/frontend/app/components/common/StatusBadge.vue +++ /dev/null @@ -1,73 +0,0 @@ - - - diff --git a/frontend/app/components/common/ToolboxVignette.vue b/frontend/app/components/common/ToolboxVignette.vue deleted file mode 100644 index 609a255..0000000 --- a/frontend/app/components/common/ToolboxVignette.vue +++ /dev/null @@ -1,183 +0,0 @@ - - - - - diff --git a/frontend/app/components/documents/DocumentList.vue b/frontend/app/components/documents/DocumentList.vue deleted file mode 100644 index 2202d1e..0000000 --- a/frontend/app/components/documents/DocumentList.vue +++ /dev/null @@ -1,88 +0,0 @@ - - - diff --git a/frontend/app/components/documents/DocumentPreview.vue b/frontend/app/components/documents/DocumentPreview.vue deleted file mode 100644 index a941e1b..0000000 --- a/frontend/app/components/documents/DocumentPreview.vue +++ /dev/null @@ -1,347 +0,0 @@ - - - - - diff --git a/frontend/app/components/documents/DocumentTuto.vue b/frontend/app/components/documents/DocumentTuto.vue deleted file mode 100644 index 127f6a7..0000000 --- a/frontend/app/components/documents/DocumentTuto.vue +++ /dev/null @@ -1,126 +0,0 @@ - - - - - diff --git a/frontend/app/components/documents/EngagementCard.vue b/frontend/app/components/documents/EngagementCard.vue deleted file mode 100644 index c86d772..0000000 --- a/frontend/app/components/documents/EngagementCard.vue +++ /dev/null @@ -1,303 +0,0 @@ - - - - - diff --git a/frontend/app/components/documents/GenesisBlock.vue b/frontend/app/components/documents/GenesisBlock.vue deleted file mode 100644 index a1e0cc5..0000000 --- a/frontend/app/components/documents/GenesisBlock.vue +++ /dev/null @@ -1,489 +0,0 @@ - - - - - diff --git a/frontend/app/components/documents/InertiaSlider.vue b/frontend/app/components/documents/InertiaSlider.vue deleted file mode 100644 index f3ef091..0000000 --- a/frontend/app/components/documents/InertiaSlider.vue +++ /dev/null @@ -1,419 +0,0 @@ - - - - - diff --git a/frontend/app/components/documents/ItemCard.vue b/frontend/app/components/documents/ItemCard.vue deleted file mode 100644 index 6a0515a..0000000 --- a/frontend/app/components/documents/ItemCard.vue +++ /dev/null @@ -1,89 +0,0 @@ - - - diff --git a/frontend/app/components/documents/ItemVersionDiff.vue b/frontend/app/components/documents/ItemVersionDiff.vue deleted file mode 100644 index 06ea5eb..0000000 --- a/frontend/app/components/documents/ItemVersionDiff.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - diff --git a/frontend/app/components/documents/MiniVoteBoard.vue b/frontend/app/components/documents/MiniVoteBoard.vue deleted file mode 100644 index 759bcd5..0000000 --- a/frontend/app/components/documents/MiniVoteBoard.vue +++ /dev/null @@ -1,240 +0,0 @@ - - - - - diff --git a/frontend/app/components/protocols/FormulaEditor.vue b/frontend/app/components/protocols/FormulaEditor.vue deleted file mode 100644 index d76af9c..0000000 --- a/frontend/app/components/protocols/FormulaEditor.vue +++ /dev/null @@ -1,243 +0,0 @@ - - - diff --git a/frontend/app/components/protocols/ModeParamsDisplay.vue b/frontend/app/components/protocols/ModeParamsDisplay.vue deleted file mode 100644 index 2312d9d..0000000 --- a/frontend/app/components/protocols/ModeParamsDisplay.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - diff --git a/frontend/app/components/protocols/ProtocolPicker.vue b/frontend/app/components/protocols/ProtocolPicker.vue deleted file mode 100644 index 5d8134c..0000000 --- a/frontend/app/components/protocols/ProtocolPicker.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - diff --git a/frontend/app/components/sanctuary/ChainAnchor.vue b/frontend/app/components/sanctuary/ChainAnchor.vue deleted file mode 100644 index aa0df0d..0000000 --- a/frontend/app/components/sanctuary/ChainAnchor.vue +++ /dev/null @@ -1,33 +0,0 @@ - - - diff --git a/frontend/app/components/sanctuary/IPFSLink.vue b/frontend/app/components/sanctuary/IPFSLink.vue deleted file mode 100644 index ec4b30b..0000000 --- a/frontend/app/components/sanctuary/IPFSLink.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/frontend/app/components/sanctuary/SanctuaryEntry.vue b/frontend/app/components/sanctuary/SanctuaryEntry.vue deleted file mode 100644 index beb3b40..0000000 --- a/frontend/app/components/sanctuary/SanctuaryEntry.vue +++ /dev/null @@ -1,171 +0,0 @@ - - - diff --git a/frontend/app/components/toolbox/ContextMapper.vue b/frontend/app/components/toolbox/ContextMapper.vue deleted file mode 100644 index fda470c..0000000 --- a/frontend/app/components/toolbox/ContextMapper.vue +++ /dev/null @@ -1,659 +0,0 @@ - - - - - diff --git a/frontend/app/components/toolbox/SocioElection.vue b/frontend/app/components/toolbox/SocioElection.vue deleted file mode 100644 index 3053502..0000000 --- a/frontend/app/components/toolbox/SocioElection.vue +++ /dev/null @@ -1,666 +0,0 @@ - - - - - diff --git a/frontend/app/components/toolbox/ToolboxSection.vue b/frontend/app/components/toolbox/ToolboxSection.vue deleted file mode 100644 index bf98772..0000000 --- a/frontend/app/components/toolbox/ToolboxSection.vue +++ /dev/null @@ -1,90 +0,0 @@ - - - - - diff --git a/frontend/app/components/toolbox/WorkflowMilestones.vue b/frontend/app/components/toolbox/WorkflowMilestones.vue deleted file mode 100644 index 9dfbcfb..0000000 --- a/frontend/app/components/toolbox/WorkflowMilestones.vue +++ /dev/null @@ -1,551 +0,0 @@ - - - - - diff --git a/frontend/app/components/votes/FormulaDisplay.vue b/frontend/app/components/votes/FormulaDisplay.vue deleted file mode 100644 index 51036a9..0000000 --- a/frontend/app/components/votes/FormulaDisplay.vue +++ /dev/null @@ -1,110 +0,0 @@ - - - diff --git a/frontend/app/components/votes/ThresholdGauge.vue b/frontend/app/components/votes/ThresholdGauge.vue deleted file mode 100644 index 96c06a4..0000000 --- a/frontend/app/components/votes/ThresholdGauge.vue +++ /dev/null @@ -1,114 +0,0 @@ - - - diff --git a/frontend/app/composables/useApi.ts b/frontend/app/composables/useApi.ts deleted file mode 100644 index b00e814..0000000 --- a/frontend/app/composables/useApi.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * Composable for making authenticated API calls to the libreDecision 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 = { - 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 { - /** 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 { - 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() - const orgsStore = useOrganizationsStore() - - /** - * 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(path: string, options: ApiOptions = {}): Promise { - const { - timeout = DEFAULT_TIMEOUT_MS, - signal: externalSignal, - noRetry = false, - ...fetchOptions - } = options - - const headers: Record = {} - if (auth.token) { - headers.Authorization = `Bearer ${auth.token}` - } - if (orgsStore.activeSlug) { - headers['X-Organization'] = orgsStore.activeSlug - } - - 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(`${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 } -} diff --git a/frontend/app/composables/useNotifications.ts b/frontend/app/composables/useNotifications.ts deleted file mode 100644 index a36492e..0000000 --- a/frontend/app/composables/useNotifications.ts +++ /dev/null @@ -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 = { - success: 'success', - error: 'error', - warning: 'warning', - info: 'info', -} - -/** Map notification types to Lucide icon names. */ -const TYPE_ICONS: Record = { - 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): 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, - } -} diff --git a/frontend/app/composables/useVoteFormula.ts b/frontend/app/composables/useVoteFormula.ts deleted file mode 100644 index cefa086..0000000 --- a/frontend/app/composables/useVoteFormula.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Composable for real-time vote formula computation. - * - * Re-exports and wraps the threshold utility functions for reactive use - * in Vue components. Provides convenient methods for threshold calculations, - * inertia factor, required ratio, and adoption checks. - */ -import { wotThreshold, smithThreshold, techcommThreshold } from '~/utils/threshold' - -export interface FormulaParams { - majority_pct: number - base_exponent: number - gradient_exponent: number - constant_base: number -} - -export function useVoteFormula() { - /** - * Compute the WoT threshold for a given set of parameters. - */ - function computeThreshold(wotSize: number, totalVotes: number, params: FormulaParams): number { - return wotThreshold( - wotSize, - totalVotes, - params.majority_pct, - params.base_exponent, - params.gradient_exponent, - params.constant_base, - ) - } - - /** - * Compute the inertia factor: 1 - (T/W)^G - * Ranges from ~1 (low participation) to ~0 (full participation). - */ - function computeInertiaFactor( - totalVotes: number, - wotSize: number, - gradientExponent: number, - ): number { - if (wotSize <= 0 || totalVotes <= 0) return 1.0 - const participationRatio = totalVotes / wotSize - return 1.0 - Math.pow(participationRatio, gradientExponent) - } - - /** - * Compute the required ratio of "pour" votes at a given participation level. - * requiredRatio = M + (1 - M) * inertiaFactor - */ - function computeRequiredRatio( - totalVotes: number, - wotSize: number, - majorityPct: number, - gradientExponent: number, - ): number { - const M = majorityPct / 100 - const inertia = computeInertiaFactor(totalVotes, wotSize, gradientExponent) - return M + (1.0 - M) * inertia - } - - /** - * Check whether a vote is adopted given all criteria. - * A vote is adopted when it passes the WoT threshold AND - * any applicable Smith/TechComm criteria. - */ - function isAdopted( - votesFor: number, - threshold: number, - smithVotesFor?: number, - smithThresholdVal?: number, - techcommVotesFor?: number, - techcommThresholdVal?: number, - ): boolean { - // Main WoT criterion - if (votesFor < threshold) return false - - // Smith criterion (if applicable) - if (smithThresholdVal !== undefined && smithThresholdVal > 0) { - if ((smithVotesFor ?? 0) < smithThresholdVal) return false - } - - // TechComm criterion (if applicable) - if (techcommThresholdVal !== undefined && techcommThresholdVal > 0) { - if ((techcommVotesFor ?? 0) < techcommThresholdVal) return false - } - - return true - } - - return { - computeThreshold, - computeInertiaFactor, - computeRequiredRatio, - isAdopted, - smithThreshold, - techcommThreshold, - } -} diff --git a/frontend/app/composables/useWebSocket.ts b/frontend/app/composables/useWebSocket.ts deleted file mode 100644 index febd25c..0000000 --- a/frontend/app/composables/useWebSocket.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** - * Composable for WebSocket connectivity to receive live updates. - * - * Connects to the backend WS endpoint and allows subscribing to - * individual vote session channels for real-time tally updates. - * - * Production-grade features: - * - Bearer token authentication via query param - * - Heartbeat (ping every 25s, pong expected within 10s) - * - Exponential backoff on reconnect (1s, 2s, 4s, 8s, max 30s) - * - Max reconnect attempts: 10 - * - Typed event handlers for domain events - * - Message queue during disconnection with replay on reconnect - */ - -/** WebSocket event types from the backend. */ -export type WsEventType = - | 'vote_submitted' - | 'decision_advanced' - | 'mandate_updated' - | 'document_changed' - | 'sanctuary_archived' - | 'pong' - | 'error' - -/** Typed WebSocket message from the backend. */ -export interface WsMessage { - event: WsEventType - data: any - timestamp?: string -} - -/** Event handler callback type. */ -type WsEventHandler = (data: any) => void - -/** Maximum reconnect attempts before giving up. */ -const MAX_RECONNECT_ATTEMPTS = 10 - -/** Base delay for reconnection backoff (ms). */ -const RECONNECT_BASE_MS = 1_000 - -/** Maximum delay between reconnection attempts (ms). */ -const RECONNECT_MAX_MS = 30_000 - -/** Interval between heartbeat pings (ms). */ -const HEARTBEAT_INTERVAL_MS = 25_000 - -/** Maximum time to wait for a pong response (ms). */ -const PONG_TIMEOUT_MS = 10_000 - -export function useWebSocket() { - const config = useRuntimeConfig() - const auth = useAuthStore() - - let ws: WebSocket | null = null - let reconnectTimer: ReturnType | null = null - let heartbeatTimer: ReturnType | null = null - let pongTimeoutTimer: ReturnType | null = null - let reconnectAttempts = 0 - let intentionalClose = false - - const connected = ref(false) - const lastMessage = ref(null) - const error = ref(null) - - /** Message queue: messages sent while disconnected are replayed on reconnect. */ - const messageQueue: string[] = [] - - /** Typed event handlers registry. */ - const eventHandlers: Map> = new Map() - - /** - * Open a WebSocket connection to the backend live endpoint. - */ - function connect() { - if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { - return - } - - // Reset state - error.value = null - intentionalClose = false - - // Build WS URL with authentication token - let wsUrl = config.public.apiBase - .replace(/^http/, 'ws') - .replace(/\/api\/v1$/, '/api/v1/ws/live') - - if (auth.token) { - wsUrl += `?token=${encodeURIComponent(auth.token)}` - } - - ws = new WebSocket(wsUrl) - - ws.onopen = () => { - connected.value = true - reconnectAttempts = 0 - error.value = null - - // Start heartbeat - _startHeartbeat() - - // Replay queued messages - _flushMessageQueue() - } - - ws.onclose = (event: CloseEvent) => { - connected.value = false - _stopHeartbeat() - - if (!intentionalClose) { - _scheduleReconnect() - } - } - - ws.onerror = () => { - connected.value = false - } - - ws.onmessage = (event: MessageEvent) => { - try { - const message: WsMessage = JSON.parse(event.data) - lastMessage.value = message - - // Handle pong for heartbeat - if (message.event === 'pong') { - _onPongReceived() - return - } - - // Dispatch to typed event handlers - _dispatchEvent(message) - } catch { - // Non-JSON message, store as-is - lastMessage.value = { event: 'error', data: event.data } - } - } - } - - /** - * Subscribe to real-time updates for a vote session. - */ - function subscribe(sessionId: string) { - _send(JSON.stringify({ action: 'subscribe', session_id: sessionId })) - } - - /** - * Unsubscribe from a vote session's updates. - */ - function unsubscribe(sessionId: string) { - _send(JSON.stringify({ action: 'unsubscribe', session_id: sessionId })) - } - - /** - * Gracefully close the WebSocket connection. - */ - function disconnect() { - intentionalClose = true - - if (reconnectTimer) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - - _stopHeartbeat() - - if (ws) { - ws.onclose = null - ws.onerror = null - ws.onmessage = null - ws.close() - ws = null - } - - connected.value = false - reconnectAttempts = 0 - error.value = null - } - - // ---- Typed event handler registration ---- - - /** - * Register a handler for when a vote is submitted. - */ - function onVoteSubmitted(handler: WsEventHandler): () => void { - return _addEventHandler('vote_submitted', handler) - } - - /** - * Register a handler for when a decision advances to the next step. - */ - function onDecisionAdvanced(handler: WsEventHandler): () => void { - return _addEventHandler('decision_advanced', handler) - } - - /** - * Register a handler for when a mandate is updated. - */ - function onMandateUpdated(handler: WsEventHandler): () => void { - return _addEventHandler('mandate_updated', handler) - } - - /** - * Register a handler for when a document is changed. - */ - function onDocumentChanged(handler: WsEventHandler): () => void { - return _addEventHandler('document_changed', handler) - } - - /** - * Register a handler for when a document is archived to the sanctuary. - */ - function onSanctuaryArchived(handler: WsEventHandler): () => void { - return _addEventHandler('sanctuary_archived', handler) - } - - // ---- Internal helpers ---- - - /** - * Send a message via WebSocket, or queue it if disconnected. - */ - function _send(message: string) { - if (ws?.readyState === WebSocket.OPEN) { - ws.send(message) - } else { - messageQueue.push(message) - } - } - - /** - * Replay all queued messages after reconnection. - */ - function _flushMessageQueue() { - while (messageQueue.length > 0) { - const message = messageQueue.shift()! - if (ws?.readyState === WebSocket.OPEN) { - ws.send(message) - } else { - // Put it back if connection dropped again - messageQueue.unshift(message) - break - } - } - } - - /** - * Register an event handler and return an unsubscribe function. - */ - function _addEventHandler(event: WsEventType, handler: WsEventHandler): () => void { - if (!eventHandlers.has(event)) { - eventHandlers.set(event, new Set()) - } - eventHandlers.get(event)!.add(handler) - - // Return unsubscribe function - return () => { - eventHandlers.get(event)?.delete(handler) - } - } - - /** - * Dispatch a received message to all registered handlers for its event type. - */ - function _dispatchEvent(message: WsMessage) { - const handlers = eventHandlers.get(message.event) - if (handlers) { - for (const handler of handlers) { - try { - handler(message.data) - } catch (err) { - console.error(`[WS] Erreur dans le handler pour "${message.event}":`, err) - } - } - } - } - - /** - * Start the heartbeat: send ping every 25s and expect pong within 10s. - */ - function _startHeartbeat() { - _stopHeartbeat() - - heartbeatTimer = setInterval(() => { - if (ws?.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ action: 'ping' })) - - // Start pong timeout - pongTimeoutTimer = setTimeout(() => { - console.warn('[WS] Pong non recu dans le delai imparti, reconnexion...') - // Force close and reconnect - if (ws) { - ws.close() - } - }, PONG_TIMEOUT_MS) - } - }, HEARTBEAT_INTERVAL_MS) - } - - /** - * Stop heartbeat timers. - */ - function _stopHeartbeat() { - if (heartbeatTimer) { - clearInterval(heartbeatTimer) - heartbeatTimer = null - } - if (pongTimeoutTimer) { - clearTimeout(pongTimeoutTimer) - pongTimeoutTimer = null - } - } - - /** - * Handle pong response: cancel the pong timeout. - */ - function _onPongReceived() { - if (pongTimeoutTimer) { - clearTimeout(pongTimeoutTimer) - pongTimeoutTimer = null - } - } - - /** - * Schedule a reconnection attempt with exponential backoff. - */ - function _scheduleReconnect() { - if (reconnectTimer) return - - if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { - error.value = 'Connexion au serveur perdue. Veuillez rafraichir la page.' - console.error(`[WS] Nombre maximum de tentatives de reconnexion atteint (${MAX_RECONNECT_ATTEMPTS})`) - return - } - - const backoffMs = Math.min( - RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts), - RECONNECT_MAX_MS, - ) - - reconnectAttempts++ - console.info(`[WS] Tentative de reconnexion ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} dans ${backoffMs}ms`) - - reconnectTimer = setTimeout(() => { - reconnectTimer = null - connect() - }, backoffMs) - } - - return { - connected, - lastMessage, - error, - connect, - subscribe, - unsubscribe, - disconnect, - onVoteSubmitted, - onDecisionAdvanced, - onMandateUpdated, - onDocumentChanged, - onSanctuaryArchived, - } -} diff --git a/frontend/app/stores/auth.ts b/frontend/app/stores/auth.ts deleted file mode 100644 index 202871c..0000000 --- a/frontend/app/stores/auth.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Auth store: manages Duniter Ed25519 challenge-response authentication. - * - * Persists the session token in localStorage for SPA rehydration. - * The identity object mirrors the backend IdentityOut schema. - */ - -/** - * Sign a challenge using the injected Duniter/Substrate wallet extension - * (Cesium2, polkadot.js extension, Talisman, etc.). - * - * The extension signs {challenge} to match the backend verifier. - */ -// TODO: trustWallet — remplacer par postMessage vers l'iframe trustWallet (librodrome) -// Protocole prévu : window.postMessage({ type: 'LD_SIGN_REQUEST', address, challenge }) -// → trustWallet répond { type: 'LD_SIGN_RESPONSE', signature } -async function _signWithExtension(address: string, challenge: string): Promise { - const { web3Enable, web3FromAddress } = await import('@polkadot/extension-dapp') - const { stringToHex } = await import('@polkadot/util') - - const extensions = await web3Enable('libreDecision') - if (!extensions.length) { - throw new Error('Aucune extension Duniter détectée. Installez Cesium² ou Polkadot.js.') - } - - let injector - try { - injector = await web3FromAddress(address) - } catch { - throw new Error(`Adresse ${address.slice(0, 10)}… introuvable dans l'extension.`) - } - - if (!injector.signer?.signRaw) { - throw new Error("L'extension ne supporte pas la signature de messages bruts.") - } - - const { signature } = await injector.signer.signRaw({ - address, - data: stringToHex(challenge), - type: 'bytes', - }) - return signature -} - -export interface DuniterIdentity { - id: string - address: string - display_name: string | null - wot_status: string - is_smith: boolean - is_techcomm: boolean -} - -interface AuthState { - token: string | null - identity: DuniterIdentity | null - loading: boolean - error: string | null -} - -export const useAuthStore = defineStore('auth', { - state: (): AuthState => ({ - token: null, - identity: null, - loading: false, - error: null, - }), - - getters: { - isAuthenticated: (state): boolean => !!state.token && !!state.identity, - isSmith: (state): boolean => state.identity?.is_smith ?? false, - isTechComm: (state): boolean => state.identity?.is_techcomm ?? false, - displayName: (state): string => { - if (!state.identity) return '' - return state.identity.display_name || state.identity.address.slice(0, 12) + '...' - }, - }, - - actions: { - /** - * Initiate the challenge-response login flow. - * - * Steps: - * 1. POST /auth/challenge with the Duniter SS58 address - * 2. Client signs the challenge with Ed25519 private key - * 3. POST /auth/verify with address + signature + challenge - * 4. Store the returned token and identity - */ - async login(address: string, signFn?: (challenge: string) => Promise) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - - // Step 1: Request challenge - const challengeRes = await $api<{ challenge: string; expires_at: string }>( - '/auth/challenge', - { - method: 'POST', - body: { address }, - }, - ) - - // Step 2: Sign the challenge via polkadot.js / Cesium2 extension - let signature: string - if (signFn) { - signature = await signFn(challengeRes.challenge) - } else { - signature = await _signWithExtension(address, challengeRes.challenge) - } - - // Step 3: Verify and get token - const verifyRes = await $api<{ token: string; identity: DuniterIdentity }>( - '/auth/verify', - { - method: 'POST', - body: { - address, - signature, - challenge: challengeRes.challenge, - }, - }, - ) - - // Step 4: Store credentials - this.token = verifyRes.token - this.identity = verifyRes.identity - this._persistToken() - - return verifyRes - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur de connexion' - throw err - } finally { - this.loading = false - } - }, - - /** - * Fetch the currently authenticated identity from the backend. - * Used on app init to validate a persisted token. - */ - async fetchMe() { - if (!this.token) return - - this.loading = true - this.error = null - - try { - const { $api } = useApi() - const identity = await $api('/auth/me') - this.identity = identity - } catch (err: any) { - const status = (err as any)?.status ?? 0 - this.error = err?.message || 'Session invalide' - // N'effacer le token que sur 401/403 (session réellement invalide) - // Les erreurs réseau ou 5xx sont transitoires — conserver la session - if (status === 401 || status === 403) { - this.token = null - this.identity = null - this._clearToken() - } - throw err - } finally { - this.loading = false - } - }, - - /** - * Log out: invalidate session on server and clear local state. - */ - async logout() { - try { - if (this.token) { - const { $api } = useApi() - await $api('/auth/logout', { method: 'POST' }) - } - } catch { - // Ignore errors during logout -- clear local state regardless - } finally { - this.token = null - this.identity = null - this.error = null - this._clearToken() - navigateTo('/login') - } - }, - - /** - * Hydrate the token from localStorage on app init. - */ - hydrateFromStorage() { - if (import.meta.client) { - const stored = localStorage.getItem('libredecision_token') - if (stored) { - this.token = stored - } - } - }, - - /** @internal Persist token to localStorage */ - _persistToken() { - if (import.meta.client && this.token) { - localStorage.setItem('libredecision_token', this.token) - } - }, - - /** @internal Clear token from localStorage */ - _clearToken() { - if (import.meta.client) { - localStorage.removeItem('libredecision_token') - } - }, - }, -}) - -// Note: hydration from localStorage happens in app.vue onMounted -// via auth.hydrateFromStorage() before calling auth.fetchMe(). diff --git a/frontend/app/stores/documents.ts b/frontend/app/stores/documents.ts deleted file mode 100644 index 6d28214..0000000 --- a/frontend/app/stores/documents.ts +++ /dev/null @@ -1,313 +0,0 @@ -/** - * Documents store: reference documents, their items, and item versions. - * - * Maps to the backend /api/v1/documents endpoints. - */ - -export interface DocumentItem { - id: string - document_id: string - position: string - item_type: string - title: string | null - current_text: string - voting_protocol_id: string | null - sort_order: number - section_tag: string | null - inertia_preset: string - is_permanent_vote: boolean - created_at: string - updated_at: string -} - -export interface Document { - id: string - slug: string - title: string - doc_type: string - version: string - status: string - description: string | null - ipfs_cid: string | null - chain_anchor: string | null - genesis_json: string | null - created_at: string - updated_at: string - items_count: number -} - -export interface ItemVersion { - id: string - item_id: string - version_number: number - proposed_text: string - rationale: string | null - diff: string | null - status: string - proposed_by: string | null - reviewed_by: string | null - created_at: string - updated_at: string -} - -export interface DocumentCreate { - slug: string - title: string - doc_type: string - description?: string | null - version?: string -} - -export interface VersionProposal { - proposed_text: string - rationale?: string | null -} - -interface DocumentsState { - list: Document[] - current: Document | null - items: DocumentItem[] - versions: ItemVersion[] - allItemVersions: Record - loadingVersions: boolean - loading: boolean - error: string | null -} - -export const useDocumentsStore = defineStore('documents', { - state: (): DocumentsState => ({ - list: [], - current: null, - items: [], - versions: [], - allItemVersions: {}, - loadingVersions: false, - loading: false, - error: null, - }), - - getters: { - byType: (state) => { - return (docType: string) => state.list.filter(d => d.doc_type === docType) - }, - activeDocuments: (state): Document[] => { - return state.list.filter(d => d.status === 'active') - }, - draftDocuments: (state): Document[] => { - return state.list.filter(d => d.status === 'draft') - }, - }, - - actions: { - /** - * Fetch all documents with optional filters. - */ - async fetchAll(params?: { doc_type?: string; status?: string }) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - const query: Record = {} - if (params?.doc_type) query.doc_type = params.doc_type - if (params?.status) query.status = params.status - - this.list = await $api('/documents/', { query }) - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des documents' - } finally { - this.loading = false - } - }, - - /** - * Fetch a single document by slug and its items. - */ - async fetchBySlug(slug: string) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - - const [doc, items] = await Promise.all([ - $api(`/documents/${slug}`), - $api(`/documents/${slug}/items`), - ]) - - this.current = doc - this.items = items - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Document introuvable' - } finally { - this.loading = false - } - }, - - /** - * Create a new reference document. - */ - async createDocument(payload: DocumentCreate) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - const doc = await $api('/documents/', { - method: 'POST', - body: payload, - }) - this.list.unshift(doc) - return doc - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation du document' - throw err - } finally { - this.loading = false - } - }, - - /** - * Fetch all versions for a specific item within a document. - */ - async fetchItemVersions(slug: string, itemId: string) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - this.versions = await $api( - `/documents/${slug}/items/${itemId}/versions`, - ) - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des versions' - } finally { - this.loading = false - } - }, - - /** - * Propose a new version for a document item. - */ - async proposeVersion(slug: string, itemId: string, data: VersionProposal) { - this.error = null - - try { - const { $api } = useApi() - const version = await $api( - `/documents/${slug}/items/${itemId}/versions`, - { - method: 'POST', - body: data, - }, - ) - this.versions.unshift(version) - return version - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de la proposition' - throw err - } - }, - - /** - * Accept a proposed version. - */ - async acceptVersion(slug: string, itemId: string, versionId: string) { - this.error = null - - try { - const { $api } = useApi() - const updated = await $api( - `/documents/${slug}/items/${itemId}/versions/${versionId}/accept`, - { method: 'POST' }, - ) - const idx = this.versions.findIndex(v => v.id === versionId) - if (idx >= 0) this.versions[idx] = updated - return updated - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'acceptation' - throw err - } - }, - - /** - * Reject a proposed version. - */ - async rejectVersion(slug: string, itemId: string, versionId: string) { - this.error = null - - try { - const { $api } = useApi() - const updated = await $api( - `/documents/${slug}/items/${itemId}/versions/${versionId}/reject`, - { method: 'POST' }, - ) - const idx = this.versions.findIndex(v => v.id === versionId) - if (idx >= 0) this.versions[idx] = updated - return updated - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors du rejet' - throw err - } - }, - - /** - * Fetch all versions for every item in a document (parallel). - * Used to compute the "projected" view (document as-if all active votes passed). - */ - async fetchAllItemVersions(slug: string, itemIds: string[]) { - this.loadingVersions = true - try { - const { $api } = useApi() - const results = await Promise.allSettled( - itemIds.map(id => $api(`/documents/${slug}/items/${id}/versions`)), - ) - const map: Record = {} - itemIds.forEach((id, i) => { - const r = results[i] - map[id] = r?.status === 'fulfilled' ? r.value : [] - }) - this.allItemVersions = map - } finally { - this.loadingVersions = false - } - }, - - /** - * Archive a document into the Sanctuary. - */ - async archiveDocument(slug: string) { - this.error = null - - try { - const { $api } = useApi() - const doc = await $api( - `/documents/${slug}/archive`, - { method: 'POST' }, - ) - // Update current if viewing this document - if (this.current?.slug === slug) { - this.current = doc - } - // Update in list - const idx = this.list.findIndex(d => d.slug === slug) - if (idx >= 0) this.list[idx] = doc - return doc - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'archivage' - throw err - } - }, - - /** - * Clear the current document, items and versions. - */ - clearCurrent() { - this.current = null - this.items = [] - this.versions = [] - this.allItemVersions = {} - this.loadingVersions = false - }, - }, -}) diff --git a/frontend/app/stores/groups.ts b/frontend/app/stores/groups.ts deleted file mode 100644 index 12255b1..0000000 --- a/frontend/app/stores/groups.ts +++ /dev/null @@ -1,109 +0,0 @@ -export interface GroupMember { - id: string - display_name: string - identity_id: string | null - added_at: string -} - -export interface Group { - id: string - name: string - description: string | null - organization_id: string | null - created_at: string - members: GroupMember[] -} - -export interface GroupSummary { - id: string - name: string - description: string | null - organization_id: string | null - member_count: number -} - -export interface GroupCreate { - name: string - description?: string | null -} - -export interface GroupMemberCreate { - display_name: string - identity_id?: string | null -} - -export const useGroupsStore = defineStore('groups', () => { - const { $api } = useApi() - - const list = ref([]) - const loading = ref(false) - const error = ref(null) - - async function fetchAll() { - loading.value = true - error.value = null - try { - list.value = await $api('/groups/') - } catch (e: any) { - error.value = e?.message ?? 'Erreur chargement groupes' - } finally { - loading.value = false - } - } - - async function getGroup(id: string): Promise { - try { - return await $api(`/groups/${id}`) - } catch { - return null - } - } - - async function create(payload: GroupCreate): Promise { - try { - const group = await $api('/groups/', { method: 'POST', body: payload }) - await fetchAll() - return group - } catch (e: any) { - error.value = e?.message ?? 'Erreur création groupe' - return null - } - } - - async function remove(id: string): Promise { - try { - await $api(`/groups/${id}`, { method: 'DELETE' }) - list.value = list.value.filter(g => g.id !== id) - return true - } catch { - return false - } - } - - async function addMember(groupId: string, payload: GroupMemberCreate): Promise { - try { - const member = await $api(`/groups/${groupId}/members`, { - method: 'POST', - body: payload, - }) - const g = list.value.find(g => g.id === groupId) - if (g) g.member_count++ - return member - } catch { - return null - } - } - - async function removeMember(groupId: string, memberId: string): Promise { - try { - await $api(`/groups/${groupId}/members/${memberId}`, { method: 'DELETE' }) - const g = list.value.find(g => g.id === groupId) - if (g) g.member_count-- - return true - } catch { - return false - } - } - - return { list, loading, error, fetchAll, getGroup, create, remove, addMember, removeMember } -}) diff --git a/frontend/app/stores/mandates.ts b/frontend/app/stores/mandates.ts deleted file mode 100644 index 9749cc0..0000000 --- a/frontend/app/stores/mandates.ts +++ /dev/null @@ -1,219 +0,0 @@ -export interface MandateStep { - id: string - mandate_id: string - step_order: number - step_type: string - title: string | null - description: string | null - status: string - vote_session_id: string | null - outcome: string | null - created_at: string -} - -export interface Mandate { - id: string - title: string - origin_id: string | null - origin_display_name: string | null - description: string | null - mandate_type: string - status: string - mandatee_id: string | null - mandatee_display_name: string | null - decision_id: string | null - starts_at: string | null - ends_at: string | null - created_at: string - updated_at: string - steps: MandateStep[] -} - -export interface MandateCreate { - title: string - origin_id?: string | null - description?: string | null - mandate_type: string - nomination_mode?: string - decision_id?: string | null - starts_at?: string | null - ends_at?: string | null -} - -export interface MandateUpdate { - title?: string - origin_id?: string | null - description?: string | null - mandate_type?: string - starts_at?: string | null - ends_at?: string | null -} - -export interface MandateStepCreate { - step_order: number - step_type: string - title?: string | null - description?: string | null -} - -interface MandatesState { - list: Mandate[] - current: Mandate | null - loading: boolean - error: string | null -} - -export const useMandatesStore = defineStore('mandates', { - state: (): MandatesState => ({ - list: [], - current: null, - loading: false, - error: null, - }), - - getters: { - byStatus: (state) => (status: string) => state.list.filter(m => m.status === status), - activeMandates: (state): Mandate[] => state.list.filter(m => m.status === 'active'), - completedMandates: (state): Mandate[] => state.list.filter(m => m.status === 'completed'), - }, - - actions: { - async fetchAll(params?: { mandate_type?: string; status?: string }) { - this.loading = true - this.error = null - try { - const { $api } = useApi() - const query: Record = {} - if (params?.mandate_type) query.mandate_type = params.mandate_type - if (params?.status) query.status = params.status - this.list = await $api('/mandates/', { query }) - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des mandats' - } finally { - this.loading = false - } - }, - - async fetchById(id: string) { - this.loading = true - this.error = null - try { - const { $api } = useApi() - this.current = await $api(`/mandates/${id}`) - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Mandat introuvable' - } finally { - this.loading = false - } - }, - - async create(payload: MandateCreate) { - this.loading = true - this.error = null - try { - const { $api } = useApi() - const mandate = await $api('/mandates/', { method: 'POST', body: payload }) - this.list.unshift(mandate) - return mandate - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation du mandat' - throw err - } finally { - this.loading = false - } - }, - - async update(id: string, data: MandateUpdate) { - this.error = null - try { - const { $api } = useApi() - const updated = await $api(`/mandates/${id}`, { method: 'PUT', body: data }) - if (this.current?.id === id) this.current = updated - const idx = this.list.findIndex(m => m.id === id) - if (idx >= 0) this.list[idx] = updated - return updated - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de la mise a jour du mandat' - throw err - } - }, - - async delete(id: string) { - this.error = null - try { - const { $api } = useApi() - await $api(`/mandates/${id}`, { method: 'DELETE' }) - this.list = this.list.filter(m => m.id !== id) - if (this.current?.id === id) this.current = null - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de la suppression du mandat' - throw err - } - }, - - async advance(id: string) { - this.error = null - try { - const { $api } = useApi() - const updated = await $api(`/mandates/${id}/advance`, { method: 'POST' }) - if (this.current?.id === id) this.current = updated - const idx = this.list.findIndex(m => m.id === id) - if (idx >= 0) this.list[idx] = updated - return updated - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'avancement du mandat' - throw err - } - }, - - async addStep(id: string, step: MandateStepCreate) { - this.error = null - try { - const { $api } = useApi() - const newStep = await $api(`/mandates/${id}/steps`, { method: 'POST', body: step }) - if (this.current?.id === id) this.current.steps.push(newStep) - return newStep - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'ajout de l\'etape' - throw err - } - }, - - async assignMandatee(id: string, mandateeId: string) { - this.error = null - try { - const { $api } = useApi() - const updated = await $api(`/mandates/${id}/assign`, { - method: 'POST', - body: { mandatee_id: mandateeId }, - }) - if (this.current?.id === id) this.current = updated - const idx = this.list.findIndex(m => m.id === id) - if (idx >= 0) this.list[idx] = updated - return updated - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de l\'assignation du mandataire' - throw err - } - }, - - async revoke(id: string) { - this.error = null - try { - const { $api } = useApi() - const updated = await $api(`/mandates/${id}/revoke`, { method: 'POST' }) - if (this.current?.id === id) this.current = updated - const idx = this.list.findIndex(m => m.id === id) - if (idx >= 0) this.list[idx] = updated - return updated - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de la revocation du mandat' - throw err - } - }, - - clearCurrent() { - this.current = null - }, - }, -}) diff --git a/frontend/app/stores/organizations.ts b/frontend/app/stores/organizations.ts deleted file mode 100644 index 9a3a688..0000000 --- a/frontend/app/stores/organizations.ts +++ /dev/null @@ -1,71 +0,0 @@ -export interface Organization { - id: string - name: string - slug: string - org_type: string - is_transparent: boolean - color: string | null - icon: string | null - description: string | null - created_at: string -} - -interface OrgState { - organizations: Organization[] - activeSlug: string | null - loading: boolean - error: string | null -} - -export const useOrganizationsStore = defineStore('organizations', { - state: (): OrgState => ({ - organizations: [], - activeSlug: null, - loading: false, - error: null, - }), - - getters: { - active: (state): Organization | null => - state.organizations.find(o => o.slug === state.activeSlug) ?? state.organizations[0] ?? null, - - hasOrganizations: (state): boolean => state.organizations.length > 0, - }, - - actions: { - async fetchOrganizations() { - this.loading = true - this.error = null - try { - const { $api } = useApi() - const orgs = await $api('/organizations/') - // Duniter G1 first, then alphabetical - this.organizations = orgs.sort((a, b) => { - if (a.slug === 'duniter-g1') return -1 - if (b.slug === 'duniter-g1') return 1 - return a.name.localeCompare(b.name) - }) - // Restore persisted active slug, or default to first org - const stored = import.meta.client ? localStorage.getItem('libredecision_org') : null - if (stored && this.organizations.some(o => o.slug === stored)) { - this.activeSlug = stored - } else if (this.organizations[0]) { - this.activeSlug = this.organizations[0].slug - } - } catch (err: any) { - this.error = err?.message || 'Erreur lors du chargement des organisations' - } finally { - this.loading = false - } - }, - - setActive(slug: string) { - if (this.organizations.some(o => o.slug === slug)) { - this.activeSlug = slug - if (import.meta.client) { - localStorage.setItem('libredecision_org', slug) - } - } - }, - }, -}) diff --git a/frontend/app/stores/protocols.ts b/frontend/app/stores/protocols.ts deleted file mode 100644 index cee9e20..0000000 --- a/frontend/app/stores/protocols.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * Protocols store: voting protocols and formula configurations. - * - * Maps to the backend /api/v1/protocols endpoints. - */ - -export interface FormulaConfig { - id: string - name: string - description: string | null - duration_days: number - majority_pct: number - base_exponent: number - gradient_exponent: number - constant_base: number - smith_exponent: number | null - techcomm_exponent: number | null - nuanced_min_participants: number | null - nuanced_threshold_pct: number | null - created_at: string -} - -export interface VotingProtocol { - id: string - name: string - description: string | null - vote_type: string - formula_config_id: string - mode_params: string | null - is_meta_governed: boolean - created_at: string - formula_config: FormulaConfig -} - -export interface ProtocolCreate { - name: string - description: string | null - vote_type: string - formula_config_id: string -} - -export interface FormulaCreate { - name: string - description?: string | null - duration_days: number - majority_pct: number - base_exponent: number - gradient_exponent: number - constant_base: number - smith_exponent?: number | null - techcomm_exponent?: number | null - nuanced_min_participants?: number | null - nuanced_threshold_pct?: number | null -} - -export interface SimulateParams { - wot_size: number - total_votes: number - majority_pct: number - base_exponent: number - gradient_exponent: number - constant_base: number - smith_wot_size?: number - smith_exponent?: number - techcomm_size?: number - techcomm_exponent?: number -} - -export interface SimulateResult { - threshold: number - smith_threshold: number | null - techcomm_threshold: number | null - inertia_factor: number - required_ratio: number -} - -interface ProtocolsState { - protocols: VotingProtocol[] - formulas: FormulaConfig[] - currentProtocol: VotingProtocol | null - loading: boolean - error: string | null -} - -export const useProtocolsStore = defineStore('protocols', { - state: (): ProtocolsState => ({ - protocols: [], - formulas: [], - currentProtocol: null, - loading: false, - error: null, - }), - - getters: { - binaryProtocols: (state): VotingProtocol[] => { - return state.protocols.filter(p => p.vote_type === 'binary') - }, - nuancedProtocols: (state): VotingProtocol[] => { - return state.protocols.filter(p => p.vote_type === 'nuanced') - }, - metaGovernedProtocols: (state): VotingProtocol[] => { - return state.protocols.filter(p => p.is_meta_governed) - }, - }, - - actions: { - /** - * Fetch all voting protocols with their formula configurations. - */ - async fetchProtocols(params?: { vote_type?: string }) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - const query: Record = {} - if (params?.vote_type) query.vote_type = params.vote_type - - this.protocols = await $api('/protocols/', { query }) - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des protocoles' - } finally { - this.loading = false - } - }, - - /** - * Fetch all formula configurations. - */ - async fetchFormulas() { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - this.formulas = await $api('/protocols/formulas') - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des formules' - } finally { - this.loading = false - } - }, - - /** - * Fetch a single protocol by ID. - */ - async fetchProtocolById(id: string) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - this.currentProtocol = await $api(`/protocols/${id}`) - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Protocole introuvable' - } finally { - this.loading = false - } - }, - - /** - * Create a new voting protocol. - */ - async createProtocol(data: ProtocolCreate) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - const protocol = await $api('/protocols/', { - method: 'POST', - body: data, - }) - this.protocols.push(protocol) - return protocol - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation du protocole' - throw err - } finally { - this.loading = false - } - }, - - /** - * Create a new formula configuration. - */ - async createFormula(data: FormulaCreate) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - const formula = await $api('/protocols/formulas', { - method: 'POST', - body: data, - }) - this.formulas.push(formula) - return formula - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation de la formule' - throw err - } finally { - this.loading = false - } - }, - - /** - * Simulate formula computation on the backend. - */ - async simulate(params: SimulateParams) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - return await $api('/protocols/simulate', { - method: 'POST', - body: params, - }) - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de la simulation' - throw err - } finally { - this.loading = false - } - }, - }, -}) diff --git a/frontend/app/stores/votes.ts b/frontend/app/stores/votes.ts deleted file mode 100644 index dc453b6..0000000 --- a/frontend/app/stores/votes.ts +++ /dev/null @@ -1,297 +0,0 @@ -/** - * Votes store: vote sessions, individual votes, and result computation. - * - * Maps to the backend /api/v1/votes endpoints. - */ - -export interface Vote { - id: string - session_id: string - voter_id: string - vote_value: string - nuanced_level: number | null - comment: string | null - signature: string - signed_payload: string - voter_wot_status: string - voter_is_smith: boolean - voter_is_techcomm: boolean - is_active: boolean - created_at: string -} - -export interface VoteSession { - id: string - decision_id: string | null - item_version_id: string | null - voting_protocol_id: string - wot_size: number - smith_size: number - techcomm_size: number - starts_at: string - ends_at: string - status: string - votes_for: number - votes_against: number - votes_total: number - smith_votes_for: number - techcomm_votes_for: number - threshold_required: number - result: string | null - chain_recorded: boolean - chain_tx_hash: string | null - created_at: string -} - -export interface VoteResult { - session_id: string - status: string - votes_for: number - votes_against: number - votes_total: number - wot_size: number - smith_size: number - techcomm_size: number - smith_votes_for: number - techcomm_votes_for: number - threshold_required: number - result: string - smith_threshold: number | null - smith_pass: boolean - techcomm_threshold: number | null - techcomm_pass: boolean -} - -export interface ThresholdDetails { - wot_threshold: number - smith_threshold: number | null - techcomm_threshold: number | null - wot_pass: boolean - smith_pass: boolean | null - techcomm_pass: boolean | null - inertia_factor: number - required_ratio: number -} - -export interface VoteCreate { - session_id: string - vote_value: string - nuanced_level?: number | null - comment?: string | null - signature: string - signed_payload: string -} - -export interface VoteSessionCreate { - decision_id?: string | null - item_version_id?: string | null - voting_protocol_id: string - wot_size?: number - smith_size?: number - techcomm_size?: number -} - -export interface SessionFilters { - status?: string - voting_protocol_id?: string - decision_id?: string -} - -interface VotesState { - currentSession: VoteSession | null - votes: Vote[] - result: VoteResult | null - thresholdDetails: ThresholdDetails | null - sessions: VoteSession[] - loading: boolean - error: string | null -} - -export const useVotesStore = defineStore('votes', { - state: (): VotesState => ({ - currentSession: null, - votes: [], - result: null, - thresholdDetails: null, - sessions: [], - loading: false, - error: null, - }), - - getters: { - isSessionOpen: (state): boolean => { - if (!state.currentSession) return false - return state.currentSession.status === 'open' && new Date(state.currentSession.ends_at) > new Date() - }, - participationRate: (state): number => { - if (!state.currentSession || state.currentSession.wot_size === 0) return 0 - return (state.currentSession.votes_total / state.currentSession.wot_size) * 100 - }, - forPercentage: (state): number => { - if (!state.currentSession || state.currentSession.votes_total === 0) return 0 - return (state.currentSession.votes_for / state.currentSession.votes_total) * 100 - }, - }, - - actions: { - /** - * Fetch a vote session by ID with its votes and result. - */ - async fetchSession(sessionId: string) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - - const [session, votes, result] = await Promise.all([ - $api(`/votes/sessions/${sessionId}`), - $api(`/votes/sessions/${sessionId}/votes`), - $api(`/votes/sessions/${sessionId}/result`), - ]) - - this.currentSession = session - this.votes = votes - this.result = result - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Session de vote introuvable' - } finally { - this.loading = false - } - }, - - /** - * Submit a vote to the current session. - */ - async submitVote(payload: VoteCreate) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - const vote = await $api(`/votes/sessions/${payload.session_id}/vote`, { - method: 'POST', - body: payload, - }) - - // Update local state - this.votes.push(vote) - - // Refresh session tallies and result - if (this.currentSession) { - const [session, result] = await Promise.all([ - $api(`/votes/sessions/${payload.session_id}`), - $api(`/votes/sessions/${payload.session_id}/result`), - ]) - this.currentSession = session - this.result = result - } - - return vote - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors du vote' - throw err - } finally { - this.loading = false - } - }, - - /** - * Fetch threshold details for a session. - */ - async fetchThresholdDetails(sessionId: string) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - this.thresholdDetails = await $api( - `/votes/sessions/${sessionId}/threshold`, - ) - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des details du seuil' - } finally { - this.loading = false - } - }, - - /** - * Close a vote session. - */ - async closeSession(sessionId: string) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - const session = await $api(`/votes/sessions/${sessionId}/close`, { - method: 'POST', - }) - this.currentSession = session - - // Refresh result after closing - this.result = await $api(`/votes/sessions/${sessionId}/result`) - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de la fermeture de la session' - throw err - } finally { - this.loading = false - } - }, - - /** - * Fetch a list of vote sessions with optional filters. - */ - async fetchSessions(filters?: SessionFilters) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - const query: Record = {} - if (filters?.status) query.status = filters.status - if (filters?.voting_protocol_id) query.voting_protocol_id = filters.voting_protocol_id - if (filters?.decision_id) query.decision_id = filters.decision_id - - this.sessions = await $api('/votes/sessions', { query }) - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des sessions' - } finally { - this.loading = false - } - }, - - /** - * Create a new vote session. - */ - async createSession(data: VoteSessionCreate) { - this.loading = true - this.error = null - - try { - const { $api } = useApi() - const session = await $api('/votes/sessions', { - method: 'POST', - body: data, - }) - this.sessions.push(session) - return session - } catch (err: any) { - this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation de la session' - throw err - } finally { - this.loading = false - } - }, - - /** - * Clear the current session state. - */ - clearSession() { - this.currentSession = null - this.votes = [] - this.result = null - this.thresholdDetails = null - }, - }, -}) diff --git a/frontend/app/utils/mode-params.ts b/frontend/app/utils/mode-params.ts deleted file mode 100644 index cf1edb2..0000000 --- a/frontend/app/utils/mode-params.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * TypeScript mirror of the Python mode_params parser. - * - * A mode-params string encodes voting formula parameters in a compact format. - * Example: "D30M50B.1G.2T.1" - * - * Supported codes: - * D = duration_days (int) - * M = majority_pct (int, 0-100) - * B = base_exponent (float) - * G = gradient_exponent (float) - * C = constant_base (float) - * S = smith_exponent (float) - * T = techcomm_exponent (float) - * N = ratio_multiplier (float) - * R = is_ratio_mode (bool, 0 or 1) - * - * Values may start with a dot for decimals < 1, e.g. "B.1" means base_exponent=0.1. - */ - -export interface ModeParams { - duration_days: number - majority_pct: number - base_exponent: number - gradient_exponent: number - constant_base: number - smith_exponent: number | null - techcomm_exponent: number | null - ratio_multiplier: number | null - is_ratio_mode: boolean -} - -type CodeType = 'int' | 'float' | 'bool' - -const CODES: Record = { - D: { key: 'duration_days', type: 'int' }, - M: { key: 'majority_pct', type: 'int' }, - B: { key: 'base_exponent', type: 'float' }, - G: { key: 'gradient_exponent', type: 'float' }, - C: { key: 'constant_base', type: 'float' }, - S: { key: 'smith_exponent', type: 'float' }, - T: { key: 'techcomm_exponent', type: 'float' }, - N: { key: 'ratio_multiplier', type: 'float' }, - R: { key: 'is_ratio_mode', type: 'bool' }, -} - -const PARAM_RE = /([A-Z])(\d*\.?\d+)/g - -function getDefaults(): ModeParams { - return { - duration_days: 30, - majority_pct: 50, - base_exponent: 0.1, - gradient_exponent: 0.2, - constant_base: 0.0, - smith_exponent: null, - techcomm_exponent: null, - ratio_multiplier: null, - is_ratio_mode: false, - } -} - -/** - * Parse a mode-params string into a structured ModeParams object. - * - * @param paramsStr - Compact parameter string, e.g. "D30M50B.1G.2T.1" - * @returns Parsed parameters with defaults for codes not found - * @throws Error if an unrecognised code letter is found - */ -export function parseModeParams(paramsStr: string): ModeParams { - const result = getDefaults() - - if (!paramsStr || !paramsStr.trim()) { - return result - } - - let match: RegExpExecArray | null - PARAM_RE.lastIndex = 0 - - while ((match = PARAM_RE.exec(paramsStr)) !== null) { - const code = match[1] - const rawValue = match[2] - - if (!(code in CODES)) { - throw new Error(`Code de parametre inconnu : '${code}'`) - } - - const { key, type } = CODES[code] - - if (type === 'int') { - ;(result as any)[key] = Math.floor(parseFloat(rawValue)) - } else if (type === 'float') { - ;(result as any)[key] = parseFloat(rawValue) - } else if (type === 'bool') { - ;(result as any)[key] = parseFloat(rawValue) !== 0 - } - } - - return result -} - -/** - * Encode a ModeParams object into a compact mode-params string. - * - * Only includes parameters that differ from defaults. - * - * @param params - Parameters to encode - * @returns Compact string, e.g. "D30M50B.1G.2" - */ -export function encodeModeParams(params: Partial): string { - const defaults = getDefaults() - const parts: string[] = [] - - const codeEntries = Object.entries(CODES) as [string, { key: keyof ModeParams; type: CodeType }][] - - for (const [code, { key, type }] of codeEntries) { - const value = params[key] - if (value === undefined || value === null) continue - if (value === defaults[key]) continue - - if (type === 'int') { - parts.push(`${code}${value}`) - } else if (type === 'float') { - const numVal = value as number - if (numVal < 1 && numVal > 0) { - parts.push(`${code}${numVal.toString().replace(/^0/, '')}`) - } else { - parts.push(`${code}${numVal}`) - } - } else if (type === 'bool') { - parts.push(`${code}${value ? 1 : 0}`) - } - } - - return parts.join('') -} - -/** - * Format a mode-params string for human display. - * - * @param paramsStr - Compact parameter string - * @returns Human-readable description in French - */ -export function formatModeParams(paramsStr: string): string { - const params = parseModeParams(paramsStr) - const parts: string[] = [] - - parts.push(`Duree: ${params.duration_days} jours`) - parts.push(`Majorite: ${params.majority_pct}%`) - parts.push(`Base: ${params.base_exponent}`) - parts.push(`Gradient: ${params.gradient_exponent}`) - - if (params.constant_base > 0) { - parts.push(`Constante: ${params.constant_base}`) - } - if (params.smith_exponent !== null) { - parts.push(`Smith: ${params.smith_exponent}`) - } - if (params.techcomm_exponent !== null) { - parts.push(`TechComm: ${params.techcomm_exponent}`) - } - - return parts.join(' | ') -} diff --git a/frontend/app/utils/threshold.ts b/frontend/app/utils/threshold.ts deleted file mode 100644 index 8427ce8..0000000 --- a/frontend/app/utils/threshold.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * TypeScript mirror of the Python WoT threshold formula. - * - * Core formula: - * Result = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T - C) - * - * Where: - * C = constant_base - * B = base_exponent - * W = wot_size (corpus of eligible voters) - * T = total_votes (for + against) - * M = majority_ratio (majority_pct / 100) - * G = gradient_exponent - * - * Inertia behaviour: - * - Low participation (T << W) -> near-unanimity required - * - High participation (T -> W) -> simple majority M suffices - * - * Reference test case: - * wot_size=7224, votes_for=97, votes_against=23 (total=120) - * params M50 B.1 G.2 => threshold=94, adopted (97 >= 94) - */ -export function wotThreshold( - wotSize: number, - totalVotes: number, - majorityPct: number = 50, - baseExponent: number = 0.1, - gradientExponent: number = 0.2, - constantBase: number = 0.0, -): number { - if (wotSize <= 0) { - throw new Error('wotSize doit etre strictement positif') - } - if (totalVotes < 0) { - throw new Error('totalVotes ne peut pas etre negatif') - } - if (majorityPct < 0 || majorityPct > 100) { - throw new Error('majorityPct doit etre entre 0 et 100') - } - - const M = majorityPct / 100 - const T = totalVotes - const W = wotSize - const C = constantBase - const B = baseExponent - const G = gradientExponent - - // Guard: if no votes, threshold is at least ceil(C + B^W) - if (T === 0) { - return Math.ceil(C + Math.pow(B, W)) - } - - // Core formula - const participationRatio = T / W - const inertiaFactor = 1.0 - Math.pow(participationRatio, G) - const requiredRatio = M + (1.0 - M) * inertiaFactor - const result = C + Math.pow(B, W) + requiredRatio * Math.max(0, T - C) - - return Math.ceil(result) -} - -/** - * Compute the Smith criterion threshold. - * - * @param smithWotSize - Number of Smith members - * @param smithExponent - Exponent S for the Smith criterion - * @returns Minimum number of Smith votes required - */ -export function smithThreshold(smithWotSize: number, smithExponent: number): number { - if (smithWotSize <= 0) return 0 - return Math.ceil(Math.pow(smithWotSize, smithExponent)) -} - -/** - * Compute the TechComm criterion threshold. - * - * @param techcommSize - Number of TechComm members - * @param techcommExponent - Exponent T for the TechComm criterion - * @returns Minimum number of TechComm votes required - */ -export function techcommThreshold(techcommSize: number, techcommExponent: number): number { - if (techcommSize <= 0) return 0 - return Math.ceil(Math.pow(techcommSize, techcommExponent)) -} diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 0b6e068..16cd1a4 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -17,13 +17,13 @@ export default defineNuxtConfig({ htmlAttrs: { lang: 'fr' }, meta: [ { name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=5' }, - { name: 'description', content: 'Plateforme de decisions collectives pour la communaute Duniter/G1' }, + { name: 'description', content: 'Boîte à outils de la première démocratie — décider ensemble, du trio à la population' }, { name: 'theme-color', content: '#16a34a' }, { name: 'apple-mobile-web-app-capable', content: 'yes' }, { name: 'apple-mobile-web-app-status-bar-style', content: 'default' }, { name: 'mobile-web-app-capable', content: 'yes' }, { property: 'og:title', content: 'libreDecision' }, - { property: 'og:description', content: 'Decisions collectives pour la communaute Duniter/G1' }, + { property: 'og:description', content: 'Décider ensemble, du trio à la population' }, { property: 'og:type', content: 'website' }, ], title: 'libreDecision', @@ -46,13 +46,4 @@ export default defineNuxtConfig({ nitro: { compressPublicAssets: true, }, - vite: { - define: { - // Polkadot packages expect a Node-like global - global: 'globalThis', - }, - optimizeDeps: { - include: ['@polkadot/extension-dapp', '@polkadot/util'], - }, - }, })