forked from yvv/decision
v3 : socle Rust (moteur pur, accès trustinWallet, serveur) ; maquette Nuxt rangée dans maquette/
- crates/ld-engine : formule inertielle (parité avec la maquette, garde C), coquille / changement de fond, assistant d'édition à règles, composition d'un document sous vote permanent (adoptions, aperçus, rivales). - crates/trustin-access : qui voit, qui ouvre, qui gère les accès. - crates/ld-server : API axum + SQLite, connexion Ğ1 (client OAuth2 de connect.monnaie-libre.fr), invitations, entrée en vigueur automatique, signalements de contradiction, traductions éditoriales, publication git, taille de la toile lue sur la chaîne. - seeds/acte-engagement-certification.json : 33 articles, fr + en + es ; en vigueur : préambule, E1, E2 (seed a minima). - maquette/ : frontend, backend et docker v2 déplacés tels quels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,618 @@
|
||||
//! 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<String>,
|
||||
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<bool> {
|
||||
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<GroupRow> {
|
||||
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(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GroupView {
|
||||
slug: String,
|
||||
name: String,
|
||||
parent: Option<String>,
|
||||
policy: String,
|
||||
open: bool,
|
||||
}
|
||||
|
||||
pub async fn groups(State(state): State<AppState>, User(user): User) -> AppResult<Json<serde_json::Value>> {
|
||||
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<AppState>,
|
||||
User(user): User,
|
||||
Path(slug): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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::<Vec<_>>()
|
||||
} 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||
pub struct UnitRow {
|
||||
pub id: String,
|
||||
pub code: String,
|
||||
pub position: f64,
|
||||
pub inertia: String,
|
||||
pub in_force_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, sqlx::FromRow)]
|
||||
pub struct ProposalRow {
|
||||
pub id: String,
|
||||
pub unit_id: String,
|
||||
pub kind: String,
|
||||
pub inertia_target: Option<String>,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub status: String,
|
||||
pub origin: String,
|
||||
pub label: Option<String>,
|
||||
pub author_id: Option<String>,
|
||||
pub author_name: Option<String>,
|
||||
pub revision: i64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub submitted_at: Option<String>,
|
||||
pub entered_at: Option<String>,
|
||||
pub confirmed_at: Option<String>,
|
||||
}
|
||||
|
||||
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<UnitRow>,
|
||||
pub proposals: Vec<ProposalRow>,
|
||||
pub tallies: HashMap<String, Tally>,
|
||||
pub blocked: HashSet<String>,
|
||||
pub signal_counts: HashMap<String, i64>,
|
||||
pub electorate: u32,
|
||||
pub electorate_source: &'static str,
|
||||
}
|
||||
|
||||
pub async fn load_doc(state: &AppState, doc_id: &str) -> AppResult<DocRow> {
|
||||
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<String> =
|
||||
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<DocState> {
|
||||
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, 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<String, Tally> = 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<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());
|
||||
}
|
||||
}
|
||||
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<UnitState> {
|
||||
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<ProposalState> {
|
||||
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<Evaluation> {
|
||||
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<HashMap<String, BTreeMap<String, serde_json::Value>>> {
|
||||
let mut out: HashMap<String, BTreeMap<String, serde_json::Value>> = 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<String, serde_json::Value>>,
|
||||
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<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<String, MyVote> = 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, "position": u.position, "inertia": u.inertia,
|
||||
"inForce": in_force, "proposals": proposals,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let publication = publication_status(&state, &id).await?;
|
||||
Ok(Json(json!({
|
||||
"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::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn publication_status(state: &AppState, doc_id: &str) -> AppResult<serde_json::Value> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
created_at: String,
|
||||
done_at: Option<String>,
|
||||
commit_sha: Option<String>,
|
||||
last_error: Option<String>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
pub async fn preview(
|
||||
State(state): State<AppState>,
|
||||
User(user): User,
|
||||
Path(id): Path<String>,
|
||||
Query(q): Query<PreviewQuery>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<String> = 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()
|
||||
}
|
||||
Reference in New Issue
Block a user