//! Writing: drafts, submission, corrections, votes, contradiction signals, //! inertia changes, editorial translations. //! //! 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; //! - 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. use crate::AppState; use crate::adoption; use crate::auth::{IdentityRow, User}; use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow, UnitRow}; use crate::error::{AppError, AppResult}; use crate::util; use axum::Json; use axum::extract::{Path, State}; use ld_engine::assist::{self, Hint}; use ld_engine::edit::{self, EditKind}; use ld_engine::threshold::InertiaPreset; use serde::Deserialize; 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 { 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 { 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. 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 { 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. 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') 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, } pub async fn assist( State(state): State, User(user): User, Path(doc_id): Path, Json(body): Json, ) -> AppResult> { 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, unit_id: Option, after_unit_id: Option, title: String, #[serde(default)] body: String, #[serde(default)] 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 { 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 (?, ?, ?, ?, ?, 'standard', NULL, ?)", ) .bind(&id) .bind(&st.doc.id) .bind(format!("N{}", n + 1)) .bind(section) .bind(position_after(&st.units, after)) .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 { 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, User(user): User, Path(doc_id): Path, Json(b): Json, ) -> AppResult> { 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" => new_unit(&state, &st, b.after_unit_id.as_deref()).await?, None => return Err(AppError::Invalid("unit")), }; 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?; Ok(Json(json!({ "id": id, "unitId": unit_id }))) } #[derive(Deserialize)] pub struct EditBody { title: String, body: String, } pub async fn edit( State(state): State, User(user): User, Path(id): Path, Json(b): Json, ) -> AppResult> { 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" }))), 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) }))) } 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?; // 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?; Ok(Json(json!({ "edit": "substantive", "revision": revision }))) } } } pub async fn submit( State(state): State, User(user): User, Path(id): Path, ) -> AppResult> { 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")); } if edit::has_hidden_controls(&p.title) || edit::has_hidden_controls(&p.body) { return Err(AppError::Invalid("hidden_characters")); } 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?; } let entered = adoption::run(&state, &st.doc.id).await?; Ok(Json(json!({ "status": "open", "entered": entered }))) } pub async fn withdraw( State(state): State, User(user): User, Path(id): Path, ) -> AppResult> { 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?; Ok(Json(json!({ "status": "withdrawn" }))) } #[derive(Deserialize)] pub struct SplitBody { parts: Vec, } /// 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, User(user): User, Path(id): Path, Json(b): Json, ) -> AppResult> { 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, User(user): User, Path(id): Path, ) -> AppResult> { 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, created_at: String, } let revs = sqlx::query_as::<_, Rev>( "SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name, r.created_at FROM revisions r LEFT JOIN identities i ON i.id = r.author_id 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. 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))); history.push(json!({ "revision": r.revision, "kind": r.kind, "author": r.author_name, "at": r.created_at, "diff": diff, })); 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, created_at: String, lifted_at: Option, lifted_by: Option, } 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, 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, "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 my_vote: Option<(String, i64)> = sqlx::query_as("SELECT choice, revision FROM votes WHERE proposal_id = ? AND identity_id = ?") .bind(&id) .bind(&user.id) .fetch_optional(&state.db) .await?; let diff = match (&in_force, p.kind.as_str()) { (Some(v), "text") => Some(edit::diff(&v.body, &p.body)), _ => None, }; let eval = st.evaluation(unit, &p); let voted_at = if p.kind == "inertia" { docs::inertia_of(unit).raised() } else { docs::inertia_of(unit) }; let tr = docs::translations_for(&state, &[&p]).await?; Ok(Json(json!({ "proposal": { "id": p.id, "kind": p.kind, "inertiaTarget": p.inertia_target, "title": p.title, "body": p.body, "status": p.status, "origin": p.origin, "label": p.label, "revision": p.revision, "author": p.author_name, "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.as_str(), "blocked": st.blocked.contains(&p.id), "myVote": my_vote.as_ref().map(|v| &v.0), "myVoteStale": my_vote.as_ref().is_some_and(|v| v.1 != p.revision), "translations": tr.get(&p.id).cloned().unwrap_or_default(), }, "unit": { "id": unit.id, "code": unit.code, "inertia": unit.inertia }, "inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })), "diff": diff, "history": history, "signals": signals, "electorate": st.electorate, "voteFloor": st.doc.vote_floor, }))) } // --------------------------------------------------------------------------- // Votes // --------------------------------------------------------------------------- #[derive(Deserialize)] pub struct VoteBody { choice: Option, } pub async fn vote( State(state): State, User(user): User, Path(id): Path, Json(b): Json, ) -> AppResult> { let (p, st) = guarded(&state, &user, &id).await?; if !matches!(p.status.as_str(), "open" | "in_force") { return Err(AppError::Conflict("not_votable")); } // Withdrawing one's vote is always possible; casting one follows trustinWallet. if b.choice.is_some() && !docs::may_vote(&state, &user, &st.group).await? { return Err(AppError::Conflict("wot_members_only")); } match b.choice.as_deref() { Some(choice @ ("for" | "against")) => { sqlx::query( "INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(proposal_id, identity_id) DO UPDATE SET choice = excluded.choice, revision = excluded.revision, cast_at = excluded.cast_at", ) .bind(&id) .bind(&user.id) .bind(choice) .bind(p.revision) .bind(util::now()) .execute(&state.db) .await?; } None => { sqlx::query("DELETE FROM votes WHERE proposal_id = ? AND identity_id = ?") .bind(&id) .bind(&user.id) .execute(&state.db) .await?; } Some(_) => return Err(AppError::Invalid("choice")), } let entered = adoption::run(&state, &st.doc.id).await?; 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, User(user): User, Path(id): Path, Json(b): Json, ) -> AppResult> { 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, User(user): User, Path(id): Path, ) -> AppResult> { 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 // --------------------------------------------------------------------------- #[derive(Deserialize)] pub struct InertiaBody { target: String, } /// Propose a change of inertia; it is voted one level above the unit. An open /// proposal for the same target is reused rather than duplicated. pub async fn propose_inertia( State(state): State, User(user): User, Path(unit_id): Path, Json(b): Json, ) -> AppResult> { let target = InertiaPreset::parse(&b.target).ok_or(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() == Some(target.as_str()) }) { return Ok(Json(json!({ "id": existing.id, "existing": true }))); } let title = format!("inertia:{}", target.as_str()); let id = insert_proposal( &state, NewProposal { unit_id: &unit_id, kind: "inertia", inertia_target: Some(target.as_str()), 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, User(user): User, Path((id, lang)): Path<(String, String)>, Json(b): Json, ) -> AppResult> { 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: "standard".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); } }