Fusion de la tête du chantier (924a40d : lots B2 et F) dans le lot D

Les deux côtés gardés partout. Aux coutures :
- une objection (redit / contredit) porte sur deux articles, jamais sur un module ;
  un ordre ne se corrige pas, ni par son auteur ni par un autre ; une coquille du
  titre suit le chemin de F (l'auteur du titre, un administrateur pour le titre
  de départ, ou 3 jours), et documents.title suit à l'application ;
- votable() couvre les modules ; leurs totaux passent par shown_tally, à l'écran
  et dans document.json ;
- « Modules à voter » ne compte plus une version retenue par une objection (test
  vu rouge) ; la liste du groupe la compte toujours parmi les propositions en cours ;
- le panier : une fonction pour les articles et les modules, avec les versions
  retenues de F ; un nouvel article se propose dans une section, à sa fin, et peut
  répondre à une objection ;
- migrations 0007 (modules) puis 0008 (objections) ;
- C25 et C26 supportent un groupe d'essai où une voix suffit (dès 16 membres).

Boucle : fmt, clippy -D warnings, cargo test (227), svelte-check 0, vitest 55,
build, e2e 35 / 35 (C1–C33 et M1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 09:41:30 +01:00
co-authored by Claude Opus 5.5
82 changed files with 8285 additions and 677 deletions
+70 -180
View File
@@ -1,5 +1,5 @@
//! Writing: drafts, submission, corrections, votes, contradiction signals,
//! inertia changes, editorial translations.
//! 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 »):
@@ -7,9 +7,10 @@
//! - once **submitted**, a correction classified *typographic* by the engine
//! keeps every vote and is shown with its diff; a *substantive* one opens a
//! new revision and every earlier vote waits for its author to reconfirm;
//! - a text **in force** accepts typographic corrections from any member of
//! the group, recorded with their author and diff, and republished — a
//! change of meaning goes through a proposal.
//! - 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;
@@ -17,8 +18,7 @@ use crate::auth::{IdentityRow, User};
use crate::ballots;
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow};
use crate::error::{AppError, AppResult};
use crate::modules;
use crate::util;
use crate::{modules, objections, typos, util};
use axum::Json;
use axum::extract::{Path, State};
use ld_engine::assist::{self, Hint};
@@ -30,10 +30,8 @@ use serde_json::json;
const MAX_TITLE: usize = 120;
const MAX_BODY: usize = 4000;
const MIN_REASON: usize = 10;
const MAX_REASON: usize = 600;
async fn load_proposal(state: &AppState, id: &str) -> AppResult<ProposalRow> {
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)
@@ -50,7 +48,11 @@ async fn doc_of_unit(state: &AppState, unit_id: &str) -> AppResult<String> {
}
/// Loads the document of a proposal and checks the person may open its group.
async fn guarded(state: &AppState, user: &IdentityRow, proposal_id: &str) -> AppResult<(ProposalRow, DocState)> {
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?;
@@ -172,6 +174,9 @@ pub struct CreateBody {
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
@@ -259,7 +264,7 @@ pub async fn create(
}
unit.id.clone()
}
None if kind == "text" => {
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() {
@@ -269,8 +274,12 @@ pub async fn create(
};
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 {
@@ -284,6 +293,16 @@ pub async fn create(
},
)
.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 })))
}
@@ -341,43 +360,8 @@ pub async fn edit(
};
match kind {
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
EditKind::Typographic => {
// Anyone in the group may fix a typo of a seed text or of a text in force.
if !(is_author || p.origin == "seed" || p.status == "in_force") {
return Err(AppError::Forbidden);
}
// Written only over the text that was judged: an entry into force or another
// correction in between makes it a conflict, never a silent change.
let done = sqlx::query(
"UPDATE proposals SET title = ?, body = ?, updated_at = ?
WHERE id = ? AND revision = ? AND status = ? AND title = ? AND body = ?",
)
.bind(title)
.bind(body)
.bind(&now)
.bind(&id)
.bind(p.revision)
.bind(&p.status)
.bind(&p.title)
.bind(&p.body)
.execute(&state.db)
.await?;
if done.rows_affected() != 1 {
return Err(AppError::Conflict("changed"));
}
record_revision(&state, &id, p.revision, "typo", title, body, Some(&user.id)).await?;
// A typo fix does not change the meaning: up-to-date translations stay up to date.
sqlx::query("UPDATE translations SET source_hash = ? WHERE proposal_id = ? AND source_hash = ?")
.bind(util::source_hash(title, body))
.bind(&id)
.bind(util::source_hash(&p.title, &p.body))
.execute(&state.db)
.await?;
if p.status == "in_force" {
adoption::enqueue_publication(&state, &st.doc, "correction typographique").await?;
}
Ok(Json(json!({ "edit": "typo", "diff": edit::diff(&format!("{}\n{}", p.title, p.body), &next) })))
}
// 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"));
@@ -406,14 +390,10 @@ pub async fn edit(
return Err(AppError::Conflict("changed"));
}
record_revision(&state, &id, revision, "substantive", title, body, Some(&user.id)).await?;
// Signals were about the former text: they lapse, their authors may signal again.
sqlx::query(
"UPDATE signals SET lifted_at = ?, lifted_by = 'revision' WHERE proposal_id = ? AND lifted_at IS NULL",
)
.bind(&now)
.bind(&id)
.execute(&state.db)
.await?;
// 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 })))
}
}
@@ -450,6 +430,12 @@ pub async fn submit(
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 })))
}
@@ -472,6 +458,9 @@ pub async fn withdraw(
.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?;
Ok(Json(json!({ "status": "withdrawn" })))
}
@@ -561,66 +550,37 @@ pub async fn detail(
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
FROM revisions r LEFT JOIN identities i ON i.id = r.author_id WHERE r.proposal_id = ? ORDER BY r.id",
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.
// 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 {
let diff = previous
.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,
"authorInvited": r.author_invited.unwrap_or(false), "at": r.created_at, "diff": diff, "typo": typo,
}));
previous = Some(r);
}
#[derive(sqlx::FromRow)]
struct Sig {
id: String,
proposal_id: String,
target_proposal_id: String,
reason: String,
author_id: String,
author_name: String,
author_invited: bool,
created_at: String,
lifted_at: Option<String>,
lifted_by: Option<String>,
}
let sigs = sqlx::query_as::<_, Sig>(
"SELECT s.id, s.proposal_id, s.target_proposal_id, s.reason, s.author_id, i.display_name AS author_name,
i.kind = 'invite' AS author_invited, s.created_at, s.lifted_at, s.lifted_by
FROM signals s JOIN identities i ON i.id = s.author_id
WHERE s.proposal_id = ? OR s.target_proposal_id = ? ORDER BY s.created_at DESC",
)
.bind(&id)
.bind(&id)
.fetch_all(&state.db)
.await?;
let mut signals = Vec::new();
for s in sigs {
let other_id = if s.proposal_id == id { &s.target_proposal_id } else { &s.proposal_id };
let other = load_proposal(&state, other_id).await.ok();
let other_code = match &other {
Some(o) => st.units.iter().find(|u| u.id == o.unit_id).map(|u| u.code.clone()),
None => None,
};
signals.push(json!({
"id": s.id, "reason": s.reason, "author": s.author_name, "authorInvited": s.author_invited,
"mine": s.author_id == user.id,
"at": s.created_at, "liftedAt": s.lifted_at, "liftedBy": s.lifted_by,
"other": other.map(|o| json!({ "id": o.id, "title": o.title, "status": o.status, "code": other_code })),
}));
}
let 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);
@@ -628,7 +588,7 @@ pub async fn detail(
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
_ => None,
};
let eval = st.evaluation(unit, &p);
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?;
@@ -640,8 +600,8 @@ pub async fn detail(
"mine": p.author_id.as_deref() == Some(user.id.as_str()),
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
"seedMinimal": p.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
"tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id),
"tally": st.shown_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at,
"blocked": st.blocked.contains(&p.id), "votable": st.votable(&p),
"myVote": my_vote.as_ref().map(|v| &v.choice),
"myVoteStale": my_vote.as_ref().is_some_and(|v| v.revision != p.revision),
"myVoteChain": my_vote.as_ref().is_some_and(|v| v.source == "chain"), "myBallot": my_ballot,
@@ -651,13 +611,14 @@ pub async fn detail(
"inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })),
"diff": diff,
"history": history,
"signals": signals,
"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.tally(&p.id).cast()),
"levels": st.levels_json(st.shown_tally(&p.id).cast()),
})))
}
@@ -685,6 +646,10 @@ pub async fn vote(
if !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Conflict("not_votable"));
}
// Held back by an objection: its votes stay, no new one (ADR 0010).
if !st.votable(&p) {
return Err(AppError::Conflict("objection"));
}
if !docs::may_vote(&state, &user, &st.group).await? {
return Err(AppError::Conflict("members_only"));
}
@@ -700,81 +665,6 @@ pub async fn vote(
Ok(Json(json!({ "entered": entered })))
}
// ---------------------------------------------------------------------------
// Contradiction signals
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SignalBody {
target_id: String,
reason: String,
}
/// « These two texts contradict each other. » While the signal stands, the two
/// can never be in force together: the one not yet in force is blocked.
pub async fn signal(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
Json(b): Json<SignalBody>,
) -> AppResult<Json<serde_json::Value>> {
let (p, st) = guarded(&state, &user, &id).await?;
let reason = b.reason.trim();
if reason.chars().count() < MIN_REASON || reason.chars().count() > MAX_REASON || edit::has_hidden_controls(reason) {
return Err(AppError::Invalid("reason"));
}
if b.target_id == id {
return Err(AppError::Invalid("target"));
}
let target = st.proposals.iter().find(|t| t.id == b.target_id).ok_or(AppError::Invalid("target"))?;
if target.status == "draft" || !matches!(p.status.as_str(), "open" | "in_force") {
return Err(AppError::Invalid("target"));
}
if target.unit_id == p.unit_id && target.kind == "text" && p.kind == "text" {
// Rivals on one unit never stand together anyway.
return Err(AppError::Invalid("same_unit"));
}
let sid = util::id();
sqlx::query(
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&sid)
.bind(&id)
.bind(&b.target_id)
.bind(reason)
.bind(&user.id)
.bind(util::now())
.execute(&state.db)
.await?;
Ok(Json(json!({ "id": sid })))
}
pub async fn lift_signal(
State(state): State<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
let (proposal_id, author): (String, String) =
sqlx::query_as("SELECT proposal_id, author_id FROM signals WHERE id = ? AND lifted_at IS NULL")
.bind(&id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
let (_, st) = guarded(&state, &user, &proposal_id).await?;
if author != user.id && !user.admin {
return Err(AppError::Forbidden);
}
sqlx::query("UPDATE signals SET lifted_at = ?, lifted_by = ? WHERE id = ?")
.bind(util::now())
.bind(&user.id)
.bind(&id)
.execute(&state.db)
.await?;
let entered = adoption::run(&state, &st.doc.id).await?;
Ok(Json(json!({ "entered": entered })))
}
// ---------------------------------------------------------------------------
// Inertia
// ---------------------------------------------------------------------------