diff --git a/crates/ld-engine/src/lib.rs b/crates/ld-engine/src/lib.rs index 945d854..3941811 100644 --- a/crates/ld-engine/src/lib.rs +++ b/crates/ld-engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod compose; pub mod edit; pub mod inertia; pub mod names; +pub mod order; pub mod pending; pub mod threshold; diff --git a/crates/ld-engine/src/order.rs b/crates/ld-engine/src/order.rs new file mode 100644 index 0000000..5616bfc --- /dev/null +++ b/crates/ld-engine/src/order.rs @@ -0,0 +1,329 @@ +//! The order of the articles of a section — a module of its own. +//! +//! Yvv, 2026-10-30: « En fin de section, il y a un "module" qui permet de +//! changer l'ordre des "articles". Lui-même à (2), avec une simple liste +//! clipable. » The order of a section is voted like an article: its version in +//! force is the order the section reads in now; a proposal is another order. +//! +//! The rules, purely: +//! +//! - **who is in the order** (`members`): the articles of the section that a +//! person can see — a text in force, or an open version. A draft is nobody's +//! but its author's; an article whose versions were all withdrawn, or that +//! was removed with nothing left open, is out; +//! - **an order proposed** names every article of the section, each once +//! (`check`); +//! - **articles come and go** while an order is under vote: the proposal is +//! **completed at the end** (`fit`). The articles it names that are still in +//! the section keep its order; those that entered since go at the end, in the +//! order of the section; those that left are dropped. Its votes stay: the +//! voters chose the relative order of the articles they saw, and a newcomer +//! at the end is where a new article goes anyway (`place_at_end`). Neither +//! lapsing nor asking every voter again would let an order ever pass in a +//! section that lives; +//! - an order that, so completed, is the order in force changes nothing and +//! cannot enter into force (the engine compares `canonical` texts); +//! - **entering into force** gives the section's places out again in the new +//! order (`positions`): the section keeps the positions it holds, so it never +//! mingles with its neighbours; +//! - an order of fewer than two articles has nothing to decide (`is_moot`). +//! +//! The vote itself — threshold, margin over the version in force (ADR 0002) — +//! is that of any version (`compose`). + +/// An article as the order of its section sees it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Article<'a> { + pub id: &'a str, + pub section: Option<&'a str>, + pub position: f64, + /// A text in force, or at least one open version (text or removal). + pub visible: bool, +} + +/// Below this many articles, an order has nothing to decide. +pub const MIN_ARTICLES: usize = 2; + +pub fn is_moot(members: &[String]) -> bool { + members.len() < MIN_ARTICLES +} + +/// The articles in the order of `section`, as it reads now: by position, then id. +pub fn members(articles: &[Article<'_>], section: &str) -> Vec { + let mut inside: Vec<&Article<'_>> = articles.iter().filter(|a| a.visible && a.section == Some(section)).collect(); + inside.sort_by(|a, b| a.position.total_cmp(&b.position).then(a.id.cmp(b.id))); + inside.into_iter().map(|a| a.id.to_owned()).collect() +} + +/// What an order proposal gives the section now. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Fitted { + /// The order as it would read now. + pub order: Vec, + /// Articles that entered the section since it was proposed: at the end. + pub added: Vec, + /// Articles it names that left the section: dropped. + pub dropped: Vec, +} + +/// An order proposal against the section as it is now: completed at the end. +pub fn fit(proposed: &[String], members: &[String]) -> Fitted { + let mut order: Vec = Vec::with_capacity(members.len()); + let mut dropped = Vec::new(); + for id in proposed { + if order.contains(id) { + continue; + } + if members.contains(id) { + order.push(id.clone()); + } else if !dropped.contains(id) { + dropped.push(id.clone()); + } + } + let added: Vec = members.iter().filter(|m| !order.contains(m)).cloned().collect(); + order.extend(added.iter().cloned()); + Fitted { order, added, dropped } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OrderError { + /// Not every article of the section, each once. + NotTheSection, + /// The order the section already reads in. + Unchanged, +} + +/// An order proposed now: every article of the section, each once, in another order. +pub fn check(proposed: &[String], members: &[String]) -> Result<(), OrderError> { + let mut seen: Vec<&String> = Vec::with_capacity(proposed.len()); + for id in proposed { + if seen.contains(&id) || !members.contains(id) { + return Err(OrderError::NotTheSection); + } + seen.push(id); + } + if proposed.len() != members.len() { + return Err(OrderError::NotTheSection); + } + if proposed == members { + return Err(OrderError::Unchanged); + } + Ok(()) +} + +/// The order as a text: one article per line. It is the body of an order +/// proposal, and what the engine compares to tell an order from another. +pub fn canonical(order: &[String]) -> String { + order.join("\n") +} + +/// The order a proposal's body holds; `None` when it holds none. +pub fn decode(body: &str) -> Option> { + let ids: Vec = body.lines().map(str::trim).map(str::to_owned).collect(); + let readable = !ids.is_empty() && ids.iter().all(|id| !id.is_empty() && !id.contains(char::is_whitespace)); + readable.then_some(ids) +} + +/// The places of the section, given out again in the new order: the section +/// keeps the positions it holds (`current`, any order), the first of them goes +/// to the first article of `order`, and so on. Articles of `order` that hold no +/// place, and places beyond `order`, are left alone. +pub fn positions(order: &[String], current: &[(String, f64)]) -> Vec<(String, f64)> { + let ranked: Vec<&String> = order.iter().filter(|id| current.iter().any(|(c, _)| c == *id)).collect(); + let mut places: Vec = current.iter().filter(|(c, _)| ranked.contains(&c)).map(|(_, p)| *p).collect(); + places.sort_by(f64::total_cmp); + ranked.into_iter().zip(places).map(|(id, p)| (id.clone(), p)).collect() +} + +/// Where a new article goes: at the end of its section — after the last article +/// of the section (seen or not: a draft keeps its place), before the first one +/// that follows it in the document. Without a section, or in a section not seen +/// yet, after everything. +pub fn place_at_end(articles: &[(Option<&str>, f64)], section: Option<&str>) -> f64 { + let last_of_doc = articles.iter().map(|(_, p)| *p).fold(None, |m: Option, p| Some(m.map_or(p, |m| m.max(p)))); + let after_all = last_of_doc.map_or(1.0, |m| m + 1.0); + let Some(section) = section else { return after_all }; + let Some(last) = articles + .iter() + .filter(|(s, _)| *s == Some(section)) + .map(|(_, p)| *p) + .fold(None, |m: Option, p| Some(m.map_or(p, |m| m.max(p)))) + else { + return after_all; + }; + let next = articles + .iter() + .map(|(_, p)| *p) + .filter(|p| *p > last) + .fold(None, |m: Option, p| Some(m.map_or(p, |m| m.min(p)))); + next.map_or(last + 1.0, |next| (last + next) / 2.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(list: &[&str]) -> Vec { + list.iter().map(|s| (*s).to_owned()).collect() + } + + fn art<'a>(id: &'a str, section: &'a str, position: f64, visible: bool) -> Article<'a> { + Article { id, section: Some(section), position, visible } + } + + #[test] + fn the_order_holds_the_articles_one_can_see_in_the_order_they_read() { + let doc = [ + art("I1", "introduction", 1.0, true), + art("T1", "fondamental", 3.0, true), + art("E2", "fondamental", 5.0, true), + art("E1", "fondamental", 4.0, true), + // A draft, or an article with nothing left open: out. + art("N1", "fondamental", 5.5, false), + ]; + assert_eq!(members(&doc, "fondamental"), ids(&["T1", "E1", "E2"])); + assert_eq!(members(&doc, "introduction"), ids(&["I1"])); + assert!(members(&doc, "annexe").is_empty()); + // The same place: the identifier settles it. + let tie = [art("b", "s", 1.0, true), art("a", "s", 1.0, true)]; + assert_eq!(members(&tie, "s"), ids(&["a", "b"])); + } + + #[test] + fn an_order_of_fewer_than_two_articles_has_nothing_to_decide() { + assert!(is_moot(&[])); + assert!(is_moot(&ids(&["O1"]))); + assert!(!is_moot(&ids(&["K1", "K2"]))); + } + + #[test] + fn an_order_is_proposed_with_every_article_of_the_section_each_once() { + let section = ids(&["T1", "E1", "E2"]); + assert_eq!(check(&ids(&["E2", "E1", "T1"]), §ion), Ok(())); + assert_eq!(check(&ids(&["T1", "E1", "E2"]), §ion), Err(OrderError::Unchanged)); + for wrong in [&["E2", "E1"][..], &["E2", "E1", "T1", "T1"], &["E2", "E1", "X9"], &["E2", "E2", "T1"], &[]] { + assert_eq!(check(&ids(wrong), §ion), Err(OrderError::NotTheSection), "{wrong:?}"); + } + } + + #[test] + fn a_proposal_still_fitting_the_section_is_read_as_it_was_proposed() { + let section = ids(&["T1", "E1", "E2"]); + let f = fit(&ids(&["E2", "T1", "E1"]), §ion); + assert_eq!(f, Fitted { order: ids(&["E2", "T1", "E1"]), added: vec![], dropped: vec![] }); + } + + #[test] + fn an_article_that_enters_the_section_goes_at_the_end_of_a_proposal() { + // Proposed when the section held T1, E1, E2; N4 then N7 entered since. + let now = ids(&["T1", "N4", "E1", "E2", "N7"]); + let f = fit(&ids(&["E2", "E1", "T1"]), &now); + assert_eq!(f.order, ids(&["E2", "E1", "T1", "N4", "N7"]), "the newcomers at the end, in the section's order"); + assert_eq!(f.added, ids(&["N4", "N7"])); + assert!(f.dropped.is_empty()); + } + + #[test] + fn an_article_that_leaves_the_section_is_dropped_from_a_proposal() { + let now = ids(&["T1", "E2"]); + let f = fit(&ids(&["E2", "E1", "T1"]), &now); + assert_eq!(f.order, ids(&["E2", "T1"])); + assert_eq!(f.dropped, ids(&["E1"])); + assert!(f.added.is_empty()); + // Both at once; a repeated or unknown line never doubles an article. + let f = fit(&ids(&["E2", "E2", "E1", "X1", "T1"]), &ids(&["T1", "E2", "N4"])); + assert_eq!((f.order, f.added, f.dropped), (ids(&["E2", "T1", "N4"]), ids(&["N4"]), ids(&["E1", "X1"]))); + } + + #[test] + fn a_proposal_that_comes_back_to_the_order_in_force_changes_nothing() { + // [E1, T1] proposed over [T1, E1]; E1 left: completed, it is the order in force. + let now = ids(&["T1"]); + assert_eq!(canonical(&fit(&ids(&["E1", "T1"]), &now).order), canonical(&now)); + } + + #[test] + fn the_body_of_an_order_proposal_is_one_article_per_line() { + let order = ids(&["a1f0", "77c2", "09bd"]); + assert_eq!(canonical(&order), "a1f0\n77c2\n09bd"); + assert_eq!(decode(&canonical(&order)), Some(order)); + assert_eq!(decode("a1\r\nb2"), Some(ids(&["a1", "b2"]))); + for unreadable in ["", "a1\n\nb2", "a1 b2", "\n"] { + assert_eq!(decode(unreadable), None, "{unreadable:?}"); + } + } + + #[test] + fn entering_into_force_gives_the_places_of_the_section_out_again() { + let current = vec![("T1".to_owned(), 3.0), ("E1".to_owned(), 4.0), ("E2".to_owned(), 5.0)]; + let mut moved = positions(&ids(&["E2", "T1", "E1"]), ¤t); + moved.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!(moved, vec![("E1".to_owned(), 5.0), ("E2".to_owned(), 3.0), ("T1".to_owned(), 4.0)]); + // Uneven places stay the section's own: the neighbours never mingle with it. + let uneven = vec![("a".to_owned(), 6.0), ("b".to_owned(), 6.5), ("c".to_owned(), 11.0)]; + assert_eq!( + positions(&ids(&["c", "a", "b"]), &uneven), + vec![("c".to_owned(), 6.0), ("a".to_owned(), 6.5), ("b".to_owned(), 11.0)] + ); + // An article of the order without a place is left out; a place without an article is kept. + assert_eq!(positions(&ids(&["z", "b"]), &uneven), vec![("b".to_owned(), 6.5)]); + } + + #[test] + fn an_order_is_voted_like_any_version_with_the_margin_of_the_one_in_force() { + use crate::compose::{ProposalKind, ProposalState, UnitState, VersionText, adoptions}; + use crate::inertia::{InertiaLevel, Scale}; + use crate::threshold::Tally; + // Monnaie libre before any median: W 5745, C 50; the order of a section at level 2. + let scale = Scale { electorate: 5745, floor: 50.0, participation: 100.0 / 5745.0 }; + let section = ids(&["T1", "E1", "E2"]); + let module = |tally: Tally| UnitState { + id: "ordre.fondamental".into(), + position: 0.0, + inertia: InertiaLevel::new(2).expect("level"), + in_force: Some(VersionText { id: "seed".into(), title: String::new(), body: canonical(§ion), tally }), + }; + let proposal = |id: &str, order: &[&str], for_votes: u32| ProposalState { + id: id.into(), + unit_id: "ordre.fondamental".into(), + kind: ProposalKind::Text, + title: String::new(), + body: canonical(&fit(&ids(order), §ion).order), + tally: Tally { for_votes, against: 0 }, + submitted_at: "2026-10-31T00:00:00Z".into(), + blocked: false, + }; + let seed = [module(Tally::default())]; + // Never under C voices for; the order in force at zero votes is a seed a minima: margin −C. + assert!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 49)], &scale).expect("valid").is_empty()); + assert_eq!(adoptions(&seed, &[proposal("p", &["E2", "E1", "T1"], 50)], &scale).expect("valid"), ["p"]); + // An order that is the one in force changes nothing, whatever its votes. + assert!(adoptions(&seed, &[proposal("same", &["T1", "E1", "E2"], 80)], &scale).expect("valid").is_empty()); + // Adopted by a vote, the order in force holds its margin: a weaker order waits, a stronger one passes. + let voted = [module(Tally { for_votes: 70, against: 0 })]; + assert!(adoptions(&voted, &[proposal("w", &["E2", "E1", "T1"], 60)], &scale).expect("valid").is_empty()); + assert_eq!(adoptions(&voted, &[proposal("s", &["E2", "E1", "T1"], 90)], &scale).expect("valid"), ["s"]); + } + + #[test] + fn a_new_article_goes_at_the_end_of_its_section() { + let doc = [ + (Some("introduction"), 1.0), + (Some("introduction"), 2.0), + (Some("fondamental"), 3.0), + (Some("fondamental"), 5.0), + (Some("technique"), 6.0), + ]; + // Between the last of its section and the first that follows it. + assert_eq!(place_at_end(&doc, Some("introduction")), 2.5); + assert_eq!(place_at_end(&doc, Some("fondamental")), 5.5); + // The last section of the document, an unknown section, or none: after everything. + assert_eq!(place_at_end(&doc, Some("technique")), 7.0); + assert_eq!(place_at_end(&doc, Some("annexe")), 7.0); + assert_eq!(place_at_end(&doc, None), 7.0); + assert_eq!(place_at_end(&[], Some("introduction")), 1.0); + // After the ones already placed at its end. + let again = [(Some("fondamental"), 5.0), (Some("fondamental"), 5.5), (Some("technique"), 6.0)]; + assert_eq!(place_at_end(&again, Some("fondamental")), 5.75); + } +} diff --git a/crates/ld-engine/src/pending.rs b/crates/ld-engine/src/pending.rs index d3c1b72..a4bebe6 100644 --- a/crates/ld-engine/src/pending.rs +++ b/crates/ld-engine/src/pending.rs @@ -1,9 +1,11 @@ //! What is left to vote in a document — the line of « Décisions ». //! -//! A module is an article (a unit). It stays **to vote** as long as no version +//! A module is an article, the title of the document, or the order of a +//! section (ADR 0009) — a unit. It stays **to vote** as long as no version //! of it was adopted by a vote: its version in force is a seed a minima not yet //! confirmed, or it has no version in force and at least one open version. -//! Variants of an article count once. For a person who votes, a module is +//! Variants of an article count once. The order of a section of fewer than two +//! articles has nothing to decide: never to vote. For a person who votes, a module is //! **without their vote** when they hold no valid vote (one not « to //! reconfirm ») on any of its versions. @@ -17,10 +19,16 @@ pub struct Module { pub open: u32, /// The person holds a valid vote on one of its versions, in force or open. pub voted: bool, + /// Nothing to decide: the order of a section of fewer than two articles + /// (`order::is_moot`). Never to vote, and not shown. + pub moot: bool, } impl Module { pub fn to_vote(&self) -> bool { + if self.moot { + return false; + } match self.in_force { Some(adopted) => !adopted, None => self.open > 0, @@ -51,7 +59,7 @@ mod tests { const ADOPTED: Option = Some(true); fn m(in_force: Option, open: u32, voted: bool) -> Module { - Module { in_force, open, voted } + Module { in_force, open, voted, moot: false } } #[test] @@ -88,6 +96,25 @@ mod tests { assert_eq!(pending(&modules), Pending { to_vote: 31, without_my_vote: 30 }); } + #[test] + fn an_order_with_nothing_to_decide_is_never_to_vote() { + let one_article = Module { moot: true, ..m(SEED, 1, false) }; + assert!(!one_article.to_vote()); + assert_eq!(pending(&[one_article]), Pending::default()); + } + + #[test] + fn the_modules_of_the_certification_act() { + // 33 articles; the title of the document; the order of each of the 8 sections, + // all seeds a minima — but « ordonnancement » holds one article: 41 to vote. + let mut modules = vec![m(SEED, 0, false); 3]; + modules.extend(vec![m(None, 1, false); 30]); + modules.push(m(SEED, 0, false)); + modules.extend(vec![m(SEED, 0, false); 7]); + modules.push(Module { moot: true, ..m(SEED, 0, false) }); + assert_eq!(pending(&modules), Pending { to_vote: 41, without_my_vote: 41 }); + } + #[test] fn nothing_to_vote_in_an_empty_document() { assert_eq!(pending(&[]), Pending::default()); diff --git a/crates/ld-server/migrations/0007_modules.sql b/crates/ld-server/migrations/0007_modules.sql new file mode 100644 index 0000000..b535061 --- /dev/null +++ b/crates/ld-server/migrations/0007_modules.sql @@ -0,0 +1,26 @@ +-- Modules (2026-10-31, ADR 0009): the title of a document and the order of the +-- articles of each of its sections, voted like its articles. Additive only: +-- every unit of before is an article, nothing else changes. +-- +-- A module is a unit of its own kind. Its versions are proposals, voted, +-- counted, signed on the chain and brought into force like those of an +-- article; their kind says what they hold: +-- title — `title` is the title of the document in its source language; +-- order — `body` is the order of the section, one article (unit id) per line. + +ALTER TABLE units ADD COLUMN kind TEXT NOT NULL DEFAULT 'article'; -- article | title | order + +-- The modules added to an existing document (src/module_amend.rs): decided +-- once per document, from what it holds then (its title, the order of each +-- section), never touching an article or a vote. Each decision is kept here: +-- applied (and what was created), refused (and why), or present (the document +-- was born with its modules). +CREATE TABLE module_amendments ( + amendment_id TEXT NOT NULL, + doc_id TEXT NOT NULL REFERENCES documents(id), + outcome TEXT NOT NULL, -- applied | refused | present + reason TEXT, -- why it was refused + created TEXT, -- JSON array of the codes of the modules created + at TEXT NOT NULL, + PRIMARY KEY (amendment_id, doc_id) +); diff --git a/crates/ld-server/src/adoption.rs b/crates/ld-server/src/adoption.rs index 951a11a..69c2f31 100644 --- a/crates/ld-server/src/adoption.rs +++ b/crates/ld-server/src/adoption.rs @@ -28,6 +28,8 @@ pub struct Entered { } pub async fn enqueue_publication(state: &AppState, doc: &DocRow, reason: &str) -> AppResult<()> { + // A text in force changed: the document's title follows its title in force (ADR 0009). + crate::doc_title::sync(state, &doc.id).await?; if doc.publish_path.is_none() { return Ok(()); } @@ -118,7 +120,8 @@ pub async fn run(state: &AppState, doc_id: &str) -> AppResult> { .bind(&unit.id) .execute(&mut *tx) .await?; - "text" + // A module does more: an order moves its articles, a title renames the document. + crate::modules::on_entry(&mut tx, &st, unit, p).await? } else { sqlx::query("UPDATE proposals SET status = 'applied', entered_at = ?, updated_at = ? WHERE id = ?") .bind(&now) diff --git a/crates/ld-server/src/doc_title.rs b/crates/ld-server/src/doc_title.rs new file mode 100644 index 0000000..2a8050a --- /dev/null +++ b/crates/ld-server/src/doc_title.rs @@ -0,0 +1,171 @@ +//! The title of a document, a module (ADR 0009, level 4). +//! +//! Its version in force is the title in the source language, the one that +//! holds. `documents.title` keeps the title as every list and the publication +//! read it: the source language from the version in force, the other languages +//! from its editorial translations (`sync`). +//! +//! When another title enters into force, the document changes title in the +//! source language. Its translations stay editorial (Yvv, 2026-10-21: the +//! French is voted, the translations are not): the new version takes those of +//! the title it replaces, pinned to that title's text, so they read « à +//! revoir » until a member translates the new one. + +use crate::AppState; +use crate::auth::User; +use crate::docs::{self, ProposalRow, UnitRow}; +use crate::error::{AppError, AppResult}; +use crate::modules::{self, ModuleKind, NewVersion}; +use crate::util::{self, LangMap}; +use axum::Json; +use axum::extract::{Path, State}; +use ld_engine::edit; +use serde::Deserialize; +use serde_json::{Value, json}; +use sqlx::SqliteConnection; + +/// The longest title, as for an article. +const MAX_TITLE: usize = 120; + +/// The translations of the title a document starts with, from its seed or its row. +pub async fn seed_translations( + conn: &mut SqliteConnection, + proposal_id: &str, + title: &str, + titles: &LangMap, + src: &str, + now: &str, +) -> sqlx::Result<()> { + let hash = util::source_hash(title, ""); + for lang in util::LANGS.iter().filter(|l| **l != src) { + let Some(t) = titles.get(*lang).filter(|t| !t.trim().is_empty()) else { continue }; + sqlx::query( + "INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at) + VALUES (?, ?, ?, '', ?, 'seed', NULL, ?)", + ) + .bind(proposal_id) + .bind(lang) + .bind(t) + .bind(&hash) + .bind(now) + .execute(&mut *conn) + .await?; + } + Ok(()) +} + +/// `documents.title` from the title in force and its translations; a language +/// the version in force does not translate keeps the title it had. +pub async fn sync_conn(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result<()> { + let row: Option<(String, String, String, String)> = sqlx::query_as( + "SELECT p.id, p.title, d.title, d.source_lang FROM units u + JOIN proposals p ON p.id = u.in_force_id JOIN documents d ON d.id = u.doc_id + WHERE u.doc_id = ? AND u.kind = 'title'", + ) + .bind(doc_id) + .fetch_optional(&mut *conn) + .await?; + let Some((pid, title, raw, src)) = row else { return Ok(()) }; + let mut titles = util::parse_lang(&raw); + titles.insert(src, title); + let translated: Vec<(String, String)> = + sqlx::query_as("SELECT lang, title FROM translations WHERE proposal_id = ?") + .bind(&pid) + .fetch_all(&mut *conn) + .await?; + for (lang, t) in translated { + titles.insert(lang, t); + } + let next = util::lang_json(&titles); + if next != raw { + sqlx::query("UPDATE documents SET title = ? WHERE id = ?").bind(&next).bind(doc_id).execute(&mut *conn).await?; + } + Ok(()) +} + +/// After any change to a text in force (a typo, a translation, an entry): the +/// document's title follows its title in force. +pub async fn sync(state: &AppState, doc_id: &str) -> sqlx::Result<()> { + let mut conn = state.db.acquire().await?; + sync_conn(&mut conn, doc_id).await +} + +/// Another title enters into force: it takes the translations of the one it +/// replaces where it has none — « à revoir », pinned to the former title — and +/// becomes the document's title. +pub async fn enter(conn: &mut SqliteConnection, doc_id: &str, unit: &UnitRow, p: &ProposalRow) -> AppResult<()> { + if let Some(before) = unit.in_force_id.as_deref().filter(|b| *b != p.id) { + sqlx::query( + "INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at) + SELECT ?1, t.lang, t.title, t.body, t.source_hash, t.origin, t.updated_by, t.updated_at + FROM translations t + WHERE t.proposal_id = ?2 + AND NOT EXISTS (SELECT 1 FROM translations n WHERE n.proposal_id = ?1 AND n.lang = t.lang)", + ) + .bind(&p.id) + .bind(before) + .execute(&mut *conn) + .await?; + } + sync_conn(conn, doc_id).await?; + tracing::info!(doc = %doc_id, proposal = %p.id, "title of the document in force"); + Ok(()) +} + +#[derive(Deserialize)] +pub struct TitleBody { + title: String, +} + +/// Propose another title for the document. An open proposal of the same title +/// is given back rather than doubled. +pub async fn propose( + State(state): State, + User(user): User, + Path(unit_id): Path, + Json(b): Json, +) -> AppResult> { + let doc_id: String = sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?") + .bind(&unit_id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + let st = docs::load_state(&state, &doc_id).await?; + docs::require_open(&state, &user, &st.group).await?; + let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?; + if st.module_of(&unit.id) != Some(ModuleKind::Title) { + return Err(AppError::Invalid("not_a_title")); + } + if edit::has_hidden_controls(&b.title) { + return Err(AppError::Invalid("hidden_characters")); + } + let title = b.title.trim(); + if title.is_empty() || title.chars().count() > MAX_TITLE || title.contains(['\n', '\r']) { + return Err(AppError::Invalid("title")); + } + let in_force = unit.in_force_id.as_deref().and_then(|id| st.proposals.iter().find(|p| p.id == id)); + if in_force.is_some_and(|p| p.title == title) { + return Err(AppError::Invalid("same_title")); + } + if let Some(existing) = st.proposals.iter().find(|p| { + p.unit_id == unit.id && p.status == "open" && p.kind == ModuleKind::Title.as_str() && p.title == title + }) { + return Ok(Json(json!({ "id": existing.id, "existing": true }))); + } + let now = util::now(); + let mut conn = state.db.acquire().await?; + let id = modules::insert_version( + &mut conn, + NewVersion { + unit_id: &unit.id, + kind: ModuleKind::Title, + title, + body: "", + author: Some(&user.id), + in_force: false, + now: &now, + }, + ) + .await?; + Ok(Json(json!({ "id": id, "existing": false }))) +} diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index 994ac0c..a824b1f 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -7,6 +7,7 @@ use crate::AppState; use crate::auth::{IdentityRow, User}; use crate::ballots::{self, MyBallot, MyVote}; use crate::error::{AppError, AppResult}; +use crate::modules::ModuleKind; use crate::participation; use crate::util::{self, LangMap}; use axum::Json; @@ -146,9 +147,10 @@ pub async fn group( let docs = if open { sqlx::query_as::<_, DocListRow>( "SELECT d.id, d.slug, d.title, d.summary, - (SELECT COUNT(*) FROM units u WHERE u.doc_id = d.id AND u.in_force_id IS NOT NULL) AS in_force, + (SELECT COUNT(*) FROM units u + WHERE u.doc_id = d.id AND u.kind = 'article' AND u.in_force_id IS NOT NULL) AS in_force, (SELECT COUNT(*) FROM proposals p JOIN units u ON u.id = p.unit_id - WHERE u.doc_id = d.id AND p.status = 'open') AS open + WHERE u.doc_id = d.id AND u.kind = 'article' AND p.status = 'open') AS open FROM documents d WHERE d.group_slug = ? ORDER BY d.created_at", ) .bind(&slug) @@ -250,7 +252,10 @@ pub fn inertia_of(unit: &UnitRow) -> InertiaLevel { pub struct DocState { pub doc: DocRow, pub group: GroupRow, + /// Every unit, articles and modules, by position. pub units: Vec, + /// The units that are modules (ADR 0009), by unit id; any other is an article. + pub module_kinds: HashMap, pub proposals: Vec, pub tallies: HashMap, pub blocked: HashSet, @@ -296,6 +301,13 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult { .bind(doc_id) .fetch_all(&state.db) .await?; + let kinds: Vec<(String, String)> = + sqlx::query_as("SELECT id, kind FROM units WHERE doc_id = ? AND kind <> 'article'") + .bind(doc_id) + .fetch_all(&state.db) + .await?; + let module_kinds: HashMap = + kinds.into_iter().filter_map(|(id, kind)| Some((id, ModuleKind::parse(&kind)?))).collect(); // Same second (the variants of a seed): the order they were written in, not their // random identifiers. The engine ranks rivals on its own; this order is what people see. let proposals = sqlx::query_as::<_, ProposalRow>(&format!( @@ -370,6 +382,7 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult { doc, group, units, + module_kinds, proposals, tallies, blocked, @@ -396,38 +409,126 @@ impl DocState { self.tallies.get(id).copied().unwrap_or_default() } + /// The module a unit is, if it is one. + pub fn module_of(&self, unit_id: &str) -> Option { + self.module_kinds.get(unit_id).copied() + } + + /// The articles, by position: every unit that is no module. + pub fn articles(&self) -> impl Iterator { + self.units.iter().filter(|u| !self.module_kinds.contains_key(&u.id)) + } + + /// The modules of a kind; the orders in the order of the document's sections. + pub fn modules(&self, kind: ModuleKind) -> Vec<&UnitRow> { + let mut out: Vec<&UnitRow> = self.units.iter().filter(|u| self.module_of(&u.id) == Some(kind)).collect(); + let sections = self.sections(); + let rank = |u: &UnitRow| u.section.as_ref().and_then(|s| sections.iter().position(|x| x == s)); + out.sort_by_key(|u| (rank(u).unwrap_or(usize::MAX), u.code.clone())); + out + } + + /// The sections of the document, in the order its articles read in. + pub fn sections(&self) -> Vec { + let mut out: Vec = Vec::new(); + for u in self.articles() { + if let Some(s) = &u.section + && !out.contains(s) + { + out.push(s.clone()); + } + } + out + } + + /// The articles in the order of `section` now: those one can see — a text in + /// force, or an open version — by position (`ld_engine::order::members`; + /// `order::sections_now` says the same in SQL). + pub fn members(&self, section: &str) -> Vec { + let open: HashSet<&str> = self + .proposals + .iter() + .filter(|p| p.status == "open" && matches!(p.kind.as_str(), "text" | "remove")) + .map(|p| p.unit_id.as_str()) + .collect(); + let articles: Vec> = self + .articles() + .map(|u| ld_engine::order::Article { + id: &u.id, + section: u.section.as_deref(), + position: u.position, + visible: u.in_force_id.is_some() || open.contains(u.id.as_str()), + }) + .collect(); + ld_engine::order::members(&articles, section) + } + + /// The order each order module's section reads in now, by unit id. + fn live_orders(&self) -> HashMap> { + self.modules(ModuleKind::Order) + .into_iter() + .map(|u| (u.id.clone(), self.members(u.section.as_deref().unwrap_or_default()))) + .collect() + } + + /// The units as the engine sees them. The version in force of an order is + /// the order its section reads in now: the engine compares orders as texts. pub fn unit_states(&self) -> Vec { let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect(); + let live = self.live_orders(); self.units .iter() .map(|u| UnitState { id: u.id.clone(), position: u.position, inertia: inertia_of(u), - in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| VersionText { - id: p.id.clone(), - title: p.title.clone(), - body: p.body.clone(), - tally: self.tally(&p.id), + in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| match live.get(&u.id) { + Some(order) => VersionText { + id: p.id.clone(), + title: String::new(), + body: ld_engine::order::canonical(order), + tally: self.tally(&p.id), + }, + None => VersionText { + id: p.id.clone(), + title: p.title.clone(), + body: p.body.clone(), + tally: self.tally(&p.id), + }, }), }) .collect() } - /// Open proposals only — the in-force versions compete as incumbents. + /// Open proposals only — the in-force versions compete as incumbents. An + /// order proposal is read as it fits its section now (completed at the end); + /// one that holds no order is blocked. pub fn proposal_states(&self) -> Vec { + let live = self.live_orders(); self.proposals .iter() .filter(|p| p.status == "open") - .map(|p| ProposalState { - id: p.id.clone(), - unit_id: p.unit_id.clone(), - kind: p.kind(), - title: p.title.clone(), - body: p.body.clone(), - tally: self.tally(&p.id), - submitted_at: p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()), - blocked: self.blocked.contains(&p.id), + .map(|p| { + let mut s = ProposalState { + id: p.id.clone(), + unit_id: p.unit_id.clone(), + kind: p.kind(), + title: p.title.clone(), + body: p.body.clone(), + tally: self.tally(&p.id), + submitted_at: p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()), + blocked: self.blocked.contains(&p.id), + }; + if let Some(members) = live.get(&p.unit_id).filter(|_| p.kind == ModuleKind::Order.as_str()) { + s.title = String::new(); + match ld_engine::order::decode(&p.body) { + Some(order) => { + s.body = ld_engine::order::canonical(&ld_engine::order::fit(&order, members).order); + } + None => s.blocked = true, + } + } + s }) .collect() } @@ -527,6 +628,8 @@ fn proposal_json( let (mine, ballot) = mine; let eval = st.evaluation(unit, p); let (kind, target) = match p.kind() { + // A module's version says what it holds (ADR 0009). + ProposalKind::Text if matches!(p.kind.as_str(), "order" | "title") => (p.kind.as_str(), None), ProposalKind::Text => ("text", None), ProposalKind::Remove => ("remove", None), ProposalKind::Inertia(t) => ("inertia", Some(t)), @@ -572,28 +675,47 @@ pub async fn document( let pending = ballots::my_ballots(&state, &user.id, &id).await?; let changes = inertia_changes(&state, &id).await?; - let units: Vec<_> = st - .units - .iter() - .map(|u| { - let of_unit: Vec<_> = visible - .iter() - .filter(|p| p.unit_id == u.id) - .map(|p| { - let of = (mine.get(&p.id), pending.get(&p.id)); - proposal_json(&st, u, p, of, translations.get(&p.id), &user.id) - }) - .collect(); - let in_force = u - .in_force_id - .as_ref() - .and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned()); - let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect(); - json!({ - "id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": inertia_of(u), - "inertiaChanges": changes.get(&u.id).cloned().unwrap_or_default(), - "inForce": in_force, "proposals": proposals, + let unit_json = |u: &UnitRow| { + let of_unit: Vec<_> = visible + .iter() + .filter(|p| p.unit_id == u.id) + .map(|p| { + let of = (mine.get(&p.id), pending.get(&p.id)); + let mut j = proposal_json(&st, u, p, of, translations.get(&p.id), &user.id); + if st.module_of(&u.id) == Some(ModuleKind::Order) { + j["order"] = crate::order::version_json(&st, u, p); + } + j }) + .collect(); + let in_force = u + .in_force_id + .as_ref() + .and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned()); + let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect(); + json!({ + "id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": inertia_of(u), + "inertiaChanges": changes.get(&u.id).cloned().unwrap_or_default(), + "inForce": in_force, "proposals": proposals, + }) + }; + let units: Vec<_> = st.articles().map(unit_json).collect(); + // The modules (ADR 0009): the title of the document, the order of each section. + let title = st.modules(ModuleKind::Title).first().map(|u| { + let mut j = unit_json(u); + j["kind"] = json!("title"); + j + }); + let orders: Vec<_> = st + .modules(ModuleKind::Order) + .into_iter() + .map(|u| { + let members = st.members(u.section.as_deref().unwrap_or_default()); + let mut j = unit_json(u); + j["kind"] = json!("order"); + j["moot"] = json!(ld_engine::order::is_moot(&members)); + j["members"] = json!(members); + j }) .collect(); @@ -612,6 +734,7 @@ pub async fn document( "publication": publication, }, "units": units, + "modules": { "title": title, "orders": orders }, // The five levels, with the *for* votes asked at the usual participation. "levels": st.levels_json(st.usual_voters()), }))) @@ -687,7 +810,9 @@ pub async fn preview( Some("wellrated") => PreviewMode::WellRated, _ => PreviewMode::InForce, }; - let units = compose::preview(&st.unit_states(), &st.proposal_states(), &st.scale(), mode) + // The articles only: a module changes no text of the preview. + let articles: Vec = st.unit_states().into_iter().filter(|u| st.module_of(&u.id).is_none()).collect(); + let units = compose::preview(&articles, &st.proposal_states(), &st.scale(), mode) .map_err(|_| AppError::Invalid("electorate"))?; // Translations of the versions shown. let shown: HashSet = units diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index 1a1a614..4eb2c25 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -11,10 +11,14 @@ pub mod adoption; pub mod auth; pub mod ballots; pub mod config; +pub mod doc_title; pub mod docs; pub mod error; pub mod indexer; pub mod inertia_amend; +pub mod module_amend; +pub mod modules; +pub mod order; pub mod overview; pub mod participation; pub mod proposals; @@ -131,6 +135,8 @@ pub fn api_router() -> Router { .route("/proposals/{id}/translations/{lang}", put(proposals::translate)) .route("/signals/{id}/lift", post(proposals::lift_signal)) .route("/units/{id}/inertia", post(proposals::propose_inertia)) + .route("/units/{id}/order", post(order::propose)) + .route("/units/{id}/title", post(doc_title::propose)) .route("/feedback", post(access::send_feedback)) .route("/access", get(access::overview)) .route("/access/groups/{slug}/members", post(access::grant)) diff --git a/crates/ld-server/src/module_amend.rs b/crates/ld-server/src/module_amend.rs new file mode 100644 index 0000000..dba7aee --- /dev/null +++ b/crates/ld-server/src/module_amend.rs @@ -0,0 +1,221 @@ +//! Module amendments — the modules a seed now declares, added to a document +//! that was born without them (ADR 0009). +//! +//! Yvv, 2026-10-30: the title of the document at level 4, the order of the +//! articles of each section at level 2. A new base gets them from the seed +//! (`modules` in the seed file). An existing base — production holds two +//! instances of the certification act, Monnaie libre and the test group — gets +//! them through an amendment listed here, built **from what the document holds +//! then**: its title as it reads, and the order each section reads in. Nothing +//! else is written: no article moves, no text changes, no vote is touched. +//! +//! Like the seed and inertia amendments, each document is decided once, at the +//! first start that knows the amendment, and the decision is kept in +//! `module_amendments` (applied with the codes created, refused and why, or +//! present) and in the logs. Applied where the document publishes, the +//! publication queue gets a row (`document.json` carries the modules). + +use crate::AppState; +use crate::modules::{self, Levels}; +use crate::seed::SeedDoc; +use crate::util; +use ld_engine::inertia::InertiaLevel; +use sqlx::SqliteConnection; + +/// Modules to add to every document instantiated from a seed. +pub struct ModuleAmendment { + /// Stable identifier: each document's decision is kept under it. + pub id: &'static str, + /// Slug of the seed document. + pub doc: &'static str, + /// The level of the title module: the seed's today, which must say so. + pub title: u8, + /// The level of the order modules: the seed's today, which must say so. + pub order: u8, + /// Reason given to the publication queue, and so to the commit of the texts. + pub reason: &'static str, +} + +pub const AMENDMENTS: &[ModuleAmendment] = &[ModuleAmendment { + id: "2026-10-31-modules-titre-ordre", + doc: "acte-engagement-certification", + title: 4, + order: 2, + reason: "modules ajoutés : le titre du document, l'ordre des articles de chaque section", +}]; + +/// What the database says of the document. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Facts { + /// Its title in its source language. + pub title: String, + /// Modules it holds already, of either kind. + pub modules: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + Apply, + /// Born with its modules: nothing to do. + Present, + Refuse(&'static str), +} + +/// The rule, apart from the database. +pub fn verdict(facts: Option<&Facts>) -> Verdict { + let Some(f) = facts else { return Verdict::Refuse("no_document") }; + if f.modules > 0 { + Verdict::Present + } else if f.title.trim().is_empty() { + Verdict::Refuse("no_title") + } else { + Verdict::Apply + } +} + +/// A decision taken at this start. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Outcome { + pub amendment: &'static str, + pub group: String, + pub verdict: Verdict, + pub created: Vec, +} + +/// Decides every amendment not yet decided, document by document. +pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[ModuleAmendment]) -> anyhow::Result> { + let mut out = Vec::new(); + for a in list { + let Some(levels) = seed_says(seeds, a) else { continue }; + let docs: Vec<(String, String, Option)> = + sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug") + .bind(a.doc) + .fetch_all(&state.db) + .await?; + for (doc_id, group, publish_path) in docs { + let Some((v, created)) = decide(state, a, levels, &doc_id, publish_path.is_some()).await? else { + continue; + }; + match v { + Verdict::Apply => tracing::info!(amendment = a.id, %group, created = ?created, "modules added"), + Verdict::Present => tracing::info!(amendment = a.id, %group, "modules: already there"), + Verdict::Refuse(why) => { + tracing::warn!(amendment = a.id, %group, reason = why, "modules refused: the document stays as it is") + } + } + if v == Verdict::Apply && publish_path.is_some() { + state.publish_wake.notify_one(); + } + out.push(Outcome { amendment: a.id, group, verdict: v, created }); + } + } + Ok(out) +} + +/// The seed must declare the modules at the levels the amendment gives: a seed +/// edited later never reaches a base by this way. +fn seed_says(seeds: &[SeedDoc], a: &ModuleAmendment) -> Option { + let levels = seeds.iter().find(|s| s.slug == a.doc).and_then(|s| s.modules.as_ref()).map(Levels::from); + let wanted = Levels { title: InertiaLevel::new(a.title), order: InertiaLevel::new(a.order) }; + match levels { + Some(l) if l == wanted && l.title.is_some() && l.order.is_some() => Some(l), + _ => { + tracing::error!( + amendment = a.id, + doc = a.doc, + "module amendment: the seed does not declare these modules, nothing decided" + ); + None + } + } +} + +async fn facts(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result> { + let doc: Option<(String, String)> = sqlx::query_as("SELECT title, source_lang FROM documents WHERE id = ?") + .bind(doc_id) + .fetch_optional(&mut *conn) + .await?; + let Some((raw, src)) = doc else { return Ok(None) }; + let modules: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE doc_id = ? AND kind <> 'article'") + .bind(doc_id) + .fetch_one(&mut *conn) + .await?; + Ok(Some(Facts { title: util::parse_lang(&raw).get(&src).cloned().unwrap_or_default(), modules })) +} + +/// One document, in one transaction: `None` when it was decided at an earlier start. +async fn decide( + state: &AppState, + a: &ModuleAmendment, + levels: Levels, + doc_id: &str, + publishes: bool, +) -> anyhow::Result)>> { + let mut tx = state.db.begin().await?; + let decided: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM module_amendments WHERE amendment_id = ? AND doc_id = ?") + .bind(a.id) + .bind(doc_id) + .fetch_one(&mut *tx) + .await?; + if decided > 0 { + return Ok(None); + } + let found = facts(&mut tx, doc_id).await?; + let v = verdict(found.as_ref()); + let now = util::now(); + let created = if v == Verdict::Apply { modules::create(&mut tx, doc_id, levels, &now).await? } else { Vec::new() }; + if v == Verdict::Apply && publishes { + sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)") + .bind(doc_id) + .bind(a.reason) + .bind(&now) + .bind(&now) + .execute(&mut *tx) + .await?; + } + let (outcome, reason) = match v { + Verdict::Apply => ("applied", None), + Verdict::Present => ("present", None), + Verdict::Refuse(why) => ("refused", Some(why)), + }; + sqlx::query( + "INSERT INTO module_amendments (amendment_id, doc_id, outcome, reason, created, at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(a.id) + .bind(doc_id) + .bind(outcome) + .bind(reason) + .bind((v == Verdict::Apply).then(|| serde_json::to_string(&created).unwrap_or_else(|_| "[]".into()))) + .bind(&now) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Some((v, created))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn doc(title: &str, modules: i64) -> Facts { + Facts { title: title.into(), modules } + } + + #[test] + fn a_document_without_modules_receives_them() { + assert_eq!(verdict(Some(&doc("Acte d'engagement Certification", 0))), Verdict::Apply); + } + + #[test] + fn a_document_born_with_its_modules_is_left_as_it_is() { + assert_eq!(verdict(Some(&doc("Acte", 9))), Verdict::Present); + assert_eq!(verdict(Some(&doc("Acte", 1))), Verdict::Present, "never a second set"); + } + + #[test] + fn nothing_without_a_document_or_a_title() { + assert_eq!(verdict(None), Verdict::Refuse("no_document")); + assert_eq!(verdict(Some(&doc(" ", 0))), Verdict::Refuse("no_title")); + } +} diff --git a/crates/ld-server/src/modules.rs b/crates/ld-server/src/modules.rs new file mode 100644 index 0000000..43f51dc --- /dev/null +++ b/crates/ld-server/src/modules.rs @@ -0,0 +1,240 @@ +//! Modules — what a document decides besides its articles (ADR 0009). +//! +//! Yvv, 2026-10-30: « En fin de section, il y a un "module" qui permet de +//! changer l'ordre des "articles". Lui-même à (2) » ; « Le titre du doc à (4) » +//! — and, asked whether the title of an article is voted apart: « Non, titre et +//! texte ensemble ». So two modules, each a unit of its own kind: +//! +//! - the **title of the document** (`title`, level 4): its version in force is +//! the title in the source language (`doc_title`); +//! - the **order of the articles of each section** (`order`, level 2): its +//! version in force is the order the section reads in now (`order`). +//! +//! A module is voted like an article — unfolded, in the basket, on the Ğ1 +//! chain — because its versions are proposals like any other: the same +//! threshold, the same margin over the version in force, the same seed a +//! minima at the start. What differs is only what entering into force does. +//! +//! A new document gets its modules from the seed (`modules` in the seed file); +//! an existing one, once, from what it holds (`module_amend`). Both go through +//! `create`, which reads the document as it is and touches no article and no +//! vote. + +use crate::docs::{DocState, ProposalRow, UnitRow}; +use crate::error::{AppError, AppResult}; +use crate::seed::SeedModules; +use crate::util; +use crate::{doc_title, order}; +use ld_engine::inertia::InertiaLevel; +use sqlx::SqliteConnection; + +/// The code of the title module of a document. +pub const TITLE_CODE: &str = "titre"; + +/// The code of the order module of a section. +pub fn order_code(section: &str) -> String { + format!("ordre.{section}") +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ModuleKind { + Title, + Order, +} + +impl ModuleKind { + /// `units.kind`; an article is no module. + pub fn parse(raw: &str) -> Option { + match raw { + "title" => Some(Self::Title), + "order" => Some(Self::Order), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Title => "title", + Self::Order => "order", + } + } +} + +/// The levels a document's modules start at; `None`: no such module. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Levels { + pub title: Option, + pub order: Option, +} + +impl From<&SeedModules> for Levels { + fn from(m: &SeedModules) -> Self { + Self { title: m.title.as_ref().map(|t| t.inertia), order: m.order.as_ref().map(|o| o.inertia) } + } +} + +/// A version of a module, written directly in force (a seed a minima) or open. +pub(crate) struct NewVersion<'a> { + pub unit_id: &'a str, + pub kind: ModuleKind, + pub title: &'a str, + pub body: &'a str, + pub author: Option<&'a str>, + pub in_force: bool, + pub now: &'a str, +} + +pub(crate) async fn insert_version(conn: &mut SqliteConnection, v: NewVersion<'_>) -> sqlx::Result { + let id = util::id(); + let (status, origin) = if v.in_force { ("in_force", "seed") } else { ("open", "member") }; + sqlx::query( + "INSERT INTO proposals (id, unit_id, kind, title, body, status, origin, label, author_id, revision, + created_at, updated_at, submitted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, 1, ?, ?, ?)", + ) + .bind(&id) + .bind(v.unit_id) + .bind(v.kind.as_str()) + .bind(v.title) + .bind(v.body) + .bind(status) + .bind(origin) + .bind(v.author) + .bind(v.now) + .bind(v.now) + .bind(v.now) + .execute(&mut *conn) + .await?; + sqlx::query( + "INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) + VALUES (?, 1, 'initial', ?, ?, ?, ?)", + ) + .bind(&id) + .bind(v.title) + .bind(v.body) + .bind(v.author) + .bind(v.now) + .execute(&mut *conn) + .await?; + if v.in_force { + sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?") + .bind(&id) + .bind(v.unit_id) + .execute(&mut *conn) + .await?; + } + Ok(id) +} + +async fn insert_unit( + conn: &mut SqliteConnection, + doc_id: &str, + kind: ModuleKind, + code: &str, + section: Option<&str>, + level: InertiaLevel, + now: &str, +) -> sqlx::Result { + let id = util::id(); + // Its place is not among the articles: the reader puts each module where it belongs. + sqlx::query( + "INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at, kind) + VALUES (?, ?, ?, ?, 0, ?, NULL, ?, ?)", + ) + .bind(&id) + .bind(doc_id) + .bind(code) + .bind(section) + .bind(level.as_db()) + .bind(now) + .bind(kind.as_str()) + .execute(&mut *conn) + .await?; + Ok(id) +} + +/// Creates the modules of a document from what it holds now — its title, and +/// the order each of its sections reads in — each in force as a seed a minima. +/// No article, no vote is touched. Returns the codes created. +pub async fn create(conn: &mut SqliteConnection, doc_id: &str, levels: Levels, now: &str) -> sqlx::Result> { + let mut created = Vec::new(); + if let Some(level) = levels.title { + let (raw, src): (String, String) = sqlx::query_as("SELECT title, source_lang FROM documents WHERE id = ?") + .bind(doc_id) + .fetch_one(&mut *conn) + .await?; + let titles = util::parse_lang(&raw); + let title = titles.get(&src).cloned().unwrap_or_default(); + let unit = insert_unit(conn, doc_id, ModuleKind::Title, TITLE_CODE, None, level, now).await?; + let pid = insert_version( + conn, + NewVersion { + unit_id: &unit, + kind: ModuleKind::Title, + title: &title, + body: "", + author: None, + in_force: true, + now, + }, + ) + .await?; + doc_title::seed_translations(conn, &pid, &title, &titles, &src, now).await?; + created.push(TITLE_CODE.to_owned()); + } + if let Some(level) = levels.order { + for (section, members) in order::sections_now(conn, doc_id).await? { + let code = order_code(§ion); + let unit = insert_unit(conn, doc_id, ModuleKind::Order, &code, Some(§ion), level, now).await?; + let body = ld_engine::order::canonical(&members); + insert_version( + conn, + NewVersion { + unit_id: &unit, + kind: ModuleKind::Order, + title: &code, + body: &body, + author: None, + in_force: true, + now, + }, + ) + .await?; + created.push(code); + } + } + Ok(created) +} + +/// What entering into force does, besides the version taking its place: the +/// order of a section moves its articles, the title of a document becomes its +/// title. Returns the kind kept in `adoptions` (`text` for an article). +pub async fn on_entry( + conn: &mut SqliteConnection, + st: &DocState, + unit: &UnitRow, + p: &ProposalRow, +) -> AppResult<&'static str> { + match st.module_of(&unit.id) { + None => Ok("text"), + Some(ModuleKind::Order) => { + order::enter(conn, st, unit, p).await?; + Ok("order") + } + Some(ModuleKind::Title) => { + doc_title::enter(conn, &st.doc.id, unit, p).await?; + Ok("title") + } + } +} + +/// The generic writing of a version stops at a module: one proposes a module +/// through its own door (`order::propose`, `doc_title::propose`). +pub fn require_article(st: &DocState, unit_id: &str) -> AppResult<()> { + if st.module_of(unit_id).is_some() { Err(AppError::Invalid("module")) } else { Ok(()) } +} + +/// An order is never corrected nor translated: another order is another proposal. +pub fn require_text(p: &ProposalRow) -> AppResult<()> { + if p.kind == ModuleKind::Order.as_str() { Err(AppError::Conflict("order_fixed")) } else { Ok(()) } +} diff --git a/crates/ld-server/src/order.rs b/crates/ld-server/src/order.rs new file mode 100644 index 0000000..cf3eb1e --- /dev/null +++ b/crates/ld-server/src/order.rs @@ -0,0 +1,166 @@ +//! The order of the articles of a section, a module (ADR 0009) — the rules are +//! the engine's (`ld_engine::order`); here, the database and the door to +//! propose one. +//! +//! - The version in force is the order the section reads in now: its articles +//! one can see, by position. The text the engine compares for it is that live +//! order, so it always reads what the reader shows. +//! - A proposal is another order of the same articles, each once. While it is +//! under vote, articles may enter the section or leave it: it is completed at +//! the end (`ld_engine::order::fit`), and its votes stay. +//! - Entering into force gives the section's places out again in the new order; +//! the articles keep their texts, their votes, their sections. + +use crate::AppState; +use crate::auth::User; +use crate::docs::{self, DocState, ProposalRow, UnitRow}; +use crate::error::{AppError, AppResult}; +use crate::modules::{self, ModuleKind, NewVersion}; +use crate::util; +use axum::Json; +use axum::extract::{Path, State}; +use ld_engine::order::{self, Article, OrderError}; +use serde::Deserialize; +use serde_json::{Value, json}; +use sqlx::SqliteConnection; + +/// More articles than any section will hold: a longer list is refused unread. +const MAX_ARTICLES: usize = 500; + +/// Every section of a document, in the order of the document, with the articles +/// of its order now (read inside a transaction: the seed writes them in it). +pub async fn sections_now(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result)>> { + #[derive(sqlx::FromRow)] + struct Row { + id: String, + section: Option, + position: f64, + visible: bool, + } + // Seen: a text in force, or an open version (`DocState::members` says the same). + let rows = sqlx::query_as::<_, Row>( + "SELECT u.id, u.section, u.position, + (u.in_force_id IS NOT NULL OR EXISTS (SELECT 1 FROM proposals p + WHERE p.unit_id = u.id AND p.status = 'open' AND p.kind IN ('text', 'remove'))) AS visible + FROM units u WHERE u.doc_id = ? AND u.kind = 'article' ORDER BY u.position, u.id", + ) + .bind(doc_id) + .fetch_all(&mut *conn) + .await?; + let articles: Vec> = rows + .iter() + .map(|r| Article { id: &r.id, section: r.section.as_deref(), position: r.position, visible: r.visible }) + .collect(); + let mut sections: Vec = Vec::new(); + for r in &rows { + if let Some(s) = &r.section + && !sections.contains(s) + { + sections.push(s.clone()); + } + } + Ok(sections + .into_iter() + .map(|s| { + let members = order::members(&articles, &s); + (s, members) + }) + .collect()) +} + +/// The order an order version gives its section now, as the screen tells it. +pub fn version_json(st: &DocState, unit: &UnitRow, p: &ProposalRow) -> Value { + let members = st.members(unit.section.as_deref().unwrap_or_default()); + if p.status == "in_force" { + return json!({ "ids": members, "added": [], "dropped": [], "same": true }); + } + match order::decode(&p.body) { + Some(proposed) => { + let f = order::fit(&proposed, &members); + let same = f.order == members; + json!({ "ids": f.order, "added": f.added, "dropped": f.dropped, "same": same }) + } + None => json!({ "ids": [], "added": [], "dropped": [], "same": false, "unreadable": true }), + } +} + +/// The order enters into force: its articles take the section's places in its order. +pub async fn enter(conn: &mut SqliteConnection, st: &DocState, unit: &UnitRow, p: &ProposalRow) -> AppResult<()> { + let members = st.members(unit.section.as_deref().unwrap_or_default()); + // An unreadable order is blocked: it never comes this far. + let Some(proposed) = order::decode(&p.body) else { return Ok(()) }; + let fitted = order::fit(&proposed, &members); + let current: Vec<(String, f64)> = members + .iter() + .filter_map(|id| st.units.iter().find(|u| &u.id == id).map(|u| (id.clone(), u.position))) + .collect(); + for (id, position) in order::positions(&fitted.order, ¤t) { + sqlx::query("UPDATE units SET position = ? WHERE id = ? AND doc_id = ? AND kind = 'article'") + .bind(position) + .bind(&id) + .bind(&st.doc.id) + .execute(&mut *conn) + .await?; + } + tracing::info!(doc = %st.doc.id, unit = %unit.code, added = fitted.added.len(), dropped = fitted.dropped.len(), "order of a section in force"); + Ok(()) +} + +#[derive(Deserialize)] +pub struct OrderBody { + /// Every article of the section (unit ids), each once, in the order proposed. + order: Vec, +} + +/// Propose another order for a section. An open proposal that already reads in +/// this order is given back rather than doubled. +pub async fn propose( + State(state): State, + User(user): User, + Path(unit_id): Path, + Json(b): Json, +) -> AppResult> { + let doc_id: String = sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?") + .bind(&unit_id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + let st = docs::load_state(&state, &doc_id).await?; + docs::require_open(&state, &user, &st.group).await?; + let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?; + if st.module_of(&unit.id) != Some(ModuleKind::Order) { + return Err(AppError::Invalid("not_an_order")); + } + if b.order.len() > MAX_ARTICLES { + return Err(AppError::Invalid("order")); + } + let members = st.members(unit.section.as_deref().unwrap_or_default()); + order::check(&b.order, &members).map_err(|e| match e { + OrderError::NotTheSection => AppError::Invalid("order"), + OrderError::Unchanged => AppError::Invalid("same_order"), + })?; + if let Some(existing) = st.proposals.iter().find(|p| { + p.unit_id == unit.id + && p.status == "open" + && p.kind == ModuleKind::Order.as_str() + && order::decode(&p.body).is_some_and(|o| order::fit(&o, &members).order == b.order) + }) { + return Ok(Json(json!({ "id": existing.id, "existing": true }))); + } + let now = util::now(); + let mut conn = state.db.acquire().await?; + let id = modules::insert_version( + &mut conn, + NewVersion { + unit_id: &unit.id, + kind: ModuleKind::Order, + title: &unit.code, + body: &order::canonical(&b.order), + author: Some(&user.id), + in_force: false, + now: &now, + }, + ) + .await?; + Ok(Json(json!({ "id": id, "existing": false }))) +} diff --git a/crates/ld-server/src/overview.rs b/crates/ld-server/src/overview.rs index bd8cc66..162e05f 100644 --- a/crates/ld-server/src/overview.rs +++ b/crates/ld-server/src/overview.rs @@ -133,9 +133,11 @@ pub async fn decisions( 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. +/// The modules of a document as `me` sees them — its articles, its title, the +/// order of each section (ADR 0009): the version in force (adopted by a vote, +/// or a seed a minima), the open versions, and whether `me` holds a valid vote +/// on one of them. A change of inertia is not a version of the text; the order +/// of a section of fewer than two articles has nothing to decide. async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult> { #[derive(sqlx::FromRow)] struct VersionRow { @@ -144,6 +146,13 @@ async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult = st + .modules(crate::modules::ModuleKind::Order) + .into_iter() + .filter(|u| ld_engine::order::is_moot(&st.members(u.section.as_deref().unwrap_or_default()))) + .map(|u| u.id.clone()) + .collect(); let rows = sqlx::query_as::<_, VersionRow>( "SELECT u.id AS unit_id, IFNULL(p.id = u.in_force_id, 0) AS in_force, @@ -164,7 +173,7 @@ async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult = None; for r in rows { if current.as_deref() != Some(r.unit_id.as_str()) { - out.push(Module::default()); + out.push(Module { moot: moot.contains(&r.unit_id), ..Module::default() }); current = Some(r.unit_id); } if let Some(m) = out.last_mut() { diff --git a/crates/ld-server/src/participation.rs b/crates/ld-server/src/participation.rs index 9a16587..77860c0 100644 --- a/crates/ld-server/src/participation.rs +++ b/crates/ld-server/src/participation.rs @@ -2,8 +2,13 @@ //! of inertia (ADR 0007, `ld_engine::inertia`). //! //! What is measured: the proposals of the group (every document, every kind, -//! drafts aside) that hold at least one vote on their current revision, the -//! last one cast within the past year. For each, T is the number of those +//! modules included) that hold at least one vote on their current revision, the +//! last one cast within the past year — those whose status is named here, and +//! only those: `open` (under vote), `in_force` (in force, still under vote) and +//! `applied` (a removal or a change of inertia that passed: a decision taken). +//! Drafts (nobody votes on them) and proposals withdrawn by their author (their +//! votes can no longer be taken back) stay out, as would any status to come +//! until it is named. For each, T is the number of those //! votes **at the time of measuring** — the same T the formula sees, since the //! vote is permanent. The median of these T, and how many proposals it rests //! on, are kept in `settings` under `participation:`. @@ -37,16 +42,15 @@ pub struct Measure { pub measured_at: String, } -/// The numbers of voters of the group's proposals voted on since `since`. -/// Drafts and withdrawn proposals stay out: neither is shown, and votes on a -/// withdrawn proposal can no longer be taken back. +/// The numbers of voters of the group's proposals voted on since `since`, for +/// the statuses counted only (the list is explicit: see the head of this file). pub async fn sample(state: &AppState, group: &str, since: &str) -> sqlx::Result> { let rows: Vec = sqlx::query_scalar( "SELECT COUNT(*) FROM votes v JOIN proposals p ON p.id = v.proposal_id JOIN units u ON u.id = p.unit_id JOIN documents d ON d.id = u.doc_id - WHERE d.group_slug = ? AND p.status NOT IN ('draft', 'withdrawn') AND v.revision = p.revision + WHERE d.group_slug = ? AND p.status IN ('open', 'in_force', 'applied') AND v.revision = p.revision GROUP BY v.proposal_id HAVING MAX(v.cast_at) >= ?", ) diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index 220c60f..2a71e04 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -15,8 +15,9 @@ use crate::AppState; use crate::adoption; use crate::auth::{IdentityRow, User}; use crate::ballots; -use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow, UnitRow}; +use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow}; use crate::error::{AppError, AppResult}; +use crate::modules; use crate::util; use axum::Json; use axum::extract::{Path, State}; @@ -162,6 +163,9 @@ pub async fn assist( pub struct CreateBody { kind: Option, unit_id: Option, + /// The section a new article goes in, at its end (ADR 0009). + section: Option, + /// Or an article of that section (an older client). after_unit_id: Option, title: String, #[serde(default)] @@ -170,27 +174,16 @@ pub struct CreateBody { submit: bool, } -/// Position right after `after` (or at the end), between it and the next unit. -fn position_after(units: &[UnitRow], after: Option<&str>) -> f64 { - let mut positions: Vec = units.iter().map(|u| u.position).collect(); - positions.sort_by(f64::total_cmp); - let Some(anchor) = after.and_then(|a| units.iter().find(|u| u.id == a)).map(|u| u.position) else { - return positions.last().copied().unwrap_or(0.0) + 1.0; - }; - match positions.iter().find(|p| **p > anchor) { - Some(next) => (anchor + next) / 2.0, - None => anchor + 1.0, - } -} - -async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppResult { +/// A new article goes at the end of its section (ADR 0009): the order of the +/// section is a module of its own, voted apart. +async fn new_unit(state: &AppState, st: &DocState, section: Option<&str>) -> AppResult { let id = util::id(); let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE doc_id = ? AND code LIKE 'N%'") .bind(&st.doc.id) .fetch_one(&state.db) .await?; - // A new article belongs to the section of the one it follows. - let section = after.and_then(|a| st.units.iter().find(|u| u.id == a)).and_then(|u| u.section.clone()); + let places: Vec<(Option<&str>, f64)> = st.articles().map(|u| (u.section.as_deref(), u.position)).collect(); + let position = ld_engine::order::place_at_end(&places, section); sqlx::query( "INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at) VALUES (?, ?, ?, ?, ?, ?, NULL, ?)", @@ -199,7 +192,7 @@ async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppRe .bind(&st.doc.id) .bind(format!("N{}", n + 1)) .bind(section) - .bind(position_after(&st.units, after)) + .bind(position) .bind(InertiaLevel::DEFAULT.as_db()) .bind(util::now()) .execute(&state.db) @@ -260,12 +253,22 @@ pub async fn create( let unit_id = match b.unit_id.as_deref() { Some(u) => { let unit = st.units.iter().find(|x| x.id == u).ok_or(AppError::NotFound)?; + modules::require_article(&st, &unit.id)?; if kind == "remove" && unit.in_force_id.is_none() { return Err(AppError::Invalid("nothing_to_remove")); } unit.id.clone() } - None if kind == "text" => new_unit(&state, &st, b.after_unit_id.as_deref()).await?, + None if kind == "text" => { + // A new article belongs to the section asked for, or to that of the article named. + let after = b.after_unit_id.as_deref().and_then(|a| st.articles().find(|u| u.id == a)); + let section = match b.section.as_deref() { + Some(s) if st.sections().iter().any(|x| x == s) => Some(s), + Some(_) => return Err(AppError::Invalid("section")), + None => after.and_then(|u| u.section.as_deref()), + }; + new_unit(&state, &st, section).await? + } None => return Err(AppError::Invalid("unit")), }; let id = insert_proposal( @@ -297,6 +300,7 @@ pub async fn edit( Json(b): Json, ) -> AppResult> { let (p, st) = guarded(&state, &user, &id).await?; + modules::require_text(&p)?; check_text(&b.title, &b.body, p.kind == "text")?; let (title, body) = (b.title.trim(), b.body.trim()); let is_author = p.author_id.as_deref() == Some(user.id.as_str()); @@ -477,7 +481,8 @@ pub struct SplitBody { } /// One draft becomes several: the first part stays, each other part becomes a -/// draft on a new unit placed right after. Drafts only — nobody voted yet. +/// draft on a new unit at the end of its section, in turn. Drafts only — nobody +/// voted yet. pub async fn split( State(state): State, User(user): User, @@ -505,10 +510,10 @@ pub async fn split( .execute(&state.db) .await?; let mut created = vec![id.clone()]; - let mut after = p.unit_id.clone(); + let section = st.articles().find(|u| u.id == p.unit_id).and_then(|u| u.section.clone()); for (n, part) in parts.iter().enumerate().skip(1) { let fresh = docs::load_state(&state, &st.doc.id).await?; - let unit = new_unit(&state, &fresh, Some(&after)).await?; + let unit = new_unit(&state, &fresh, section.as_deref()).await?; let title = format!("{} ({})", p.title, n + 1); let title: String = title.chars().take(MAX_TITLE).collect(); created.push( @@ -526,7 +531,6 @@ pub async fn split( ) .await?, ); - after = unit; } Ok(Json(json!({ "drafts": created }))) } @@ -835,6 +839,7 @@ pub async fn translate( Json(b): Json, ) -> AppResult> { let (p, st) = guarded(&state, &user, &id).await?; + modules::require_text(&p)?; if !util::is_lang(&lang) || lang == st.doc.source_lang { return Err(AppError::Invalid("lang")); } @@ -860,21 +865,3 @@ pub async fn translate( } Ok(Json(json!({ "ok": true }))) } - -#[cfg(test)] -mod tests { - use super::*; - - fn unit(id: &str, position: f64) -> UnitRow { - UnitRow { id: id.into(), code: id.into(), section: None, position, inertia: "3".into(), in_force_id: None } - } - - #[test] - fn new_units_slot_between_neighbours() { - let units = [unit("a", 1.0), unit("b", 2.0), unit("c", 4.0)]; - assert_eq!(position_after(&units, Some("a")), 1.5); - assert_eq!(position_after(&units, Some("c")), 5.0); - assert_eq!(position_after(&units, None), 5.0); - assert_eq!(position_after(&[], None), 1.0); - } -} diff --git a/crates/ld-server/src/publish.rs b/crates/ld-server/src/publish.rs index 873d45d..9b3522f 100644 --- a/crates/ld-server/src/publish.rs +++ b/crates/ld-server/src/publish.rs @@ -289,13 +289,15 @@ pub async fn status(state: &AppState) -> anyhow::Result { })) } -/// Markdown per language + JSON. Only the units in force, in order. +/// Markdown per language + JSON. Only the articles in force, in order; the +/// title of the document is its title in force (ADR 0009). pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result> { let titles = util::parse_lang(&st.doc.title); let src = st.doc.source_lang.clone(); + let modules = modules_json(state, st).await?; + let stale_title = |lang: &str| modules["title"]["translations"][lang]["stale"].as_bool().unwrap_or(false); let in_force: Vec<_> = st - .units - .iter() + .articles() .filter_map(|u| { let fid = u.in_force_id.as_ref()?; st.proposals.iter().find(|p| &p.id == fid).map(|p| (u, p)) @@ -313,6 +315,9 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result\n")); + } for (_, p) in &in_force { let t = tr.get(&p.id).and_then(|m| m.get(lang)); let (ut, ub, fallback) = match t { @@ -356,8 +361,61 @@ pub async fn render(state: &AppState, st: &DocState) -> anyhow::Result anyhow::Result { + use crate::modules::ModuleKind; + let status = |p: &docs::ProposalRow| { + if p.entered_at.is_some() { + "adopted" + } else if p.confirmed_at.is_some() { + "confirmed" + } else { + "seed-minimal" + } + }; + let version = |u: &docs::UnitRow| u.in_force_id.as_ref().and_then(|id| st.proposals.iter().find(|p| &p.id == id)); + let title = match st.modules(ModuleKind::Title).first().and_then(|u| Some((*u, version(u)?))) { + Some((u, p)) => { + let tr = docs::translations_for(state, &[p]).await.map_err(|e| anyhow::anyhow!("{e}"))?; + let translations: serde_json::Map = tr + .get(&p.id) + .map(|m| { + m.iter().map(|(l, t)| (l.clone(), json!({ "title": t["title"], "stale": t["stale"] }))).collect() + }) + .unwrap_or_default(); + let tally = st.tally(&p.id); + json!({ + "inertia": crate::docs::inertia_of(u), "status": status(p), "enteredAt": p.entered_at, + "votes": { "for": tally.for_votes, "against": tally.against }, "translations": translations, + }) + } + None => serde_json::Value::Null, + }; + let orders: Vec<_> = st + .modules(ModuleKind::Order) + .into_iter() + .filter_map(|u| { + let p = version(u)?; + let section = u.section.clone().unwrap_or_default(); + let codes: Vec<&str> = st + .members(§ion) + .iter() + .filter_map(|id| st.units.iter().find(|x| &x.id == id).map(|x| x.code.as_str())) + .collect(); + let tally = st.tally(&p.id); + Some(json!({ + "section": section, "inertia": crate::docs::inertia_of(u), "status": status(p), + "enteredAt": p.entered_at, "votes": { "for": tally.for_votes, "against": tally.against }, + "articles": codes, + })) + }) + .collect(); + Ok(json!({ "title": title, "orders": orders })) +} diff --git a/crates/ld-server/src/seed.rs b/crates/ld-server/src/seed.rs index ef1cd07..ef6d42d 100644 --- a/crates/ld-server/src/seed.rs +++ b/crates/ld-server/src/seed.rs @@ -7,10 +7,14 @@ //! existing document is a seed amendment (`seed_amend`): listed, decided once, //! and applied only to an article nobody has touched; a newer starting level of //! inertia, an inertia amendment (`inertia_amend`), on an article whose inertia -//! no vote changed. +//! no vote changed; modules the seed declares (the title, the order of each +//! section, ADR 0009), a module amendment (`module_amend`), built from what the +//! document holds. use crate::AppState; use crate::inertia_amend; +use crate::module_amend; +use crate::modules; use crate::seed_amend; use crate::util::{self, LangMap}; use ld_engine::inertia::InertiaLevel; @@ -25,9 +29,24 @@ pub struct SeedDoc { pub summary: LangMap, pub source_lang: String, pub instances: Vec, + /// The modules of the document (ADR 0009): its title, the order of each section. + #[serde(default)] + pub modules: Option, pub units: Vec, } +#[derive(Deserialize)] +pub struct SeedModules { + pub title: Option, + pub order: Option, +} + +#[derive(Deserialize)] +pub struct SeedModule { + /// The level it starts at, 1 to 5. + pub inertia: InertiaLevel, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SeedInstance { @@ -72,12 +91,13 @@ pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> { Ok(()) } -/// On start: the groups, the documents missing from them, then the seed and -/// inertia amendments (each decided once per document). +/// On start: the groups, the documents missing from them, then the seed, +/// inertia and module amendments (each decided once per document). pub async fn ensure(state: &AppState) -> anyhow::Result<()> { let seeds = documents(state).await?; seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?; inertia_amend::apply(state, &seeds, inertia_amend::AMENDMENTS).await?; + module_amend::apply(state, &seeds, module_amend::AMENDMENTS).await?; Ok(()) } @@ -223,6 +243,10 @@ async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> a } } } + // The modules, from the document just written: its title, the order of each section. + if let Some(m) = &seed.modules { + modules::create(&mut tx, &doc_id, m.into(), &now).await?; + } if inst.publish_path.is_some() { sqlx::query( "INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, 'publication initiale', ?, ?)", diff --git a/crates/ld-server/tests/ballots.rs b/crates/ld-server/tests/ballots.rs index b05e7d1..360e418 100644 --- a/crates/ld-server/tests/ballots.rs +++ b/crates/ld-server/tests/ballots.rs @@ -618,10 +618,10 @@ async fn a_session_sees_its_own_ballots_only() { assert_eq!((find(&mine, &i1)["myVote"].as_str(), find(&mine, &e1)["myVote"].as_str()), (Some("for"), None)); let theirs = other.doc("monnaie-libre").await; assert_eq!((find(&theirs, &i1)["myVote"].as_str(), find(&theirs, &e1)["myVote"].as_str()), (None, Some("against"))); - // « Décisions » counts the same way. + // « Décisions » counts the same way (33 articles, the title, 7 orders with something to decide). let d = chiara.ok("GET", "/api/groups/monnaie-libre/decisions", None).await; assert_eq!(d["canVote"], true); - assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(33), Some(32))); + assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(40))); } #[tokio::test] @@ -668,6 +668,81 @@ async fn fifty_voices_on_the_chain_make_a_text_pass_and_a_signal_holds_it() { assert!(unit(&d, "E5")["inForce"].is_null(), "a signal holds it back"); } +#[tokio::test] +async fn fifty_voices_on_the_chain_bring_an_order_and_a_title_into_force() { + // The modules (ADR 0009) are voted like articles: in a basket, in one transaction. + let indexer = Indexer::start().await; + let (mut c, state) = Client::new(chain_config(&indexer)).await; + c.login("Chiara").await; + let doc = c.doc("monnaie-libre").await; + let order = doc["modules"]["orders"] + .as_array() + .expect("orders") + .iter() + .find(|o| o["section"] == "conclusion") + .expect("conclusion") + .clone(); + let (k1, k2) = (unit(&doc, "K1")["id"].as_str().expect("K1"), unit(&doc, "K2")["id"].as_str().expect("K2")); + assert_eq!(order["members"], json!([k1, k2])); + let o = c + .ok( + "POST", + &format!("/api/units/{}/order", order["id"].as_str().expect("id")), + Some(json!({ "order": [k2, k1] })), + ) + .await["id"] + .as_str() + .expect("order") + .to_owned(); + let title_unit = doc["modules"]["title"]["id"].as_str().expect("title").to_owned(); + let t = c + .ok( + "POST", + &format!("/api/units/{title_unit}/title"), + Some(json!({ "title": "Acte d'engagement de certification" })), + ) + .await["id"] + .as_str() + .expect("title") + .to_owned(); + let doc = c.doc("monnaie-libre").await; + let keys: Vec = (1..=50u8).map(|n| address::encode(G1_PREFIX, &[n; 32])).collect(); + let mut last = Value::Null; + for (n, account) in keys.iter().enumerate() { + let b = c.ballot(&doc, &[(&o, Some("for")), (&t, Some("for"))], None).await; + let index = 20_000 + u32::try_from(n).expect("n"); + indexer.pay( + 2_000 + index, + b["comment"].as_str().expect("c"), + Signer { account, identity: Some((index, "Membre", true)) }, + ); + last = b; + if n % 10 == 9 { + ballots::sweep(&state).await.expect("sweep"); + } + } + ballots::sweep(&state).await.expect("sweep"); + let s = c.status(&last).await; + assert_eq!(s["status"], "confirmed"); + let mut entered: Vec<(&str, &str)> = s["entered"] + .as_array() + .expect("entered") + .iter() + .map(|e| (e["code"].as_str().unwrap_or_default(), e["kind"].as_str().unwrap_or_default())) + .collect(); + entered.sort_unstable(); + assert_eq!(entered, [("ordre.conclusion", "order"), ("titre", "title")], "{s}"); + let d = c.doc("monnaie-libre").await; + let (p1, p2) = (unit(&d, "K1")["position"].as_f64().expect("p"), unit(&d, "K2")["position"].as_f64().expect("p")); + assert!(p2 < p1, "K2 now reads before K1"); + assert_eq!(d["doc"]["title"]["fr"], "Acte d'engagement de certification"); + assert_eq!(d["modules"]["title"]["inForce"]["translations"]["en"]["stale"], true, "the English title, to review"); + // The adoptions keep what entered, by kind. + let kinds: Vec = + sqlx::query_scalar("SELECT kind FROM adoptions ORDER BY kind").fetch_all(&state.db).await.expect("k"); + assert_eq!(kinds, ["order", "title"]); +} + // --------------------------------------------------------------------------- // Without the chain, and in managed groups // --------------------------------------------------------------------------- diff --git a/crates/ld-server/tests/inertia.rs b/crates/ld-server/tests/inertia.rs index aa144cd..7ee777b 100644 --- a/crates/ld-server/tests/inertia.rs +++ b/crates/ld-server/tests/inertia.rs @@ -310,10 +310,66 @@ async fn a_withdrawn_proposal_leaves_the_usual_participation() { assert_eq!((p["source"].as_str(), p["sample"].as_u64()), (Some("reference"), Some(4))); } +#[tokio::test] +async fn the_usual_participation_counts_the_statuses_it_names_and_only_those() { + // Relecture of 71819f9: the list is explicit — open, in force, applied. A decision + // taken (a change of inertia applied) keeps its voters; a status not named stays out. + let (mut first, state) = Client::new().await; + let mut people = Vec::new(); + for name in ["S1", "S2"] { + let mut c = first.other(); + c.login(name).await; + people.push(c); + } + first.login("S1").await; + let doc = first.doc("essai").await; + let open: Vec = ["I2", "T1", "E4", "E5", "E6", "E8"] + .iter() + .map(|code| unit(&doc, code)["proposals"][0]["id"].as_str().expect("open").to_owned()) + .collect(); + for p in &open { + for c in &mut people { + c.vote(p, "against").await; + } + } + let sample = |state: AppState| async move { + ld_server::participation::refresh(&state, true).await.expect("refresh"); + ld_server::participation::stored(&state, "essai").await.expect("stored").expect("measure").sample + }; + assert_eq!(sample(state.clone()).await, 6); + for (id, status) in [(&open[0], "applied"), (&open[1], "in_force")] { + sqlx::query("UPDATE proposals SET status = ? WHERE id = ?") + .bind(status) + .bind(id) + .execute(&state.db) + .await + .expect("status"); + } + assert_eq!(sample(state.clone()).await, 6, "applied and in force stay counted"); + sqlx::query("UPDATE proposals SET status = 'superseded' WHERE id = ?") + .bind(&open[2]) + .execute(&state.db) + .await + .expect("status"); + assert_eq!(sample(state.clone()).await, 5, "a status not named is out"); +} + // --------------------------------------------------------------------------- // The base as production holds it on 2026-10-30, then the new version // --------------------------------------------------------------------------- +/// The seed as it was then: no modules yet (ADR 0009 came after). +fn seed_of_the_time(tag: &str) -> std::path::PathBuf { + let today = Config::for_tests().seed_dir.join("acte-engagement-certification.json"); + let mut seed: Value = serde_json::from_str(&std::fs::read_to_string(today).expect("seed")).expect("json"); + seed.as_object_mut().expect("object").remove("modules"); + let dir = std::env::temp_dir().join(format!("ld-levels-seed-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("dir"); + std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write"); + dir +} + /// Migrations up to 0004: the schema production runs before this version. async fn base_before_levels(tag: &str) -> (SqlitePool, AppState) { use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; @@ -325,6 +381,7 @@ async fn base_before_levels(tag: &str) -> (SqlitePool, AppState) { before.run(&pool).await.expect("migrations 1 to 4"); let mut cfg = Config::for_tests(); cfg.data_dir = std::env::temp_dir().join(format!("ld-levels-{tag}-{}", std::process::id())); + cfg.seed_dir = seed_of_the_time(tag); let state = AppState::new(cfg, pool.clone()).await.expect("state"); seed::documents(&state).await.expect("documents"); // The words of the time: every article `standard`. diff --git a/crates/ld-server/tests/modules.rs b/crates/ld-server/tests/modules.rs new file mode 100644 index 0000000..2a99fb8 --- /dev/null +++ b/crates/ld-server/tests/modules.rs @@ -0,0 +1,596 @@ +//! Modules (ADR 0009): the title of the document (level 4) and the order of the +//! articles of each section (level 2), voted like articles; the guarded +//! addition that gives them to a document born without them. + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use http_body_util::BodyExt; +use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed}; +use serde_json::{Value, json}; +use tower::ServiceExt; + +struct Client { + router: axum::Router, + cookie: Option, +} + +impl Client { + fn of(state: &AppState) -> Self { + Self { router: app(state.clone()), cookie: None } + } + + async fn new() -> (Self, AppState) { + let db = open_db("sqlite::memory:").await.expect("db"); + let state = AppState::new(Config::for_tests(), db).await.expect("state"); + seed::ensure(&state).await.expect("seed"); + (Self::of(&state), state) + } + + fn other(&self) -> Self { + Self { router: self.router.clone(), cookie: None } + } + + async fn call(&mut self, method: &str, uri: &str, body: Option) -> (StatusCode, Value) { + let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE); + if let Some(c) = &self.cookie { + req = req.header(header::COOKIE, c); + } + let req = match body { + Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())), + None => req.body(Body::empty()), + } + .expect("request"); + let res = self.router.clone().oneshot(req).await.expect("response"); + let status = res.status(); + if let Some(set) = res.headers().get(header::SET_COOKIE) { + let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned(); + if raw.starts_with("ld_session=") { + self.cookie = Some(raw); + } + } + let bytes = res.into_body().collect().await.expect("body").to_bytes(); + (status, serde_json::from_slice(&bytes).unwrap_or(Value::Null)) + } + + async fn ok(&mut self, method: &str, uri: &str, body: Option) -> Value { + let (s, v) = self.call(method, uri, body).await; + assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}"); + v + } + + async fn login(&mut self, name: &str) { + self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": false }))).await; + } + + async fn doc(&mut self, group: &str) -> Value { + let g = self.ok("GET", &format!("/api/groups/{group}"), None).await; + let id = g["docs"][0]["id"].as_str().expect("doc").to_owned(); + self.ok("GET", &format!("/api/docs/{id}"), None).await + } + + async fn vote(&mut self, proposal: &str, choice: &str) -> Value { + self.ok("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await + } +} + +fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value { + doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit") +} + +fn id_of(doc: &Value, code: &str) -> String { + unit(doc, code)["id"].as_str().expect("id").to_owned() +} + +fn order_of<'a>(doc: &'a Value, section: &str) -> &'a Value { + doc["modules"]["orders"].as_array().expect("orders").iter().find(|o| o["section"] == section).expect("order") +} + +/// The codes of the articles of a section one can see (a text in force or an open +/// version), in the order the document reads them. +fn read_order(doc: &Value, section: &str) -> Vec { + let seen = |u: &Value| { + !u["inForce"].is_null() + || u["proposals"] + .as_array() + .is_some_and(|p| p.iter().any(|p| p["status"] == "open" && p["kind"] != "inertia")) + }; + let mut units: Vec<&Value> = + doc["units"].as_array().expect("units").iter().filter(|u| u["section"] == section && seen(u)).collect(); + units.sort_by(|a, b| a["position"].as_f64().unwrap_or(0.0).total_cmp(&b["position"].as_f64().unwrap_or(0.0))); + units.iter().map(|u| u["code"].as_str().expect("code").to_owned()).collect() +} + +fn codes(doc: &Value, ids: &Value) -> Vec { + ids.as_array() + .expect("ids") + .iter() + .map(|id| { + doc["units"] + .as_array() + .expect("units") + .iter() + .find(|u| u["id"] == *id) + .map(|u| u["code"].as_str().expect("code").to_owned()) + .unwrap_or_else(|| format!("?{id}")) + }) + .collect() +} + +/// Two people of the test group (W = 2, C = 1): two « for » adopt. +async fn two() -> (Client, Client, AppState) { + let (mut a, state) = Client::new().await; + a.login("Yvv").await; + let mut b = a.other(); + b.login("Chiara").await; + (a, b, state) +} + +#[tokio::test] +async fn a_new_base_has_its_title_and_the_order_of_each_section() { + let (mut c, state) = Client::new().await; + c.login("Chiara").await; + for group in ["monnaie-libre", "essai"] { + let doc = c.doc(group).await; + // The articles stay the articles: 33 of them, none of the modules. + assert_eq!(doc["units"].as_array().expect("units").len(), 33, "{group}"); + let title = &doc["modules"]["title"]; + assert_eq!( + (title["kind"].as_str(), title["code"].as_str(), title["inertia"].as_u64()), + (Some("title"), Some("titre"), Some(4)) + ); + let v = &title["inForce"]; + assert_eq!( + (v["title"].as_str(), v["seedMinimal"].as_bool()), + (Some("Acte d'engagement Certification"), Some(true)) + ); + assert_eq!(v["translations"]["en"]["title"], "Certification Commitment Act"); + assert_eq!(v["translations"]["es"]["stale"], false); + // One order per section, in the order of the document, each at level 2. + let orders = doc["modules"]["orders"].as_array().expect("orders"); + let sections: Vec<&str> = orders.iter().filter_map(|o| o["section"].as_str()).collect(); + assert_eq!( + sections, + [ + "introduction", + "fondamental", + "technique", + "conclusion", + "annexe", + "formule", + "inertie", + "ordonnancement" + ] + ); + assert!(orders.iter().all(|o| o["inertia"] == 2 && o["inForce"]["seedMinimal"] == true)); + // Its version in force is the order the section reads in now. + let fondamental = order_of(&doc, "fondamental"); + assert_eq!(codes(&doc, &fondamental["members"]), ["T1", "E1", "E2"]); + assert_eq!(fondamental["inForce"]["order"]["ids"], fondamental["members"]); + assert_eq!(fondamental["moot"], false); + // « Ordonnancement » holds one article: nothing to decide. + assert_eq!(order_of(&doc, "ordonnancement")["moot"], true); + } + // Born with them: the addition says so, once per document. + let rows: Vec<(String, String)> = sqlx::query_as( + "SELECT d.group_slug, m.outcome FROM module_amendments m JOIN documents d ON d.id = m.doc_id ORDER BY d.group_slug", + ) + .fetch_all(&state.db) + .await + .expect("decisions"); + assert_eq!(rows, [("essai".into(), "present".into()), ("monnaie-libre".into(), "present".into())]); + // The previews show articles only. + let doc = c.doc("monnaie-libre").await; + let id = doc["doc"]["id"].as_str().expect("id"); + let all = c.ok("GET", &format!("/api/docs/{id}/preview?mode=all"), None).await; + assert_eq!(all["units"].as_array().expect("units").len(), 33); + // The list of the group counts articles only. + let g = c.ok("GET", "/api/groups/monnaie-libre", None).await; + assert_eq!((g["docs"][0]["inForce"].as_u64(), g["docs"][0]["open"].as_u64()), (Some(3), Some(32))); +} + +#[tokio::test] +async fn an_order_is_proposed_with_the_articles_of_its_section_then_voted_into_force() { + let (mut yvv, mut chiara, _) = two().await; + let doc = yvv.doc("essai").await; + let conclusion = order_of(&doc, "conclusion").clone(); + let module = conclusion["id"].as_str().expect("id").to_owned(); + let (k1, k2) = (id_of(&doc, "K1"), id_of(&doc, "K2")); + let uri = format!("/api/units/{module}/order"); + // Every article of the section, each once, in another order. + for (wrong, code) in [ + (json!([k1]), "order"), + (json!([k2, k1, k1]), "order"), + (json!([k2, id_of(&doc, "E1")]), "order"), + (json!([k1, k2]), "same_order"), + ] { + let (s, v) = yvv.call("POST", &uri, Some(json!({ "order": wrong }))).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)), "{wrong}"); + } + let first = yvv.ok("POST", &uri, Some(json!({ "order": [k2, k1] }))).await; + assert_eq!(first["existing"], false); + // The same order again: the open one is given back. + let again = chiara.ok("POST", &uri, Some(json!({ "order": [k2, k1] }))).await; + assert_eq!((again["id"].as_str(), again["existing"].as_bool()), (first["id"].as_str(), Some(true))); + let p = first["id"].as_str().expect("id").to_owned(); + let doc = yvv.doc("essai").await; + let proposal = &order_of(&doc, "conclusion")["proposals"][0]; + assert_eq!((proposal["kind"].as_str(), proposal["votedAtInertia"].as_u64()), (Some("order"), Some(2))); + assert_eq!(codes(&doc, &proposal["order"]["ids"]), ["K2", "K1"]); + assert_eq!(proposal["order"]["same"], false); + // Voted like an article: one voice of two is not enough, the second brings it into force. + assert!(yvv.vote(&p, "for").await["entered"].as_array().expect("e").is_empty()); + let v = chiara.vote(&p, "for").await; + assert_eq!( + (v["entered"][0]["code"].as_str(), v["entered"][0]["kind"].as_str()), + (Some("ordre.conclusion"), Some("order")) + ); + let doc = yvv.doc("essai").await; + assert_eq!(read_order(&doc, "conclusion"), ["K2", "K1"]); + let now = order_of(&doc, "conclusion"); + assert_eq!(now["inForce"]["id"], p.as_str()); + assert_eq!(codes(&doc, &now["inForce"]["order"]["ids"]), ["K2", "K1"]); + // The order of before goes back among the proposals, with its votes, and could return. + let back = &now["proposals"][0]; + assert_eq!((back["status"].as_str(), back["origin"].as_str()), (Some("open"), Some("seed"))); + assert_eq!(codes(&doc, &back["order"]["ids"]), ["K1", "K2"]); + // Nothing else moved: the other sections read as before, the texts are the same. + assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E2"]); + assert_eq!(read_order(&doc, "technique")[0], "T2"); +} + +#[tokio::test] +async fn articles_that_enter_or_leave_a_section_complete_an_order_under_vote() { + let (mut yvv, mut chiara, _) = two().await; + let doc = yvv.doc("essai").await; + let module = order_of(&doc, "inertie")["id"].as_str().expect("id").to_owned(); + let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + let (n1, n11) = (id_of(&doc, "N1"), id_of(&doc, "N1.1")); + let p = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [n11, n1] }))).await["id"] + .as_str() + .expect("id") + .to_owned(); + yvv.vote(&p, "for").await; + // A new article enters the section: at its end, and at the end of the order under vote. + let created = chiara + .ok( + "POST", + &format!("/api/docs/{doc_id}/proposals"), + Some(json!({ "section": "inertie", "title": "Inertie des modules", "body": "Le titre du document est à 4.", "submit": true })), + ) + .await; + let doc = yvv.doc("essai").await; + let fresh = unit(&doc, "N1")["code"] + .as_str() + .map(|_| ()) + .and_then(|()| doc["units"].as_array().expect("u").iter().find(|u| u["id"] == created["unitId"]).cloned()); + let fresh = fresh.expect("the new article"); + let new_code = fresh["code"].as_str().expect("code").to_owned(); + assert_eq!(read_order(&doc, "inertie"), ["N1", "N1.1", new_code.as_str()], "a new article goes at the end"); + let module_now = order_of(&doc, "inertie"); + let open = module_now["proposals"].as_array().expect("p").iter().find(|x| x["id"] == p.as_str()).expect("p"); + assert_eq!(codes(&doc, &open["order"]["ids"]), ["N1.1", "N1", new_code.as_str()]); + assert_eq!(codes(&doc, &open["order"]["added"]), [new_code.as_str()]); + // Its vote stays: completing an order never asks the voters again. + assert_eq!((open["tally"]["for"].as_u64(), open["myVote"].as_str()), (Some(1), Some("for"))); + // The new article leaves again (withdrawn by its author): dropped, as if never there. + let added = created["id"].as_str().expect("id"); + chiara.ok("POST", &format!("/api/proposals/{added}/withdraw"), None).await; + let doc = yvv.doc("essai").await; + let open = order_of(&doc, "inertie")["proposals"] + .as_array() + .expect("p") + .iter() + .find(|x| x["id"] == p.as_str()) + .cloned() + .expect("p"); + assert_eq!(codes(&doc, &open["order"]["ids"]), ["N1.1", "N1"]); + assert!(open["order"]["added"].as_array().expect("a").is_empty()); + // An order proposed with three articles, one of which then leaves: dropped from it. + let created = chiara + .ok( + "POST", + &format!("/api/docs/{doc_id}/proposals"), + Some(json!({ "section": "inertie", "title": "Autre", "body": "Un article de passage dans la section.", "submit": true })), + ) + .await; + let other = created["unitId"].as_str().expect("unit").to_owned(); + let three = yvv.ok("POST", &format!("/api/units/{module}/order"), Some(json!({ "order": [other, n11, n1] }))).await + ["id"] + .as_str() + .expect("id") + .to_owned(); + chiara.ok("POST", &format!("/api/proposals/{}/withdraw", created["id"].as_str().expect("id")), None).await; + let doc = yvv.doc("essai").await; + let o = order_of(&doc, "inertie")["proposals"] + .as_array() + .expect("p") + .iter() + .find(|x| x["id"] == three.as_str()) + .cloned() + .expect("o"); + assert_eq!(codes(&doc, &o["order"]["ids"]), ["N1.1", "N1"]); + assert_eq!(o["order"]["dropped"], json!([other])); + // Completed, it reads like the first one: the second voice brings that order in. + let v = chiara.vote(&p, "for").await; + assert_eq!(v["entered"][0]["kind"], "order"); + let doc = yvv.doc("essai").await; + assert_eq!(read_order(&doc, "inertie"), ["N1.1", "N1"]); + // Now the order in force: the other one changes nothing more and cannot enter. + let o = order_of(&doc, "inertie")["proposals"] + .as_array() + .expect("p") + .iter() + .find(|x| x["id"] == three.as_str()) + .cloned() + .expect("o"); + assert_eq!(o["order"]["same"], true); + yvv.vote(&three, "for").await; + assert!(chiara.vote(&three, "for").await["entered"].as_array().expect("e").is_empty()); +} + +#[tokio::test] +async fn the_generic_writing_stops_at_a_module_and_an_order_is_never_corrected() { + let (mut yvv, _, _) = two().await; + let doc = yvv.doc("essai").await; + let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + let conclusion = order_of(&doc, "conclusion"); + let module = conclusion["id"].as_str().expect("id"); + let seed_order = conclusion["inForce"]["id"].as_str().expect("id"); + let (s, v) = yvv + .call( + "POST", + &format!("/api/docs/{doc_id}/proposals"), + Some(json!({ "unitId": module, "title": "Texte", "body": "Un texte sur un module.", "submit": true })), + ) + .await; + assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("module"))); + let (s, v) = yvv + .call( + "PATCH", + &format!("/api/proposals/{seed_order}"), + Some(json!({ "title": "ordre.conclusion", "body": "x\ny" })), + ) + .await; + assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("order_fixed"))); + let (s, v) = yvv + .call( + "PUT", + &format!("/api/proposals/{seed_order}/translations/en"), + Some(json!({ "title": "order", "body": "" })), + ) + .await; + assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("order_fixed"))); + // A title and an order each have their own door, and only that one. + let title = doc["modules"]["title"]["id"].as_str().expect("title"); + let (s, v) = yvv.call("POST", &format!("/api/units/{title}/order"), Some(json!({ "order": [] }))).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("not_an_order"))); + let (s, v) = yvv.call("POST", &format!("/api/units/{module}/title"), Some(json!({ "title": "Autre" }))).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("not_a_title"))); + // A module's inertia is voted like an article's. + let i = yvv.ok("POST", &format!("/api/units/{module}/inertia"), Some(json!({ "target": 3 }))).await; + assert_eq!(i["existing"], false); +} + +#[tokio::test] +async fn another_title_enters_into_force_and_its_translations_are_to_review() { + let (mut yvv, mut chiara, state) = two().await; + let doc = yvv.doc("essai").await; + let module = doc["modules"]["title"]["id"].as_str().expect("id").to_owned(); + let uri = format!("/api/units/{module}/title"); + for (wrong, code) in [ + (" ", "title"), + ("Deux\nlignes", "title"), + ("Acte d'engagement Certification", "same_title"), + ("Acte\u{200b}d'engagement", "hidden_characters"), + ] { + let (s, v) = yvv.call("POST", &uri, Some(json!({ "title": wrong }))).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some(code)), "{wrong:?}"); + } + let new_title = "Acte d'engagement de la certification"; + let p = yvv.ok("POST", &uri, Some(json!({ "title": new_title }))).await["id"].as_str().expect("id").to_owned(); + let doc = yvv.doc("essai").await; + let proposal = &doc["modules"]["title"]["proposals"][0]; + assert_eq!((proposal["kind"].as_str(), proposal["votedAtInertia"].as_u64()), (Some("title"), Some(4))); + yvv.vote(&p, "for").await; + let v = chiara.vote(&p, "for").await; + assert_eq!((v["entered"][0]["code"].as_str(), v["entered"][0]["kind"].as_str()), (Some("titre"), Some("title"))); + // The document changes title in French; English and Spanish stay, « à revoir ». + let doc = yvv.doc("essai").await; + assert_eq!(doc["doc"]["title"]["fr"], new_title); + assert_eq!(doc["doc"]["title"]["en"], "Certification Commitment Act"); + let tr = &doc["modules"]["title"]["inForce"]["translations"]; + assert_eq!((tr["en"]["stale"].as_bool(), tr["es"]["stale"].as_bool()), (Some(true), Some(true))); + let g = yvv.ok("GET", "/api/groups/essai", None).await; + assert_eq!(g["docs"][0]["title"]["fr"], new_title); + let d = yvv.ok("GET", "/api/groups/essai/decisions", None).await; + assert_eq!(d["docs"][0]["title"]["fr"], new_title); + // A member translates it again: the document's English title follows, up to date. + yvv.ok( + "PUT", + &format!("/api/proposals/{p}/translations/en"), + Some(json!({ "title": "Commitment Act of Certification", "body": "" })), + ) + .await; + let doc = yvv.doc("essai").await; + assert_eq!(doc["doc"]["title"]["en"], "Commitment Act of Certification"); + assert_eq!(doc["modules"]["title"]["inForce"]["translations"]["en"]["stale"], false); + // A typo of the title in force is corrected without a vote, and the document follows. + let fixed = "Acte d’engagement de la certification"; + let r = chiara.ok("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": fixed, "body": "" }))).await; + assert_eq!(r["edit"], "typo"); + assert_eq!(yvv.doc("essai").await["doc"]["title"]["fr"], fixed); + // The test group never publishes; the title of the publication is covered by `render`. + let n: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE d.group_slug = 'essai'", + ) + .fetch_one(&state.db) + .await + .expect("outbox"); + assert_eq!(n, 0); +} + +#[tokio::test] +async fn a_new_article_goes_at_the_end_of_the_section_asked_for() { + let (mut yvv, _, _) = two().await; + let doc = yvv.doc("essai").await; + let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + let new = |section: Option<&str>, after: Option| json!({ "section": section, "afterUnitId": after, "title": "Nouveau", "body": "Un nouvel article.", "submit": true }); + let a = yvv.ok("POST", &format!("/api/docs/{doc_id}/proposals"), Some(new(Some("fondamental"), None))).await; + // An older client names an article: the end of its section, not right after it. + let b = yvv.ok("POST", &format!("/api/docs/{doc_id}/proposals"), Some(new(None, Some(id_of(&doc, "I1"))))).await; + let (s, v) = yvv.call("POST", &format!("/api/docs/{doc_id}/proposals"), Some(new(Some("nulle-part"), None))).await; + assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("section"))); + let doc = yvv.doc("essai").await; + let code = |created: &Value| { + doc["units"].as_array().expect("u").iter().find(|u| u["id"] == created["unitId"]).expect("unit")["code"] + .as_str() + .expect("code") + .to_owned() + }; + let (ca, cb) = (code(&a), code(&b)); + assert_eq!(read_order(&doc, "fondamental"), ["T1", "E1", "E2", ca.as_str()]); + assert_eq!(read_order(&doc, "introduction"), ["I1", "I2", cb.as_str()]); + // It joins the order of its section, at its end. + assert_eq!(codes(&doc, &order_of(&doc, "fondamental")["members"]), ["T1", "E1", "E2", ca.as_str()]); +} + +// --------------------------------------------------------------------------- +// A base born without modules, as production holds it, then the new version +// --------------------------------------------------------------------------- + +fn seed_without_modules(tag: &str) -> std::path::PathBuf { + let today = Config::for_tests().seed_dir.join("acte-engagement-certification.json"); + let mut seed: Value = serde_json::from_str(&std::fs::read_to_string(today).expect("seed")).expect("json"); + seed.as_object_mut().expect("object").remove("modules"); + let dir = std::env::temp_dir().join(format!("ld-modules-seed-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("dir"); + std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write"); + dir +} + +/// What must not move: every unit of the base (place, text in force, section, +/// inertia), every version (status, text, revision) and every vote. +async fn snapshot( + state: &AppState, +) -> (Vec<(String, f64, Option, Option, String)>, Vec<(String, String, String, i64)>, i64) { + let units = sqlx::query_as( + "SELECT id, position, in_force_id, section, inertia FROM units WHERE kind = 'article' ORDER BY id", + ) + .fetch_all(&state.db) + .await + .expect("units"); + let proposals = sqlx::query_as( + "SELECT p.id, p.status, p.body, p.revision FROM proposals p JOIN units u ON u.id = p.unit_id + WHERE u.kind = 'article' ORDER BY p.id", + ) + .fetch_all(&state.db) + .await + .expect("proposals"); + let votes = sqlx::query_scalar("SELECT COUNT(*) FROM votes").fetch_one(&state.db).await.expect("votes"); + (units, proposals, votes) +} + +#[tokio::test] +async fn an_existing_document_receives_its_modules_once_from_what_it_holds() { + // Production before this version: the documents, no module. + let mut cfg = Config::for_tests(); + cfg.seed_dir = seed_without_modules("applied"); + let db = open_db("sqlite::memory:").await.expect("db"); + let before = AppState::new(cfg, db).await.expect("state"); + seed::documents(&before).await.expect("documents"); + let mut c = Client::of(&before); + c.login("Yvv").await; + let mut chiara = c.other(); + chiara.login("Chiara").await; + let doc = c.doc("essai").await; + assert!(doc["modules"]["title"].is_null()); + // Life goes on there: votes, an article adopted, another one proposed in « conclusion ». + let e3 = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("e3").to_owned(); + c.vote(&e3, "for").await; + chiara.vote(&e3, "for").await; + let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); + c.ok( + "POST", + &format!("/api/docs/{doc_id}/proposals"), + Some(json!({ "section": "conclusion", "title": "Rappel", "body": "Un rappel final, proposé avant les modules.", "submit": true })), + ) + .await; + let kept = snapshot(&before).await; + let reads = read_order(&c.doc("essai").await, "conclusion"); + + // The new version starts: each document receives its modules, from what it holds. + let state = AppState::new(Config::for_tests(), before.db.clone()).await.expect("state"); + seed::ensure(&state).await.expect("seed"); + let rows: Vec<(String, String, Option, Option)> = sqlx::query_as( + "SELECT d.group_slug, m.outcome, m.reason, m.created FROM module_amendments m JOIN documents d ON d.id = m.doc_id + ORDER BY d.group_slug", + ) + .fetch_all(&state.db) + .await + .expect("decisions"); + let created = r#"["titre","ordre.introduction","ordre.fondamental","ordre.technique","ordre.conclusion","ordre.annexe","ordre.formule","ordre.inertie","ordre.ordonnancement"]"#; + assert_eq!( + rows, + [ + ("essai".into(), "applied".into(), None, Some(created.into())), + ("monnaie-libre".into(), "applied".into(), None, Some(created.into())), + ] + ); + // Nothing else moved: articles, versions, votes. + assert_eq!(snapshot(&state).await, kept); + let mut c = Client::of(&state); + c.login("Yvv").await; + let doc = c.doc("essai").await; + assert_eq!(doc["modules"]["title"]["inForce"]["title"], "Acte d'engagement Certification"); + assert_eq!( + doc["modules"]["title"]["inForce"]["translations"]["es"]["title"], + "Acta de compromiso de Certificación" + ); + // The order of each section in force is the one it read in, the new article included. + let conclusion = order_of(&doc, "conclusion"); + assert_eq!(codes(&doc, &conclusion["inForce"]["order"]["ids"]), reads); + assert_eq!(reads.len(), 3); + assert_eq!(conclusion["inForce"]["seedMinimal"], true); + // « Décisions » counts them: 41 + the new article in the test group, E3 adopted there. + let d = c.ok("GET", "/api/groups/essai/decisions", None).await; + assert_eq!(d["docs"][0]["toVote"], 41); + // Republished where the document publishes, never from the test group. + let queued: Vec<(String, String)> = sqlx::query_as( + "SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE o.reason LIKE 'modules%'", + ) + .fetch_all(&state.db) + .await + .expect("outbox"); + assert_eq!( + queued, + [( + "monnaie-libre".to_owned(), + "modules ajoutés : le titre du document, l'ordre des articles de chaque section".to_owned() + )] + ); + // What is published: the same text, and the modules in document.json. + let ml = c.doc("monnaie-libre").await; + let st = ld_server::docs::load_state(&state, ml["doc"]["id"].as_str().expect("id")).await.expect("state"); + let files = ld_server::publish::render(&state, &st).await.expect("render"); + let file = |name: &str| files.iter().find(|(n, _)| n == name).map(|(_, c)| c.clone()).expect("file"); + assert!(!file("fr.md").contains("ordre.") && file("fr.md").contains("# Acte d'engagement Certification")); + let json: Value = serde_json::from_str(&file("document.json")).expect("json"); + assert_eq!(json["units"].as_array().expect("units").len(), 3, "the articles in force, no module"); + assert_eq!( + (json["modules"]["title"]["inertia"].as_u64(), json["modules"]["title"]["status"].as_str()), + (Some(4), Some("seed-minimal")) + ); + assert_eq!(json["modules"]["orders"].as_array().expect("orders").len(), 8); + assert_eq!(json["modules"]["orders"][1]["articles"], json!(["T1", "E1", "E2"])); + + // Once only: the next start adds nothing. + let again = AppState::new(Config::for_tests(), state.db.clone()).await.expect("state"); + seed::ensure(&again).await.expect("seed"); + let n: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE kind <> 'article'").fetch_one(&again.db).await.expect("n"); + assert_eq!(n, 18, "9 modules in each of the two documents"); + let decided: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM module_amendments").fetch_one(&again.db).await.expect("n"); + assert_eq!(decided, 2); +} diff --git a/crates/ld-server/tests/overview.rs b/crates/ld-server/tests/overview.rs index e4b6d64..1cf31a3 100644 --- a/crates/ld-server/tests/overview.rs +++ b/crates/ld-server/tests/overview.rs @@ -147,20 +147,21 @@ async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() { assert_eq!(docs.len(), 1); assert_eq!(docs[0]["slug"], "acte-engagement-certification"); assert_eq!(docs[0]["title"]["fr"], "Acte d'engagement Certification"); - // 3 seeds a minima + 30 open articles, the two variants counting once. - assert_eq!(docs[0]["toVote"], 33); - assert_eq!(docs[0]["withoutMyVote"], 33); + // 3 seeds a minima + 30 open articles, the two variants counting once; the title of the + // document and the order of 7 sections (« ordonnancement » holds one article: nothing to decide). + assert_eq!(docs[0]["toVote"], 41); + assert_eq!(docs[0]["withoutMyVote"], 41); let doc = the_doc(&mut c, "essai").await; c.vote(&version(&doc, "E3", "0"), "for").await; c.vote(&version(&doc, "E3", "1"), "against").await; c.vote(&version(&doc, "I1", "inForce"), "for").await; let d = decisions(&mut c, "essai").await; - assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(33), Some(31))); + assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(41), Some(39))); // Someone else has not voted yet. let mut yvv = c.other(); yvv.login("Yvv", false).await; - assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 33); + assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41); } #[tokio::test] @@ -176,7 +177,7 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou let v = chiara.vote(&e3, "for").await; assert_eq!(v["entered"][0]["code"], "E3"); let d = decisions(&mut yvv, "essai").await; - assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(32), Some(32))); + assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(40), Some(40))); // A new article by Chiara: one more module to vote. let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned(); @@ -191,9 +192,9 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou .await; assert_eq!(s, StatusCode::OK, "{v}"); let added = v["id"].as_str().expect("id").to_owned(); - assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["toVote"], 33); + assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["toVote"], 41); yvv.vote(&added, "against").await; - assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 32); + assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 40); // Chiara changes its meaning: Yvv's vote is to reconfirm, it no longer counts as his vote. let (s, v) = chiara .call( @@ -206,7 +207,7 @@ async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_cou .await; assert_eq!(s, StatusCode::OK, "{v}"); assert_eq!(v["edit"], "substantive"); - assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 33); + assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 41); } #[tokio::test] @@ -217,10 +218,10 @@ async fn where_one_does_not_vote_there_is_no_count_of_ones_own() { guest.invited(&mut admin, &["monnaie-libre", "essai"]).await; let d = decisions(&mut guest, "monnaie-libre").await; assert_eq!(d["canVote"], false); - assert_eq!(d["docs"][0]["toVote"], 33); + assert_eq!(d["docs"][0]["toVote"], 41); assert!(d["docs"][0]["withoutMyVote"].is_null()); // In the test group, the same person votes. let d = decisions(&mut guest, "essai").await; assert_eq!(d["canVote"], true); - assert_eq!(d["docs"][0]["withoutMyVote"], 33); + assert_eq!(d["docs"][0]["withoutMyVote"], 41); } diff --git a/seeds/acte-engagement-certification.json b/seeds/acte-engagement-certification.json index 771e33a..2d4f7e9 100644 --- a/seeds/acte-engagement-certification.json +++ b/seeds/acte-engagement-certification.json @@ -1,5 +1,5 @@ { - "_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21). Inertie 3 / 5 partout, 4 pour E1 et E2 (revue du 2026-10-30). I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30).", + "_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote (Yvv, 2026-10-21). Inertie 3 / 5 partout, 4 pour E1 et E2 (revue du 2026-10-30). I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30). Modules : le titre du document à 4, l'ordre des articles de chaque section à 2 (revue du 2026-10-30, ADR 0009).", "slug": "acte-engagement-certification", "title": { "fr": "Acte d'engagement Certification", @@ -24,6 +24,14 @@ "publishPath": null } ], + "modules": { + "title": { + "inertia": 4 + }, + "order": { + "inertia": 2 + } + }, "units": [ { "code": "I1",