//! 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::error::{AppError, AppResult}; use crate::util::{self, LangMap}; use axum::Json; use axum::extract::{Path, Query, State}; use ld_engine::compose::{self, PreviewMode, ProposalKind, ProposalState, UnitState, VersionText}; use ld_engine::threshold::{Evaluation, InertiaPreset, Tally, evaluate}; 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(()) } /// 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.in_force_id IS NOT NULL) AS in_force, (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, pub vote_floor: f64, pub publish_path: Option, } #[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, } #[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, pub revision: i64, pub created_at: String, pub updated_at: String, pub submitted_at: Option, pub entered_at: Option, pub confirmed_at: Option, } 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, p.revision, p.created_at, p.updated_at, p.submitted_at, p.entered_at, p.confirmed_at FROM proposals p LEFT JOIN identities i ON i.id = p.author_id"; impl ProposalRow { pub fn kind(&self) -> ProposalKind { match self.kind.as_str() { "remove" => ProposalKind::Remove, "inertia" => ProposalKind::Inertia( self.inertia_target.as_deref().and_then(InertiaPreset::parse).unwrap_or(InertiaPreset::Standard), ), _ => ProposalKind::Text, } } } pub fn inertia_of(unit: &UnitRow) -> InertiaPreset { InertiaPreset::parse(&unit.inertia).unwrap_or(InertiaPreset::Standard) } pub struct DocState { pub doc: DocRow, pub group: GroupRow, pub units: Vec, pub proposals: Vec, pub tallies: HashMap, pub blocked: HashSet, pub signal_counts: HashMap, pub electorate: u32, pub electorate_source: &'static str, } 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 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 FROM units WHERE doc_id = ? ORDER BY position, id", ) .bind(doc_id) .fetch_all(&state.db) .await?; 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.id" )) .bind(doc_id) .fetch_all(&state.db) .await?; #[derive(sqlx::FromRow)] struct TallyRow { proposal_id: String, choice: String, n: i64, } let rows = sqlx::query_as::<_, TallyRow>( "SELECT v.proposal_id, v.choice, 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", ) .bind(doc_id) .fetch_all(&state.db) .await?; let mut tallies: HashMap = HashMap::new(); for r in rows { let t = tallies.entry(r.proposal_id).or_default(); let n = u32::try_from(r.n).unwrap_or(0); match r.choice.as_str() { "for" => t.for_votes = n, "against" => t.against = n, _ => {} } } #[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 = 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()); } } let (electorate, electorate_source) = electorate(state, &group).await?; Ok(DocState { doc, group, units, proposals, tallies, blocked, signal_counts, electorate, electorate_source }) } impl DocState { pub fn tally(&self, id: &str) -> Tally { self.tallies.get(id).copied().unwrap_or_default() } pub fn unit_states(&self) -> Vec { let by_id: HashMap<&str, &ProposalRow> = self.proposals.iter().map(|p| (p.id.as_str(), p)).collect(); self.units .iter() .map(|u| UnitState { id: u.id.clone(), position: u.position, inertia: inertia_of(u), in_force: u.in_force_id.as_deref().and_then(|id| by_id.get(id)).map(|p| 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. pub fn proposal_states(&self) -> Vec { self.proposals .iter() .filter(|p| p.status == "open") .map(|p| 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), }) .collect() } pub fn evaluation(&self, unit: &UnitRow, p: &ProposalRow) -> Option { let us = UnitState { id: unit.id.clone(), position: unit.position, inertia: inertia_of(unit), in_force: None }; evaluate(self.tally(&p.id), self.electorate, &compose::params_for(&us, p.kind(), self.doc.vote_floor)).ok() } } // --------------------------------------------------------------------------- // 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) } #[derive(sqlx::FromRow)] struct MyVote { proposal_id: String, choice: String, revision: i64, } fn proposal_json( st: &DocState, unit: &UnitRow, p: &ProposalRow, mine: Option<&MyVote>, translations: Option<&BTreeMap>, me: &str, ) -> serde_json::Value { let eval = st.evaluation(unit, p); let (kind, target) = match p.kind() { ProposalKind::Text => ("text", None), ProposalKind::Remove => ("remove", None), ProposalKind::Inertia(t) => ("inertia", Some(t.as_str())), }; let voted_at = match p.kind() { ProposalKind::Inertia(_) => inertia_of(unit).raised(), _ => inertia_of(unit), }; 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 })), "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.status == "in_force" && p.entered_at.is_none() && p.confirmed_at.is_none() && p.origin == "seed", "tally": st.tally(&p.id), "evaluation": eval, "votedAtInertia": voted_at.as_str(), "blocked": st.blocked.contains(&p.id), "signals": 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), "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: HashMap = sqlx::query_as::<_, MyVote>( "SELECT v.proposal_id, v.choice, v.revision 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.identity_id = ?", ) .bind(&id) .bind(&user.id) .fetch_all(&state.db) .await? .into_iter() .map(|v| (v.proposal_id.clone(), v)) .collect(); let units: Vec<_> = st .units .iter() .map(|u| { let of_unit: Vec<_> = visible .iter() .filter(|p| p.unit_id == u.id) .map(|p| proposal_json(&st, u, p, mine.get(&p.id), translations.get(&p.id), &user.id)) .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(); json!({ "id": u.id, "code": u.code, "section": u.section, "position": u.position, "inertia": u.inertia, "inForce": in_force, "proposals": proposals, }) }) .collect(); let publication = publication_status(&state, &id).await?; let can_vote = may_vote(&state, &user, &st.group).await?; Ok(Json(json!({ "canVote": can_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, "electorate": st.electorate, "electorateSource": st.electorate_source, "formula": { "base": ld_engine::FormulaParams::DEFAULT_BASE }, "publication": publication, }, "units": units, "inertia": InertiaPreset::ALL.iter().map(|p| json!({ "key": p.as_str(), "majorityPct": p.majority_pct(), "gradient": p.gradient(), })).collect::>(), }))) } 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?; 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 { "pending" }, "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, }; let units = compose::preview(&st.unit_states(), &st.proposal_states(), st.electorate, st.doc.vote_floor, 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() }