Fusion de la tête du chantier (924a40d : lots B2 et F) dans le lot D

Les deux côtés gardés partout. Aux coutures :
- une objection (redit / contredit) porte sur deux articles, jamais sur un module ;
  un ordre ne se corrige pas, ni par son auteur ni par un autre ; une coquille du
  titre suit le chemin de F (l'auteur du titre, un administrateur pour le titre
  de départ, ou 3 jours), et documents.title suit à l'application ;
- votable() couvre les modules ; leurs totaux passent par shown_tally, à l'écran
  et dans document.json ;
- « Modules à voter » ne compte plus une version retenue par une objection (test
  vu rouge) ; la liste du groupe la compte toujours parmi les propositions en cours ;
- le panier : une fonction pour les articles et les modules, avec les versions
  retenues de F ; un nouvel article se propose dans une section, à sa fin, et peut
  répondre à une objection ;
- migrations 0007 (modules) puis 0008 (objections) ;
- C25 et C26 supportent un groupe d'essai où une voix suffit (dès 16 membres).

Boucle : fmt, clippy -D warnings, cargo test (227), svelte-check 0, vitest 55,
build, e2e 35 / 35 (C1–C33 et M1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-31 09:41:30 +01:00
co-authored by Claude Opus 5.5
82 changed files with 8285 additions and 677 deletions
+34 -29
View File
@@ -73,6 +73,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?;
@@ -259,7 +265,9 @@ pub struct DocState {
pub module_kinds: HashMap<String, ModuleKind>,
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,
@@ -344,32 +352,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(
@@ -410,6 +395,25 @@ impl DocState {
self.tallies.get(id).copied().unwrap_or_default()
}
/// The totals shown to people: today the live count, as `tally`. The one
/// place where a publication by steps would plug in — on the chain, who
/// voted and when is public, and live totals tell what (the secret of the
/// vote, review of 74f96be, a decision that waits for Yvv). Adoption reads
/// `tally`; the published texts (`publish.rs`) would follow this one.
pub fn shown_tally(&self, id: &str) -> Tally {
self.tally(id)
}
/// How far the threshold is, as shown: on `shown_tally`.
pub fn shown_evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option<Evaluation> {
self.scale().evaluate(self.shown_tally(&p.id), self.voting_level(unit, p)).ok()
}
/// 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)
}
/// The module a unit is, if it is one.
pub fn module_of(&self, unit_id: &str) -> Option<ModuleKind> {
self.module_kinds.get(unit_id).copied()
@@ -627,7 +631,7 @@ fn proposal_json(
me: &str,
) -> serde_json::Value {
let (mine, ballot) = mine;
let eval = st.evaluation(unit, p);
let eval = st.shown_evaluation(unit, p);
let (kind, target) = match p.kind() {
// A module's version says what it holds (ADR 0009).
ProposalKind::Text if matches!(p.kind.as_str(), "order" | "title") => (p.kind.as_str(), None),
@@ -646,11 +650,12 @@ fn proposal_json(
"revision": p.revision, "createdAt": p.created_at, "updatedAt": p.updated_at,
"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),
"tally": st.shown_tally(&p.id),
"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"),