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