use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sqlx::PgPool; use std::sync::Arc; use uuid::Uuid; use crate::plugins::{ hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope, }; pub struct StructuredDeliberationPlugin; impl StructuredDeliberationPlugin { pub fn new() -> Self { Self } } #[async_trait] impl Plugin for StructuredDeliberationPlugin { fn metadata(&self) -> PluginMetadata { PluginMetadata { name: "structured_deliberation", version: "1.0.0", description: "Pro/con arguments and collaborative summaries", is_core: false, scope: PluginScope::Community, default_enabled: true, settings_schema: Some(json!({ "type": "object", "properties": { "require_reading": {"type": "boolean", "default": true}, "enable_summaries": {"type": "boolean", "default": true} } })), } } fn register(&self, system: &mut PluginSystem) { // Hook: Check reading requirement before comment system.add_action( "comment.pre_create", "structured_deliberation".to_string(), 5, Arc::new(|ctx: HookContext, payload: Value| { Box::pin(async move { if let (Some(proposal_id), Some(user_id)) = ( payload .get("proposal_id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok()), ctx.actor_user_id, ) { let can_comment = DeliberationService::check_can_participate( &ctx.pool, proposal_id, user_id, "comment", ) .await?; if !can_comment { return Err(PluginError::Message( "Please read the proposal before commenting".to_string(), )); } } Ok(()) }) }), ); // Hook: Calculate metrics periodically system.add_action( "cron.hourly", "structured_deliberation".to_string(), 50, Arc::new(|ctx: HookContext, _payload: Value| { Box::pin(async move { DeliberationService::update_all_metrics(&ctx.pool).await?; Ok(()) }) }), ); // Hook: Track argument creation system.add_action( "deliberation.argument.created", "structured_deliberation".to_string(), 50, Arc::new(|ctx: HookContext, payload: Value| { Box::pin(async move { ctx.emit_public_event( Some("structured_deliberation"), "argument.created", payload.clone(), ) .await?; Ok(()) }) }), ); } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Argument { pub id: Uuid, pub proposal_id: Uuid, pub stance: String, pub title: String, pub content: String, pub author_id: Uuid, pub upvotes: i32, pub downvotes: i32, pub quality_score: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Summary { pub id: Uuid, pub proposal_id: Uuid, pub summary_type: String, pub content: String, pub version: i32, pub is_approved: bool, } pub struct DeliberationService; impl DeliberationService { /// Check if user can participate based on reading requirements pub async fn check_can_participate( pool: &PgPool, proposal_id: Uuid, user_id: Uuid, action: &str, ) -> Result { let reading = sqlx::query!( r#"SELECT can_comment, can_vote FROM deliberation_reading_log WHERE proposal_id = $1 AND user_id = $2"#, proposal_id, user_id ) .fetch_optional(pool) .await?; match reading { Some(r) => Ok(match action { "comment" => r.can_comment.unwrap_or(false), "vote" => r.can_vote.unwrap_or(false), _ => true, }), None => Ok(false), // No reading record } } /// Record that user has read content pub async fn record_reading( pool: &PgPool, proposal_id: Uuid, user_id: Uuid, read_type: &str, time_seconds: i32, ) -> Result<(), PluginError> { sqlx::query!( r#"INSERT INTO deliberation_reading_log (proposal_id, user_id, first_read_at, reading_time_seconds) VALUES ($1, $2, NOW(), $3) ON CONFLICT (proposal_id, user_id) DO UPDATE SET read_proposal = CASE WHEN $4 = 'proposal' THEN true ELSE deliberation_reading_log.read_proposal END, read_summaries = CASE WHEN $4 = 'summaries' THEN true ELSE deliberation_reading_log.read_summaries END, read_top_arguments = CASE WHEN $4 = 'arguments' THEN true ELSE deliberation_reading_log.read_top_arguments END, reading_time_seconds = deliberation_reading_log.reading_time_seconds + $3, updated_at = NOW()"#, proposal_id, user_id, time_seconds, read_type ) .execute(pool) .await?; Ok(()) } /// Add an argument pub async fn add_argument( pool: &PgPool, proposal_id: Uuid, parent_id: Option, stance: &str, title: &str, content: &str, author_id: Uuid, ) -> Result { let argument_id: Uuid = sqlx::query_scalar( "SELECT add_deliberation_argument($1, $2, $3::argument_stance, $4, $5, $6)", ) .bind(proposal_id) .bind(parent_id) .bind(stance) .bind(title) .bind(content) .bind(author_id) .fetch_one(pool) .await?; Ok(argument_id) } /// Vote on an argument pub async fn vote_argument( pool: &PgPool, argument_id: Uuid, user_id: Uuid, vote_type: &str, ) -> Result<(), PluginError> { sqlx::query("SELECT vote_on_argument($1, $2, $3)") .bind(argument_id) .bind(user_id) .bind(vote_type) .execute(pool) .await?; Ok(()) } /// Get arguments for a proposal pub async fn get_arguments( pool: &PgPool, proposal_id: Uuid, stance: Option<&str>, limit: i64, ) -> Result, PluginError> { let arguments = if let Some(s) = stance { sqlx::query_as!( Argument, r#"SELECT id, proposal_id, stance::text AS "stance!", title, content, author_id, upvotes, downvotes, quality_score::float8 AS quality_score FROM deliberation_arguments WHERE proposal_id = $1 AND stance::text = $2 AND NOT is_hidden AND parent_id IS NULL ORDER BY quality_score DESC NULLS LAST LIMIT $3"#, proposal_id, s, limit ) .fetch_all(pool) .await? } else { sqlx::query_as!( Argument, r#"SELECT id, proposal_id, stance::text AS "stance!", title, content, author_id, upvotes, downvotes, quality_score::float8 AS quality_score FROM deliberation_arguments WHERE proposal_id = $1 AND NOT is_hidden AND parent_id IS NULL ORDER BY quality_score DESC NULLS LAST LIMIT $2"#, proposal_id, limit ) .fetch_all(pool) .await? }; Ok(arguments) } /// Create or update a summary pub async fn upsert_summary( pool: &PgPool, proposal_id: Uuid, summary_type: &str, content: &str, key_points: Value, editor_id: Uuid, ) -> Result { // Check if summary exists let existing = sqlx::query_scalar!( "SELECT id FROM deliberation_summaries WHERE proposal_id = $1 AND summary_type = $2::summary_type", proposal_id, summary_type as _ ) .fetch_optional(pool) .await?; if let Some(summary_id) = existing { // Save to history sqlx::query!( r#"INSERT INTO summary_edit_history (summary_id, version, content, key_points, editor_id) SELECT id, version, content, key_points, last_editor_id FROM deliberation_summaries WHERE id = $1"#, summary_id ) .execute(pool) .await?; // Update sqlx::query!( r#"UPDATE deliberation_summaries SET content = $2, key_points = $3, last_editor_id = $4, version = version + 1, edit_count = edit_count + 1, is_approved = false, updated_at = NOW() WHERE id = $1"#, summary_id, content, key_points, editor_id ) .execute(pool) .await?; Ok(summary_id) } else { // Create new let summary_id = sqlx::query_scalar!( r#"INSERT INTO deliberation_summaries ( proposal_id, summary_type, content, key_points, last_editor_id ) VALUES ($1, $2::summary_type, $3, $4, $5) RETURNING id"#, proposal_id, summary_type as _, content, key_points, editor_id ) .fetch_one(pool) .await?; Ok(summary_id) } } /// Get summaries for a proposal pub async fn get_summaries( pool: &PgPool, proposal_id: Uuid, ) -> Result, PluginError> { let summaries = sqlx::query_as!( Summary, r#"SELECT id, proposal_id, summary_type::text AS "summary_type!", content, version, is_approved FROM deliberation_summaries WHERE proposal_id = $1 ORDER BY summary_type"#, proposal_id ) .fetch_all(pool) .await?; Ok(summaries) } /// Approve a summary pub async fn approve_summary( pool: &PgPool, summary_id: Uuid, approver_id: Uuid, ) -> Result<(), PluginError> { sqlx::query!( r#"UPDATE deliberation_summaries SET is_approved = true, approved_by = $2, approved_at = NOW() WHERE id = $1"#, summary_id, approver_id ) .execute(pool) .await?; Ok(()) } /// Update metrics for all active proposals pub async fn update_all_metrics(pool: &PgPool) -> Result { let proposals = sqlx::query_scalar!( r#"SELECT DISTINCT proposal_id FROM deliberation_arguments WHERE created_at > NOW() - INTERVAL '1 day'"# ) .fetch_all(pool) .await?; let mut count = 0; for proposal_id in proposals { sqlx::query("SELECT calculate_deliberation_metrics($1)") .bind(proposal_id) .execute(pool) .await?; count += 1; } Ok(count) } /// Get deliberation overview pub async fn get_overview(pool: &PgPool, proposal_id: Uuid) -> Result { let metrics = sqlx::query!( r#"SELECT total_arguments, pro_arguments, con_arguments, neutral_arguments, unique_participants, substantive_ratio::float8 AS substantive_ratio, balance_score::float8 AS balance_score FROM deliberation_metrics WHERE proposal_id = $1 ORDER BY calculated_at DESC LIMIT 1"#, proposal_id ) .fetch_optional(pool) .await?; let top_pro = Self::get_arguments(pool, proposal_id, Some("pro"), 3).await?; let top_con = Self::get_arguments(pool, proposal_id, Some("con"), 3).await?; let summaries = Self::get_summaries(pool, proposal_id).await?; Ok(json!({ "metrics": metrics.map(|m| json!({ "total_arguments": m.total_arguments, "pro": m.pro_arguments, "con": m.con_arguments, "neutral": m.neutral_arguments, "participants": m.unique_participants, "substantive_ratio": m.substantive_ratio, "balance_score": m.balance_score })), "top_pro_arguments": top_pro, "top_con_arguments": top_con, "summaries": summaries })) } }