//! 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 { 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. 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, } 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, /// The section a new article goes in, at its end (ADR 0009). section: Option, /// Or an article of that section (an older client). after_unit_id: Option, 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, } /// 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 { 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 { 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)?; 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, User(user): User, Path(id): Path, Json(b): Json, ) -> AppResult> { 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, 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")); } // 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 = 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, 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 !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, } /// 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, 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 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, 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, author_invited: Option, created_at: String, typo_id: Option, answered_by: Option, answered_name: Option, } 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, } /// 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, User(user): User, Path(id): Path, Json(b): Json, ) -> AppResult> { 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, User(user): User, Path(unit_id): Path, Json(b): Json, ) -> AppResult> { let target = serde_json::from_value::(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, User(user): User, Path((id, lang)): Path<(String, String)>, Json(b): Json, ) -> AppResult> { 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 }))) }