forked from yvv/decision
Conflits avec B2 : docs.rs garde shown_tally / shown_evaluation et votable ; proposals.rs sert les totaux montrés et « votable » ; CONTROLES réunit C24 (« faire une objection », le panier par son adresse), C28–C33 et les deux passages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
772 lines
29 KiB
Rust
772 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, UnitRow};
|
|
use crate::error::{AppError, AppResult};
|
|
use crate::{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>,
|
|
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>,
|
|
}
|
|
|
|
/// 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<f64> = 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<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?;
|
|
// 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());
|
|
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_after(&st.units, after))
|
|
.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)?;
|
|
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() => new_unit(&state, &st, b.after_unit_id.as_deref()).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?;
|
|
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, _) = 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?;
|
|
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 placed right after. 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 mut after = p.unit_id.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 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?,
|
|
);
|
|
after = unit;
|
|
}
|
|
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 {
|
|
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, "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 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.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed",
|
|
"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,
|
|
"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()),
|
|
})))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Votes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct VoteBody {
|
|
choice: Option<String>,
|
|
}
|
|
|
|
/// One vote on the server, in a managed group. Where the vote goes through the
|
|
/// chain (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?;
|
|
if trustin_access::vote_way(&st.group.to_group()) == trustin_access::VoteWay::Chain {
|
|
return Err(AppError::Conflict("chain_only"));
|
|
}
|
|
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"));
|
|
}
|
|
let choice = match b.choice.as_deref() {
|
|
Some("for") => Choice::For,
|
|
Some("against") => Choice::Against,
|
|
None => Choice::None,
|
|
Some(_) => return Err(AppError::Invalid("choice")),
|
|
};
|
|
let cast = ballots::Cast { proposal_id: p.id.clone(), revision: p.revision, choice };
|
|
ballots::write_server_votes(&state, &user.id, &[cast]).await?;
|
|
let entered = adoption::run(&state, &st.doc.id).await?;
|
|
Ok(Json(json!({ "entered": entered })))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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?;
|
|
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 })))
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|