//! Typo fixes validated (2026-10-31, lot F, L13, ADR 0010). //! //! What a typo is does not change (`ld_engine::edit`, judged against the voted //! text). Who applies it, and when, is `ld_engine::correction`: the one who //! answers for the version (its author; for a text without author, an //! administrator of trustinWallet — `trustin_access::answers_typo`) applies a //! fix at once; anyone else's waits for them, told by a message, and applies by //! itself after 3 days (`due`, run by the sweeper and at start). While the //! version is not in force its author keeps the hand: a fix applied may be taken //! back, a fix refused validated after all. A fix is written only over the text //! it was written over: once another one applies, or the substance changes, it //! is stale. Translations stay out of this: editorial, without validation. use crate::AppState; use crate::adoption; use crate::auth::{IDENTITY_COLUMNS, IdentityRow, User}; use crate::docs::{self, DocState, ProposalRow}; use crate::error::{AppError, AppResult}; use crate::messages::{self, Topic}; use crate::{proposals, util}; use axum::Json; use axum::extract::{Path, State}; use ld_engine::correction::{self, Answer, Route, Settle, Text, TextChange, TypoStatus}; use ld_engine::edit; use serde_json::{Value, json}; use sqlx::SqliteConnection; #[derive(Clone, Debug, sqlx::FromRow)] pub struct TypoRow { pub id: String, pub proposal_id: String, pub doc_id: String, pub writer_id: String, pub answerer_id: Option, pub revision: i64, pub base_title: String, pub base_body: String, pub title: String, pub body: String, pub status: String, pub created_at: String, pub due_at: String, pub settled_at: Option, pub settled_by: Option, } const SELECT: &str = "SELECT id, proposal_id, doc_id, writer_id, answerer_id, revision, base_title, base_body, title, body, status, created_at, due_at, settled_at, settled_by FROM typos"; impl TypoRow { fn status(&self) -> TypoStatus { TypoStatus::parse(&self.status).unwrap_or(TypoStatus::Stale) } fn base(&self) -> Text<'_> { Text { revision: self.revision, title: &self.base_title, body: &self.base_body } } fn fixed(&self) -> Text<'_> { Text { revision: self.revision, title: &self.title, body: &self.body } } } fn text_of(p: &ProposalRow) -> Text<'_> { Text { revision: p.revision, title: &p.title, body: &p.body } } async fn load(state: &AppState, id: &str) -> AppResult { sqlx::query_as::<_, TypoRow>(&format!("{SELECT} WHERE id = ?")) .bind(id) .fetch_optional(&state.db) .await? .ok_or(AppError::NotFound) } /// Writes `to` over a version, only where its text is still `from` — and, when /// `status` is given, only while its status is that one; records the revision and /// keeps up-to-date translations up to date. False when the text was not there. #[allow(clippy::too_many_arguments)] async fn write_text( tx: &mut SqliteConnection, proposal_id: &str, from: Text<'_>, to: (&str, &str), status: Option<&str>, author: &str, link: Option<(&str, &str)>, kind: &str, now: &str, ) -> AppResult { let done = sqlx::query( "UPDATE proposals SET title = ?, body = ?, updated_at = ? WHERE id = ? AND revision = ? AND title = ? AND body = ? AND status IN ('open', 'in_force') AND (?8 IS NULL OR status = ?8)", ) .bind(to.0) .bind(to.1) .bind(now) .bind(proposal_id) .bind(from.revision) .bind(from.title) .bind(from.body) .bind(status) .execute(&mut *tx) .await?; if done.rows_affected() != 1 { return Ok(false); } sqlx::query( "INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at, typo_id, answered_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(proposal_id) .bind(from.revision) .bind(kind) .bind(to.0) .bind(to.1) .bind(author) .bind(now) .bind(link.map(|l| l.0)) .bind(link.map(|l| l.1)) .execute(&mut *tx) .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(to.0, to.1)) .bind(proposal_id) .bind(util::source_hash(from.title, from.body)) .execute(&mut *tx) .await?; Ok(true) } /// After the text of a version changed, or it closed: the fixes waiting on a text /// that is gone are stale, and their messages say so. pub async fn on_text_changed(state: &AppState, proposal_id: &str) -> AppResult<()> { let p = proposals::load_proposal(state, proposal_id).await?; let open = matches!(p.status.as_str(), "open" | "in_force"); let pending = sqlx::query_as::<_, TypoRow>(&format!("{SELECT} WHERE proposal_id = ? AND status = 'pending'")) .bind(proposal_id) .fetch_all(&state.db) .await?; for t in pending { if open && correction::fits(t.base(), text_of(&p)) { continue; } stale(state, &t, "pending").await?; } Ok(()) } async fn stale(state: &AppState, t: &TypoRow, from: &str) -> AppResult<()> { let done = sqlx::query( "UPDATE typos SET status = 'stale', settled_at = ?, settled_by = 'changed' WHERE id = ? AND status = ?", ) .bind(state.clock.now()) .bind(&t.id) .bind(from) .execute(&state.db) .await?; if done.rows_affected() == 1 { messages::close(state, Topic::Typo, &t.id, None, "stale").await?; } Ok(()) } async fn republish(state: &AppState, proposal_id: &str) -> AppResult<()> { let p = proposals::load_proposal(state, proposal_id).await?; if p.status == "in_force" { let doc_id: String = sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?").bind(&p.unit_id).fetch_one(&state.db).await?; adoption::enqueue_publication(state, &docs::load_doc(state, &doc_id).await?, "correction typographique") .await?; } Ok(()) } /// A fix the engine judged typographic, written by `user` on `p`: applied at once /// by the one who answers for the version, waiting for them otherwise. pub async fn write( state: &AppState, user: &IdentityRow, p: &ProposalRow, st: &DocState, title: &str, body: &str, ) -> AppResult> { let diff = edit::diff(&format!("{}\n{}", p.title, p.body), &format!("{title}\n{body}")); let answers = trustin_access::answers_typo(&user.to_identity(), p.author_id.as_deref()); let now = state.clock.now(); match correction::route(answers) { Route::Apply => { // 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 mut tx = state.db.begin().await?; if !write_text(&mut tx, &p.id, text_of(p), (title, body), Some(&p.status), &user.id, None, "typo", &now) .await? { return Err(AppError::Conflict("changed")); } tx.commit().await?; if p.status == "in_force" { adoption::enqueue_publication(state, &st.doc, "correction typographique").await?; } on_text_changed(state, &p.id).await?; Ok(Json(json!({ "edit": "typo", "diff": diff }))) } Route::Await => { let id = util::id(); let due = state.clock.in_days(correction::ANSWER_DAYS); sqlx::query( "INSERT INTO typos (id, proposal_id, doc_id, writer_id, answerer_id, revision, base_title, base_body, title, body, status, created_at, due_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)", ) .bind(&id) .bind(&p.id) .bind(&st.doc.id) .bind(&user.id) .bind(&p.author_id) .bind(p.revision) .bind(&p.title) .bind(&p.body) .bind(title) .bind(body) .bind(&now) .bind(&due) .execute(&state.db) .await?; // Who answers is told: the author, or for a text without author, every // administrator who opens the group (the others could not answer). let recipients: Vec = match &p.author_id { Some(a) => vec![a.clone()], None => { let admins = sqlx::query_as::<_, IdentityRow>(&format!( "SELECT {IDENTITY_COLUMNS} FROM identities WHERE admin = 1 AND id <> ? ORDER BY created_at" )) .bind(&user.id) .fetch_all(&state.db) .await?; let mut open = Vec::new(); for a in admins { if docs::opens(state, &a, &st.group).await? { open.push(a.id); } } open } }; for r in recipients { messages::notify(state, &r, &st.doc.id, Topic::Typo, &id, "typo_pending", 1, Some(&due)).await?; } let answerer = if p.author_id.is_some() { "author" } else { "admin" }; tracing::info!(typo = %id, proposal = %p.id, answerer, "typo fix waiting for validation"); Ok(Json(json!({ "edit": "typo_pending", "diff": diff, "typo": { "id": id, "dueAt": due, "answerer": answerer }, }))) } } } /// Settles one fix, in one transaction: its status (only over the one read), and /// its text. `Ok(Err("settled"))`: answered meanwhile; `Ok(Err("changed"))`: the /// text it needs is gone — nothing is written. async fn settle(state: &AppState, t: &TypoRow, s: Settle, by: &str, now: &str) -> AppResult> { let mut tx = state.db.begin().await?; let claimed = sqlx::query("UPDATE typos SET status = ?, settled_at = ?, settled_by = ? WHERE id = ? AND status = ?") .bind(s.status.as_str()) .bind(now) .bind(by) .bind(&t.id) .bind(&t.status) .execute(&mut *tx) .await?; if claimed.rows_affected() != 1 { return Ok(Err("settled")); } // The author's change of mind lands only on a version still open (relecture of cd7e683). let only = s.while_open.then_some("open"); let written = match s.text { TextChange::Write => { write_text( &mut tx, &t.proposal_id, t.base(), (&t.title, &t.body), only, &t.writer_id, Some((&t.id, by)), "typo", now, ) .await? } TextChange::Restore => { write_text( &mut tx, &t.proposal_id, t.fixed(), (&t.base_title, &t.base_body), only, by, Some((&t.id, by)), "revert", now, ) .await? } TextChange::Keep => true, }; if !written { tx.rollback().await?; return Ok(Err("changed")); } tx.commit().await?; if s.text != TextChange::Keep { republish(state, &t.proposal_id).await?; on_text_changed(state, &t.proposal_id).await?; } Ok(Ok(())) } async fn answer(state: &AppState, user: &IdentityRow, id: &str, a: Answer) -> AppResult> { let t = load(state, id).await?; let (p, _) = proposals::guarded(state, user, &t.proposal_id).await?; if !trustin_access::answers_typo(&user.to_identity(), t.answerer_id.as_deref()) { return Err(AppError::Forbidden); } let status = t.status(); if status == TypoStatus::Stale { return Err(AppError::Conflict("changed")); } let s = correction::answer(status, a, p.status == "in_force").map_err(|_| AppError::Conflict("settled"))?; let now = state.clock.now(); match settle(state, &t, s, &user.id, &now).await? { Ok(()) => {} Err("changed") => { // Written over a text that is gone: a pending fix is stale; one already decided stays as it is. if status == TypoStatus::Pending { stale(state, &t, "pending").await?; } return Err(AppError::Conflict("changed")); } Err(_) => return Err(AppError::Conflict("settled")), } if status == TypoStatus::Pending { let outcome = if s.status == TypoStatus::Applied { "applied" } else { "refused" }; messages::close(state, Topic::Typo, &t.id, None, outcome).await?; } messages::read_subject(state, &user.id, Topic::Typo, &t.id).await?; tracing::info!(typo = %t.id, status = s.status.as_str(), by = %user.id, "typo fix answered"); Ok(Json(json!({ "status": s.status }))) } /// `POST /api/typos/{id}/validate` pub async fn validate( State(state): State, User(user): User, Path(id): Path, ) -> AppResult> { answer(&state, &user, &id, Answer::Validate).await } /// `POST /api/typos/{id}/refuse` — refuses a pending fix, or takes back an applied /// one while the version is not in force. pub async fn refuse(State(state): State, User(user): User, Path(id): Path) -> AppResult> { answer(&state, &user, &id, Answer::Refuse).await } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct Due { pub applied: usize, pub stale: usize, } /// The fixes whose deadline passed apply by themselves — where their text is still there. pub async fn due(state: &AppState) -> AppResult { let now = state.clock.now(); let rows = sqlx::query_as::<_, TypoRow>(&format!( "{SELECT} WHERE status = 'pending' AND due_at <= ? ORDER BY created_at, rowid" )) .bind(&now) .fetch_all(&state.db) .await?; let mut out = Due::default(); for t in rows { let Some(s) = correction::tick(t.status(), &t.due_at, &now) else { continue }; match settle(state, &t, s, "deadline", &now).await? { Ok(()) => { messages::close(state, Topic::Typo, &t.id, None, "deadline").await?; tracing::info!(typo = %t.id, "typo fix applied at its deadline"); out.applied += 1; } Err("changed") => { stale(state, &t, "pending").await?; out.stale += 1; } Err(_) => {} } } Ok(out) } // --------------------------------------------------------------------------- // What the screen shows // --------------------------------------------------------------------------- async fn name_of(state: &AppState, id: &str) -> AppResult<(Option, bool)> { let row: Option<(String, bool)> = sqlx::query_as("SELECT display_name, kind = 'invite' FROM identities WHERE id = ?") .bind(id) .fetch_optional(&state.db) .await?; Ok(row.map_or((None, false), |(n, i)| (Some(n), i))) } /// What `viewer` may do with a fix now. fn gestures(viewer: &IdentityRow, t: &TypoRow, p: &ProposalRow) -> Vec<&'static str> { if !trustin_access::answers_typo(&viewer.to_identity(), t.answerer_id.as_deref()) { return vec![]; } let in_force = p.status == "in_force"; let open = matches!(p.status.as_str(), "open" | "in_force"); match t.status() { TypoStatus::Pending => vec!["validate", "refuse"], TypoStatus::Applied if open && !in_force && correction::fits(t.fixed(), text_of(p)) => vec!["refuse"], TypoStatus::Refused if open && !in_force && correction::fits(t.base(), text_of(p)) => vec!["validate"], _ => vec![], } } async fn view(state: &AppState, viewer: &IdentityRow, t: &TypoRow, p: &ProposalRow) -> AppResult { let (writer, invited) = name_of(state, &t.writer_id).await?; let settled_by = match t.settled_by.as_deref() { Some(by @ ("deadline" | "changed")) => Some(by.to_owned()), Some(id) => name_of(state, id).await?.0, None => None, }; Ok(json!({ "id": t.id, "status": t.status, "writer": writer, "writerInvited": invited, "at": t.created_at, "dueAt": t.due_at, "settledAt": t.settled_at, "settledBy": settled_by, "answerer": if t.answerer_id.is_some() { "author" } else { "admin" }, "diff": edit::diff(&format!("{}\n{}", t.base_title, t.base_body), &format!("{}\n{}", t.title, t.body)), "gestures": gestures(viewer, t, p), })) } /// The fixes written by others on one version, newest first, as `viewer` sees them. pub async fn of_proposal(state: &AppState, viewer: &IdentityRow, p: &ProposalRow) -> AppResult> { let rows = sqlx::query_as::<_, TypoRow>(&format!("{SELECT} WHERE proposal_id = ? ORDER BY created_at DESC, rowid DESC")) .bind(&p.id) .fetch_all(&state.db) .await?; let mut out = Vec::with_capacity(rows.len()); for t in &rows { out.push(view(state, viewer, t, p).await?); } Ok(out) } /// A fix, for a message of its box: the gestures only while it waits. pub async fn for_message( state: &AppState, viewer: &IdentityRow, subject_id: &str, ) -> AppResult)>> { let Ok(t) = load(state, subject_id).await else { return Ok(None) }; let p = proposals::load_proposal(state, &t.proposal_id).await?; let mut v = view(state, viewer, &t, &p).await?; let code: Option = sqlx::query_scalar("SELECT code FROM units WHERE id = ?").bind(&p.unit_id).fetch_optional(&state.db).await?; v["version"] = json!({ "id": p.id, "title": p.title, "status": p.status, "unitId": p.unit_id, "code": code }); let gestures = if t.status() == TypoStatus::Pending { gestures(viewer, &t, &p) } else { vec![] }; v["gestures"] = json!(gestures); Ok(Some((v, gestures))) } #[cfg(test)] mod tests { use super::*; use crate::{Config, open_db, seed}; /// Relecture of cd7e683 (M1): the author takes back a fix applied, read while her /// version was open; a vote puts it in force in between. The text in force stays. #[tokio::test] async fn a_change_of_mind_never_writes_over_a_text_just_in_force() { let db = open_db("sqlite::memory:").await.expect("db"); let state = AppState::new(Config::for_tests(), db).await.expect("state"); seed::ensure(&state).await.expect("seed"); let now = "2026-10-31T00:00:00Z"; // A seed proposal (ADR 0011) the vote has just put in force. let (id, doc, revision, title, body): (String, String, i64, String, String) = sqlx::query_as( "SELECT p.id, u.doc_id, p.revision, p.title, p.body FROM proposals p JOIN units u ON u.id = p.unit_id WHERE p.status = 'open' AND p.seed_featured = 1 AND p.kind = 'text' LIMIT 1", ) .fetch_one(&state.db) .await .expect("a seed proposal"); sqlx::query("UPDATE proposals SET status = 'in_force', entered_at = ? WHERE id = ?") .bind(now) .bind(&id) .execute(&state.db) .await .expect("in force"); sqlx::query("UPDATE units SET in_force_id = ?1 WHERE id = (SELECT unit_id FROM proposals WHERE id = ?1)") .bind(&id) .execute(&state.db) .await .expect("unit"); sqlx::query( "INSERT INTO identities (id, kind, display_name, created_at) VALUES ('dev:aude', 'dev', 'Aude', ?)", ) .bind(now) .execute(&state.db) .await .expect("identity"); // Its text is the one a fix wrote over « Un texte d'avant ». sqlx::query( "INSERT INTO typos (id, proposal_id, doc_id, writer_id, answerer_id, revision, base_title, base_body, title, body, status, created_at, due_at) VALUES ('t1', ?, ?, 'dev:aude', 'dev:aude', ?, ?, 'Un texte d''avant', ?, ?, 'applied', ?, ?)", ) .bind(&id) .bind(&doc) .bind(revision) .bind(&title) .bind(&title) .bind(&body) .bind(now) .bind(now) .execute(&state.db) .await .expect("typo"); let t = load(&state, "t1").await.expect("load"); // What `answer` decided when it read the version, still open. let s = correction::answer(TypoStatus::Applied, Answer::Refuse, false).expect("refuse"); assert_eq!(settle(&state, &t, s, "dev:aude", now).await.expect("settle"), Err("changed")); let kept: String = sqlx::query_scalar("SELECT body FROM proposals WHERE id = ?") .bind(&id) .fetch_one(&state.db) .await .expect("body"); assert_eq!(kept, body, "the text in force stays"); let status: String = sqlx::query_scalar("SELECT status FROM typos WHERE id = 't1'").fetch_one(&state.db).await.expect("status"); assert_eq!(status, "applied", "nothing settled"); } }