//! Deliberation API endpoints for structured democratic discourse. use axum::{ extract::{Path, Query, State}, http::StatusCode, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sqlx::PgPool; use uuid::Uuid; use chrono::{DateTime, Utc}; use crate::auth::AuthUser; use crate::plugins::builtin::structured_deliberation::{Argument, Summary, DeliberationService}; // ============================================================================ // Types // ============================================================================ #[derive(Debug, Serialize)] pub struct ProposalResource { pub id: Uuid, pub proposal_id: Uuid, pub title: String, pub resource_type: String, pub content: Option, pub url: Option, pub author_name: Option, pub sort_order: i32, pub created_at: DateTime, } #[derive(Debug, Deserialize)] pub struct CreateResourceRequest { pub title: String, pub resource_type: String, pub content: Option, pub url: Option, pub author_name: Option, } #[derive(Debug, Serialize)] pub struct ResourceReadStatus { pub resource_id: Uuid, pub has_read: bool, pub read_at: Option>, } #[derive(Debug, Serialize)] pub struct ProposalPosition { pub id: Uuid, pub proposal_id: Uuid, pub user_id: Uuid, pub position: String, pub reasoning: Option, pub created_at: DateTime, } #[derive(Debug, Deserialize)] pub struct SetPositionRequest { pub position: String, pub reasoning: Option, } #[derive(Debug, Serialize)] pub struct PositionSummary { pub strongly_support: i64, pub support: i64, pub neutral: i64, pub oppose: i64, pub strongly_oppose: i64, pub total: i64, } #[derive(Debug, Deserialize)] pub struct ReactToCommentRequest { pub reaction_type: String, } #[derive(Debug, Serialize)] pub struct CommentReactions { pub agree: i64, pub disagree: i64, pub insightful: i64, pub off_topic: i64, pub constructive: i64, } #[derive(Debug, Deserialize)] pub struct AddArgumentRequest { pub stance: String, pub title: String, pub content: String, pub parent_id: Option, } #[derive(Debug, Deserialize)] pub struct VoteArgumentRequest { pub vote_type: String, } #[derive(Debug, Deserialize)] pub struct ArgumentsQuery { pub stance: Option, pub limit: Option, } #[derive(Debug, Deserialize)] pub struct UpsertSummaryRequest { pub summary_type: String, pub content: String, pub key_points: Option, } #[derive(Debug, Deserialize)] pub struct RecordReadingRequest { pub read_type: String, pub time_seconds: i32, } #[derive(Debug, Serialize)] pub struct CanParticipateResponse { pub can_comment: bool, pub can_vote: bool, } // ============================================================================ // Handlers // ============================================================================ /// List resources for a proposal's inform phase async fn list_resources( Path(proposal_id): Path, State(pool): State, ) -> Result>, (StatusCode, String)> { let resources = sqlx::query_as!( ProposalResource, r#"SELECT id, proposal_id, title, resource_type, content, url, author_name, sort_order, created_at FROM proposal_resources WHERE proposal_id = $1 ORDER BY sort_order"#, proposal_id ) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(resources)) } /// Add a resource to a proposal async fn add_resource( auth: AuthUser, Path(proposal_id): Path, State(pool): State, Json(req): Json, ) -> Result, (StatusCode, String)> { // Check if user is proposal author or facilitator let proposal = sqlx::query!( "SELECT author_id, facilitator_id FROM proposals WHERE id = $1", proposal_id ) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?; let can_add = proposal.author_id == auth.user_id || proposal.facilitator_id == Some(auth.user_id); if !can_add { return Err((StatusCode::FORBIDDEN, "Not authorized to add resources".to_string())); } let resource = sqlx::query_as!( ProposalResource, r#"INSERT INTO proposal_resources (proposal_id, title, resource_type, content, url, author_name, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, proposal_id, title, resource_type, content, url, author_name, sort_order, created_at"#, proposal_id, req.title, req.resource_type, req.content, req.url, req.author_name, auth.user_id ) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(resource)) } /// Mark a resource as read async fn mark_resource_read( auth: AuthUser, Path(resource_id): Path, State(pool): State, ) -> Result, (StatusCode, String)> { sqlx::query!( r#"INSERT INTO proposal_resource_reads (resource_id, user_id) VALUES ($1, $2) ON CONFLICT (resource_id, user_id) DO NOTHING"#, resource_id, auth.user_id ) .execute(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let read = sqlx::query!( "SELECT read_at FROM proposal_resource_reads WHERE resource_id = $1 AND user_id = $2", resource_id, auth.user_id ) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(ResourceReadStatus { resource_id, has_read: read.is_some(), read_at: read.map(|r| r.read_at), })) } /// Get user's read status for all resources of a proposal async fn get_read_status( auth: AuthUser, Path(proposal_id): Path, State(pool): State, ) -> Result>, (StatusCode, String)> { let statuses = sqlx::query!( r#"SELECT r.id as resource_id, rr.read_at as "read_at?" FROM proposal_resources r LEFT JOIN proposal_resource_reads rr ON r.id = rr.resource_id AND rr.user_id = $2 WHERE r.proposal_id = $1 ORDER BY r.sort_order"#, proposal_id, auth.user_id ) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(statuses.into_iter().map(|s| ResourceReadStatus { resource_id: s.resource_id, has_read: s.read_at.is_some(), read_at: s.read_at, }).collect())) } /// Set user's position on a proposal async fn set_position( auth: AuthUser, Path(proposal_id): Path, State(pool): State, Json(req): Json, ) -> Result, (StatusCode, String)> { let position = sqlx::query_as!( ProposalPosition, r#"INSERT INTO proposal_positions (proposal_id, user_id, position, reasoning) VALUES ($1, $2, $3, $4) ON CONFLICT (proposal_id, user_id) DO UPDATE SET position = $3, reasoning = $4, updated_at = NOW() RETURNING id, proposal_id, user_id, position, reasoning, created_at"#, proposal_id, auth.user_id, req.position, req.reasoning ) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(position)) } /// Get position summary for a proposal (agreement visualization) async fn get_position_summary( Path(proposal_id): Path, State(pool): State, ) -> Result, (StatusCode, String)> { let counts = sqlx::query!( r#"SELECT COUNT(*) FILTER (WHERE position = 'strongly_support') as strongly_support, COUNT(*) FILTER (WHERE position = 'support') as support, COUNT(*) FILTER (WHERE position = 'neutral') as neutral, COUNT(*) FILTER (WHERE position = 'oppose') as oppose, COUNT(*) FILTER (WHERE position = 'strongly_oppose') as strongly_oppose, COUNT(*) as total FROM proposal_positions WHERE proposal_id = $1"#, proposal_id ) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(PositionSummary { strongly_support: counts.strongly_support.unwrap_or(0), support: counts.support.unwrap_or(0), neutral: counts.neutral.unwrap_or(0), oppose: counts.oppose.unwrap_or(0), strongly_oppose: counts.strongly_oppose.unwrap_or(0), total: counts.total.unwrap_or(0), })) } /// React to a comment async fn react_to_comment( auth: AuthUser, Path(comment_id): Path, State(pool): State, Json(req): Json, ) -> Result, (StatusCode, String)> { // Add reaction (toggle - remove if exists, add if not) let existing = sqlx::query!( "SELECT id FROM comment_reactions WHERE comment_id = $1 AND user_id = $2 AND reaction_type = $3", comment_id, auth.user_id, req.reaction_type ) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if existing.is_some() { sqlx::query!( "DELETE FROM comment_reactions WHERE comment_id = $1 AND user_id = $2 AND reaction_type = $3", comment_id, auth.user_id, req.reaction_type ) .execute(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; } else { sqlx::query!( "INSERT INTO comment_reactions (comment_id, user_id, reaction_type) VALUES ($1, $2, $3)", comment_id, auth.user_id, req.reaction_type ) .execute(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; } // Return updated counts get_comment_reactions_internal(&pool, comment_id).await } /// Get reactions for a comment async fn get_comment_reactions( Path(comment_id): Path, State(pool): State, ) -> Result, (StatusCode, String)> { get_comment_reactions_internal(&pool, comment_id).await } async fn get_comment_reactions_internal( pool: &PgPool, comment_id: Uuid, ) -> Result, (StatusCode, String)> { let counts = sqlx::query!( r#"SELECT COUNT(*) FILTER (WHERE reaction_type = 'agree') as agree, COUNT(*) FILTER (WHERE reaction_type = 'disagree') as disagree, COUNT(*) FILTER (WHERE reaction_type = 'insightful') as insightful, COUNT(*) FILTER (WHERE reaction_type = 'off_topic') as off_topic, COUNT(*) FILTER (WHERE reaction_type = 'constructive') as constructive FROM comment_reactions WHERE comment_id = $1"#, comment_id ) .fetch_one(pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(CommentReactions { agree: counts.agree.unwrap_or(0), disagree: counts.disagree.unwrap_or(0), insightful: counts.insightful.unwrap_or(0), off_topic: counts.off_topic.unwrap_or(0), constructive: counts.constructive.unwrap_or(0), })) } // ============================================================================ // Argument Handlers (wired to DeliberationService) // ============================================================================ /// Get arguments for a proposal async fn list_arguments( Path(proposal_id): Path, Query(query): Query, State(pool): State, ) -> Result>, (StatusCode, String)> { let limit = query.limit.unwrap_or(50); let arguments = DeliberationService::get_arguments( &pool, proposal_id, query.stance.as_deref(), limit, ) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(arguments)) } /// Add an argument to a proposal async fn add_argument( auth: AuthUser, Path(proposal_id): Path, State(pool): State, Json(req): Json, ) -> Result, (StatusCode, String)> { // Check if user can participate let can = DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment") .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if !can { return Err((StatusCode::FORBIDDEN, "Must read proposal content before participating".to_string())); } let argument_id = DeliberationService::add_argument( &pool, proposal_id, req.parent_id, &req.stance, &req.title, &req.content, auth.user_id, ) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(json!({"id": argument_id}))) } /// Vote on an argument async fn vote_argument( auth: AuthUser, Path(argument_id): Path, State(pool): State, Json(req): Json, ) -> Result, (StatusCode, String)> { DeliberationService::vote_argument(&pool, argument_id, auth.user_id, &req.vote_type) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(json!({"success": true}))) } // ============================================================================ // Summary Handlers (wired to DeliberationService) // ============================================================================ /// Get summaries for a proposal async fn list_summaries( Path(proposal_id): Path, State(pool): State, ) -> Result>, (StatusCode, String)> { let summaries = DeliberationService::get_summaries(&pool, proposal_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(summaries)) } /// Create or update a summary async fn upsert_summary( auth: AuthUser, Path(proposal_id): Path, State(pool): State, Json(req): Json, ) -> Result, (StatusCode, String)> { let key_points = req.key_points.unwrap_or(json!([])); let summary_id = DeliberationService::upsert_summary( &pool, proposal_id, &req.summary_type, &req.content, key_points, auth.user_id, ) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(json!({"id": summary_id}))) } /// Approve a summary async fn approve_summary( auth: AuthUser, Path(summary_id): Path, State(pool): State, ) -> Result, (StatusCode, String)> { DeliberationService::approve_summary(&pool, summary_id, auth.user_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(json!({"success": true}))) } // ============================================================================ // Reading/Participation Handlers (wired to DeliberationService) // ============================================================================ /// Record reading progress async fn record_reading( auth: AuthUser, Path(proposal_id): Path, State(pool): State, Json(req): Json, ) -> Result, (StatusCode, String)> { DeliberationService::record_reading( &pool, proposal_id, auth.user_id, &req.read_type, req.time_seconds, ) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(json!({"success": true}))) } /// Check if user can participate async fn check_can_participate( auth: AuthUser, Path(proposal_id): Path, State(pool): State, ) -> Result, (StatusCode, String)> { let can_comment = DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment") .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let can_vote = DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "vote") .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(CanParticipateResponse { can_comment, can_vote })) } /// Get deliberation overview (metrics + top arguments + summaries) async fn get_overview( Path(proposal_id): Path, State(pool): State, ) -> Result, (StatusCode, String)> { let overview = DeliberationService::get_overview(&pool, proposal_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(overview)) } // ============================================================================ // Router // ============================================================================ pub fn router(pool: PgPool) -> Router { Router::new() // Resources (inform phase) .route("/api/proposals/{proposal_id}/resources", get(list_resources).post(add_resource)) .route("/api/proposals/{proposal_id}/resources/read-status", get(get_read_status)) .route("/api/resources/{resource_id}/read", post(mark_resource_read)) // Positions (agreement visualization) .route("/api/proposals/{proposal_id}/positions", post(set_position)) .route("/api/proposals/{proposal_id}/positions/summary", get(get_position_summary)) // Comment reactions (quality scoring) .route("/api/comments/{comment_id}/reactions", get(get_comment_reactions).post(react_to_comment)) // Arguments (structured debate) - wired to DeliberationService .route("/api/proposals/{proposal_id}/arguments", get(list_arguments).post(add_argument)) .route("/api/arguments/{argument_id}/vote", post(vote_argument)) // Summaries (collaborative summaries) - wired to DeliberationService .route("/api/proposals/{proposal_id}/summaries", get(list_summaries).post(upsert_summary)) .route("/api/summaries/{summary_id}/approve", post(approve_summary)) // Reading/participation tracking - wired to DeliberationService .route("/api/proposals/{proposal_id}/reading", post(record_reading)) .route("/api/proposals/{proposal_id}/can-participate", get(check_can_participate)) // Overview (combined metrics) .route("/api/proposals/{proposal_id}/deliberation", get(get_overview)) .with_state(pool) }