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:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user