diff --git a/backend/src/api/analytics.rs b/backend/src/api/analytics.rs index fd40f04..09aed0f 100644 --- a/backend/src/api/analytics.rs +++ b/backend/src/api/analytics.rs @@ -136,12 +136,33 @@ async fn export_data( pub fn router(pool: PgPool) -> Router { Router::new() - .route("/api/communities/{community_id}/analytics/dashboard", get(get_dashboard)) - .route("/api/communities/{community_id}/analytics/health", get(get_health)) - .route("/api/communities/{community_id}/analytics/participation", get(get_participation_trends)) - .route("/api/communities/{community_id}/analytics/delegation", get(get_delegation_analytics)) - .route("/api/communities/{community_id}/analytics/decision-load", get(get_decision_load)) - .route("/api/communities/{community_id}/analytics/voting-methods", get(get_voting_method_comparison)) - .route("/api/communities/{community_id}/analytics/export", get(export_data)) + .route( + "/api/communities/{community_id}/analytics/dashboard", + get(get_dashboard), + ) + .route( + "/api/communities/{community_id}/analytics/health", + get(get_health), + ) + .route( + "/api/communities/{community_id}/analytics/participation", + get(get_participation_trends), + ) + .route( + "/api/communities/{community_id}/analytics/delegation", + get(get_delegation_analytics), + ) + .route( + "/api/communities/{community_id}/analytics/decision-load", + get(get_decision_load), + ) + .route( + "/api/communities/{community_id}/analytics/voting-methods", + get(get_voting_method_comparison), + ) + .route( + "/api/communities/{community_id}/analytics/export", + get(export_data), + ) .with_state(pool) } diff --git a/backend/src/api/approvals.rs b/backend/src/api/approvals.rs index ace1f11..8da5489 100644 --- a/backend/src/api/approvals.rs +++ b/backend/src/api/approvals.rs @@ -6,13 +6,13 @@ use axum::{ routing::{get, post}, Json, Router, }; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; -use chrono::{DateTime, Utc}; +use super::permissions::{perms, require_permission}; use crate::auth::AuthUser; -use super::permissions::{require_permission, perms}; // ============================================================================ // Types @@ -72,7 +72,7 @@ async fn list_pending_registrations( require_permission(&pool, auth.user_id, perms::USER_MANAGE, None).await?; let status_filter = query.status.unwrap_or_else(|| "pending".to_string()); - + let registrations = sqlx::query!( r#"SELECT id, username, email, display_name, status, created_at, expires_at FROM pending_registrations @@ -85,15 +85,20 @@ async fn list_pending_registrations( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(registrations.into_iter().map(|r| PendingRegistration { - id: r.id, - username: r.username, - email: r.email, - display_name: r.display_name, - status: r.status.unwrap_or_default(), - created_at: r.created_at.unwrap_or_else(Utc::now), - expires_at: r.expires_at, - }).collect())) + Ok(Json( + registrations + .into_iter() + .map(|r| PendingRegistration { + id: r.id, + username: r.username, + email: r.email, + display_name: r.display_name, + status: r.status.unwrap_or_default(), + created_at: r.created_at.unwrap_or_else(Utc::now), + expires_at: r.expires_at, + }) + .collect(), + )) } /// Review a pending registration (approve or reject) @@ -117,9 +122,15 @@ async fn review_registration( .map_err(|e| { let msg = e.to_string(); if msg.contains("not found") || msg.contains("already processed") { - (StatusCode::NOT_FOUND, "Pending registration not found or already processed".to_string()) + ( + StatusCode::NOT_FOUND, + "Pending registration not found or already processed".to_string(), + ) } else if msg.contains("expired") { - (StatusCode::GONE, "Registration request has expired".to_string()) + ( + StatusCode::GONE, + "Registration request has expired".to_string(), + ) } else { (StatusCode::INTERNAL_SERVER_ERROR, msg) } @@ -147,13 +158,11 @@ async fn review_registration( .ok(); } - let is_admin: bool = sqlx::query_scalar!( - "SELECT is_admin FROM users WHERE id = $1", - new_user_id - ) - .fetch_one(&pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let is_admin: bool = + sqlx::query_scalar!("SELECT is_admin FROM users WHERE id = $1", new_user_id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if is_admin { let admin_role_id: Option = sqlx::query_scalar!( @@ -204,7 +213,10 @@ async fn review_registration( message: "Registration rejected".to_string(), })) } else { - Err((StatusCode::NOT_FOUND, "Pending registration not found or already processed".to_string())) + Err(( + StatusCode::NOT_FOUND, + "Pending registration not found or already processed".to_string(), + )) } } } @@ -222,7 +234,7 @@ async fn list_pending_communities( require_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?; let status_filter = query.status.unwrap_or_else(|| "pending".to_string()); - + let communities = sqlx::query!( r#"SELECT pc.id, pc.name, pc.slug, pc.description, pc.requested_by, pc.status, pc.created_at, u.username as requester_username @@ -237,16 +249,21 @@ async fn list_pending_communities( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(communities.into_iter().map(|c| PendingCommunity { - id: c.id, - name: c.name, - slug: c.slug, - description: c.description, - requested_by: c.requested_by, - requested_by_username: Some(c.requester_username), - status: c.status.unwrap_or_default(), - created_at: c.created_at.unwrap_or_else(Utc::now), - }).collect())) + Ok(Json( + communities + .into_iter() + .map(|c| PendingCommunity { + id: c.id, + name: c.name, + slug: c.slug, + description: c.description, + requested_by: c.requested_by, + requested_by_username: Some(c.requester_username), + status: c.status.unwrap_or_default(), + created_at: c.created_at.unwrap_or_else(Utc::now), + }) + .collect(), + )) } /// Review a pending community request (approve or reject) @@ -260,21 +277,21 @@ async fn review_community( if req.approve { // Approve community - let result = sqlx::query_scalar!( - "SELECT approve_community($1, $2)", - pending_id, - auth.user_id - ) - .fetch_one(&pool) - .await - .map_err(|e| { - let msg = e.to_string(); - if msg.contains("not found") || msg.contains("already processed") { - (StatusCode::NOT_FOUND, "Pending community not found or already processed".to_string()) - } else { - (StatusCode::INTERNAL_SERVER_ERROR, msg) - } - })?; + let result = + sqlx::query_scalar!("SELECT approve_community($1, $2)", pending_id, auth.user_id) + .fetch_one(&pool) + .await + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("not found") || msg.contains("already processed") { + ( + StatusCode::NOT_FOUND, + "Pending community not found or already processed".to_string(), + ) + } else { + (StatusCode::INTERNAL_SERVER_ERROR, msg) + } + })?; Ok(Json(ReviewResponse { success: true, @@ -301,7 +318,10 @@ async fn review_community( message: "Community request rejected".to_string(), })) } else { - Err((StatusCode::NOT_FOUND, "Pending community not found or already processed".to_string())) + Err(( + StatusCode::NOT_FOUND, + "Pending community not found or already processed".to_string(), + )) } } } @@ -312,8 +332,14 @@ async fn review_community( pub fn router(pool: PgPool) -> Router { Router::new() - .route("/api/approvals/registrations", get(list_pending_registrations)) - .route("/api/approvals/registrations/{id}", post(review_registration)) + .route( + "/api/approvals/registrations", + get(list_pending_registrations), + ) + .route( + "/api/approvals/registrations/{id}", + post(review_registration), + ) .route("/api/approvals/communities", get(list_pending_communities)) .route("/api/approvals/communities/{id}", post(review_community)) .with_state(pool) diff --git a/backend/src/api/auth.rs b/backend/src/api/auth.rs index 3a1df21..afc136e 100644 --- a/backend/src/api/auth.rs +++ b/backend/src/api/auth.rs @@ -1,4 +1,9 @@ -use axum::{extract::State, http::StatusCode, routing::{get, post}, Extension, Json, Router}; +use axum::{ + extract::State, + http::StatusCode, + routing::{get, post}, + Extension, Json, Router, +}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::sync::Arc; @@ -68,10 +73,16 @@ async fn register( let registration_mode = if let Some(s) = &settings { if !s.registration_enabled { - return Err((StatusCode::FORBIDDEN, "Registration is currently disabled".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Registration is currently disabled".to_string(), + )); } if s.registration_mode == "invite_only" && req.invitation_code.is_none() { - return Err((StatusCode::FORBIDDEN, "Registration requires an invitation code".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Registration requires an invitation code".to_string(), + )); } s.registration_mode.clone() } else { @@ -90,24 +101,41 @@ async fn register( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; match validation { - None => return Err((StatusCode::BAD_REQUEST, "Invalid invitation code".to_string())), + None => { + return Err(( + StatusCode::BAD_REQUEST, + "Invalid invitation code".to_string(), + )) + } Some(inv) => { if !inv.is_active.unwrap_or(false) { - return Err((StatusCode::BAD_REQUEST, "Invitation is no longer active".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Invitation is no longer active".to_string(), + )); } if let Some(exp) = inv.expires_at { if exp < chrono::Utc::now() { - return Err((StatusCode::BAD_REQUEST, "Invitation has expired".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Invitation has expired".to_string(), + )); } } if let Some(max) = inv.max_uses { if inv.uses_count.unwrap_or(0) >= max { - return Err((StatusCode::BAD_REQUEST, "Invitation has reached maximum uses".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Invitation has reached maximum uses".to_string(), + )); } } if let Some(email) = &inv.email { if email != &req.email { - return Err((StatusCode::BAD_REQUEST, "This invitation is for a different email address".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "This invitation is for a different email address".to_string(), + )); } } inv.community_id @@ -162,7 +190,10 @@ async fn register( })?; // Return a special response indicating pending approval - return Err((StatusCode::ACCEPTED, "Registration submitted for approval. You will be notified when approved.".to_string())); + return Err(( + StatusCode::ACCEPTED, + "Registration submitted for approval. You will be notified when approved.".to_string(), + )); } // Direct registration (open mode, invite_only with valid invite, or first user) @@ -183,7 +214,10 @@ async fn register( .await .map_err(|e| { if e.to_string().contains("duplicate key") { - (StatusCode::CONFLICT, "Username or email already exists".to_string()) + ( + StatusCode::CONFLICT, + "Username or email already exists".to_string(), + ) } else { (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) } @@ -234,10 +268,15 @@ async fn register( // Use invitation if provided (records usage and links user) if let Some(code) = &req.invitation_code { - sqlx::query!("SELECT use_invitation($1, $2, $3)", code, user.id, req.email.as_str()) - .fetch_one(&pool) - .await - .ok(); // Ignore errors - user is already created + sqlx::query!( + "SELECT use_invitation($1, $2, $3)", + code, + user.id, + req.email.as_str() + ) + .fetch_one(&pool) + .await + .ok(); // Ignore errors - user is already created } // If invitation was for a specific community, add user as member @@ -283,7 +322,10 @@ async fn login( .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::UNAUTHORIZED, "Demo account not found".to_string()))?; + .ok_or(( + StatusCode::UNAUTHORIZED, + "Demo account not found".to_string(), + ))?; let token = create_token(user.id, &user.username, &config.jwt_secret) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; diff --git a/backend/src/api/comments.rs b/backend/src/api/comments.rs index f67a88f..5b44021 100644 --- a/backend/src/api/comments.rs +++ b/backend/src/api/comments.rs @@ -2,8 +2,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, routing::get, - Extension, - Json, Router, + Extension, Json, Router, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -14,8 +13,8 @@ use uuid::Uuid; use crate::auth::AuthUser; use crate::plugins::HookContext; -use crate::plugins::PluginManager; use crate::plugins::PluginError; +use crate::plugins::PluginManager; #[derive(Debug, Serialize)] pub struct Comment { @@ -36,7 +35,10 @@ pub struct CreateComment { pub fn router(pool: PgPool) -> Router { Router::new() - .route("/api/proposals/{proposal_id}/comments", get(list_comments).post(create_comment)) + .route( + "/api/proposals/{proposal_id}/comments", + get(list_comments).post(create_comment), + ) .with_state(pool) } diff --git a/backend/src/api/communities.rs b/backend/src/api/communities.rs index 1695c4a..b95b49d 100644 --- a/backend/src/api/communities.rs +++ b/backend/src/api/communities.rs @@ -2,8 +2,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, routing::{get, post}, - Extension, - Json, Router, + Extension, Json, Router, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -11,10 +10,10 @@ use sqlx::PgPool; use std::sync::Arc; use uuid::Uuid; +use super::permissions::{perms, user_has_permission}; use crate::auth::AuthUser; use crate::models::community::CommunityResponse; use crate::plugins::{HookContext, PluginManager}; -use super::permissions::{user_has_permission, perms}; #[derive(Debug, Deserialize)] pub struct CreateCommunityRequest { @@ -35,7 +34,10 @@ use axum::routing::put; pub fn router(pool: PgPool) -> Router { Router::new() - .route("/api/communities", get(list_communities).post(create_community)) + .route( + "/api/communities", + get(list_communities).post(create_community), + ) .route("/api/communities/{id}", put(update_community)) .route("/api/communities/{id}/details", get(get_community_details)) .route("/api/communities/{id}/join", post(join_community)) @@ -58,7 +60,12 @@ async fn list_communities( .await .map_err(|e| e.to_string())?; - Ok(Json(communities.into_iter().map(CommunityResponse::from).collect())) + Ok(Json( + communities + .into_iter() + .map(CommunityResponse::from) + .collect(), + )) } async fn create_community( @@ -68,28 +75,34 @@ async fn create_community( Json(req): Json, ) -> Result, (StatusCode, String)> { // Check platform mode for community creation permissions - let settings = sqlx::query!( - "SELECT platform_mode FROM instance_settings LIMIT 1" - ) - .fetch_optional(&pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let settings = sqlx::query!("SELECT platform_mode FROM instance_settings LIMIT 1") + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if let Some(s) = settings { match s.platform_mode.as_str() { "single_community" => { - return Err((StatusCode::FORBIDDEN, "This platform is dedicated to a single community".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "This platform is dedicated to a single community".to_string(), + )); } "admin_only" => { // Check platform admin or community create permission - let can_create = user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?; + let can_create = + user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?; if !can_create { - return Err((StatusCode::FORBIDDEN, "Only administrators can create communities".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Only administrators can create communities".to_string(), + )); } } "approval" => { // Check if user has direct create permission (admins bypass approval) - let can_create = user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?; + let can_create = + user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?; if !can_create { // Create pending community request instead sqlx::query!( @@ -104,13 +117,20 @@ async fn create_community( .await .map_err(|e| { if e.to_string().contains("duplicate key") { - (StatusCode::CONFLICT, "A community with this slug already exists or is pending approval".to_string()) + ( + StatusCode::CONFLICT, + "A community with this slug already exists or is pending approval" + .to_string(), + ) } else { (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) } })?; - - return Err((StatusCode::ACCEPTED, "Community request submitted for approval".to_string())); + + return Err(( + StatusCode::ACCEPTED, + "Community request submitted for approval".to_string(), + )); } } _ => {} // "open" mode - anyone can create @@ -132,7 +152,10 @@ async fn create_community( .await .map_err(|e| { if e.to_string().contains("duplicate key") { - (StatusCode::CONFLICT, "Community name or slug already exists".to_string()) + ( + StatusCode::CONFLICT, + "Community name or slug already exists".to_string(), + ) } else { (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) } @@ -153,7 +176,11 @@ async fn create_community( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - tracing::info!("Community '{}' created by user {}", community.name, auth.username); + tracing::info!( + "Community '{}' created by user {}", + community.name, + auth.username + ); Ok(Json(CommunityResponse::from(community))) } @@ -237,7 +264,10 @@ async fn join_community( return Err((StatusCode::BAD_REQUEST, err.to_string())); } - let role = filtered.get("role").and_then(|v| v.as_str()).unwrap_or("member"); + let role = filtered + .get("role") + .and_then(|v| v.as_str()) + .unwrap_or("member"); sqlx::query!( "INSERT INTO community_members (user_id, community_id, role) VALUES ($1, $2, $3)", @@ -254,12 +284,18 @@ async fn join_community( community_id: Some(community_id), actor_user_id: Some(auth.user_id), }; - let _ = plugins.do_action("member.join", ctx, serde_json::json!({ - "community_id": community_id.to_string(), - "user_id": auth.user_id.to_string(), - "username": auth.username.clone(), - "role": role, - })).await; + let _ = plugins + .do_action( + "member.join", + ctx, + serde_json::json!({ + "community_id": community_id.to_string(), + "user_id": auth.user_id.to_string(), + "username": auth.username.clone(), + "role": role, + }), + ) + .await; tracing::info!("User {} joined community {}", auth.username, community_id); Ok(Json(serde_json::json!({"status": "joined"}))) @@ -300,7 +336,10 @@ async fn leave_community( .unwrap_or(0); if admin_count <= 1 { - return Err((StatusCode::BAD_REQUEST, "Cannot leave: you are the only admin".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Cannot leave: you are the only admin".to_string(), + )); } } @@ -339,12 +378,18 @@ async fn leave_community( community_id: Some(community_id), actor_user_id: Some(auth.user_id), }; - let _ = plugins.do_action("member.leave", ctx, serde_json::json!({ - "community_id": community_id.to_string(), - "user_id": auth.user_id.to_string(), - "username": auth.username.clone(), - "role": role, - })).await; + let _ = plugins + .do_action( + "member.leave", + ctx, + serde_json::json!({ + "community_id": community_id.to_string(), + "user_id": auth.user_id.to_string(), + "username": auth.username.clone(), + "role": role, + }), + ) + .await; tracing::info!("User {} left community {}", auth.username, community_id); Ok(Json(serde_json::json!({"status": "left"}))) @@ -429,7 +474,12 @@ async fn my_communities( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(communities.into_iter().map(CommunityResponse::from).collect())) + Ok(Json( + communities + .into_iter() + .map(CommunityResponse::from) + .collect(), + )) } #[derive(Debug, Serialize)] @@ -458,21 +508,24 @@ async fn recent_activity( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let mut activities: Vec = proposals.into_iter().map(|p| { - let desc = match p.status.as_str() { - "voting" => "Now open for voting", - "discussion" => "Open for discussion", - "closed" => "Voting completed", - _ => "New proposal created", - }; - ActivityItem { - activity_type: "proposal".to_string(), - title: p.title, - description: desc.to_string(), - link: format!("/proposals/{}", p.id), - created_at: p.created_at, - } - }).collect(); + let mut activities: Vec = proposals + .into_iter() + .map(|p| { + let desc = match p.status.as_str() { + "voting" => "Now open for voting", + "discussion" => "Open for discussion", + "closed" => "Voting completed", + _ => "New proposal created", + }; + ActivityItem { + activity_type: "proposal".to_string(), + title: p.title, + description: desc.to_string(), + link: format!("/proposals/{}", p.id), + created_at: p.created_at, + } + }) + .collect(); let communities = sqlx::query!( "SELECT name, slug, created_at FROM communities WHERE is_active = true ORDER BY created_at DESC LIMIT 5" @@ -518,10 +571,16 @@ async fn update_community( .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::FORBIDDEN, "Not a member of this community".to_string()))?; + .ok_or(( + StatusCode::FORBIDDEN, + "Not a member of this community".to_string(), + ))?; if membership.role != "admin" { - return Err((StatusCode::FORBIDDEN, "Only admins can edit the community".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Only admins can edit the community".to_string(), + )); } let updated = sqlx::query_as!( diff --git a/backend/src/api/conflicts.rs b/backend/src/api/conflicts.rs index 0dc4604..f68c404 100644 --- a/backend/src/api/conflicts.rs +++ b/backend/src/api/conflicts.rs @@ -269,16 +269,37 @@ async fn add_to_mediator_pool( pub fn router(pool: PgPool) -> Router { Router::new() // Community conflicts - .route("/api/communities/{community_id}/conflicts", get(get_active_conflicts).post(report_conflict)) - .route("/api/communities/{community_id}/conflicts/stats", get(get_statistics)) - .route("/api/communities/{community_id}/mediators", post(add_to_mediator_pool)) + .route( + "/api/communities/{community_id}/conflicts", + get(get_active_conflicts).post(report_conflict), + ) + .route( + "/api/communities/{community_id}/conflicts/stats", + get(get_statistics), + ) + .route( + "/api/communities/{community_id}/mediators", + post(add_to_mediator_pool), + ) // Individual conflict operations .route("/api/conflicts/{conflict_id}", get(get_conflict)) - .route("/api/conflicts/{conflict_id}/status", post(transition_status)) - .route("/api/conflicts/{conflict_id}/compromise", post(propose_compromise)) - .route("/api/conflicts/{conflict_id}/session", post(schedule_session)) + .route( + "/api/conflicts/{conflict_id}/status", + post(transition_status), + ) + .route( + "/api/conflicts/{conflict_id}/compromise", + post(propose_compromise), + ) + .route( + "/api/conflicts/{conflict_id}/session", + post(schedule_session), + ) .route("/api/conflicts/{conflict_id}/note", post(add_note)) // Compromise responses - .route("/api/compromises/{proposal_id}/respond", post(respond_to_compromise)) + .route( + "/api/compromises/{proposal_id}/respond", + post(respond_to_compromise), + ) .with_state(pool) } diff --git a/backend/src/api/delegation.rs b/backend/src/api/delegation.rs index d2e6282..de98215 100644 --- a/backend/src/api/delegation.rs +++ b/backend/src/api/delegation.rs @@ -9,10 +9,10 @@ use axum::{ routing::{delete, get}, Json, Router, }; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; -use chrono::{DateTime, Utc}; use crate::auth::AuthUser; @@ -56,7 +56,9 @@ pub struct CreateDelegationRequest { pub weight: f64, } -fn default_weight() -> f64 { 1.0 } +fn default_weight() -> f64 { + 1.0 +} #[derive(Debug, Serialize)] pub struct DelegateProfile { @@ -134,35 +136,56 @@ async fn create_delegation( ) -> Result, (StatusCode, String)> { // Validate weight if req.weight <= 0.0 || req.weight > 1.0 { - return Err((StatusCode::BAD_REQUEST, "Weight must be between 0 and 1".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Weight must be between 0 and 1".to_string(), + )); } // Validate scope references to avoid DB constraint errors match req.scope { DelegationScope::Global => { if req.community_id.is_some() || req.topic_id.is_some() || req.proposal_id.is_some() { - return Err((StatusCode::BAD_REQUEST, "Global delegation cannot include community/topic/proposal".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Global delegation cannot include community/topic/proposal".to_string(), + )); } } DelegationScope::Community => { if req.community_id.is_none() { - return Err((StatusCode::BAD_REQUEST, "Community delegation requires community_id".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Community delegation requires community_id".to_string(), + )); } if req.topic_id.is_some() || req.proposal_id.is_some() { - return Err((StatusCode::BAD_REQUEST, "Community delegation cannot include topic_id/proposal_id".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Community delegation cannot include topic_id/proposal_id".to_string(), + )); } } DelegationScope::Topic => { if req.topic_id.is_none() { - return Err((StatusCode::BAD_REQUEST, "Topic delegation requires topic_id".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Topic delegation requires topic_id".to_string(), + )); } if req.proposal_id.is_some() { - return Err((StatusCode::BAD_REQUEST, "Topic delegation cannot include proposal_id".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Topic delegation cannot include proposal_id".to_string(), + )); } } DelegationScope::Proposal => { if req.proposal_id.is_none() { - return Err((StatusCode::BAD_REQUEST, "Proposal delegation requires proposal_id".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Proposal delegation requires proposal_id".to_string(), + )); } } } @@ -210,7 +233,10 @@ async fn create_delegation( if let Some(p) = profile { if !p.accepting_delegations { - return Err((StatusCode::BAD_REQUEST, "Delegate is not accepting delegations".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Delegate is not accepting delegations".to_string(), + )); } } @@ -340,21 +366,24 @@ async fn list_my_delegations( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(delegations.into_iter().map(|d| { - Delegation { - id: d.id, - delegator_id: d.delegator_id, - delegate_id: d.delegate_id, - delegate_username: Some(d.delegate_username), - scope: d.scope, - community_id: d.community_id, - topic_id: d.topic_id, - proposal_id: d.proposal_id, - weight: d.weight, - is_active: d.is_active, - created_at: d.created_at, - } - }).collect())) + Ok(Json( + delegations + .into_iter() + .map(|d| Delegation { + id: d.id, + delegator_id: d.delegator_id, + delegate_id: d.delegate_id, + delegate_username: Some(d.delegate_username), + scope: d.scope, + community_id: d.community_id, + topic_id: d.topic_id, + proposal_id: d.proposal_id, + weight: d.weight, + is_active: d.is_active, + created_at: d.created_at, + }) + .collect(), + )) } /// List delegations TO a user (they are the delegate) @@ -376,21 +405,24 @@ async fn list_delegations_to_me( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(delegations.into_iter().map(|d| { - Delegation { - id: d.id, - delegator_id: d.delegator_id, - delegate_id: d.delegate_id, - delegate_username: Some(d.delegator_username), - scope: d.scope, - community_id: d.community_id, - topic_id: d.topic_id, - proposal_id: d.proposal_id, - weight: d.weight, - is_active: d.is_active, - created_at: d.created_at, - } - }).collect())) + Ok(Json( + delegations + .into_iter() + .map(|d| Delegation { + id: d.id, + delegator_id: d.delegator_id, + delegate_id: d.delegate_id, + delegate_username: Some(d.delegator_username), + scope: d.scope, + community_id: d.community_id, + topic_id: d.topic_id, + proposal_id: d.proposal_id, + weight: d.weight, + is_active: d.is_active, + created_at: d.created_at, + }) + .collect(), + )) } /// Revoke a delegation @@ -552,16 +584,21 @@ async fn list_delegates( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(profiles.into_iter().map(|p| DelegateProfile { - user_id: p.user_id, - username: p.username, - display_name: p.display_name, - bio: p.bio, - accepting_delegations: p.accepting_delegations, - delegation_policy: p.delegation_policy, - total_delegators: p.total_delegators, - total_votes_cast: p.total_votes_cast, - }).collect())) + Ok(Json( + profiles + .into_iter() + .map(|p| DelegateProfile { + user_id: p.user_id, + username: p.username, + display_name: p.display_name, + bio: p.bio, + accepting_delegations: p.accepting_delegations, + delegation_policy: p.delegation_policy, + total_delegators: p.total_delegators, + total_votes_cast: p.total_votes_cast, + }) + .collect(), + )) } // ============================================================================ @@ -604,10 +641,16 @@ async fn create_topic( .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::FORBIDDEN, "Not a member of this community".to_string()))?; + .ok_or(( + StatusCode::FORBIDDEN, + "Not a member of this community".to_string(), + ))?; if membership.role != "admin" && membership.role != "moderator" { - return Err((StatusCode::FORBIDDEN, "Only admins/moderators can create topics".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Only admins/moderators can create topics".to_string(), + )); } let topic = sqlx::query_as!( @@ -635,13 +678,25 @@ async fn create_topic( pub fn router(pool: PgPool) -> Router { Router::new() // Delegations - .route("/api/delegations", get(list_my_delegations).post(create_delegation)) + .route( + "/api/delegations", + get(list_my_delegations).post(create_delegation), + ) .route("/api/delegations/to-me", get(list_delegations_to_me)) - .route("/api/delegations/{delegation_id}", delete(revoke_delegation)) + .route( + "/api/delegations/{delegation_id}", + delete(revoke_delegation), + ) // Delegate profiles .route("/api/delegates", get(list_delegates)) - .route("/api/delegates/me", get(get_my_profile).put(update_my_profile)) + .route( + "/api/delegates/me", + get(get_my_profile).put(update_my_profile), + ) // Topics - .route("/api/communities/{community_id}/topics", get(list_topics).post(create_topic)) + .route( + "/api/communities/{community_id}/topics", + get(list_topics).post(create_topic), + ) .with_state(pool) } diff --git a/backend/src/api/deliberation.rs b/backend/src/api/deliberation.rs index 8bb9710..9ed5ed2 100644 --- a/backend/src/api/deliberation.rs +++ b/backend/src/api/deliberation.rs @@ -6,14 +6,14 @@ use axum::{ routing::{get, post}, Json, Router, }; +use chrono::{DateTime, Utc}; 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}; +use crate::plugins::builtin::structured_deliberation::{Argument, DeliberationService, Summary}; // ============================================================================ // Types @@ -167,11 +167,14 @@ async fn add_resource( .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); + 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())); + return Err(( + StatusCode::FORBIDDEN, + "Not authorized to add resources".to_string(), + )); } let resource = sqlx::query_as!( @@ -246,11 +249,16 @@ async fn get_read_status( .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())) + 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 @@ -401,14 +409,10 @@ async fn list_arguments( 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()))?; + 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)) } @@ -421,12 +425,16 @@ async fn add_argument( 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()))?; + 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())); + return Err(( + StatusCode::FORBIDDEN, + "Must read proposal content before participating".to_string(), + )); } let argument_id = DeliberationService::add_argument( @@ -539,15 +547,20 @@ async fn check_can_participate( 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_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()))?; + 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 })) + Ok(Json(CanParticipateResponse { + can_comment, + can_vote, + })) } /// Get deliberation overview (metrics + top arguments + summaries) @@ -569,24 +582,51 @@ async fn get_overview( 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)) + .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)) + .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)) + .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/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/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)) + .route( + "/api/proposals/{proposal_id}/can-participate", + get(check_can_participate), + ) // Overview (combined metrics) - .route("/api/proposals/{proposal_id}/deliberation", get(get_overview)) + .route( + "/api/proposals/{proposal_id}/deliberation", + get(get_overview), + ) .with_state(pool) } diff --git a/backend/src/api/demo.rs b/backend/src/api/demo.rs index f748905..2c3c4eb 100644 --- a/backend/src/api/demo.rs +++ b/backend/src/api/demo.rs @@ -11,10 +11,10 @@ use serde_json::json; use sqlx::PgPool; use std::sync::Arc; +use super::permissions::{perms, require_permission}; use crate::auth::AuthUser; use crate::config::Config; use crate::demo::{self, DEMO_ACCOUNTS}; -use super::permissions::{require_permission, perms}; /// Combined state for demo endpoints #[derive(Clone)] @@ -24,9 +24,7 @@ pub struct DemoState { } /// Get demo mode status and available accounts -async fn get_demo_status( - State(state): State, -) -> impl IntoResponse { +async fn get_demo_status(State(state): State) -> impl IntoResponse { Json(json!({ "demo_mode": state.config.is_demo(), "accounts": if state.config.is_demo() { @@ -41,7 +39,7 @@ async fn get_demo_status( "restrictions": if state.config.is_demo() { vec![ "Cannot delete communities", - "Cannot delete users", + "Cannot delete users", "Cannot modify instance settings", "Data resets periodically" ] @@ -52,45 +50,42 @@ async fn get_demo_status( } /// Reset demo data to initial state (only in demo mode) -async fn reset_demo( - State(state): State, - auth: AuthUser, -) -> impl IntoResponse { +async fn reset_demo(State(state): State, auth: AuthUser) -> impl IntoResponse { if !state.config.is_demo() { return ( StatusCode::FORBIDDEN, - Json(json!({"error": "Demo mode not enabled"})) - ).into_response(); + Json(json!({"error": "Demo mode not enabled"})), + ) + .into_response(); } - if let Err((status, msg)) = require_permission(&state.pool, auth.user_id, perms::PLATFORM_ADMIN, None).await { + if let Err((status, msg)) = + require_permission(&state.pool, auth.user_id, perms::PLATFORM_ADMIN, None).await + { return (status, Json(json!({"error": msg}))).into_response(); } match demo::reset_demo_data(&state.pool).await { Ok(_) => ( StatusCode::OK, - Json(json!({"success": true, "message": "Demo data has been reset to initial state"})) - ).into_response(), + Json(json!({"success": true, "message": "Demo data has been reset to initial state"})), + ) + .into_response(), Err(e) => { tracing::error!("Failed to reset demo data: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "Failed to reset demo data"})) - ).into_response() + Json(json!({"error": "Failed to reset demo data"})), + ) + .into_response() } } } /// Get demo communities summary -async fn get_demo_communities( - State(state): State, -) -> impl IntoResponse { +async fn get_demo_communities(State(state): State) -> impl IntoResponse { if !state.config.is_demo() { - return ( - StatusCode::OK, - Json(json!({"communities": []})) - ).into_response(); + return (StatusCode::OK, Json(json!({"communities": []}))).into_response(); } let communities = sqlx::query_as::<_, (String, String, String, i64, i64)>( @@ -104,38 +99,42 @@ async fn get_demo_communities( FROM communities c WHERE c.slug IN ('aurora', 'civic-commons', 'makers') ORDER BY c.name - "# + "#, ) .fetch_all(&state.pool) .await; match communities { Ok(rows) => { - let communities: Vec<_> = rows.iter().map(|(name, slug, desc, members, proposals)| { - json!({ - "name": name, - "slug": slug, - "description": desc, - "member_count": members, - "proposal_count": proposals + let communities: Vec<_> = rows + .iter() + .map(|(name, slug, desc, members, proposals)| { + json!({ + "name": name, + "slug": slug, + "description": desc, + "member_count": members, + "proposal_count": proposals + }) }) - }).collect(); - + .collect(); + (StatusCode::OK, Json(json!({"communities": communities}))).into_response() } Err(e) => { tracing::error!("Failed to fetch demo communities: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "Failed to fetch communities"})) - ).into_response() + Json(json!({"error": "Failed to fetch communities"})), + ) + .into_response() } } } pub fn router(pool: PgPool, config: Arc) -> Router { let state = DemoState { pool, config }; - + Router::new() .route("/api/demo/status", get(get_demo_status)) .route("/api/demo/reset", post(reset_demo)) diff --git a/backend/src/api/exports.rs b/backend/src/api/exports.rs index 5d79da6..fc087f4 100644 --- a/backend/src/api/exports.rs +++ b/backend/src/api/exports.rs @@ -85,7 +85,10 @@ async fn get_job( pub fn router(pool: PgPool) -> Router { Router::new() - .route("/api/communities/{community_id}/exports", get(get_available).post(create_job)) + .route( + "/api/communities/{community_id}/exports", + get(get_available).post(create_job), + ) .route("/api/exports/{job_id}", get(get_job)) .with_state(pool) } diff --git a/backend/src/api/federation.rs b/backend/src/api/federation.rs index a82627d..15d02ab 100644 --- a/backend/src/api/federation.rs +++ b/backend/src/api/federation.rs @@ -176,7 +176,10 @@ async fn receive_federation_request( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if !community_exists { - return Err((StatusCode::NOT_FOUND, "Community not found or inactive".to_string())); + return Err(( + StatusCode::NOT_FOUND, + "Community not found or inactive".to_string(), + )); } let request_id = FederationService::request_federation( @@ -199,12 +202,30 @@ async fn receive_federation_request( pub fn router(pool: PgPool) -> Router { Router::new() // Instances - .route("/api/federation/instances", get(get_instances).post(register_instance)) - .route("/api/federation/instances/{instance_id}/trust", post(set_trust_level)) + .route( + "/api/federation/instances", + get(get_instances).post(register_instance), + ) + .route( + "/api/federation/instances/{instance_id}/trust", + post(set_trust_level), + ) // Community federations - .route("/api/communities/{community_id}/federation", get(get_community_federations).post(request_federation)) - .route("/api/communities/{community_id}/federation/stats", get(get_stats)) - .route("/api/communities/{community_id}/federation/request", post(receive_federation_request)) - .route("/api/federation/{federation_id}/approve", post(approve_federation)) + .route( + "/api/communities/{community_id}/federation", + get(get_community_federations).post(request_federation), + ) + .route( + "/api/communities/{community_id}/federation/stats", + get(get_stats), + ) + .route( + "/api/communities/{community_id}/federation/request", + post(receive_federation_request), + ) + .route( + "/api/federation/{federation_id}/approve", + post(approve_federation), + ) .with_state(pool) } diff --git a/backend/src/api/gitlab.rs b/backend/src/api/gitlab.rs index 36c5de9..f27a243 100644 --- a/backend/src/api/gitlab.rs +++ b/backend/src/api/gitlab.rs @@ -9,10 +9,10 @@ use axum::{ routing::{get, post}, Json, Router, }; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; -use chrono::{DateTime, Utc}; use crate::auth::AuthUser; @@ -148,12 +148,18 @@ async fn create_connection( .ok_or((StatusCode::FORBIDDEN, "Not a community member".to_string()))?; if membership.role != "admin" { - return Err((StatusCode::FORBIDDEN, "Only admins can configure GitLab".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Only admins can configure GitLab".to_string(), + )); } // Validate GitLab URL if !req.gitlab_url.starts_with("https://") { - return Err((StatusCode::BAD_REQUEST, "GitLab URL must use HTTPS".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "GitLab URL must use HTTPS".to_string(), + )); } let connection = sqlx::query!( @@ -212,17 +218,22 @@ async fn list_issues( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(issues.into_iter().map(|i| GitLabIssue { - id: i.id, - gitlab_iid: i.gitlab_iid, - title: i.title, - description: i.description, - state: i.state, - author_username: i.author_username, - labels: i.labels.unwrap_or_default(), - proposal_id: i.proposal_id, - gitlab_created_at: i.gitlab_created_at, - }).collect())) + Ok(Json( + issues + .into_iter() + .map(|i| GitLabIssue { + id: i.id, + gitlab_iid: i.gitlab_iid, + title: i.title, + description: i.description, + state: i.state, + author_username: i.author_username, + labels: i.labels.unwrap_or_default(), + proposal_id: i.proposal_id, + gitlab_created_at: i.gitlab_created_at, + }) + .collect(), + )) } /// List GitLab merge requests for a community @@ -245,19 +256,23 @@ async fn list_merge_requests( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(mrs.into_iter().map(|m| GitLabMergeRequest { - id: m.id, - gitlab_iid: m.gitlab_iid, - title: m.title, - description: m.description, - state: m.state, - author_username: m.author_username, - source_branch: m.source_branch, - target_branch: m.target_branch, - labels: m.labels.unwrap_or_default(), - proposal_id: m.proposal_id, - gitlab_created_at: m.gitlab_created_at, - }).collect())) + Ok(Json( + mrs.into_iter() + .map(|m| GitLabMergeRequest { + id: m.id, + gitlab_iid: m.gitlab_iid, + title: m.title, + description: m.description, + state: m.state, + author_username: m.author_username, + source_branch: m.source_branch, + target_branch: m.target_branch, + labels: m.labels.unwrap_or_default(), + proposal_id: m.proposal_id, + gitlab_created_at: m.gitlab_created_at, + }) + .collect(), + )) } /// Create proposal from GitLab issue @@ -292,7 +307,10 @@ async fn create_proposal_from_issue( .ok_or((StatusCode::NOT_FOUND, "Issue not found".to_string()))?; if issue.proposal_id.is_some() { - return Err((StatusCode::CONFLICT, "Issue already linked to a proposal".to_string())); + return Err(( + StatusCode::CONFLICT, + "Issue already linked to a proposal".to_string(), + )); } // Create proposal @@ -340,9 +358,21 @@ async fn create_proposal_from_issue( pub fn router(pool: PgPool) -> Router { Router::new() - .route("/api/communities/{community_id}/gitlab", get(get_connection).post(create_connection)) - .route("/api/communities/{community_id}/gitlab/issues", get(list_issues)) - .route("/api/communities/{community_id}/gitlab/merge-requests", get(list_merge_requests)) - .route("/api/communities/{community_id}/gitlab/issues/{issue_id}/create-proposal", post(create_proposal_from_issue)) + .route( + "/api/communities/{community_id}/gitlab", + get(get_connection).post(create_connection), + ) + .route( + "/api/communities/{community_id}/gitlab/issues", + get(list_issues), + ) + .route( + "/api/communities/{community_id}/gitlab/merge-requests", + get(list_merge_requests), + ) + .route( + "/api/communities/{community_id}/gitlab/issues/{issue_id}/create-proposal", + post(create_proposal_from_issue), + ) .with_state(pool) } diff --git a/backend/src/api/invitations.rs b/backend/src/api/invitations.rs index d2f43b7..3939f11 100644 --- a/backend/src/api/invitations.rs +++ b/backend/src/api/invitations.rs @@ -3,16 +3,16 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, - routing::{get, delete}, + routing::{delete, get}, Json, Router, }; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; -use chrono::{DateTime, Utc}; +use super::permissions::{perms, require_permission, user_has_permission}; use crate::auth::AuthUser; -use super::permissions::{require_permission, user_has_permission, perms}; // ============================================================================ // Types @@ -43,7 +43,9 @@ pub struct CreateInvitationRequest { pub expires_in_hours: Option, } -fn default_max_uses() -> Option { Some(1) } +fn default_max_uses() -> Option { + Some(1) +} #[derive(Debug, Deserialize)] pub struct ListInvitationsQuery { @@ -83,12 +85,15 @@ async fn create_invitation( .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate invitation code".to_string()))?; + .ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to generate invitation code".to_string(), + ))?; // Calculate expiration - let expires_at = req.expires_in_hours.map(|h| { - Utc::now() + chrono::Duration::hours(h as i64) - }); + let expires_at = req + .expires_in_hours + .map(|h| Utc::now() + chrono::Duration::hours(h as i64)); let invite = sqlx::query!( r#"INSERT INTO invitations (code, created_by, email, community_id, max_uses, expires_at) @@ -167,20 +172,25 @@ async fn list_invitations( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(invites.into_iter().map(|i| Invitation { - id: i.id, - code: i.code, - created_by: i.created_by, - created_by_username: Some(i.creator_username), - email: i.email, - community_id: i.community_id, - community_name: Some(i.community_name), - max_uses: i.max_uses, - uses_count: i.uses_count.unwrap_or(0), - expires_at: i.expires_at, - is_active: i.is_active.unwrap_or(true), - created_at: i.created_at.unwrap_or_else(Utc::now), - }).collect())) + Ok(Json( + invites + .into_iter() + .map(|i| Invitation { + id: i.id, + code: i.code, + created_by: i.created_by, + created_by_username: Some(i.creator_username), + email: i.email, + community_id: i.community_id, + community_name: Some(i.community_name), + max_uses: i.max_uses, + uses_count: i.uses_count.unwrap_or(0), + expires_at: i.expires_at, + is_active: i.is_active.unwrap_or(true), + created_at: i.created_at.unwrap_or_else(Utc::now), + }) + .collect(), + )) } /// Validate an invitation code (public endpoint for registration) @@ -247,22 +257,31 @@ async fn revoke_invitation( Path(invitation_id): Path, ) -> Result, (StatusCode, String)> { // Check ownership or admin - let invite = sqlx::query!("SELECT created_by FROM invitations WHERE id = $1", invitation_id) - .fetch_optional(&pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::NOT_FOUND, "Invitation not found".to_string()))?; + let invite = sqlx::query!( + "SELECT created_by FROM invitations WHERE id = $1", + invitation_id + ) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Invitation not found".to_string()))?; let is_admin = user_has_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?; - + if invite.created_by != auth.user_id && !is_admin { - return Err((StatusCode::FORBIDDEN, "Not authorized to revoke this invitation".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Not authorized to revoke this invitation".to_string(), + )); } - sqlx::query!("UPDATE invitations SET is_active = FALSE WHERE id = $1", invitation_id) - .execute(&pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + sqlx::query!( + "UPDATE invitations SET is_active = FALSE WHERE id = $1", + invitation_id + ) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(serde_json::json!({"success": true}))) } @@ -273,7 +292,10 @@ async fn revoke_invitation( pub fn router(pool: PgPool) -> Router { Router::new() - .route("/api/invitations", get(list_invitations).post(create_invitation)) + .route( + "/api/invitations", + get(list_invitations).post(create_invitation), + ) .route("/api/invitations/validate/{code}", get(validate_invitation)) .route("/api/invitations/{id}", delete(revoke_invitation)) .with_state(pool) diff --git a/backend/src/api/lifecycle.rs b/backend/src/api/lifecycle.rs index 08d543a..d82cbf8 100644 --- a/backend/src/api/lifecycle.rs +++ b/backend/src/api/lifecycle.rs @@ -91,9 +91,10 @@ async fn compare_versions( State(pool): State, Json(req): Json, ) -> Result, (StatusCode, String)> { - let diff = LifecycleService::compare_versions(&pool, proposal_id, req.from_version, req.to_version) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let diff = + LifecycleService::compare_versions(&pool, proposal_id, req.from_version, req.to_version) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(diff)) } @@ -203,9 +204,15 @@ async fn vote_amendment( State(pool): State, Json(req): Json, ) -> Result, (StatusCode, String)> { - LifecycleService::vote_amendment(&pool, amendment_id, auth.user_id, &req.vote, req.comment.as_deref()) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + LifecycleService::vote_amendment( + &pool, + amendment_id, + auth.user_id, + &req.vote, + req.comment.as_deref(), + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(serde_json::json!({"success": true}))) } @@ -232,16 +239,37 @@ pub fn router(pool: PgPool) -> Router { Router::new() // Versions .route("/api/proposals/{proposal_id}/versions", get(get_versions)) - .route("/api/proposals/{proposal_id}/versions/{version_number}", get(get_version)) - .route("/api/proposals/{proposal_id}/versions/compare", post(compare_versions)) + .route( + "/api/proposals/{proposal_id}/versions/{version_number}", + get(get_version), + ) + .route( + "/api/proposals/{proposal_id}/versions/compare", + post(compare_versions), + ) // Lifecycle - .route("/api/proposals/{proposal_id}/lifecycle", get(get_lifecycle_summary)) - .route("/api/proposals/{proposal_id}/lifecycle/transition", post(transition_status)) - .route("/api/proposals/{proposal_id}/lifecycle/fork", post(fork_proposal)) + .route( + "/api/proposals/{proposal_id}/lifecycle", + get(get_lifecycle_summary), + ) + .route( + "/api/proposals/{proposal_id}/lifecycle/transition", + post(transition_status), + ) + .route( + "/api/proposals/{proposal_id}/lifecycle/fork", + post(fork_proposal), + ) .route("/api/proposals/{proposal_id}/forks", get(get_forks)) // Amendments - .route("/api/proposals/{proposal_id}/amendments", get(get_amendments).post(propose_amendment)) + .route( + "/api/proposals/{proposal_id}/amendments", + get(get_amendments).post(propose_amendment), + ) .route("/api/amendments/{amendment_id}/vote", post(vote_amendment)) - .route("/api/amendments/{amendment_id}/accept", post(accept_amendment)) + .route( + "/api/amendments/{amendment_id}/accept", + post(accept_amendment), + ) .with_state(pool) } diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index 013e92d..ee344d4 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -4,8 +4,8 @@ pub mod auth; pub mod comments; pub mod communities; pub mod conflicts; -pub mod deliberation; pub mod delegation; +pub mod deliberation; pub mod demo; pub mod exports; pub mod federation; diff --git a/backend/src/api/moderation.rs b/backend/src/api/moderation.rs index 1dec376..b8554bd 100644 --- a/backend/src/api/moderation.rs +++ b/backend/src/api/moderation.rs @@ -4,13 +4,13 @@ use axum::{ routing::get, Json, Router, }; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; -use chrono::{DateTime, Utc}; +use crate::api::permissions::{perms, require_permission}; use crate::auth::AuthUser; -use crate::api::permissions::{require_permission, perms}; #[derive(Debug, Serialize)] pub struct ModerationEntry { @@ -34,7 +34,10 @@ pub struct CreateModerationEntry { pub fn router(pool: PgPool) -> Router { Router::new() - .route("/api/communities/{community_id}/moderation", get(list_moderation).post(create_moderation)) + .route( + "/api/communities/{community_id}/moderation", + get(list_moderation).post(create_moderation), + ) .with_state(pool) } @@ -44,7 +47,13 @@ async fn list_moderation( State(pool): State, ) -> Result>, (StatusCode, String)> { // Require permission to view moderation reports - require_permission(&pool, auth.user_id, perms::MOD_VIEW_REPORTS, Some(community_id)).await?; + require_permission( + &pool, + auth.user_id, + perms::MOD_VIEW_REPORTS, + Some(community_id), + ) + .await?; let entries = sqlx::query!( r#" @@ -93,22 +102,49 @@ async fn create_moderation( // Check specific permission based on action type let has_permission = match req.action_type.as_str() { "ban" | "unban" | "suspend" | "unsuspend" => { - user_has_permission(&pool, auth.user_id, perms::MOD_BAN_USERS, Some(community_id)).await? + user_has_permission( + &pool, + auth.user_id, + perms::MOD_BAN_USERS, + Some(community_id), + ) + .await? } "remove_content" | "hide_content" | "restore_content" => { - user_has_permission(&pool, auth.user_id, perms::MOD_REMOVE_CONTENT, Some(community_id)).await? + user_has_permission( + &pool, + auth.user_id, + perms::MOD_REMOVE_CONTENT, + Some(community_id), + ) + .await? } "admin_action" | "settings_change" => { - user_has_permission(&pool, auth.user_id, perms::COMMUNITY_ADMIN, Some(community_id)).await? + user_has_permission( + &pool, + auth.user_id, + perms::COMMUNITY_ADMIN, + Some(community_id), + ) + .await? } _ => { // Default to requiring community.moderate permission - user_has_permission(&pool, auth.user_id, perms::COMMUNITY_MODERATE, Some(community_id)).await? + user_has_permission( + &pool, + auth.user_id, + perms::COMMUNITY_MODERATE, + Some(community_id), + ) + .await? } }; if !has_permission { - return Err((StatusCode::FORBIDDEN, format!("Permission required for action '{}'", req.action_type))); + return Err(( + StatusCode::FORBIDDEN, + format!("Permission required for action '{}'", req.action_type), + )); } let entry = sqlx::query!( diff --git a/backend/src/api/moderation_ledger.rs b/backend/src/api/moderation_ledger.rs index a953675..5175fc4 100644 --- a/backend/src/api/moderation_ledger.rs +++ b/backend/src/api/moderation_ledger.rs @@ -17,7 +17,10 @@ pub fn router(pool: PgPool) -> Router { Router::new() .route("/api/ledger", get(list_entries)) .route("/api/ledger/entry/{id}", get(get_entry)) - .route("/api/ledger/target/{target_type}/{target_id}", get(get_target_history)) + .route( + "/api/ledger/target/{target_type}/{target_id}", + get(get_target_history), + ) .route("/api/ledger/verify", get(verify_chain)) .route("/api/ledger/stats", get(get_stats)) .route("/api/ledger/export", get(export_ledger)) @@ -151,14 +154,12 @@ async fn get_entry( Path(id): Path, ) -> Result)> { // Use LedgerService for consistency - let entry = LedgerService::get_entry(&pool, id) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": e.to_string()})), - ) - })?; + let entry = LedgerService::get_entry(&pool, id).await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ) + })?; match entry { Some(e) => { @@ -367,9 +368,8 @@ async fn create_entry( let actor_role = get_actor_role(&pool, auth.user_id, req.community_id).await?; - let action_type = parse_action_type(&req.action_type).map_err(|e| { - (StatusCode::BAD_REQUEST, Json(json!({"error": e}))) - })?; + let action_type = parse_action_type(&req.action_type) + .map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({"error": e}))))?; let entry_id = LedgerService::create_entry( &pool, diff --git a/backend/src/api/notifications.rs b/backend/src/api/notifications.rs index 635811b..ba1711f 100644 --- a/backend/src/api/notifications.rs +++ b/backend/src/api/notifications.rs @@ -49,15 +49,18 @@ async fn list_notifications( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let result = notifications.into_iter().map(|n| Notification { - id: n.id, - notification_type: n.notification_type, - title: n.title, - message: n.message, - link: n.link, - is_read: n.is_read, - created_at: n.created_at, - }).collect(); + let result = notifications + .into_iter() + .map(|n| Notification { + id: n.id, + notification_type: n.notification_type, + title: n.title, + message: n.message, + link: n.link, + is_read: n.is_read, + created_at: n.created_at, + }) + .collect(); Ok(Json(result)) } diff --git a/backend/src/api/permissions.rs b/backend/src/api/permissions.rs index befd765..cc10917 100644 --- a/backend/src/api/permissions.rs +++ b/backend/src/api/permissions.rs @@ -57,16 +57,16 @@ pub async fn require_any_permission( } Err(( StatusCode::FORBIDDEN, - format!("One of these permissions required: {}", permissions.join(", ")), + format!( + "One of these permissions required: {}", + permissions.join(", ") + ), )) } /// Check if user is a platform admin (has platform.admin permission). #[allow(dead_code)] -pub async fn is_platform_admin( - pool: &PgPool, - user_id: Uuid, -) -> Result { +pub async fn is_platform_admin(pool: &PgPool, user_id: Uuid) -> Result { user_has_permission(pool, user_id, "platform.admin", None).await } @@ -86,7 +86,8 @@ pub async fn is_community_staff( user_id: Uuid, community_id: Uuid, ) -> Result { - let is_admin = user_has_permission(pool, user_id, "community.settings", Some(community_id)).await?; + let is_admin = + user_has_permission(pool, user_id, "community.settings", Some(community_id)).await?; if is_admin { return Ok(true); } @@ -100,13 +101,13 @@ pub mod perms { pub const PLATFORM_ADMIN: &str = "platform.admin"; pub const PLATFORM_SETTINGS: &str = "platform.settings"; pub const PLATFORM_PLUGINS: &str = "plugins.configure"; - + // Community-level pub const COMMUNITY_CREATE: &str = "community.create"; pub const COMMUNITY_ADMIN: &str = "community.settings"; pub const COMMUNITY_SETTINGS: &str = "community.settings"; pub const COMMUNITY_MODERATE: &str = "moderation.users.warn"; - + // Proposals pub const PROPOSAL_CREATE: &str = "proposals.create"; pub const PROPOSAL_EDIT_OWN: &str = "proposals.edit.own"; @@ -114,17 +115,17 @@ pub mod perms { pub const PROPOSAL_DELETE_OWN: &str = "proposals.delete.own"; pub const PROPOSAL_DELETE_ANY: &str = "proposals.delete.any"; pub const PROPOSAL_MANAGE_STATUS: &str = "proposals.moderate"; - + // Voting pub const VOTE_CAST: &str = "voting.vote"; pub const VOTE_VIEW_RESULTS: &str = "voting.results.view"; pub const VOTING_CONFIG: &str = "voting.configure"; - + // Moderation pub const MOD_BAN_USERS: &str = "platform.users.ban"; pub const MOD_REMOVE_CONTENT: &str = "moderation.comments.delete"; pub const MOD_VIEW_REPORTS: &str = "moderation.log.view"; - + // Users pub const USER_MANAGE: &str = "platform.users.manage"; pub const USER_INVITE: &str = "community.members.invite"; diff --git a/backend/src/api/plugins.rs b/backend/src/api/plugins.rs index 7458484..f357f0f 100644 --- a/backend/src/api/plugins.rs +++ b/backend/src/api/plugins.rs @@ -2,26 +2,25 @@ use axum::{ extract::{Path, State}, http::StatusCode, routing::{get, post, put}, - Extension, - Json, Router, + Extension, Json, Router, }; use base64::{engine::general_purpose, Engine as _}; use chrono::{DateTime, Utc}; use ed25519_dalek::{Signature, VerifyingKey}; use jsonschema::{Draft, JSONSchema}; +use reqwest::Url; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use sqlx::PgPool; use sqlx::Row; use std::net::IpAddr; use std::sync::Arc; use uuid::Uuid; -use sha2::{Digest, Sha256}; -use reqwest::Url; use crate::auth::AuthUser; -use crate::plugins::PluginManager; use crate::plugins::wasm::host_api::PluginManifest; +use crate::plugins::PluginManager; #[derive(Debug, Serialize)] pub struct CommunityPluginInfo { @@ -51,7 +50,12 @@ async fn get_plugin_policy( match membership { Some(m) if m.role == "admin" || m.role == "moderator" => {} - _ => return Err((StatusCode::FORBIDDEN, "Must be admin or moderator".to_string())), + _ => { + return Err(( + StatusCode::FORBIDDEN, + "Must be admin or moderator".to_string(), + )) + } } let row = sqlx::query!( @@ -91,7 +95,12 @@ async fn update_plugin_policy( match membership { Some(m) if m.role == "admin" || m.role == "moderator" => {} - _ => return Err((StatusCode::FORBIDDEN, "Must be admin or moderator".to_string())), + _ => { + return Err(( + StatusCode::FORBIDDEN, + "Must be admin or moderator".to_string(), + )) + } } let current = sqlx::query!( @@ -160,9 +169,16 @@ async fn update_plugin_policy( trust_policy: parse_trust_policy(¤t.settings), install_sources: parse_install_sources(¤t.settings), allow_outbound_http: parse_bool(¤t.settings, "plugin_allow_outbound_http", false), - http_egress_allowlist: parse_string_list(¤t.settings, "plugin_http_egress_allowlist"), + http_egress_allowlist: parse_string_list( + ¤t.settings, + "plugin_http_egress_allowlist", + ), registry_allowlist: parse_string_list(¤t.settings, "plugin_registry_allowlist"), - allow_background_jobs: parse_bool(¤t.settings, "plugin_allow_background_jobs", false), + allow_background_jobs: parse_bool( + ¤t.settings, + "plugin_allow_background_jobs", + false, + ), trusted_publishers: parse_string_list(¤t.settings, "plugin_trusted_publishers"), })); } @@ -402,7 +418,10 @@ fn verify_signature_if_required( if matches!(trust_policy, PluginTrustPolicy::SignedOnly) { if trusted_publishers.is_empty() { - return Err((StatusCode::FORBIDDEN, "No trusted publishers configured".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "No trusted publishers configured".to_string(), + )); } if !trusted_publishers.iter().any(|p| p == publisher) { return Err((StatusCode::FORBIDDEN, "Publisher not trusted".to_string())); @@ -425,29 +444,44 @@ fn verify_signature_if_required( } fn enforce_registry_allowlist(url: &Url, allowlist: &[String]) -> Result<(), (StatusCode, String)> { - let host = url - .host_str() - .ok_or((StatusCode::BAD_REQUEST, "Registry URL must include host".to_string()))?; + let host = url.host_str().ok_or(( + StatusCode::BAD_REQUEST, + "Registry URL must include host".to_string(), + ))?; if let Ok(ip) = host.parse::() { let is_disallowed = match ip { - IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified(), + IpAddr::V4(v4) => { + v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified() + } IpAddr::V6(v6) => { - v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local() || v6.is_unspecified() + v6.is_loopback() + || v6.is_unique_local() + || v6.is_unicast_link_local() + || v6.is_unspecified() } }; if is_disallowed { - return Err((StatusCode::FORBIDDEN, "Registry host is not allowed".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Registry host is not allowed".to_string(), + )); } } if host.eq_ignore_ascii_case("localhost") { - return Err((StatusCode::FORBIDDEN, "Registry host is not allowed".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Registry host is not allowed".to_string(), + )); } if !allowlist.is_empty() && !allowlist.iter().any(|h| h == host) { - return Err((StatusCode::FORBIDDEN, "Registry host not in allowlist".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Registry host not in allowlist".to_string(), + )); } Ok(()) @@ -469,7 +503,10 @@ async fn ensure_admin_or_moderator( match membership { Some(m) if m.role == "admin" || m.role == "moderator" => Ok(()), - _ => Err((StatusCode::FORBIDDEN, "Must be admin or moderator".to_string())), + _ => Err(( + StatusCode::FORBIDDEN, + "Must be admin or moderator".to_string(), + )), } } @@ -610,7 +647,10 @@ async fn update_community_plugin_package( .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .ok_or((StatusCode::NOT_FOUND, "Package not installed for community".to_string()))?; + .ok_or(( + StatusCode::NOT_FOUND, + "Package not installed for community".to_string(), + ))?; let link_is_active: bool = link_row .try_get("is_active") @@ -622,8 +662,12 @@ async fn update_community_plugin_package( .try_get("installed_at") .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let manifest: PluginManifest = serde_json::from_value(pkg.manifest.clone()) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Stored manifest invalid: {e}")))?; + let manifest: PluginManifest = serde_json::from_value(pkg.manifest.clone()).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Stored manifest invalid: {e}"), + ) + })?; if let Some(settings) = &req.settings { if let Some(schema) = &manifest.settings_schema { @@ -632,7 +676,10 @@ async fn update_community_plugin_package( .with_draft(Draft::Draft7) .compile(schema) .map_err(|e| { - (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid settings schema: {e}")) + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Invalid settings schema: {e}"), + ) })?; if !compiled.is_valid(settings) { @@ -792,7 +839,10 @@ async fn upload_plugin_package( let trusted_publishers = parse_string_list(&settings, "plugin_trusted_publishers"); if !sources.contains(&PluginInstallSource::Upload) { - return Err((StatusCode::FORBIDDEN, "Upload installs are disabled by policy".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Upload installs are disabled by policy".to_string(), + )); } let publisher = req.publisher.unwrap_or_default(); @@ -980,7 +1030,10 @@ async fn install_registry_plugin_package( let registry_allowlist = parse_string_list(&settings, "plugin_registry_allowlist"); if !sources.contains(&PluginInstallSource::Registry) { - return Err((StatusCode::FORBIDDEN, "Registry installs are disabled by policy".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Registry installs are disabled by policy".to_string(), + )); } let url = Url::parse(&req.url) @@ -988,7 +1041,12 @@ async fn install_registry_plugin_package( match url.scheme() { "https" | "http" => {} - _ => return Err((StatusCode::BAD_REQUEST, "Invalid registry URL scheme".to_string())), + _ => { + return Err(( + StatusCode::BAD_REQUEST, + "Invalid registry URL scheme".to_string(), + )) + } } enforce_registry_allowlist(&url, ®istry_allowlist)?; @@ -1001,13 +1059,20 @@ async fn install_registry_plugin_package( return Err((StatusCode::BAD_GATEWAY, "Registry fetch failed".to_string())); } - let bundle: UploadPluginPackageRequest = res - .json() - .await - .map_err(|_| (StatusCode::BAD_GATEWAY, "Invalid registry response".to_string()))?; + let bundle: UploadPluginPackageRequest = res.json().await.map_err(|_| { + ( + StatusCode::BAD_GATEWAY, + "Invalid registry response".to_string(), + ) + })?; - let parsed_manifest: PluginManifest = serde_json::from_value(bundle.manifest.clone()) - .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Registry returned invalid manifest: {e}")))?; + let parsed_manifest: PluginManifest = + serde_json::from_value(bundle.manifest.clone()).map_err(|e| { + ( + StatusCode::BAD_GATEWAY, + format!("Registry returned invalid manifest: {e}"), + ) + })?; if parsed_manifest.name != bundle.name { return Err(( @@ -1177,10 +1242,7 @@ async fn install_registry_plugin_package( } fn parse_trust_policy(settings: &Value) -> PluginTrustPolicy { - match settings - .get("plugin_trust_policy") - .and_then(|v| v.as_str()) - { + match settings.get("plugin_trust_policy").and_then(|v| v.as_str()) { Some("unsigned_allowed") => PluginTrustPolicy::UnsignedAllowed, _ => PluginTrustPolicy::SignedOnly, } @@ -1194,7 +1256,10 @@ fn trust_policy_str(policy: PluginTrustPolicy) -> &'static str { } fn parse_install_sources(settings: &Value) -> Vec { - let Some(arr) = settings.get("plugin_install_sources").and_then(|v| v.as_array()) else { + let Some(arr) = settings + .get("plugin_install_sources") + .and_then(|v| v.as_array()) + else { return vec![PluginInstallSource::Upload, PluginInstallSource::Registry]; }; @@ -1235,7 +1300,10 @@ fn install_sources_json(sources: &[PluginInstallSource]) -> Value { } fn parse_bool(settings: &Value, key: &str, default: bool) -> bool { - settings.get(key).and_then(|v| v.as_bool()).unwrap_or(default) + settings + .get(key) + .and_then(|v| v.as_bool()) + .unwrap_or(default) } fn parse_string_list(settings: &Value, key: &str) -> Vec { @@ -1268,7 +1336,12 @@ async fn list_community_plugins( match membership { Some(m) if m.role == "admin" || m.role == "moderator" => {} - _ => return Err((StatusCode::FORBIDDEN, "Must be admin or moderator".to_string())), + _ => { + return Err(( + StatusCode::FORBIDDEN, + "Must be admin or moderator".to_string(), + )) + } } let rows = sqlx::query!( @@ -1334,7 +1407,12 @@ async fn update_community_plugin( match membership { Some(m) if m.role == "admin" || m.role == "moderator" => {} - _ => return Err((StatusCode::FORBIDDEN, "Must be admin or moderator".to_string())), + _ => { + return Err(( + StatusCode::FORBIDDEN, + "Must be admin or moderator".to_string(), + )) + } } let plugin = sqlx::query!( diff --git a/backend/src/api/proposals.rs b/backend/src/api/proposals.rs index 272d3dc..a689248 100644 --- a/backend/src/api/proposals.rs +++ b/backend/src/api/proposals.rs @@ -2,8 +2,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, routing::{get, post}, - Extension, - Json, Router, + Extension, Json, Router, }; use serde::Deserialize; use sqlx::PgPool; @@ -11,20 +10,36 @@ use std::sync::Arc; use uuid::Uuid; use crate::auth::AuthUser; -use crate::models::proposal::{CreateProposal, Proposal, ProposalOptionWithVotes, ProposalWithOptions}; +use crate::models::proposal::{ + CreateProposal, Proposal, ProposalOptionWithVotes, ProposalWithOptions, +}; use crate::plugins::{HookContext, PluginError, PluginManager}; pub fn router(pool: PgPool) -> Router { Router::new() .route("/api/proposals", get(list_all_proposals)) .route("/api/proposals/my", get(my_proposals)) - .route("/api/communities/{community_id}/proposals", get(list_proposals).post(create_proposal)) - .route("/api/proposals/{id}", get(get_proposal).delete(delete_proposal).put(update_proposal)) + .route( + "/api/communities/{community_id}/proposals", + get(list_proposals).post(create_proposal), + ) + .route( + "/api/proposals/{id}", + get(get_proposal) + .delete(delete_proposal) + .put(update_proposal), + ) .route("/api/proposals/{id}/vote", post(cast_vote)) .route("/api/proposals/{id}/vote/ranked", post(cast_ranked_vote)) - .route("/api/proposals/{id}/vote/quadratic", post(cast_quadratic_vote)) + .route( + "/api/proposals/{id}/vote/quadratic", + post(cast_quadratic_vote), + ) .route("/api/proposals/{id}/vote/star", post(cast_star_vote)) - .route("/api/proposals/{id}/start-discussion", post(start_discussion)) + .route( + "/api/proposals/{id}/start-discussion", + post(start_discussion), + ) .route("/api/proposals/{id}/start-voting", post(start_voting)) .route("/api/proposals/{id}/close-voting", post(close_voting)) .route("/api/proposals/{id}/results", get(get_voting_results)) @@ -66,17 +81,20 @@ async fn list_all_proposals( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let result = proposals.into_iter().map(|p| ProposalWithCommunity { - id: p.id, - title: p.title, - description: p.description, - status: p.status, - community_name: p.community_name, - community_slug: p.community_slug, - vote_count: p.vote_count.unwrap_or(0), - comment_count: p.comment_count.unwrap_or(0), - created_at: p.created_at, - }).collect(); + let result = proposals + .into_iter() + .map(|p| ProposalWithCommunity { + id: p.id, + title: p.title, + description: p.description, + status: p.status, + community_name: p.community_name, + community_slug: p.community_slug, + vote_count: p.vote_count.unwrap_or(0), + comment_count: p.comment_count.unwrap_or(0), + created_at: p.created_at, + }) + .collect(); Ok(Json(result)) } @@ -102,17 +120,20 @@ async fn my_proposals( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let result = proposals.into_iter().map(|p| ProposalWithCommunity { - id: p.id, - title: p.title, - description: p.description, - status: p.status, - community_name: p.community_name, - community_slug: p.community_slug, - vote_count: p.vote_count.unwrap_or(0), - comment_count: p.comment_count.unwrap_or(0), - created_at: p.created_at, - }).collect(); + let result = proposals + .into_iter() + .map(|p| ProposalWithCommunity { + id: p.id, + title: p.title, + description: p.description, + status: p.status, + community_name: p.community_name, + community_slug: p.community_slug, + vote_count: p.vote_count.unwrap_or(0), + comment_count: p.comment_count.unwrap_or(0), + created_at: p.created_at, + }) + .collect(); Ok(Json(result)) } @@ -147,10 +168,16 @@ async fn create_proposal( Extension(plugins): Extension>, Json(req): Json, ) -> Result, (StatusCode, String)> { - use crate::api::permissions::{require_permission, perms}; + use crate::api::permissions::{perms, require_permission}; // Require proposal.create permission in community - require_permission(&pool, auth.user_id, perms::PROPOSAL_CREATE, Some(community_id)).await?; + require_permission( + &pool, + auth.user_id, + perms::PROPOSAL_CREATE, + Some(community_id), + ) + .await?; let filtered = plugins .apply_filters( @@ -225,7 +252,10 @@ async fn create_proposal( .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Start transaction - let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let mut tx = pool + .begin() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Create proposal with community's default voting method let proposal = sqlx::query_as!( @@ -260,7 +290,9 @@ async fn create_proposal( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; } - tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + tx.commit() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; tracing::info!("Proposal '{}' created by {}", proposal.title, auth.username); Ok(Json(proposal)) @@ -285,10 +317,13 @@ async fn get_proposal( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?; - let author = sqlx::query_scalar!("SELECT username FROM users WHERE id = $1", proposal.author_id) - .fetch_one(&pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let author = sqlx::query_scalar!( + "SELECT username FROM users WHERE id = $1", + proposal.author_id + ) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let options = sqlx::query!( r#"SELECT o.id, o.label, o.description, COUNT(v.id) as vote_count @@ -363,7 +398,7 @@ async fn cast_vote( Extension(plugins): Extension>, Json(req): Json, ) -> Result, (StatusCode, String)> { - use crate::api::permissions::{require_permission, perms}; + use crate::api::permissions::{perms, require_permission}; let proposal = sqlx::query_as!( Proposal, @@ -381,10 +416,19 @@ async fn cast_vote( .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?; // Require vote.cast permission in community - require_permission(&pool, auth.user_id, perms::VOTE_CAST, Some(proposal.community_id)).await?; + require_permission( + &pool, + auth.user_id, + perms::VOTE_CAST, + Some(proposal.community_id), + ) + .await?; if !matches!(proposal.status, crate::models::ProposalStatus::Voting) { - return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Proposal is not in voting phase".to_string(), + )); } let vote_payload = serde_json::json!({ @@ -427,7 +471,10 @@ async fn cast_vote( RETURNING id"#, auth.user_id, proposal.community_id, - format!("voter-{}", uuid::Uuid::new_v4().to_string()[..8].to_string()) + format!( + "voter-{}", + uuid::Uuid::new_v4().to_string()[..8].to_string() + ) ) .fetch_one(&pool) .await @@ -459,10 +506,16 @@ async fn cast_vote( community_id: Some(proposal.community_id), actor_user_id: Some(auth.user_id), }; - let _ = plugins.do_action("vote.cast", ctx, serde_json::json!({ - "proposal_id": proposal_id.to_string(), - "voter_id": auth.user_id.to_string(), - })).await; + let _ = plugins + .do_action( + "vote.cast", + ctx, + serde_json::json!({ + "proposal_id": proposal_id.to_string(), + "voter_id": auth.user_id.to_string(), + }), + ) + .await; Ok(Json(serde_json::json!({"status": "voted"}))) } @@ -483,11 +536,17 @@ async fn cast_ranked_vote( .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?; if !matches!(proposal.status, crate::models::ProposalStatus::Voting) { - return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Proposal is not in voting phase".to_string(), + )); } if proposal.voting_method != "ranked_choice" { - return Err((StatusCode::BAD_REQUEST, "This proposal uses a different voting method".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "This proposal uses a different voting method".to_string(), + )); } let voting_identity = sqlx::query_scalar!( @@ -497,15 +556,24 @@ async fn cast_ranked_vote( RETURNING id"#, auth.user_id, proposal.community_id, - format!("voter-{}", uuid::Uuid::new_v4().to_string()[..8].to_string()) + format!( + "voter-{}", + uuid::Uuid::new_v4().to_string()[..8].to_string() + ) ) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Clear existing ranked votes - sqlx::query!("DELETE FROM ranked_votes WHERE proposal_id = $1 AND voter_id = $2", proposal_id, voting_identity) - .execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + sqlx::query!( + "DELETE FROM ranked_votes WHERE proposal_id = $1 AND voter_id = $2", + proposal_id, + voting_identity + ) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Insert ranked votes for ranking in req.rankings { @@ -515,7 +583,9 @@ async fn cast_ranked_vote( ).execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; } - Ok(Json(serde_json::json!({"status": "voted", "method": "ranked_choice"}))) + Ok(Json( + serde_json::json!({"status": "voted", "method": "ranked_choice"}), + )) } async fn cast_quadratic_vote( @@ -524,7 +594,7 @@ async fn cast_quadratic_vote( State(pool): State, Json(req): Json, ) -> Result, (StatusCode, String)> { - use crate::voting::quadratic::{vote_cost, max_votes_for_credits}; + use crate::voting::quadratic::{max_votes_for_credits, vote_cost}; let proposal = sqlx::query!( "SELECT community_id, status as \"status: crate::models::ProposalStatus\", voting_method FROM proposals WHERE id = $1", @@ -536,11 +606,17 @@ async fn cast_quadratic_vote( .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?; if !matches!(proposal.status, crate::models::ProposalStatus::Voting) { - return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Proposal is not in voting phase".to_string(), + )); } if proposal.voting_method != "quadratic" { - return Err((StatusCode::BAD_REQUEST, "This proposal uses a different voting method".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "This proposal uses a different voting method".to_string(), + )); } // Validate using quadratic voting module @@ -548,10 +624,13 @@ async fn cast_quadratic_vote( let total_cost: i32 = req.allocations.iter().map(|a| vote_cost(a.credits)).sum(); if total_cost > total_credits { let max_single = max_votes_for_credits(total_credits); - return Err((StatusCode::BAD_REQUEST, format!( - "Total cost {} exceeds {} credits. Max votes on single option: {}", - total_cost, total_credits, max_single - ))); + return Err(( + StatusCode::BAD_REQUEST, + format!( + "Total cost {} exceeds {} credits. Max votes on single option: {}", + total_cost, total_credits, max_single + ), + )); } let voting_identity = sqlx::query_scalar!( @@ -561,15 +640,24 @@ async fn cast_quadratic_vote( RETURNING id"#, auth.user_id, proposal.community_id, - format!("voter-{}", uuid::Uuid::new_v4().to_string()[..8].to_string()) + format!( + "voter-{}", + uuid::Uuid::new_v4().to_string()[..8].to_string() + ) ) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Clear existing quadratic votes - sqlx::query!("DELETE FROM quadratic_votes WHERE proposal_id = $1 AND voter_id = $2", proposal_id, voting_identity) - .execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + sqlx::query!( + "DELETE FROM quadratic_votes WHERE proposal_id = $1 AND voter_id = $2", + proposal_id, + voting_identity + ) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Insert quadratic votes for alloc in req.allocations { @@ -581,7 +669,9 @@ async fn cast_quadratic_vote( } } - Ok(Json(serde_json::json!({"status": "voted", "method": "quadratic", "credits_used": total_cost}))) + Ok(Json( + serde_json::json!({"status": "voted", "method": "quadratic", "credits_used": total_cost}), + )) } async fn cast_star_vote( @@ -600,17 +690,26 @@ async fn cast_star_vote( .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?; if !matches!(proposal.status, crate::models::ProposalStatus::Voting) { - return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Proposal is not in voting phase".to_string(), + )); } if proposal.voting_method != "star" { - return Err((StatusCode::BAD_REQUEST, "This proposal uses a different voting method".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "This proposal uses a different voting method".to_string(), + )); } // Validate star ratings (0-5) for rating in &req.ratings { if rating.stars < 0 || rating.stars > 5 { - return Err((StatusCode::BAD_REQUEST, "Star ratings must be between 0 and 5".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Star ratings must be between 0 and 5".to_string(), + )); } } @@ -621,15 +720,24 @@ async fn cast_star_vote( RETURNING id"#, auth.user_id, proposal.community_id, - format!("voter-{}", uuid::Uuid::new_v4().to_string()[..8].to_string()) + format!( + "voter-{}", + uuid::Uuid::new_v4().to_string()[..8].to_string() + ) ) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Clear existing star votes - sqlx::query!("DELETE FROM star_votes WHERE proposal_id = $1 AND voter_id = $2", proposal_id, voting_identity) - .execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + sqlx::query!( + "DELETE FROM star_votes WHERE proposal_id = $1 AND voter_id = $2", + proposal_id, + voting_identity + ) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Insert star votes for rating in req.ratings { @@ -639,7 +747,9 @@ async fn cast_star_vote( ).execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; } - Ok(Json(serde_json::json!({"status": "voted", "method": "star"}))) + Ok(Json( + serde_json::json!({"status": "voted", "method": "star"}), + )) } async fn start_discussion( @@ -663,7 +773,10 @@ async fn start_discussion( .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?; if proposal.author_id != auth.user_id { - return Err((StatusCode::FORBIDDEN, "Only the author can start discussion".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Only the author can start discussion".to_string(), + )); } let updated = sqlx::query_as!( @@ -706,7 +819,10 @@ async fn start_voting( .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?; if proposal.author_id != auth.user_id { - return Err((StatusCode::FORBIDDEN, "Only the author can start voting".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Only the author can start voting".to_string(), + )); } let updated = sqlx::query_as!( @@ -733,7 +849,7 @@ async fn close_voting( Path(proposal_id): Path, State(pool): State, ) -> Result, (StatusCode, String)> { - use crate::api::permissions::{user_has_permission, perms}; + use crate::api::permissions::{perms, user_has_permission}; let proposal = sqlx::query_as!( Proposal, @@ -752,14 +868,26 @@ async fn close_voting( // Check if user can manage status: author or users with manage_status permission let is_author = proposal.author_id == auth.user_id; - let can_manage = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_MANAGE_STATUS, Some(proposal.community_id)).await?; - + let can_manage = user_has_permission( + &pool, + auth.user_id, + perms::PROPOSAL_MANAGE_STATUS, + Some(proposal.community_id), + ) + .await?; + if !is_author && !can_manage { - return Err((StatusCode::FORBIDDEN, "Only the author or admins can close voting".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Only the author or admins can close voting".to_string(), + )); } if !matches!(proposal.status, crate::models::ProposalStatus::Voting) { - return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Proposal is not in voting phase".to_string(), + )); } let updated = sqlx::query_as!( @@ -787,7 +915,7 @@ async fn delete_proposal( Path(proposal_id): Path, State(pool): State, ) -> Result, (StatusCode, String)> { - use crate::api::permissions::{user_has_permission, perms}; + use crate::api::permissions::{perms, user_has_permission}; let proposal = sqlx::query!( "SELECT author_id, community_id, status as \"status: crate::models::ProposalStatus\", title FROM proposals WHERE id = $1", @@ -800,25 +928,49 @@ async fn delete_proposal( // Check if user can delete: author needs delete_own, others need delete_any let is_author = proposal.author_id == auth.user_id; - let can_delete_own = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_DELETE_OWN, Some(proposal.community_id)).await?; - let can_delete_any = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_DELETE_ANY, Some(proposal.community_id)).await?; - + let can_delete_own = user_has_permission( + &pool, + auth.user_id, + perms::PROPOSAL_DELETE_OWN, + Some(proposal.community_id), + ) + .await?; + let can_delete_any = user_has_permission( + &pool, + auth.user_id, + perms::PROPOSAL_DELETE_ANY, + Some(proposal.community_id), + ) + .await?; + if is_author && !can_delete_own { - return Err((StatusCode::FORBIDDEN, "You don't have permission to delete proposals".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "You don't have permission to delete proposals".to_string(), + )); } if !is_author && !can_delete_any { - return Err((StatusCode::FORBIDDEN, "Only the author or admins can delete this proposal".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Only the author or admins can delete this proposal".to_string(), + )); } if !matches!(proposal.status, crate::models::ProposalStatus::Draft) { - return Err((StatusCode::BAD_REQUEST, "Only draft proposals can be deleted".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Only draft proposals can be deleted".to_string(), + )); } // Delete related data first - sqlx::query!("DELETE FROM proposal_options WHERE proposal_id = $1", proposal_id) - .execute(&pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + sqlx::query!( + "DELETE FROM proposal_options WHERE proposal_id = $1", + proposal_id + ) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; sqlx::query!("DELETE FROM comments WHERE proposal_id = $1", proposal_id) .execute(&pool) @@ -846,7 +998,7 @@ async fn update_proposal( State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { - use crate::api::permissions::{user_has_permission, perms}; + use crate::api::permissions::{perms, user_has_permission}; let proposal = sqlx::query!( "SELECT author_id, community_id, status as \"status: crate::models::ProposalStatus\" FROM proposals WHERE id = $1", @@ -859,18 +1011,42 @@ async fn update_proposal( // Check edit permissions: author needs edit_own, others need edit_any let is_author = proposal.author_id == auth.user_id; - let can_edit_own = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_EDIT_OWN, Some(proposal.community_id)).await?; - let can_edit_any = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_EDIT_ANY, Some(proposal.community_id)).await?; - + let can_edit_own = user_has_permission( + &pool, + auth.user_id, + perms::PROPOSAL_EDIT_OWN, + Some(proposal.community_id), + ) + .await?; + let can_edit_any = user_has_permission( + &pool, + auth.user_id, + perms::PROPOSAL_EDIT_ANY, + Some(proposal.community_id), + ) + .await?; + if is_author && !can_edit_own { - return Err((StatusCode::FORBIDDEN, "You don't have permission to edit proposals".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "You don't have permission to edit proposals".to_string(), + )); } if !is_author && !can_edit_any { - return Err((StatusCode::FORBIDDEN, "Only the author or admins can edit this proposal".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "Only the author or admins can edit this proposal".to_string(), + )); } - if !matches!(proposal.status, crate::models::ProposalStatus::Draft | crate::models::ProposalStatus::Discussion) { - return Err((StatusCode::BAD_REQUEST, "Can only edit proposals in draft or discussion phase".to_string())); + if !matches!( + proposal.status, + crate::models::ProposalStatus::Draft | crate::models::ProposalStatus::Discussion + ) { + return Err(( + StatusCode::BAD_REQUEST, + "Can only edit proposals in draft or discussion phase".to_string(), + )); } let updated = sqlx::query_as!( @@ -921,7 +1097,7 @@ async fn get_voting_results( Path(proposal_id): Path, State(pool): State, ) -> Result, (StatusCode, String)> { - use crate::api::permissions::{require_permission, perms}; + use crate::api::permissions::{perms, require_permission}; let proposal = sqlx::query!( "SELECT id, community_id, voting_method, status as \"status: crate::models::ProposalStatus\" FROM proposals WHERE id = $1", @@ -933,7 +1109,13 @@ async fn get_voting_results( .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?; // Require permission to view voting results - require_permission(&pool, auth.user_id, perms::VOTE_VIEW_RESULTS, Some(proposal.community_id)).await?; + require_permission( + &pool, + auth.user_id, + perms::VOTE_VIEW_RESULTS, + Some(proposal.community_id), + ) + .await?; let options: Vec<(Uuid, String)> = sqlx::query!( "SELECT id, label FROM proposal_options WHERE proposal_id = $1 ORDER BY sort_order", @@ -947,10 +1129,12 @@ async fn get_voting_results( .collect(); let voting_method = proposal.voting_method.as_str(); - + let (results, total_votes, details) = match voting_method { "approval" => calculate_approval_results(&pool, proposal_id, &options).await?, - "ranked_choice" | "schulze" => calculate_ranked_results(&pool, proposal_id, &options, voting_method).await?, + "ranked_choice" | "schulze" => { + calculate_ranked_results(&pool, proposal_id, &options, voting_method).await? + } "star" => calculate_star_results(&pool, proposal_id, &options).await?, "quadratic" => calculate_quadratic_results(&pool, proposal_id, &options).await?, _ => calculate_approval_results(&pool, proposal_id, &options).await?, @@ -992,30 +1176,40 @@ async fn calculate_approval_results( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .unwrap_or(0); - let mut results: Vec = options.iter().map(|(opt_id, label)| { - let votes = vote_counts.iter() - .find(|v| v.option_id == *opt_id) - .map(|v| v.count.unwrap_or(0)) - .unwrap_or(0); - let percentage = if total_voters > 0 { - (votes as f64 / total_voters as f64) * 100.0 - } else { 0.0 }; - - OptionResult { - option_id: *opt_id, - label: label.clone(), - votes, - percentage, - rank: 0, - } - }).collect(); + let mut results: Vec = options + .iter() + .map(|(opt_id, label)| { + let votes = vote_counts + .iter() + .find(|v| v.option_id == *opt_id) + .map(|v| v.count.unwrap_or(0)) + .unwrap_or(0); + let percentage = if total_voters > 0 { + (votes as f64 / total_voters as f64) * 100.0 + } else { + 0.0 + }; + + OptionResult { + option_id: *opt_id, + label: label.clone(), + votes, + percentage, + rank: 0, + } + }) + .collect(); results.sort_by(|a, b| b.votes.cmp(&a.votes)); for (i, r) in results.iter_mut().enumerate() { r.rank = (i + 1) as i32; } - Ok((results, total_voters, serde_json::json!({"method": "approval"}))) + Ok(( + results, + total_voters, + serde_json::json!({"method": "approval"}), + )) } async fn calculate_ranked_results( @@ -1024,7 +1218,7 @@ async fn calculate_ranked_results( options: &[(Uuid, String)], method: &str, ) -> Result<(Vec, i64, serde_json::Value), (StatusCode, String)> { - use crate::voting::{schulze, ranked_choice}; + use crate::voting::{ranked_choice, schulze}; let ballots_raw = sqlx::query!( r#"SELECT voter_id, option_id, rank FROM ranked_votes @@ -1036,48 +1230,72 @@ async fn calculate_ranked_results( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let option_ids: Vec = options.iter().map(|(id, _)| *id).collect(); - + // Group ballots by voter - let mut voter_ballots: std::collections::HashMap> = std::collections::HashMap::new(); + let mut voter_ballots: std::collections::HashMap> = + std::collections::HashMap::new(); for b in &ballots_raw { - voter_ballots.entry(b.voter_id).or_default().push((b.option_id, b.rank)); + voter_ballots + .entry(b.voter_id) + .or_default() + .push((b.option_id, b.rank)); } let total_voters = voter_ballots.len() as i64; let result = if method == "schulze" { - let ballots: Vec = voter_ballots.values().map(|rankings| { - schulze::RankedBallot { - rankings: rankings.iter().map(|(id, rank)| (*id, *rank as usize)).collect() - } - }).collect(); + let ballots: Vec = voter_ballots + .values() + .map(|rankings| schulze::RankedBallot { + rankings: rankings + .iter() + .map(|(id, rank)| (*id, *rank as usize)) + .collect(), + }) + .collect(); schulze::calculate(&option_ids, &ballots) } else { - let ballots: Vec = voter_ballots.values().map(|rankings| { - let mut sorted = rankings.clone(); - sorted.sort_by_key(|(_, rank)| *rank); - ranked_choice::RankedBallot { - rankings: sorted.iter().map(|(id, _)| *id).collect() - } - }).collect(); + let ballots: Vec = voter_ballots + .values() + .map(|rankings| { + let mut sorted = rankings.clone(); + sorted.sort_by_key(|(_, rank)| *rank); + ranked_choice::RankedBallot { + rankings: sorted.iter().map(|(id, _)| *id).collect(), + } + }) + .collect(); ranked_choice::calculate(&option_ids, &ballots) }; - let results: Vec = result.ranking.iter().map(|r| { - let label = options.iter() - .find(|(id, _)| *id == r.option_id) - .map(|(_, l)| l.clone()) - .unwrap_or_default(); - OptionResult { - option_id: r.option_id, - label, - votes: r.score as i64, - percentage: if total_voters > 0 { (r.score / total_voters as f64) * 100.0 } else { 0.0 }, - rank: r.rank as i32, - } - }).collect(); + let results: Vec = result + .ranking + .iter() + .map(|r| { + let label = options + .iter() + .find(|(id, _)| *id == r.option_id) + .map(|(_, l)| l.clone()) + .unwrap_or_default(); + OptionResult { + option_id: r.option_id, + label, + votes: r.score as i64, + percentage: if total_voters > 0 { + (r.score / total_voters as f64) * 100.0 + } else { + 0.0 + }, + rank: r.rank as i32, + } + }) + .collect(); - Ok((results, total_voters, serde_json::to_value(&result.details).unwrap_or_default())) + Ok(( + results, + total_voters, + serde_json::to_value(&result.details).unwrap_or_default(), + )) } async fn calculate_star_results( @@ -1098,35 +1316,49 @@ async fn calculate_star_results( let option_ids: Vec = options.iter().map(|(id, _)| *id).collect(); // Group by voter - let mut voter_scores: std::collections::HashMap> = std::collections::HashMap::new(); + let mut voter_scores: std::collections::HashMap> = + std::collections::HashMap::new(); for v in &votes_raw { - voter_scores.entry(v.voter_id).or_default().push((v.option_id, v.stars)); + voter_scores + .entry(v.voter_id) + .or_default() + .push((v.option_id, v.stars)); } - let ballots: Vec = voter_scores.values().map(|scores| { - star::ScoreBallot { - scores: scores.clone() - } - }).collect(); + let ballots: Vec = voter_scores + .values() + .map(|scores| star::ScoreBallot { + scores: scores.clone(), + }) + .collect(); let total_voters = ballots.len() as i64; let result = star::calculate(&option_ids, &ballots); - let results: Vec = result.ranking.iter().map(|r| { - let label = options.iter() - .find(|(id, _)| *id == r.option_id) - .map(|(_, l)| l.clone()) - .unwrap_or_default(); - OptionResult { - option_id: r.option_id, - label, - votes: r.score as i64, - percentage: 0.0, - rank: r.rank as i32, - } - }).collect(); + let results: Vec = result + .ranking + .iter() + .map(|r| { + let label = options + .iter() + .find(|(id, _)| *id == r.option_id) + .map(|(_, l)| l.clone()) + .unwrap_or_default(); + OptionResult { + option_id: r.option_id, + label, + votes: r.score as i64, + percentage: 0.0, + rank: r.rank as i32, + } + }) + .collect(); - Ok((results, total_voters, serde_json::to_value(&result.details).unwrap_or_default())) + Ok(( + results, + total_voters, + serde_json::to_value(&result.details).unwrap_or_default(), + )) } async fn calculate_quadratic_results( @@ -1147,35 +1379,55 @@ async fn calculate_quadratic_results( let option_ids: Vec = options.iter().map(|(id, _)| *id).collect(); // Group by voter to build ballots - let mut voter_allocations: std::collections::HashMap> = std::collections::HashMap::new(); + let mut voter_allocations: std::collections::HashMap> = + std::collections::HashMap::new(); for v in &votes_raw { - voter_allocations.entry(v.voter_id).or_default().push((v.option_id, v.credits)); + voter_allocations + .entry(v.voter_id) + .or_default() + .push((v.option_id, v.credits)); } // Convert to QuadraticBallot format (100 credits per voter) - let ballots: Vec = voter_allocations.values().map(|allocs| { - quadratic::QuadraticBallot { - total_credits: 100, // Standard credit allocation - allocations: allocs.clone(), - } - }).collect(); + let ballots: Vec = voter_allocations + .values() + .map(|allocs| { + quadratic::QuadraticBallot { + total_credits: 100, // Standard credit allocation + allocations: allocs.clone(), + } + }) + .collect(); let total_voters = ballots.len() as i64; let result = quadratic::calculate(&option_ids, &ballots); - let results: Vec = result.ranking.iter().map(|r| { - let label = options.iter() - .find(|(id, _)| *id == r.option_id) - .map(|(_, l)| l.clone()) - .unwrap_or_default(); - OptionResult { - option_id: r.option_id, - label, - votes: r.score as i64, - percentage: if total_voters > 0 { (r.score / total_voters as f64) * 100.0 } else { 0.0 }, - rank: r.rank as i32, - } - }).collect(); + let results: Vec = result + .ranking + .iter() + .map(|r| { + let label = options + .iter() + .find(|(id, _)| *id == r.option_id) + .map(|(_, l)| l.clone()) + .unwrap_or_default(); + OptionResult { + option_id: r.option_id, + label, + votes: r.score as i64, + percentage: if total_voters > 0 { + (r.score / total_voters as f64) * 100.0 + } else { + 0.0 + }, + rank: r.rank as i32, + } + }) + .collect(); - Ok((results, total_voters, serde_json::to_value(&result.details).unwrap_or_default())) + Ok(( + results, + total_voters, + serde_json::to_value(&result.details).unwrap_or_default(), + )) } diff --git a/backend/src/api/roles.rs b/backend/src/api/roles.rs index 441a36e..4eae29f 100644 --- a/backend/src/api/roles.rs +++ b/backend/src/api/roles.rs @@ -5,13 +5,13 @@ use axum::{ extract::{Path, State}, http::StatusCode, - routing::{get, post, delete}, + routing::{delete, get, post}, Json, Router, }; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; -use chrono::{DateTime, Utc}; use crate::auth::AuthUser; @@ -117,13 +117,18 @@ async fn list_permissions( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(perms.into_iter().map(|p| Permission { - id: p.id, - name: p.name, - category: p.category, - description: p.description, - is_system: p.is_system, - }).collect())) + Ok(Json( + perms + .into_iter() + .map(|p| Permission { + id: p.id, + name: p.name, + category: p.category, + description: p.description, + is_system: p.is_system, + }) + .collect(), + )) } // ============================================================================ @@ -149,18 +154,23 @@ async fn list_platform_roles( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(roles.into_iter().map(|r| Role { - id: r.id, - name: r.name, - display_name: r.display_name, - description: r.description, - color: r.color, - community_id: None, - is_system: r.is_system, - is_default: r.is_default, - priority: r.priority, - permissions: r.permissions.unwrap_or_default(), - }).collect())) + Ok(Json( + roles + .into_iter() + .map(|r| Role { + id: r.id, + name: r.name, + display_name: r.display_name, + description: r.description, + color: r.color, + community_id: None, + is_system: r.is_system, + is_default: r.is_default, + priority: r.priority, + permissions: r.permissions.unwrap_or_default(), + }) + .collect(), + )) } // ============================================================================ @@ -188,18 +198,23 @@ async fn list_community_roles( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(roles.into_iter().map(|r| Role { - id: r.id, - name: r.name, - display_name: r.display_name, - description: r.description, - color: r.color, - community_id: Some(community_id), - is_system: r.is_system, - is_default: r.is_default, - priority: r.priority, - permissions: r.permissions.unwrap_or_default(), - }).collect())) + Ok(Json( + roles + .into_iter() + .map(|r| Role { + id: r.id, + name: r.name, + display_name: r.display_name, + description: r.description, + color: r.color, + community_id: Some(community_id), + is_system: r.is_system, + is_default: r.is_default, + priority: r.priority, + permissions: r.permissions.unwrap_or_default(), + }) + .collect(), + )) } /// Create a community role @@ -221,7 +236,10 @@ async fn create_community_role( .unwrap_or(false); if !has_perm { - return Err((StatusCode::FORBIDDEN, "No permission to manage roles".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "No permission to manage roles".to_string(), + )); } // Create role @@ -288,7 +306,10 @@ async fn assign_role( .unwrap_or(false); if !has_perm { - return Err((StatusCode::FORBIDDEN, "No permission to manage roles".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "No permission to manage roles".to_string(), + )); } // Verify role belongs to community @@ -339,7 +360,10 @@ async fn remove_role( .unwrap_or(false); if !has_perm { - return Err((StatusCode::FORBIDDEN, "No permission to manage roles".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "No permission to manage roles".to_string(), + )); } sqlx::query!( @@ -375,12 +399,17 @@ async fn get_user_roles( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(roles.into_iter().map(|r| RoleSummary { - id: r.id, - name: r.name, - display_name: r.display_name, - color: r.color, - }).collect())) + Ok(Json( + roles + .into_iter() + .map(|r| RoleSummary { + id: r.id, + name: r.name, + display_name: r.display_name, + color: r.color, + }) + .collect(), + )) } /// Check if current user has a specific permission @@ -417,10 +446,25 @@ pub fn router(pool: PgPool) -> Router { // Platform roles .route("/api/roles", get(list_platform_roles)) // Community roles - .route("/api/communities/{community_id}/roles", get(list_community_roles).post(create_community_role)) - .route("/api/communities/{community_id}/roles/{role_id}/assign", post(assign_role)) - .route("/api/communities/{community_id}/roles/{role_id}/users/{user_id}", delete(remove_role)) - .route("/api/communities/{community_id}/users/{user_id}/roles", get(get_user_roles)) - .route("/api/communities/{community_id}/permissions/{permission_name}/check", get(check_permission)) + .route( + "/api/communities/{community_id}/roles", + get(list_community_roles).post(create_community_role), + ) + .route( + "/api/communities/{community_id}/roles/{role_id}/assign", + post(assign_role), + ) + .route( + "/api/communities/{community_id}/roles/{role_id}/users/{user_id}", + delete(remove_role), + ) + .route( + "/api/communities/{community_id}/users/{user_id}/roles", + get(get_user_roles), + ) + .route( + "/api/communities/{community_id}/permissions/{permission_name}/check", + get(check_permission), + ) .with_state(pool) } diff --git a/backend/src/api/self_moderation.rs b/backend/src/api/self_moderation.rs index 50d26b0..37a9418 100644 --- a/backend/src/api/self_moderation.rs +++ b/backend/src/api/self_moderation.rs @@ -12,9 +12,7 @@ use sqlx::PgPool; use uuid::Uuid; use crate::auth::AuthUser; -use crate::plugins::builtin::self_moderation::{ - CommunityRule, ModerationRulesService, -}; +use crate::plugins::builtin::self_moderation::{CommunityRule, ModerationRulesService}; // ============================================================================ // Request Types @@ -174,12 +172,24 @@ async fn lift_sanction( pub fn router(pool: PgPool) -> Router { Router::new() // Rules - .route("/api/communities/{community_id}/rules", get(get_community_rules).post(create_rule)) + .route( + "/api/communities/{community_id}/rules", + get(get_community_rules).post(create_rule), + ) // Violations - .route("/api/communities/{community_id}/violations", get(get_pending_violations).post(report_violation)) - .route("/api/violations/{violation_id}/review", post(review_violation)) + .route( + "/api/communities/{community_id}/violations", + get(get_pending_violations).post(report_violation), + ) + .route( + "/api/violations/{violation_id}/review", + post(review_violation), + ) // User summary - .route("/api/communities/{community_id}/users/{user_id}/moderation", get(get_user_summary)) + .route( + "/api/communities/{community_id}/users/{user_id}/moderation", + get(get_user_summary), + ) // Sanctions .route("/api/sanctions/{sanction_id}/lift", post(lift_sanction)) .with_state(pool) diff --git a/backend/src/api/settings.rs b/backend/src/api/settings.rs index 65018a7..0b0c431 100644 --- a/backend/src/api/settings.rs +++ b/backend/src/api/settings.rs @@ -3,8 +3,7 @@ use axum::{ extract::{Path, State}, routing::{get, patch, post}, - Extension, - Json, Router, + Extension, Json, Router, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -12,9 +11,9 @@ use sqlx::PgPool; use std::sync::Arc; use uuid::Uuid; +use super::permissions::{perms, require_any_permission, require_permission}; use crate::auth::AuthUser; use crate::config::Config; -use super::permissions::{require_permission, require_any_permission, perms}; use axum::http::StatusCode; // ============================================================================ @@ -100,12 +99,10 @@ pub struct UpdateCommunitySettingsRequest { /// Check if setup is required (public endpoint) async fn get_setup_status(State(pool): State) -> Result, String> { - let row = sqlx::query!( - "SELECT setup_completed, instance_name FROM instance_settings LIMIT 1" - ) - .fetch_optional(&pool) - .await - .map_err(|e| e.to_string())?; + let row = sqlx::query!("SELECT setup_completed, instance_name FROM instance_settings LIMIT 1") + .fetch_optional(&pool) + .await + .map_err(|e| e.to_string())?; match row { Some(r) => Ok(Json(SetupStatus { @@ -120,7 +117,9 @@ async fn get_setup_status(State(pool): State) -> Result) -> Result, String> { +async fn get_public_settings( + State(pool): State, +) -> Result, String> { let row = sqlx::query!( r#"SELECT setup_completed, instance_name, platform_mode, registration_enabled, registration_mode, @@ -186,12 +185,18 @@ async fn complete_setup( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if existing.map(|e| e.setup_completed).unwrap_or(false) { - return Err((StatusCode::BAD_REQUEST, "Setup already completed".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Setup already completed".to_string(), + )); } // Handle single_community mode let single_community_id: Option = if req.platform_mode == "single_community" { - let name = req.single_community_name.as_deref().unwrap_or("Main Community"); + let name = req + .single_community_name + .as_deref() + .unwrap_or("Main Community"); let community = sqlx::query!( r#"INSERT INTO communities (name, slug, description, is_active, created_by) VALUES ($1, $2, $3, true, $4) @@ -375,7 +380,8 @@ async fn update_community_settings( auth.user_id, &[perms::COMMUNITY_SETTINGS, perms::PLATFORM_ADMIN], Some(community_id), - ).await?; + ) + .await?; // Ensure settings exist sqlx::query!( @@ -426,7 +432,13 @@ pub fn router(pool: PgPool) -> Router { .route("/api/settings/setup", post(complete_setup)) .route("/api/settings/instance", get(get_instance_settings)) .route("/api/settings/instance", patch(update_instance_settings)) - .route("/api/settings/communities/{community_id}", get(get_community_settings)) - .route("/api/settings/communities/{community_id}", patch(update_community_settings)) + .route( + "/api/settings/communities/{community_id}", + get(get_community_settings), + ) + .route( + "/api/settings/communities/{community_id}", + patch(update_community_settings), + ) .with_state(pool) } diff --git a/backend/src/api/users.rs b/backend/src/api/users.rs index 1b58ca0..e4326fb 100644 --- a/backend/src/api/users.rs +++ b/backend/src/api/users.rs @@ -21,9 +21,7 @@ pub fn router(pool: PgPool) -> Router { .with_state(pool) } -async fn list_users( - State(pool): State, -) -> Result>, String> { +async fn list_users(State(pool): State) -> Result>, String> { let users = sqlx::query_as!( crate::models::User, "SELECT * FROM users WHERE is_active = true ORDER BY created_at DESC LIMIT 100" @@ -104,12 +102,15 @@ async fn get_user_profile( username: user.username, display_name: user.display_name, created_at: user.created_at, - communities: communities.into_iter().map(|c| CommunityMembership { - id: c.id, - name: c.name, - slug: c.slug, - role: c.role, - }).collect(), + communities: communities + .into_iter() + .map(|c| CommunityMembership { + id: c.id, + name: c.name, + slug: c.slug, + role: c.role, + }) + .collect(), proposal_count, comment_count, })) @@ -176,13 +177,16 @@ async fn get_user_votes( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let result = votes.into_iter().map(|v| UserVote { - proposal_id: v.proposal_id, - proposal_title: v.proposal_title, - community_name: v.community_name, - option_label: v.option_label, - voted_at: v.voted_at, - }).collect(); + let result = votes + .into_iter() + .map(|v| UserVote { + proposal_id: v.proposal_id, + proposal_title: v.proposal_title, + community_name: v.community_name, + option_label: v.option_label, + voted_at: v.voted_at, + }) + .collect(); Ok(Json(result)) } diff --git a/backend/src/api/voting_config.rs b/backend/src/api/voting_config.rs index 4f77f08..e5f0634 100644 --- a/backend/src/api/voting_config.rs +++ b/backend/src/api/voting_config.rs @@ -8,14 +8,14 @@ use axum::{ routing::{get, post, put}, Json, Router, }; +#[allow(unused_imports)] +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; -#[allow(unused_imports)] -use chrono::{DateTime, Utc}; +use super::permissions::{perms, require_permission}; use crate::auth::AuthUser; -use super::permissions::{require_permission, perms}; // ============================================================================ // Types @@ -100,19 +100,24 @@ async fn list_voting_methods( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(methods.into_iter().map(|m| VotingMethodPlugin { - id: m.id, - name: m.name, - display_name: m.display_name, - description: m.description, - icon: m.icon, - is_active: m.is_active, - is_default: m.is_default, - config_schema: m.config_schema, - default_config: m.default_config.unwrap_or_default(), - complexity_level: m.complexity_level.unwrap_or_default(), - supports_delegation: m.supports_delegation, - }).collect())) + Ok(Json( + methods + .into_iter() + .map(|m| VotingMethodPlugin { + id: m.id, + name: m.name, + display_name: m.display_name, + description: m.description, + icon: m.icon, + is_active: m.is_active, + is_default: m.is_default, + config_schema: m.config_schema, + default_config: m.default_config.unwrap_or_default(), + complexity_level: m.complexity_level.unwrap_or_default(), + supports_delegation: m.supports_delegation, + }) + .collect(), + )) } /// Update platform voting method (admin only) @@ -191,25 +196,30 @@ async fn list_community_voting_methods( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(methods.into_iter().map(|m| CommunityVotingMethod { - id: m.id, - voting_method: VotingMethodPlugin { - id: m.id, - name: m.name.clone(), - display_name: m.display_name.clone(), - description: m.description.clone(), - icon: m.icon.clone(), - is_active: m.platform_active, - is_default: m.is_default.unwrap_or(false), - config_schema: m.config_schema.clone(), - default_config: m.default_config.clone().unwrap_or_default(), - complexity_level: m.complexity_level.clone().unwrap_or_default(), - supports_delegation: m.supports_delegation, - }, - is_enabled: m.is_enabled.unwrap_or(false), - is_default: m.is_default.unwrap_or(false), - config: m.config.unwrap_or_default(), - }).collect())) + Ok(Json( + methods + .into_iter() + .map(|m| CommunityVotingMethod { + id: m.id, + voting_method: VotingMethodPlugin { + id: m.id, + name: m.name.clone(), + display_name: m.display_name.clone(), + description: m.description.clone(), + icon: m.icon.clone(), + is_active: m.platform_active, + is_default: m.is_default.unwrap_or(false), + config_schema: m.config_schema.clone(), + default_config: m.default_config.clone().unwrap_or_default(), + complexity_level: m.complexity_level.clone().unwrap_or_default(), + supports_delegation: m.supports_delegation, + }, + is_enabled: m.is_enabled.unwrap_or(false), + is_default: m.is_default.unwrap_or(false), + config: m.config.unwrap_or_default(), + }) + .collect(), + )) } /// Configure voting method for a community @@ -231,7 +241,10 @@ async fn configure_community_voting_method( .unwrap_or(false); if !has_perm { - return Err((StatusCode::FORBIDDEN, "No permission to manage voting methods".to_string())); + return Err(( + StatusCode::FORBIDDEN, + "No permission to manage voting methods".to_string(), + )); } // If setting as default, unset other defaults first @@ -313,16 +326,21 @@ async fn list_default_plugins( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(plugins.into_iter().map(|p| DefaultPlugin { - plugin_name: p.plugin_name, - plugin_type: p.plugin_type, - display_name: p.display_name, - description: p.description, - is_core: p.is_core, - is_recommended: p.is_recommended, - default_enabled: p.default_enabled, - category: p.category, - }).collect())) + Ok(Json( + plugins + .into_iter() + .map(|p| DefaultPlugin { + plugin_name: p.plugin_name, + plugin_type: p.plugin_type, + display_name: p.display_name, + description: p.description, + is_core: p.is_core, + is_recommended: p.is_recommended, + default_enabled: p.default_enabled, + category: p.category, + }) + .collect(), + )) } /// List instance plugins @@ -336,11 +354,16 @@ async fn list_instance_plugins( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - Ok(Json(plugins.into_iter().map(|p| InstancePlugin { - plugin_name: p.plugin_name, - is_enabled: p.is_enabled, - config: p.config.unwrap_or_default(), - }).collect())) + Ok(Json( + plugins + .into_iter() + .map(|p| InstancePlugin { + plugin_name: p.plugin_name, + is_enabled: p.is_enabled, + config: p.config.unwrap_or_default(), + }) + .collect(), + )) } /// Update instance plugin @@ -364,7 +387,10 @@ async fn update_instance_plugin( .unwrap_or(false); if is_core && req.is_enabled == Some(false) { - return Err((StatusCode::BAD_REQUEST, "Cannot disable core plugins".to_string())); + return Err(( + StatusCode::BAD_REQUEST, + "Cannot disable core plugins".to_string(), + )); } let plugin = sqlx::query!( @@ -401,17 +427,16 @@ async fn initialize_default_plugins( require_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?; // Get all default plugins - let defaults = sqlx::query!( - "SELECT plugin_name, is_core, default_enabled FROM default_plugins" - ) - .fetch_all(&pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let defaults = + sqlx::query!("SELECT plugin_name, is_core, default_enabled FROM default_plugins") + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Initialize each plugin for plugin in defaults { let is_enabled = plugin.is_core || enabled_plugins.contains(&plugin.plugin_name); - + sqlx::query!( r#"INSERT INTO instance_plugins (plugin_name, is_enabled, enabled_by, enabled_at) VALUES ($1, $2, $3, NOW()) @@ -441,12 +466,21 @@ pub fn router(pool: PgPool) -> Router { .route("/api/voting-methods", get(list_voting_methods)) .route("/api/voting-methods/{method_id}", put(update_voting_method)) // Community voting methods - .route("/api/communities/{community_id}/voting-methods", get(list_community_voting_methods)) - .route("/api/communities/{community_id}/voting-methods/{method_id}", put(configure_community_voting_method)) + .route( + "/api/communities/{community_id}/voting-methods", + get(list_community_voting_methods), + ) + .route( + "/api/communities/{community_id}/voting-methods/{method_id}", + put(configure_community_voting_method), + ) // Default plugins .route("/api/plugins/defaults", get(list_default_plugins)) .route("/api/plugins/instance", get(list_instance_plugins)) - .route("/api/plugins/instance/{plugin_name}", put(update_instance_plugin)) + .route( + "/api/plugins/instance/{plugin_name}", + put(update_instance_plugin), + ) .route("/api/plugins/initialize", post(initialize_default_plugins)) .with_state(pool) } diff --git a/backend/src/api/workflows.rs b/backend/src/api/workflows.rs index 3d2b1a5..a18f8b7 100644 --- a/backend/src/api/workflows.rs +++ b/backend/src/api/workflows.rs @@ -149,11 +149,26 @@ async fn advance_phase( pub fn router(pool: PgPool) -> Router { Router::new() // Templates - .route("/api/communities/{community_id}/workflow-templates", get(list_templates).post(create_template)) - .route("/api/workflow-templates/{template_id}/phases", get(get_template_phases).post(add_phase)) + .route( + "/api/communities/{community_id}/workflow-templates", + get(list_templates).post(create_template), + ) + .route( + "/api/workflow-templates/{template_id}/phases", + get(get_template_phases).post(add_phase), + ) // Proposal workflows - .route("/api/proposals/{proposal_id}/workflow", get(get_workflow_for_proposal)) - .route("/api/proposals/{proposal_id}/workflow/progress", get(get_workflow_progress)) - .route("/api/proposals/{proposal_id}/workflow/advance", post(advance_phase)) + .route( + "/api/proposals/{proposal_id}/workflow", + get(get_workflow_for_proposal), + ) + .route( + "/api/proposals/{proposal_id}/workflow/progress", + get(get_workflow_progress), + ) + .route( + "/api/proposals/{proposal_id}/workflow/advance", + post(advance_phase), + ) .with_state(pool) } diff --git a/backend/src/auth/jwt.rs b/backend/src/auth/jwt.rs index 9880875..1387972 100644 --- a/backend/src/auth/jwt.rs +++ b/backend/src/auth/jwt.rs @@ -11,7 +11,11 @@ pub struct Claims { pub iat: i64, } -pub fn create_token(user_id: Uuid, username: &str, secret: &str) -> Result { +pub fn create_token( + user_id: Uuid, + username: &str, + secret: &str, +) -> Result { let now = Utc::now(); let exp = now + Duration::hours(24); diff --git a/backend/src/auth/middleware.rs b/backend/src/auth/middleware.rs index 48a52ca..9e08160 100644 --- a/backend/src/auth/middleware.rs +++ b/backend/src/auth/middleware.rs @@ -26,9 +26,10 @@ where .and_then(|value| value.to_str().ok()) .ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header"))?; - let token = auth_header - .strip_prefix("Bearer ") - .ok_or((StatusCode::UNAUTHORIZED, "Invalid authorization header format"))?; + let token = auth_header.strip_prefix("Bearer ").ok_or(( + StatusCode::UNAUTHORIZED, + "Invalid authorization header format", + ))?; let secret = parts .extensions diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index 461e45c..dab0674 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -1,7 +1,7 @@ -pub mod password; pub mod jwt; pub mod middleware; +pub mod password; -pub use password::{hash_password, verify_password}; pub use jwt::create_token; pub use middleware::AuthUser; +pub use password::{hash_password, verify_password}; diff --git a/backend/src/demo/mod.rs b/backend/src/demo/mod.rs index e97f680..a8a662e 100644 --- a/backend/src/demo/mod.rs +++ b/backend/src/demo/mod.rs @@ -1,5 +1,5 @@ //! Demo mode functionality for Likwid -//! +//! //! When DEMO_MODE=true, the system: //! - Restricts certain destructive actions (delete community, change instance settings) //! - Allows demo accounts to log in with known passwords @@ -293,9 +293,7 @@ pub async fn reset_demo_data(pool: &PgPool) -> Result<(), sqlx::Error> { .await?; // Votes and delegation artifacts - sqlx::query("DELETE FROM votes") - .execute(&mut *tx) - .await?; + sqlx::query("DELETE FROM votes").execute(&mut *tx).await?; sqlx::query("DELETE FROM delegated_votes") .execute(&mut *tx) .await?; @@ -403,7 +401,7 @@ pub async fn reset_demo_data(pool: &PgPool) -> Result<(), sqlx::Error> { .await?; tx.commit().await?; - + tracing::info!("Demo data reset complete"); Ok(()) } diff --git a/backend/src/main.rs b/backend/src/main.rs index 046ae85..fe93fff 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -8,13 +8,13 @@ mod plugins; mod rate_limit; mod voting; -use std::net::SocketAddr; -use std::sync::Arc; -use axum::{middleware, Extension}; use axum::http::{HeaderName, HeaderValue}; use axum::response::Response; +use axum::{middleware, Extension}; use chrono::{Datelike, Timelike, Utc, Weekday}; use serde_json::json; +use std::net::SocketAddr; +use std::sync::Arc; use thiserror::Error; use tower_http::cors::{Any, CorsLayer}; use tower_http::trace::TraceLayer; @@ -65,7 +65,8 @@ async fn run() -> Result<(), StartupError> { tracing::info!("🎭 DEMO MODE ENABLED - Some actions are restricted"); } - let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| config.database_url.clone()); + let database_url = + std::env::var("DATABASE_URL").unwrap_or_else(|_| config.database_url.clone()); let pool = db::create_pool(&database_url).await?; @@ -121,7 +122,9 @@ async fn run() -> Result<(), StartupError> { }; let payload = json!({"ts": now.to_rfc3339()}); - cron_plugins.do_action("cron.minute", ctx.clone(), payload.clone()).await; + cron_plugins + .do_action("cron.minute", ctx.clone(), payload.clone()) + .await; cron_plugins .do_action("cron.minutely", ctx.clone(), payload.clone()) .await; @@ -166,18 +169,17 @@ async fn run() -> Result<(), StartupError> { } // WASM plugins need per-community context. - let community_ids: Vec = match sqlx::query_scalar( - "SELECT id FROM communities WHERE is_active = true", - ) - .fetch_all(&cron_pool) - .await - { - Ok(ids) => ids, - Err(e) => { - tracing::error!("cron: failed to list communities: {}", e); - continue; - } - }; + let community_ids: Vec = + match sqlx::query_scalar("SELECT id FROM communities WHERE is_active = true") + .fetch_all(&cron_pool) + .await + { + Ok(ids) => ids, + Err(e) => { + tracing::error!("cron: failed to list communities: {}", e); + continue; + } + }; let mut wasm_hooks: Vec<&'static str> = vec!["cron.minute", "cron.minutely"]; if min15_key == last_15min_key { @@ -215,15 +217,20 @@ async fn run() -> Result<(), StartupError> { .layer(TraceLayer::new_for_http()) .layer(middleware::map_response(add_security_headers)); - let host: std::net::IpAddr = config.server_host.parse() + let host: std::net::IpAddr = config + .server_host + .parse() .unwrap_or_else(|_| std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1))); let addr = SocketAddr::from((host, config.server_port)); tracing::info!("Likwid backend listening on http://{}", addr); let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app.into_make_service_with_connect_info::()) - .await - .map_err(|e| StartupError::Serve(e.to_string()))?; + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .map_err(|e| StartupError::Serve(e.to_string()))?; Ok(()) } diff --git a/backend/src/models/community.rs b/backend/src/models/community.rs index 55224ee..3d7d3ab 100644 --- a/backend/src/models/community.rs +++ b/backend/src/models/community.rs @@ -1,7 +1,7 @@ +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use uuid::Uuid; -use chrono::{DateTime, Utc}; #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct Community { diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 5f09a8a..a4d531e 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,7 +1,7 @@ -pub mod user; pub mod community; pub mod proposal; +pub mod user; -pub use user::User; pub use community::Community; pub use proposal::ProposalStatus; +pub use user::User; diff --git a/backend/src/models/proposal.rs b/backend/src/models/proposal.rs index a4285fd..0688194 100644 --- a/backend/src/models/proposal.rs +++ b/backend/src/models/proposal.rs @@ -1,7 +1,7 @@ +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use uuid::Uuid; -use chrono::{DateTime, Utc}; #[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "proposal_status", rename_all = "lowercase")] diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs index 62990ec..f258719 100644 --- a/backend/src/models/user.rs +++ b/backend/src/models/user.rs @@ -1,7 +1,7 @@ +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::FromRow; use uuid::Uuid; -use chrono::{DateTime, Utc}; #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct User { diff --git a/backend/src/plugins/builtin/conflict_resolution.rs b/backend/src/plugins/builtin/conflict_resolution.rs index ed78085..5c51f9c 100644 --- a/backend/src/plugins/builtin/conflict_resolution.rs +++ b/backend/src/plugins/builtin/conflict_resolution.rs @@ -6,9 +6,7 @@ use std::sync::Arc; use uuid::Uuid; use crate::plugins::{ - hooks::HookContext, - manager::PluginSystem, - Plugin, PluginError, PluginMetadata, PluginScope, + hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope, }; pub struct ConflictResolutionPlugin; @@ -48,24 +46,33 @@ impl Plugin for ConflictResolutionPlugin { 50, Arc::new(|ctx: HookContext, payload: Value| { Box::pin(async move { - if let Some(conflict_id) = payload.get("conflict_id") + if let Some(conflict_id) = payload + .get("conflict_id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok()) { // Check if auto-assign is enabled if let Some(community_id) = ctx.community_id { - let auto_assign = ConflictService::should_auto_assign(&ctx.pool, community_id).await?; + let auto_assign = + ConflictService::should_auto_assign(&ctx.pool, community_id) + .await?; if auto_assign { - ConflictService::assign_mediators(&ctx.pool, conflict_id, ctx.actor_user_id).await?; + ConflictService::assign_mediators( + &ctx.pool, + conflict_id, + ctx.actor_user_id, + ) + .await?; } } } - + ctx.emit_public_event( Some("conflict_resolution"), "conflict.reported", payload.clone(), - ).await?; + ) + .await?; Ok(()) }) }), @@ -82,7 +89,8 @@ impl Plugin for ConflictResolutionPlugin { Some("conflict_resolution"), "compromise.proposed", payload.clone(), - ).await?; + ) + .await?; Ok(()) }) }), @@ -99,7 +107,8 @@ impl Plugin for ConflictResolutionPlugin { Some("conflict_resolution"), "conflict.resolved", payload.clone(), - ).await?; + ) + .await?; Ok(()) }) }), @@ -135,7 +144,10 @@ pub struct ConflictService; impl ConflictService { /// Check if auto-assign mediators is enabled - pub async fn should_auto_assign(pool: &PgPool, community_id: Uuid) -> Result { + pub async fn should_auto_assign( + pool: &PgPool, + community_id: Uuid, + ) -> Result { let result = sqlx::query_scalar!( r#"SELECT COALESCE( (SELECT (cp.settings->>'auto_assign_mediators')::boolean @@ -148,7 +160,7 @@ impl ConflictService { ) .fetch_one(pool) .await?; - + Ok(result) } @@ -172,7 +184,7 @@ impl ConflictService { party_a_id, party_b_id, reported_by, reported_anonymously, severity_level ) VALUES ($1, $2, $3, $4::conflict_type, $5, $6, $7, $8, $9) - RETURNING id"# + RETURNING id"#, ) .bind(community_id) .bind(title) @@ -210,7 +222,7 @@ impl ConflictService { .bind(assigned_by) .fetch_one(pool) .await?; - + Ok(count) } @@ -223,7 +235,7 @@ impl ConflictService { notes: Option<&str>, ) -> Result { let success: bool = sqlx::query_scalar( - "SELECT transition_conflict_status($1, $2::conflict_status, $3, $4)" + "SELECT transition_conflict_status($1, $2::conflict_status, $3, $4)", ) .bind(conflict_id) .bind(new_status) @@ -231,7 +243,7 @@ impl ConflictService { .bind(notes) .fetch_one(pool) .await?; - + Ok(success) } @@ -276,7 +288,7 @@ impl ConflictService { pub async fn respond_to_compromise( pool: &PgPool, proposal_id: Uuid, - party: &str, // "a" or "b" + party: &str, // "a" or "b" response: &str, // accept, reject, counter feedback: Option<&str>, ) -> Result<(), PluginError> { @@ -319,8 +331,8 @@ impl ConflictService { .fetch_one(pool) .await?; - if proposal.party_a_response.as_deref() == Some("accept") - && proposal.party_b_response.as_deref() == Some("accept") + if proposal.party_a_response.as_deref() == Some("accept") + && proposal.party_b_response.as_deref() == Some("accept") { // Mark proposal as accepted sqlx::query!( @@ -426,7 +438,10 @@ impl ConflictService { } /// Get conflict details - pub async fn get_conflict(pool: &PgPool, conflict_id: Uuid) -> Result, PluginError> { + pub async fn get_conflict( + pool: &PgPool, + conflict_id: Uuid, + ) -> Result, PluginError> { let conflict = sqlx::query_as!( ConflictCase, r#"SELECT @@ -444,7 +459,10 @@ impl ConflictService { } /// Get active conflicts for a community - pub async fn get_active_conflicts(pool: &PgPool, community_id: Uuid) -> Result, PluginError> { + pub async fn get_active_conflicts( + pool: &PgPool, + community_id: Uuid, + ) -> Result, PluginError> { let conflicts = sqlx::query_as!( ConflictCase, r#"SELECT @@ -502,7 +520,7 @@ impl ConflictService { certification_level = COALESCE($3, mediator_pool.certification_level), is_trained = $4 OR mediator_pool.is_trained, updated_at = NOW() - RETURNING id"# + RETURNING id"#, ) .bind(community_id) .bind(user_id) diff --git a/backend/src/plugins/builtin/decision_workflows.rs b/backend/src/plugins/builtin/decision_workflows.rs index 7b9890c..28f22c5 100644 --- a/backend/src/plugins/builtin/decision_workflows.rs +++ b/backend/src/plugins/builtin/decision_workflows.rs @@ -6,9 +6,7 @@ use std::sync::Arc; use uuid::Uuid; use crate::plugins::{ - hooks::HookContext, - manager::PluginSystem, - Plugin, PluginError, PluginMetadata, PluginScope, + hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope, }; pub struct DecisionWorkflowsPlugin; @@ -61,17 +59,31 @@ impl Plugin for DecisionWorkflowsPlugin { Arc::new(|ctx: HookContext, payload: Value| { Box::pin(async move { if let (Some(proposal_id), Some(community_id)) = ( - payload.get("proposal_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()), - payload.get("community_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()), + payload + .get("proposal_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()), + payload + .get("community_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()), ) { // Check if auto-workflow is enabled for this community - let auto_start = WorkflowService::should_auto_start_workflow(&ctx.pool, community_id).await?; - + let auto_start = + WorkflowService::should_auto_start_workflow(&ctx.pool, community_id) + .await?; + if auto_start { - let template_id = WorkflowService::get_default_template(&ctx.pool, Some(community_id)).await?; + let template_id = WorkflowService::get_default_template( + &ctx.pool, + Some(community_id), + ) + .await?; if let Some(tid) = template_id { - let instance_id = WorkflowService::start_workflow(&ctx.pool, proposal_id, tid).await?; - + let instance_id = + WorkflowService::start_workflow(&ctx.pool, proposal_id, tid) + .await?; + ctx.emit_public_event( Some("decision_workflows"), "workflow.started", @@ -80,7 +92,8 @@ impl Plugin for DecisionWorkflowsPlugin { "workflow_instance_id": instance_id, "template_id": tid }), - ).await?; + ) + .await?; } } } @@ -110,10 +123,19 @@ impl Plugin for DecisionWorkflowsPlugin { 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()), + payload + .get("proposal_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()), ctx.actor_user_id, ) { - WorkflowService::record_participation(&ctx.pool, proposal_id, user_id, "viewed").await?; + WorkflowService::record_participation( + &ctx.pool, + proposal_id, + user_id, + "viewed", + ) + .await?; } Ok(()) }) @@ -127,10 +149,19 @@ impl Plugin for DecisionWorkflowsPlugin { 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()), + payload + .get("proposal_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()), ctx.actor_user_id, ) { - WorkflowService::record_participation(&ctx.pool, proposal_id, user_id, "commented").await?; + WorkflowService::record_participation( + &ctx.pool, + proposal_id, + user_id, + "commented", + ) + .await?; } Ok(()) }) @@ -144,17 +175,25 @@ impl Plugin for DecisionWorkflowsPlugin { 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()), + payload + .get("proposal_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()), ctx.actor_user_id, ) { - WorkflowService::record_participation(&ctx.pool, proposal_id, user_id, "voted").await?; + WorkflowService::record_participation( + &ctx.pool, + proposal_id, + user_id, + "voted", + ) + .await?; } Ok(()) }) }), ); } - } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -228,7 +267,7 @@ impl WorkflowService { ) .fetch_one(pool) .await?; - + Ok(result) } @@ -247,12 +286,12 @@ impl WorkflowService { ) .fetch_optional(pool) .await?; - + if community_default.is_some() { return Ok(community_default); } } - + // Fall back to system default let system_default = sqlx::query_scalar!( r#"SELECT id FROM workflow_templates @@ -261,7 +300,7 @@ impl WorkflowService { ) .fetch_optional(pool) .await?; - + Ok(system_default) } @@ -271,14 +310,12 @@ impl WorkflowService { proposal_id: Uuid, template_id: Uuid, ) -> Result { - let instance_id: Uuid = sqlx::query_scalar( - "SELECT start_workflow($1, $2)" - ) - .bind(proposal_id) - .bind(template_id) - .fetch_one(pool) - .await?; - + let instance_id: Uuid = sqlx::query_scalar("SELECT start_workflow($1, $2)") + .bind(proposal_id) + .bind(template_id) + .fetch_one(pool) + .await?; + Ok(instance_id) } @@ -289,15 +326,14 @@ impl WorkflowService { user_id: Option, reason: Option<&str>, ) -> Result, PluginError> { - let new_phase_id: Option = sqlx::query_scalar( - "SELECT advance_workflow_phase($1, 'manual', $2, $3)" - ) - .bind(workflow_instance_id) - .bind(user_id) - .bind(reason) - .fetch_one(pool) - .await?; - + let new_phase_id: Option = + sqlx::query_scalar("SELECT advance_workflow_phase($1, 'manual', $2, $3)") + .bind(workflow_instance_id) + .bind(user_id) + .bind(reason) + .fetch_one(pool) + .await?; + Ok(new_phase_id) } @@ -321,7 +357,7 @@ impl WorkflowService { ) .execute(pool) .await?; - + // Update participant count sqlx::query!( r#"UPDATE phase_instances pi @@ -338,7 +374,7 @@ impl WorkflowService { ) .execute(pool) .await?; - + Ok(()) } @@ -353,26 +389,25 @@ impl WorkflowService { ) .fetch_all(pool) .await?; - + for phase in expired_phases { if phase.auto_advance { // Auto-advance to next phase let _: Option = sqlx::query_scalar( - "SELECT advance_workflow_phase($1, 'timeout', NULL, 'Phase duration expired')" + "SELECT advance_workflow_phase($1, 'timeout', NULL, 'Phase duration expired')", ) .bind(phase.workflow_instance_id) .fetch_one(pool) .await?; } } - + // Check quorum for active phases and record snapshots - let active_phases = sqlx::query!( - r#"SELECT pi.id FROM phase_instances pi WHERE pi.status = 'active'"# - ) - .fetch_all(pool) - .await?; - + let active_phases = + sqlx::query!(r#"SELECT pi.id FROM phase_instances pi WHERE pi.status = 'active'"#) + .fetch_all(pool) + .await?; + for phase in active_phases { // Calculate and record quorum sqlx::query( @@ -383,7 +418,7 @@ impl WorkflowService { .bind(phase.id) .execute(pool) .await?; - + // Update quorum_reached flag sqlx::query!( r#"UPDATE phase_instances @@ -406,7 +441,7 @@ impl WorkflowService { .execute(pool) .await?; } - + Ok(()) } @@ -426,7 +461,7 @@ impl WorkflowService { ) .fetch_optional(pool) .await?; - + Ok(instance) } @@ -449,7 +484,7 @@ impl WorkflowService { ) .fetch_all(pool) .await?; - + Ok(phases) } @@ -479,7 +514,7 @@ impl WorkflowService { ) .fetch_optional(pool) .await?; - + match progress { Some(p) => Ok(json!({ "workflow_id": p.id, @@ -493,10 +528,10 @@ impl WorkflowService { "total_phases": p.total_phases, "completed_phases": p.completed_phases, "progress_percentage": p.total_phases.map(|t| { - if t > 0 { - (p.completed_phases.unwrap_or(0) as f64 / t as f64 * 100.0).round() - } else { - 0.0 + if t > 0 { + (p.completed_phases.unwrap_or(0) as f64 / t as f64 * 100.0).round() + } else { + 0.0 } }) })), @@ -519,7 +554,7 @@ impl WorkflowService { ) .fetch_all(pool) .await?; - + Ok(templates) } @@ -542,7 +577,7 @@ impl WorkflowService { ) .fetch_one(pool) .await?; - + Ok(template_id) } @@ -562,7 +597,7 @@ impl WorkflowService { default_duration_hours, quorum_value ) VALUES ($1, $2, $3::workflow_phase_type, $4, $5, $6::numeric) - RETURNING id"# + RETURNING id"#, ) .bind(template_id) .bind(name) @@ -572,7 +607,7 @@ impl WorkflowService { .bind(quorum_value) .fetch_one(pool) .await?; - + Ok(phase_id) } } diff --git a/backend/src/plugins/builtin/federation.rs b/backend/src/plugins/builtin/federation.rs index 0e984fb..ca386a6 100644 --- a/backend/src/plugins/builtin/federation.rs +++ b/backend/src/plugins/builtin/federation.rs @@ -6,9 +6,7 @@ use std::sync::Arc; use uuid::Uuid; use crate::plugins::{ - hooks::HookContext, - manager::PluginSystem, - Plugin, PluginError, PluginMetadata, PluginScope, + hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope, }; pub struct FederationPlugin; @@ -61,11 +59,13 @@ impl Plugin for FederationPlugin { 100, Arc::new(|ctx: HookContext, payload: Value| { Box::pin(async move { - if let Some(proposal_id) = payload.get("proposal_id") + if let Some(proposal_id) = payload + .get("proposal_id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok()) { - FederationService::share_proposal_if_federated(&ctx.pool, proposal_id).await?; + FederationService::share_proposal_if_federated(&ctx.pool, proposal_id) + .await?; } Ok(()) }) @@ -79,7 +79,8 @@ impl Plugin for FederationPlugin { 100, Arc::new(|ctx: HookContext, payload: Value| { Box::pin(async move { - if let Some(proposal_id) = payload.get("proposal_id") + if let Some(proposal_id) = payload + .get("proposal_id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok()) { @@ -122,15 +123,14 @@ impl FederationService { description: Option<&str>, public_key: Option<&str>, ) -> Result { - let instance_id: Uuid = sqlx::query_scalar( - "SELECT register_federated_instance($1, $2, $3, $4)" - ) - .bind(url) - .bind(name) - .bind(description) - .bind(public_key) - .fetch_one(pool) - .await?; + let instance_id: Uuid = + sqlx::query_scalar("SELECT register_federated_instance($1, $2, $3, $4)") + .bind(url) + .bind(name) + .bind(description) + .bind(public_key) + .fetch_one(pool) + .await?; Ok(instance_id) } @@ -145,7 +145,7 @@ impl FederationService { sync_direction: &str, ) -> Result { let federation_id: Uuid = sqlx::query_scalar( - "SELECT create_community_federation($1, $2, $3, $4, $5::sync_direction)" + "SELECT create_community_federation($1, $2, $3, $4, $5::sync_direction)", ) .bind(local_community_id) .bind(remote_instance_id) @@ -203,11 +203,11 @@ impl FederationService { // In a real implementation, this would make HTTP requests to remote instances // For now, just log the sync attempt let start = std::time::Instant::now(); - + sqlx::query( r#"INSERT INTO federation_sync_log (federation_id, instance_id, operation_type, direction, success, duration_ms) - VALUES ($1, $2, 'scheduled_sync', $3::sync_direction, true, $4)"# + VALUES ($1, $2, 'scheduled_sync', $3::sync_direction, true, $4)"#, ) .bind(fed.id) .bind(fed.remote_instance_id) @@ -277,10 +277,7 @@ impl FederationService { } /// Broadcast decision to federated communities - pub async fn broadcast_decision( - pool: &PgPool, - proposal_id: Uuid, - ) -> Result<(), PluginError> { + pub async fn broadcast_decision(pool: &PgPool, proposal_id: Uuid) -> Result<(), PluginError> { // Find federated proposal let federated = sqlx::query!( r#"SELECT fp.id, fp.federation_id @@ -319,12 +316,9 @@ impl FederationService { /// Get federation statistics for a community pub async fn get_stats(pool: &PgPool, community_id: Uuid) -> Result { - let stats = sqlx::query!( - "SELECT * FROM get_federation_stats($1)", - community_id - ) - .fetch_one(pool) - .await?; + let stats = sqlx::query!("SELECT * FROM get_federation_stats($1)", community_id) + .fetch_one(pool) + .await?; Ok(json!({ "total_federations": stats.total_federations, diff --git a/backend/src/plugins/builtin/governance_analytics.rs b/backend/src/plugins/builtin/governance_analytics.rs index 731851a..6b634c6 100644 --- a/backend/src/plugins/builtin/governance_analytics.rs +++ b/backend/src/plugins/builtin/governance_analytics.rs @@ -6,9 +6,7 @@ use std::sync::Arc; use uuid::Uuid; use crate::plugins::{ - hooks::HookContext, - manager::PluginSystem, - Plugin, PluginError, PluginMetadata, PluginScope, + hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope, }; pub struct GovernanceAnalyticsPlugin; @@ -66,7 +64,6 @@ impl Plugin for GovernanceAnalyticsPlugin { }), ); } - } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -99,23 +96,20 @@ impl AnalyticsService { pool: &PgPool, community_id: Uuid, ) -> Result { - let snapshot_id: Uuid = sqlx::query_scalar( - "SELECT calculate_participation_snapshot($1, CURRENT_DATE)" - ) - .bind(community_id) - .fetch_one(pool) - .await?; - + let snapshot_id: Uuid = + sqlx::query_scalar("SELECT calculate_participation_snapshot($1, CURRENT_DATE)") + .bind(community_id) + .fetch_one(pool) + .await?; + Ok(snapshot_id) } /// Calculate snapshots for all communities pub async fn calculate_all_snapshots(pool: &PgPool) -> Result { - let communities = sqlx::query_scalar!( - "SELECT id FROM communities WHERE is_active = true" - ) - .fetch_all(pool) - .await?; + let communities = sqlx::query_scalar!("SELECT id FROM communities WHERE is_active = true") + .fetch_all(pool) + .await?; let mut count = 0; for community_id in communities { @@ -130,27 +124,20 @@ impl AnalyticsService { } /// Calculate governance health score for a community - pub async fn calculate_health( - pool: &PgPool, - community_id: Uuid, - ) -> Result { - let health_id: Uuid = sqlx::query_scalar( - "SELECT calculate_governance_health($1)" - ) - .bind(community_id) - .fetch_one(pool) - .await?; - + pub async fn calculate_health(pool: &PgPool, community_id: Uuid) -> Result { + let health_id: Uuid = sqlx::query_scalar("SELECT calculate_governance_health($1)") + .bind(community_id) + .fetch_one(pool) + .await?; + Ok(health_id) } /// Calculate health scores for all communities pub async fn calculate_all_health_scores(pool: &PgPool) -> Result { - let communities = sqlx::query_scalar!( - "SELECT id FROM communities WHERE is_active = true" - ) - .fetch_all(pool) - .await?; + let communities = sqlx::query_scalar!("SELECT id FROM communities WHERE is_active = true") + .fetch_all(pool) + .await?; let mut count = 0; for community_id in communities { @@ -316,22 +303,24 @@ impl AnalyticsService { .fetch_all(pool) .await?; - Ok(methods.into_iter().map(|m| json!({ - "method": m.voting_method, - "proposals": m.proposals_using_method, - "total_votes": m.total_votes_cast, - "avg_turnout": m.turnout, - "avg_decision_time": m.avg_time, - "decisive_results": m.decisive_results, - "close_results": m.close_results - })).collect()) + Ok(methods + .into_iter() + .map(|m| { + json!({ + "method": m.voting_method, + "proposals": m.proposals_using_method, + "total_votes": m.total_votes_cast, + "avg_turnout": m.turnout, + "avg_decision_time": m.avg_time, + "decisive_results": m.decisive_results, + "close_results": m.close_results + }) + }) + .collect()) } /// Get full dashboard data - pub async fn get_dashboard( - pool: &PgPool, - community_id: Uuid, - ) -> Result { + pub async fn get_dashboard(pool: &PgPool, community_id: Uuid) -> Result { let health = Self::get_health(pool, community_id).await?; let trends = Self::get_participation_trends(pool, community_id, 30).await?; let delegation = Self::get_delegation_analytics(pool, community_id).await?; diff --git a/backend/src/plugins/builtin/moderation_ledger.rs b/backend/src/plugins/builtin/moderation_ledger.rs index 508748c..bb78849 100644 --- a/backend/src/plugins/builtin/moderation_ledger.rs +++ b/backend/src/plugins/builtin/moderation_ledger.rs @@ -12,10 +12,10 @@ use crate::plugins::{ }; /// Moderation Ledger Plugin -/// +/// /// Creates an immutable, cryptographically-chained log of all moderation decisions. /// This plugin is NON-DEACTIVATABLE by design - transparency is not optional. -/// +/// /// Features: /// - Immutable entries with SHA-256 hash chain /// - Full audit trail with actor, target, reason, evidence @@ -34,7 +34,7 @@ pub enum ModerationActionType { ContentEdit, ContentFlag, ContentUnflag, - + // User moderation UserWarn, UserMute, @@ -44,20 +44,20 @@ pub enum ModerationActionType { UserBan, UserUnban, UserRoleChange, - + // Community moderation CommunitySettingChange, CommunityRuleAdd, CommunityRuleEdit, CommunityRuleRemove, - + // Proposal/voting moderation ProposalClose, ProposalReopen, ProposalArchive, VoteInvalidate, VoteRestore, - + // Escalation EscalateToAdmin, EscalateToCommunity, @@ -216,7 +216,10 @@ impl LedgerService { } /// Get a single entry by ID - pub async fn get_entry(pool: &PgPool, entry_id: Uuid) -> Result, PluginError> { + pub async fn get_entry( + pool: &PgPool, + entry_id: Uuid, + ) -> Result, PluginError> { let entry = sqlx::query_as!( LedgerEntry, r#"SELECT @@ -412,7 +415,8 @@ impl Plugin for ModerationLedgerPlugin { Arc::new(move |ctx: HookContext, payload: Value| { let plugin_name = plugin_name.clone(); Box::pin(async move { - let actor_id = ctx.actor_user_id + let actor_id = ctx + .actor_user_id .ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?; let target_id = payload @@ -452,17 +456,20 @@ impl Plugin for ModerationLedgerPlugin { "unilateral", None, None, - ).await?; + ) + .await?; - let _ = ctx.emit_public_event( - Some(&plugin_name), - "ledger.entry_created", - json!({ - "entry_id": entry_id, - "action_type": "content_remove", - "target_type": content_type, - }), - ).await; + let _ = ctx + .emit_public_event( + Some(&plugin_name), + "ledger.entry_created", + json!({ + "entry_id": entry_id, + "action_type": "content_remove", + "target_type": content_type, + }), + ) + .await; Ok(()) }) @@ -478,7 +485,8 @@ impl Plugin for ModerationLedgerPlugin { Arc::new(move |ctx: HookContext, payload: Value| { let plugin_name = plugin_name2.clone(); Box::pin(async move { - let actor_id = ctx.actor_user_id + let actor_id = ctx + .actor_user_id .ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?; let target_user_id = payload @@ -534,17 +542,20 @@ impl Plugin for ModerationLedgerPlugin { "unilateral", None, None, - ).await?; + ) + .await?; - let _ = ctx.emit_public_event( - Some(&plugin_name), - "ledger.entry_created", - json!({ - "entry_id": entry_id, - "action_type": action, - "target_type": "user", - }), - ).await; + let _ = ctx + .emit_public_event( + Some(&plugin_name), + "ledger.entry_created", + json!({ + "entry_id": entry_id, + "action_type": action, + "target_type": "user", + }), + ) + .await; Ok(()) }) @@ -560,7 +571,8 @@ impl Plugin for ModerationLedgerPlugin { Arc::new(move |ctx: HookContext, payload: Value| { let plugin_name = plugin_name3.clone(); Box::pin(async move { - let actor_id = ctx.actor_user_id + let actor_id = ctx + .actor_user_id .ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?; let proposal_id = payload @@ -617,17 +629,20 @@ impl Plugin for ModerationLedgerPlugin { decision_type, vote_proposal_id, payload.get("vote_result").cloned(), - ).await?; + ) + .await?; - let _ = ctx.emit_public_event( - Some(&plugin_name), - "ledger.entry_created", - json!({ - "entry_id": entry_id, - "action_type": action, - "target_type": "proposal", - }), - ).await; + let _ = ctx + .emit_public_event( + Some(&plugin_name), + "ledger.entry_created", + json!({ + "entry_id": entry_id, + "action_type": action, + "target_type": "proposal", + }), + ) + .await; Ok(()) }) diff --git a/backend/src/plugins/builtin/proposal_lifecycle.rs b/backend/src/plugins/builtin/proposal_lifecycle.rs index 6cbd616..131cadf 100644 --- a/backend/src/plugins/builtin/proposal_lifecycle.rs +++ b/backend/src/plugins/builtin/proposal_lifecycle.rs @@ -6,9 +6,7 @@ use std::sync::Arc; use uuid::Uuid; use crate::plugins::{ - hooks::HookContext, - manager::PluginSystem, - Plugin, PluginError, PluginMetadata, PluginScope, + hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope, }; pub struct ProposalLifecyclePlugin; @@ -48,19 +46,29 @@ impl Plugin for ProposalLifecyclePlugin { 10, Arc::new(|ctx: HookContext, payload: Value| { Box::pin(async move { - if let Some(proposal_id) = payload.get("proposal_id") + if let Some(proposal_id) = payload + .get("proposal_id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok()) { let title = payload.get("title").and_then(|v| v.as_str()).unwrap_or(""); - let content = payload.get("content").and_then(|v| v.as_str()).unwrap_or(""); + let content = payload + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or(""); let summary = payload.get("change_summary").and_then(|v| v.as_str()); - + if let Some(user_id) = ctx.actor_user_id { LifecycleService::create_version( - &ctx.pool, proposal_id, title, content, - user_id, "edit", summary - ).await?; + &ctx.pool, + proposal_id, + title, + content, + user_id, + "edit", + summary, + ) + .await?; } } Ok(()) @@ -79,7 +87,8 @@ impl Plugin for ProposalLifecyclePlugin { Some("proposal_lifecycle"), "status.transition", payload.clone(), - ).await?; + ) + .await?; Ok(()) }) }), @@ -96,13 +105,13 @@ impl Plugin for ProposalLifecyclePlugin { Some("proposal_lifecycle"), "proposal.forked", payload.clone(), - ).await?; + ) + .await?; Ok(()) }) }), ); } - } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -143,18 +152,17 @@ impl LifecycleService { change_type: &str, change_summary: Option<&str>, ) -> Result { - let version: i32 = sqlx::query_scalar( - "SELECT create_proposal_version($1, $2, $3, NULL, $4, $5, $6)" - ) - .bind(proposal_id) - .bind(title) - .bind(content) - .bind(created_by) - .bind(change_type) - .bind(change_summary) - .fetch_one(pool) - .await?; - + let version: i32 = + sqlx::query_scalar("SELECT create_proposal_version($1, $2, $3, NULL, $4, $5, $6)") + .bind(proposal_id) + .bind(title) + .bind(content) + .bind(created_by) + .bind(change_type) + .bind(change_summary) + .fetch_one(pool) + .await?; + Ok(version) } @@ -168,7 +176,7 @@ impl LifecycleService { reason: Option<&str>, ) -> Result { let success: bool = sqlx::query_scalar( - "SELECT transition_proposal_status($1, $2::proposal_lifecycle_status, $3, $4, $5)" + "SELECT transition_proposal_status($1, $2::proposal_lifecycle_status, $3, $4, $5)", ) .bind(proposal_id) .bind(new_status) @@ -177,7 +185,7 @@ impl LifecycleService { .bind(reason) .fetch_one(pool) .await?; - + Ok(success) } @@ -189,16 +197,14 @@ impl LifecycleService { reason: &str, community_id: Uuid, ) -> Result { - let new_id: Uuid = sqlx::query_scalar( - "SELECT fork_proposal($1, $2, $3, $4)" - ) - .bind(source_proposal_id) - .bind(forked_by) - .bind(reason) - .bind(community_id) - .fetch_one(pool) - .await?; - + let new_id: Uuid = sqlx::query_scalar("SELECT fork_proposal($1, $2, $3, $4)") + .bind(source_proposal_id) + .bind(forked_by) + .bind(reason) + .bind(community_id) + .fetch_one(pool) + .await?; + Ok(new_id) } @@ -219,7 +225,7 @@ impl LifecycleService { ) .fetch_all(pool) .await?; - + Ok(versions) } @@ -241,7 +247,7 @@ impl LifecycleService { ) .fetch_optional(pool) .await?; - + Ok(version) } @@ -254,21 +260,19 @@ impl LifecycleService { ) -> Result { let from = Self::get_version(pool, proposal_id, from_version).await?; let to = Self::get_version(pool, proposal_id, to_version).await?; - + match (from, to) { - (Some(f), Some(t)) => { - Ok(json!({ - "from_version": from_version, - "to_version": to_version, - "title_changed": f.title != t.title, - "content_changed": f.content != t.content, - "from_title": f.title, - "to_title": t.title, - "from_content_length": f.content.len(), - "to_content_length": t.content.len(), - "change_summary": t.change_summary - })) - } + (Some(f), Some(t)) => Ok(json!({ + "from_version": from_version, + "to_version": to_version, + "title_changed": f.title != t.title, + "content_changed": f.content != t.content, + "from_title": f.title, + "to_title": t.title, + "from_content_length": f.content.len(), + "to_content_length": t.content.len(), + "change_summary": t.change_summary + })), _ => Ok(json!({"error": "Version not found"})), } } @@ -441,10 +445,7 @@ impl LifecycleService { } /// Get forks of a proposal - pub async fn get_forks( - pool: &PgPool, - proposal_id: Uuid, - ) -> Result, PluginError> { + pub async fn get_forks(pool: &PgPool, proposal_id: Uuid) -> Result, PluginError> { let forks = sqlx::query!( r#"SELECT pf.fork_proposal_id, @@ -464,14 +465,19 @@ impl LifecycleService { .fetch_all(pool) .await?; - Ok(forks.into_iter().map(|f| json!({ - "fork_id": f.fork_proposal_id, - "title": f.fork_title, - "forked_by": f.forked_by_username, - "forked_at": f.forked_at, - "reason": f.fork_reason, - "is_competing": f.is_competing, - "is_merged": f.is_merged - })).collect()) + Ok(forks + .into_iter() + .map(|f| { + json!({ + "fork_id": f.fork_proposal_id, + "title": f.fork_title, + "forked_by": f.forked_by_username, + "forked_at": f.forked_at, + "reason": f.fork_reason, + "is_competing": f.is_competing, + "is_merged": f.is_merged + }) + }) + .collect()) } } diff --git a/backend/src/plugins/builtin/public_data_export.rs b/backend/src/plugins/builtin/public_data_export.rs index e28374b..3acdf5b 100644 --- a/backend/src/plugins/builtin/public_data_export.rs +++ b/backend/src/plugins/builtin/public_data_export.rs @@ -6,9 +6,7 @@ use std::sync::Arc; use uuid::Uuid; use crate::plugins::{ - hooks::HookContext, - manager::PluginSystem, - Plugin, PluginError, PluginMetadata, PluginScope, + hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope, }; pub struct PublicDataExportPlugin; @@ -74,11 +72,13 @@ impl Plugin for PublicDataExportPlugin { 50, Arc::new(|ctx: HookContext, payload: Value| { Box::pin(async move { - if let Some(job_id) = payload.get("job_id") + if let Some(job_id) = payload + .get("job_id") .and_then(|v| v.as_str()) .and_then(|s| Uuid::parse_str(s).ok()) { - ExportService::record_download(&ctx.pool, job_id, ctx.actor_user_id).await?; + ExportService::record_download(&ctx.pool, job_id, ctx.actor_user_id) + .await?; } Ok(()) }) @@ -120,17 +120,16 @@ impl ExportService { date_from: Option>, date_to: Option>, ) -> Result { - let job_id: Uuid = sqlx::query_scalar( - "SELECT create_export_job($1, $2, $3::export_format, $4, $5, $6)" - ) - .bind(community_id) - .bind(export_type) - .bind(format) - .bind(requested_by) - .bind(date_from) - .bind(date_to) - .fetch_one(pool) - .await?; + let job_id: Uuid = + sqlx::query_scalar("SELECT create_export_job($1, $2, $3::export_format, $4, $5, $6)") + .bind(community_id) + .bind(export_type) + .bind(format) + .bind(requested_by) + .bind(date_from) + .bind(date_to) + .fetch_one(pool) + .await?; Ok(job_id) } @@ -160,7 +159,10 @@ impl ExportService { "proposals" => Self::export_proposals(pool, job.community_id, &job.format).await, "votes" => Self::export_votes(pool, job.community_id, &job.format).await, "analytics" => Self::export_analytics(pool, job.community_id, &job.format).await, - _ => Err(PluginError::Message(format!("Unknown export type: {}", job.export_type))), + _ => Err(PluginError::Message(format!( + "Unknown export type: {}", + job.export_type + ))), }; match result { @@ -201,8 +203,9 @@ impl ExportService { community_id: Option, format: &str, ) -> Result<(i32, String), PluginError> { - let community_id = community_id.ok_or_else(|| PluginError::Message("Community ID required".into()))?; - + let community_id = + community_id.ok_or_else(|| PluginError::Message("Community ID required".into()))?; + let proposals = sqlx::query!( r#"SELECT * FROM get_exportable_proposals($1, true, NULL, NULL)"#, community_id @@ -213,14 +216,19 @@ impl ExportService { let count = proposals.len() as i32; let data = match format { "json" => { - let items: Vec = proposals.iter().map(|p| json!({ - "id": p.id, - "title": p.title, - "author_id": p.author_id, - "status": p.status, - "created_at": p.created_at, - "vote_count": p.vote_count - })).collect(); + let items: Vec = proposals + .iter() + .map(|p| { + json!({ + "id": p.id, + "title": p.title, + "author_id": p.author_id, + "status": p.status, + "created_at": p.created_at, + "vote_count": p.vote_count + }) + }) + .collect(); serde_json::to_string_pretty(&items).unwrap_or_default() } "csv" => { @@ -250,8 +258,9 @@ impl ExportService { community_id: Option, format: &str, ) -> Result<(i32, String), PluginError> { - let community_id = community_id.ok_or_else(|| PluginError::Message("Community ID required".into()))?; - + let community_id = + community_id.ok_or_else(|| PluginError::Message("Community ID required".into()))?; + let votes = sqlx::query!( r#"SELECT v.id, v.proposal_id, @@ -269,12 +278,17 @@ impl ExportService { let count = votes.len() as i32; let data = match format { "json" => { - let items: Vec = votes.iter().map(|v| json!({ - "id": v.id, - "proposal_id": v.proposal_id, - "voter_hash": v.voter_hash, - "created_at": v.created_at - })).collect(); + let items: Vec = votes + .iter() + .map(|v| { + json!({ + "id": v.id, + "proposal_id": v.proposal_id, + "voter_hash": v.voter_hash, + "created_at": v.created_at + }) + }) + .collect(); serde_json::to_string_pretty(&items).unwrap_or_default() } "csv" => { @@ -282,7 +296,8 @@ impl ExportService { for v in &votes { csv.push_str(&format!( "{},{},{},{}\n", - v.id, v.proposal_id, + v.id, + v.proposal_id, v.voter_hash.as_deref().unwrap_or(""), v.created_at )); @@ -301,8 +316,9 @@ impl ExportService { community_id: Option, format: &str, ) -> Result<(i32, String), PluginError> { - let community_id = community_id.ok_or_else(|| PluginError::Message("Community ID required".into()))?; - + let community_id = + community_id.ok_or_else(|| PluginError::Message("Community ID required".into()))?; + let analytics = sqlx::query!( r#"SELECT snapshot_date, total_members, active_members, votes_cast FROM participation_snapshots @@ -317,12 +333,17 @@ impl ExportService { let count = analytics.len() as i32; let data = match format { "json" => { - let items: Vec = analytics.iter().map(|a| json!({ - "date": a.snapshot_date.to_string(), - "total_members": a.total_members, - "active_members": a.active_members, - "votes_cast": a.votes_cast - })).collect(); + let items: Vec = analytics + .iter() + .map(|a| { + json!({ + "date": a.snapshot_date.to_string(), + "total_members": a.total_members, + "active_members": a.active_members, + "votes_cast": a.votes_cast + }) + }) + .collect(); serde_json::to_string_pretty(&items).unwrap_or_default() } "csv" => { @@ -381,7 +402,10 @@ impl ExportService { } /// Get available exports for a community - pub async fn get_available(pool: &PgPool, community_id: Uuid) -> Result, PluginError> { + pub async fn get_available( + pool: &PgPool, + community_id: Uuid, + ) -> Result, PluginError> { let configs = sqlx::query_as!( ExportConfig, r#"SELECT id, community_id, name, export_type, public_access diff --git a/backend/src/plugins/builtin/self_moderation.rs b/backend/src/plugins/builtin/self_moderation.rs index 6d9eb99..a0d1293 100644 --- a/backend/src/plugins/builtin/self_moderation.rs +++ b/backend/src/plugins/builtin/self_moderation.rs @@ -6,9 +6,7 @@ use std::sync::Arc; use uuid::Uuid; use crate::plugins::{ - hooks::HookContext, - manager::PluginSystem, - Plugin, PluginError, PluginMetadata, PluginScope, + hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope, }; pub struct SelfModerationPlugin; @@ -67,12 +65,16 @@ impl Plugin for SelfModerationPlugin { payload.get("action").and_then(|v| v.as_str()), ) { let is_blocked = ModerationRulesService::check_user_blocked( - &ctx.pool, user_id, community_id, action - ).await?; - + &ctx.pool, + user_id, + community_id, + action, + ) + .await?; + if is_blocked { return Err(PluginError::Message( - "Action blocked due to active sanction".to_string() + "Action blocked due to active sanction".to_string(), )); } } @@ -92,7 +94,8 @@ impl Plugin for SelfModerationPlugin { Some("self_moderation_rules"), "violation.reported", payload.clone(), - ).await?; + ) + .await?; Ok(()) }) }), @@ -109,13 +112,13 @@ impl Plugin for SelfModerationPlugin { Some("self_moderation_rules"), "sanction.applied", payload.clone(), - ).await?; + ) + .await?; Ok(()) }) }), ); } - } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -182,20 +185,19 @@ impl ModerationRulesService { }; if let Some(st) = sanction_type { - let blocked: bool = sqlx::query_scalar( - "SELECT user_has_active_sanction($1, $2, $3::sanction_type)" - ) - .bind(user_id) - .bind(community_id) - .bind(st) - .fetch_one(pool) - .await?; + let blocked: bool = + sqlx::query_scalar("SELECT user_has_active_sanction($1, $2, $3::sanction_type)") + .bind(user_id) + .bind(community_id) + .bind(st) + .fetch_one(pool) + .await?; return Ok(blocked); } // Check for permanent ban let banned: bool = sqlx::query_scalar( - "SELECT user_has_active_sanction($1, $2, 'permanent_ban'::sanction_type)" + "SELECT user_has_active_sanction($1, $2, 'permanent_ban'::sanction_type)", ) .bind(user_id) .bind(community_id) @@ -251,14 +253,13 @@ impl ModerationRulesService { .fetch_one(pool) .await?; - let escalation_level: i32 = sqlx::query_scalar( - "SELECT calculate_escalation_level($1, $2, $3)" - ) - .bind(violation.target_user_id) - .bind(violation.community_id) - .bind(violation.rule_id) - .fetch_one(pool) - .await?; + let escalation_level: i32 = + sqlx::query_scalar("SELECT calculate_escalation_level($1, $2, $3)") + .bind(violation.target_user_id) + .bind(violation.community_id) + .bind(violation.rule_id) + .fetch_one(pool) + .await?; // Check if community vote is required let rule = sqlx::query!( @@ -353,15 +354,14 @@ impl ModerationRulesService { .await?; // Apply the sanction - let sanction_id: Uuid = sqlx::query_scalar( - "SELECT apply_sanction($1, $2::sanction_type, $3, $4, 'manual')" - ) - .bind(violation_id) - .bind(&sanction.sanction_type) - .bind(sanction.duration_hours) - .bind(applied_by) - .fetch_one(pool) - .await?; + let sanction_id: Uuid = + sqlx::query_scalar("SELECT apply_sanction($1, $2::sanction_type, $3, $4, 'manual')") + .bind(violation_id) + .bind(&sanction.sanction_type) + .bind(sanction.duration_hours) + .bind(applied_by) + .fetch_one(pool) + .await?; Ok(sanction_id) } @@ -513,18 +513,20 @@ impl ModerationRulesService { Ok(violations .into_iter() - .map(|v| json!({ - "id": v.id, - "rule_code": v.rule_code, - "rule_title": v.rule_title, - "severity": v.severity, - "target_user_id": v.target_user_id, - "target_username": v.target_username, - "reported_by": v.reported_by, - "status": v.status, - "reported_at": v.reported_at, - "reason": v.report_reason - })) + .map(|v| { + json!({ + "id": v.id, + "rule_code": v.rule_code, + "rule_title": v.rule_title, + "severity": v.severity, + "target_user_id": v.target_user_id, + "target_username": v.target_username, + "reported_by": v.reported_by, + "status": v.status, + "reported_at": v.reported_at, + "reason": v.report_reason + }) + }) .collect()) } } diff --git a/backend/src/plugins/builtin/structured_deliberation.rs b/backend/src/plugins/builtin/structured_deliberation.rs index 11698d4..4d1f3e9 100644 --- a/backend/src/plugins/builtin/structured_deliberation.rs +++ b/backend/src/plugins/builtin/structured_deliberation.rs @@ -6,9 +6,7 @@ use std::sync::Arc; use uuid::Uuid; use crate::plugins::{ - hooks::HookContext, - manager::PluginSystem, - Plugin, PluginError, PluginMetadata, PluginScope, + hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope, }; pub struct StructuredDeliberationPlugin; @@ -48,16 +46,23 @@ impl Plugin for StructuredDeliberationPlugin { 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()), + 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?; - + &ctx.pool, + proposal_id, + user_id, + "comment", + ) + .await?; + if !can_comment { return Err(PluginError::Message( - "Please read the proposal before commenting".to_string() + "Please read the proposal before commenting".to_string(), )); } } @@ -90,7 +95,8 @@ impl Plugin for StructuredDeliberationPlugin { Some("structured_deliberation"), "argument.created", payload.clone(), - ).await?; + ) + .await?; Ok(()) }) }), @@ -189,7 +195,7 @@ impl DeliberationService { author_id: Uuid, ) -> Result { let argument_id: Uuid = sqlx::query_scalar( - "SELECT add_deliberation_argument($1, $2, $3::argument_stance, $4, $5, $6)" + "SELECT add_deliberation_argument($1, $2, $3::argument_stance, $4, $5, $6)", ) .bind(proposal_id) .bind(parent_id) diff --git a/backend/src/plugins/hooks.rs b/backend/src/plugins/hooks.rs index 76e8133..e4525a7 100644 --- a/backend/src/plugins/hooks.rs +++ b/backend/src/plugins/hooks.rs @@ -1,9 +1,4 @@ -use std::{ - collections::HashMap, - future::Future, - pin::Pin, - sync::Arc, -}; +use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; use chrono::{DateTime, Utc}; use serde_json::Value; @@ -103,16 +98,10 @@ impl HookRegistry { } pub fn actions_for(&self, hook: &str) -> &[ActionHandler] { - self.actions - .get(hook) - .map(|v| v.as_slice()) - .unwrap_or(&[]) + self.actions.get(hook).map(|v| v.as_slice()).unwrap_or(&[]) } pub fn filters_for(&self, hook: &str) -> &[FilterHandler] { - self.filters - .get(hook) - .map(|v| v.as_slice()) - .unwrap_or(&[]) + self.filters.get(hook).map(|v| v.as_slice()).unwrap_or(&[]) } } diff --git a/backend/src/plugins/manager.rs b/backend/src/plugins/manager.rs index c049586..0c7cfb1 100644 --- a/backend/src/plugins/manager.rs +++ b/backend/src/plugins/manager.rs @@ -1,4 +1,7 @@ -use std::{collections::{HashMap, HashSet}, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use async_trait::async_trait; use serde_json::{json, Value}; @@ -6,9 +9,9 @@ use sqlx::PgPool; use uuid::Uuid; use crate::plugins::hooks::{ActionHandler, FilterHandler, HookContext, HookRegistry, PluginError}; -use crate::plugins::wasm::WasmPlugin; use crate::plugins::wasm::host_api::PluginManifest; use crate::plugins::wasm::runtime::WasmRuntime; +use crate::plugins::wasm::WasmPlugin; #[derive(Debug, Clone, Copy)] pub enum PluginScope { @@ -350,7 +353,8 @@ impl PluginManager { .await?; for community in communities { - self.ensure_default_community_plugins(community.id, None).await?; + self.ensure_default_community_plugins(community.id, None) + .await?; } for plugin in &self.plugins { @@ -360,12 +364,16 @@ impl PluginManager { Ok(Arc::new(self)) } - async fn active_plugins(&self, community_id: Option) -> Result, PluginError> { + async fn active_plugins( + &self, + community_id: Option, + ) -> Result, PluginError> { let mut active: HashSet = HashSet::new(); - let core = sqlx::query!("SELECT name FROM plugins WHERE is_active = true AND is_core = true") - .fetch_all(&self.pool) - .await?; + let core = + sqlx::query!("SELECT name FROM plugins WHERE is_active = true AND is_core = true") + .fetch_all(&self.pool) + .await?; for row in core { active.insert(row.name); } @@ -454,7 +462,12 @@ impl PluginManager { } } - pub async fn do_wasm_action_for_community(&self, hook: &str, community_id: Uuid, payload: Value) { + pub async fn do_wasm_action_for_community( + &self, + hook: &str, + community_id: Uuid, + payload: Value, + ) { let wasm = match sqlx::query!( r#"SELECT DISTINCT pp.id FROM plugin_packages pp @@ -468,7 +481,11 @@ impl PluginManager { { Ok(rows) => rows, Err(e) => { - tracing::error!("Failed to resolve active WASM plugins for hook {}: {}", hook, e); + tracing::error!( + "Failed to resolve active WASM plugins for hook {}: {}", + hook, + e + ); return; } }; @@ -621,12 +638,8 @@ impl PluginManager { if old_is_active && !new_is_active { self.invoke_deactivate(plugin_name, ctx.clone(), old_settings.clone()) .await; - self.do_action( - "plugin.deactivated", - ctx, - json!({"plugin": plugin_name}), - ) - .await; + self.do_action("plugin.deactivated", ctx, json!({"plugin": plugin_name})) + .await; } } diff --git a/backend/src/plugins/wasm/host_api.rs b/backend/src/plugins/wasm/host_api.rs index bd59ee7..c26e114 100644 --- a/backend/src/plugins/wasm/host_api.rs +++ b/backend/src/plugins/wasm/host_api.rs @@ -10,8 +10,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::PgPool; use tokio::runtime::Handle; -use tokio::task::block_in_place; use tokio::sync::Mutex; +use tokio::task::block_in_place; use uuid::Uuid; use wasmtime::{Linker, StoreLimits}; @@ -70,7 +70,11 @@ impl HostState { .find(|c| c.name == CAP_OUTBOUND_HTTP && c.allowed) .and_then(|c| c.config.get("allowlist")) .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) .unwrap_or_default(); Self { @@ -88,7 +92,9 @@ impl HostState { } pub fn has_capability(&self, name: &str) -> bool { - self.capabilities.iter().any(|c| c.name == name && c.allowed) + self.capabilities + .iter() + .any(|c| c.name == name && c.allowed) } pub fn is_url_allowed(&self, url: &str) -> bool { @@ -146,7 +152,7 @@ impl HostStateWithLimits { guard.data = data.to_vec(); data.len() as u32 } - + /// Get the result buffer contents pub fn get_result(&self) -> Vec { let guard = match self.inner.result_buffer.lock() { @@ -158,31 +164,40 @@ impl HostStateWithLimits { } /// Registers host functions for WASM plugins. -pub fn register_host_functions(linker: &mut Linker) -> Result<(), PluginError> { +pub fn register_host_functions( + linker: &mut Linker, +) -> Result<(), PluginError> { // host_log: Allow plugins to emit log messages linker - .func_wrap("env", "host_log", |mut caller: wasmtime::Caller<'_, HostStateWithLimits>, ptr: u32, len: u32, level: u32| { - let memory = caller.get_export("memory").and_then(|e| e.into_memory()); - if let Some(mem) = memory { - if len > MAX_WASM_STRING_BYTES { - return; - } - let mut buf = vec![0u8; len as usize]; - if mem.read(&caller, ptr as usize, &mut buf).is_ok() { - if let Ok(msg) = String::from_utf8(buf) { - let level_str = match level { - 0 => "TRACE", - 1 => "DEBUG", - 2 => "INFO", - 3 => "WARN", - _ => "ERROR", - }; - let plugin_name = caller.data().inner.plugin_name.clone(); - tracing::info!(plugin = %plugin_name, level = %level_str, "{}", msg); + .func_wrap( + "env", + "host_log", + |mut caller: wasmtime::Caller<'_, HostStateWithLimits>, + ptr: u32, + len: u32, + level: u32| { + let memory = caller.get_export("memory").and_then(|e| e.into_memory()); + if let Some(mem) = memory { + if len > MAX_WASM_STRING_BYTES { + return; + } + let mut buf = vec![0u8; len as usize]; + if mem.read(&caller, ptr as usize, &mut buf).is_ok() { + if let Ok(msg) = String::from_utf8(buf) { + let level_str = match level { + 0 => "TRACE", + 1 => "DEBUG", + 2 => "INFO", + 3 => "WARN", + _ => "ERROR", + }; + let plugin_name = caller.data().inner.plugin_name.clone(); + tracing::info!(plugin = %plugin_name, level = %level_str, "{}", msg); + } } } - } - }) + }, + ) .map_err(|e| PluginError::Message(format!("Failed to register host_log: {e}")))?; // host_get_setting: Retrieve plugin settings @@ -333,7 +348,7 @@ pub fn register_host_functions(linker: &mut Linker) -> Resu let mut key_buf = vec![0u8; key_len as usize]; let mut val_buf = vec![0u8; val_len as usize]; - + if memory.read(&caller, key_ptr as usize, &mut key_buf).is_err() { return 2; } @@ -399,7 +414,7 @@ pub fn register_host_functions(linker: &mut Linker) -> Resu let mut event_buf = vec![0u8; event_len as usize]; let mut payload_buf = vec![0u8; payload_len as usize]; - + if memory.read(&caller, event_ptr as usize, &mut event_buf).is_err() { return 2; } @@ -424,7 +439,7 @@ pub fn register_host_functions(linker: &mut Linker) -> Resu // Parse payload as JSON let payload: Value = serde_json::from_str(&payload_str).unwrap_or(Value::Null); - + let plugin_name = state.inner.plugin_name.clone(); let community_id = state.inner.community_id; let actor_user_id = state.inner.actor_user_id; @@ -472,21 +487,31 @@ pub fn register_host_functions(linker: &mut Linker) -> Resu // host_get_result: Copy result buffer to WASM memory // This is a helper for retrieving data from host functions linker - .func_wrap("env", "host_get_result", |mut caller: wasmtime::Caller<'_, HostStateWithLimits>, dest_ptr: u32, max_len: u32| -> u32 { - let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) { - Some(m) => m, - None => return 0, - }; + .func_wrap( + "env", + "host_get_result", + |mut caller: wasmtime::Caller<'_, HostStateWithLimits>, + dest_ptr: u32, + max_len: u32| + -> u32 { + let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) { + Some(m) => m, + None => return 0, + }; - let result = caller.data().get_result(); - let copy_len = std::cmp::min(result.len(), max_len as usize); - - if memory.write(&mut caller, dest_ptr as usize, &result[..copy_len]).is_err() { - return 0; - } - - copy_len as u32 - }) + let result = caller.data().get_result(); + let copy_len = std::cmp::min(result.len(), max_len as usize); + + if memory + .write(&mut caller, dest_ptr as usize, &result[..copy_len]) + .is_err() + { + return 0; + } + + copy_len as u32 + }, + ) .map_err(|e| PluginError::Message(format!("Failed to register host_get_result: {e}")))?; // host_http_request: Make outbound HTTP request (capability-gated) @@ -503,7 +528,7 @@ pub fn register_host_functions(linker: &mut Linker) -> Resu let mut url_buf = vec![0u8; url_len as usize]; let mut method_buf = vec![0u8; method_len as usize]; - + if memory.read(&caller, url_ptr as usize, &mut url_buf).is_err() { return pack_result(2, 0); } @@ -544,7 +569,7 @@ pub fn register_host_functions(linker: &mut Linker) -> Resu }; let state = caller.data(); - + if !state.inner.has_capability(CAP_OUTBOUND_HTTP) { tracing::warn!(plugin = %state.inner.plugin_name, url = %url, "HTTP denied: no capability"); return pack_result(6, 0); diff --git a/backend/src/plugins/wasm/plugin.rs b/backend/src/plugins/wasm/plugin.rs index 05dc095..1eda165 100644 --- a/backend/src/plugins/wasm/plugin.rs +++ b/backend/src/plugins/wasm/plugin.rs @@ -1,261 +1,274 @@ -//! WASM plugin implementation. -//! -//! Wraps compiled WASM modules and implements the Plugin trait for -//! integration with the hook system. - -use std::sync::Arc; - -use async_trait::async_trait; -use serde_json::{json, Value}; -use sqlx::PgPool; -use uuid::Uuid; - -use super::host_api::{Capability, HostState, PluginManifest, CAP_EMIT_EVENTS, CAP_KV_STORE, CAP_OUTBOUND_HTTP, CAP_SETTINGS}; -use super::runtime::{CompiledPlugin, ExecutionLimits, PluginInstance}; -use crate::plugins::hooks::{HookContext, PluginError}; -use crate::plugins::manager::{Plugin, PluginMetadata, PluginScope, PluginSystem}; - -/// A WASM-based plugin that can be loaded dynamically. -pub struct WasmPlugin { - package_id: Uuid, - manifest: PluginManifest, - compiled: Arc, - limits: ExecutionLimits, -} - -async fn capabilities_for_manifest( - pool: &PgPool, - community_id: Option, - manifest_capabilities: &[String], -) -> Result, PluginError> { - let mut out: Vec = Vec::new(); - - let (allow_http, allowlist) = if let Some(cid) = community_id { - let row = sqlx::query!( - r#"SELECT settings as "settings!: serde_json::Value" FROM communities WHERE id = $1"#, - cid - ) - .fetch_optional(pool) - .await?; - - if let Some(row) = row { - let allow_http = row - .settings - .get("plugin_allow_outbound_http") - .and_then(|v: &serde_json::Value| v.as_bool()) - .unwrap_or(false); - - let allowlist: Vec = row - .settings - .get("plugin_http_egress_allowlist") - .and_then(|v: &serde_json::Value| v.as_array()) - .map(|arr: &Vec| { - arr.iter() - .filter_map(|v: &serde_json::Value| v.as_str().map(|s: &str| s.to_string())) - .collect() - }) - .unwrap_or_default(); - - (allow_http, allowlist) - } else { - (false, Vec::new()) - } - } else { - (false, Vec::new()) - }; - - for cap in manifest_capabilities { - match cap.as_str() { - CAP_OUTBOUND_HTTP => { - let allowed = allow_http && !allowlist.is_empty(); - out.push(Capability { - name: cap.clone(), - allowed, - config: serde_json::json!({"allowlist": allowlist}), - }); - } - CAP_SETTINGS | CAP_KV_STORE | CAP_EMIT_EVENTS => { - out.push(Capability { - name: cap.clone(), - allowed: true, - config: serde_json::json!({}), - }); - } - _ => { - out.push(Capability { - name: cap.clone(), - allowed: false, - config: serde_json::json!({}), - }); - } - } - } - - Ok(out) -} - -impl WasmPlugin { - /// Creates a new WASM plugin from a manifest and compiled module. - pub fn new( - package_id: Uuid, - manifest: PluginManifest, - compiled: Arc, - ) -> Self { - Self { - package_id, - manifest, - compiled, - limits: ExecutionLimits::default(), - } - } - - /// Sets custom execution limits for this plugin. - #[allow(dead_code)] // API for future use - pub fn with_limits(mut self, limits: ExecutionLimits) -> Self { - self.limits = limits; - self - } - - async fn capabilities_for(&self, pool: &PgPool, ctx: &HookContext) -> Result, PluginError> { - capabilities_for_manifest(pool, ctx.community_id, &self.manifest.capabilities).await - } - - async fn create_instance(&self, ctx: &HookContext) -> Result { - let capabilities = self.capabilities_for(&ctx.pool, ctx).await?; - let host_state = HostState::new( - self.manifest.name.clone(), - ctx.community_id, - ctx.actor_user_id, - ctx.pool.clone(), - self.package_id, - capabilities, - ); - - PluginInstance::new(&self.compiled, host_state, self.limits.clone()).await - } -} - -#[async_trait] -impl Plugin for WasmPlugin { - fn metadata(&self) -> PluginMetadata { - PluginMetadata { - name: Box::leak(self.manifest.name.clone().into_boxed_str()), - version: Box::leak(self.manifest.version.clone().into_boxed_str()), - description: Box::leak(self.manifest.description.clone().into_boxed_str()), - is_core: false, - scope: PluginScope::Community, - default_enabled: false, - settings_schema: self.manifest.settings_schema.clone(), - } - } - - fn register(&self, system: &mut PluginSystem) { - let plugin_name = self.manifest.name.clone(); - let package_id = self.package_id; - let handler_plugin_id = format!("wasm:{}", package_id); - let manifest_capabilities = self.manifest.capabilities.clone(); - let compiled = self.compiled.clone(); - let limits = self.limits.clone(); - - for hook in &self.manifest.hooks { - let hook_name = hook.clone(); - let hook_name_ref = hook_name.clone(); - let plugin_name_clone = plugin_name.clone(); - let handler_plugin_id_clone = handler_plugin_id.clone(); - let compiled_clone = compiled.clone(); - let limits_clone = limits.clone(); - let manifest_capabilities_for_hook = manifest_capabilities.clone(); - - system.add_action( - &hook_name_ref, - handler_plugin_id_clone.clone(), - 50, - Arc::new(move |ctx: HookContext, payload: Value| { - let hook = hook_name.clone(); - let plugin = plugin_name_clone.clone(); - let package_id = package_id; - let manifest_capabilities = manifest_capabilities_for_hook.clone(); - let compiled = compiled_clone.clone(); - let lim = limits_clone.clone(); - - Box::pin(async move { - let capabilities = capabilities_for_manifest( - &ctx.pool, - ctx.community_id, - &manifest_capabilities, - ) - .await?; - - let host_state = HostState::new( - plugin.clone(), - ctx.community_id, - ctx.actor_user_id, - ctx.pool.clone(), - package_id, - capabilities, - ); - - let mut instance = PluginInstance::new(&compiled, host_state, lim).await?; - - let payload_json = serde_json::to_string(&payload) - .map_err(|e| PluginError::Message(format!("Failed to serialize payload: {e}")))?; - - let _result = instance.call_hook(&hook, &payload_json).await?; - - let remaining_fuel = instance.get_fuel(); - tracing::debug!( - plugin = %plugin, - hook = %hook, - fuel_remaining = remaining_fuel, - "WASM plugin hook completed" - ); - - Ok(()) - }) - }), - ); - } - } - - async fn activate(&self, ctx: HookContext, settings: Value) -> Result<(), PluginError> { - let mut instance = self.create_instance(&ctx).await?; - let payload = json!({ - "event": "activate", - "settings": settings - }); - let payload_json = serde_json::to_string(&payload) - .map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?; - instance.call_hook("lifecycle.activate", &payload_json).await.ok(); - Ok(()) - } - - async fn deactivate(&self, ctx: HookContext, settings: Value) -> Result<(), PluginError> { - let mut instance = self.create_instance(&ctx).await?; - let payload = json!({ - "event": "deactivate", - "settings": settings - }); - let payload_json = serde_json::to_string(&payload) - .map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?; - instance.call_hook("lifecycle.deactivate", &payload_json).await.ok(); - Ok(()) - } - - async fn settings_updated( - &self, - ctx: HookContext, - old_settings: Value, - new_settings: Value, - ) -> Result<(), PluginError> { - let mut instance = self.create_instance(&ctx).await?; - let payload = json!({ - "event": "settings_updated", - "old_settings": old_settings, - "new_settings": new_settings - }); - let payload_json = serde_json::to_string(&payload) - .map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?; - instance.call_hook("lifecycle.settings_updated", &payload_json).await.ok(); - Ok(()) - } -} +//! WASM plugin implementation. +//! +//! Wraps compiled WASM modules and implements the Plugin trait for +//! integration with the hook system. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Value}; +use sqlx::PgPool; +use uuid::Uuid; + +use super::host_api::{ + Capability, HostState, PluginManifest, CAP_EMIT_EVENTS, CAP_KV_STORE, CAP_OUTBOUND_HTTP, + CAP_SETTINGS, +}; +use super::runtime::{CompiledPlugin, ExecutionLimits, PluginInstance}; +use crate::plugins::hooks::{HookContext, PluginError}; +use crate::plugins::manager::{Plugin, PluginMetadata, PluginScope, PluginSystem}; + +/// A WASM-based plugin that can be loaded dynamically. +pub struct WasmPlugin { + package_id: Uuid, + manifest: PluginManifest, + compiled: Arc, + limits: ExecutionLimits, +} + +async fn capabilities_for_manifest( + pool: &PgPool, + community_id: Option, + manifest_capabilities: &[String], +) -> Result, PluginError> { + let mut out: Vec = Vec::new(); + + let (allow_http, allowlist) = if let Some(cid) = community_id { + let row = sqlx::query!( + r#"SELECT settings as "settings!: serde_json::Value" FROM communities WHERE id = $1"#, + cid + ) + .fetch_optional(pool) + .await?; + + if let Some(row) = row { + let allow_http = row + .settings + .get("plugin_allow_outbound_http") + .and_then(|v: &serde_json::Value| v.as_bool()) + .unwrap_or(false); + + let allowlist: Vec = row + .settings + .get("plugin_http_egress_allowlist") + .and_then(|v: &serde_json::Value| v.as_array()) + .map(|arr: &Vec| { + arr.iter() + .filter_map(|v: &serde_json::Value| v.as_str().map(|s: &str| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + (allow_http, allowlist) + } else { + (false, Vec::new()) + } + } else { + (false, Vec::new()) + }; + + for cap in manifest_capabilities { + match cap.as_str() { + CAP_OUTBOUND_HTTP => { + let allowed = allow_http && !allowlist.is_empty(); + out.push(Capability { + name: cap.clone(), + allowed, + config: serde_json::json!({"allowlist": allowlist}), + }); + } + CAP_SETTINGS | CAP_KV_STORE | CAP_EMIT_EVENTS => { + out.push(Capability { + name: cap.clone(), + allowed: true, + config: serde_json::json!({}), + }); + } + _ => { + out.push(Capability { + name: cap.clone(), + allowed: false, + config: serde_json::json!({}), + }); + } + } + } + + Ok(out) +} + +impl WasmPlugin { + /// Creates a new WASM plugin from a manifest and compiled module. + pub fn new(package_id: Uuid, manifest: PluginManifest, compiled: Arc) -> Self { + Self { + package_id, + manifest, + compiled, + limits: ExecutionLimits::default(), + } + } + + /// Sets custom execution limits for this plugin. + #[allow(dead_code)] // API for future use + pub fn with_limits(mut self, limits: ExecutionLimits) -> Self { + self.limits = limits; + self + } + + async fn capabilities_for( + &self, + pool: &PgPool, + ctx: &HookContext, + ) -> Result, PluginError> { + capabilities_for_manifest(pool, ctx.community_id, &self.manifest.capabilities).await + } + + async fn create_instance(&self, ctx: &HookContext) -> Result { + let capabilities = self.capabilities_for(&ctx.pool, ctx).await?; + let host_state = HostState::new( + self.manifest.name.clone(), + ctx.community_id, + ctx.actor_user_id, + ctx.pool.clone(), + self.package_id, + capabilities, + ); + + PluginInstance::new(&self.compiled, host_state, self.limits.clone()).await + } +} + +#[async_trait] +impl Plugin for WasmPlugin { + fn metadata(&self) -> PluginMetadata { + PluginMetadata { + name: Box::leak(self.manifest.name.clone().into_boxed_str()), + version: Box::leak(self.manifest.version.clone().into_boxed_str()), + description: Box::leak(self.manifest.description.clone().into_boxed_str()), + is_core: false, + scope: PluginScope::Community, + default_enabled: false, + settings_schema: self.manifest.settings_schema.clone(), + } + } + + fn register(&self, system: &mut PluginSystem) { + let plugin_name = self.manifest.name.clone(); + let package_id = self.package_id; + let handler_plugin_id = format!("wasm:{}", package_id); + let manifest_capabilities = self.manifest.capabilities.clone(); + let compiled = self.compiled.clone(); + let limits = self.limits.clone(); + + for hook in &self.manifest.hooks { + let hook_name = hook.clone(); + let hook_name_ref = hook_name.clone(); + let plugin_name_clone = plugin_name.clone(); + let handler_plugin_id_clone = handler_plugin_id.clone(); + let compiled_clone = compiled.clone(); + let limits_clone = limits.clone(); + let manifest_capabilities_for_hook = manifest_capabilities.clone(); + + system.add_action( + &hook_name_ref, + handler_plugin_id_clone.clone(), + 50, + Arc::new(move |ctx: HookContext, payload: Value| { + let hook = hook_name.clone(); + let plugin = plugin_name_clone.clone(); + let package_id = package_id; + let manifest_capabilities = manifest_capabilities_for_hook.clone(); + let compiled = compiled_clone.clone(); + let lim = limits_clone.clone(); + + Box::pin(async move { + let capabilities = capabilities_for_manifest( + &ctx.pool, + ctx.community_id, + &manifest_capabilities, + ) + .await?; + + let host_state = HostState::new( + plugin.clone(), + ctx.community_id, + ctx.actor_user_id, + ctx.pool.clone(), + package_id, + capabilities, + ); + + let mut instance = PluginInstance::new(&compiled, host_state, lim).await?; + + let payload_json = serde_json::to_string(&payload).map_err(|e| { + PluginError::Message(format!("Failed to serialize payload: {e}")) + })?; + + let _result = instance.call_hook(&hook, &payload_json).await?; + + let remaining_fuel = instance.get_fuel(); + tracing::debug!( + plugin = %plugin, + hook = %hook, + fuel_remaining = remaining_fuel, + "WASM plugin hook completed" + ); + + Ok(()) + }) + }), + ); + } + } + + async fn activate(&self, ctx: HookContext, settings: Value) -> Result<(), PluginError> { + let mut instance = self.create_instance(&ctx).await?; + let payload = json!({ + "event": "activate", + "settings": settings + }); + let payload_json = serde_json::to_string(&payload) + .map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?; + instance + .call_hook("lifecycle.activate", &payload_json) + .await + .ok(); + Ok(()) + } + + async fn deactivate(&self, ctx: HookContext, settings: Value) -> Result<(), PluginError> { + let mut instance = self.create_instance(&ctx).await?; + let payload = json!({ + "event": "deactivate", + "settings": settings + }); + let payload_json = serde_json::to_string(&payload) + .map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?; + instance + .call_hook("lifecycle.deactivate", &payload_json) + .await + .ok(); + Ok(()) + } + + async fn settings_updated( + &self, + ctx: HookContext, + old_settings: Value, + new_settings: Value, + ) -> Result<(), PluginError> { + let mut instance = self.create_instance(&ctx).await?; + let payload = json!({ + "event": "settings_updated", + "old_settings": old_settings, + "new_settings": new_settings + }); + let payload_json = serde_json::to_string(&payload) + .map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?; + instance + .call_hook("lifecycle.settings_updated", &payload_json) + .await + .ok(); + Ok(()) + } +} diff --git a/backend/src/plugins/wasm/runtime.rs b/backend/src/plugins/wasm/runtime.rs index d5ce8c9..24fd604 100644 --- a/backend/src/plugins/wasm/runtime.rs +++ b/backend/src/plugins/wasm/runtime.rs @@ -57,13 +57,13 @@ impl WasmRuntime { let engine = Arc::new( Engine::new(&config) - .map_err(|e| PluginError::Message(format!("Failed to create WASM engine: {e}")))? + .map_err(|e| PluginError::Message(format!("Failed to create WASM engine: {e}")))?, ); // Spawn epoch ticker for timeout enforcement let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); let engine_clone = engine.clone(); - + tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_millis(10)); loop { @@ -78,7 +78,7 @@ impl WasmRuntime { } }); - Ok(Self { + Ok(Self { engine, _epoch_ticker_shutdown: Some(shutdown_tx), }) @@ -140,11 +140,11 @@ impl PluginInstance { let mut store = Store::new(compiled.engine(), state_with_limits); store.limiter(|state| &mut state.limits); - - store.set_fuel(limits.fuel).map_err(|e| { - PluginError::Message(format!("Failed to set fuel limit: {e}")) - })?; - + + store + .set_fuel(limits.fuel) + .map_err(|e| PluginError::Message(format!("Failed to set fuel limit: {e}")))?; + let epoch_deadline = (limits.timeout_ms / 10).max(1); store.epoch_deadline_async_yield_and_update(epoch_deadline); @@ -177,7 +177,9 @@ impl PluginInstance { let handle_hook = self .instance .get_typed_func::<(u32, u32, u32, u32), u64>(&mut self.store, "handle_hook") - .map_err(|e| PluginError::Message(format!("Plugin missing 'handle_hook' export: {e}")))?; + .map_err(|e| { + PluginError::Message(format!("Plugin missing 'handle_hook' export: {e}")) + })?; let memory = self .instance @@ -185,15 +187,21 @@ impl PluginInstance { .ok_or_else(|| PluginError::Message("Plugin missing 'memory' export".to_string()))?; let hook_bytes = hook_name.as_bytes(); - let hook_ptr = alloc.call_async(&mut self.store, hook_bytes.len() as u32).await + let hook_ptr = alloc + .call_async(&mut self.store, hook_bytes.len() as u32) + .await .map_err(|e| PluginError::Message(format!("alloc failed for hook name: {e}")))?; - memory.write(&mut self.store, hook_ptr as usize, hook_bytes) + memory + .write(&mut self.store, hook_ptr as usize, hook_bytes) .map_err(|e| PluginError::Message(format!("Failed to write hook name: {e}")))?; let payload_bytes = payload_json.as_bytes(); - let payload_ptr = alloc.call_async(&mut self.store, payload_bytes.len() as u32).await + let payload_ptr = alloc + .call_async(&mut self.store, payload_bytes.len() as u32) + .await .map_err(|e| PluginError::Message(format!("alloc failed for payload: {e}")))?; - memory.write(&mut self.store, payload_ptr as usize, payload_bytes) + memory + .write(&mut self.store, payload_ptr as usize, payload_bytes) .map_err(|e| PluginError::Message(format!("Failed to write payload: {e}")))?; let result = handle_hook @@ -213,12 +221,22 @@ impl PluginInstance { let result_len = (result & 0xFFFFFFFF) as u32; let mut result_bytes = vec![0u8; result_len as usize]; - memory.read(&self.store, result_ptr as usize, &mut result_bytes) + memory + .read(&self.store, result_ptr as usize, &mut result_bytes) .map_err(|e| PluginError::Message(format!("Failed to read result: {e}")))?; - dealloc.call_async(&mut self.store, (hook_ptr, hook_bytes.len() as u32)).await.ok(); - dealloc.call_async(&mut self.store, (payload_ptr, payload_bytes.len() as u32)).await.ok(); - dealloc.call_async(&mut self.store, (result_ptr, result_len)).await.ok(); + dealloc + .call_async(&mut self.store, (hook_ptr, hook_bytes.len() as u32)) + .await + .ok(); + dealloc + .call_async(&mut self.store, (payload_ptr, payload_bytes.len() as u32)) + .await + .ok(); + dealloc + .call_async(&mut self.store, (result_ptr, result_len)) + .await + .ok(); String::from_utf8(result_bytes) .map_err(|e| PluginError::Message(format!("Result is not valid UTF-8: {e}"))) diff --git a/backend/src/rate_limit.rs b/backend/src/rate_limit.rs index 0bf23ad..72d220a 100644 --- a/backend/src/rate_limit.rs +++ b/backend/src/rate_limit.rs @@ -98,20 +98,14 @@ impl FixedWindowLimiter { } fn parse_ip_from_headers(headers: &HeaderMap) -> Option { - if let Some(forwarded) = headers - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - { + if let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) { let first = forwarded.split(',').next().map(|s| s.trim()).unwrap_or(""); if let Ok(ip) = first.parse::() { return Some(ip); } } - if let Some(real_ip) = headers - .get("x-real-ip") - .and_then(|v| v.to_str().ok()) - { + if let Some(real_ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) { if let Ok(ip) = real_ip.trim().parse::() { return Some(ip); } diff --git a/backend/src/voting/mod.rs b/backend/src/voting/mod.rs index 6d85c3a..43c2ec4 100644 --- a/backend/src/voting/mod.rs +++ b/backend/src/voting/mod.rs @@ -6,10 +6,10 @@ //! - Quadratic Voting (intensity-weighted preferences) //! - Ranked Choice / Instant Runoff -pub mod schulze; -pub mod star; pub mod quadratic; pub mod ranked_choice; +pub mod schulze; +pub mod star; use serde::{Deserialize, Serialize}; use uuid::Uuid; diff --git a/backend/src/voting/quadratic.rs b/backend/src/voting/quadratic.rs index 9bc61f5..1e94279 100644 --- a/backend/src/voting/quadratic.rs +++ b/backend/src/voting/quadratic.rs @@ -78,12 +78,14 @@ pub fn calculate(options: &[Uuid], ballots: &[QuadraticBallot]) -> VotingResult } // Sort by total votes (descending) - let mut sorted_votes: Vec<(Uuid, i64)> = vote_totals.iter() + let mut sorted_votes: Vec<(Uuid, i64)> = vote_totals + .iter() .map(|(&id, &votes)| (id, votes)) .collect(); sorted_votes.sort_by(|a, b| b.1.cmp(&a.1)); - let ranking: Vec = sorted_votes.iter() + let ranking: Vec = sorted_votes + .iter() .enumerate() .map(|(i, (id, votes))| RankedOption { option_id: *id, @@ -154,7 +156,7 @@ mod tests { ]; let result = calculate(&options, &ballots); - + // A: 9 votes, B: 6 votes -> A wins despite fewer supporters // This demonstrates intensity expression assert_eq!(result.winner, Some(a)); @@ -165,12 +167,10 @@ mod tests { let a = Uuid::new_v4(); let options = vec![a]; - let ballots = vec![ - QuadraticBallot { - total_credits: 100, - allocations: vec![(a, 11)], // Costs 121, exceeds 100 - }, - ]; + let ballots = vec![QuadraticBallot { + total_credits: 100, + allocations: vec![(a, 11)], // Costs 121, exceeds 100 + }]; let result = calculate(&options, &ballots); // Invalid ballot should be skipped diff --git a/backend/src/voting/ranked_choice.rs b/backend/src/voting/ranked_choice.rs index 1d956a3..86e065f 100644 --- a/backend/src/voting/ranked_choice.rs +++ b/backend/src/voting/ranked_choice.rs @@ -36,9 +36,8 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult { loop { // Count first-choice votes among active options - let mut vote_counts: HashMap = active_options.iter() - .map(|&id| (id, 0)) - .collect(); + let mut vote_counts: HashMap = + active_options.iter().map(|&id| (id, 0)).collect(); for ballot in ballots { // Find first choice among active options @@ -53,7 +52,8 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult { } // Sort by vote count - let mut sorted: Vec<(Uuid, i64)> = vote_counts.iter() + let mut sorted: Vec<(Uuid, i64)> = vote_counts + .iter() .map(|(&id, &count)| (id, count)) .collect(); sorted.sort_by(|a, b| b.1.cmp(&a.1)); @@ -70,7 +70,8 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult { }); // Build final ranking - let mut final_ranking: Vec = sorted.iter() + let mut final_ranking: Vec = sorted + .iter() .enumerate() .map(|(i, (id, count))| RankedOption { option_id: *id, @@ -91,10 +92,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult { return VotingResult { winner: Some(*winner), ranking: final_ranking, - details: VotingDetails::RankedChoice { - rounds, - eliminated, - }, + details: VotingDetails::RankedChoice { rounds, eliminated }, total_ballots: ballots.len(), }; } @@ -103,14 +101,15 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult { // Only one option left - it wins if active_options.len() <= 1 { let winner = active_options.iter().next().cloned(); - + rounds.push(RoundResult { round: round_num, vote_counts: sorted.clone(), eliminated: None, }); - let mut final_ranking: Vec = sorted.iter() + let mut final_ranking: Vec = sorted + .iter() .enumerate() .map(|(i, (id, count))| RankedOption { option_id: *id, @@ -130,10 +129,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult { return VotingResult { winner, ranking: final_ranking, - details: VotingDetails::RankedChoice { - rounds, - eliminated, - }, + details: VotingDetails::RankedChoice { rounds, eliminated }, total_ballots: ballots.len(), }; } @@ -141,7 +137,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult { // Eliminate lowest-ranked option if let Some((loser, _)) = sorted.last() { let loser_id = *loser; - + rounds.push(RoundResult { round: round_num, vote_counts: sorted, @@ -159,10 +155,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult { VotingResult { winner: None, ranking: vec![], - details: VotingDetails::RankedChoice { - rounds, - eliminated, - }, + details: VotingDetails::RankedChoice { rounds, eliminated }, total_ballots: ballots.len(), } } @@ -180,11 +173,21 @@ mod tests { // A has clear majority let ballots = vec![ - RankedBallot { rankings: vec![a, b, c] }, - RankedBallot { rankings: vec![a, b, c] }, - RankedBallot { rankings: vec![a, c, b] }, - RankedBallot { rankings: vec![b, a, c] }, - RankedBallot { rankings: vec![c, b, a] }, + RankedBallot { + rankings: vec![a, b, c], + }, + RankedBallot { + rankings: vec![a, b, c], + }, + RankedBallot { + rankings: vec![a, c, b], + }, + RankedBallot { + rankings: vec![b, a, c], + }, + RankedBallot { + rankings: vec![c, b, a], + }, ]; let result = calculate(&options, &ballots); @@ -200,19 +203,29 @@ mod tests { // No first-round majority, C eliminated, B wins let ballots = vec![ - RankedBallot { rankings: vec![a, b, c] }, - RankedBallot { rankings: vec![a, b, c] }, - RankedBallot { rankings: vec![b, a, c] }, - RankedBallot { rankings: vec![b, c, a] }, - RankedBallot { rankings: vec![c, b, a] }, // C's vote goes to B + RankedBallot { + rankings: vec![a, b, c], + }, + RankedBallot { + rankings: vec![a, b, c], + }, + RankedBallot { + rankings: vec![b, a, c], + }, + RankedBallot { + rankings: vec![b, c, a], + }, + RankedBallot { + rankings: vec![c, b, a], + }, // C's vote goes to B ]; let result = calculate(&options, &ballots); - + // Round 1: A=2, B=2, C=1 -> C eliminated // Round 2: A=2, B=3 -> B wins with majority assert_eq!(result.winner, Some(b)); - + if let VotingDetails::RankedChoice { rounds, eliminated } = &result.details { assert_eq!(rounds.len(), 2); assert_eq!(eliminated, &vec![c]); @@ -230,15 +243,25 @@ mod tests { let options = vec![a, b, spoiler]; let ballots = vec![ - RankedBallot { rankings: vec![a, spoiler, b] }, - RankedBallot { rankings: vec![a, spoiler, b] }, - RankedBallot { rankings: vec![spoiler, a, b] }, // Spoiler fans prefer A - RankedBallot { rankings: vec![b, a, spoiler] }, - RankedBallot { rankings: vec![b, a, spoiler] }, + RankedBallot { + rankings: vec![a, spoiler, b], + }, + RankedBallot { + rankings: vec![a, spoiler, b], + }, + RankedBallot { + rankings: vec![spoiler, a, b], + }, // Spoiler fans prefer A + RankedBallot { + rankings: vec![b, a, spoiler], + }, + RankedBallot { + rankings: vec![b, a, spoiler], + }, ]; let result = calculate(&options, &ballots); - + // Without RCV, spoiler might split A's vote // With RCV, spoiler eliminated, vote goes to A // Round 1: A=2, B=2, Spoiler=1 -> Spoiler eliminated diff --git a/backend/src/voting/schulze.rs b/backend/src/voting/schulze.rs index 84f673d..dda239c 100644 --- a/backend/src/voting/schulze.rs +++ b/backend/src/voting/schulze.rs @@ -32,11 +32,8 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult { } // Create option index mapping - let _option_index: HashMap = options - .iter() - .enumerate() - .map(|(i, &id)| (id, i)) - .collect(); + let _option_index: HashMap = + options.iter().enumerate().map(|(i, &id)| (id, i)).collect(); // Build pairwise preference matrix // d[i][j] = number of voters who prefer option i over option j @@ -111,9 +108,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult { .iter() .enumerate() .map(|(i, &opt_id)| { - let win_count: i32 = (0..n) - .filter(|&j| i != j && p[i][j] > p[j][i]) - .count() as i32; + let win_count: i32 = (0..n).filter(|&j| i != j && p[i][j] > p[j][i]).count() as i32; (opt_id, win_count) }) .collect(); @@ -159,11 +154,21 @@ mod tests { // 3 voters prefer A > B > C // 2 voters prefer B > C > A let ballots = vec![ - RankedBallot { rankings: vec![(a, 1), (b, 2), (c, 3)] }, - RankedBallot { rankings: vec![(a, 1), (b, 2), (c, 3)] }, - RankedBallot { rankings: vec![(a, 1), (b, 2), (c, 3)] }, - RankedBallot { rankings: vec![(b, 1), (c, 2), (a, 3)] }, - RankedBallot { rankings: vec![(b, 1), (c, 2), (a, 3)] }, + RankedBallot { + rankings: vec![(a, 1), (b, 2), (c, 3)], + }, + RankedBallot { + rankings: vec![(a, 1), (b, 2), (c, 3)], + }, + RankedBallot { + rankings: vec![(a, 1), (b, 2), (c, 3)], + }, + RankedBallot { + rankings: vec![(b, 1), (c, 2), (a, 3)], + }, + RankedBallot { + rankings: vec![(b, 1), (c, 2), (a, 3)], + }, ]; let result = calculate(&options, &ballots); @@ -181,11 +186,21 @@ mod tests { // A beats B, B beats C, C beats A let ballots = vec![ - RankedBallot { rankings: vec![(a, 1), (b, 2), (c, 3)] }, - RankedBallot { rankings: vec![(a, 1), (b, 2), (c, 3)] }, - RankedBallot { rankings: vec![(b, 1), (c, 2), (a, 3)] }, - RankedBallot { rankings: vec![(b, 1), (c, 2), (a, 3)] }, - RankedBallot { rankings: vec![(c, 1), (a, 2), (b, 3)] }, + RankedBallot { + rankings: vec![(a, 1), (b, 2), (c, 3)], + }, + RankedBallot { + rankings: vec![(a, 1), (b, 2), (c, 3)], + }, + RankedBallot { + rankings: vec![(b, 1), (c, 2), (a, 3)], + }, + RankedBallot { + rankings: vec![(b, 1), (c, 2), (a, 3)], + }, + RankedBallot { + rankings: vec![(c, 1), (a, 2), (b, 3)], + }, ]; let result = calculate(&options, &ballots); diff --git a/backend/src/voting/star.rs b/backend/src/voting/star.rs index d0c6dbe..f2783b6 100644 --- a/backend/src/voting/star.rs +++ b/backend/src/voting/star.rs @@ -44,7 +44,8 @@ pub fn calculate(options: &[Uuid], ballots: &[ScoreBallot]) -> VotingResult { } // Sort by total score (descending) - let mut sorted_scores: Vec<(Uuid, i64)> = score_totals.iter() + let mut sorted_scores: Vec<(Uuid, i64)> = score_totals + .iter() .map(|(&id, &score)| (id, score)) .collect(); sorted_scores.sort_by(|a, b| b.1.cmp(&a.1)); @@ -54,13 +55,15 @@ pub fn calculate(options: &[Uuid], ballots: &[ScoreBallot]) -> VotingResult { let winner = sorted_scores.first().map(|(id, _)| *id); return VotingResult { winner, - ranking: sorted_scores.iter().enumerate().map(|(i, (id, score))| { - RankedOption { + ranking: sorted_scores + .iter() + .enumerate() + .map(|(i, (id, score))| RankedOption { option_id: *id, rank: i + 1, score: *score as f64, - } - }).collect(), + }) + .collect(), details: VotingDetails::Star { score_totals: sorted_scores, finalists: (winner.unwrap_or(Uuid::nil()), Uuid::nil()), @@ -80,7 +83,7 @@ pub fn calculate(options: &[Uuid], ballots: &[ScoreBallot]) -> VotingResult { for ballot in ballots { let ballot_scores: HashMap = ballot.scores.iter().cloned().collect(); - + let score_a = ballot_scores.get(&finalist_a).copied().unwrap_or(0); let score_b = ballot_scores.get(&finalist_b).copied().unwrap_or(0); @@ -100,13 +103,15 @@ pub fn calculate(options: &[Uuid], ballots: &[ScoreBallot]) -> VotingResult { }; // Build final ranking - let mut ranking: Vec = sorted_scores.iter().enumerate().map(|(i, (id, score))| { - RankedOption { + let mut ranking: Vec = sorted_scores + .iter() + .enumerate() + .map(|(i, (id, score))| RankedOption { option_id: *id, rank: i + 1, score: *score as f64, - } - }).collect(); + }) + .collect(); // Adjust ranking for runoff result (swap if needed) if winner == finalist_b && ranking.len() >= 2 { @@ -139,9 +144,15 @@ mod tests { let options = vec![a, b, c]; let ballots = vec![ - ScoreBallot { scores: vec![(a, 5), (b, 3), (c, 1)] }, - ScoreBallot { scores: vec![(a, 5), (b, 2), (c, 0)] }, - ScoreBallot { scores: vec![(a, 4), (b, 4), (c, 2)] }, + ScoreBallot { + scores: vec![(a, 5), (b, 3), (c, 1)], + }, + ScoreBallot { + scores: vec![(a, 5), (b, 2), (c, 0)], + }, + ScoreBallot { + scores: vec![(a, 4), (b, 4), (c, 2)], + }, ]; let result = calculate(&options, &ballots); @@ -157,11 +168,21 @@ mod tests { // A gets high scores from few, B gets moderate scores from many let ballots = vec![ - ScoreBallot { scores: vec![(a, 5), (b, 4)] }, // Prefers A - ScoreBallot { scores: vec![(a, 5), (b, 4)] }, // Prefers A - ScoreBallot { scores: vec![(a, 0), (b, 3)] }, // Prefers B - ScoreBallot { scores: vec![(a, 0), (b, 3)] }, // Prefers B - ScoreBallot { scores: vec![(a, 0), (b, 3)] }, // Prefers B + ScoreBallot { + scores: vec![(a, 5), (b, 4)], + }, // Prefers A + ScoreBallot { + scores: vec![(a, 5), (b, 4)], + }, // Prefers A + ScoreBallot { + scores: vec![(a, 0), (b, 3)], + }, // Prefers B + ScoreBallot { + scores: vec![(a, 0), (b, 3)], + }, // Prefers B + ScoreBallot { + scores: vec![(a, 0), (b, 3)], + }, // Prefers B ]; let result = calculate(&options, &ballots);