//! A group at a glance: what is coming in each tool, and what is left to vote. //! //! What is coming is data, not code: `seeds/upcoming/.json`, one file //! per group, read once at start (titles in fr, en and es). It is served with //! the group (`GET /api/groups/{slug}`, field `upcoming`), only to those who //! open it. What is left to vote (`GET /api/groups/{slug}/decisions`) is one //! line per document, counted by `ld_engine::pending`. use crate::AppState; use crate::auth::User; use crate::docs; use crate::error::AppResult; use crate::util::{self, LangMap}; use anyhow::{Context, bail}; use axum::Json; use axum::extract::{Path, State}; use ld_engine::pending::{Module, pending}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::collections::{BTreeMap, HashSet}; /// Something announced in a tool of a group, not open yet. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct UpcomingItem { pub key: String, pub title: LangMap, } /// What is coming in the tools of one group. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct GroupUpcoming { #[serde(default)] pub docs: Vec, #[serde(default)] pub mandats: Vec, #[serde(default)] pub protocoles: Vec, } impl GroupUpcoming { fn check(&self) -> anyhow::Result<()> { for (tool, items) in [("docs", &self.docs), ("mandats", &self.mandats), ("protocoles", &self.protocoles)] { let mut keys = HashSet::new(); for item in items { if item.key.trim().is_empty() || !keys.insert(item.key.as_str()) { bail!("{tool}: empty or repeated key « {} »", item.key); } for lang in util::LANGS { if item.title.get(lang).is_none_or(|t| t.trim().is_empty()) { bail!("{tool}/{}: no title in {lang}", item.key); } } } } Ok(()) } } /// Every group's announcements, by group slug. #[derive(Clone, Debug, Default)] pub struct Upcoming(BTreeMap); impl Upcoming { /// Reads `/upcoming/*.json`; no directory, nothing coming. A file /// that does not read, or lacks a language, stops the start. pub async fn load(seed_dir: &std::path::Path) -> anyhow::Result { let dir = seed_dir.join("upcoming"); let mut entries = match tokio::fs::read_dir(&dir).await { Ok(e) => e, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()), Err(e) => return Err(e).context(dir.display().to_string()), }; let mut out = BTreeMap::new(); while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if path.extension().is_none_or(|x| x != "json") { continue; } let Some(group) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue }; let raw = tokio::fs::read_to_string(&path).await?; let parsed: GroupUpcoming = serde_json::from_str(&raw).with_context(|| path.display().to_string())?; parsed.check().with_context(|| path.display().to_string())?; out.insert(group, parsed); } Ok(Self(out)) } pub fn of(&self, group: &str) -> GroupUpcoming { self.0.get(group).cloned().unwrap_or_default() } } /// The group, its tools and documents (`docs::group`), plus what is coming. pub async fn group(State(state): State, user: User, Path(slug): Path) -> AppResult> { let Json(mut view) = docs::group(State(state.clone()), user, Path(slug.clone())).await?; let open = view["group"]["open"].as_bool().unwrap_or(false); let upcoming = if open { state.upcoming.of(&slug) } else { GroupUpcoming::default() }; view["upcoming"] = serde_json::to_value(upcoming).unwrap_or(Value::Null); Ok(Json(view)) } /// « Décisions »: one line per document in progress, with its modules left to vote. pub async fn decisions( State(state): State, User(user): User, Path(slug): Path, ) -> AppResult> { let group = docs::load_group(&state, &slug).await?; docs::require_open(&state, &user, &group).await?; let can_vote = crate::ballots::access(&state, &user, &group).await?["open"].as_bool().unwrap_or(false); #[derive(sqlx::FromRow)] struct DocRow { id: String, slug: String, title: String, } let rows = sqlx::query_as::<_, DocRow>("SELECT id, slug, title FROM documents WHERE group_slug = ? ORDER BY created_at") .bind(&slug) .fetch_all(&state.db) .await?; let mut out = Vec::with_capacity(rows.len()); for d in rows { let p = pending(&modules(&state, &d.id, &user.id).await?); out.push(json!({ "id": d.id, "slug": d.slug, "title": util::parse_lang(&d.title), "toVote": p.to_vote, "withoutMyVote": can_vote.then_some(p.without_my_vote), })); } Ok(Json(json!({ "canVote": can_vote, "docs": out }))) } /// The modules of a document as `me` sees them — its articles, its title, the /// order of each section (ADR 0009): the version in force (adopted by a vote, /// or a seed a minima), the open versions, and whether `me` holds a valid vote /// on one of them. A change of inertia is not a version of the text; the order /// of a section of fewer than two articles has nothing to decide; a version held /// by an objection is nobody's to vote while it is held (ADR 0010). async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult> { #[derive(sqlx::FromRow)] struct VersionRow { id: String, unit_id: String, in_force: bool, adopted: bool, voted: bool, } let st = docs::load_state(state, doc_id).await?; let moot: HashSet = st .modules(crate::modules::ModuleKind::Order) .into_iter() .filter(|u| ld_engine::order::is_moot(&st.members(u.section.as_deref().unwrap_or_default()))) .map(|u| u.id.clone()) .collect(); let rows = sqlx::query_as::<_, VersionRow>( "SELECT p.id AS id, u.id AS unit_id, IFNULL(p.id = u.in_force_id, 0) AS in_force, (p.entered_at IS NOT NULL OR p.confirmed_at IS NOT NULL OR p.origin <> 'seed') AS adopted, EXISTS (SELECT 1 FROM votes v WHERE v.proposal_id = p.id AND v.revision = p.revision AND (v.identity_id = ?1 OR v.ballot_id IN (SELECT b.id FROM ballots b WHERE b.requested_by = ?1 AND b.status = 'confirmed'))) AS voted FROM units u JOIN proposals p ON p.unit_id = u.id WHERE u.doc_id = ?2 AND p.kind <> 'inertia' AND (p.id = u.in_force_id OR p.status = 'open') ORDER BY u.position, u.id", ) .bind(me) .bind(doc_id) .fetch_all(&state.db) .await?; let mut out: Vec = Vec::new(); let mut current: Option = None; for r in rows { if current.as_deref() != Some(r.unit_id.as_str()) { out.push(Module { moot: moot.contains(&r.unit_id), ..Module::default() }); current = Some(r.unit_id); } if let Some(m) = out.last_mut() { if r.in_force { m.in_force = Some(r.adopted); } else if st.proposals.iter().any(|p| p.id == r.id && st.votable(p)) { m.open += 1; } m.voted |= r.voted; } } Ok(out) } #[cfg(test)] mod tests { use super::*; use crate::Config; async fn load(files: &[(&str, &str)]) -> anyhow::Result { let dir = std::env::temp_dir().join(format!("ld-upcoming-{}", util::id())); tokio::fs::create_dir_all(dir.join("upcoming")).await?; for (name, body) in files { tokio::fs::write(dir.join("upcoming").join(name), body).await?; } let out = Upcoming::load(&dir).await; let _ = tokio::fs::remove_dir_all(&dir).await; out } const ITEM: &str = r#"{"key": "k", "title": {"fr": "Un", "en": "One", "es": "Uno"}}"#; #[tokio::test] async fn one_file_per_group_and_nothing_elsewhere() { let up = load(&[("axiom.json", &format!(r#"{{"mandats": [{ITEM}]}}"#)), ("notes.txt", "x")]).await.expect("load"); assert_eq!(up.of("axiom").mandats.len(), 1); assert!(up.of("axiom").docs.is_empty()); assert!(up.of("librodrome").mandats.is_empty()); let none = Upcoming::load(&std::env::temp_dir().join("ld-no-such-dir")).await.expect("load"); assert!(none.of("axiom").docs.is_empty()); } #[tokio::test] async fn a_missing_language_an_unknown_tool_or_a_repeated_key_stops_the_start() { let no_es = r#"{"docs": [{"key": "k", "title": {"fr": "Un", "en": "One"}}]}"#; assert!(load(&[("axiom.json", no_es)]).await.is_err()); assert!(load(&[("axiom.json", &format!(r#"{{"outils": [{ITEM}]}}"#))]).await.is_err()); assert!(load(&[("axiom.json", &format!(r#"{{"docs": [{ITEM}, {ITEM}]}}"#))]).await.is_err()); } #[tokio::test] async fn the_seeds_of_the_repository_read() { let up = Upcoming::load(&Config::for_tests().seed_dir).await.expect("load"); assert_eq!(up.of("monnaie-libre").mandats.len(), 2); } }