forked from yvv/decision
Une version retenue par une objection le dit sous l'article, à chacun selon son rôle : la personne qui vote voit qu'elle ne se vote plus (les votes faits restent) ; l'auteur, quoi faire (« Modifier l'article visé » pour une redite, « Reformuler » pour une contradiction) ; la personne qui objecte, lever ou réitérer avant l'échéance. Le panier la montre sans choix et ne l'envoie jamais. « Détails » : faire une objection (redit / contredit), les objections et où elles en sont, les coquilles proposées par d'autres avec leur diff resserré, valider / refuser / revenir, l'historique dit qui a validé. Une coquille se propose sur toute version ouverte ou en vigueur. Boîte « Messages » dans le menu, avec le nombre de non-lus (pastille sur le bouton du menu en mobile) : objet, texte, gestes, échéance ; un message traité n'a plus de geste. Répondre à une objection lit son message. Trilingue (fr, en, es). C6 suit la règle : la coquille d'un administrateur sur un texte du seed passe tout de suite ; C28–C31 couvrent le reste. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
588 lines
23 KiB
Rust
588 lines
23 KiB
Rust
//! 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 (by `by`): the objections around it whose author it
|
|
/// answers move on; the messages of the one who answered are read.
|
|
pub async fn on_change(state: &AppState, proposal_id: &str, substantive: bool, by: &str) -> 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" };
|
|
if store(state, &o, &step, proposal_id, Some((answer, proposal_id))).await? {
|
|
messages::read_subject(state, by, Topic::Objection, &o.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 (by `by`): it answers the objection.
|
|
pub async fn on_linked(state: &AppState, objection_id: &str, proposal_id: &str, by: &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?
|
|
{
|
|
messages::read_subject(state, by, Topic::Objection, &o.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)))
|
|
}
|