v3 : Décisions et « À venir » côté serveur

- ld-engine/pending : un module (article) reste à voter tant qu'aucune
  version n'a été adoptée par un vote (seed a minima non confirmé, ou pas
  de version en vigueur et au moins une version ouverte) ; les variantes
  comptent pour un ; « sans ton vote » = aucun vote valide sur aucune version.
- overview.rs : GET /api/groups/{slug} ajoute « upcoming » (docs, mandats,
  protocoles), lu au démarrage dans seeds/upcoming/<groupe>.json, trilingue,
  servi seulement à qui ouvre le groupe ; GET /api/groups/{slug}/decisions,
  une ligne par doc avec toVote et withoutMyVote (si la personne vote).
- Retour après connexion par défaut : « / » au lieu de « /groupes ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-30 09:07:39 +01:00
co-authored by Claude Opus 5.5
parent d400bfc92e
commit c640413a70
7 changed files with 596 additions and 9 deletions
+7 -7
View File
@@ -237,7 +237,7 @@ fn safe_return(raw: Option<&str>) -> String {
{
p.to_owned()
}
_ => "/groupes".to_owned(),
_ => "/".to_owned(),
}
}
@@ -577,12 +577,12 @@ mod tests {
#[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");
assert_eq!(safe_return(Some("//evil.example")), "/");
assert_eq!(safe_return(Some("https://evil.example")), "/");
assert_eq!(safe_return(Some("/\\evil")), "/");
assert_eq!(safe_return(Some("/\t/evil.example")), "/");
assert_eq!(safe_return(Some("/\n/evil.example")), "/");
assert_eq!(safe_return(None), "/");
}
#[tokio::test]
+7 -2
View File
@@ -11,6 +11,7 @@ pub mod auth;
pub mod config;
pub mod docs;
pub mod error;
pub mod overview;
pub mod proposals;
pub mod publish;
pub mod seed;
@@ -41,6 +42,8 @@ pub struct AppState {
pub http: reqwest::Client,
/// Wakes the publisher as soon as an adoption is recorded.
pub publish_wake: Arc<Notify>,
/// What is coming in the tools of each group (`seeds/upcoming/`), read at start.
pub upcoming: Arc<overview::Upcoming>,
}
impl AppState {
@@ -49,7 +52,8 @@ impl AppState {
.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()) })
let upcoming = Arc::new(overview::Upcoming::load(&cfg.seed_dir).await?);
Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()), upcoming })
}
}
@@ -103,7 +107,8 @@ pub fn api_router() -> Router<AppState> {
.route("/auth/logout", post(auth::logout))
.route("/me", get(auth::me))
.route("/groups", get(docs::groups))
.route("/groups/{slug}", get(docs::group))
.route("/groups/{slug}", get(overview::group))
.route("/groups/{slug}/decisions", get(overview::decisions))
.route("/docs/{id}", get(docs::document))
.route("/docs/{id}/preview", get(docs::preview))
.route("/docs/{id}/assist", post(proposals::assist))
+222
View File
@@ -0,0 +1,222 @@
//! A group at a glance: what is coming in each tool, and what is left to vote.
//!
//! What is coming is data, not code: `seeds/upcoming/<group>.json`, one file
//! per group, read once at start (titles in fr, en and es). It is served with
//! the group (`GET /api/groups/{slug}`, field `upcoming`), only to those who
//! open it. What is left to vote (`GET /api/groups/{slug}/decisions`) is one
//! line per document, counted by `ld_engine::pending`.
use crate::AppState;
use crate::auth::User;
use crate::docs;
use crate::error::AppResult;
use crate::util::{self, LangMap};
use anyhow::{Context, bail};
use axum::Json;
use axum::extract::{Path, State};
use ld_engine::pending::{Module, pending};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashSet};
/// Something announced in a tool of a group, not open yet.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct UpcomingItem {
pub key: String,
pub title: LangMap,
}
/// What is coming in the tools of one group.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct GroupUpcoming {
#[serde(default)]
pub docs: Vec<UpcomingItem>,
#[serde(default)]
pub mandats: Vec<UpcomingItem>,
#[serde(default)]
pub protocoles: Vec<UpcomingItem>,
}
impl GroupUpcoming {
fn check(&self) -> anyhow::Result<()> {
for (tool, items) in [("docs", &self.docs), ("mandats", &self.mandats), ("protocoles", &self.protocoles)] {
let mut keys = HashSet::new();
for item in items {
if item.key.trim().is_empty() || !keys.insert(item.key.as_str()) {
bail!("{tool}: empty or repeated key « {} »", item.key);
}
for lang in util::LANGS {
if item.title.get(lang).is_none_or(|t| t.trim().is_empty()) {
bail!("{tool}/{}: no title in {lang}", item.key);
}
}
}
}
Ok(())
}
}
/// Every group's announcements, by group slug.
#[derive(Clone, Debug, Default)]
pub struct Upcoming(BTreeMap<String, GroupUpcoming>);
impl Upcoming {
/// Reads `<seed_dir>/upcoming/*.json`; no directory, nothing coming. A file
/// that does not read, or lacks a language, stops the start.
pub async fn load(seed_dir: &std::path::Path) -> anyhow::Result<Self> {
let dir = seed_dir.join("upcoming");
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => return Err(e).context(dir.display().to_string()),
};
let mut out = BTreeMap::new();
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().is_none_or(|x| x != "json") {
continue;
}
let Some(group) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
let raw = tokio::fs::read_to_string(&path).await?;
let parsed: GroupUpcoming = serde_json::from_str(&raw).with_context(|| path.display().to_string())?;
parsed.check().with_context(|| path.display().to_string())?;
out.insert(group, parsed);
}
Ok(Self(out))
}
pub fn of(&self, group: &str) -> GroupUpcoming {
self.0.get(group).cloned().unwrap_or_default()
}
}
/// The group, its tools and documents (`docs::group`), plus what is coming.
pub async fn group(State(state): State<AppState>, user: User, Path(slug): Path<String>) -> AppResult<Json<Value>> {
let Json(mut view) = docs::group(State(state.clone()), user, Path(slug.clone())).await?;
let open = view["group"]["open"].as_bool().unwrap_or(false);
let upcoming = if open { state.upcoming.of(&slug) } else { GroupUpcoming::default() };
view["upcoming"] = serde_json::to_value(upcoming).unwrap_or(Value::Null);
Ok(Json(view))
}
/// « Décisions »: one line per document in progress, with its modules left to vote.
pub async fn decisions(
State(state): State<AppState>,
User(user): User,
Path(slug): Path<String>,
) -> AppResult<Json<Value>> {
let group = docs::load_group(&state, &slug).await?;
docs::require_open(&state, &user, &group).await?;
let can_vote = docs::may_vote(&state, &user, &group).await?;
#[derive(sqlx::FromRow)]
struct DocRow {
id: String,
slug: String,
title: String,
}
let rows =
sqlx::query_as::<_, DocRow>("SELECT id, slug, title FROM documents WHERE group_slug = ? ORDER BY created_at")
.bind(&slug)
.fetch_all(&state.db)
.await?;
let mut out = Vec::with_capacity(rows.len());
for d in rows {
let p = pending(&modules(&state, &d.id, &user.id).await?);
out.push(json!({
"id": d.id, "slug": d.slug, "title": util::parse_lang(&d.title),
"toVote": p.to_vote,
"withoutMyVote": can_vote.then_some(p.without_my_vote),
}));
}
Ok(Json(json!({ "canVote": can_vote, "docs": out })))
}
/// The articles of a document as `me` sees them: its version in force (adopted
/// by a vote, or a seed a minima), its open versions, and whether `me` holds a
/// valid vote on one of them. A change of inertia is not a version of the text.
async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Module>> {
#[derive(sqlx::FromRow)]
struct VersionRow {
unit_id: String,
in_force: bool,
adopted: bool,
voted: bool,
}
let rows = sqlx::query_as::<_, VersionRow>(
"SELECT u.id AS unit_id,
IFNULL(p.id = u.in_force_id, 0) AS in_force,
(p.entered_at IS NOT NULL OR p.confirmed_at IS NOT NULL OR p.origin <> 'seed') AS adopted,
EXISTS (SELECT 1 FROM votes v
WHERE v.proposal_id = p.id AND v.identity_id = ? AND v.revision = p.revision) AS voted
FROM units u JOIN proposals p ON p.unit_id = u.id
WHERE u.doc_id = ? AND p.kind <> 'inertia' AND (p.id = u.in_force_id OR p.status = 'open')
ORDER BY u.position, u.id",
)
.bind(me)
.bind(doc_id)
.fetch_all(&state.db)
.await?;
let mut out: Vec<Module> = Vec::new();
let mut current: Option<String> = None;
for r in rows {
if current.as_deref() != Some(r.unit_id.as_str()) {
out.push(Module::default());
current = Some(r.unit_id);
}
if let Some(m) = out.last_mut() {
if r.in_force {
m.in_force = Some(r.adopted);
} else {
m.open += 1;
}
m.voted |= r.voted;
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Config;
async fn load(files: &[(&str, &str)]) -> anyhow::Result<Upcoming> {
let dir = std::env::temp_dir().join(format!("ld-upcoming-{}", util::id()));
tokio::fs::create_dir_all(dir.join("upcoming")).await?;
for (name, body) in files {
tokio::fs::write(dir.join("upcoming").join(name), body).await?;
}
let out = Upcoming::load(&dir).await;
let _ = tokio::fs::remove_dir_all(&dir).await;
out
}
const ITEM: &str = r#"{"key": "k", "title": {"fr": "Un", "en": "One", "es": "Uno"}}"#;
#[tokio::test]
async fn one_file_per_group_and_nothing_elsewhere() {
let up =
load(&[("axiom.json", &format!(r#"{{"mandats": [{ITEM}]}}"#)), ("notes.txt", "x")]).await.expect("load");
assert_eq!(up.of("axiom").mandats.len(), 1);
assert!(up.of("axiom").docs.is_empty());
assert!(up.of("librodrome").mandats.is_empty());
let none = Upcoming::load(&std::env::temp_dir().join("ld-no-such-dir")).await.expect("load");
assert!(none.of("axiom").docs.is_empty());
}
#[tokio::test]
async fn a_missing_language_an_unknown_tool_or_a_repeated_key_stops_the_start() {
let no_es = r#"{"docs": [{"key": "k", "title": {"fr": "Un", "en": "One"}}]}"#;
assert!(load(&[("axiom.json", no_es)]).await.is_err());
assert!(load(&[("axiom.json", &format!(r#"{{"outils": [{ITEM}]}}"#))]).await.is_err());
assert!(load(&[("axiom.json", &format!(r#"{{"docs": [{ITEM}, {ITEM}]}}"#))]).await.is_err());
}
#[tokio::test]
async fn the_seeds_of_the_repository_read() {
let up = Upcoming::load(&Config::for_tests().seed_dir).await.expect("load");
assert_eq!(up.of("monnaie-libre").mandats.len(), 2);
}
}