//! The order of the articles of a section, a module (ADR 0009) — the rules are //! the engine's (`ld_engine::order`); here, the database and the door to //! propose one. //! //! - The version in force is the order the section reads in now: its articles //! one can see, by position. The text the engine compares for it is that live //! order, so it always reads what the reader shows. //! - A proposal is another order of the same articles, each once. While it is //! under vote, articles may enter the section or leave it: it is completed at //! the end (`ld_engine::order::fit`), and its votes stay. //! - Entering into force gives the section's places out again in the new order; //! the articles keep their texts, their votes, their sections. use crate::AppState; use crate::auth::User; use crate::docs::{self, DocState, ProposalRow, UnitRow}; use crate::error::{AppError, AppResult}; use crate::modules::{self, ModuleKind, NewVersion}; use crate::util; use axum::Json; use axum::extract::{Path, State}; use ld_engine::order::{self, Article, OrderError}; use serde::Deserialize; use serde_json::{Value, json}; use sqlx::SqliteConnection; /// More articles than any section will hold: a longer list is refused unread. const MAX_ARTICLES: usize = 500; /// Every section of a document, in the order of the document, with the articles /// of its order now (read inside a transaction: the seed writes them in it). pub async fn sections_now(conn: &mut SqliteConnection, doc_id: &str) -> sqlx::Result)>> { #[derive(sqlx::FromRow)] struct Row { id: String, section: Option, position: f64, visible: bool, } // Seen: a text in force, or an open version (`DocState::members` says the same). It reads // only the columns of before 0012: a document born now, or given its modules by an // amendment, holds no retired unit (ADR 0014); `article_amend` keeps the live ones. let rows = sqlx::query_as::<_, Row>( "SELECT u.id, u.section, u.position, (u.in_force_id IS NOT NULL OR EXISTS (SELECT 1 FROM proposals p WHERE p.unit_id = u.id AND p.status = 'open' AND p.kind IN ('text', 'remove'))) AS visible FROM units u WHERE u.doc_id = ? AND u.kind = 'article' ORDER BY u.position, u.id", ) .bind(doc_id) .fetch_all(&mut *conn) .await?; let articles: Vec> = rows .iter() .map(|r| Article { id: &r.id, section: r.section.as_deref(), position: r.position, visible: r.visible }) .collect(); let mut sections: Vec = Vec::new(); for r in &rows { if let Some(s) = &r.section && !sections.contains(s) { sections.push(s.clone()); } } Ok(sections .into_iter() .map(|s| { let members = order::members(&articles, &s); (s, members) }) .collect()) } /// The order an order version gives its section now, as the screen tells it. pub fn version_json(st: &DocState, unit: &UnitRow, p: &ProposalRow) -> Value { let members = st.members(unit.section.as_deref().unwrap_or_default()); if p.status == "in_force" { return json!({ "ids": members, "added": [], "dropped": [], "same": true }); } match order::decode(&p.body) { Some(proposed) => { let f = order::fit(&proposed, &members); let same = f.order == members; json!({ "ids": f.order, "added": f.added, "dropped": f.dropped, "same": same }) } None => json!({ "ids": [], "added": [], "dropped": [], "same": false, "unreadable": true }), } } /// The order enters into force: its articles take the section's places in its order. pub async fn enter(conn: &mut SqliteConnection, st: &DocState, unit: &UnitRow, p: &ProposalRow) -> AppResult<()> { let members = st.members(unit.section.as_deref().unwrap_or_default()); // An unreadable order is blocked: it never comes this far. let Some(proposed) = order::decode(&p.body) else { return Ok(()) }; let fitted = order::fit(&proposed, &members); let current: Vec<(String, f64)> = members .iter() .filter_map(|id| st.units.iter().find(|u| &u.id == id).map(|u| (id.clone(), u.position))) .collect(); for (id, position) in order::positions(&fitted.order, ¤t) { sqlx::query("UPDATE units SET position = ? WHERE id = ? AND doc_id = ? AND kind = 'article'") .bind(position) .bind(&id) .bind(&st.doc.id) .execute(&mut *conn) .await?; } tracing::info!(doc = %st.doc.id, unit = %unit.code, added = fitted.added.len(), dropped = fitted.dropped.len(), "order of a section in force"); Ok(()) } #[derive(Deserialize)] pub struct OrderBody { /// Every article of the section (unit ids), each once, in the order proposed. order: Vec, } /// Propose another order for a section. An open proposal that already reads in /// this order is given back rather than doubled. pub async fn propose( State(state): State, User(user): User, Path(unit_id): Path, Json(b): Json, ) -> AppResult> { let doc_id: String = sqlx::query_scalar("SELECT doc_id FROM units WHERE id = ?") .bind(&unit_id) .fetch_optional(&state.db) .await? .ok_or(AppError::NotFound)?; let st = docs::load_state(&state, &doc_id).await?; docs::require_open(&state, &user, &st.group).await?; let unit = st.units.iter().find(|u| u.id == unit_id).ok_or(AppError::NotFound)?; if st.module_of(&unit.id) != Some(ModuleKind::Order) { return Err(AppError::Invalid("not_an_order")); } if b.order.len() > MAX_ARTICLES { return Err(AppError::Invalid("order")); } let members = st.members(unit.section.as_deref().unwrap_or_default()); order::check(&b.order, &members).map_err(|e| match e { OrderError::NotTheSection => AppError::Invalid("order"), OrderError::Unchanged => AppError::Invalid("same_order"), })?; if let Some(existing) = st.proposals.iter().find(|p| { p.unit_id == unit.id && p.status == "open" && p.kind == ModuleKind::Order.as_str() && order::decode(&p.body).is_some_and(|o| order::fit(&o, &members).order == b.order) }) { return Ok(Json(json!({ "id": existing.id, "existing": true }))); } let now = util::now(); let mut conn = state.db.acquire().await?; let id = modules::insert_version( &mut conn, NewVersion { unit_id: &unit.id, kind: ModuleKind::Order, title: &unit.code, body: &order::canonical(&b.order), author: Some(&user.id), in_force: false, now: &now, }, ) .await?; Ok(Json(json!({ "id": id, "existing": false }))) }