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:
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user