forked from yvv/decision
v3 : serveur des objections, des coquilles validées et de la messagerie (lot F)
Objections « redit » / « contredit » à la place des signalements : la dernière arrivée des deux versions n'est plus votable (repli, panier, chaîne : un vote déjà scellé est gardé mais ne la fait pas entrer) ; un texte en vigueur ne l'est jamais. L'auteur répond en modifiant l'article visé (proposition liée) ou en reformulant ; la personne qui objecte lève ou réitère depuis ses messages ; sans réponse sous 3 jours, l'objection est levée. Un administrateur peut lever. Coquilles : la sienne passe tout de suite ; celle d'un autre attend l'auteur (valider / refuser), passe seule au bout de 3 jours, et l'auteur garde la main tant que le texte n'est pas en vigueur. En vigueur, seule une coquille validée corrige (auteur de la version, administrateur pour un texte semé). Deux coquilles en attente : la seconde se mesure au texte voté ; à l'application, celle qui ne colle plus est périmée. Messagerie dans l'application (boîte, non-lus, gestes, échéance), chaque personne ne lit que les siens. Échéances par le balayage existant, avec rattrapage au démarrage et horloge injectable (route de dev). Migration 0008 additive : les signalements deviennent des « contredit », sans perte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
//! 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 (?9 IS NULL OR status = ?9)",
|
||||
)
|
||||
.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"));
|
||||
}
|
||||
let written = match s.text {
|
||||
TextChange::Write => {
|
||||
write_text(
|
||||
&mut tx,
|
||||
&t.proposal_id,
|
||||
t.base(),
|
||||
(&t.title, &t.body),
|
||||
None,
|
||||
&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),
|
||||
None,
|
||||
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)))
|
||||
}
|
||||
Reference in New Issue
Block a user