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:
@@ -203,7 +203,8 @@ fn kept_params(st: &DocState, unit: &docs::UnitRow, p: &docs::ProposalRow) -> se
|
||||
|
||||
/// The electorate changes (the web of trust grows or shrinks): every few
|
||||
/// minutes, every document is passed through the engine again — after the
|
||||
/// usual participation of each group is measured again, once a day.
|
||||
/// usual participation of each group is measured again, once a day, and after
|
||||
/// the deadlines of three days are passed (`deadlines`).
|
||||
pub async fn sweeper(state: AppState) {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(300));
|
||||
loop {
|
||||
@@ -211,6 +212,10 @@ pub async fn sweeper(state: AppState) {
|
||||
if let Err(e) = crate::participation::refresh(&state, false).await {
|
||||
tracing::warn!(error = %e, "usual participation");
|
||||
}
|
||||
// Objections lifted and typo fixes applied at their deadline (three days).
|
||||
if let Err(e) = crate::deadlines::sweep(&state).await {
|
||||
tracing::warn!(error = %e, "deadlines");
|
||||
}
|
||||
let ids: Vec<String> = match sqlx::query_scalar("SELECT id FROM documents").fetch_all(&state.db).await {
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
|
||||
@@ -104,6 +104,10 @@ fn casts(st: &DocState, lines: &[LineBody]) -> AppResult<Vec<Cast>> {
|
||||
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||
return Err(AppError::Conflict("not_votable"));
|
||||
}
|
||||
// Held back by an objection: no new vote, on the server or on the chain (ADR 0010).
|
||||
if !st.votable(p) {
|
||||
return Err(AppError::Conflict("objection"));
|
||||
}
|
||||
out.push(Cast { proposal_id: p.id.clone(), revision: p.revision, choice: parse_choice(l.choice.as_deref())? });
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! The clock of the deadlines (objections, typo fixes): the time of the
|
||||
//! machine, plus an offset that only tests move — the API tests directly, the
|
||||
//! end-to-end tests through `POST /api/dev/clock`, open with the dev login only.
|
||||
//! A deadline is never waited for with a sleep.
|
||||
|
||||
use crate::util;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Clock {
|
||||
offset_secs: Arc<AtomicI64>,
|
||||
}
|
||||
|
||||
impl Clock {
|
||||
pub fn at(&self) -> OffsetDateTime {
|
||||
OffsetDateTime::now_utc() + Duration::seconds(self.offset_secs.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Now, as a sortable stamp (RFC 3339 UTC, whole seconds).
|
||||
pub fn now(&self) -> String {
|
||||
util::stamp(self.at())
|
||||
}
|
||||
|
||||
pub fn in_days(&self, days: i64) -> String {
|
||||
util::stamp(self.at() + Duration::days(days))
|
||||
}
|
||||
|
||||
/// Moves the clock forward (tests only).
|
||||
pub fn advance(&self, secs: i64) {
|
||||
self.offset_secs.fetch_add(secs, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> i64 {
|
||||
self.offset_secs.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn advancing_moves_now_and_the_deadlines() {
|
||||
let c = Clock::default();
|
||||
let before = c.now();
|
||||
c.advance(3 * 86_400);
|
||||
assert!(c.now() > before);
|
||||
assert!(c.in_days(0) >= c.now());
|
||||
assert_eq!(c.offset(), 3 * 86_400);
|
||||
// Clones share the offset: the server and its tasks read one clock.
|
||||
let other = c.clone();
|
||||
other.advance(1);
|
||||
assert_eq!(c.offset(), 3 * 86_400 + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! The deadlines of three days (2026-10-31, lot F): an objection whose author
|
||||
//! answered and whose objector said nothing is lifted; a typo fix nobody
|
||||
//! answered applies. Run by the adoption sweeper (every 5 minutes) and once at
|
||||
//! start, so a deadline passed while the server was stopped is dealt with as
|
||||
//! soon as it starts again. Tests move the clock (`AppState::clock`), never sleep.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::adoption;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::{objections, typos};
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Swept {
|
||||
/// Objections lifted at their deadline.
|
||||
pub lifted: usize,
|
||||
/// Typo fixes applied at their deadline.
|
||||
pub applied: usize,
|
||||
/// Typo fixes that could not apply: their text was gone.
|
||||
pub stale: usize,
|
||||
}
|
||||
|
||||
/// One pass: the objections, then the typo fixes, then the adoption of the
|
||||
/// documents where an objection was lifted (a version may now enter into force).
|
||||
pub async fn sweep(state: &AppState) -> AppResult<Swept> {
|
||||
let (lifted, docs) = objections::due(state).await?;
|
||||
let fixes = typos::due(state).await?;
|
||||
for doc in &docs {
|
||||
adoption::run(state, doc).await?;
|
||||
}
|
||||
Ok(Swept { lifted, applied: fixes.applied, stale: fixes.stale })
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ClockBody {
|
||||
seconds: i64,
|
||||
}
|
||||
|
||||
/// `POST /api/dev/clock` — the end-to-end tests move the server's clock forward
|
||||
/// and pass the deadlines at once. Open with the dev login only, like it.
|
||||
pub async fn dev_clock(State(state): State<AppState>, Json(b): Json<ClockBody>) -> AppResult<Json<Value>> {
|
||||
if !state.cfg.dev_login {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
if b.seconds <= 0 || b.seconds > 400 * 86_400 {
|
||||
return Err(AppError::Invalid("seconds"));
|
||||
}
|
||||
state.clock.advance(b.seconds);
|
||||
let swept = sweep(&state).await?;
|
||||
Ok(Json(json!({
|
||||
"now": state.clock.now(), "offset": state.clock.offset(),
|
||||
"lifted": swept.lifted, "applied": swept.applied, "stale": swept.stale,
|
||||
})))
|
||||
}
|
||||
@@ -72,6 +72,12 @@ pub async fn require_open(state: &AppState, user: &IdentityRow, group: &GroupRow
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Does this identity open the group now (see `trustin_access::opens`)?
|
||||
pub async fn opens(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<bool> {
|
||||
let has = granted(state, &group.slug, &user.id).await?;
|
||||
Ok(trustin_access::opens(&user.to_identity(), &group.to_group(), has))
|
||||
}
|
||||
|
||||
/// May this identity vote in the group (see `trustin_access::votes_in`)?
|
||||
pub async fn may_vote(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<bool> {
|
||||
let has = granted(state, &group.slug, &user.id).await?;
|
||||
@@ -253,7 +259,9 @@ pub struct DocState {
|
||||
pub units: Vec<UnitRow>,
|
||||
pub proposals: Vec<ProposalRow>,
|
||||
pub tallies: HashMap<String, Tally>,
|
||||
/// Versions held back by a standing objection: not votable, never entering into force.
|
||||
pub blocked: HashSet<String>,
|
||||
/// Standing objections touching each version, either side.
|
||||
pub signal_counts: HashMap<String, i64>,
|
||||
pub electorate: u32,
|
||||
pub electorate_source: &'static str,
|
||||
@@ -331,32 +339,9 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SignalRow {
|
||||
proposal_id: String,
|
||||
target_proposal_id: String,
|
||||
}
|
||||
let signals = sqlx::query_as::<_, SignalRow>(
|
||||
"SELECT s.proposal_id, s.target_proposal_id FROM signals s JOIN proposals p ON p.id = s.proposal_id
|
||||
JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ? AND s.lifted_at IS NULL",
|
||||
)
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let in_force: HashSet<&str> = units.iter().filter_map(|u| u.in_force_id.as_deref()).collect();
|
||||
let mut blocked = HashSet::new();
|
||||
let mut signal_counts: HashMap<String, i64> = HashMap::new();
|
||||
for s in &signals {
|
||||
*signal_counts.entry(s.proposal_id.clone()).or_default() += 1;
|
||||
*signal_counts.entry(s.target_proposal_id.clone()).or_default() += 1;
|
||||
// Two texts that contradict each other never stand in force together.
|
||||
if in_force.contains(s.target_proposal_id.as_str()) {
|
||||
blocked.insert(s.proposal_id.clone());
|
||||
}
|
||||
if in_force.contains(s.proposal_id.as_str()) {
|
||||
blocked.insert(s.target_proposal_id.clone());
|
||||
}
|
||||
}
|
||||
// A standing objection holds back the latest arrival of its pair (never a version
|
||||
// in force): not votable, and it cannot enter into force (ADR 0010).
|
||||
let (blocked, signal_counts) = crate::objections::held(state, doc_id, &units).await?;
|
||||
let (electorate, electorate_source) = electorate(state, &group).await?;
|
||||
let measure = participation::stored(state, &group.slug).await?;
|
||||
let participation = Participation::of(
|
||||
@@ -396,6 +381,11 @@ impl DocState {
|
||||
self.tallies.get(id).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Open to a vote: open or in force, and not held back by an objection.
|
||||
pub fn votable(&self, p: &ProposalRow) -> bool {
|
||||
matches!(p.status.as_str(), "open" | "in_force") && !self.blocked.contains(&p.id)
|
||||
}
|
||||
|
||||
pub fn unit_states(&self) -> Vec<UnitState> {
|
||||
let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect();
|
||||
self.units
|
||||
@@ -546,7 +536,8 @@ fn proposal_json(
|
||||
"evaluation": eval,
|
||||
"votedAtInertia": voted_at,
|
||||
"blocked": st.blocked.contains(&p.id),
|
||||
"signals": st.signal_counts.get(&p.id).copied().unwrap_or(0),
|
||||
"votable": st.votable(p),
|
||||
"objections": st.signal_counts.get(&p.id).copied().unwrap_or(0),
|
||||
"myVote": mine.map(|v| v.choice.as_str()),
|
||||
"myVoteStale": mine.is_some_and(|v| v.revision != p.revision),
|
||||
"myVoteChain": mine.is_some_and(|v| v.source == "chain"),
|
||||
|
||||
@@ -2,19 +2,24 @@
|
||||
//!
|
||||
//! One binary serves the JSON API under `/api`, the compiled Svelte interface,
|
||||
//! and runs four background workers: the electorate refresher (size of the Ğ1
|
||||
//! web of trust), the adoption sweeper, the ballot watcher (votes signed on the
|
||||
//! Ğ1 chain, confirmed on the indexer) and the publisher (adopted texts pushed to the git repository
|
||||
//! that client applications read).
|
||||
//! web of trust), the adoption sweeper (with the deadlines of three days of
|
||||
//! objections and typo fixes), the ballot watcher (votes signed on the Ğ1 chain,
|
||||
//! confirmed on the indexer) and the publisher (adopted texts pushed to the git
|
||||
//! repository that client applications read).
|
||||
|
||||
pub mod access;
|
||||
pub mod adoption;
|
||||
pub mod auth;
|
||||
pub mod ballots;
|
||||
pub mod clock;
|
||||
pub mod config;
|
||||
pub mod deadlines;
|
||||
pub mod docs;
|
||||
pub mod error;
|
||||
pub mod indexer;
|
||||
pub mod inertia_amend;
|
||||
pub mod messages;
|
||||
pub mod objections;
|
||||
pub mod overview;
|
||||
pub mod participation;
|
||||
pub mod proposals;
|
||||
@@ -22,6 +27,7 @@ pub mod publish;
|
||||
pub mod seed;
|
||||
pub mod seed_amend;
|
||||
pub mod team;
|
||||
pub mod typos;
|
||||
pub mod util;
|
||||
pub mod web;
|
||||
pub mod wot;
|
||||
@@ -50,6 +56,8 @@ pub struct AppState {
|
||||
pub publish_wake: Arc<Notify>,
|
||||
/// What is coming in the tools of each group (`seeds/upcoming/`), read at start.
|
||||
pub upcoming: Arc<overview::Upcoming>,
|
||||
/// The clock of the deadlines of three days (objections, typo fixes); tests move it.
|
||||
pub clock: clock::Clock,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -59,7 +67,14 @@ impl AppState {
|
||||
.user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION")))
|
||||
.build()?;
|
||||
let upcoming = Arc::new(overview::Upcoming::load(&cfg.seed_dir).await?);
|
||||
Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()), upcoming })
|
||||
Ok(Self {
|
||||
db,
|
||||
cfg: Arc::new(cfg),
|
||||
http,
|
||||
publish_wake: Arc::new(Notify::new()),
|
||||
upcoming,
|
||||
clock: clock::Clock::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +142,17 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/proposals/{id}/withdraw", post(proposals::withdraw))
|
||||
.route("/proposals/{id}/split", post(proposals::split))
|
||||
.route("/proposals/{id}/vote", post(proposals::vote))
|
||||
.route("/proposals/{id}/signals", post(proposals::signal))
|
||||
.route("/proposals/{id}/objections", post(objections::raise))
|
||||
.route("/proposals/{id}/translations/{lang}", put(proposals::translate))
|
||||
.route("/signals/{id}/lift", post(proposals::lift_signal))
|
||||
.route("/objections/{id}/lift", post(objections::lift))
|
||||
.route("/objections/{id}/reiterate", post(objections::reiterate))
|
||||
.route("/typos/{id}/validate", post(typos::validate))
|
||||
.route("/typos/{id}/refuse", post(typos::refuse))
|
||||
.route("/messages", get(messages::list))
|
||||
.route("/messages/unread", get(messages::unread))
|
||||
.route("/messages/read", post(messages::read_all))
|
||||
.route("/messages/{id}/read", post(messages::read))
|
||||
.route("/dev/clock", post(deadlines::dev_clock))
|
||||
.route("/units/{id}/inertia", post(proposals::propose_inertia))
|
||||
.route("/feedback", post(access::send_feedback))
|
||||
.route("/access", get(access::overview))
|
||||
@@ -210,6 +233,12 @@ pub async fn run() -> anyhow::Result<()> {
|
||||
tracing::warn!(error = %e, "usual participation");
|
||||
}
|
||||
auth::bootstrap(&state).await?;
|
||||
// A deadline passed while the server was stopped is dealt with now.
|
||||
match deadlines::sweep(&state).await {
|
||||
Ok(s) if s != deadlines::Swept::default() => tracing::info!(?s, "deadlines passed while stopped"),
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!(error = %e, "deadlines"),
|
||||
}
|
||||
if let Err(e) = publish::ensure_key(&state).await {
|
||||
tracing::warn!(error = %e, "publication key");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! The messages of trustinWallet (2026-10-31, lot F, ADR 0010): a box in the
|
||||
//! application, in the menu, with the number of unread messages — no mail.
|
||||
//!
|
||||
//! A message is about a subject (an objection, a typo fix) and carries the
|
||||
//! gestures that subject asks of its recipient now — lift or reiterate,
|
||||
//! validate or refuse — and its deadline. A message dealt with offers no
|
||||
//! gesture any more. Its text is told by the interface, in the language of the
|
||||
//! reader, from the subject. Each person reads their own messages only
|
||||
//! (`trustin_access::reads_message`); acting on a subject marks one's messages
|
||||
//! about it as read.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::auth::{IdentityRow, User};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::{docs, objections, typos, util};
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The most recent messages shown in the box.
|
||||
const SHOWN: i64 = 100;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Topic {
|
||||
Objection,
|
||||
Typo,
|
||||
}
|
||||
|
||||
impl Topic {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Objection => "objection",
|
||||
Self::Typo => "typo",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts a message in someone's box.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn notify(
|
||||
state: &AppState,
|
||||
recipient: &str,
|
||||
doc_id: &str,
|
||||
topic: Topic,
|
||||
subject_id: &str,
|
||||
kind: &str,
|
||||
cycle: i64,
|
||||
due: Option<&str>,
|
||||
) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO messages (id, recipient_id, doc_id, topic, subject_id, kind, cycle, created_at, due_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(util::id())
|
||||
.bind(recipient)
|
||||
.bind(doc_id)
|
||||
.bind(topic.as_str())
|
||||
.bind(subject_id)
|
||||
.bind(kind)
|
||||
.bind(cycle)
|
||||
.bind(state.clock.now())
|
||||
.bind(due)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The messages about a subject (of some kinds, or all) are dealt with: what became of it.
|
||||
pub async fn close(
|
||||
state: &AppState,
|
||||
topic: Topic,
|
||||
subject_id: &str,
|
||||
kinds: Option<&[&str]>,
|
||||
outcome: &str,
|
||||
) -> AppResult<()> {
|
||||
let now = state.clock.now();
|
||||
match kinds {
|
||||
None => {
|
||||
sqlx::query(
|
||||
"UPDATE messages SET done_at = ?, outcome = ? WHERE topic = ? AND subject_id = ? AND done_at IS NULL",
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(outcome)
|
||||
.bind(topic.as_str())
|
||||
.bind(subject_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
Some(kinds) => {
|
||||
for kind in kinds {
|
||||
sqlx::query(
|
||||
"UPDATE messages SET done_at = ?, outcome = ?
|
||||
WHERE topic = ? AND subject_id = ? AND kind = ? AND done_at IS NULL",
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(outcome)
|
||||
.bind(topic.as_str())
|
||||
.bind(subject_id)
|
||||
.bind(kind)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Someone acted on a subject: their messages about it are read.
|
||||
pub async fn read_subject(state: &AppState, recipient: &str, topic: Topic, subject_id: &str) -> AppResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE messages SET read_at = ? WHERE recipient_id = ? AND topic = ? AND subject_id = ? AND read_at IS NULL",
|
||||
)
|
||||
.bind(state.clock.now())
|
||||
.bind(recipient)
|
||||
.bind(topic.as_str())
|
||||
.bind(subject_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unread_of(state: &AppState, recipient: &str) -> AppResult<i64> {
|
||||
Ok(sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE recipient_id = ? AND read_at IS NULL")
|
||||
.bind(recipient)
|
||||
.fetch_one(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct MessageRow {
|
||||
id: String,
|
||||
recipient_id: String,
|
||||
doc_id: String,
|
||||
topic: String,
|
||||
subject_id: String,
|
||||
kind: String,
|
||||
cycle: i64,
|
||||
created_at: String,
|
||||
due_at: Option<String>,
|
||||
read_at: Option<String>,
|
||||
done_at: Option<String>,
|
||||
outcome: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DocLine {
|
||||
group_slug: String,
|
||||
slug: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
async fn view(state: &AppState, me: &IdentityRow, m: &MessageRow) -> AppResult<Option<Value>> {
|
||||
let doc = sqlx::query_as::<_, DocLine>("SELECT group_slug, slug, title FROM documents WHERE id = ?")
|
||||
.bind(&m.doc_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
let Some(doc) = doc else { return Ok(None) };
|
||||
// A message tells what is in a group: shown only while its recipient opens it.
|
||||
if !docs::opens(state, me, &docs::load_group(state, &doc.group_slug).await?).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
let (subject, gestures) = match m.topic.as_str() {
|
||||
"objection" => match objections::for_message(state, me, &m.subject_id, &m.kind, m.cycle).await? {
|
||||
Some((v, g)) => (json!({ "objection": v }), g),
|
||||
None => return Ok(None),
|
||||
},
|
||||
"typo" => match typos::for_message(state, me, &m.subject_id).await? {
|
||||
Some((v, g)) => (json!({ "typo": v }), g),
|
||||
None => return Ok(None),
|
||||
},
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let mut out = json!({
|
||||
"id": m.id, "topic": m.topic, "kind": m.kind, "cycle": m.cycle, "createdAt": m.created_at,
|
||||
"dueAt": m.due_at, "read": m.read_at.is_some(), "done": m.done_at.is_some() || gestures.is_empty(),
|
||||
"outcome": m.outcome, "gestures": gestures,
|
||||
"doc": { "id": m.doc_id, "groupSlug": doc.group_slug, "slug": doc.slug, "title": util::parse_lang(&doc.title) },
|
||||
});
|
||||
if let (Some(o), Value::Object(s)) = (out.as_object_mut(), subject) {
|
||||
o.extend(s);
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// `GET /api/messages` — one's own box, newest first.
|
||||
pub async fn list(State(state): State<AppState>, User(me): User) -> AppResult<Json<Value>> {
|
||||
let rows = sqlx::query_as::<_, MessageRow>(
|
||||
"SELECT id, recipient_id, doc_id, topic, subject_id, kind, cycle, created_at, due_at, read_at, done_at, outcome
|
||||
FROM messages WHERE recipient_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
||||
)
|
||||
.bind(&me.id)
|
||||
.bind(SHOWN)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for m in &rows {
|
||||
if !trustin_access::reads_message(&me.to_identity(), &m.recipient_id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(v) = view(&state, &me, m).await? {
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({ "unread": unread_of(&state, &me.id).await?, "messages": out })))
|
||||
}
|
||||
|
||||
/// `GET /api/messages/unread` — the number next to « Messages » in the menu.
|
||||
pub async fn unread(State(state): State<AppState>, User(me): User) -> AppResult<Json<Value>> {
|
||||
Ok(Json(json!({ "unread": unread_of(&state, &me.id).await? })))
|
||||
}
|
||||
|
||||
/// `POST /api/messages/{id}/read` — one's own message only (404 otherwise).
|
||||
pub async fn read(State(state): State<AppState>, User(me): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
|
||||
let recipient: String = sqlx::query_scalar("SELECT recipient_id FROM messages WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
if !trustin_access::reads_message(&me.to_identity(), &recipient) {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
sqlx::query("UPDATE messages SET read_at = ? WHERE id = ? AND read_at IS NULL")
|
||||
.bind(state.clock.now())
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "unread": unread_of(&state, &me.id).await? })))
|
||||
}
|
||||
|
||||
/// `POST /api/messages/read` — every message of one's box.
|
||||
pub async fn read_all(State(state): State<AppState>, User(me): User) -> AppResult<Json<Value>> {
|
||||
sqlx::query("UPDATE messages SET read_at = ? WHERE recipient_id = ? AND read_at IS NULL")
|
||||
.bind(state.clock.now())
|
||||
.bind(&me.id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(Json(json!({ "unread": 0 })))
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
//! Objections « redit » / « contredit » (2026-10-31, lot F, L12, ADR 0010).
|
||||
//!
|
||||
//! The rules are the engine's (`ld_engine::objection`): the latest arrival of
|
||||
//! the pair, never a version in force, is no longer votable while the objection
|
||||
//! stands; its author answers (a substantive revision, or a proposal linked to
|
||||
//! the objection — a revision of the other side answers too); the objector is
|
||||
//! then asked, by a message, to lift or reiterate; without an answer within 3
|
||||
//! days the objection is lifted (`due`, run by the sweeper and at start).
|
||||
//!
|
||||
//! Objections live in the table of the contradiction signals of before
|
||||
//! (`signals`, migration 0008): an older image still reads them as signals, and
|
||||
//! every lifting writes `lifted_at` / `lifted_by` as it did.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::adoption;
|
||||
use crate::auth::{IdentityRow, User};
|
||||
use crate::docs::{self, DocState, UnitRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::messages::{self, Topic};
|
||||
use crate::proposals;
|
||||
use crate::util;
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::edit;
|
||||
use ld_engine::objection::{
|
||||
self, Change, Effect, Event, Lift, ObjectionKind, Phase, Refusal, Side, Step, Touched, Who,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub const MIN_REASON: usize = 10;
|
||||
pub const MAX_REASON: usize = 600;
|
||||
|
||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||
pub struct ObjectionRow {
|
||||
pub id: String,
|
||||
/// The version the objection was raised from.
|
||||
pub proposal_id: String,
|
||||
/// The version it was raised against.
|
||||
pub target_proposal_id: String,
|
||||
pub reason: String,
|
||||
/// The objector.
|
||||
pub author_id: String,
|
||||
pub created_at: String,
|
||||
pub lifted_at: Option<String>,
|
||||
pub lifted_by: Option<String>,
|
||||
pub kind: String,
|
||||
/// The version held back (not votable).
|
||||
pub held_id: Option<String>,
|
||||
pub phase: String,
|
||||
pub cycle: i64,
|
||||
pub due_at: Option<String>,
|
||||
pub answered_at: Option<String>,
|
||||
pub answer: Option<String>,
|
||||
pub answer_id: Option<String>,
|
||||
pub lift: Option<String>,
|
||||
}
|
||||
|
||||
const SELECT: &str = "SELECT id, proposal_id, target_proposal_id, reason, author_id, created_at, lifted_at, lifted_by,
|
||||
kind, held_id, phase, cycle, due_at, answered_at, answer, answer_id, lift FROM signals";
|
||||
|
||||
impl ObjectionRow {
|
||||
pub fn phase(&self) -> Phase {
|
||||
let cycle = u32::try_from(self.cycle).unwrap_or(1);
|
||||
match self.phase.as_str() {
|
||||
"objector" => Phase::Objector { cycle, due: self.due_at.clone().unwrap_or_default() },
|
||||
"lifted" => Phase::Lifted(self.lift.as_deref().and_then(Lift::parse).unwrap_or(Lift::Objector)),
|
||||
_ => Phase::Author { cycle },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> ObjectionKind {
|
||||
ObjectionKind::parse(&self.kind).unwrap_or(ObjectionKind::Contredit)
|
||||
}
|
||||
|
||||
/// The version the held one is said to repeat or contradict.
|
||||
pub fn other_id(&self) -> &str {
|
||||
if self.held_id.as_deref() == Some(self.proposal_id.as_str()) {
|
||||
&self.target_proposal_id
|
||||
} else {
|
||||
&self.proposal_id
|
||||
}
|
||||
}
|
||||
|
||||
fn touches(&self, id: &str) -> Option<Touched> {
|
||||
if self.held_id.as_deref() == Some(id) {
|
||||
Some(Touched::Target)
|
||||
} else if self.proposal_id == id || self.target_proposal_id == id {
|
||||
Some(Touched::Other)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load(state: &AppState, id: &str) -> AppResult<ObjectionRow> {
|
||||
sqlx::query_as::<_, ObjectionRow>(&format!("{SELECT} WHERE id = ?"))
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Standing objections touching one version, either side.
|
||||
async fn standing_around(state: &AppState, proposal_id: &str) -> AppResult<Vec<ObjectionRow>> {
|
||||
Ok(sqlx::query_as::<_, ObjectionRow>(&format!(
|
||||
"{SELECT} WHERE phase <> 'lifted' AND (proposal_id = ?1 OR target_proposal_id = ?1) ORDER BY created_at, rowid"
|
||||
))
|
||||
.bind(proposal_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// For a document: the versions held back by a standing objection (never one in
|
||||
/// force), and how many standing objections touch each version.
|
||||
pub async fn held(
|
||||
state: &AppState,
|
||||
doc_id: &str,
|
||||
units: &[UnitRow],
|
||||
) -> AppResult<(HashSet<String>, HashMap<String, i64>)> {
|
||||
let rows = sqlx::query_as::<_, ObjectionRow>(&format!(
|
||||
"{SELECT} WHERE phase <> 'lifted' AND proposal_id IN
|
||||
(SELECT p.id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ?)"
|
||||
))
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let in_force: HashSet<&str> = units.iter().filter_map(|u| u.in_force_id.as_deref()).collect();
|
||||
let mut held = HashSet::new();
|
||||
let mut counts: HashMap<String, i64> = HashMap::new();
|
||||
for o in &rows {
|
||||
*counts.entry(o.proposal_id.clone()).or_default() += 1;
|
||||
*counts.entry(o.target_proposal_id.clone()).or_default() += 1;
|
||||
if let Some(h) = &o.held_id
|
||||
&& !objection::votable(h, in_force.contains(h.as_str()), &[h.as_str()])
|
||||
{
|
||||
held.insert(h.clone());
|
||||
}
|
||||
}
|
||||
Ok((held, counts))
|
||||
}
|
||||
|
||||
/// The order a version arrived in: its submission (or creation), then the order it was written in.
|
||||
async fn arrival(state: &AppState, p: &docs::ProposalRow) -> AppResult<(String, i64)> {
|
||||
let seq: i64 =
|
||||
sqlx::query_scalar("SELECT rowid FROM proposals WHERE id = ?").bind(&p.id).fetch_one(&state.db).await?;
|
||||
Ok((p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()), seq))
|
||||
}
|
||||
|
||||
fn is_in_force(st: &DocState, id: &str) -> bool {
|
||||
st.units.iter().any(|u| u.in_force_id.as_deref() == Some(id))
|
||||
}
|
||||
|
||||
fn who(user: &IdentityRow, objector: &str) -> Who {
|
||||
let me = user.to_identity();
|
||||
if trustin_access::reiterates_objection(&me, objector) {
|
||||
Who::Objector
|
||||
} else if trustin_access::lifts_objection(&me, objector) {
|
||||
Who::Admin
|
||||
} else {
|
||||
Who::Other
|
||||
}
|
||||
}
|
||||
|
||||
fn refused(r: Refusal) -> AppError {
|
||||
match r {
|
||||
Refusal::Lifted => AppError::Conflict("lifted"),
|
||||
Refusal::NotYours => AppError::Forbidden,
|
||||
Refusal::NothingToReiterate => AppError::Conflict("nothing_to_reiterate"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn author_of(state: &AppState, proposal_id: &str) -> AppResult<Option<String>> {
|
||||
Ok(sqlx::query_scalar::<_, Option<String>>("SELECT author_id FROM proposals WHERE id = ?")
|
||||
.bind(proposal_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.flatten())
|
||||
}
|
||||
|
||||
async fn doc_of(state: &AppState, proposal_id: &str) -> AppResult<String> {
|
||||
sqlx::query_scalar("SELECT u.doc_id FROM proposals p JOIN units u ON u.id = p.unit_id WHERE p.id = ?")
|
||||
.bind(proposal_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Stores a step, only over the phase it was computed from (two people acting at
|
||||
/// once: the second gets a conflict), then does its effects. Returns false when
|
||||
/// the phase had changed meanwhile.
|
||||
async fn store(
|
||||
state: &AppState,
|
||||
o: &ObjectionRow,
|
||||
step: &Step,
|
||||
by: &str,
|
||||
extra: Option<(&str, &str)>,
|
||||
) -> AppResult<bool> {
|
||||
let now = state.clock.now();
|
||||
let (phase, cycle, due, lift) = match &step.phase {
|
||||
Phase::Author { cycle } => ("author", i64::from(*cycle), None, None),
|
||||
Phase::Objector { cycle, due } => ("objector", i64::from(*cycle), Some(due.clone()), None),
|
||||
Phase::Lifted(l) => ("lifted", o.cycle, None, Some(*l)),
|
||||
};
|
||||
let (answer, answer_id) =
|
||||
extra.map_or((o.answer.clone(), o.answer_id.clone()), |(a, id)| (Some(a.into()), Some(id.into())));
|
||||
let answered_at = if extra.is_some() { Some(now.clone()) } else { o.answered_at.clone() };
|
||||
let done = sqlx::query(
|
||||
"UPDATE signals SET phase = ?, cycle = ?, due_at = ?, lift = ?, answer = ?, answer_id = ?, answered_at = ?,
|
||||
lifted_at = CASE WHEN ? = 'lifted' THEN ? ELSE lifted_at END,
|
||||
lifted_by = CASE WHEN ? = 'lifted' THEN ? ELSE lifted_by END
|
||||
WHERE id = ? AND phase = ? AND cycle = ?",
|
||||
)
|
||||
.bind(phase)
|
||||
.bind(cycle)
|
||||
.bind(&due)
|
||||
.bind(lift.map(Lift::as_str))
|
||||
.bind(&answer)
|
||||
.bind(&answer_id)
|
||||
.bind(&answered_at)
|
||||
.bind(phase)
|
||||
.bind(&now)
|
||||
.bind(phase)
|
||||
.bind(by)
|
||||
.bind(&o.id)
|
||||
.bind(&o.phase)
|
||||
.bind(o.cycle)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if done.rows_affected() != 1 {
|
||||
return Ok(false);
|
||||
}
|
||||
let doc_id = doc_of(state, &o.proposal_id).await?;
|
||||
let held_author = match &o.held_id {
|
||||
Some(h) => author_of(state, h).await?,
|
||||
None => None,
|
||||
};
|
||||
for effect in &step.effects {
|
||||
match effect {
|
||||
Effect::AskObjector => {
|
||||
// The author's question of this cycle is answered.
|
||||
messages::close(
|
||||
state,
|
||||
Topic::Objection,
|
||||
&o.id,
|
||||
Some(&["objection_raised", "objection_reiterated"]),
|
||||
"answered",
|
||||
)
|
||||
.await?;
|
||||
if held_author.as_deref() != Some(o.author_id.as_str()) {
|
||||
messages::notify(
|
||||
state,
|
||||
&o.author_id,
|
||||
&doc_id,
|
||||
Topic::Objection,
|
||||
&o.id,
|
||||
"objection_answered",
|
||||
cycle,
|
||||
due.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Effect::TellAuthor => {
|
||||
messages::close(state, Topic::Objection, &o.id, Some(&["objection_answered"]), "reiterated").await?;
|
||||
if let Some(author) = held_author.as_deref().filter(|a| *a != o.author_id) {
|
||||
messages::notify(
|
||||
state,
|
||||
author,
|
||||
&doc_id,
|
||||
Topic::Objection,
|
||||
&o.id,
|
||||
"objection_reiterated",
|
||||
cycle,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Effect::Ended => {
|
||||
let outcome = match lift {
|
||||
Some(Lift::Admin) => "admin",
|
||||
Some(Lift::Deadline) => "deadline",
|
||||
Some(Lift::Withdrawn) => "withdrawn",
|
||||
_ => "lifted",
|
||||
};
|
||||
messages::close(state, Topic::Objection, &o.id, None, outcome).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raise, lift, reiterate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RaiseBody {
|
||||
other_id: String,
|
||||
kind: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
/// `POST /api/proposals/{id}/objections` — « this version repeats (or
|
||||
/// contradicts) that one ». The latest arrival of the two is held back.
|
||||
pub async fn raise(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
Json(b): Json<RaiseBody>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (p, st) = proposals::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"));
|
||||
}
|
||||
let kind = ObjectionKind::parse(&b.kind).ok_or(AppError::Invalid("kind"))?;
|
||||
let other = st.proposals.iter().find(|x| x.id == b.other_id).ok_or(AppError::Invalid("other"))?;
|
||||
let voted = |x: &docs::ProposalRow| matches!(x.status.as_str(), "open" | "in_force") && x.kind == "text";
|
||||
if other.id == p.id || !voted(other) || !voted(&p) {
|
||||
return Err(AppError::Invalid("other"));
|
||||
}
|
||||
if other.unit_id == p.unit_id {
|
||||
// Rivals on one article never stand together anyway.
|
||||
return Err(AppError::Invalid("same_unit"));
|
||||
}
|
||||
let (pa, oa) = (arrival(&state, &p).await?, arrival(&state, other).await?);
|
||||
let held = objection::target(
|
||||
&Side { id: &p.id, arrived_at: &pa.0, seq: pa.1, in_force: is_in_force(&st, &p.id) },
|
||||
&Side { id: &other.id, arrived_at: &oa.0, seq: oa.1, in_force: is_in_force(&st, &other.id) },
|
||||
)
|
||||
.map_err(|e| match e {
|
||||
objection::PairError::BothInForce => AppError::Invalid("both_in_force"),
|
||||
objection::PairError::Same => AppError::Invalid("other"),
|
||||
})?
|
||||
.to_owned();
|
||||
let exists: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM signals WHERE phase <> 'lifted' AND author_id = ?
|
||||
AND ((proposal_id = ?2 AND target_proposal_id = ?3) OR (proposal_id = ?3 AND target_proposal_id = ?2))",
|
||||
)
|
||||
.bind(&user.id)
|
||||
.bind(&p.id)
|
||||
.bind(&other.id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if exists > 0 {
|
||||
return Err(AppError::Conflict("objection_exists"));
|
||||
}
|
||||
let oid = util::id();
|
||||
sqlx::query(
|
||||
"INSERT INTO signals (id, proposal_id, target_proposal_id, reason, author_id, created_at, kind, held_id, phase, cycle)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'author', 1)",
|
||||
)
|
||||
.bind(&oid)
|
||||
.bind(&p.id)
|
||||
.bind(&other.id)
|
||||
.bind(reason)
|
||||
.bind(&user.id)
|
||||
.bind(state.clock.now())
|
||||
.bind(kind.as_str())
|
||||
.bind(&held)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if let Some(author) = author_of(&state, &held).await?.filter(|a| *a != user.id) {
|
||||
messages::notify(&state, &author, &st.doc.id, Topic::Objection, &oid, "objection_raised", 1, None).await?;
|
||||
}
|
||||
tracing::info!(objection = %oid, held = %held, kind = kind.as_str(), "objection raised");
|
||||
Ok(Json(json!({ "id": oid, "heldId": held })))
|
||||
}
|
||||
|
||||
async fn act(state: &AppState, user: &IdentityRow, id: &str, lift: bool) -> AppResult<ObjectionRow> {
|
||||
let o = load(state, id).await?;
|
||||
proposals::guarded(state, user, &o.proposal_id).await?;
|
||||
let event = if lift { Event::Lift(who(user, &o.author_id)) } else { Event::Reiterate(who(user, &o.author_id)) };
|
||||
let step = objection::step(&o.phase(), &event).map_err(refused)?.ok_or(AppError::Conflict("lifted"))?;
|
||||
if !store(state, &o, &step, &user.id, None).await? {
|
||||
return Err(AppError::Conflict("changed"));
|
||||
}
|
||||
messages::read_subject(state, &user.id, Topic::Objection, &o.id).await?;
|
||||
tracing::info!(objection = %o.id, lift, by = %user.id, "objection lifted or reiterated");
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// `POST /api/objections/{id}/lift` — by the objector, or an administrator.
|
||||
pub async fn lift(State(state): State<AppState>, User(user): User, Path(id): Path<String>) -> AppResult<Json<Value>> {
|
||||
let o = act(&state, &user, &id, true).await?;
|
||||
let entered = adoption::run(&state, &doc_of(&state, &o.proposal_id).await?).await?;
|
||||
Ok(Json(json!({ "entered": entered })))
|
||||
}
|
||||
|
||||
/// `POST /api/objections/{id}/reiterate` — by the objector only, once the author answered.
|
||||
pub async fn reiterate(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
act(&state, &user, &id, false).await?;
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// What answers an objection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A version changed: the objections around it whose author it answers move on.
|
||||
pub async fn on_change(state: &AppState, proposal_id: &str, substantive: bool) -> AppResult<()> {
|
||||
for o in standing_around(state, proposal_id).await? {
|
||||
let Some(side) = o.touches(proposal_id) else { continue };
|
||||
let change = if substantive { Change::Revision(side) } else { Change::Typo(side) };
|
||||
if !objection::answers(change) {
|
||||
continue;
|
||||
}
|
||||
let due = state.clock.in_days(objection::ANSWER_DAYS);
|
||||
if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)? {
|
||||
let answer = if side == Touched::Target { "revision" } else { "other" };
|
||||
store(state, &o, &step, proposal_id, Some((answer, proposal_id))).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// May this person write a proposal linked to this objection, on this article?
|
||||
pub async fn check_link(state: &AppState, user: &IdentityRow, objection_id: &str, unit_id: &str) -> AppResult<()> {
|
||||
let o = load(state, objection_id).await.map_err(|_| AppError::Invalid("link"))?;
|
||||
let held = o.held_id.as_deref().ok_or(AppError::Invalid("link"))?;
|
||||
let unit_of = |id: String| async move {
|
||||
sqlx::query_scalar::<_, String>("SELECT unit_id FROM proposals WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
};
|
||||
let (held_unit, other_unit) = (unit_of(held.to_owned()).await?, unit_of(o.other_id().to_owned()).await?);
|
||||
let author = author_of(state, held).await?;
|
||||
if !o.phase().standing()
|
||||
|| !objection::may_link(author.as_deref(), &user.id)
|
||||
|| !objection::link_fits(unit_id, &held_unit, &other_unit)
|
||||
{
|
||||
return Err(AppError::Invalid("link"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A proposal linked to an objection was submitted: it answers the objection.
|
||||
pub async fn on_linked(state: &AppState, objection_id: &str, proposal_id: &str) -> AppResult<()> {
|
||||
let o = load(state, objection_id).await?;
|
||||
if !objection::answers(Change::LinkedProposal) {
|
||||
return Ok(());
|
||||
}
|
||||
let due = state.clock.in_days(objection::ANSWER_DAYS);
|
||||
if let Some(step) = objection::step(&o.phase(), &Event::Answered { due: &due }).map_err(refused)? {
|
||||
store(state, &o, &step, proposal_id, Some(("proposal", proposal_id))).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A version was withdrawn: the objections around it end.
|
||||
pub async fn on_withdrawn(state: &AppState, proposal_id: &str, by: &str) -> AppResult<()> {
|
||||
for o in standing_around(state, proposal_id).await? {
|
||||
if let Some(step) = objection::step(&o.phase(), &Event::Withdrawn).map_err(refused)? {
|
||||
store(state, &o, &step, by, None).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The objections whose deadline passed are lifted by themselves; returns how
|
||||
/// many, and the documents touched.
|
||||
pub async fn due(state: &AppState) -> AppResult<(usize, Vec<String>)> {
|
||||
let now = state.clock.now();
|
||||
let rows = sqlx::query_as::<_, ObjectionRow>(&format!("{SELECT} WHERE phase = 'objector' AND due_at <= ?"))
|
||||
.bind(&now)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut docs = Vec::new();
|
||||
let mut lifted = 0;
|
||||
for o in rows {
|
||||
if let Some(step) = objection::step(&o.phase(), &Event::Tick { now: &now }).map_err(refused)?
|
||||
&& store(state, &o, &step, "deadline", None).await?
|
||||
{
|
||||
tracing::info!(objection = %o.id, "objection lifted at its deadline");
|
||||
lifted += 1;
|
||||
let doc = doc_of(state, &o.proposal_id).await?;
|
||||
if !docs.contains(&doc) {
|
||||
docs.push(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((lifted, docs))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// What the screen shows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn version_json(st: &DocState, id: &str) -> Value {
|
||||
let p = st.proposals.iter().find(|p| p.id == id);
|
||||
let unit = p.and_then(|p| st.units.iter().find(|u| u.id == p.unit_id));
|
||||
json!({
|
||||
"id": id, "title": p.map(|p| &p.title), "status": p.map(|p| &p.status), "unitId": p.map(|p| &p.unit_id),
|
||||
"code": unit.map(|u| &u.code), "author": p.and_then(|p| p.author_name.as_ref()),
|
||||
"authorId": p.and_then(|p| p.author_id.as_ref()),
|
||||
})
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
/// One objection, as `viewer` sees it: who raised it, what it holds back, where
|
||||
/// it stands, and what the viewer may do.
|
||||
pub async fn view(state: &AppState, st: &DocState, viewer: &IdentityRow, o: &ObjectionRow) -> AppResult<Value> {
|
||||
let (objector, invited) = name_of(state, &o.author_id).await?;
|
||||
let phase = o.phase();
|
||||
let held_author = match &o.held_id {
|
||||
Some(h) => author_of(state, h).await?,
|
||||
None => None,
|
||||
};
|
||||
let me = viewer.to_identity();
|
||||
let standing = phase.standing();
|
||||
let i_answer = standing && o.held_id.is_some() && objection::may_link(held_author.as_deref(), &viewer.id);
|
||||
let asked = matches!(phase, Phase::Objector { .. });
|
||||
Ok(json!({
|
||||
"id": o.id, "kind": o.kind(), "reason": o.reason, "objector": objector, "objectorInvited": invited,
|
||||
"mine": o.author_id == viewer.id, "at": o.created_at,
|
||||
"phase": o.phase, "cycle": o.cycle, "dueAt": o.due_at, "answer": o.answer, "answerId": o.answer_id,
|
||||
"answeredAt": o.answered_at, "lift": o.lift, "liftedAt": o.lifted_at,
|
||||
"heldVersion": o.held_id.as_ref().map(|h| version_json(st, h)),
|
||||
"other": version_json(st, o.other_id()),
|
||||
"iAnswer": i_answer,
|
||||
"canLift": standing && trustin_access::lifts_objection(&me, &o.author_id),
|
||||
"canReiterate": asked && trustin_access::reiterates_objection(&me, &o.author_id),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The objections around one version, newest first, each as `viewer` sees it;
|
||||
/// `held` tells whether this version is the one held back.
|
||||
pub async fn around(state: &AppState, st: &DocState, viewer: &IdentityRow, proposal_id: &str) -> AppResult<Vec<Value>> {
|
||||
let rows = sqlx::query_as::<_, ObjectionRow>(&format!(
|
||||
"{SELECT} WHERE proposal_id = ?1 OR target_proposal_id = ?1 ORDER BY created_at DESC, rowid DESC"
|
||||
))
|
||||
.bind(proposal_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for o in rows {
|
||||
let mut v = view(state, st, viewer, &o).await?;
|
||||
v["held"] = json!(o.held_id.as_deref() == Some(proposal_id));
|
||||
out.push(v);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// An objection, for a message of its box: what it is about, and the gestures
|
||||
/// the message offers now (none once dealt with).
|
||||
pub async fn for_message(
|
||||
state: &AppState,
|
||||
viewer: &IdentityRow,
|
||||
subject_id: &str,
|
||||
kind: &str,
|
||||
cycle: i64,
|
||||
) -> AppResult<Option<(Value, Vec<&'static str>)>> {
|
||||
let Ok(o) = load(state, subject_id).await else { return Ok(None) };
|
||||
let st = docs::load_state(state, &doc_of(state, &o.proposal_id).await?).await?;
|
||||
let v = view(state, &st, viewer, &o).await?;
|
||||
let current = o.cycle == cycle;
|
||||
let gestures = match (o.phase(), kind) {
|
||||
(Phase::Author { .. }, "objection_raised" | "objection_reiterated") if current => vec!["respond"],
|
||||
(Phase::Objector { .. }, "objection_answered") if current && o.author_id == viewer.id => {
|
||||
vec!["lift", "reiterate"]
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
Ok(Some((v, gestures)))
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Writing: drafts, submission, corrections, votes, contradiction signals,
|
||||
//! inertia changes, editorial translations.
|
||||
//! 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 »):
|
||||
@@ -7,9 +7,10 @@
|
||||
//! - 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.
|
||||
//! - 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;
|
||||
@@ -17,7 +18,7 @@ use crate::auth::{IdentityRow, User};
|
||||
use crate::ballots;
|
||||
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow, UnitRow};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::util;
|
||||
use crate::{objections, typos, util};
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use ld_engine::assist::{self, Hint};
|
||||
@@ -29,10 +30,8 @@ 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<ProposalRow> {
|
||||
pub(crate) async fn load_proposal(state: &AppState, id: &str) -> AppResult<ProposalRow> {
|
||||
sqlx::query_as::<_, ProposalRow>(&format!("{PROPOSAL_SELECT} WHERE p.id = ?"))
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -49,7 +48,11 @@ async fn doc_of_unit(state: &AppState, unit_id: &str) -> AppResult<String> {
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
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?;
|
||||
@@ -168,6 +171,9 @@ pub struct CreateBody {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// Position right after `after` (or at the end), between it and the next unit.
|
||||
@@ -265,9 +271,13 @@ pub async fn create(
|
||||
}
|
||||
unit.id.clone()
|
||||
}
|
||||
None if kind == "text" => new_unit(&state, &st, b.after_unit_id.as_deref()).await?,
|
||||
None if kind == "text" && b.objection_id.is_none() => new_unit(&state, &st, b.after_unit_id.as_deref()).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 {
|
||||
@@ -281,6 +291,16 @@ pub async fn create(
|
||||
},
|
||||
)
|
||||
.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).await?;
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({ "id": id, "unitId": unit_id })))
|
||||
}
|
||||
|
||||
@@ -337,43 +357,8 @@ pub async fn edit(
|
||||
};
|
||||
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) })))
|
||||
}
|
||||
// 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"));
|
||||
@@ -402,14 +387,10 @@ pub async fn edit(
|
||||
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?;
|
||||
// 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).await?;
|
||||
typos::on_text_changed(&state, &id).await?;
|
||||
Ok(Json(json!({ "edit": "substantive", "revision": revision })))
|
||||
}
|
||||
}
|
||||
@@ -446,6 +427,12 @@ pub async fn submit(
|
||||
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<String> =
|
||||
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).await?;
|
||||
}
|
||||
let entered = adoption::run(&state, &st.doc.id).await?;
|
||||
Ok(Json(json!({ "status": "open", "entered": entered })))
|
||||
}
|
||||
@@ -468,6 +455,9 @@ pub async fn withdraw(
|
||||
.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?;
|
||||
Ok(Json(json!({ "status": "withdrawn" })))
|
||||
}
|
||||
|
||||
@@ -557,66 +547,37 @@ pub async fn detail(
|
||||
author_name: Option<String>,
|
||||
author_invited: Option<bool>,
|
||||
created_at: String,
|
||||
typo_id: Option<String>,
|
||||
answered_by: Option<String>,
|
||||
answered_name: Option<String>,
|
||||
}
|
||||
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
|
||||
FROM revisions r LEFT JOIN identities i ON i.id = r.author_id WHERE r.proposal_id = ? ORDER BY r.id",
|
||||
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.
|
||||
// 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 {
|
||||
let diff = previous
|
||||
.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,
|
||||
"authorInvited": r.author_invited.unwrap_or(false), "at": r.created_at, "diff": diff, "typo": typo,
|
||||
}));
|
||||
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,
|
||||
author_invited: bool,
|
||||
created_at: String,
|
||||
lifted_at: Option<String>,
|
||||
lifted_by: Option<String>,
|
||||
}
|
||||
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,
|
||||
i.kind = 'invite' AS author_invited, 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, "authorInvited": s.author_invited,
|
||||
"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 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);
|
||||
@@ -637,7 +598,7 @@ pub async fn detail(
|
||||
"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,
|
||||
"blocked": st.blocked.contains(&p.id),
|
||||
"blocked": st.blocked.contains(&p.id), "votable": st.votable(&p),
|
||||
"myVote": my_vote.as_ref().map(|v| &v.choice),
|
||||
"myVoteStale": my_vote.as_ref().is_some_and(|v| v.revision != p.revision),
|
||||
"myVoteChain": my_vote.as_ref().is_some_and(|v| v.source == "chain"), "myBallot": my_ballot,
|
||||
@@ -647,7 +608,8 @@ pub async fn detail(
|
||||
"inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })),
|
||||
"diff": diff,
|
||||
"history": history,
|
||||
"signals": signals,
|
||||
"objections": objections,
|
||||
"typos": typos,
|
||||
"electorate": st.electorate,
|
||||
"electorateSource": st.electorate_source,
|
||||
"voteFloor": st.doc.vote_floor,
|
||||
@@ -681,6 +643,10 @@ pub async fn vote(
|
||||
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||
return Err(AppError::Conflict("not_votable"));
|
||||
}
|
||||
// Held back by an objection: its votes stay, no new one (ADR 0010).
|
||||
if !st.votable(&p) {
|
||||
return Err(AppError::Conflict("objection"));
|
||||
}
|
||||
if !docs::may_vote(&state, &user, &st.group).await? {
|
||||
return Err(AppError::Conflict("members_only"));
|
||||
}
|
||||
@@ -696,81 +662,6 @@ pub async fn vote(
|
||||
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<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
Json(b): Json<SignalBody>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -71,8 +71,10 @@ pub struct Facts {
|
||||
pub revisions: Vec<String>,
|
||||
/// Translations written or corrected by a member.
|
||||
pub member_translations: i64,
|
||||
/// Contradiction signals naming it, standing or lifted.
|
||||
/// Contradiction signals (objections since 2026-10-31) naming it, standing or lifted.
|
||||
pub signals: i64,
|
||||
/// Typo fixes someone else wrote on it, whatever became of them (2026-10-31).
|
||||
pub typos: i64,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
@@ -100,7 +102,7 @@ pub fn verdict(facts: Option<&Facts>, a: &Amendment) -> Verdict {
|
||||
"revised"
|
||||
} else if f.votes > 0 {
|
||||
"votes"
|
||||
} else if f.revisions != ["initial"] {
|
||||
} else if f.revisions != ["initial"] || f.typos > 0 {
|
||||
"corrected"
|
||||
} else if f.member_translations > 0 {
|
||||
"translation_edited"
|
||||
@@ -214,6 +216,10 @@ async fn facts(conn: &mut SqliteConnection, proposal_id: &str) -> anyhow::Result
|
||||
.bind(proposal_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
let typos: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM typos WHERE proposal_id = ?")
|
||||
.bind(proposal_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
Ok(Some(Facts {
|
||||
origin: p.origin,
|
||||
entered: p.entered_at.is_some(),
|
||||
@@ -223,6 +229,7 @@ async fn facts(conn: &mut SqliteConnection, proposal_id: &str) -> anyhow::Result
|
||||
revisions,
|
||||
member_translations,
|
||||
signals,
|
||||
typos,
|
||||
fingerprint: fingerprint(&p.title, &p.body),
|
||||
}))
|
||||
}
|
||||
@@ -383,6 +390,7 @@ mod tests {
|
||||
(Some(Facts { votes: 1, ..intact() }), "votes"),
|
||||
(Some(Facts { revisions: vec!["initial".into(), "typo".into()], ..intact() }), "corrected"),
|
||||
(Some(Facts { revisions: vec![], ..intact() }), "corrected"),
|
||||
(Some(Facts { typos: 1, ..intact() }), "corrected"),
|
||||
(Some(Facts { member_translations: 1, ..intact() }), "translation_edited"),
|
||||
(Some(Facts { signals: 1, ..intact() }), "signalled"),
|
||||
(Some(Facts { fingerprint: "other".into(), ..intact() }), "text_differs"),
|
||||
|
||||
@@ -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