//! Reading: groups, tools, documents, previews. //! //! The state of a document is loaded once (`DocState`) and turned into the //! engine's pure types; every number shown on screen comes from the engine. use crate::AppState; use crate::auth::{IdentityRow, User}; use crate::ballots::{self, MyBallot, MyVote}; use crate::error::{AppError, AppResult}; use crate::modules::ModuleKind; use crate::participation; use crate::util::{self, LangMap}; use axum::Json; use axum::extract::{Path, Query, State}; use ld_engine::ballot::Choice; use ld_engine::buffer::{self, Rules, Stage}; use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText}; use ld_engine::inertia::{InertiaLevel, Participation, Scale}; use ld_engine::threshold::{self, Evaluation, FormulaParams, Tally}; use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::{BTreeMap, HashMap, HashSet}; use trustin_access::{Group, GroupPolicy}; #[derive(Clone, Debug, sqlx::FromRow)] pub struct GroupRow { pub slug: String, pub name: String, pub parent: Option, pub policy: String, pub hidden: bool, pub position: i64, } impl GroupRow { pub fn to_group(&self) -> Group { Group { slug: self.slug.clone(), name: self.name.clone(), parent: self.parent.clone(), policy: GroupPolicy::parse(&self.policy).unwrap_or(GroupPolicy::Managed), hidden: self.hidden, } } } pub async fn granted(state: &AppState, group: &str, identity: &str) -> AppResult { let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM memberships WHERE group_slug = ? AND identity_id = ?") .bind(group) .bind(identity) .fetch_one(&state.db) .await?; Ok(n > 0) } pub async fn load_group(state: &AppState, slug: &str) -> AppResult { sqlx::query_as::<_, GroupRow>("SELECT slug, name, parent, policy, hidden, position FROM groups WHERE slug = ?") .bind(slug) .fetch_optional(&state.db) .await? .ok_or(AppError::NotFound) } /// 404 for a group one cannot see, 403 for one seen but not open. pub async fn require_open(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<()> { let id = user.to_identity(); let g = group.to_group(); let has = granted(state, &group.slug, &user.id).await?; if !trustin_access::sees(&id, &g, has) { return Err(AppError::NotFound); } if !trustin_access::opens(&id, &g, has) { return Err(AppError::Forbidden); } Ok(()) } /// Does this identity open the group now (see `trustin_access::opens`)? pub async fn opens(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult { 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 { let has = granted(state, &group.slug, &user.id).await?; Ok(trustin_access::votes_in(&user.to_identity(), &group.to_group(), has)) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct GroupView { slug: String, name: String, parent: Option, policy: String, open: bool, } pub async fn groups(State(state): State, User(user): User) -> AppResult> { let rows = sqlx::query_as::<_, GroupRow>( "SELECT slug, name, parent, policy, hidden, position FROM groups ORDER BY position", ) .fetch_all(&state.db) .await?; let id = user.to_identity(); let mut out = Vec::new(); for row in rows { let has = granted(&state, &row.slug, &user.id).await?; let g = row.to_group(); if trustin_access::sees(&id, &g, has) { out.push(GroupView { open: trustin_access::opens(&id, &g, has), slug: row.slug, name: row.name, parent: row.parent, policy: row.policy, }); } } Ok(Json(json!({ "groups": out }))) } #[derive(Serialize, sqlx::FromRow)] #[serde(rename_all = "camelCase")] struct DocListRow { id: String, slug: String, #[sqlx(rename = "title")] #[serde(skip)] title_raw: String, #[sqlx(rename = "summary")] #[serde(skip)] summary_raw: String, in_force: i64, open: i64, } /// The tools of a group. Only "Docs de référence" is active for now. pub const TOOLS: [(&str, bool); 4] = [("decisions", false), ("docs", true), ("mandats", false), ("protocoles", false)]; pub async fn group( State(state): State, User(user): User, Path(slug): Path, ) -> AppResult> { let row = load_group(&state, &slug).await?; let id = user.to_identity(); let has = granted(&state, &slug, &user.id).await?; let g = row.to_group(); if !trustin_access::sees(&id, &g, has) { return Err(AppError::NotFound); } let open = trustin_access::opens(&id, &g, has); let docs = if open { sqlx::query_as::<_, DocListRow>( "SELECT d.id, d.slug, d.title, d.summary, (SELECT COUNT(*) FROM units u WHERE u.doc_id = d.id AND u.kind = 'article' AND u.in_force_id IS NOT NULL) AS in_force, -- Articles in force; open proposals of articles and modules alike (ADR 0009). (SELECT COUNT(*) FROM proposals p JOIN units u ON u.id = p.unit_id WHERE u.doc_id = d.id AND p.status = 'open') AS open FROM documents d WHERE d.group_slug = ? ORDER BY d.created_at", ) .bind(&slug) .fetch_all(&state.db) .await? .into_iter() .map(|d| { json!({ "id": d.id, "slug": d.slug, "title": util::parse_lang(&d.title_raw), "summary": util::parse_lang(&d.summary_raw), "inForce": d.in_force, "open": d.open, }) }) .collect::>() } else { Vec::new() }; let tools: Vec<_> = TOOLS.iter().map(|(key, active)| json!({ "key": key, "active": active })).collect(); Ok(Json(json!({ "group": { "slug": row.slug, "name": row.name, "parent": row.parent, "policy": row.policy, "open": open }, "tools": tools, "docs": docs, }))) } // --------------------------------------------------------------------------- // Document state // --------------------------------------------------------------------------- #[derive(Clone, Debug, sqlx::FromRow)] pub struct DocRow { pub id: String, pub group_slug: String, pub slug: String, pub title: String, pub summary: String, pub source_lang: String, /// The reference quorum Q₀ (C before 2026-11-03): read for the reference /// participation p0 = 2Q₀/W only, and as every level's quorum while `quorums` is NULL. pub vote_floor: f64, pub publish_path: Option, /// The quorum of each level, JSON `[Q1, …, Q5]` (ADR 0013); NULL before the quorum /// amendment (`quorum_amend`). pub quorums: Option, } impl DocRow { /// The quorum of each level, 1 to 5: the document's, or, in a base the quorum /// amendment has not reached yet, its reference quorum at every level — the C of before. pub fn quorums(&self) -> [u32; 5] { self.quorums.as_deref().and_then(|raw| serde_json::from_str(raw).ok()).unwrap_or([whole(self.vote_floor); 5]) } } #[derive(Clone, Debug, sqlx::FromRow)] pub struct UnitRow { pub id: String, pub code: String, pub section: Option, pub position: f64, pub inertia: String, pub in_force_id: Option, /// Its own quorum, set by the author of its first version before the first vote /// (`quorum`); NULL: the quorum of its level. pub quorum: Option, } impl UnitRow { /// Its own quorum, if its author set one. pub fn own_quorum(&self) -> Option { self.quorum.and_then(|q| u32::try_from(q).ok()) } } #[derive(Clone, Debug, sqlx::FromRow)] pub struct ProposalRow { pub id: String, pub unit_id: String, pub kind: String, pub inertia_target: Option, pub title: String, pub body: String, pub status: String, pub origin: String, pub label: Option, pub author_id: Option, pub author_name: Option, /// The author came by invitation, not with a Ğ1 key: shown next to the name, /// so a name taken ahead of its owner, or a look-alike, does not pass for them. pub author_invited: Option, pub revision: i64, pub created_at: String, pub updated_at: String, pub submitted_at: Option, pub entered_at: Option, pub confirmed_at: Option, /// Put forward by the seed to start the vote (ADR 0011): see `seed_proposal`. pub seed_featured: bool, } pub const PROPOSAL_SELECT: &str = "SELECT p.id, p.unit_id, p.kind, p.inertia_target, p.title, p.body, p.status, p.origin, p.label, p.author_id, i.display_name AS author_name, i.kind = 'invite' AS author_invited, p.revision, p.created_at, p.updated_at, p.submitted_at, p.entered_at, p.confirmed_at, p.seed_featured FROM proposals p LEFT JOIN identities i ON i.id = p.author_id"; impl ProposalRow { /// A seed a minima: in force from the seed, never voted into force nor confirmed /// (the title and the orders of a new base; a text a seed proposal amendment left). pub fn seed_minimal(&self) -> bool { self.status == "in_force" && self.entered_at.is_none() && self.confirmed_at.is_none() && self.origin == "seed" } /// A seed proposal (ADR 0011): open, from the seed, put forward to start the vote, /// never voted into force. Not in force: it enters like any proposal. pub fn seed_proposal(&self) -> bool { self.seed_featured && self.status == "open" && self.origin == "seed" && self.entered_at.is_none() } pub fn kind(&self) -> ProposalKind { match self.kind.as_str() { "remove" => ProposalKind::Remove, "inertia" => { ProposalKind::Inertia(self.inertia_target.as_deref().and_then(InertiaLevel::parse).unwrap_or_default()) } _ => ProposalKind::Text, } } } /// The level of a unit: a digit, or one of the four words of before. pub fn inertia_of(unit: &UnitRow) -> InertiaLevel { InertiaLevel::parse(&unit.inertia).unwrap_or_default() } pub struct DocState { pub doc: DocRow, pub group: GroupRow, /// Every unit, articles and modules, by position. pub units: Vec, /// The units that are modules (ADR 0009), by unit id; any other is an article. pub module_kinds: HashMap, pub proposals: Vec, /// What adoption counts (ADR 0012): recorded votes, and simulated ones by /// default — never an intention without proof. pub tallies: HashMap, /// What is recorded and shown: recorded votes and simulated ones, without the /// intentions (what a published text may say). pub recorded: HashMap, /// The totals shown: `recorded`, each intention in place of the vote its /// session sees as its own (`ld_engine::buffer::weigh_intent`). pub shown: HashMap, /// The part of `shown` still waiting for its proof. pub pending: HashMap, /// The part of `shown` that comes from the simulation. pub simulated: HashMap, /// What each stage of a vote weighs. pub rules: Rules, /// Versions held back by a standing objection: against or a withdrawal only, never /// a new *for* (`admits`), and never entering into force. pub blocked: HashSet, /// Standing objections touching each version, either side. pub signal_counts: HashMap, pub electorate: u32, pub electorate_source: &'static str, /// p̃, from the group's last measure (or the reference). pub participation: Participation, pub measured_at: Option, } pub async fn load_doc(state: &AppState, doc_id: &str) -> AppResult { sqlx::query_as::<_, DocRow>( "SELECT id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path, quorums FROM documents WHERE id = ?", ) .bind(doc_id) .fetch_optional(&state.db) .await? .ok_or(AppError::NotFound) } /// W: the Ğ1 web of trust for Monnaie libre, the members of the group otherwise. pub async fn electorate(state: &AppState, group: &GroupRow) -> AppResult<(u32, &'static str)> { if group.policy == GroupPolicy::G1Members.as_str() { let cached: Option = sqlx::query_scalar("SELECT value FROM settings WHERE key = 'wot_size'").fetch_optional(&state.db).await?; let w = cached.and_then(|v| v.parse().ok()).unwrap_or(state.cfg.wot_fallback); return Ok((w.max(1), "wot")); } let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM memberships WHERE group_slug = ?") .bind(&group.slug) .fetch_one(&state.db) .await?; Ok((u32::try_from(n).unwrap_or(u32::MAX).max(1), "group")) } pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult { let doc = load_doc(state, doc_id).await?; let group = load_group(state, &doc.group_slug).await?; let units = sqlx::query_as::<_, UnitRow>( "SELECT id, code, section, position, inertia, in_force_id, quorum FROM units WHERE doc_id = ? ORDER BY position, id", ) .bind(doc_id) .fetch_all(&state.db) .await?; let kinds: Vec<(String, String)> = sqlx::query_as("SELECT id, kind FROM units WHERE doc_id = ? AND kind <> 'article'") .bind(doc_id) .fetch_all(&state.db) .await?; let module_kinds: HashMap = kinds.into_iter().filter_map(|(id, kind)| Some((id, ModuleKind::parse(&kind)?))).collect(); // Same second (the variants of a seed): the order they were written in, not their // random identifiers. The engine ranks rivals on its own; this order is what people see. let proposals = sqlx::query_as::<_, ProposalRow>(&format!( "{PROPOSAL_SELECT} WHERE p.unit_id IN (SELECT id FROM units WHERE doc_id = ?) AND p.status IN ('open', 'in_force', 'draft') ORDER BY p.submitted_at, p.created_at, p.rowid" )) .bind(doc_id) .fetch_all(&state.db) .await?; #[derive(sqlx::FromRow)] struct TallyRow { proposal_id: String, choice: String, source: String, n: i64, } let rows = sqlx::query_as::<_, TallyRow>( "SELECT v.proposal_id, v.choice, v.source, COUNT(*) AS n FROM votes v JOIN proposals p ON p.id = v.proposal_id JOIN units u ON u.id = p.unit_id WHERE u.doc_id = ? AND v.revision = p.revision GROUP BY v.proposal_id, v.choice, v.source", ) .bind(doc_id) .fetch_all(&state.db) .await?; let rules = state.vote_rules(); let add = |map: &mut HashMap, id: &str, choice: &str, n: u32| { let t = map.entry(id.to_owned()).or_default(); match choice { "for" => t.for_votes = t.for_votes.saturating_add(n), "against" => t.against = t.against.saturating_add(n), _ => {} } }; let (mut tallies, mut recorded, mut simulated) = (HashMap::new(), HashMap::new(), HashMap::new()); for r in rows { let n = u32::try_from(r.n).unwrap_or(0); let stage = Stage::of_source(&r.source); if rules.adopts(stage) { add(&mut tallies, &r.proposal_id, &r.choice, n); } if rules.shown(stage) { add(&mut recorded, &r.proposal_id, &r.choice, n); if stage == Stage::Simulated { add(&mut simulated, &r.proposal_id, &r.choice, n); } } } // A standing objection holds back the latest arrival of its pair (never a version // in force): no new *for*, and it cannot enter into force (ADR 0010). let (blocked, signal_counts) = crate::objections::held(state, doc_id, &units).await?; // The intentions (ADR 0012): seen at once in the totals shown, in place of the // vote their session sees as its own; never counted for adoption. let mut shown = recorded.clone(); let mut pending: HashMap = HashMap::new(); let mut seen_by: HashMap> = HashMap::new(); for i in ballots::intents(state, doc_id).await? { let Some(p) = proposals.iter().find(|p| p.id == i.proposal_id) else { continue }; if !matches!(p.status.as_str(), "open" | "in_force") { continue; } if !seen_by.contains_key(&i.requester) { seen_by.insert(i.requester.clone(), ballots::my_votes(state, &i.requester, doc_id).await?); } let seen = seen_by .get(&i.requester) .and_then(|m| m.get(&p.id)) .filter(|v| v.revision == p.revision) .and_then(|v| Choice::parse(&v.choice)); let w = buffer::weigh_intent(seen, i.choice, i.revision == p.revision, blocked.contains(&p.id)); let t = shown.entry(p.id.clone()).or_default(); *t = w.shift.apply(*t); let q = pending.entry(p.id.clone()).or_default(); q.for_votes = q.for_votes.saturating_add(w.pending.for_votes); q.against = q.against.saturating_add(w.pending.against); } let (electorate, electorate_source) = electorate(state, &group).await?; let measure = participation::stored(state, &group.slug).await?; let participation = Participation::of( electorate, doc.vote_floor, measure.as_ref().and_then(|m| m.median), measure.as_ref().map_or(0, |m| m.sample), ); let measured_at = measure.map(|m| m.measured_at); Ok(DocState { doc, group, units, module_kinds, proposals, tallies, recorded, shown, pending, simulated, rules, blocked, signal_counts, electorate, electorate_source, participation, measured_at, }) } impl DocState { /// What this document's votes are judged by: W, p̃ and the quorum of each level. pub fn scale(&self) -> Scale { Scale { electorate: self.electorate, participation: self.participation.ratio, quorums: self.doc.quorums() } } /// The quorum an article is judged on, and its versions with it: its own, else its /// level's (`Scale::quorum`). pub fn quorum_of(&self, unit: &UnitRow) -> u32 { self.scale().quorum(inertia_of(unit), unit.own_quorum()) } /// The quorum of the article's level: what it is judged on without one of its own. pub fn quorum_default(&self, unit: &UnitRow) -> u32 { self.scale().quorum(inertia_of(unit), None) } /// The letters a proposal is judged by: its level and its article's quorum. pub fn params(&self, unit: &UnitRow, p: &ProposalRow) -> FormulaParams { compose::params_for(&bare(unit), p.kind(), &self.scale()) } /// p̃ · W, as a whole number of voters. pub fn usual_voters(&self) -> u32 { whole(self.participation.voters(self.electorate)) } pub fn tally(&self, id: &str) -> Tally { self.tallies.get(id).copied().unwrap_or_default() } /// The totals shown to people: the live count, intentions included (ADR /// 0012: « je vois ce que j'ai fait sans attendre »). 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). Adoption reads `tally`, never an intention. pub fn shown_tally(&self, id: &str) -> Tally { self.shown.get(id).copied().unwrap_or_default() } /// The part of `shown_tally` still waiting for its proof. pub fn pending_tally(&self, id: &str) -> Tally { self.pending.get(id).copied().unwrap_or_default() } /// The part of `shown_tally` that comes from the simulation. pub fn simulated_tally(&self, id: &str) -> Tally { self.simulated.get(id).copied().unwrap_or_default() } /// What is recorded and shown, without the intentions: what a published text says. pub fn recorded_tally(&self, id: &str) -> Tally { self.recorded.get(id).copied().unwrap_or_default() } /// The simulated share of what adoption counts for a version (zero when the /// simulation counts for display only). pub fn simulated_counted(&self, id: &str) -> Tally { if self.rules.adopts(Stage::Simulated) { self.simulated_tally(id) } else { Tally::default() } } /// How far the threshold is, as shown: on `shown_tally`. pub fn shown_evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option { threshold::evaluate(self.shown_tally(&p.id), self.electorate, &self.params(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) } /// May `choice` be cast on `p` now — by one vote, a grouped one or a ballot of the /// chain? Open or in force; held back by an objection, against or a withdrawal /// only, never a *for* (`ld_engine::objection::may_cast`, Yvv 2026-11-01). pub fn admits(&self, p: &ProposalRow, choice: Choice) -> bool { matches!(p.status.as_str(), "open" | "in_force") && ld_engine::objection::may_cast(self.blocked.contains(&p.id), choice) } /// The module a unit is, if it is one. pub fn module_of(&self, unit_id: &str) -> Option { self.module_kinds.get(unit_id).copied() } /// The articles, by position: every unit that is no module. pub fn articles(&self) -> impl Iterator { self.units.iter().filter(|u| !self.module_kinds.contains_key(&u.id)) } /// The modules of a kind; the orders in the order of the document's sections. pub fn modules(&self, kind: ModuleKind) -> Vec<&UnitRow> { let mut out: Vec<&UnitRow> = self.units.iter().filter(|u| self.module_of(&u.id) == Some(kind)).collect(); let sections = self.sections(); let rank = |u: &UnitRow| u.section.as_ref().and_then(|s| sections.iter().position(|x| x == s)); out.sort_by_key(|u| (rank(u).unwrap_or(usize::MAX), u.code.clone())); out } /// The sections of the document, in the order its articles read in. pub fn sections(&self) -> Vec { let mut out: Vec = Vec::new(); for u in self.articles() { if let Some(s) = &u.section && !out.contains(s) { out.push(s.clone()); } } out } /// The articles in the order of `section` now: those one can see — a text in /// force, or an open version — by position (`ld_engine::order::members`; /// `order::sections_now` says the same in SQL). pub fn members(&self, section: &str) -> Vec { let open: HashSet<&str> = self .proposals .iter() .filter(|p| p.status == "open" && matches!(p.kind.as_str(), "text" | "remove")) .map(|p| p.unit_id.as_str()) .collect(); let articles: Vec> = self .articles() .map(|u| ld_engine::order::Article { id: &u.id, section: u.section.as_deref(), position: u.position, visible: u.in_force_id.is_some() || open.contains(u.id.as_str()), }) .collect(); ld_engine::order::members(&articles, section) } /// The order each order module's section reads in now, by unit id. fn live_orders(&self) -> HashMap> { self.modules(ModuleKind::Order) .into_iter() .map(|u| (u.id.clone(), self.members(u.section.as_deref().unwrap_or_default()))) .collect() } /// The units as the engine sees them. The version in force of an order is /// the order its section reads in now: the engine compares orders as texts. pub fn unit_states(&self) -> Vec { let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect(); let live = self.live_orders(); self.units .iter() .map(|u| UnitState { id: u.id.clone(), position: u.position, inertia: inertia_of(u), quorum: u.own_quorum(), in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| match live.get(&u.id) { Some(order) => VersionText { id: p.id.clone(), title: String::new(), body: ld_engine::order::canonical(order), tally: self.tally(&p.id), }, None => VersionText { id: p.id.clone(), title: p.title.clone(), body: p.body.clone(), tally: self.tally(&p.id), }, }), }) .collect() } /// Open proposals only — the in-force versions compete as incumbents. An /// order proposal is read as it fits its section now (completed at the end); /// one that holds no order is blocked. pub fn proposal_states(&self) -> Vec { let live = self.live_orders(); self.proposals .iter() .filter(|p| p.status == "open") .map(|p| { let mut s = ProposalState { id: p.id.clone(), unit_id: p.unit_id.clone(), kind: p.kind(), title: p.title.clone(), body: p.body.clone(), tally: self.tally(&p.id), submitted_at: p.submitted_at.clone().unwrap_or_else(|| p.created_at.clone()), blocked: self.blocked.contains(&p.id), }; if let Some(members) = live.get(&p.unit_id).filter(|_| p.kind == ModuleKind::Order.as_str()) { s.title = String::new(); match ld_engine::order::decode(&p.body) { Some(order) => { s.body = ld_engine::order::canonical(&ld_engine::order::fit(&order, members).order); } None => s.blocked = true, } } s }) .collect() } pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option { threshold::evaluate(self.tally(&p.id), self.electorate, &self.params(unit, p)).ok() } /// The level a proposal is voted at — a change of inertia inherits its article's. pub fn voting_level(&self, unit: &UnitRow, p: &ProposalRow) -> InertiaLevel { compose::voting_level(&bare(unit), p.kind()) } /// The usual participation, as the screen tells it. pub fn participation_json(&self) -> serde_json::Value { let p = &self.participation; json!({ "ratio": p.ratio, "source": p.source, "sample": p.sample, "median": p.median, "bounded": p.bounded, "voters": self.usual_voters(), "minSample": Participation::MIN_SAMPLE, "windowDays": participation::WINDOW_DAYS, "everyHours": participation::EVERY_HOURS, "bounds": [Participation::LOWEST, Participation::HIGHEST], "measuredAt": self.measured_at, }) } /// The five levels on this scale: M, G, s_k, the quorum, and the *for* votes asked at /// `cast` votes — on an article's own quorum if it has one (`own`), else each level's. pub fn levels_json(&self, cast: u32, own: Option) -> Vec { let scale = self.scale(); InertiaLevel::ALL .iter() .map(|k| { let quorum = scale.quorum(*k, own); let params = scale.params(*k, quorum); json!({ "level": k, "majorityPct": params.majority_pct, "gradient": params.gradient, "sharePct": k.share_pct(), "quorum": quorum, "cast": cast, "required": scale.required(cast, *k, quorum).ok(), }) }) .collect() } } /// A unit as the engine sees it, without its text: its level and its own quorum. fn bare(unit: &UnitRow) -> UnitState { UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), quorum: unit.own_quorum(), in_force: None, } } /// A non-negative count, rounded to the nearest whole. fn whole(x: f64) -> u32 { if x.is_finite() && x > 0.0 { x.round().min(f64::from(u32::MAX)) as u32 } else { 0 } } // --------------------------------------------------------------------------- // Document view // --------------------------------------------------------------------------- #[derive(sqlx::FromRow)] struct TranslationRow { proposal_id: String, lang: String, title: String, body: String, source_hash: String, origin: String, } pub async fn translations_for( state: &AppState, ids: &[&ProposalRow], ) -> AppResult>> { let mut out: HashMap> = HashMap::new(); if ids.is_empty() { return Ok(out); } let marks = vec!["?"; ids.len()].join(","); let sql = format!( "SELECT proposal_id, lang, title, body, source_hash, origin FROM translations WHERE proposal_id IN ({marks})" ); let mut q = sqlx::query_as::<_, TranslationRow>(&sql); for p in ids { q = q.bind(&p.id); } let rows = q.fetch_all(&state.db).await?; let current: HashMap<&str, String> = ids.iter().map(|p| (p.id.as_str(), util::source_hash(&p.title, &p.body))).collect(); for r in rows { let stale = current.get(r.proposal_id.as_str()).is_none_or(|h| *h != r.source_hash); out.entry(r.proposal_id) .or_default() .insert(r.lang, json!({ "title": r.title, "body": r.body, "stale": stale, "origin": r.origin })); } Ok(out) } fn proposal_json( st: &DocState, unit: &UnitRow, p: &ProposalRow, mine: (Option<&MyVote>, Option<&MyBallot>), translations: Option<&BTreeMap>, me: &str, ) -> serde_json::Value { let (mine, ballot) = mine; let seen = ballots::seen(p.revision, mine, ballot); 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), ProposalKind::Text => ("text", None), ProposalKind::Remove => ("remove", None), ProposalKind::Inertia(t) => ("inertia", Some(t)), }; let voted_at = st.voting_level(unit, p); json!({ "id": p.id, "unitId": p.unit_id, "kind": kind, "inertiaTarget": target, "title": p.title, "body": p.body, "status": p.status, "origin": p.origin, "label": p.label, "author": p.author_id.as_ref().map(|id| json!({ "id": id, "name": p.author_name, "invited": p.author_invited.unwrap_or(false), })), "mine": p.author_id.as_deref() == Some(me), "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.seed_minimal(), "seedProposal": p.seed_proposal(), "tally": st.shown_tally(&p.id), "tallyPending": st.pending_tally(&p.id), "tallySimulated": st.simulated_tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at, "blocked": st.blocked.contains(&p.id), "votable": st.votable(p), "objections": st.signal_counts.get(&p.id).copied().unwrap_or(0), "myVote": seen.choice, "myVoteState": seen.state, "myVoteStale": seen.stale, "myVoteChain": seen.chain, "myBallot": ballot, "translations": translations.cloned().unwrap_or_default(), }) } #[derive(Deserialize)] pub struct DocQuery {} pub async fn document( State(state): State, User(user): User, Path(id): Path, ) -> AppResult> { let st = load_state(&state, &id).await?; require_open(&state, &user, &st.group).await?; let visible: Vec<&ProposalRow> = st.proposals.iter().filter(|p| p.status != "draft" || p.author_id.as_deref() == Some(&user.id)).collect(); let translations = translations_for(&state, &visible).await?; let mine = ballots::my_votes(&state, &user.id, &id).await?; let pending = ballots::my_ballots(&state, &user.id, &id).await?; let changes = inertia_changes(&state, &id).await?; let rights = crate::quorum::Rights::of(&state, &id).await?; let unit_json = |u: &UnitRow| { let of_unit: Vec<_> = visible .iter() .filter(|p| p.unit_id == u.id) .map(|p| { let of = (mine.get(&p.id), pending.get(&p.id)); let mut j = proposal_json(&st, u, p, of, translations.get(&p.id), &user.id); if st.module_of(&u.id) == Some(ModuleKind::Order) { j["order"] = crate::order::version_json(&st, u, p); } j }) .collect(); let in_force = u .in_force_id .as_ref() .and_then(|fid| of_unit.iter().find(|p| p["id"].as_str() == Some(fid.as_str())).cloned()); let proposals: Vec<_> = of_unit.into_iter().filter(|p| p["status"] != "in_force").collect(); let mut j = json!({ "id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": inertia_of(u), "inertiaChanges": changes.get(&u.id).cloned().unwrap_or_default(), "inForce": in_force, "proposals": proposals, }); // Its quorum (ADR 0013): its own or `null`, its level's, and who may set it. if let (Some(o), serde_json::Value::Object(q)) = (j.as_object_mut(), crate::quorum::unit_json(&st, &rights, &user, u)) { o.extend(q); } j }; let units: Vec<_> = st.articles().map(unit_json).collect(); // The modules (ADR 0009): the title of the document, the order of each section. let title = st.modules(ModuleKind::Title).first().map(|u| { let mut j = unit_json(u); j["kind"] = json!("title"); j }); let orders: Vec<_> = st .modules(ModuleKind::Order) .into_iter() .map(|u| { let members = st.members(u.section.as_deref().unwrap_or_default()); let mut j = unit_json(u); j["kind"] = json!("order"); j["moot"] = json!(ld_engine::order::is_moot(&members)); j["members"] = json!(members); j }) .collect(); let publication = publication_status(&state, &id).await?; let vote = ballots::access(&state, &user, &st.group).await?; Ok(Json(json!({ "canVote": vote["open"], "vote": vote, "doc": { "id": st.doc.id, "slug": st.doc.slug, "groupSlug": st.group.slug, "groupName": st.group.name, "title": util::parse_lang(&st.doc.title), "summary": util::parse_lang(&st.doc.summary), "sourceLang": st.doc.source_lang, "voteFloor": st.doc.vote_floor, "quorums": st.doc.quorums(), "electorate": st.electorate, "electorateSource": st.electorate_source, "formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE }, "participation": st.participation_json(), "publication": publication, }, "units": units, "modules": { "title": title, "orders": orders }, // The five levels, with the *for* votes asked at the usual participation. "levels": st.levels_json(st.usual_voters(), None), }))) } /// The past of each article's inertia (unit id → changes, oldest first). async fn inertia_changes(state: &AppState, doc_id: &str) -> AppResult>> { #[derive(sqlx::FromRow)] struct Row { unit_id: String, from_level: String, to_level: String, cause: String, at: String, } let rows = sqlx::query_as::<_, Row>( "SELECT unit_id, from_level, to_level, cause, at FROM inertia_changes WHERE doc_id = ? ORDER BY id", ) .bind(doc_id) .fetch_all(&state.db) .await?; let mut out: HashMap> = HashMap::new(); for r in rows { out.entry(r.unit_id).or_default().push(json!({ "from": InertiaLevel::parse(&r.from_level), "to": InertiaLevel::parse(&r.to_level), "cause": r.cause, "at": r.at, })); } Ok(out) } pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult { #[derive(sqlx::FromRow)] struct Row { created_at: String, done_at: Option, commit_sha: Option, last_error: Option, attempts: i64, } let last = sqlx::query_as::<_, Row>( "SELECT created_at, done_at, commit_sha, last_error, attempts FROM outbox WHERE doc_id = ? ORDER BY id DESC LIMIT 1", ) .bind(doc_id) .fetch_optional(&state.db) .await?; // Waiting, not failing: no article in force (ADR 0011), or something in effect that // does not hold on the proven votes yet (ADR 0012) — nothing goes out. let waits = match &last { Some(r) if r.done_at.is_none() => crate::publish::waits(state, doc_id).await?, _ => None, }; Ok(match last { None => json!({ "state": "none", "configured": state.cfg.publish.is_some() }), Some(r) => json!({ "state": if r.done_at.is_some() { "done" } else if waits.is_some() { "suspended" } else { "pending" }, "waitsFor": waits.map(crate::publish::Wait::as_str), "configured": state.cfg.publish.is_some(), "since": r.created_at, "doneAt": r.done_at, "commit": r.commit_sha, "attempts": r.attempts, "error": r.last_error.map(|_| "retrying"), }), }) } #[derive(Deserialize)] pub struct PreviewQuery { mode: Option, } pub async fn preview( State(state): State, User(user): User, Path(id): Path, Query(q): Query, ) -> AppResult> { let st = load_state(&state, &id).await?; require_open(&state, &user, &st.group).await?; let mode = match q.mode.as_deref() { Some("all") => PreviewMode::All, Some("wellrated") => PreviewMode::WellRated, _ => PreviewMode::InForce, }; // The articles only: a module changes no text of the preview. let articles: Vec = st.unit_states().into_iter().filter(|u| st.module_of(&u.id).is_none()).collect(); let units = compose::preview(&articles, &st.proposal_states(), &st.scale(), mode) .map_err(|_| AppError::Invalid("electorate"))?; // Translations of the versions shown. let shown: HashSet = units .iter() .map(|u| { u.proposal_id.clone().unwrap_or_else(|| { st.units.iter().find(|x| x.id == u.unit_id).and_then(|x| x.in_force_id.clone()).unwrap_or_default() }) }) .collect(); let rows: Vec<&ProposalRow> = st.proposals.iter().filter(|p| shown.contains(&p.id)).collect(); let tr = translations_for(&state, &rows).await?; let out: Vec<_> = units .into_iter() .map(|u| { let vid = u .proposal_id .clone() .or_else(|| st.units.iter().find(|x| x.id == u.unit_id).and_then(|x| x.in_force_id.clone())); let translations = vid.as_ref().and_then(|v| tr.get(v)).cloned().unwrap_or_default(); json!({ "unitId": u.unit_id, "title": u.title, "body": u.body, "mark": u.mark, "proposalId": u.proposal_id, "versionId": vid, "translations": translations, }) }) .collect(); Ok(Json(json!({ "mode": q.mode.unwrap_or_else(|| "inforce".into()), "units": out }))) } /// Unused for now; keeps the language list close to the documents. pub fn source_langs() -> LangMap { util::LANGS.iter().map(|l| ((*l).to_owned(), String::new())).collect() }