forked from yvv/decision
- Dans Monnaie libre (électorat = toile de confiance), seuls les membres de la toile votent (trustin_access::votes_in) ; une identité d'invitation y lit, propose et signale. L'interface le dit à la place des boutons (fr/en/es). - Un lien d'invitation ne vise jamais une identité Ğ1 ; le lien est réservé avant de créer l'identité (deux acceptations simultanées : une seule passe). - Un nombre n'est jamais une coquille (« 50 » → « 5,0 », « 1 5 » → « 15 »). - Redirection après connexion : caractères de contrôle refusés. - En-têtes : X-Frame-Options DENY, frame-ancestors 'none', nosniff, Referrer-Policy. - Déploiement : le produit passe en premier ; la maquette attend son succès. Les conteneurs, même arrêtés, qui revendiquent encore la racine sortent de « restart: always » (ils reviendraient au redémarrage de Docker). - Tests : 57 Rust (dont 14 scénarios d'API), e2e 12 / 12 avec C11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
628 lines
22 KiB
Rust
628 lines
22 KiB
Rust
//! 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(())
|
|
}
|
|
|
|
/// 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?;
|
|
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<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 section: Option<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, 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<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, "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::<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()
|
|
}
|