Files
decision/crates/ld-server/src/proposals.rs
T
YvvandClaude Opus 5.5 c0c4d129cb Fusion de chantier/acte-certification (lots H et S) dans le lot V : le tampon et ses états, avec le quorum et les Propositions seed
- conflits résolus des deux côtés : kept_params garde le quorum (S) et la part simulée, la version déplacée, le cran d'avant (V) ; proposals.rs garde seedMinimal / seedProposal (S) et tallyPending / tallySimulated / myVoteState (V) ;
- simulation::reexamine juge sur le quorum gardé par l'adoption (params.quorum, à défaut celui de l'article) ;
- l'acte répété n'examine plus une adoption qui tient (params.reexamined) ;
- tests V repris sans texte en vigueur au départ (I1, E1 : la Proposition seed) et sur un quorum 2 (small_web) ; le test overview d'une personne invitée dans Monnaie libre suit la simulation (elle vote, chaîne fermée).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 05:32:51 +01:00

762 lines
29 KiB
Rust

//! Writing: drafts, submission, corrections, votes, inertia changes, editorial
//! translations. Objections live in `objections`, typo fixes of others in `typos`.
//!
//! Editing is not voting (Yvv, 2026-10-21: « il ne faut pas que ça devienne un
//! système qui nécessite un vote pour corriger une coquille »):
//! - a **draft** is free: its author edits it at will, nobody votes on it;
//! - once **submitted**, a correction classified *typographic* by the engine
//! keeps every vote and is shown with its diff; a *substantive* one opens a
//! new revision and every earlier vote waits for its author to reconfirm;
//! - any member of the group may write a typo fix on a version; it applies at
//! once when its writer answers for the version (its author; an administrator
//! for a text without author), and waits for them otherwise — three days at
//! most (2026-10-31, `typos`). A change of meaning goes through a proposal.
use crate::AppState;
use crate::adoption;
use crate::auth::{IdentityRow, User};
use crate::ballots;
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow};
use crate::error::{AppError, AppResult};
use crate::{modules, objections, typos, util};
use axum::Json;
use axum::extract::{Path, State};
use ld_engine::assist::{self, Hint};
use ld_engine::ballot::Choice;
use ld_engine::edit::{self, EditKind};
use ld_engine::inertia::InertiaLevel;
use serde::Deserialize;
use serde_json::json;
const MAX_TITLE: usize = 120;
const MAX_BODY: usize = 4000;
pub(crate) async fn load_proposal(state: &AppState, id: &str) -> AppResult<ProposalRow> {
sqlx::query_as::<_, ProposalRow>(&format!("{PROPOSAL_SELECT} WHERE p.id = ?"))
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)
}
async fn doc_of_unit(state: &AppState, unit_id: &str) -> AppResult<String> {
sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?")
.bind(unit_id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)
}
/// Loads the document of a proposal and checks the person may open its group.
pub(crate) async fn guarded(
state: &AppState,
user: &IdentityRow,
proposal_id: &str,
) -> AppResult<(ProposalRow, DocState)> {
let p = load_proposal(state, proposal_id).await?;
let doc_id = doc_of_unit(state, &p.unit_id).await?;
let st = docs::load_state(state, &doc_id).await?;
docs::require_open(state, user, &st.group).await?;
if p.status == "draft" && p.author_id.as_deref() != Some(user.id.as_str()) {
return Err(AppError::NotFound);
}
Ok((p, st))
}
fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
if edit::has_hidden_controls(title) || edit::has_hidden_controls(body) {
return Err(AppError::Invalid("hidden_characters"));
}
if title.trim().is_empty() || title.chars().count() > MAX_TITLE || title.contains(['\n', '\r']) {
return Err(AppError::Invalid("title"));
}
if (needs_body && body.trim().is_empty()) || body.chars().count() > MAX_BODY {
return Err(AppError::Invalid("body"));
}
Ok(())
}
/// The text the votes were cast on: its last initial or substantive revision, or the
/// starting text a seed amendment put in place (`seed_amend`).
async fn voted_text(state: &AppState, p: &ProposalRow) -> AppResult<(String, String)> {
let row: Option<(String, String)> = sqlx::query_as(
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive', 'amendment')
ORDER BY id DESC LIMIT 1",
)
.bind(&p.id)
.fetch_optional(&state.db)
.await?;
Ok(row.unwrap_or_else(|| (p.title.clone(), p.body.clone())))
}
async fn record_revision(
state: &AppState,
proposal_id: &str,
revision: i64,
kind: &str,
title: &str,
body: &str,
author: Option<&str>,
) -> AppResult<()> {
sqlx::query(
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(proposal_id)
.bind(revision)
.bind(kind)
.bind(title)
.bind(body)
.bind(author)
.bind(util::now())
.execute(&state.db)
.await?;
Ok(())
}
// ---------------------------------------------------------------------------
// Assistant
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssistBody {
text: String,
proposal_id: Option<String>,
}
pub async fn assist(
State(state): State<AppState>,
User(user): User,
Path(doc_id): Path<String>,
Json(body): Json<AssistBody>,
) -> AppResult<Json<serde_json::Value>> {
let st = docs::load_state(&state, &doc_id).await?;
docs::require_open(&state, &user, &st.group).await?;
let others: Vec<(String, String)> = st
.proposals
.iter()
.filter(|p| p.status != "draft" && Some(&p.id) != body.proposal_id.as_ref() && p.kind == "text")
.map(|p| (p.id.clone(), format!("{}\n{}", p.title, p.body)))
.collect();
let hints: Vec<_> = assist::hints(&body.text, &others)
.into_iter()
.map(|h| match h {
Hint::Similar { target, score } => {
let p = st.proposals.iter().find(|p| p.id == target);
let unit = p.and_then(|p| st.units.iter().find(|u| u.id == p.unit_id));
json!({
"kind": "similar", "score": score, "proposalId": target,
"unitId": unit.map(|u| &u.id), "code": unit.map(|u| &u.code),
"title": p.map(|p| &p.title), "inForce": p.is_some_and(|p| p.status == "in_force"),
})
}
other => serde_json::to_value(other).unwrap_or_default(),
})
.collect();
Ok(Json(json!({ "hints": hints })))
}
// ---------------------------------------------------------------------------
// Create, edit, submit, withdraw, split
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateBody {
kind: Option<String>,
unit_id: Option<String>,
/// The section a new article goes in, at its end (ADR 0009).
section: Option<String>,
/// Or an article of that section (an older client).
after_unit_id: Option<String>,
title: String,
#[serde(default)]
body: String,
#[serde(default)]
submit: bool,
/// Written to answer an objection (« modifier l'article visé »): on the
/// article the held version repeats or contradicts, or on its own.
objection_id: Option<String>,
}
/// 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<String> {
let id = util::id();
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE doc_id = ? AND code LIKE 'N%'")
.bind(&st.doc.id)
.fetch_one(&state.db)
.await?;
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, ?)",
)
.bind(&id)
.bind(&st.doc.id)
.bind(format!("N{}", n + 1))
.bind(section)
.bind(position)
.bind(InertiaLevel::DEFAULT.as_db())
.bind(util::now())
.execute(&state.db)
.await?;
Ok(id)
}
struct NewProposal<'a> {
unit_id: &'a str,
kind: &'a str,
inertia_target: Option<&'a str>,
title: &'a str,
body: &'a str,
author: &'a str,
submit: bool,
}
async fn insert_proposal(state: &AppState, n: NewProposal<'_>) -> AppResult<String> {
let NewProposal { unit_id, kind, inertia_target, title, body, author, submit } = n;
let id = util::id();
let now = util::now();
sqlx::query(
"INSERT INTO proposals (id, unit_id, kind, inertia_target, title, body, status, origin, author_id, revision,
created_at, updated_at, submitted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'member', ?, 1, ?, ?, ?)",
)
.bind(&id)
.bind(unit_id)
.bind(kind)
.bind(inertia_target)
.bind(title.trim())
.bind(body.trim())
.bind(if submit { "open" } else { "draft" })
.bind(author)
.bind(&now)
.bind(&now)
.bind(submit.then_some(&now))
.execute(&state.db)
.await?;
record_revision(state, &id, 1, "initial", title.trim(), body.trim(), Some(author)).await?;
Ok(id)
}
pub async fn create(
State(state): State<AppState>,
User(user): User,
Path(doc_id): Path<String>,
Json(b): Json<CreateBody>,
) -> AppResult<Json<serde_json::Value>> {
let st = docs::load_state(&state, &doc_id).await?;
docs::require_open(&state, &user, &st.group).await?;
let kind = match b.kind.as_deref().unwrap_or("text") {
"text" => "text",
"remove" => "remove",
_ => return Err(AppError::Invalid("kind")),
};
check_text(&b.title, &b.body, kind == "text")?;
let unit_id = match b.unit_id.as_deref() {
Some(u) => {
let unit = st.units.iter().find(|x| x.id == u).ok_or(AppError::NotFound)?;
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" && b.objection_id.is_none() => {
// 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 if kind == "text" => return Err(AppError::Invalid("link")),
None => return Err(AppError::Invalid("unit")),
};
if let Some(oid) = &b.objection_id {
objections::check_link(&state, &user, oid, &unit_id).await?;
}
let id = insert_proposal(
&state,
NewProposal {
unit_id: &unit_id,
kind,
inertia_target: None,
title: &b.title,
body: &b.body,
author: &user.id,
submit: b.submit,
},
)
.await?;
if let Some(oid) = &b.objection_id {
sqlx::query("UPDATE proposals SET objection_id = ? WHERE id = ?")
.bind(oid)
.bind(&id)
.execute(&state.db)
.await?;
if b.submit {
objections::on_linked(&state, oid, &id, &user.id).await?;
}
}
Ok(Json(json!({ "id": id, "unitId": unit_id })))
}
#[derive(Deserialize)]
pub struct EditBody {
title: String,
body: String,
}
pub async fn edit(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
Json(b): Json<EditBody>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
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());
let now = util::now();
if p.status == "draft" {
if !is_author {
return Err(AppError::Forbidden);
}
sqlx::query("UPDATE proposals SET title = ?, body = ?, updated_at = ? WHERE id = ?")
.bind(title)
.bind(body)
.bind(&now)
.bind(&id)
.execute(&state.db)
.await?;
return Ok(Json(json!({ "edit": "draft" })));
}
if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("closed"));
}
// Measured against the text that was voted, not the current one: successive
// typo fixes are judged together and never add up to a change of meaning.
// Title and body are judged apart (a title never absorbs the start of the
// body) and together (two slips at most in all).
let next = format!("{title}\n{body}");
let kind = if title == p.title && body == p.body {
EditKind::Unchanged
} else {
let (voted_title, voted_body) = voted_text(&state, &p).await?;
let kinds = [
edit::classify(&voted_title, title),
edit::classify(&voted_body, body),
edit::classify(&format!("{voted_title}\n{voted_body}"), &next),
];
// All unchanged: back to the voted text, itself a correction.
if kinds.contains(&EditKind::Substantive) { EditKind::Substantive } else { EditKind::Typographic }
};
match kind {
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
// Anyone in the group may fix a typo; who applies it, and when, is `typos`.
EditKind::Typographic => typos::write(&state, &user, &p, &st, title, body).await,
EditKind::Substantive => {
if p.status == "in_force" {
return Err(AppError::Conflict("substantive_in_force"));
}
if !is_author {
return Err(AppError::Conflict("substantive_not_author"));
}
let revision = p.revision + 1;
// Only on the open proposal that was read: never on a text that entered into
// force in the meantime.
let done = sqlx::query(
"UPDATE proposals SET title = ?, body = ?, revision = ?, updated_at = ?
WHERE id = ? AND revision = ? AND status = 'open' AND title = ? AND body = ?",
)
.bind(title)
.bind(body)
.bind(revision)
.bind(&now)
.bind(&id)
.bind(p.revision)
.bind(&p.title)
.bind(&p.body)
.execute(&state.db)
.await?;
if done.rows_affected() != 1 {
return Err(AppError::Conflict("changed"));
}
record_revision(&state, &id, revision, "substantive", title, body, Some(&user.id)).await?;
// It answers the objections around it (the objector judges again), and
// the typo fixes waiting on the former text no longer fit.
objections::on_change(&state, &id, true, &user.id).await?;
typos::on_text_changed(&state, &id).await?;
Ok(Json(json!({ "edit": "substantive", "revision": revision })))
}
}
}
pub async fn submit(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
if p.author_id.as_deref() != Some(user.id.as_str()) {
return Err(AppError::Forbidden);
}
if p.status != "draft" {
return Err(AppError::Conflict("not_draft"));
}
// Drafts written under older rules are checked again before the vote opens.
check_text(&p.title, &p.body, p.kind == "text")?;
let now = util::now();
sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?")
.bind(&now)
.bind(&now)
.bind(&id)
.execute(&state.db)
.await?;
// A draft is edited freely: the text submitted is the one votes will be cast on.
let initial = sqlx::query("UPDATE revisions SET title = ?, body = ? WHERE proposal_id = ? AND kind = 'initial'")
.bind(&p.title)
.bind(&p.body)
.bind(&id)
.execute(&state.db)
.await?;
if initial.rows_affected() == 0 {
record_revision(&state, &id, p.revision, "initial", &p.title, &p.body, Some(&user.id)).await?;
}
// Written to answer an objection: submitted, it answers it (a draft answers nothing).
let linked: Option<String> =
sqlx::query_scalar("SELECT objection_id FROM proposals WHERE id = ?").bind(&id).fetch_one(&state.db).await?;
if let Some(oid) = linked {
objections::on_linked(&state, &oid, &id, &user.id).await?;
}
let entered = adoption::run(&state, &st.doc.id).await?;
Ok(Json(json!({ "status": "open", "entered": entered })))
}
pub async fn withdraw(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
if p.author_id.as_deref() != Some(user.id.as_str()) {
return Err(AppError::Forbidden);
}
if !matches!(p.status.as_str(), "draft" | "open") {
return Err(AppError::Conflict("closed"));
}
sqlx::query("UPDATE proposals SET status = 'withdrawn', left_at = ?, updated_at = ? WHERE id = ?")
.bind(util::now())
.bind(util::now())
.bind(&id)
.execute(&state.db)
.await?;
// Nothing left to object to, nor to correct.
objections::on_withdrawn(&state, &id, &user.id).await?;
typos::on_text_changed(&state, &id).await?;
// A version its objection held back may enter now, with its votes (relecture of cd7e683).
adoption::run(&state, &st.doc.id).await?;
Ok(Json(json!({ "status": "withdrawn" })))
}
#[derive(Deserialize)]
pub struct SplitBody {
parts: Vec<String>,
}
/// One draft becomes several: the first part stays, each other part becomes a
/// draft on a new unit at the end of its section, in turn. Drafts only — nobody
/// voted yet.
pub async fn split(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
Json(b): Json<SplitBody>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
if p.author_id.as_deref() != Some(user.id.as_str()) {
return Err(AppError::Forbidden);
}
if p.status != "draft" || p.kind != "text" {
return Err(AppError::Conflict("not_draft"));
}
let parts: Vec<&str> = b.parts.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
if parts.len() < 2 || parts.len() > 12 || parts.iter().any(|s| s.chars().count() > MAX_BODY) {
return Err(AppError::Invalid("parts"));
}
if parts.iter().any(|s| edit::has_hidden_controls(s)) {
return Err(AppError::Invalid("hidden_characters"));
}
sqlx::query("UPDATE proposals SET body = ?, updated_at = ? WHERE id = ?")
.bind(parts[0])
.bind(util::now())
.bind(&id)
.execute(&state.db)
.await?;
let mut created = vec![id.clone()];
let 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, section.as_deref()).await?;
let title = format!("{} ({})", p.title, n + 1);
let title: String = title.chars().take(MAX_TITLE).collect();
created.push(
insert_proposal(
&state,
NewProposal {
unit_id: &unit,
kind: "text",
inertia_target: None,
title: &title,
body: part,
author: &user.id,
submit: false,
},
)
.await?,
);
}
Ok(Json(json!({ "drafts": created })))
}
// ---------------------------------------------------------------------------
// Detail
// ---------------------------------------------------------------------------
pub async fn detail(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
let unit = st.units.iter().find(|u| u.id == p.unit_id).ok_or(AppError::NotFound)?;
let in_force = match &unit.in_force_id {
Some(fid) if *fid != p.id => Some(load_proposal(&state, fid).await?),
_ => None,
};
#[derive(sqlx::FromRow, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct Rev {
revision: i64,
kind: String,
title: String,
body: String,
author_name: Option<String>,
author_invited: Option<bool>,
created_at: String,
typo_id: Option<String>,
answered_by: Option<String>,
answered_name: Option<String>,
}
let revs = sqlx::query_as::<_, Rev>(
"SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name,
i.kind = 'invite' AS author_invited, r.created_at, r.typo_id, r.answered_by, a.display_name AS answered_name
FROM revisions r LEFT JOIN identities i ON i.id = r.author_id LEFT JOIN identities a ON a.id = r.answered_by
WHERE r.proposal_id = ? ORDER BY r.id",
)
.bind(&id)
.fetch_all(&state.db)
.await?;
// Each correction with the diff from the text before it; a fix someone else
// wrote says who let it in (or that it passed at its deadline).
let mut history = Vec::new();
let mut previous: Option<&Rev> = None;
for r in &revs {
// A change of status (a seed proposal, ADR 0011) changes no text: no diff.
let diff = previous
.filter(|_| r.kind != "seed_proposal")
.map(|prev| edit::diff(&format!("{}\n{}", prev.title, prev.body), &format!("{}\n{}", r.title, r.body)));
let typo = r.typo_id.as_ref().map(|t| {
json!({ "id": t, "answeredBy": r.answered_name, "deadline": r.answered_by.as_deref() == Some("deadline") })
});
history.push(json!({
"revision": r.revision, "kind": r.kind, "author": r.author_name,
"authorInvited": r.author_invited.unwrap_or(false), "at": r.created_at, "diff": diff, "typo": typo,
}));
previous = Some(r);
}
let objections = objections::around(&state, &st, &user, &id).await?;
let typos = typos::of_proposal(&state, &user, &p).await?;
let my_vote = ballots::my_votes(&state, &user.id, &st.doc.id).await?.remove(&id);
let my_ballot = ballots::my_ballots(&state, &user.id, &st.doc.id).await?.remove(&id);
let seen = ballots::seen(p.revision, my_vote.as_ref(), my_ballot.as_ref());
let diff = match (&in_force, p.kind.as_str()) {
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
_ => None,
};
let eval = st.shown_evaluation(unit, &p);
let voted_at = st.voting_level(unit, &p);
let target = p.inertia_target.as_deref().and_then(InertiaLevel::parse);
let tr = docs::translations_for(&state, &[&p]).await?;
Ok(Json(json!({
"proposal": {
"id": p.id, "kind": p.kind, "inertiaTarget": target, "title": p.title, "body": p.body,
"status": p.status, "origin": p.origin, "label": p.label, "revision": p.revision,
"author": p.author_name, "authorInvited": p.author_invited.unwrap_or(false),
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
"seedMinimal": p.seed_minimal(), "seedProposal": p.seed_proposal(),
"tally": st.shown_tally(&p.id), "tallyPending": st.pending_tally(&p.id),
"tallySimulated": st.simulated_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id), "votable": st.votable(&p),
"myVote": seen.choice, "myVoteState": seen.state, "myVoteStale": seen.stale,
"myVoteChain": seen.chain, "myBallot": my_ballot,
"translations": tr.get(&p.id).cloned().unwrap_or_default(),
},
"unit": { "id": unit.id, "code": unit.code, "inertia": docs::inertia_of(unit) },
"inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })),
"diff": diff,
"history": history,
"objections": objections,
"typos": typos,
"electorate": st.electorate,
"electorateSource": st.electorate_source,
"voteFloor": st.doc.vote_floor,
"participation": st.participation_json(),
// The five levels, with the *for* votes each would ask at this version's participation.
"levels": st.levels_json(st.shown_tally(&p.id).cast(), unit.own_quorum()),
})))
}
// ---------------------------------------------------------------------------
// Votes
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct VoteBody {
choice: Option<String>,
}
/// One vote written by the server: in a managed group, or in simulation while
/// the chain is closed (ADR 0012). Where the vote goes through the chain and the
/// chain is open (Monnaie libre), it is a ballot (`ballots::create`), never this.
pub async fn vote(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
Json(b): Json<VoteBody>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
let way = ballots::server_way(&state, &st.group)?;
if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("not_votable"));
}
let choice = match b.choice.as_deref() {
Some("for") => Choice::For,
Some("against") => Choice::Against,
None => Choice::None,
Some(_) => return Err(AppError::Invalid("choice")),
};
// Held back by an objection: its votes stay; against or a withdrawal, never a
// new *for* (ADR 0010, Yvv 2026-11-01: « Retirer ou "contre" »).
if !st.admits(&p, choice) {
return Err(AppError::Conflict("objection"));
}
if !ballots::may_write(&state, &user, &st.group, way).await? {
return Err(AppError::Conflict("members_only"));
}
let cast = ballots::Cast { proposal_id: p.id.clone(), revision: p.revision, choice };
let entered = ballots::write(&state, &user, &st, way, &[cast]).await?;
Ok(Json(json!({ "entered": entered, "simulated": way == ballots::ServerWay::Simulated })))
}
// ---------------------------------------------------------------------------
// Inertia
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct InertiaBody {
/// A level, 1 to 5 (a word of before is read as its level).
target: serde_json::Value,
}
/// Propose a change of inertia; it is voted at the level of the unit (it
/// inherits it, ADR 0007). An open proposal for the same target is reused
/// rather than duplicated.
pub async fn propose_inertia(
State(state): State<AppState>,
User(user): User,
Path(unit_id): Path<String>,
Json(b): Json<InertiaBody>,
) -> AppResult<Json<serde_json::Value>> {
let target = serde_json::from_value::<InertiaLevel>(b.target).map_err(|_| AppError::Invalid("inertia"))?;
let doc_id = doc_of_unit(&state, &unit_id).await?;
let st = docs::load_state(&state, &doc_id).await?;
docs::require_open(&state, &user, &st.group).await?;
let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?;
if docs::inertia_of(unit) == target {
return Err(AppError::Invalid("same_inertia"));
}
if let Some(existing) = st.proposals.iter().find(|p| {
p.unit_id == unit_id
&& p.kind == "inertia"
&& p.status == "open"
&& p.inertia_target.as_deref().and_then(InertiaLevel::parse) == Some(target)
}) {
return Ok(Json(json!({ "id": existing.id, "existing": true })));
}
let title = format!("inertia:{}", target.get());
let level = target.as_db();
let id = insert_proposal(
&state,
NewProposal {
unit_id: &unit_id,
kind: "inertia",
inertia_target: Some(&level),
title: &title,
body: "",
author: &user.id,
submit: true,
},
)
.await?;
Ok(Json(json!({ "id": id, "existing": false })))
}
// ---------------------------------------------------------------------------
// Editorial translations
// ---------------------------------------------------------------------------
pub async fn translate(
State(state): State<AppState>,
User(user): User,
Path((id, lang)): Path<(String, String)>,
Json(b): Json<EditBody>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
modules::require_text(&p)?;
if !util::is_lang(&lang) || lang == st.doc.source_lang {
return Err(AppError::Invalid("lang"));
}
check_text(&b.title, &b.body, p.kind == "text")?;
sqlx::query(
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
VALUES (?, ?, ?, ?, ?, 'member', ?, ?)
ON CONFLICT(proposal_id, lang) DO UPDATE SET title = excluded.title, body = excluded.body,
source_hash = excluded.source_hash, origin = 'member', updated_by = excluded.updated_by,
updated_at = excluded.updated_at",
)
.bind(&id)
.bind(&lang)
.bind(b.title.trim())
.bind(b.body.trim())
.bind(util::source_hash(&p.title, &p.body))
.bind(&user.id)
.bind(util::now())
.execute(&state.db)
.await?;
if p.status == "in_force" {
adoption::enqueue_publication(&state, &st.doc, "traduction").await?;
}
Ok(Json(json!({ "ok": true })))
}