//! Seeds — so as not to start from a blank page. //! //! On start, missing groups are created and every `seeds/*.json` document is //! instantiated in each group it names, **only if that group does not hold it //! yet**. An existing document is never touched: from its first vote on, it //! is runtime data. use crate::AppState; use crate::util::{self, LangMap}; use serde::Deserialize; use std::path::Path; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SeedDoc { pub slug: String, pub title: LangMap, pub summary: LangMap, pub source_lang: String, pub instances: Vec, pub units: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SeedInstance { pub group: String, pub vote_floor: f64, pub publish_path: Option, } #[derive(Deserialize)] pub struct SeedUnit { pub code: String, pub section: Option, pub position: f64, pub inertia: String, pub versions: Vec, } #[derive(Deserialize)] pub struct SeedVersion { pub label: Option, /// `in_force` (seed a minima) or `open` (proposal at zero votes). pub status: String, pub title: LangMap, pub body: LangMap, } pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> { for (position, g) in trustin_access::default_groups().into_iter().enumerate() { sqlx::query( "INSERT OR IGNORE INTO groups (slug, name, parent, policy, hidden, position) VALUES (?, ?, ?, ?, ?, ?)", ) .bind(&g.slug) .bind(&g.name) .bind(&g.parent) .bind(g.policy.as_str()) .bind(g.hidden) .bind(i64::try_from(position).unwrap_or(0)) .execute(&state.db) .await?; } Ok(()) } pub async fn ensure(state: &AppState) -> anyhow::Result<()> { ensure_groups(state).await?; let dir = state.cfg.seed_dir.clone(); let mut entries = match tokio::fs::read_dir(&dir).await { Ok(e) => e, Err(e) => { tracing::warn!(dir = %dir.display(), error = %e, "no seed directory"); return Ok(()); } }; let mut files = Vec::new(); while let Some(entry) = entries.next_entry().await? { let path = entry.path(); if path.extension().is_some_and(|x| x == "json") { files.push(path); } } files.sort(); for path in files { load_file(state, &path).await?; } Ok(()) } async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<()> { let raw = tokio::fs::read_to_string(path).await?; let seed: SeedDoc = serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?; for inst in &seed.instances { let group_exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM groups WHERE slug = ?") .bind(&inst.group) .fetch_one(&state.db) .await?; if group_exists == 0 { tracing::warn!(group = %inst.group, slug = %seed.slug, "seed names an unknown group"); continue; } let present: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents WHERE group_slug = ? AND slug = ?") .bind(&inst.group) .bind(&seed.slug) .fetch_one(&state.db) .await?; if present > 0 { continue; } instantiate(state, &seed, inst).await?; tracing::info!(group = %inst.group, slug = %seed.slug, "seed instantiated"); } Ok(()) } async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> anyhow::Result<()> { let src = seed.source_lang.as_str(); let pick = |m: &LangMap| m.get(src).cloned().unwrap_or_default(); let now = util::now(); let doc_id = util::id(); let mut tx = state.db.begin().await?; sqlx::query( "INSERT INTO documents (id, group_slug, slug, title, summary, source_lang, vote_floor, publish_path, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&doc_id) .bind(&inst.group) .bind(&seed.slug) .bind(util::lang_json(&seed.title)) .bind(util::lang_json(&seed.summary)) .bind(src) .bind(inst.vote_floor) .bind(&inst.publish_path) .bind(&now) .execute(&mut *tx) .await?; for unit in &seed.units { let unit_id = util::id(); sqlx::query( "INSERT INTO units (id, doc_id, code, section, position, inertia, in_force_id, created_at) VALUES (?, ?, ?, ?, ?, ?, NULL, ?)", ) .bind(&unit_id) .bind(&doc_id) .bind(&unit.code) .bind(&unit.section) .bind(unit.position) .bind(&unit.inertia) .bind(&now) .execute(&mut *tx) .await?; for v in &unit.versions { let pid = util::id(); let (title, body) = (pick(&v.title), pick(&v.body)); let status = if v.status == "in_force" { "in_force" } else { "open" }; sqlx::query( "INSERT INTO proposals (id, unit_id, kind, title, body, status, origin, label, author_id, revision, created_at, updated_at, submitted_at) VALUES (?, ?, 'text', ?, ?, ?, 'seed', ?, NULL, 1, ?, ?, ?)", ) .bind(&pid) .bind(&unit_id) .bind(&title) .bind(&body) .bind(status) .bind(&v.label) .bind(&now) .bind(&now) .bind(&now) .execute(&mut *tx) .await?; sqlx::query( "INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at) VALUES (?, 1, 'initial', ?, ?, NULL, ?)", ) .bind(&pid) .bind(&title) .bind(&body) .bind(&now) .execute(&mut *tx) .await?; if status == "in_force" { sqlx::query("UPDATE units SET in_force_id = ? WHERE id = ?") .bind(&pid) .bind(&unit_id) .execute(&mut *tx) .await?; } let hash = util::source_hash(&title, &body); for lang in util::LANGS.iter().filter(|l| **l != src) { let (Some(t), Some(b)) = (v.title.get(*lang), v.body.get(*lang)) else { continue }; sqlx::query( "INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, 'seed', NULL, ?)", ) .bind(&pid) .bind(lang) .bind(t) .bind(b) .bind(&hash) .bind(&now) .execute(&mut *tx) .await?; } } } if inst.publish_path.is_some() { sqlx::query( "INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, 'publication initiale', ?, ?)", ) .bind(&doc_id) .bind(&now) .bind(&now) .execute(&mut *tx) .await?; } tx.commit().await?; Ok(()) }