Files
decision/crates/ld-server/src/typos.rs
T

552 lines
21 KiB
Rust

//! 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<String>,
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<String>,
pub settled_by: Option<String>,
}
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<TypoRow> {
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<bool> {
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<Json<Value>> {
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<String> = 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<Result<(), &'static str>> {
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<Json<Value>> {
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<AppState>,
User(user): User,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
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<AppState>, User(user): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
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<Due> {
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<String>, 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<Value> {
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<Vec<Value>> {
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<Option<(Value, Vec<&'static str>)>> {
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<String> =
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");
}
}