Liens d'équipe (Yvv, 2026-10-21) : un lien réutilisable jusqu'à une date
et pour un nombre de personnes, révocable. Chaque ouverture demande un nom
et crée sa propre identité d'invitation, jamais administratrice ; un nom
déjà pris est refusé ; une personne déjà connectée reçoit les groupes.
trustinWallet montre par quel lien chaque identité est venue, et donne un
nouveau lien de connexion à une personne invitée (autre appareil).
Migration additive 0002 (table team_links, colonne identities.team_link).
Publication : yvv/libredecision-textes sur git.open.us.org, pour
l'instant (un dépôt pour l'ouverture, puis le dépôt officiel, viendront).
Huitième relecture (c3d6bf8 : ok), points corrigés :
- une négation n'est jamais coupée ni formée par collage (« ja mais ») ;
- caractères invisibles : tout l'ensemble Unicode des ignorables et les
contrôles autres que saut de ligne et tabulation ;
- sauts de ligne et indentation font partie de la forme (listes) ;
- un titre tient sur une ligne.
Boucle : 65 tests Rust, svelte-check, 9 vitest, e2e 13/13 (C12 ajouté).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
885 lines
32 KiB
Rust
885 lines
32 KiB
Rust
//! Writing: drafts, submission, corrections, votes, contradiction signals,
|
|
//! inertia changes, editorial translations.
|
|
//!
|
|
//! Editing is not voting (Yvv, 2026-10-21: « il ne faut pas que ça devienne un
|
|
//! système qui nécessite un vote pour corriger une coquille »):
|
|
//! - a **draft** is free: its author edits it at will, nobody votes on it;
|
|
//! - once **submitted**, a correction classified *typographic* by the engine
|
|
//! keeps every vote and is shown with its diff; a *substantive* one opens a
|
|
//! new revision and every earlier vote waits for its author to reconfirm;
|
|
//! - 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.
|
|
|
|
use crate::AppState;
|
|
use crate::adoption;
|
|
use crate::auth::{IdentityRow, User};
|
|
use crate::docs::{self, DocState, PROPOSAL_SELECT, ProposalRow, UnitRow};
|
|
use crate::error::{AppError, AppResult};
|
|
use crate::util;
|
|
use axum::Json;
|
|
use axum::extract::{Path, State};
|
|
use ld_engine::assist::{self, Hint};
|
|
use ld_engine::edit::{self, EditKind};
|
|
use ld_engine::threshold::InertiaPreset;
|
|
use serde::Deserialize;
|
|
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> {
|
|
sqlx::query_as::<_, ProposalRow>(&format!("{PROPOSAL_SELECT} WHERE p.id = ?"))
|
|
.bind(id)
|
|
.fetch_optional(&state.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound)
|
|
}
|
|
|
|
async fn doc_of_unit(state: &AppState, unit_id: &str) -> AppResult<String> {
|
|
sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?")
|
|
.bind(unit_id)
|
|
.fetch_optional(&state.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound)
|
|
}
|
|
|
|
/// Loads the document of a proposal and checks the person may open its group.
|
|
async fn guarded(state: &AppState, user: &IdentityRow, proposal_id: &str) -> AppResult<(ProposalRow, DocState)> {
|
|
let p = load_proposal(state, proposal_id).await?;
|
|
let doc_id = doc_of_unit(state, &p.unit_id).await?;
|
|
let st = docs::load_state(state, &doc_id).await?;
|
|
docs::require_open(state, user, &st.group).await?;
|
|
if p.status == "draft" && p.author_id.as_deref() != Some(user.id.as_str()) {
|
|
return Err(AppError::NotFound);
|
|
}
|
|
Ok((p, st))
|
|
}
|
|
|
|
fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
|
|
if edit::has_hidden_controls(title) || edit::has_hidden_controls(body) {
|
|
return Err(AppError::Invalid("hidden_characters"));
|
|
}
|
|
if title.trim().is_empty() || title.chars().count() > MAX_TITLE || title.contains(['\n', '\r']) {
|
|
return Err(AppError::Invalid("title"));
|
|
}
|
|
if (needs_body && body.trim().is_empty()) || body.chars().count() > MAX_BODY {
|
|
return Err(AppError::Invalid("body"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The text the votes were cast on: its last initial or substantive revision.
|
|
async fn voted_text(state: &AppState, p: &ProposalRow) -> AppResult<(String, String)> {
|
|
let row: Option<(String, String)> = sqlx::query_as(
|
|
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive')
|
|
ORDER BY id DESC LIMIT 1",
|
|
)
|
|
.bind(&p.id)
|
|
.fetch_optional(&state.db)
|
|
.await?;
|
|
Ok(row.unwrap_or_else(|| (p.title.clone(), p.body.clone())))
|
|
}
|
|
|
|
async fn record_revision(
|
|
state: &AppState,
|
|
proposal_id: &str,
|
|
revision: i64,
|
|
kind: &str,
|
|
title: &str,
|
|
body: &str,
|
|
author: Option<&str>,
|
|
) -> AppResult<()> {
|
|
sqlx::query(
|
|
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(proposal_id)
|
|
.bind(revision)
|
|
.bind(kind)
|
|
.bind(title)
|
|
.bind(body)
|
|
.bind(author)
|
|
.bind(util::now())
|
|
.execute(&state.db)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Assistant
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AssistBody {
|
|
text: String,
|
|
proposal_id: Option<String>,
|
|
}
|
|
|
|
pub async fn assist(
|
|
State(state): State<AppState>,
|
|
User(user): User,
|
|
Path(doc_id): Path<String>,
|
|
Json(body): Json<AssistBody>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
let st = docs::load_state(&state, &doc_id).await?;
|
|
docs::require_open(&state, &user, &st.group).await?;
|
|
let others: Vec<(String, String)> = st
|
|
.proposals
|
|
.iter()
|
|
.filter(|p| p.status != "draft" && Some(&p.id) != body.proposal_id.as_ref() && p.kind == "text")
|
|
.map(|p| (p.id.clone(), format!("{}\n{}", p.title, p.body)))
|
|
.collect();
|
|
let hints: Vec<_> = assist::hints(&body.text, &others)
|
|
.into_iter()
|
|
.map(|h| match h {
|
|
Hint::Similar { target, score } => {
|
|
let p = st.proposals.iter().find(|p| p.id == target);
|
|
let unit = p.and_then(|p| st.units.iter().find(|u| u.id == p.unit_id));
|
|
json!({
|
|
"kind": "similar", "score": score, "proposalId": target,
|
|
"unitId": unit.map(|u| &u.id), "code": unit.map(|u| &u.code),
|
|
"title": p.map(|p| &p.title), "inForce": p.is_some_and(|p| p.status == "in_force"),
|
|
})
|
|
}
|
|
other => serde_json::to_value(other).unwrap_or_default(),
|
|
})
|
|
.collect();
|
|
Ok(Json(json!({ "hints": hints })))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Create, edit, submit, withdraw, split
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CreateBody {
|
|
kind: Option<String>,
|
|
unit_id: Option<String>,
|
|
after_unit_id: Option<String>,
|
|
title: String,
|
|
#[serde(default)]
|
|
body: String,
|
|
#[serde(default)]
|
|
submit: bool,
|
|
}
|
|
|
|
/// Position right after `after` (or at the end), between it and the next unit.
|
|
fn position_after(units: &[UnitRow], after: Option<&str>) -> f64 {
|
|
let mut positions: Vec<f64> = units.iter().map(|u| u.position).collect();
|
|
positions.sort_by(f64::total_cmp);
|
|
let Some(anchor) = after.and_then(|a| units.iter().find(|u| u.id == a)).map(|u| u.position) else {
|
|
return positions.last().copied().unwrap_or(0.0) + 1.0;
|
|
};
|
|
match positions.iter().find(|p| **p > anchor) {
|
|
Some(next) => (anchor + next) / 2.0,
|
|
None => anchor + 1.0,
|
|
}
|
|
}
|
|
|
|
async fn new_unit(state: &AppState, st: &DocState, after: Option<&str>) -> AppResult<String> {
|
|
let id = util::id();
|
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM units WHERE doc_id = ? AND code LIKE 'N%'")
|
|
.bind(&st.doc.id)
|
|
.fetch_one(&state.db)
|
|
.await?;
|
|
// A new article belongs to the section of the one it follows.
|
|
let section = after.and_then(|a| st.units.iter().find(|u| u.id == a)).and_then(|u| u.section.clone());
|
|
sqlx::query(
|
|
"INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at)
|
|
VALUES (?, ?, ?, ?, ?, 'standard', NULL, ?)",
|
|
)
|
|
.bind(&id)
|
|
.bind(&st.doc.id)
|
|
.bind(format!("N{}", n + 1))
|
|
.bind(section)
|
|
.bind(position_after(&st.units, after))
|
|
.bind(util::now())
|
|
.execute(&state.db)
|
|
.await?;
|
|
Ok(id)
|
|
}
|
|
|
|
struct NewProposal<'a> {
|
|
unit_id: &'a str,
|
|
kind: &'a str,
|
|
inertia_target: Option<&'a str>,
|
|
title: &'a str,
|
|
body: &'a str,
|
|
author: &'a str,
|
|
submit: bool,
|
|
}
|
|
|
|
async fn insert_proposal(state: &AppState, n: NewProposal<'_>) -> AppResult<String> {
|
|
let NewProposal { unit_id, kind, inertia_target, title, body, author, submit } = n;
|
|
let id = util::id();
|
|
let now = util::now();
|
|
sqlx::query(
|
|
"INSERT INTO proposals (id, unit_id, kind, inertia_target, title, body, status, origin, author_id, revision,
|
|
created_at, updated_at, submitted_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'member', ?, 1, ?, ?, ?)",
|
|
)
|
|
.bind(&id)
|
|
.bind(unit_id)
|
|
.bind(kind)
|
|
.bind(inertia_target)
|
|
.bind(title.trim())
|
|
.bind(body.trim())
|
|
.bind(if submit { "open" } else { "draft" })
|
|
.bind(author)
|
|
.bind(&now)
|
|
.bind(&now)
|
|
.bind(submit.then_some(&now))
|
|
.execute(&state.db)
|
|
.await?;
|
|
record_revision(state, &id, 1, "initial", title.trim(), body.trim(), Some(author)).await?;
|
|
Ok(id)
|
|
}
|
|
|
|
pub async fn create(
|
|
State(state): State<AppState>,
|
|
User(user): User,
|
|
Path(doc_id): Path<String>,
|
|
Json(b): Json<CreateBody>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
let st = docs::load_state(&state, &doc_id).await?;
|
|
docs::require_open(&state, &user, &st.group).await?;
|
|
let kind = match b.kind.as_deref().unwrap_or("text") {
|
|
"text" => "text",
|
|
"remove" => "remove",
|
|
_ => return Err(AppError::Invalid("kind")),
|
|
};
|
|
check_text(&b.title, &b.body, kind == "text")?;
|
|
let unit_id = match b.unit_id.as_deref() {
|
|
Some(u) => {
|
|
let unit = st.units.iter().find(|x| x.id == u).ok_or(AppError::NotFound)?;
|
|
if kind == "remove" && unit.in_force_id.is_none() {
|
|
return Err(AppError::Invalid("nothing_to_remove"));
|
|
}
|
|
unit.id.clone()
|
|
}
|
|
None if kind == "text" => new_unit(&state, &st, b.after_unit_id.as_deref()).await?,
|
|
None => return Err(AppError::Invalid("unit")),
|
|
};
|
|
let id = insert_proposal(
|
|
&state,
|
|
NewProposal {
|
|
unit_id: &unit_id,
|
|
kind,
|
|
inertia_target: None,
|
|
title: &b.title,
|
|
body: &b.body,
|
|
author: &user.id,
|
|
submit: b.submit,
|
|
},
|
|
)
|
|
.await?;
|
|
Ok(Json(json!({ "id": id, "unitId": unit_id })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct EditBody {
|
|
title: String,
|
|
body: String,
|
|
}
|
|
|
|
pub async fn edit(
|
|
State(state): State<AppState>,
|
|
User(user): User,
|
|
Path(id): Path<String>,
|
|
Json(b): Json<EditBody>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
let (p, st) = guarded(&state, &user, &id).await?;
|
|
check_text(&b.title, &b.body, p.kind == "text")?;
|
|
let (title, body) = (b.title.trim(), b.body.trim());
|
|
let is_author = p.author_id.as_deref() == Some(user.id.as_str());
|
|
let now = util::now();
|
|
|
|
if p.status == "draft" {
|
|
if !is_author {
|
|
return Err(AppError::Forbidden);
|
|
}
|
|
sqlx::query("UPDATE proposals SET title = ?, body = ?, updated_at = ? WHERE id = ?")
|
|
.bind(title)
|
|
.bind(body)
|
|
.bind(&now)
|
|
.bind(&id)
|
|
.execute(&state.db)
|
|
.await?;
|
|
return Ok(Json(json!({ "edit": "draft" })));
|
|
}
|
|
if !matches!(p.status.as_str(), "open" | "in_force") {
|
|
return Err(AppError::Conflict("closed"));
|
|
}
|
|
// Measured against the text that was voted, not the current one: successive
|
|
// typo fixes are judged together and never add up to a change of meaning.
|
|
// Title and body are judged apart (a title never absorbs the start of the
|
|
// body) and together (two slips at most in all).
|
|
let next = format!("{title}\n{body}");
|
|
let kind = if title == p.title && body == p.body {
|
|
EditKind::Unchanged
|
|
} else {
|
|
let (voted_title, voted_body) = voted_text(&state, &p).await?;
|
|
let kinds = [
|
|
edit::classify(&voted_title, title),
|
|
edit::classify(&voted_body, body),
|
|
edit::classify(&format!("{voted_title}\n{voted_body}"), &next),
|
|
];
|
|
// All unchanged: back to the voted text, itself a correction.
|
|
if kinds.contains(&EditKind::Substantive) { EditKind::Substantive } else { EditKind::Typographic }
|
|
};
|
|
match kind {
|
|
EditKind::Unchanged => Ok(Json(json!({ "edit": "unchanged" }))),
|
|
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) })))
|
|
}
|
|
EditKind::Substantive => {
|
|
if p.status == "in_force" {
|
|
return Err(AppError::Conflict("substantive_in_force"));
|
|
}
|
|
if !is_author {
|
|
return Err(AppError::Conflict("substantive_not_author"));
|
|
}
|
|
let revision = p.revision + 1;
|
|
// Only on the open proposal that was read: never on a text that entered into
|
|
// force in the meantime.
|
|
let done = sqlx::query(
|
|
"UPDATE proposals SET title = ?, body = ?, revision = ?, updated_at = ?
|
|
WHERE id = ? AND revision = ? AND status = 'open' AND title = ? AND body = ?",
|
|
)
|
|
.bind(title)
|
|
.bind(body)
|
|
.bind(revision)
|
|
.bind(&now)
|
|
.bind(&id)
|
|
.bind(p.revision)
|
|
.bind(&p.title)
|
|
.bind(&p.body)
|
|
.execute(&state.db)
|
|
.await?;
|
|
if done.rows_affected() != 1 {
|
|
return Err(AppError::Conflict("changed"));
|
|
}
|
|
record_revision(&state, &id, revision, "substantive", title, body, Some(&user.id)).await?;
|
|
// 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?;
|
|
Ok(Json(json!({ "edit": "substantive", "revision": revision })))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn submit(
|
|
State(state): State<AppState>,
|
|
User(user): User,
|
|
Path(id): Path<String>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
let (p, st) = guarded(&state, &user, &id).await?;
|
|
if p.author_id.as_deref() != Some(user.id.as_str()) {
|
|
return Err(AppError::Forbidden);
|
|
}
|
|
if p.status != "draft" {
|
|
return Err(AppError::Conflict("not_draft"));
|
|
}
|
|
if edit::has_hidden_controls(&p.title) || edit::has_hidden_controls(&p.body) {
|
|
return Err(AppError::Invalid("hidden_characters"));
|
|
}
|
|
let now = util::now();
|
|
sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?")
|
|
.bind(&now)
|
|
.bind(&now)
|
|
.bind(&id)
|
|
.execute(&state.db)
|
|
.await?;
|
|
// A draft is edited freely: the text submitted is the one votes will be cast on.
|
|
let initial = sqlx::query("UPDATE revisions SET title = ?, body = ? WHERE proposal_id = ? AND kind = 'initial'")
|
|
.bind(&p.title)
|
|
.bind(&p.body)
|
|
.bind(&id)
|
|
.execute(&state.db)
|
|
.await?;
|
|
if initial.rows_affected() == 0 {
|
|
record_revision(&state, &id, p.revision, "initial", &p.title, &p.body, Some(&user.id)).await?;
|
|
}
|
|
let entered = adoption::run(&state, &st.doc.id).await?;
|
|
Ok(Json(json!({ "status": "open", "entered": entered })))
|
|
}
|
|
|
|
pub async fn withdraw(
|
|
State(state): State<AppState>,
|
|
User(user): User,
|
|
Path(id): Path<String>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
let (p, _) = guarded(&state, &user, &id).await?;
|
|
if p.author_id.as_deref() != Some(user.id.as_str()) {
|
|
return Err(AppError::Forbidden);
|
|
}
|
|
if !matches!(p.status.as_str(), "draft" | "open") {
|
|
return Err(AppError::Conflict("closed"));
|
|
}
|
|
sqlx::query("UPDATE proposals SET status = 'withdrawn', left_at = ?, updated_at = ? WHERE id = ?")
|
|
.bind(util::now())
|
|
.bind(util::now())
|
|
.bind(&id)
|
|
.execute(&state.db)
|
|
.await?;
|
|
Ok(Json(json!({ "status": "withdrawn" })))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SplitBody {
|
|
parts: Vec<String>,
|
|
}
|
|
|
|
/// One draft becomes several: the first part stays, each other part becomes a
|
|
/// draft on a new unit placed right after. Drafts only — nobody voted yet.
|
|
pub async fn split(
|
|
State(state): State<AppState>,
|
|
User(user): User,
|
|
Path(id): Path<String>,
|
|
Json(b): Json<SplitBody>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
let (p, st) = guarded(&state, &user, &id).await?;
|
|
if p.author_id.as_deref() != Some(user.id.as_str()) {
|
|
return Err(AppError::Forbidden);
|
|
}
|
|
if p.status != "draft" || p.kind != "text" {
|
|
return Err(AppError::Conflict("not_draft"));
|
|
}
|
|
let parts: Vec<&str> = b.parts.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
|
if parts.len() < 2 || parts.len() > 12 || parts.iter().any(|s| s.chars().count() > MAX_BODY) {
|
|
return Err(AppError::Invalid("parts"));
|
|
}
|
|
if parts.iter().any(|s| edit::has_hidden_controls(s)) {
|
|
return Err(AppError::Invalid("hidden_characters"));
|
|
}
|
|
sqlx::query("UPDATE proposals SET body = ?, updated_at = ? WHERE id = ?")
|
|
.bind(parts[0])
|
|
.bind(util::now())
|
|
.bind(&id)
|
|
.execute(&state.db)
|
|
.await?;
|
|
let mut created = vec![id.clone()];
|
|
let mut after = p.unit_id.clone();
|
|
for (n, part) in parts.iter().enumerate().skip(1) {
|
|
let fresh = docs::load_state(&state, &st.doc.id).await?;
|
|
let unit = new_unit(&state, &fresh, Some(&after)).await?;
|
|
let title = format!("{} ({})", p.title, n + 1);
|
|
let title: String = title.chars().take(MAX_TITLE).collect();
|
|
created.push(
|
|
insert_proposal(
|
|
&state,
|
|
NewProposal {
|
|
unit_id: &unit,
|
|
kind: "text",
|
|
inertia_target: None,
|
|
title: &title,
|
|
body: part,
|
|
author: &user.id,
|
|
submit: false,
|
|
},
|
|
)
|
|
.await?,
|
|
);
|
|
after = unit;
|
|
}
|
|
Ok(Json(json!({ "drafts": created })))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Detail
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub async fn detail(
|
|
State(state): State<AppState>,
|
|
User(user): User,
|
|
Path(id): Path<String>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
let (p, st) = guarded(&state, &user, &id).await?;
|
|
let unit = st.units.iter().find(|u| u.id == p.unit_id).ok_or(AppError::NotFound)?;
|
|
let in_force = match &unit.in_force_id {
|
|
Some(fid) if *fid != p.id => Some(load_proposal(&state, fid).await?),
|
|
_ => None,
|
|
};
|
|
|
|
#[derive(sqlx::FromRow, serde::Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct Rev {
|
|
revision: i64,
|
|
kind: String,
|
|
title: String,
|
|
body: String,
|
|
author_name: Option<String>,
|
|
created_at: String,
|
|
}
|
|
let revs = sqlx::query_as::<_, Rev>(
|
|
"SELECT r.revision, r.kind, r.title, r.body, i.display_name AS author_name, r.created_at
|
|
FROM revisions r LEFT JOIN identities i ON i.id = r.author_id 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.
|
|
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)));
|
|
history.push(json!({
|
|
"revision": r.revision, "kind": r.kind, "author": r.author_name, "at": r.created_at, "diff": diff,
|
|
}));
|
|
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,
|
|
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,
|
|
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, "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 my_vote: Option<(String, i64)> =
|
|
sqlx::query_as("SELECT choice, revision FROM votes WHERE proposal_id = ? AND identity_id = ?")
|
|
.bind(&id)
|
|
.bind(&user.id)
|
|
.fetch_optional(&state.db)
|
|
.await?;
|
|
let diff = match (&in_force, p.kind.as_str()) {
|
|
(Some(v), "text") => Some(edit::diff(&v.body, &p.body)),
|
|
_ => None,
|
|
};
|
|
let eval = st.evaluation(unit, &p);
|
|
let voted_at = if p.kind == "inertia" { docs::inertia_of(unit).raised() } else { docs::inertia_of(unit) };
|
|
let tr = docs::translations_for(&state, &[&p]).await?;
|
|
Ok(Json(json!({
|
|
"proposal": {
|
|
"id": p.id, "kind": p.kind, "inertiaTarget": p.inertia_target, "title": p.title, "body": p.body,
|
|
"status": p.status, "origin": p.origin, "label": p.label, "revision": p.revision,
|
|
"author": p.author_name, "mine": p.author_id.as_deref() == Some(user.id.as_str()),
|
|
"submittedAt": p.submitted_at, "enteredAt": p.entered_at, "confirmedAt": p.confirmed_at,
|
|
"seedMinimal": p.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.as_str(),
|
|
"blocked": st.blocked.contains(&p.id),
|
|
"myVote": my_vote.as_ref().map(|v| &v.0), "myVoteStale": my_vote.as_ref().is_some_and(|v| v.1 != p.revision),
|
|
"translations": tr.get(&p.id).cloned().unwrap_or_default(),
|
|
},
|
|
"unit": { "id": unit.id, "code": unit.code, "inertia": unit.inertia },
|
|
"inForce": in_force.map(|v| json!({ "id": v.id, "title": v.title, "body": v.body })),
|
|
"diff": diff,
|
|
"history": history,
|
|
"signals": signals,
|
|
"electorate": st.electorate,
|
|
"voteFloor": st.doc.vote_floor,
|
|
})))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Votes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct VoteBody {
|
|
choice: Option<String>,
|
|
}
|
|
|
|
pub async fn vote(
|
|
State(state): State<AppState>,
|
|
User(user): User,
|
|
Path(id): Path<String>,
|
|
Json(b): Json<VoteBody>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
let (p, st) = guarded(&state, &user, &id).await?;
|
|
if !matches!(p.status.as_str(), "open" | "in_force") {
|
|
return Err(AppError::Conflict("not_votable"));
|
|
}
|
|
// Withdrawing one's vote is always possible; casting one follows trustinWallet.
|
|
if b.choice.is_some() && !docs::may_vote(&state, &user, &st.group).await? {
|
|
return Err(AppError::Conflict("wot_members_only"));
|
|
}
|
|
match b.choice.as_deref() {
|
|
Some(choice @ ("for" | "against")) => {
|
|
sqlx::query(
|
|
"INSERT INTO votes (proposal_id, identity_id, choice, revision, cast_at) VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(proposal_id, identity_id) DO UPDATE SET choice = excluded.choice,
|
|
revision = excluded.revision, cast_at = excluded.cast_at",
|
|
)
|
|
.bind(&id)
|
|
.bind(&user.id)
|
|
.bind(choice)
|
|
.bind(p.revision)
|
|
.bind(util::now())
|
|
.execute(&state.db)
|
|
.await?;
|
|
}
|
|
None => {
|
|
sqlx::query("DELETE FROM votes WHERE proposal_id = ? AND identity_id = ?")
|
|
.bind(&id)
|
|
.bind(&user.id)
|
|
.execute(&state.db)
|
|
.await?;
|
|
}
|
|
Some(_) => return Err(AppError::Invalid("choice")),
|
|
}
|
|
let entered = adoption::run(&state, &st.doc.id).await?;
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct InertiaBody {
|
|
target: String,
|
|
}
|
|
|
|
/// Propose a change of inertia; it is voted one level above the unit. An open
|
|
/// proposal for the same target is reused rather than duplicated.
|
|
pub async fn propose_inertia(
|
|
State(state): State<AppState>,
|
|
User(user): User,
|
|
Path(unit_id): Path<String>,
|
|
Json(b): Json<InertiaBody>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
let target = InertiaPreset::parse(&b.target).ok_or(AppError::Invalid("inertia"))?;
|
|
let doc_id = doc_of_unit(&state, &unit_id).await?;
|
|
let st = docs::load_state(&state, &doc_id).await?;
|
|
docs::require_open(&state, &user, &st.group).await?;
|
|
let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?;
|
|
if docs::inertia_of(unit) == target {
|
|
return Err(AppError::Invalid("same_inertia"));
|
|
}
|
|
if let Some(existing) = st.proposals.iter().find(|p| {
|
|
p.unit_id == unit_id
|
|
&& p.kind == "inertia"
|
|
&& p.status == "open"
|
|
&& p.inertia_target.as_deref() == Some(target.as_str())
|
|
}) {
|
|
return Ok(Json(json!({ "id": existing.id, "existing": true })));
|
|
}
|
|
let title = format!("inertia:{}", target.as_str());
|
|
let id = insert_proposal(
|
|
&state,
|
|
NewProposal {
|
|
unit_id: &unit_id,
|
|
kind: "inertia",
|
|
inertia_target: Some(target.as_str()),
|
|
title: &title,
|
|
body: "",
|
|
author: &user.id,
|
|
submit: true,
|
|
},
|
|
)
|
|
.await?;
|
|
Ok(Json(json!({ "id": id, "existing": false })))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Editorial translations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub async fn translate(
|
|
State(state): State<AppState>,
|
|
User(user): User,
|
|
Path((id, lang)): Path<(String, String)>,
|
|
Json(b): Json<EditBody>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
let (p, st) = guarded(&state, &user, &id).await?;
|
|
if !util::is_lang(&lang) || lang == st.doc.source_lang {
|
|
return Err(AppError::Invalid("lang"));
|
|
}
|
|
check_text(&b.title, &b.body, p.kind == "text")?;
|
|
sqlx::query(
|
|
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, 'member', ?, ?)
|
|
ON CONFLICT(proposal_id, lang) DO UPDATE SET title = excluded.title, body = excluded.body,
|
|
source_hash = excluded.source_hash, origin = 'member', updated_by = excluded.updated_by,
|
|
updated_at = excluded.updated_at",
|
|
)
|
|
.bind(&id)
|
|
.bind(&lang)
|
|
.bind(b.title.trim())
|
|
.bind(b.body.trim())
|
|
.bind(util::source_hash(&p.title, &p.body))
|
|
.bind(&user.id)
|
|
.bind(util::now())
|
|
.execute(&state.db)
|
|
.await?;
|
|
if p.status == "in_force" {
|
|
adoption::enqueue_publication(&state, &st.doc, "traduction").await?;
|
|
}
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn unit(id: &str, position: f64) -> UnitRow {
|
|
UnitRow {
|
|
id: id.into(),
|
|
code: id.into(),
|
|
section: None,
|
|
position,
|
|
inertia: "standard".into(),
|
|
in_force_id: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn new_units_slot_between_neighbours() {
|
|
let units = [unit("a", 1.0), unit("b", 2.0), unit("c", 4.0)];
|
|
assert_eq!(position_after(&units, Some("a")), 1.5);
|
|
assert_eq!(position_after(&units, Some("c")), 5.0);
|
|
assert_eq!(position_after(&units, None), 5.0);
|
|
assert_eq!(position_after(&[], None), 1.0);
|
|
}
|
|
}
|