fmt: rustfmt backend

This commit is contained in:
Marco Allegretti 2026-02-03 17:54:39 +01:00
parent a889bc3ff3
commit 99c0c300b5
56 changed files with 2692 additions and 1624 deletions

View file

@ -136,12 +136,33 @@ async fn export_data(
pub fn router(pool: PgPool) -> Router { pub fn router(pool: PgPool) -> Router {
Router::new() Router::new()
.route("/api/communities/{community_id}/analytics/dashboard", get(get_dashboard)) .route(
.route("/api/communities/{community_id}/analytics/health", get(get_health)) "/api/communities/{community_id}/analytics/dashboard",
.route("/api/communities/{community_id}/analytics/participation", get(get_participation_trends)) get(get_dashboard),
.route("/api/communities/{community_id}/analytics/delegation", get(get_delegation_analytics)) )
.route("/api/communities/{community_id}/analytics/decision-load", get(get_decision_load)) .route(
.route("/api/communities/{community_id}/analytics/voting-methods", get(get_voting_method_comparison)) "/api/communities/{community_id}/analytics/health",
.route("/api/communities/{community_id}/analytics/export", get(export_data)) 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) .with_state(pool)
} }

View file

@ -6,13 +6,13 @@ use axum::{
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use chrono::{DateTime, Utc};
use super::permissions::{perms, require_permission};
use crate::auth::AuthUser; use crate::auth::AuthUser;
use super::permissions::{require_permission, perms};
// ============================================================================ // ============================================================================
// Types // Types
@ -85,15 +85,20 @@ async fn list_pending_registrations(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(registrations.into_iter().map(|r| PendingRegistration { Ok(Json(
id: r.id, registrations
username: r.username, .into_iter()
email: r.email, .map(|r| PendingRegistration {
display_name: r.display_name, id: r.id,
status: r.status.unwrap_or_default(), username: r.username,
created_at: r.created_at.unwrap_or_else(Utc::now), email: r.email,
expires_at: r.expires_at, display_name: r.display_name,
}).collect())) 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) /// Review a pending registration (approve or reject)
@ -117,9 +122,15 @@ async fn review_registration(
.map_err(|e| { .map_err(|e| {
let msg = e.to_string(); let msg = e.to_string();
if msg.contains("not found") || msg.contains("already processed") { 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") { } else if msg.contains("expired") {
(StatusCode::GONE, "Registration request has expired".to_string()) (
StatusCode::GONE,
"Registration request has expired".to_string(),
)
} else { } else {
(StatusCode::INTERNAL_SERVER_ERROR, msg) (StatusCode::INTERNAL_SERVER_ERROR, msg)
} }
@ -147,13 +158,11 @@ async fn review_registration(
.ok(); .ok();
} }
let is_admin: bool = sqlx::query_scalar!( let is_admin: bool =
"SELECT is_admin FROM users WHERE id = $1", sqlx::query_scalar!("SELECT is_admin FROM users WHERE id = $1", new_user_id)
new_user_id .fetch_one(&pool)
) .await
.fetch_one(&pool) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if is_admin { if is_admin {
let admin_role_id: Option<Uuid> = sqlx::query_scalar!( let admin_role_id: Option<Uuid> = sqlx::query_scalar!(
@ -204,7 +213,10 @@ async fn review_registration(
message: "Registration rejected".to_string(), message: "Registration rejected".to_string(),
})) }))
} else { } 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(),
))
} }
} }
} }
@ -237,16 +249,21 @@ async fn list_pending_communities(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(communities.into_iter().map(|c| PendingCommunity { Ok(Json(
id: c.id, communities
name: c.name, .into_iter()
slug: c.slug, .map(|c| PendingCommunity {
description: c.description, id: c.id,
requested_by: c.requested_by, name: c.name,
requested_by_username: Some(c.requester_username), slug: c.slug,
status: c.status.unwrap_or_default(), description: c.description,
created_at: c.created_at.unwrap_or_else(Utc::now), requested_by: c.requested_by,
}).collect())) 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) /// Review a pending community request (approve or reject)
@ -260,21 +277,21 @@ async fn review_community(
if req.approve { if req.approve {
// Approve community // Approve community
let result = sqlx::query_scalar!( let result =
"SELECT approve_community($1, $2)", sqlx::query_scalar!("SELECT approve_community($1, $2)", pending_id, auth.user_id)
pending_id, .fetch_one(&pool)
auth.user_id .await
) .map_err(|e| {
.fetch_one(&pool) let msg = e.to_string();
.await if msg.contains("not found") || msg.contains("already processed") {
.map_err(|e| { (
let msg = e.to_string(); StatusCode::NOT_FOUND,
if msg.contains("not found") || msg.contains("already processed") { "Pending community not found or already processed".to_string(),
(StatusCode::NOT_FOUND, "Pending community not found or already processed".to_string()) )
} else { } else {
(StatusCode::INTERNAL_SERVER_ERROR, msg) (StatusCode::INTERNAL_SERVER_ERROR, msg)
} }
})?; })?;
Ok(Json(ReviewResponse { Ok(Json(ReviewResponse {
success: true, success: true,
@ -301,7 +318,10 @@ async fn review_community(
message: "Community request rejected".to_string(), message: "Community request rejected".to_string(),
})) }))
} else { } 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 { pub fn router(pool: PgPool) -> Router {
Router::new() Router::new()
.route("/api/approvals/registrations", get(list_pending_registrations)) .route(
.route("/api/approvals/registrations/{id}", post(review_registration)) "/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", get(list_pending_communities))
.route("/api/approvals/communities/{id}", post(review_community)) .route("/api/approvals/communities/{id}", post(review_community))
.with_state(pool) .with_state(pool)

View file

@ -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 serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
@ -68,10 +73,16 @@ async fn register(
let registration_mode = if let Some(s) = &settings { let registration_mode = if let Some(s) = &settings {
if !s.registration_enabled { 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() { 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() s.registration_mode.clone()
} else { } else {
@ -90,24 +101,41 @@ async fn register(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
match validation { 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) => { Some(inv) => {
if !inv.is_active.unwrap_or(false) { 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 let Some(exp) = inv.expires_at {
if exp < chrono::Utc::now() { 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 let Some(max) = inv.max_uses {
if inv.uses_count.unwrap_or(0) >= max { 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 let Some(email) = &inv.email {
if email != &req.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 inv.community_id
@ -162,7 +190,10 @@ async fn register(
})?; })?;
// Return a special response indicating pending approval // 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) // Direct registration (open mode, invite_only with valid invite, or first user)
@ -183,7 +214,10 @@ async fn register(
.await .await
.map_err(|e| { .map_err(|e| {
if e.to_string().contains("duplicate key") { 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 { } else {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
} }
@ -234,10 +268,15 @@ async fn register(
// Use invitation if provided (records usage and links user) // Use invitation if provided (records usage and links user)
if let Some(code) = &req.invitation_code { if let Some(code) = &req.invitation_code {
sqlx::query!("SELECT use_invitation($1, $2, $3)", code, user.id, req.email.as_str()) sqlx::query!(
.fetch_one(&pool) "SELECT use_invitation($1, $2, $3)",
.await code,
.ok(); // Ignore errors - user is already created 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 // If invitation was for a specific community, add user as member
@ -283,7 +322,10 @@ async fn login(
.fetch_optional(&pool) .fetch_optional(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .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) let token = create_token(user.id, &user.username, &config.jwt_secret)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

View file

@ -2,8 +2,7 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
routing::get, routing::get,
Extension, Extension, Json, Router,
Json, Router,
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -14,8 +13,8 @@ use uuid::Uuid;
use crate::auth::AuthUser; use crate::auth::AuthUser;
use crate::plugins::HookContext; use crate::plugins::HookContext;
use crate::plugins::PluginManager;
use crate::plugins::PluginError; use crate::plugins::PluginError;
use crate::plugins::PluginManager;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct Comment { pub struct Comment {
@ -36,7 +35,10 @@ pub struct CreateComment {
pub fn router(pool: PgPool) -> Router { pub fn router(pool: PgPool) -> Router {
Router::new() 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) .with_state(pool)
} }

View file

@ -2,8 +2,7 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
routing::{get, post}, routing::{get, post},
Extension, Extension, Json, Router,
Json, Router,
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -11,10 +10,10 @@ use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use super::permissions::{perms, user_has_permission};
use crate::auth::AuthUser; use crate::auth::AuthUser;
use crate::models::community::CommunityResponse; use crate::models::community::CommunityResponse;
use crate::plugins::{HookContext, PluginManager}; use crate::plugins::{HookContext, PluginManager};
use super::permissions::{user_has_permission, perms};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreateCommunityRequest { pub struct CreateCommunityRequest {
@ -35,7 +34,10 @@ use axum::routing::put;
pub fn router(pool: PgPool) -> Router { pub fn router(pool: PgPool) -> Router {
Router::new() 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}", put(update_community))
.route("/api/communities/{id}/details", get(get_community_details)) .route("/api/communities/{id}/details", get(get_community_details))
.route("/api/communities/{id}/join", post(join_community)) .route("/api/communities/{id}/join", post(join_community))
@ -58,7 +60,12 @@ async fn list_communities(
.await .await
.map_err(|e| e.to_string())?; .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( async fn create_community(
@ -68,28 +75,34 @@ async fn create_community(
Json(req): Json<CreateCommunityRequest>, Json(req): Json<CreateCommunityRequest>,
) -> Result<Json<CommunityResponse>, (StatusCode, String)> { ) -> Result<Json<CommunityResponse>, (StatusCode, String)> {
// Check platform mode for community creation permissions // Check platform mode for community creation permissions
let settings = sqlx::query!( let settings = sqlx::query!("SELECT platform_mode FROM instance_settings LIMIT 1")
"SELECT platform_mode FROM instance_settings LIMIT 1" .fetch_optional(&pool)
) .await
.fetch_optional(&pool) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some(s) = settings { if let Some(s) = settings {
match s.platform_mode.as_str() { match s.platform_mode.as_str() {
"single_community" => { "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" => { "admin_only" => {
// Check platform admin or community create permission // 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 { 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" => { "approval" => {
// Check if user has direct create permission (admins bypass 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 { if !can_create {
// Create pending community request instead // Create pending community request instead
sqlx::query!( sqlx::query!(
@ -104,13 +117,20 @@ async fn create_community(
.await .await
.map_err(|e| { .map_err(|e| {
if e.to_string().contains("duplicate key") { 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 { } else {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) (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 _ => {} // "open" mode - anyone can create
@ -132,7 +152,10 @@ async fn create_community(
.await .await
.map_err(|e| { .map_err(|e| {
if e.to_string().contains("duplicate key") { 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 { } else {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
} }
@ -153,7 +176,11 @@ async fn create_community(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .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))) Ok(Json(CommunityResponse::from(community)))
} }
@ -237,7 +264,10 @@ async fn join_community(
return Err((StatusCode::BAD_REQUEST, err.to_string())); 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!( sqlx::query!(
"INSERT INTO community_members (user_id, community_id, role) VALUES ($1, $2, $3)", "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), community_id: Some(community_id),
actor_user_id: Some(auth.user_id), actor_user_id: Some(auth.user_id),
}; };
let _ = plugins.do_action("member.join", ctx, serde_json::json!({ let _ = plugins
"community_id": community_id.to_string(), .do_action(
"user_id": auth.user_id.to_string(), "member.join",
"username": auth.username.clone(), ctx,
"role": role, serde_json::json!({
})).await; "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); tracing::info!("User {} joined community {}", auth.username, community_id);
Ok(Json(serde_json::json!({"status": "joined"}))) Ok(Json(serde_json::json!({"status": "joined"})))
@ -300,7 +336,10 @@ async fn leave_community(
.unwrap_or(0); .unwrap_or(0);
if admin_count <= 1 { 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), community_id: Some(community_id),
actor_user_id: Some(auth.user_id), actor_user_id: Some(auth.user_id),
}; };
let _ = plugins.do_action("member.leave", ctx, serde_json::json!({ let _ = plugins
"community_id": community_id.to_string(), .do_action(
"user_id": auth.user_id.to_string(), "member.leave",
"username": auth.username.clone(), ctx,
"role": role, serde_json::json!({
})).await; "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); tracing::info!("User {} left community {}", auth.username, community_id);
Ok(Json(serde_json::json!({"status": "left"}))) Ok(Json(serde_json::json!({"status": "left"})))
@ -429,7 +474,12 @@ async fn my_communities(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .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)] #[derive(Debug, Serialize)]
@ -458,21 +508,24 @@ async fn recent_activity(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut activities: Vec<ActivityItem> = proposals.into_iter().map(|p| { let mut activities: Vec<ActivityItem> = proposals
let desc = match p.status.as_str() { .into_iter()
"voting" => "Now open for voting", .map(|p| {
"discussion" => "Open for discussion", let desc = match p.status.as_str() {
"closed" => "Voting completed", "voting" => "Now open for voting",
_ => "New proposal created", "discussion" => "Open for discussion",
}; "closed" => "Voting completed",
ActivityItem { _ => "New proposal created",
activity_type: "proposal".to_string(), };
title: p.title, ActivityItem {
description: desc.to_string(), activity_type: "proposal".to_string(),
link: format!("/proposals/{}", p.id), title: p.title,
created_at: p.created_at, description: desc.to_string(),
} link: format!("/proposals/{}", p.id),
}).collect(); created_at: p.created_at,
}
})
.collect();
let communities = sqlx::query!( let communities = sqlx::query!(
"SELECT name, slug, created_at FROM communities WHERE is_active = true ORDER BY created_at DESC LIMIT 5" "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) .fetch_optional(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .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" { 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!( let updated = sqlx::query_as!(

View file

@ -269,16 +269,37 @@ async fn add_to_mediator_pool(
pub fn router(pool: PgPool) -> Router { pub fn router(pool: PgPool) -> Router {
Router::new() Router::new()
// Community conflicts // Community conflicts
.route("/api/communities/{community_id}/conflicts", get(get_active_conflicts).post(report_conflict)) .route(
.route("/api/communities/{community_id}/conflicts/stats", get(get_statistics)) "/api/communities/{community_id}/conflicts",
.route("/api/communities/{community_id}/mediators", post(add_to_mediator_pool)) 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 // Individual conflict operations
.route("/api/conflicts/{conflict_id}", get(get_conflict)) .route("/api/conflicts/{conflict_id}", get(get_conflict))
.route("/api/conflicts/{conflict_id}/status", post(transition_status)) .route(
.route("/api/conflicts/{conflict_id}/compromise", post(propose_compromise)) "/api/conflicts/{conflict_id}/status",
.route("/api/conflicts/{conflict_id}/session", post(schedule_session)) 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)) .route("/api/conflicts/{conflict_id}/note", post(add_note))
// Compromise responses // 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) .with_state(pool)
} }

View file

@ -9,10 +9,10 @@ use axum::{
routing::{delete, get}, routing::{delete, get},
Json, Router, Json, Router,
}; };
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::auth::AuthUser; use crate::auth::AuthUser;
@ -56,7 +56,9 @@ pub struct CreateDelegationRequest {
pub weight: f64, pub weight: f64,
} }
fn default_weight() -> f64 { 1.0 } fn default_weight() -> f64 {
1.0
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct DelegateProfile { pub struct DelegateProfile {
@ -134,35 +136,56 @@ async fn create_delegation(
) -> Result<Json<Delegation>, (StatusCode, String)> { ) -> Result<Json<Delegation>, (StatusCode, String)> {
// Validate weight // Validate weight
if req.weight <= 0.0 || req.weight > 1.0 { 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 // Validate scope references to avoid DB constraint errors
match req.scope { match req.scope {
DelegationScope::Global => { DelegationScope::Global => {
if req.community_id.is_some() || req.topic_id.is_some() || req.proposal_id.is_some() { 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 => { DelegationScope::Community => {
if req.community_id.is_none() { 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() { 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 => { DelegationScope::Topic => {
if req.topic_id.is_none() { 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() { 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 => { DelegationScope::Proposal => {
if req.proposal_id.is_none() { 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 let Some(p) = profile {
if !p.accepting_delegations { 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 .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(delegations.into_iter().map(|d| { Ok(Json(
Delegation { delegations
id: d.id, .into_iter()
delegator_id: d.delegator_id, .map(|d| Delegation {
delegate_id: d.delegate_id, id: d.id,
delegate_username: Some(d.delegate_username), delegator_id: d.delegator_id,
scope: d.scope, delegate_id: d.delegate_id,
community_id: d.community_id, delegate_username: Some(d.delegate_username),
topic_id: d.topic_id, scope: d.scope,
proposal_id: d.proposal_id, community_id: d.community_id,
weight: d.weight, topic_id: d.topic_id,
is_active: d.is_active, proposal_id: d.proposal_id,
created_at: d.created_at, weight: d.weight,
} is_active: d.is_active,
}).collect())) created_at: d.created_at,
})
.collect(),
))
} }
/// List delegations TO a user (they are the delegate) /// List delegations TO a user (they are the delegate)
@ -376,21 +405,24 @@ async fn list_delegations_to_me(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(delegations.into_iter().map(|d| { Ok(Json(
Delegation { delegations
id: d.id, .into_iter()
delegator_id: d.delegator_id, .map(|d| Delegation {
delegate_id: d.delegate_id, id: d.id,
delegate_username: Some(d.delegator_username), delegator_id: d.delegator_id,
scope: d.scope, delegate_id: d.delegate_id,
community_id: d.community_id, delegate_username: Some(d.delegator_username),
topic_id: d.topic_id, scope: d.scope,
proposal_id: d.proposal_id, community_id: d.community_id,
weight: d.weight, topic_id: d.topic_id,
is_active: d.is_active, proposal_id: d.proposal_id,
created_at: d.created_at, weight: d.weight,
} is_active: d.is_active,
}).collect())) created_at: d.created_at,
})
.collect(),
))
} }
/// Revoke a delegation /// Revoke a delegation
@ -552,16 +584,21 @@ async fn list_delegates(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(profiles.into_iter().map(|p| DelegateProfile { Ok(Json(
user_id: p.user_id, profiles
username: p.username, .into_iter()
display_name: p.display_name, .map(|p| DelegateProfile {
bio: p.bio, user_id: p.user_id,
accepting_delegations: p.accepting_delegations, username: p.username,
delegation_policy: p.delegation_policy, display_name: p.display_name,
total_delegators: p.total_delegators, bio: p.bio,
total_votes_cast: p.total_votes_cast, accepting_delegations: p.accepting_delegations,
}).collect())) 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) .fetch_optional(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .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" { 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!( let topic = sqlx::query_as!(
@ -635,13 +678,25 @@ async fn create_topic(
pub fn router(pool: PgPool) -> Router { pub fn router(pool: PgPool) -> Router {
Router::new() Router::new()
// Delegations // 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/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 // Delegate profiles
.route("/api/delegates", get(list_delegates)) .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 // 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) .with_state(pool)
} }

View file

@ -6,14 +6,14 @@ use axum::{
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::auth::AuthUser; use crate::auth::AuthUser;
use crate::plugins::builtin::structured_deliberation::{Argument, Summary, DeliberationService}; use crate::plugins::builtin::structured_deliberation::{Argument, DeliberationService, Summary};
// ============================================================================ // ============================================================================
// Types // Types
@ -167,11 +167,14 @@ async fn add_resource(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?; .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
let can_add = proposal.author_id == auth.user_id let can_add =
|| proposal.facilitator_id == Some(auth.user_id); proposal.author_id == auth.user_id || proposal.facilitator_id == Some(auth.user_id);
if !can_add { 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!( let resource = sqlx::query_as!(
@ -246,11 +249,16 @@ async fn get_read_status(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(statuses.into_iter().map(|s| ResourceReadStatus { Ok(Json(
resource_id: s.resource_id, statuses
has_read: s.read_at.is_some(), .into_iter()
read_at: s.read_at, .map(|s| ResourceReadStatus {
}).collect())) 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 /// Set user's position on a proposal
@ -401,14 +409,10 @@ async fn list_arguments(
State(pool): State<PgPool>, State(pool): State<PgPool>,
) -> Result<Json<Vec<Argument>>, (StatusCode, String)> { ) -> Result<Json<Vec<Argument>>, (StatusCode, String)> {
let limit = query.limit.unwrap_or(50); let limit = query.limit.unwrap_or(50);
let arguments = DeliberationService::get_arguments( let arguments =
&pool, DeliberationService::get_arguments(&pool, proposal_id, query.stance.as_deref(), limit)
proposal_id, .await
query.stance.as_deref(), .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
limit,
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(arguments)) Ok(Json(arguments))
} }
@ -421,12 +425,16 @@ async fn add_argument(
Json(req): Json<AddArgumentRequest>, Json(req): Json<AddArgumentRequest>,
) -> Result<Json<Value>, (StatusCode, String)> { ) -> Result<Json<Value>, (StatusCode, String)> {
// Check if user can participate // Check if user can participate
let can = DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment") let can =
.await DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !can { 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( let argument_id = DeliberationService::add_argument(
@ -539,15 +547,20 @@ async fn check_can_participate(
Path(proposal_id): Path<Uuid>, Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>, State(pool): State<PgPool>,
) -> Result<Json<CanParticipateResponse>, (StatusCode, String)> { ) -> Result<Json<CanParticipateResponse>, (StatusCode, String)> {
let can_comment = DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment") let can_comment =
.await DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .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") let can_vote =
.await DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "vote")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .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) /// Get deliberation overview (metrics + top arguments + summaries)
@ -569,24 +582,51 @@ async fn get_overview(
pub fn router(pool: PgPool) -> Router { pub fn router(pool: PgPool) -> Router {
Router::new() Router::new()
// Resources (inform phase) // Resources (inform phase)
.route("/api/proposals/{proposal_id}/resources", get(list_resources).post(add_resource)) .route(
.route("/api/proposals/{proposal_id}/resources/read-status", get(get_read_status)) "/api/proposals/{proposal_id}/resources",
.route("/api/resources/{resource_id}/read", post(mark_resource_read)) 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) // Positions (agreement visualization)
.route("/api/proposals/{proposal_id}/positions", post(set_position)) .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) // 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 // 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)) .route("/api/arguments/{argument_id}/vote", post(vote_argument))
// Summaries (collaborative summaries) - wired to DeliberationService // 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)) .route("/api/summaries/{summary_id}/approve", post(approve_summary))
// Reading/participation tracking - wired to DeliberationService // Reading/participation tracking - wired to DeliberationService
.route("/api/proposals/{proposal_id}/reading", post(record_reading)) .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) // Overview (combined metrics)
.route("/api/proposals/{proposal_id}/deliberation", get(get_overview)) .route(
"/api/proposals/{proposal_id}/deliberation",
get(get_overview),
)
.with_state(pool) .with_state(pool)
} }

View file

@ -11,10 +11,10 @@ use serde_json::json;
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
use super::permissions::{perms, require_permission};
use crate::auth::AuthUser; use crate::auth::AuthUser;
use crate::config::Config; use crate::config::Config;
use crate::demo::{self, DEMO_ACCOUNTS}; use crate::demo::{self, DEMO_ACCOUNTS};
use super::permissions::{require_permission, perms};
/// Combined state for demo endpoints /// Combined state for demo endpoints
#[derive(Clone)] #[derive(Clone)]
@ -24,9 +24,7 @@ pub struct DemoState {
} }
/// Get demo mode status and available accounts /// Get demo mode status and available accounts
async fn get_demo_status( async fn get_demo_status(State(state): State<DemoState>) -> impl IntoResponse {
State(state): State<DemoState>,
) -> impl IntoResponse {
Json(json!({ Json(json!({
"demo_mode": state.config.is_demo(), "demo_mode": state.config.is_demo(),
"accounts": if state.config.is_demo() { "accounts": if state.config.is_demo() {
@ -52,45 +50,42 @@ async fn get_demo_status(
} }
/// Reset demo data to initial state (only in demo mode) /// Reset demo data to initial state (only in demo mode)
async fn reset_demo( async fn reset_demo(State(state): State<DemoState>, auth: AuthUser) -> impl IntoResponse {
State(state): State<DemoState>,
auth: AuthUser,
) -> impl IntoResponse {
if !state.config.is_demo() { if !state.config.is_demo() {
return ( return (
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
Json(json!({"error": "Demo mode not enabled"})) Json(json!({"error": "Demo mode not enabled"})),
).into_response(); )
.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(); return (status, Json(json!({"error": msg}))).into_response();
} }
match demo::reset_demo_data(&state.pool).await { match demo::reset_demo_data(&state.pool).await {
Ok(_) => ( Ok(_) => (
StatusCode::OK, StatusCode::OK,
Json(json!({"success": true, "message": "Demo data has been reset to initial state"})) Json(json!({"success": true, "message": "Demo data has been reset to initial state"})),
).into_response(), )
.into_response(),
Err(e) => { Err(e) => {
tracing::error!("Failed to reset demo data: {}", e); tracing::error!("Failed to reset demo data: {}", e);
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "Failed to reset demo data"})) Json(json!({"error": "Failed to reset demo data"})),
).into_response() )
.into_response()
} }
} }
} }
/// Get demo communities summary /// Get demo communities summary
async fn get_demo_communities( async fn get_demo_communities(State(state): State<DemoState>) -> impl IntoResponse {
State(state): State<DemoState>,
) -> impl IntoResponse {
if !state.config.is_demo() { if !state.config.is_demo() {
return ( return (StatusCode::OK, Json(json!({"communities": []}))).into_response();
StatusCode::OK,
Json(json!({"communities": []}))
).into_response();
} }
let communities = sqlx::query_as::<_, (String, String, String, i64, i64)>( let communities = sqlx::query_as::<_, (String, String, String, i64, i64)>(
@ -104,22 +99,25 @@ async fn get_demo_communities(
FROM communities c FROM communities c
WHERE c.slug IN ('aurora', 'civic-commons', 'makers') WHERE c.slug IN ('aurora', 'civic-commons', 'makers')
ORDER BY c.name ORDER BY c.name
"# "#,
) )
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await; .await;
match communities { match communities {
Ok(rows) => { Ok(rows) => {
let communities: Vec<_> = rows.iter().map(|(name, slug, desc, members, proposals)| { let communities: Vec<_> = rows
json!({ .iter()
"name": name, .map(|(name, slug, desc, members, proposals)| {
"slug": slug, json!({
"description": desc, "name": name,
"member_count": members, "slug": slug,
"proposal_count": proposals "description": desc,
"member_count": members,
"proposal_count": proposals
})
}) })
}).collect(); .collect();
(StatusCode::OK, Json(json!({"communities": communities}))).into_response() (StatusCode::OK, Json(json!({"communities": communities}))).into_response()
} }
@ -127,8 +125,9 @@ async fn get_demo_communities(
tracing::error!("Failed to fetch demo communities: {}", e); tracing::error!("Failed to fetch demo communities: {}", e);
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "Failed to fetch communities"})) Json(json!({"error": "Failed to fetch communities"})),
).into_response() )
.into_response()
} }
} }
} }

View file

@ -85,7 +85,10 @@ async fn get_job(
pub fn router(pool: PgPool) -> Router { pub fn router(pool: PgPool) -> Router {
Router::new() 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)) .route("/api/exports/{job_id}", get(get_job))
.with_state(pool) .with_state(pool)
} }

View file

@ -176,7 +176,10 @@ async fn receive_federation_request(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !community_exists { 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( let request_id = FederationService::request_federation(
@ -199,12 +202,30 @@ async fn receive_federation_request(
pub fn router(pool: PgPool) -> Router { pub fn router(pool: PgPool) -> Router {
Router::new() Router::new()
// Instances // Instances
.route("/api/federation/instances", get(get_instances).post(register_instance)) .route(
.route("/api/federation/instances/{instance_id}/trust", post(set_trust_level)) "/api/federation/instances",
get(get_instances).post(register_instance),
)
.route(
"/api/federation/instances/{instance_id}/trust",
post(set_trust_level),
)
// Community federations // Community federations
.route("/api/communities/{community_id}/federation", get(get_community_federations).post(request_federation)) .route(
.route("/api/communities/{community_id}/federation/stats", get(get_stats)) "/api/communities/{community_id}/federation",
.route("/api/communities/{community_id}/federation/request", post(receive_federation_request)) get(get_community_federations).post(request_federation),
.route("/api/federation/{federation_id}/approve", post(approve_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) .with_state(pool)
} }

View file

@ -9,10 +9,10 @@ use axum::{
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::auth::AuthUser; use crate::auth::AuthUser;
@ -148,12 +148,18 @@ async fn create_connection(
.ok_or((StatusCode::FORBIDDEN, "Not a community member".to_string()))?; .ok_or((StatusCode::FORBIDDEN, "Not a community member".to_string()))?;
if membership.role != "admin" { 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 // Validate GitLab URL
if !req.gitlab_url.starts_with("https://") { 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!( let connection = sqlx::query!(
@ -212,17 +218,22 @@ async fn list_issues(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(issues.into_iter().map(|i| GitLabIssue { Ok(Json(
id: i.id, issues
gitlab_iid: i.gitlab_iid, .into_iter()
title: i.title, .map(|i| GitLabIssue {
description: i.description, id: i.id,
state: i.state, gitlab_iid: i.gitlab_iid,
author_username: i.author_username, title: i.title,
labels: i.labels.unwrap_or_default(), description: i.description,
proposal_id: i.proposal_id, state: i.state,
gitlab_created_at: i.gitlab_created_at, author_username: i.author_username,
}).collect())) 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 /// List GitLab merge requests for a community
@ -245,19 +256,23 @@ async fn list_merge_requests(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(mrs.into_iter().map(|m| GitLabMergeRequest { Ok(Json(
id: m.id, mrs.into_iter()
gitlab_iid: m.gitlab_iid, .map(|m| GitLabMergeRequest {
title: m.title, id: m.id,
description: m.description, gitlab_iid: m.gitlab_iid,
state: m.state, title: m.title,
author_username: m.author_username, description: m.description,
source_branch: m.source_branch, state: m.state,
target_branch: m.target_branch, author_username: m.author_username,
labels: m.labels.unwrap_or_default(), source_branch: m.source_branch,
proposal_id: m.proposal_id, target_branch: m.target_branch,
gitlab_created_at: m.gitlab_created_at, labels: m.labels.unwrap_or_default(),
}).collect())) proposal_id: m.proposal_id,
gitlab_created_at: m.gitlab_created_at,
})
.collect(),
))
} }
/// Create proposal from GitLab issue /// 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()))?; .ok_or((StatusCode::NOT_FOUND, "Issue not found".to_string()))?;
if issue.proposal_id.is_some() { 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 // Create proposal
@ -340,9 +358,21 @@ async fn create_proposal_from_issue(
pub fn router(pool: PgPool) -> Router { pub fn router(pool: PgPool) -> Router {
Router::new() Router::new()
.route("/api/communities/{community_id}/gitlab", get(get_connection).post(create_connection)) .route(
.route("/api/communities/{community_id}/gitlab/issues", get(list_issues)) "/api/communities/{community_id}/gitlab",
.route("/api/communities/{community_id}/gitlab/merge-requests", get(list_merge_requests)) get(get_connection).post(create_connection),
.route("/api/communities/{community_id}/gitlab/issues/{issue_id}/create-proposal", post(create_proposal_from_issue)) )
.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) .with_state(pool)
} }

View file

@ -3,16 +3,16 @@
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
routing::{get, delete}, routing::{delete, get},
Json, Router, Json, Router,
}; };
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use chrono::{DateTime, Utc};
use super::permissions::{perms, require_permission, user_has_permission};
use crate::auth::AuthUser; use crate::auth::AuthUser;
use super::permissions::{require_permission, user_has_permission, perms};
// ============================================================================ // ============================================================================
// Types // Types
@ -43,7 +43,9 @@ pub struct CreateInvitationRequest {
pub expires_in_hours: Option<i32>, pub expires_in_hours: Option<i32>,
} }
fn default_max_uses() -> Option<i32> { Some(1) } fn default_max_uses() -> Option<i32> {
Some(1)
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ListInvitationsQuery { pub struct ListInvitationsQuery {
@ -83,12 +85,15 @@ async fn create_invitation(
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .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 // Calculate expiration
let expires_at = req.expires_in_hours.map(|h| { let expires_at = req
Utc::now() + chrono::Duration::hours(h as i64) .expires_in_hours
}); .map(|h| Utc::now() + chrono::Duration::hours(h as i64));
let invite = sqlx::query!( let invite = sqlx::query!(
r#"INSERT INTO invitations (code, created_by, email, community_id, max_uses, expires_at) r#"INSERT INTO invitations (code, created_by, email, community_id, max_uses, expires_at)
@ -167,20 +172,25 @@ async fn list_invitations(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(invites.into_iter().map(|i| Invitation { Ok(Json(
id: i.id, invites
code: i.code, .into_iter()
created_by: i.created_by, .map(|i| Invitation {
created_by_username: Some(i.creator_username), id: i.id,
email: i.email, code: i.code,
community_id: i.community_id, created_by: i.created_by,
community_name: Some(i.community_name), created_by_username: Some(i.creator_username),
max_uses: i.max_uses, email: i.email,
uses_count: i.uses_count.unwrap_or(0), community_id: i.community_id,
expires_at: i.expires_at, community_name: Some(i.community_name),
is_active: i.is_active.unwrap_or(true), max_uses: i.max_uses,
created_at: i.created_at.unwrap_or_else(Utc::now), uses_count: i.uses_count.unwrap_or(0),
}).collect())) 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) /// Validate an invitation code (public endpoint for registration)
@ -247,22 +257,31 @@ async fn revoke_invitation(
Path(invitation_id): Path<Uuid>, Path(invitation_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> { ) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// Check ownership or admin // Check ownership or admin
let invite = sqlx::query!("SELECT created_by FROM invitations WHERE id = $1", invitation_id) let invite = sqlx::query!(
.fetch_optional(&pool) "SELECT created_by FROM invitations WHERE id = $1",
.await invitation_id
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? )
.ok_or((StatusCode::NOT_FOUND, "Invitation not found".to_string()))?; .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?; let is_admin = user_has_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?;
if invite.created_by != auth.user_id && !is_admin { 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) sqlx::query!(
.execute(&pool) "UPDATE invitations SET is_active = FALSE WHERE id = $1",
.await invitation_id
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; )
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({"success": true}))) Ok(Json(serde_json::json!({"success": true})))
} }
@ -273,7 +292,10 @@ async fn revoke_invitation(
pub fn router(pool: PgPool) -> Router { pub fn router(pool: PgPool) -> Router {
Router::new() 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/validate/{code}", get(validate_invitation))
.route("/api/invitations/{id}", delete(revoke_invitation)) .route("/api/invitations/{id}", delete(revoke_invitation))
.with_state(pool) .with_state(pool)

View file

@ -91,9 +91,10 @@ async fn compare_versions(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Json(req): Json<CompareVersionsRequest>, Json(req): Json<CompareVersionsRequest>,
) -> Result<Json<Value>, (StatusCode, String)> { ) -> Result<Json<Value>, (StatusCode, String)> {
let diff = LifecycleService::compare_versions(&pool, proposal_id, req.from_version, req.to_version) let diff =
.await LifecycleService::compare_versions(&pool, proposal_id, req.from_version, req.to_version)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(diff)) Ok(Json(diff))
} }
@ -203,9 +204,15 @@ async fn vote_amendment(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Json(req): Json<VoteAmendmentRequest>, Json(req): Json<VoteAmendmentRequest>,
) -> Result<Json<Value>, (StatusCode, String)> { ) -> Result<Json<Value>, (StatusCode, String)> {
LifecycleService::vote_amendment(&pool, amendment_id, auth.user_id, &req.vote, req.comment.as_deref()) LifecycleService::vote_amendment(
.await &pool,
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; 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}))) Ok(Json(serde_json::json!({"success": true})))
} }
@ -232,16 +239,37 @@ pub fn router(pool: PgPool) -> Router {
Router::new() Router::new()
// Versions // Versions
.route("/api/proposals/{proposal_id}/versions", get(get_versions)) .route("/api/proposals/{proposal_id}/versions", get(get_versions))
.route("/api/proposals/{proposal_id}/versions/{version_number}", get(get_version)) .route(
.route("/api/proposals/{proposal_id}/versions/compare", post(compare_versions)) "/api/proposals/{proposal_id}/versions/{version_number}",
get(get_version),
)
.route(
"/api/proposals/{proposal_id}/versions/compare",
post(compare_versions),
)
// Lifecycle // Lifecycle
.route("/api/proposals/{proposal_id}/lifecycle", get(get_lifecycle_summary)) .route(
.route("/api/proposals/{proposal_id}/lifecycle/transition", post(transition_status)) "/api/proposals/{proposal_id}/lifecycle",
.route("/api/proposals/{proposal_id}/lifecycle/fork", post(fork_proposal)) 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)) .route("/api/proposals/{proposal_id}/forks", get(get_forks))
// Amendments // 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}/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) .with_state(pool)
} }

View file

@ -4,8 +4,8 @@ pub mod auth;
pub mod comments; pub mod comments;
pub mod communities; pub mod communities;
pub mod conflicts; pub mod conflicts;
pub mod deliberation;
pub mod delegation; pub mod delegation;
pub mod deliberation;
pub mod demo; pub mod demo;
pub mod exports; pub mod exports;
pub mod federation; pub mod federation;

View file

@ -4,13 +4,13 @@ use axum::{
routing::get, routing::get,
Json, Router, Json, Router,
}; };
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::api::permissions::{perms, require_permission};
use crate::auth::AuthUser; use crate::auth::AuthUser;
use crate::api::permissions::{require_permission, perms};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct ModerationEntry { pub struct ModerationEntry {
@ -34,7 +34,10 @@ pub struct CreateModerationEntry {
pub fn router(pool: PgPool) -> Router { pub fn router(pool: PgPool) -> Router {
Router::new() 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) .with_state(pool)
} }
@ -44,7 +47,13 @@ async fn list_moderation(
State(pool): State<PgPool>, State(pool): State<PgPool>,
) -> Result<Json<Vec<ModerationEntry>>, (StatusCode, String)> { ) -> Result<Json<Vec<ModerationEntry>>, (StatusCode, String)> {
// Require permission to view moderation reports // 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!( let entries = sqlx::query!(
r#" r#"
@ -93,22 +102,49 @@ async fn create_moderation(
// Check specific permission based on action type // Check specific permission based on action type
let has_permission = match req.action_type.as_str() { let has_permission = match req.action_type.as_str() {
"ban" | "unban" | "suspend" | "unsuspend" => { "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" => { "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" => { "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 // 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 { 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!( let entry = sqlx::query!(

View file

@ -17,7 +17,10 @@ pub fn router(pool: PgPool) -> Router {
Router::new() Router::new()
.route("/api/ledger", get(list_entries)) .route("/api/ledger", get(list_entries))
.route("/api/ledger/entry/{id}", get(get_entry)) .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/verify", get(verify_chain))
.route("/api/ledger/stats", get(get_stats)) .route("/api/ledger/stats", get(get_stats))
.route("/api/ledger/export", get(export_ledger)) .route("/api/ledger/export", get(export_ledger))
@ -151,14 +154,12 @@ async fn get_entry(
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, Json<Value>)> { ) -> Result<impl IntoResponse, (StatusCode, Json<Value>)> {
// Use LedgerService for consistency // Use LedgerService for consistency
let entry = LedgerService::get_entry(&pool, id) let entry = LedgerService::get_entry(&pool, id).await.map_err(|e| {
.await (
.map_err(|e| { StatusCode::INTERNAL_SERVER_ERROR,
( Json(json!({"error": e.to_string()})),
StatusCode::INTERNAL_SERVER_ERROR, )
Json(json!({"error": e.to_string()})), })?;
)
})?;
match entry { match entry {
Some(e) => { 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 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| { let action_type = parse_action_type(&req.action_type)
(StatusCode::BAD_REQUEST, Json(json!({"error": e}))) .map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({"error": e}))))?;
})?;
let entry_id = LedgerService::create_entry( let entry_id = LedgerService::create_entry(
&pool, &pool,

View file

@ -49,15 +49,18 @@ async fn list_notifications(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let result = notifications.into_iter().map(|n| Notification { let result = notifications
id: n.id, .into_iter()
notification_type: n.notification_type, .map(|n| Notification {
title: n.title, id: n.id,
message: n.message, notification_type: n.notification_type,
link: n.link, title: n.title,
is_read: n.is_read, message: n.message,
created_at: n.created_at, link: n.link,
}).collect(); is_read: n.is_read,
created_at: n.created_at,
})
.collect();
Ok(Json(result)) Ok(Json(result))
} }

View file

@ -57,16 +57,16 @@ pub async fn require_any_permission(
} }
Err(( Err((
StatusCode::FORBIDDEN, 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). /// Check if user is a platform admin (has platform.admin permission).
#[allow(dead_code)] #[allow(dead_code)]
pub async fn is_platform_admin( pub async fn is_platform_admin(pool: &PgPool, user_id: Uuid) -> Result<bool, (StatusCode, String)> {
pool: &PgPool,
user_id: Uuid,
) -> Result<bool, (StatusCode, String)> {
user_has_permission(pool, user_id, "platform.admin", None).await user_has_permission(pool, user_id, "platform.admin", None).await
} }
@ -86,7 +86,8 @@ pub async fn is_community_staff(
user_id: Uuid, user_id: Uuid,
community_id: Uuid, community_id: Uuid,
) -> Result<bool, (StatusCode, String)> { ) -> Result<bool, (StatusCode, String)> {
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 { if is_admin {
return Ok(true); return Ok(true);
} }

View file

@ -2,26 +2,25 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
routing::{get, post, put}, routing::{get, post, put},
Extension, Extension, Json, Router,
Json, Router,
}; };
use base64::{engine::general_purpose, Engine as _}; use base64::{engine::general_purpose, Engine as _};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use ed25519_dalek::{Signature, VerifyingKey}; use ed25519_dalek::{Signature, VerifyingKey};
use jsonschema::{Draft, JSONSchema}; use jsonschema::{Draft, JSONSchema};
use reqwest::Url;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use sqlx::PgPool; use sqlx::PgPool;
use sqlx::Row; use sqlx::Row;
use std::net::IpAddr; use std::net::IpAddr;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use sha2::{Digest, Sha256};
use reqwest::Url;
use crate::auth::AuthUser; use crate::auth::AuthUser;
use crate::plugins::PluginManager;
use crate::plugins::wasm::host_api::PluginManifest; use crate::plugins::wasm::host_api::PluginManifest;
use crate::plugins::PluginManager;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct CommunityPluginInfo { pub struct CommunityPluginInfo {
@ -51,7 +50,12 @@ async fn get_plugin_policy(
match membership { match membership {
Some(m) if m.role == "admin" || m.role == "moderator" => {} 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!( let row = sqlx::query!(
@ -91,7 +95,12 @@ async fn update_plugin_policy(
match membership { match membership {
Some(m) if m.role == "admin" || m.role == "moderator" => {} 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!( let current = sqlx::query!(
@ -160,9 +169,16 @@ async fn update_plugin_policy(
trust_policy: parse_trust_policy(&current.settings), trust_policy: parse_trust_policy(&current.settings),
install_sources: parse_install_sources(&current.settings), install_sources: parse_install_sources(&current.settings),
allow_outbound_http: parse_bool(&current.settings, "plugin_allow_outbound_http", false), allow_outbound_http: parse_bool(&current.settings, "plugin_allow_outbound_http", false),
http_egress_allowlist: parse_string_list(&current.settings, "plugin_http_egress_allowlist"), http_egress_allowlist: parse_string_list(
&current.settings,
"plugin_http_egress_allowlist",
),
registry_allowlist: parse_string_list(&current.settings, "plugin_registry_allowlist"), registry_allowlist: parse_string_list(&current.settings, "plugin_registry_allowlist"),
allow_background_jobs: parse_bool(&current.settings, "plugin_allow_background_jobs", false), allow_background_jobs: parse_bool(
&current.settings,
"plugin_allow_background_jobs",
false,
),
trusted_publishers: parse_string_list(&current.settings, "plugin_trusted_publishers"), trusted_publishers: parse_string_list(&current.settings, "plugin_trusted_publishers"),
})); }));
} }
@ -402,7 +418,10 @@ fn verify_signature_if_required(
if matches!(trust_policy, PluginTrustPolicy::SignedOnly) { if matches!(trust_policy, PluginTrustPolicy::SignedOnly) {
if trusted_publishers.is_empty() { 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) { if !trusted_publishers.iter().any(|p| p == publisher) {
return Err((StatusCode::FORBIDDEN, "Publisher not trusted".to_string())); 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)> { fn enforce_registry_allowlist(url: &Url, allowlist: &[String]) -> Result<(), (StatusCode, String)> {
let host = url let host = url.host_str().ok_or((
.host_str() StatusCode::BAD_REQUEST,
.ok_or((StatusCode::BAD_REQUEST, "Registry URL must include host".to_string()))?; "Registry URL must include host".to_string(),
))?;
if let Ok(ip) = host.parse::<IpAddr>() { if let Ok(ip) = host.parse::<IpAddr>() {
let is_disallowed = match ip { 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) => { 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 { 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") { 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) { 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(()) Ok(())
@ -469,7 +503,10 @@ async fn ensure_admin_or_moderator(
match membership { match membership {
Some(m) if m.role == "admin" || m.role == "moderator" => Ok(()), 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) .fetch_optional(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .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 let link_is_active: bool = link_row
.try_get("is_active") .try_get("is_active")
@ -622,8 +662,12 @@ async fn update_community_plugin_package(
.try_get("installed_at") .try_get("installed_at")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let manifest: PluginManifest = serde_json::from_value(pkg.manifest.clone()) let manifest: PluginManifest = serde_json::from_value(pkg.manifest.clone()).map_err(|e| {
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Stored manifest invalid: {e}")))?; (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Stored manifest invalid: {e}"),
)
})?;
if let Some(settings) = &req.settings { if let Some(settings) = &req.settings {
if let Some(schema) = &manifest.settings_schema { if let Some(schema) = &manifest.settings_schema {
@ -632,7 +676,10 @@ async fn update_community_plugin_package(
.with_draft(Draft::Draft7) .with_draft(Draft::Draft7)
.compile(schema) .compile(schema)
.map_err(|e| { .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) { if !compiled.is_valid(settings) {
@ -792,7 +839,10 @@ async fn upload_plugin_package(
let trusted_publishers = parse_string_list(&settings, "plugin_trusted_publishers"); let trusted_publishers = parse_string_list(&settings, "plugin_trusted_publishers");
if !sources.contains(&PluginInstallSource::Upload) { 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(); 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"); let registry_allowlist = parse_string_list(&settings, "plugin_registry_allowlist");
if !sources.contains(&PluginInstallSource::Registry) { 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) let url = Url::parse(&req.url)
@ -988,7 +1041,12 @@ async fn install_registry_plugin_package(
match url.scheme() { match url.scheme() {
"https" | "http" => {} "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, &registry_allowlist)?; enforce_registry_allowlist(&url, &registry_allowlist)?;
@ -1001,13 +1059,20 @@ async fn install_registry_plugin_package(
return Err((StatusCode::BAD_GATEWAY, "Registry fetch failed".to_string())); return Err((StatusCode::BAD_GATEWAY, "Registry fetch failed".to_string()));
} }
let bundle: UploadPluginPackageRequest = res let bundle: UploadPluginPackageRequest = res.json().await.map_err(|_| {
.json() (
.await StatusCode::BAD_GATEWAY,
.map_err(|_| (StatusCode::BAD_GATEWAY, "Invalid registry response".to_string()))?; "Invalid registry response".to_string(),
)
})?;
let parsed_manifest: PluginManifest = serde_json::from_value(bundle.manifest.clone()) let parsed_manifest: PluginManifest =
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Registry returned invalid manifest: {e}")))?; 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 { if parsed_manifest.name != bundle.name {
return Err(( return Err((
@ -1177,10 +1242,7 @@ async fn install_registry_plugin_package(
} }
fn parse_trust_policy(settings: &Value) -> PluginTrustPolicy { fn parse_trust_policy(settings: &Value) -> PluginTrustPolicy {
match settings match settings.get("plugin_trust_policy").and_then(|v| v.as_str()) {
.get("plugin_trust_policy")
.and_then(|v| v.as_str())
{
Some("unsigned_allowed") => PluginTrustPolicy::UnsignedAllowed, Some("unsigned_allowed") => PluginTrustPolicy::UnsignedAllowed,
_ => PluginTrustPolicy::SignedOnly, _ => PluginTrustPolicy::SignedOnly,
} }
@ -1194,7 +1256,10 @@ fn trust_policy_str(policy: PluginTrustPolicy) -> &'static str {
} }
fn parse_install_sources(settings: &Value) -> Vec<PluginInstallSource> { fn parse_install_sources(settings: &Value) -> Vec<PluginInstallSource> {
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]; 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 { 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<String> { fn parse_string_list(settings: &Value, key: &str) -> Vec<String> {
@ -1268,7 +1336,12 @@ async fn list_community_plugins(
match membership { match membership {
Some(m) if m.role == "admin" || m.role == "moderator" => {} 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!( let rows = sqlx::query!(
@ -1334,7 +1407,12 @@ async fn update_community_plugin(
match membership { match membership {
Some(m) if m.role == "admin" || m.role == "moderator" => {} 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!( let plugin = sqlx::query!(

View file

@ -2,8 +2,7 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
routing::{get, post}, routing::{get, post},
Extension, Extension, Json, Router,
Json, Router,
}; };
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
@ -11,20 +10,36 @@ use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::AuthUser; 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}; use crate::plugins::{HookContext, PluginError, PluginManager};
pub fn router(pool: PgPool) -> Router { pub fn router(pool: PgPool) -> Router {
Router::new() Router::new()
.route("/api/proposals", get(list_all_proposals)) .route("/api/proposals", get(list_all_proposals))
.route("/api/proposals/my", get(my_proposals)) .route("/api/proposals/my", get(my_proposals))
.route("/api/communities/{community_id}/proposals", get(list_proposals).post(create_proposal)) .route(
.route("/api/proposals/{id}", get(get_proposal).delete(delete_proposal).put(update_proposal)) "/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", post(cast_vote))
.route("/api/proposals/{id}/vote/ranked", post(cast_ranked_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}/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}/start-voting", post(start_voting))
.route("/api/proposals/{id}/close-voting", post(close_voting)) .route("/api/proposals/{id}/close-voting", post(close_voting))
.route("/api/proposals/{id}/results", get(get_voting_results)) .route("/api/proposals/{id}/results", get(get_voting_results))
@ -66,17 +81,20 @@ async fn list_all_proposals(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let result = proposals.into_iter().map(|p| ProposalWithCommunity { let result = proposals
id: p.id, .into_iter()
title: p.title, .map(|p| ProposalWithCommunity {
description: p.description, id: p.id,
status: p.status, title: p.title,
community_name: p.community_name, description: p.description,
community_slug: p.community_slug, status: p.status,
vote_count: p.vote_count.unwrap_or(0), community_name: p.community_name,
comment_count: p.comment_count.unwrap_or(0), community_slug: p.community_slug,
created_at: p.created_at, vote_count: p.vote_count.unwrap_or(0),
}).collect(); comment_count: p.comment_count.unwrap_or(0),
created_at: p.created_at,
})
.collect();
Ok(Json(result)) Ok(Json(result))
} }
@ -102,17 +120,20 @@ async fn my_proposals(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let result = proposals.into_iter().map(|p| ProposalWithCommunity { let result = proposals
id: p.id, .into_iter()
title: p.title, .map(|p| ProposalWithCommunity {
description: p.description, id: p.id,
status: p.status, title: p.title,
community_name: p.community_name, description: p.description,
community_slug: p.community_slug, status: p.status,
vote_count: p.vote_count.unwrap_or(0), community_name: p.community_name,
comment_count: p.comment_count.unwrap_or(0), community_slug: p.community_slug,
created_at: p.created_at, vote_count: p.vote_count.unwrap_or(0),
}).collect(); comment_count: p.comment_count.unwrap_or(0),
created_at: p.created_at,
})
.collect();
Ok(Json(result)) Ok(Json(result))
} }
@ -147,10 +168,16 @@ async fn create_proposal(
Extension(plugins): Extension<Arc<PluginManager>>, Extension(plugins): Extension<Arc<PluginManager>>,
Json(req): Json<CreateProposal>, Json(req): Json<CreateProposal>,
) -> Result<Json<Proposal>, (StatusCode, String)> { ) -> Result<Json<Proposal>, (StatusCode, String)> {
use crate::api::permissions::{require_permission, perms}; use crate::api::permissions::{perms, require_permission};
// Require proposal.create permission in community // 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 let filtered = plugins
.apply_filters( .apply_filters(
@ -225,7 +252,10 @@ async fn create_proposal(
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Start transaction // 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 // Create proposal with community's default voting method
let proposal = sqlx::query_as!( let proposal = sqlx::query_as!(
@ -260,7 +290,9 @@ async fn create_proposal(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .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); tracing::info!("Proposal '{}' created by {}", proposal.title, auth.username);
Ok(Json(proposal)) Ok(Json(proposal))
@ -285,10 +317,13 @@ async fn get_proposal(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".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) let author = sqlx::query_scalar!(
.fetch_one(&pool) "SELECT username FROM users WHERE id = $1",
.await proposal.author_id
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; )
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let options = sqlx::query!( let options = sqlx::query!(
r#"SELECT o.id, o.label, o.description, COUNT(v.id) as vote_count 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<Arc<PluginManager>>, Extension(plugins): Extension<Arc<PluginManager>>,
Json(req): Json<VoteRequest>, Json(req): Json<VoteRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> { ) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
use crate::api::permissions::{require_permission, perms}; use crate::api::permissions::{perms, require_permission};
let proposal = sqlx::query_as!( let proposal = sqlx::query_as!(
Proposal, Proposal,
@ -381,10 +416,19 @@ async fn cast_vote(
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?; .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
// Require vote.cast permission in community // 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) { 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!({ let vote_payload = serde_json::json!({
@ -427,7 +471,10 @@ async fn cast_vote(
RETURNING id"#, RETURNING id"#,
auth.user_id, auth.user_id,
proposal.community_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) .fetch_one(&pool)
.await .await
@ -459,10 +506,16 @@ async fn cast_vote(
community_id: Some(proposal.community_id), community_id: Some(proposal.community_id),
actor_user_id: Some(auth.user_id), actor_user_id: Some(auth.user_id),
}; };
let _ = plugins.do_action("vote.cast", ctx, serde_json::json!({ let _ = plugins
"proposal_id": proposal_id.to_string(), .do_action(
"voter_id": auth.user_id.to_string(), "vote.cast",
})).await; 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"}))) 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()))?; .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) { 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" { 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!( let voting_identity = sqlx::query_scalar!(
@ -497,15 +556,24 @@ async fn cast_ranked_vote(
RETURNING id"#, RETURNING id"#,
auth.user_id, auth.user_id,
proposal.community_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) .fetch_one(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Clear existing ranked votes // Clear existing ranked votes
sqlx::query!("DELETE FROM ranked_votes WHERE proposal_id = $1 AND voter_id = $2", proposal_id, voting_identity) sqlx::query!(
.execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; "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 // Insert ranked votes
for ranking in req.rankings { 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()))?; ).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( async fn cast_quadratic_vote(
@ -524,7 +594,7 @@ async fn cast_quadratic_vote(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Json(req): Json<QuadraticVoteRequest>, Json(req): Json<QuadraticVoteRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> { ) -> Result<Json<serde_json::Value>, (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!( let proposal = sqlx::query!(
"SELECT community_id, status as \"status: crate::models::ProposalStatus\", voting_method FROM proposals WHERE id = $1", "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()))?; .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) { 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" { 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 // 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(); let total_cost: i32 = req.allocations.iter().map(|a| vote_cost(a.credits)).sum();
if total_cost > total_credits { if total_cost > total_credits {
let max_single = max_votes_for_credits(total_credits); let max_single = max_votes_for_credits(total_credits);
return Err((StatusCode::BAD_REQUEST, format!( return Err((
"Total cost {} exceeds {} credits. Max votes on single option: {}", StatusCode::BAD_REQUEST,
total_cost, total_credits, max_single format!(
))); "Total cost {} exceeds {} credits. Max votes on single option: {}",
total_cost, total_credits, max_single
),
));
} }
let voting_identity = sqlx::query_scalar!( let voting_identity = sqlx::query_scalar!(
@ -561,15 +640,24 @@ async fn cast_quadratic_vote(
RETURNING id"#, RETURNING id"#,
auth.user_id, auth.user_id,
proposal.community_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) .fetch_one(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Clear existing quadratic votes // Clear existing quadratic votes
sqlx::query!("DELETE FROM quadratic_votes WHERE proposal_id = $1 AND voter_id = $2", proposal_id, voting_identity) sqlx::query!(
.execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; "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 // Insert quadratic votes
for alloc in req.allocations { 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( async fn cast_star_vote(
@ -600,17 +690,26 @@ async fn cast_star_vote(
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?; .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) { 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" { 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) // Validate star ratings (0-5)
for rating in &req.ratings { for rating in &req.ratings {
if rating.stars < 0 || rating.stars > 5 { 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"#, RETURNING id"#,
auth.user_id, auth.user_id,
proposal.community_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) .fetch_one(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Clear existing star votes // Clear existing star votes
sqlx::query!("DELETE FROM star_votes WHERE proposal_id = $1 AND voter_id = $2", proposal_id, voting_identity) sqlx::query!(
.execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; "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 // Insert star votes
for rating in req.ratings { 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()))?; ).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( async fn start_discussion(
@ -663,7 +773,10 @@ async fn start_discussion(
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?; .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
if proposal.author_id != auth.user_id { 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!( let updated = sqlx::query_as!(
@ -706,7 +819,10 @@ async fn start_voting(
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?; .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
if proposal.author_id != auth.user_id { 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!( let updated = sqlx::query_as!(
@ -733,7 +849,7 @@ async fn close_voting(
Path(proposal_id): Path<Uuid>, Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>, State(pool): State<PgPool>,
) -> Result<Json<Proposal>, (StatusCode, String)> { ) -> Result<Json<Proposal>, (StatusCode, String)> {
use crate::api::permissions::{user_has_permission, perms}; use crate::api::permissions::{perms, user_has_permission};
let proposal = sqlx::query_as!( let proposal = sqlx::query_as!(
Proposal, Proposal,
@ -752,14 +868,26 @@ async fn close_voting(
// Check if user can manage status: author or users with manage_status permission // Check if user can manage status: author or users with manage_status permission
let is_author = proposal.author_id == auth.user_id; 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 { 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) { 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!( let updated = sqlx::query_as!(
@ -787,7 +915,7 @@ async fn delete_proposal(
Path(proposal_id): Path<Uuid>, Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>, State(pool): State<PgPool>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> { ) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
use crate::api::permissions::{user_has_permission, perms}; use crate::api::permissions::{perms, user_has_permission};
let proposal = sqlx::query!( let proposal = sqlx::query!(
"SELECT author_id, community_id, status as \"status: crate::models::ProposalStatus\", title FROM proposals WHERE id = $1", "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 // Check if user can delete: author needs delete_own, others need delete_any
let is_author = proposal.author_id == auth.user_id; 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_own = user_has_permission(
let can_delete_any = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_DELETE_ANY, Some(proposal.community_id)).await?; &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 { 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 { 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) { 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 // Delete related data first
sqlx::query!("DELETE FROM proposal_options WHERE proposal_id = $1", proposal_id) sqlx::query!(
.execute(&pool) "DELETE FROM proposal_options WHERE proposal_id = $1",
.await proposal_id
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; )
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
sqlx::query!("DELETE FROM comments WHERE proposal_id = $1", proposal_id) sqlx::query!("DELETE FROM comments WHERE proposal_id = $1", proposal_id)
.execute(&pool) .execute(&pool)
@ -846,7 +998,7 @@ async fn update_proposal(
State(pool): State<PgPool>, State(pool): State<PgPool>,
Json(payload): Json<UpdateProposal>, Json(payload): Json<UpdateProposal>,
) -> Result<Json<Proposal>, (StatusCode, String)> { ) -> Result<Json<Proposal>, (StatusCode, String)> {
use crate::api::permissions::{user_has_permission, perms}; use crate::api::permissions::{perms, user_has_permission};
let proposal = sqlx::query!( let proposal = sqlx::query!(
"SELECT author_id, community_id, status as \"status: crate::models::ProposalStatus\" FROM proposals WHERE id = $1", "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 // Check edit permissions: author needs edit_own, others need edit_any
let is_author = proposal.author_id == auth.user_id; 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_own = user_has_permission(
let can_edit_any = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_EDIT_ANY, Some(proposal.community_id)).await?; &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 { 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 { 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) { if !matches!(
return Err((StatusCode::BAD_REQUEST, "Can only edit proposals in draft or discussion phase".to_string())); 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!( let updated = sqlx::query_as!(
@ -921,7 +1097,7 @@ async fn get_voting_results(
Path(proposal_id): Path<Uuid>, Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>, State(pool): State<PgPool>,
) -> Result<Json<VotingResultsResponse>, (StatusCode, String)> { ) -> Result<Json<VotingResultsResponse>, (StatusCode, String)> {
use crate::api::permissions::{require_permission, perms}; use crate::api::permissions::{perms, require_permission};
let proposal = sqlx::query!( let proposal = sqlx::query!(
"SELECT id, community_id, voting_method, status as \"status: crate::models::ProposalStatus\" FROM proposals WHERE id = $1", "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()))?; .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
// Require permission to view voting results // 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!( let options: Vec<(Uuid, String)> = sqlx::query!(
"SELECT id, label FROM proposal_options WHERE proposal_id = $1 ORDER BY sort_order", "SELECT id, label FROM proposal_options WHERE proposal_id = $1 ORDER BY sort_order",
@ -950,7 +1132,9 @@ async fn get_voting_results(
let (results, total_votes, details) = match voting_method { let (results, total_votes, details) = match voting_method {
"approval" => calculate_approval_results(&pool, proposal_id, &options).await?, "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?, "star" => calculate_star_results(&pool, proposal_id, &options).await?,
"quadratic" => calculate_quadratic_results(&pool, proposal_id, &options).await?, "quadratic" => calculate_quadratic_results(&pool, proposal_id, &options).await?,
_ => calculate_approval_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()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.unwrap_or(0); .unwrap_or(0);
let mut results: Vec<OptionResult> = options.iter().map(|(opt_id, label)| { let mut results: Vec<OptionResult> = options
let votes = vote_counts.iter() .iter()
.find(|v| v.option_id == *opt_id) .map(|(opt_id, label)| {
.map(|v| v.count.unwrap_or(0)) let votes = vote_counts
.unwrap_or(0); .iter()
let percentage = if total_voters > 0 { .find(|v| v.option_id == *opt_id)
(votes as f64 / total_voters as f64) * 100.0 .map(|v| v.count.unwrap_or(0))
} else { 0.0 }; .unwrap_or(0);
let percentage = if total_voters > 0 {
(votes as f64 / total_voters as f64) * 100.0
} else {
0.0
};
OptionResult { OptionResult {
option_id: *opt_id, option_id: *opt_id,
label: label.clone(), label: label.clone(),
votes, votes,
percentage, percentage,
rank: 0, rank: 0,
} }
}).collect(); })
.collect();
results.sort_by(|a, b| b.votes.cmp(&a.votes)); results.sort_by(|a, b| b.votes.cmp(&a.votes));
for (i, r) in results.iter_mut().enumerate() { for (i, r) in results.iter_mut().enumerate() {
r.rank = (i + 1) as i32; 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( async fn calculate_ranked_results(
@ -1024,7 +1218,7 @@ async fn calculate_ranked_results(
options: &[(Uuid, String)], options: &[(Uuid, String)],
method: &str, method: &str,
) -> Result<(Vec<OptionResult>, i64, serde_json::Value), (StatusCode, String)> { ) -> Result<(Vec<OptionResult>, i64, serde_json::Value), (StatusCode, String)> {
use crate::voting::{schulze, ranked_choice}; use crate::voting::{ranked_choice, schulze};
let ballots_raw = sqlx::query!( let ballots_raw = sqlx::query!(
r#"SELECT voter_id, option_id, rank FROM ranked_votes r#"SELECT voter_id, option_id, rank FROM ranked_votes
@ -1038,46 +1232,70 @@ async fn calculate_ranked_results(
let option_ids: Vec<Uuid> = options.iter().map(|(id, _)| *id).collect(); let option_ids: Vec<Uuid> = options.iter().map(|(id, _)| *id).collect();
// Group ballots by voter // Group ballots by voter
let mut voter_ballots: std::collections::HashMap<Uuid, Vec<(Uuid, i32)>> = std::collections::HashMap::new(); let mut voter_ballots: std::collections::HashMap<Uuid, Vec<(Uuid, i32)>> =
std::collections::HashMap::new();
for b in &ballots_raw { 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 total_voters = voter_ballots.len() as i64;
let result = if method == "schulze" { let result = if method == "schulze" {
let ballots: Vec<schulze::RankedBallot> = voter_ballots.values().map(|rankings| { let ballots: Vec<schulze::RankedBallot> = voter_ballots
schulze::RankedBallot { .values()
rankings: rankings.iter().map(|(id, rank)| (*id, *rank as usize)).collect() .map(|rankings| schulze::RankedBallot {
} rankings: rankings
}).collect(); .iter()
.map(|(id, rank)| (*id, *rank as usize))
.collect(),
})
.collect();
schulze::calculate(&option_ids, &ballots) schulze::calculate(&option_ids, &ballots)
} else { } else {
let ballots: Vec<ranked_choice::RankedBallot> = voter_ballots.values().map(|rankings| { let ballots: Vec<ranked_choice::RankedBallot> = voter_ballots
let mut sorted = rankings.clone(); .values()
sorted.sort_by_key(|(_, rank)| *rank); .map(|rankings| {
ranked_choice::RankedBallot { let mut sorted = rankings.clone();
rankings: sorted.iter().map(|(id, _)| *id).collect() sorted.sort_by_key(|(_, rank)| *rank);
} ranked_choice::RankedBallot {
}).collect(); rankings: sorted.iter().map(|(id, _)| *id).collect(),
}
})
.collect();
ranked_choice::calculate(&option_ids, &ballots) ranked_choice::calculate(&option_ids, &ballots)
}; };
let results: Vec<OptionResult> = result.ranking.iter().map(|r| { let results: Vec<OptionResult> = result
let label = options.iter() .ranking
.find(|(id, _)| *id == r.option_id) .iter()
.map(|(_, l)| l.clone()) .map(|r| {
.unwrap_or_default(); let label = options
OptionResult { .iter()
option_id: r.option_id, .find(|(id, _)| *id == r.option_id)
label, .map(|(_, l)| l.clone())
votes: r.score as i64, .unwrap_or_default();
percentage: if total_voters > 0 { (r.score / total_voters as f64) * 100.0 } else { 0.0 }, OptionResult {
rank: r.rank as i32, option_id: r.option_id,
} label,
}).collect(); 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( async fn calculate_star_results(
@ -1098,35 +1316,49 @@ async fn calculate_star_results(
let option_ids: Vec<Uuid> = options.iter().map(|(id, _)| *id).collect(); let option_ids: Vec<Uuid> = options.iter().map(|(id, _)| *id).collect();
// Group by voter // Group by voter
let mut voter_scores: std::collections::HashMap<Uuid, Vec<(Uuid, i32)>> = std::collections::HashMap::new(); let mut voter_scores: std::collections::HashMap<Uuid, Vec<(Uuid, i32)>> =
std::collections::HashMap::new();
for v in &votes_raw { 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<star::ScoreBallot> = voter_scores.values().map(|scores| { let ballots: Vec<star::ScoreBallot> = voter_scores
star::ScoreBallot { .values()
scores: scores.clone() .map(|scores| star::ScoreBallot {
} scores: scores.clone(),
}).collect(); })
.collect();
let total_voters = ballots.len() as i64; let total_voters = ballots.len() as i64;
let result = star::calculate(&option_ids, &ballots); let result = star::calculate(&option_ids, &ballots);
let results: Vec<OptionResult> = result.ranking.iter().map(|r| { let results: Vec<OptionResult> = result
let label = options.iter() .ranking
.find(|(id, _)| *id == r.option_id) .iter()
.map(|(_, l)| l.clone()) .map(|r| {
.unwrap_or_default(); let label = options
OptionResult { .iter()
option_id: r.option_id, .find(|(id, _)| *id == r.option_id)
label, .map(|(_, l)| l.clone())
votes: r.score as i64, .unwrap_or_default();
percentage: 0.0, OptionResult {
rank: r.rank as i32, option_id: r.option_id,
} label,
}).collect(); 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( async fn calculate_quadratic_results(
@ -1147,35 +1379,55 @@ async fn calculate_quadratic_results(
let option_ids: Vec<Uuid> = options.iter().map(|(id, _)| *id).collect(); let option_ids: Vec<Uuid> = options.iter().map(|(id, _)| *id).collect();
// Group by voter to build ballots // Group by voter to build ballots
let mut voter_allocations: std::collections::HashMap<Uuid, Vec<(Uuid, i32)>> = std::collections::HashMap::new(); let mut voter_allocations: std::collections::HashMap<Uuid, Vec<(Uuid, i32)>> =
std::collections::HashMap::new();
for v in &votes_raw { 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) // Convert to QuadraticBallot format (100 credits per voter)
let ballots: Vec<quadratic::QuadraticBallot> = voter_allocations.values().map(|allocs| { let ballots: Vec<quadratic::QuadraticBallot> = voter_allocations
quadratic::QuadraticBallot { .values()
total_credits: 100, // Standard credit allocation .map(|allocs| {
allocations: allocs.clone(), quadratic::QuadraticBallot {
} total_credits: 100, // Standard credit allocation
}).collect(); allocations: allocs.clone(),
}
})
.collect();
let total_voters = ballots.len() as i64; let total_voters = ballots.len() as i64;
let result = quadratic::calculate(&option_ids, &ballots); let result = quadratic::calculate(&option_ids, &ballots);
let results: Vec<OptionResult> = result.ranking.iter().map(|r| { let results: Vec<OptionResult> = result
let label = options.iter() .ranking
.find(|(id, _)| *id == r.option_id) .iter()
.map(|(_, l)| l.clone()) .map(|r| {
.unwrap_or_default(); let label = options
OptionResult { .iter()
option_id: r.option_id, .find(|(id, _)| *id == r.option_id)
label, .map(|(_, l)| l.clone())
votes: r.score as i64, .unwrap_or_default();
percentage: if total_voters > 0 { (r.score / total_voters as f64) * 100.0 } else { 0.0 }, OptionResult {
rank: r.rank as i32, option_id: r.option_id,
} label,
}).collect(); 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(),
))
} }

View file

@ -5,13 +5,13 @@
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
routing::{get, post, delete}, routing::{delete, get, post},
Json, Router, Json, Router,
}; };
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::auth::AuthUser; use crate::auth::AuthUser;
@ -117,13 +117,18 @@ async fn list_permissions(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(perms.into_iter().map(|p| Permission { Ok(Json(
id: p.id, perms
name: p.name, .into_iter()
category: p.category, .map(|p| Permission {
description: p.description, id: p.id,
is_system: p.is_system, name: p.name,
}).collect())) category: p.category,
description: p.description,
is_system: p.is_system,
})
.collect(),
))
} }
// ============================================================================ // ============================================================================
@ -149,18 +154,23 @@ async fn list_platform_roles(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(roles.into_iter().map(|r| Role { Ok(Json(
id: r.id, roles
name: r.name, .into_iter()
display_name: r.display_name, .map(|r| Role {
description: r.description, id: r.id,
color: r.color, name: r.name,
community_id: None, display_name: r.display_name,
is_system: r.is_system, description: r.description,
is_default: r.is_default, color: r.color,
priority: r.priority, community_id: None,
permissions: r.permissions.unwrap_or_default(), is_system: r.is_system,
}).collect())) 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 .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(roles.into_iter().map(|r| Role { Ok(Json(
id: r.id, roles
name: r.name, .into_iter()
display_name: r.display_name, .map(|r| Role {
description: r.description, id: r.id,
color: r.color, name: r.name,
community_id: Some(community_id), display_name: r.display_name,
is_system: r.is_system, description: r.description,
is_default: r.is_default, color: r.color,
priority: r.priority, community_id: Some(community_id),
permissions: r.permissions.unwrap_or_default(), is_system: r.is_system,
}).collect())) is_default: r.is_default,
priority: r.priority,
permissions: r.permissions.unwrap_or_default(),
})
.collect(),
))
} }
/// Create a community role /// Create a community role
@ -221,7 +236,10 @@ async fn create_community_role(
.unwrap_or(false); .unwrap_or(false);
if !has_perm { 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 // Create role
@ -288,7 +306,10 @@ async fn assign_role(
.unwrap_or(false); .unwrap_or(false);
if !has_perm { 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 // Verify role belongs to community
@ -339,7 +360,10 @@ async fn remove_role(
.unwrap_or(false); .unwrap_or(false);
if !has_perm { 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!( sqlx::query!(
@ -375,12 +399,17 @@ async fn get_user_roles(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(roles.into_iter().map(|r| RoleSummary { Ok(Json(
id: r.id, roles
name: r.name, .into_iter()
display_name: r.display_name, .map(|r| RoleSummary {
color: r.color, id: r.id,
}).collect())) name: r.name,
display_name: r.display_name,
color: r.color,
})
.collect(),
))
} }
/// Check if current user has a specific permission /// Check if current user has a specific permission
@ -417,10 +446,25 @@ pub fn router(pool: PgPool) -> Router {
// Platform roles // Platform roles
.route("/api/roles", get(list_platform_roles)) .route("/api/roles", get(list_platform_roles))
// Community roles // Community roles
.route("/api/communities/{community_id}/roles", get(list_community_roles).post(create_community_role)) .route(
.route("/api/communities/{community_id}/roles/{role_id}/assign", post(assign_role)) "/api/communities/{community_id}/roles",
.route("/api/communities/{community_id}/roles/{role_id}/users/{user_id}", delete(remove_role)) get(list_community_roles).post(create_community_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/{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) .with_state(pool)
} }

View file

@ -12,9 +12,7 @@ use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::AuthUser; use crate::auth::AuthUser;
use crate::plugins::builtin::self_moderation::{ use crate::plugins::builtin::self_moderation::{CommunityRule, ModerationRulesService};
CommunityRule, ModerationRulesService,
};
// ============================================================================ // ============================================================================
// Request Types // Request Types
@ -174,12 +172,24 @@ async fn lift_sanction(
pub fn router(pool: PgPool) -> Router { pub fn router(pool: PgPool) -> Router {
Router::new() Router::new()
// Rules // 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 // Violations
.route("/api/communities/{community_id}/violations", get(get_pending_violations).post(report_violation)) .route(
.route("/api/violations/{violation_id}/review", post(review_violation)) "/api/communities/{community_id}/violations",
get(get_pending_violations).post(report_violation),
)
.route(
"/api/violations/{violation_id}/review",
post(review_violation),
)
// User summary // 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 // Sanctions
.route("/api/sanctions/{sanction_id}/lift", post(lift_sanction)) .route("/api/sanctions/{sanction_id}/lift", post(lift_sanction))
.with_state(pool) .with_state(pool)

View file

@ -3,8 +3,7 @@
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
routing::{get, patch, post}, routing::{get, patch, post},
Extension, Extension, Json, Router,
Json, Router,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@ -12,9 +11,9 @@ use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use super::permissions::{perms, require_any_permission, require_permission};
use crate::auth::AuthUser; use crate::auth::AuthUser;
use crate::config::Config; use crate::config::Config;
use super::permissions::{require_permission, require_any_permission, perms};
use axum::http::StatusCode; use axum::http::StatusCode;
// ============================================================================ // ============================================================================
@ -100,12 +99,10 @@ pub struct UpdateCommunitySettingsRequest {
/// Check if setup is required (public endpoint) /// Check if setup is required (public endpoint)
async fn get_setup_status(State(pool): State<PgPool>) -> Result<Json<SetupStatus>, String> { async fn get_setup_status(State(pool): State<PgPool>) -> Result<Json<SetupStatus>, String> {
let row = sqlx::query!( let row = sqlx::query!("SELECT setup_completed, instance_name FROM instance_settings LIMIT 1")
"SELECT setup_completed, instance_name FROM instance_settings LIMIT 1" .fetch_optional(&pool)
) .await
.fetch_optional(&pool) .map_err(|e| e.to_string())?;
.await
.map_err(|e| e.to_string())?;
match row { match row {
Some(r) => Ok(Json(SetupStatus { Some(r) => Ok(Json(SetupStatus {
@ -120,7 +117,9 @@ async fn get_setup_status(State(pool): State<PgPool>) -> Result<Json<SetupStatus
} }
/// Public instance settings (no auth) /// Public instance settings (no auth)
async fn get_public_settings(State(pool): State<PgPool>) -> Result<Json<PublicInstanceSettings>, String> { async fn get_public_settings(
State(pool): State<PgPool>,
) -> Result<Json<PublicInstanceSettings>, String> {
let row = sqlx::query!( let row = sqlx::query!(
r#"SELECT setup_completed, instance_name, platform_mode, r#"SELECT setup_completed, instance_name, platform_mode,
registration_enabled, registration_mode, registration_enabled, registration_mode,
@ -186,12 +185,18 @@ async fn complete_setup(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if existing.map(|e| e.setup_completed).unwrap_or(false) { 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 // Handle single_community mode
let single_community_id: Option<Uuid> = if req.platform_mode == "single_community" { let single_community_id: Option<Uuid> = 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!( let community = sqlx::query!(
r#"INSERT INTO communities (name, slug, description, is_active, created_by) r#"INSERT INTO communities (name, slug, description, is_active, created_by)
VALUES ($1, $2, $3, true, $4) VALUES ($1, $2, $3, true, $4)
@ -375,7 +380,8 @@ async fn update_community_settings(
auth.user_id, auth.user_id,
&[perms::COMMUNITY_SETTINGS, perms::PLATFORM_ADMIN], &[perms::COMMUNITY_SETTINGS, perms::PLATFORM_ADMIN],
Some(community_id), Some(community_id),
).await?; )
.await?;
// Ensure settings exist // Ensure settings exist
sqlx::query!( sqlx::query!(
@ -426,7 +432,13 @@ pub fn router(pool: PgPool) -> Router {
.route("/api/settings/setup", post(complete_setup)) .route("/api/settings/setup", post(complete_setup))
.route("/api/settings/instance", get(get_instance_settings)) .route("/api/settings/instance", get(get_instance_settings))
.route("/api/settings/instance", patch(update_instance_settings)) .route("/api/settings/instance", patch(update_instance_settings))
.route("/api/settings/communities/{community_id}", get(get_community_settings)) .route(
.route("/api/settings/communities/{community_id}", patch(update_community_settings)) "/api/settings/communities/{community_id}",
get(get_community_settings),
)
.route(
"/api/settings/communities/{community_id}",
patch(update_community_settings),
)
.with_state(pool) .with_state(pool)
} }

View file

@ -21,9 +21,7 @@ pub fn router(pool: PgPool) -> Router {
.with_state(pool) .with_state(pool)
} }
async fn list_users( async fn list_users(State(pool): State<PgPool>) -> Result<Json<Vec<UserResponse>>, String> {
State(pool): State<PgPool>,
) -> Result<Json<Vec<UserResponse>>, String> {
let users = sqlx::query_as!( let users = sqlx::query_as!(
crate::models::User, crate::models::User,
"SELECT * FROM users WHERE is_active = true ORDER BY created_at DESC LIMIT 100" "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, username: user.username,
display_name: user.display_name, display_name: user.display_name,
created_at: user.created_at, created_at: user.created_at,
communities: communities.into_iter().map(|c| CommunityMembership { communities: communities
id: c.id, .into_iter()
name: c.name, .map(|c| CommunityMembership {
slug: c.slug, id: c.id,
role: c.role, name: c.name,
}).collect(), slug: c.slug,
role: c.role,
})
.collect(),
proposal_count, proposal_count,
comment_count, comment_count,
})) }))
@ -176,13 +177,16 @@ async fn get_user_votes(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let result = votes.into_iter().map(|v| UserVote { let result = votes
proposal_id: v.proposal_id, .into_iter()
proposal_title: v.proposal_title, .map(|v| UserVote {
community_name: v.community_name, proposal_id: v.proposal_id,
option_label: v.option_label, proposal_title: v.proposal_title,
voted_at: v.voted_at, community_name: v.community_name,
}).collect(); option_label: v.option_label,
voted_at: v.voted_at,
})
.collect();
Ok(Json(result)) Ok(Json(result))
} }

View file

@ -8,14 +8,14 @@ use axum::{
routing::{get, post, put}, routing::{get, post, put},
Json, Router, Json, Router,
}; };
#[allow(unused_imports)]
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
#[allow(unused_imports)]
use chrono::{DateTime, Utc};
use super::permissions::{perms, require_permission};
use crate::auth::AuthUser; use crate::auth::AuthUser;
use super::permissions::{require_permission, perms};
// ============================================================================ // ============================================================================
// Types // Types
@ -100,19 +100,24 @@ async fn list_voting_methods(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(methods.into_iter().map(|m| VotingMethodPlugin { Ok(Json(
id: m.id, methods
name: m.name, .into_iter()
display_name: m.display_name, .map(|m| VotingMethodPlugin {
description: m.description, id: m.id,
icon: m.icon, name: m.name,
is_active: m.is_active, display_name: m.display_name,
is_default: m.is_default, description: m.description,
config_schema: m.config_schema, icon: m.icon,
default_config: m.default_config.unwrap_or_default(), is_active: m.is_active,
complexity_level: m.complexity_level.unwrap_or_default(), is_default: m.is_default,
supports_delegation: m.supports_delegation, config_schema: m.config_schema,
}).collect())) 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) /// Update platform voting method (admin only)
@ -191,25 +196,30 @@ async fn list_community_voting_methods(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(methods.into_iter().map(|m| CommunityVotingMethod { Ok(Json(
id: m.id, methods
voting_method: VotingMethodPlugin { .into_iter()
id: m.id, .map(|m| CommunityVotingMethod {
name: m.name.clone(), id: m.id,
display_name: m.display_name.clone(), voting_method: VotingMethodPlugin {
description: m.description.clone(), id: m.id,
icon: m.icon.clone(), name: m.name.clone(),
is_active: m.platform_active, display_name: m.display_name.clone(),
is_default: m.is_default.unwrap_or(false), description: m.description.clone(),
config_schema: m.config_schema.clone(), icon: m.icon.clone(),
default_config: m.default_config.clone().unwrap_or_default(), is_active: m.platform_active,
complexity_level: m.complexity_level.clone().unwrap_or_default(), is_default: m.is_default.unwrap_or(false),
supports_delegation: m.supports_delegation, config_schema: m.config_schema.clone(),
}, default_config: m.default_config.clone().unwrap_or_default(),
is_enabled: m.is_enabled.unwrap_or(false), complexity_level: m.complexity_level.clone().unwrap_or_default(),
is_default: m.is_default.unwrap_or(false), supports_delegation: m.supports_delegation,
config: m.config.unwrap_or_default(), },
}).collect())) 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 /// Configure voting method for a community
@ -231,7 +241,10 @@ async fn configure_community_voting_method(
.unwrap_or(false); .unwrap_or(false);
if !has_perm { 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 // If setting as default, unset other defaults first
@ -313,16 +326,21 @@ async fn list_default_plugins(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(plugins.into_iter().map(|p| DefaultPlugin { Ok(Json(
plugin_name: p.plugin_name, plugins
plugin_type: p.plugin_type, .into_iter()
display_name: p.display_name, .map(|p| DefaultPlugin {
description: p.description, plugin_name: p.plugin_name,
is_core: p.is_core, plugin_type: p.plugin_type,
is_recommended: p.is_recommended, display_name: p.display_name,
default_enabled: p.default_enabled, description: p.description,
category: p.category, is_core: p.is_core,
}).collect())) is_recommended: p.is_recommended,
default_enabled: p.default_enabled,
category: p.category,
})
.collect(),
))
} }
/// List instance plugins /// List instance plugins
@ -336,11 +354,16 @@ async fn list_instance_plugins(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(plugins.into_iter().map(|p| InstancePlugin { Ok(Json(
plugin_name: p.plugin_name, plugins
is_enabled: p.is_enabled, .into_iter()
config: p.config.unwrap_or_default(), .map(|p| InstancePlugin {
}).collect())) plugin_name: p.plugin_name,
is_enabled: p.is_enabled,
config: p.config.unwrap_or_default(),
})
.collect(),
))
} }
/// Update instance plugin /// Update instance plugin
@ -364,7 +387,10 @@ async fn update_instance_plugin(
.unwrap_or(false); .unwrap_or(false);
if is_core && req.is_enabled == Some(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!( let plugin = sqlx::query!(
@ -401,12 +427,11 @@ async fn initialize_default_plugins(
require_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?; require_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?;
// Get all default plugins // Get all default plugins
let defaults = sqlx::query!( let defaults =
"SELECT plugin_name, is_core, default_enabled FROM default_plugins" sqlx::query!("SELECT plugin_name, is_core, default_enabled FROM default_plugins")
) .fetch_all(&pool)
.fetch_all(&pool) .await
.await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Initialize each plugin // Initialize each plugin
for plugin in defaults { for plugin in defaults {
@ -441,12 +466,21 @@ pub fn router(pool: PgPool) -> Router {
.route("/api/voting-methods", get(list_voting_methods)) .route("/api/voting-methods", get(list_voting_methods))
.route("/api/voting-methods/{method_id}", put(update_voting_method)) .route("/api/voting-methods/{method_id}", put(update_voting_method))
// Community voting methods // Community voting methods
.route("/api/communities/{community_id}/voting-methods", get(list_community_voting_methods)) .route(
.route("/api/communities/{community_id}/voting-methods/{method_id}", put(configure_community_voting_method)) "/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 // Default plugins
.route("/api/plugins/defaults", get(list_default_plugins)) .route("/api/plugins/defaults", get(list_default_plugins))
.route("/api/plugins/instance", get(list_instance_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)) .route("/api/plugins/initialize", post(initialize_default_plugins))
.with_state(pool) .with_state(pool)
} }

View file

@ -149,11 +149,26 @@ async fn advance_phase(
pub fn router(pool: PgPool) -> Router { pub fn router(pool: PgPool) -> Router {
Router::new() Router::new()
// Templates // Templates
.route("/api/communities/{community_id}/workflow-templates", get(list_templates).post(create_template)) .route(
.route("/api/workflow-templates/{template_id}/phases", get(get_template_phases).post(add_phase)) "/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 // Proposal workflows
.route("/api/proposals/{proposal_id}/workflow", get(get_workflow_for_proposal)) .route(
.route("/api/proposals/{proposal_id}/workflow/progress", get(get_workflow_progress)) "/api/proposals/{proposal_id}/workflow",
.route("/api/proposals/{proposal_id}/workflow/advance", post(advance_phase)) 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) .with_state(pool)
} }

View file

@ -11,7 +11,11 @@ pub struct Claims {
pub iat: i64, pub iat: i64,
} }
pub fn create_token(user_id: Uuid, username: &str, secret: &str) -> Result<String, jsonwebtoken::errors::Error> { pub fn create_token(
user_id: Uuid,
username: &str,
secret: &str,
) -> Result<String, jsonwebtoken::errors::Error> {
let now = Utc::now(); let now = Utc::now();
let exp = now + Duration::hours(24); let exp = now + Duration::hours(24);

View file

@ -26,9 +26,10 @@ where
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header"))?; .ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header"))?;
let token = auth_header let token = auth_header.strip_prefix("Bearer ").ok_or((
.strip_prefix("Bearer ") StatusCode::UNAUTHORIZED,
.ok_or((StatusCode::UNAUTHORIZED, "Invalid authorization header format"))?; "Invalid authorization header format",
))?;
let secret = parts let secret = parts
.extensions .extensions

View file

@ -1,7 +1,7 @@
pub mod password;
pub mod jwt; pub mod jwt;
pub mod middleware; pub mod middleware;
pub mod password;
pub use password::{hash_password, verify_password};
pub use jwt::create_token; pub use jwt::create_token;
pub use middleware::AuthUser; pub use middleware::AuthUser;
pub use password::{hash_password, verify_password};

View file

@ -293,9 +293,7 @@ pub async fn reset_demo_data(pool: &PgPool) -> Result<(), sqlx::Error> {
.await?; .await?;
// Votes and delegation artifacts // Votes and delegation artifacts
sqlx::query("DELETE FROM votes") sqlx::query("DELETE FROM votes").execute(&mut *tx).await?;
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM delegated_votes") sqlx::query("DELETE FROM delegated_votes")
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;

View file

@ -8,13 +8,13 @@ mod plugins;
mod rate_limit; mod rate_limit;
mod voting; mod voting;
use std::net::SocketAddr;
use std::sync::Arc;
use axum::{middleware, Extension};
use axum::http::{HeaderName, HeaderValue}; use axum::http::{HeaderName, HeaderValue};
use axum::response::Response; use axum::response::Response;
use axum::{middleware, Extension};
use chrono::{Datelike, Timelike, Utc, Weekday}; use chrono::{Datelike, Timelike, Utc, Weekday};
use serde_json::json; use serde_json::json;
use std::net::SocketAddr;
use std::sync::Arc;
use thiserror::Error; use thiserror::Error;
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
@ -65,7 +65,8 @@ async fn run() -> Result<(), StartupError> {
tracing::info!("🎭 DEMO MODE ENABLED - Some actions are restricted"); 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?; let pool = db::create_pool(&database_url).await?;
@ -121,7 +122,9 @@ async fn run() -> Result<(), StartupError> {
}; };
let payload = json!({"ts": now.to_rfc3339()}); 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 cron_plugins
.do_action("cron.minutely", ctx.clone(), payload.clone()) .do_action("cron.minutely", ctx.clone(), payload.clone())
.await; .await;
@ -166,18 +169,17 @@ async fn run() -> Result<(), StartupError> {
} }
// WASM plugins need per-community context. // WASM plugins need per-community context.
let community_ids: Vec<Uuid> = match sqlx::query_scalar( let community_ids: Vec<Uuid> =
"SELECT id FROM communities WHERE is_active = true", match sqlx::query_scalar("SELECT id FROM communities WHERE is_active = true")
) .fetch_all(&cron_pool)
.fetch_all(&cron_pool) .await
.await {
{ Ok(ids) => ids,
Ok(ids) => ids, Err(e) => {
Err(e) => { tracing::error!("cron: failed to list communities: {}", e);
tracing::error!("cron: failed to list communities: {}", e); continue;
continue; }
} };
};
let mut wasm_hooks: Vec<&'static str> = vec!["cron.minute", "cron.minutely"]; let mut wasm_hooks: Vec<&'static str> = vec!["cron.minute", "cron.minutely"];
if min15_key == last_15min_key { if min15_key == last_15min_key {
@ -215,15 +217,20 @@ async fn run() -> Result<(), StartupError> {
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(middleware::map_response(add_security_headers)); .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))); .unwrap_or_else(|_| std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)));
let addr = SocketAddr::from((host, config.server_port)); let addr = SocketAddr::from((host, config.server_port));
tracing::info!("Likwid backend listening on http://{}", addr); tracing::info!("Likwid backend listening on http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?; let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()) axum::serve(
.await listener,
.map_err(|e| StartupError::Serve(e.to_string()))?; app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.map_err(|e| StartupError::Serve(e.to_string()))?;
Ok(()) Ok(())
} }

View file

@ -1,7 +1,7 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::FromRow; use sqlx::FromRow;
use uuid::Uuid; use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Community { pub struct Community {

View file

@ -1,7 +1,7 @@
pub mod user;
pub mod community; pub mod community;
pub mod proposal; pub mod proposal;
pub mod user;
pub use user::User;
pub use community::Community; pub use community::Community;
pub use proposal::ProposalStatus; pub use proposal::ProposalStatus;
pub use user::User;

View file

@ -1,7 +1,7 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::FromRow; use sqlx::FromRow;
use uuid::Uuid; use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "proposal_status", rename_all = "lowercase")] #[sqlx(type_name = "proposal_status", rename_all = "lowercase")]

View file

@ -1,7 +1,7 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::FromRow; use sqlx::FromRow;
use uuid::Uuid; use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User { pub struct User {

View file

@ -6,9 +6,7 @@ use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use crate::plugins::{ use crate::plugins::{
hooks::HookContext, hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
manager::PluginSystem,
Plugin, PluginError, PluginMetadata, PluginScope,
}; };
pub struct ConflictResolutionPlugin; pub struct ConflictResolutionPlugin;
@ -48,15 +46,23 @@ impl Plugin for ConflictResolutionPlugin {
50, 50,
Arc::new(|ctx: HookContext, payload: Value| { Arc::new(|ctx: HookContext, payload: Value| {
Box::pin(async move { 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(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok()) .and_then(|s| Uuid::parse_str(s).ok())
{ {
// Check if auto-assign is enabled // Check if auto-assign is enabled
if let Some(community_id) = ctx.community_id { 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 { 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?;
} }
} }
} }
@ -65,7 +71,8 @@ impl Plugin for ConflictResolutionPlugin {
Some("conflict_resolution"), Some("conflict_resolution"),
"conflict.reported", "conflict.reported",
payload.clone(), payload.clone(),
).await?; )
.await?;
Ok(()) Ok(())
}) })
}), }),
@ -82,7 +89,8 @@ impl Plugin for ConflictResolutionPlugin {
Some("conflict_resolution"), Some("conflict_resolution"),
"compromise.proposed", "compromise.proposed",
payload.clone(), payload.clone(),
).await?; )
.await?;
Ok(()) Ok(())
}) })
}), }),
@ -99,7 +107,8 @@ impl Plugin for ConflictResolutionPlugin {
Some("conflict_resolution"), Some("conflict_resolution"),
"conflict.resolved", "conflict.resolved",
payload.clone(), payload.clone(),
).await?; )
.await?;
Ok(()) Ok(())
}) })
}), }),
@ -135,7 +144,10 @@ pub struct ConflictService;
impl ConflictService { impl ConflictService {
/// Check if auto-assign mediators is enabled /// Check if auto-assign mediators is enabled
pub async fn should_auto_assign(pool: &PgPool, community_id: Uuid) -> Result<bool, PluginError> { pub async fn should_auto_assign(
pool: &PgPool,
community_id: Uuid,
) -> Result<bool, PluginError> {
let result = sqlx::query_scalar!( let result = sqlx::query_scalar!(
r#"SELECT COALESCE( r#"SELECT COALESCE(
(SELECT (cp.settings->>'auto_assign_mediators')::boolean (SELECT (cp.settings->>'auto_assign_mediators')::boolean
@ -172,7 +184,7 @@ impl ConflictService {
party_a_id, party_b_id, reported_by, reported_anonymously, party_a_id, party_b_id, reported_by, reported_anonymously,
severity_level severity_level
) VALUES ($1, $2, $3, $4::conflict_type, $5, $6, $7, $8, $9) ) VALUES ($1, $2, $3, $4::conflict_type, $5, $6, $7, $8, $9)
RETURNING id"# RETURNING id"#,
) )
.bind(community_id) .bind(community_id)
.bind(title) .bind(title)
@ -223,7 +235,7 @@ impl ConflictService {
notes: Option<&str>, notes: Option<&str>,
) -> Result<bool, PluginError> { ) -> Result<bool, PluginError> {
let success: bool = sqlx::query_scalar( 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(conflict_id)
.bind(new_status) .bind(new_status)
@ -276,7 +288,7 @@ impl ConflictService {
pub async fn respond_to_compromise( pub async fn respond_to_compromise(
pool: &PgPool, pool: &PgPool,
proposal_id: Uuid, proposal_id: Uuid,
party: &str, // "a" or "b" party: &str, // "a" or "b"
response: &str, // accept, reject, counter response: &str, // accept, reject, counter
feedback: Option<&str>, feedback: Option<&str>,
) -> Result<(), PluginError> { ) -> Result<(), PluginError> {
@ -426,7 +438,10 @@ impl ConflictService {
} }
/// Get conflict details /// Get conflict details
pub async fn get_conflict(pool: &PgPool, conflict_id: Uuid) -> Result<Option<ConflictCase>, PluginError> { pub async fn get_conflict(
pool: &PgPool,
conflict_id: Uuid,
) -> Result<Option<ConflictCase>, PluginError> {
let conflict = sqlx::query_as!( let conflict = sqlx::query_as!(
ConflictCase, ConflictCase,
r#"SELECT r#"SELECT
@ -444,7 +459,10 @@ impl ConflictService {
} }
/// Get active conflicts for a community /// Get active conflicts for a community
pub async fn get_active_conflicts(pool: &PgPool, community_id: Uuid) -> Result<Vec<ConflictCase>, PluginError> { pub async fn get_active_conflicts(
pool: &PgPool,
community_id: Uuid,
) -> Result<Vec<ConflictCase>, PluginError> {
let conflicts = sqlx::query_as!( let conflicts = sqlx::query_as!(
ConflictCase, ConflictCase,
r#"SELECT r#"SELECT
@ -502,7 +520,7 @@ impl ConflictService {
certification_level = COALESCE($3, mediator_pool.certification_level), certification_level = COALESCE($3, mediator_pool.certification_level),
is_trained = $4 OR mediator_pool.is_trained, is_trained = $4 OR mediator_pool.is_trained,
updated_at = NOW() updated_at = NOW()
RETURNING id"# RETURNING id"#,
) )
.bind(community_id) .bind(community_id)
.bind(user_id) .bind(user_id)

View file

@ -6,9 +6,7 @@ use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use crate::plugins::{ use crate::plugins::{
hooks::HookContext, hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
manager::PluginSystem,
Plugin, PluginError, PluginMetadata, PluginScope,
}; };
pub struct DecisionWorkflowsPlugin; pub struct DecisionWorkflowsPlugin;
@ -61,16 +59,30 @@ impl Plugin for DecisionWorkflowsPlugin {
Arc::new(|ctx: HookContext, payload: Value| { Arc::new(|ctx: HookContext, payload: Value| {
Box::pin(async move { Box::pin(async move {
if let (Some(proposal_id), Some(community_id)) = ( 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
payload.get("community_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()), .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 // 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 { 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 { 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( ctx.emit_public_event(
Some("decision_workflows"), Some("decision_workflows"),
@ -80,7 +92,8 @@ impl Plugin for DecisionWorkflowsPlugin {
"workflow_instance_id": instance_id, "workflow_instance_id": instance_id,
"template_id": tid "template_id": tid
}), }),
).await?; )
.await?;
} }
} }
} }
@ -110,10 +123,19 @@ impl Plugin for DecisionWorkflowsPlugin {
Arc::new(|ctx: HookContext, payload: Value| { Arc::new(|ctx: HookContext, payload: Value| {
Box::pin(async move { Box::pin(async move {
if let (Some(proposal_id), Some(user_id)) = ( 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, 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(()) Ok(())
}) })
@ -127,10 +149,19 @@ impl Plugin for DecisionWorkflowsPlugin {
Arc::new(|ctx: HookContext, payload: Value| { Arc::new(|ctx: HookContext, payload: Value| {
Box::pin(async move { Box::pin(async move {
if let (Some(proposal_id), Some(user_id)) = ( 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, 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(()) Ok(())
}) })
@ -144,17 +175,25 @@ impl Plugin for DecisionWorkflowsPlugin {
Arc::new(|ctx: HookContext, payload: Value| { Arc::new(|ctx: HookContext, payload: Value| {
Box::pin(async move { Box::pin(async move {
if let (Some(proposal_id), Some(user_id)) = ( 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, 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(()) Ok(())
}) })
}), }),
); );
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -271,13 +310,11 @@ impl WorkflowService {
proposal_id: Uuid, proposal_id: Uuid,
template_id: Uuid, template_id: Uuid,
) -> Result<Uuid, PluginError> { ) -> Result<Uuid, PluginError> {
let instance_id: Uuid = sqlx::query_scalar( let instance_id: Uuid = sqlx::query_scalar("SELECT start_workflow($1, $2)")
"SELECT start_workflow($1, $2)" .bind(proposal_id)
) .bind(template_id)
.bind(proposal_id) .fetch_one(pool)
.bind(template_id) .await?;
.fetch_one(pool)
.await?;
Ok(instance_id) Ok(instance_id)
} }
@ -289,14 +326,13 @@ impl WorkflowService {
user_id: Option<Uuid>, user_id: Option<Uuid>,
reason: Option<&str>, reason: Option<&str>,
) -> Result<Option<Uuid>, PluginError> { ) -> Result<Option<Uuid>, PluginError> {
let new_phase_id: Option<Uuid> = sqlx::query_scalar( let new_phase_id: Option<Uuid> =
"SELECT advance_workflow_phase($1, 'manual', $2, $3)" sqlx::query_scalar("SELECT advance_workflow_phase($1, 'manual', $2, $3)")
) .bind(workflow_instance_id)
.bind(workflow_instance_id) .bind(user_id)
.bind(user_id) .bind(reason)
.bind(reason) .fetch_one(pool)
.fetch_one(pool) .await?;
.await?;
Ok(new_phase_id) Ok(new_phase_id)
} }
@ -358,7 +394,7 @@ impl WorkflowService {
if phase.auto_advance { if phase.auto_advance {
// Auto-advance to next phase // Auto-advance to next phase
let _: Option<Uuid> = sqlx::query_scalar( let _: Option<Uuid> = 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) .bind(phase.workflow_instance_id)
.fetch_one(pool) .fetch_one(pool)
@ -367,11 +403,10 @@ impl WorkflowService {
} }
// Check quorum for active phases and record snapshots // Check quorum for active phases and record snapshots
let active_phases = sqlx::query!( let active_phases =
r#"SELECT pi.id FROM phase_instances pi WHERE pi.status = 'active'"# sqlx::query!(r#"SELECT pi.id FROM phase_instances pi WHERE pi.status = 'active'"#)
) .fetch_all(pool)
.fetch_all(pool) .await?;
.await?;
for phase in active_phases { for phase in active_phases {
// Calculate and record quorum // Calculate and record quorum
@ -562,7 +597,7 @@ impl WorkflowService {
default_duration_hours, quorum_value default_duration_hours, quorum_value
) )
VALUES ($1, $2, $3::workflow_phase_type, $4, $5, $6::numeric) VALUES ($1, $2, $3::workflow_phase_type, $4, $5, $6::numeric)
RETURNING id"# RETURNING id"#,
) )
.bind(template_id) .bind(template_id)
.bind(name) .bind(name)

View file

@ -6,9 +6,7 @@ use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use crate::plugins::{ use crate::plugins::{
hooks::HookContext, hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
manager::PluginSystem,
Plugin, PluginError, PluginMetadata, PluginScope,
}; };
pub struct FederationPlugin; pub struct FederationPlugin;
@ -61,11 +59,13 @@ impl Plugin for FederationPlugin {
100, 100,
Arc::new(|ctx: HookContext, payload: Value| { Arc::new(|ctx: HookContext, payload: Value| {
Box::pin(async move { 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(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok()) .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(()) Ok(())
}) })
@ -79,7 +79,8 @@ impl Plugin for FederationPlugin {
100, 100,
Arc::new(|ctx: HookContext, payload: Value| { Arc::new(|ctx: HookContext, payload: Value| {
Box::pin(async move { 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(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok()) .and_then(|s| Uuid::parse_str(s).ok())
{ {
@ -122,15 +123,14 @@ impl FederationService {
description: Option<&str>, description: Option<&str>,
public_key: Option<&str>, public_key: Option<&str>,
) -> Result<Uuid, PluginError> { ) -> Result<Uuid, PluginError> {
let instance_id: Uuid = sqlx::query_scalar( let instance_id: Uuid =
"SELECT register_federated_instance($1, $2, $3, $4)" sqlx::query_scalar("SELECT register_federated_instance($1, $2, $3, $4)")
) .bind(url)
.bind(url) .bind(name)
.bind(name) .bind(description)
.bind(description) .bind(public_key)
.bind(public_key) .fetch_one(pool)
.fetch_one(pool) .await?;
.await?;
Ok(instance_id) Ok(instance_id)
} }
@ -145,7 +145,7 @@ impl FederationService {
sync_direction: &str, sync_direction: &str,
) -> Result<Uuid, PluginError> { ) -> Result<Uuid, PluginError> {
let federation_id: Uuid = sqlx::query_scalar( 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(local_community_id)
.bind(remote_instance_id) .bind(remote_instance_id)
@ -207,7 +207,7 @@ impl FederationService {
sqlx::query( sqlx::query(
r#"INSERT INTO federation_sync_log r#"INSERT INTO federation_sync_log
(federation_id, instance_id, operation_type, direction, success, duration_ms) (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.id)
.bind(fed.remote_instance_id) .bind(fed.remote_instance_id)
@ -277,10 +277,7 @@ impl FederationService {
} }
/// Broadcast decision to federated communities /// Broadcast decision to federated communities
pub async fn broadcast_decision( pub async fn broadcast_decision(pool: &PgPool, proposal_id: Uuid) -> Result<(), PluginError> {
pool: &PgPool,
proposal_id: Uuid,
) -> Result<(), PluginError> {
// Find federated proposal // Find federated proposal
let federated = sqlx::query!( let federated = sqlx::query!(
r#"SELECT fp.id, fp.federation_id r#"SELECT fp.id, fp.federation_id
@ -319,12 +316,9 @@ impl FederationService {
/// Get federation statistics for a community /// Get federation statistics for a community
pub async fn get_stats(pool: &PgPool, community_id: Uuid) -> Result<Value, PluginError> { pub async fn get_stats(pool: &PgPool, community_id: Uuid) -> Result<Value, PluginError> {
let stats = sqlx::query!( let stats = sqlx::query!("SELECT * FROM get_federation_stats($1)", community_id)
"SELECT * FROM get_federation_stats($1)", .fetch_one(pool)
community_id .await?;
)
.fetch_one(pool)
.await?;
Ok(json!({ Ok(json!({
"total_federations": stats.total_federations, "total_federations": stats.total_federations,

View file

@ -6,9 +6,7 @@ use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use crate::plugins::{ use crate::plugins::{
hooks::HookContext, hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
manager::PluginSystem,
Plugin, PluginError, PluginMetadata, PluginScope,
}; };
pub struct GovernanceAnalyticsPlugin; pub struct GovernanceAnalyticsPlugin;
@ -66,7 +64,6 @@ impl Plugin for GovernanceAnalyticsPlugin {
}), }),
); );
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -99,23 +96,20 @@ impl AnalyticsService {
pool: &PgPool, pool: &PgPool,
community_id: Uuid, community_id: Uuid,
) -> Result<Uuid, PluginError> { ) -> Result<Uuid, PluginError> {
let snapshot_id: Uuid = sqlx::query_scalar( let snapshot_id: Uuid =
"SELECT calculate_participation_snapshot($1, CURRENT_DATE)" sqlx::query_scalar("SELECT calculate_participation_snapshot($1, CURRENT_DATE)")
) .bind(community_id)
.bind(community_id) .fetch_one(pool)
.fetch_one(pool) .await?;
.await?;
Ok(snapshot_id) Ok(snapshot_id)
} }
/// Calculate snapshots for all communities /// Calculate snapshots for all communities
pub async fn calculate_all_snapshots(pool: &PgPool) -> Result<i32, PluginError> { pub async fn calculate_all_snapshots(pool: &PgPool) -> Result<i32, PluginError> {
let communities = sqlx::query_scalar!( let communities = sqlx::query_scalar!("SELECT id FROM communities WHERE is_active = true")
"SELECT id FROM communities WHERE is_active = true" .fetch_all(pool)
) .await?;
.fetch_all(pool)
.await?;
let mut count = 0; let mut count = 0;
for community_id in communities { for community_id in communities {
@ -130,27 +124,20 @@ impl AnalyticsService {
} }
/// Calculate governance health score for a community /// Calculate governance health score for a community
pub async fn calculate_health( pub async fn calculate_health(pool: &PgPool, community_id: Uuid) -> Result<Uuid, PluginError> {
pool: &PgPool, let health_id: Uuid = sqlx::query_scalar("SELECT calculate_governance_health($1)")
community_id: Uuid, .bind(community_id)
) -> Result<Uuid, PluginError> { .fetch_one(pool)
let health_id: Uuid = sqlx::query_scalar( .await?;
"SELECT calculate_governance_health($1)"
)
.bind(community_id)
.fetch_one(pool)
.await?;
Ok(health_id) Ok(health_id)
} }
/// Calculate health scores for all communities /// Calculate health scores for all communities
pub async fn calculate_all_health_scores(pool: &PgPool) -> Result<i32, PluginError> { pub async fn calculate_all_health_scores(pool: &PgPool) -> Result<i32, PluginError> {
let communities = sqlx::query_scalar!( let communities = sqlx::query_scalar!("SELECT id FROM communities WHERE is_active = true")
"SELECT id FROM communities WHERE is_active = true" .fetch_all(pool)
) .await?;
.fetch_all(pool)
.await?;
let mut count = 0; let mut count = 0;
for community_id in communities { for community_id in communities {
@ -316,22 +303,24 @@ impl AnalyticsService {
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
Ok(methods.into_iter().map(|m| json!({ Ok(methods
"method": m.voting_method, .into_iter()
"proposals": m.proposals_using_method, .map(|m| {
"total_votes": m.total_votes_cast, json!({
"avg_turnout": m.turnout, "method": m.voting_method,
"avg_decision_time": m.avg_time, "proposals": m.proposals_using_method,
"decisive_results": m.decisive_results, "total_votes": m.total_votes_cast,
"close_results": m.close_results "avg_turnout": m.turnout,
})).collect()) "avg_decision_time": m.avg_time,
"decisive_results": m.decisive_results,
"close_results": m.close_results
})
})
.collect())
} }
/// Get full dashboard data /// Get full dashboard data
pub async fn get_dashboard( pub async fn get_dashboard(pool: &PgPool, community_id: Uuid) -> Result<Value, PluginError> {
pool: &PgPool,
community_id: Uuid,
) -> Result<Value, PluginError> {
let health = Self::get_health(pool, community_id).await?; let health = Self::get_health(pool, community_id).await?;
let trends = Self::get_participation_trends(pool, community_id, 30).await?; let trends = Self::get_participation_trends(pool, community_id, 30).await?;
let delegation = Self::get_delegation_analytics(pool, community_id).await?; let delegation = Self::get_delegation_analytics(pool, community_id).await?;

View file

@ -216,7 +216,10 @@ impl LedgerService {
} }
/// Get a single entry by ID /// Get a single entry by ID
pub async fn get_entry(pool: &PgPool, entry_id: Uuid) -> Result<Option<LedgerEntry>, PluginError> { pub async fn get_entry(
pool: &PgPool,
entry_id: Uuid,
) -> Result<Option<LedgerEntry>, PluginError> {
let entry = sqlx::query_as!( let entry = sqlx::query_as!(
LedgerEntry, LedgerEntry,
r#"SELECT r#"SELECT
@ -412,7 +415,8 @@ impl Plugin for ModerationLedgerPlugin {
Arc::new(move |ctx: HookContext, payload: Value| { Arc::new(move |ctx: HookContext, payload: Value| {
let plugin_name = plugin_name.clone(); let plugin_name = plugin_name.clone();
Box::pin(async move { 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()))?; .ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
let target_id = payload let target_id = payload
@ -452,17 +456,20 @@ impl Plugin for ModerationLedgerPlugin {
"unilateral", "unilateral",
None, None,
None, None,
).await?; )
.await?;
let _ = ctx.emit_public_event( let _ = ctx
Some(&plugin_name), .emit_public_event(
"ledger.entry_created", Some(&plugin_name),
json!({ "ledger.entry_created",
"entry_id": entry_id, json!({
"action_type": "content_remove", "entry_id": entry_id,
"target_type": content_type, "action_type": "content_remove",
}), "target_type": content_type,
).await; }),
)
.await;
Ok(()) Ok(())
}) })
@ -478,7 +485,8 @@ impl Plugin for ModerationLedgerPlugin {
Arc::new(move |ctx: HookContext, payload: Value| { Arc::new(move |ctx: HookContext, payload: Value| {
let plugin_name = plugin_name2.clone(); let plugin_name = plugin_name2.clone();
Box::pin(async move { 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()))?; .ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
let target_user_id = payload let target_user_id = payload
@ -534,17 +542,20 @@ impl Plugin for ModerationLedgerPlugin {
"unilateral", "unilateral",
None, None,
None, None,
).await?; )
.await?;
let _ = ctx.emit_public_event( let _ = ctx
Some(&plugin_name), .emit_public_event(
"ledger.entry_created", Some(&plugin_name),
json!({ "ledger.entry_created",
"entry_id": entry_id, json!({
"action_type": action, "entry_id": entry_id,
"target_type": "user", "action_type": action,
}), "target_type": "user",
).await; }),
)
.await;
Ok(()) Ok(())
}) })
@ -560,7 +571,8 @@ impl Plugin for ModerationLedgerPlugin {
Arc::new(move |ctx: HookContext, payload: Value| { Arc::new(move |ctx: HookContext, payload: Value| {
let plugin_name = plugin_name3.clone(); let plugin_name = plugin_name3.clone();
Box::pin(async move { 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()))?; .ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
let proposal_id = payload let proposal_id = payload
@ -617,17 +629,20 @@ impl Plugin for ModerationLedgerPlugin {
decision_type, decision_type,
vote_proposal_id, vote_proposal_id,
payload.get("vote_result").cloned(), payload.get("vote_result").cloned(),
).await?; )
.await?;
let _ = ctx.emit_public_event( let _ = ctx
Some(&plugin_name), .emit_public_event(
"ledger.entry_created", Some(&plugin_name),
json!({ "ledger.entry_created",
"entry_id": entry_id, json!({
"action_type": action, "entry_id": entry_id,
"target_type": "proposal", "action_type": action,
}), "target_type": "proposal",
).await; }),
)
.await;
Ok(()) Ok(())
}) })

View file

@ -6,9 +6,7 @@ use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use crate::plugins::{ use crate::plugins::{
hooks::HookContext, hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
manager::PluginSystem,
Plugin, PluginError, PluginMetadata, PluginScope,
}; };
pub struct ProposalLifecyclePlugin; pub struct ProposalLifecyclePlugin;
@ -48,19 +46,29 @@ impl Plugin for ProposalLifecyclePlugin {
10, 10,
Arc::new(|ctx: HookContext, payload: Value| { Arc::new(|ctx: HookContext, payload: Value| {
Box::pin(async move { 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(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok()) .and_then(|s| Uuid::parse_str(s).ok())
{ {
let title = payload.get("title").and_then(|v| v.as_str()).unwrap_or(""); 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()); let summary = payload.get("change_summary").and_then(|v| v.as_str());
if let Some(user_id) = ctx.actor_user_id { if let Some(user_id) = ctx.actor_user_id {
LifecycleService::create_version( LifecycleService::create_version(
&ctx.pool, proposal_id, title, content, &ctx.pool,
user_id, "edit", summary proposal_id,
).await?; title,
content,
user_id,
"edit",
summary,
)
.await?;
} }
} }
Ok(()) Ok(())
@ -79,7 +87,8 @@ impl Plugin for ProposalLifecyclePlugin {
Some("proposal_lifecycle"), Some("proposal_lifecycle"),
"status.transition", "status.transition",
payload.clone(), payload.clone(),
).await?; )
.await?;
Ok(()) Ok(())
}) })
}), }),
@ -96,13 +105,13 @@ impl Plugin for ProposalLifecyclePlugin {
Some("proposal_lifecycle"), Some("proposal_lifecycle"),
"proposal.forked", "proposal.forked",
payload.clone(), payload.clone(),
).await?; )
.await?;
Ok(()) Ok(())
}) })
}), }),
); );
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -143,17 +152,16 @@ impl LifecycleService {
change_type: &str, change_type: &str,
change_summary: Option<&str>, change_summary: Option<&str>,
) -> Result<i32, PluginError> { ) -> Result<i32, PluginError> {
let version: i32 = sqlx::query_scalar( let version: i32 =
"SELECT create_proposal_version($1, $2, $3, NULL, $4, $5, $6)" sqlx::query_scalar("SELECT create_proposal_version($1, $2, $3, NULL, $4, $5, $6)")
) .bind(proposal_id)
.bind(proposal_id) .bind(title)
.bind(title) .bind(content)
.bind(content) .bind(created_by)
.bind(created_by) .bind(change_type)
.bind(change_type) .bind(change_summary)
.bind(change_summary) .fetch_one(pool)
.fetch_one(pool) .await?;
.await?;
Ok(version) Ok(version)
} }
@ -168,7 +176,7 @@ impl LifecycleService {
reason: Option<&str>, reason: Option<&str>,
) -> Result<bool, PluginError> { ) -> Result<bool, PluginError> {
let success: bool = sqlx::query_scalar( 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(proposal_id)
.bind(new_status) .bind(new_status)
@ -189,15 +197,13 @@ impl LifecycleService {
reason: &str, reason: &str,
community_id: Uuid, community_id: Uuid,
) -> Result<Uuid, PluginError> { ) -> Result<Uuid, PluginError> {
let new_id: Uuid = sqlx::query_scalar( let new_id: Uuid = sqlx::query_scalar("SELECT fork_proposal($1, $2, $3, $4)")
"SELECT fork_proposal($1, $2, $3, $4)" .bind(source_proposal_id)
) .bind(forked_by)
.bind(source_proposal_id) .bind(reason)
.bind(forked_by) .bind(community_id)
.bind(reason) .fetch_one(pool)
.bind(community_id) .await?;
.fetch_one(pool)
.await?;
Ok(new_id) Ok(new_id)
} }
@ -256,19 +262,17 @@ impl LifecycleService {
let to = Self::get_version(pool, proposal_id, to_version).await?; let to = Self::get_version(pool, proposal_id, to_version).await?;
match (from, to) { match (from, to) {
(Some(f), Some(t)) => { (Some(f), Some(t)) => Ok(json!({
Ok(json!({ "from_version": from_version,
"from_version": from_version, "to_version": to_version,
"to_version": to_version, "title_changed": f.title != t.title,
"title_changed": f.title != t.title, "content_changed": f.content != t.content,
"content_changed": f.content != t.content, "from_title": f.title,
"from_title": f.title, "to_title": t.title,
"to_title": t.title, "from_content_length": f.content.len(),
"from_content_length": f.content.len(), "to_content_length": t.content.len(),
"to_content_length": t.content.len(), "change_summary": t.change_summary
"change_summary": t.change_summary })),
}))
}
_ => Ok(json!({"error": "Version not found"})), _ => Ok(json!({"error": "Version not found"})),
} }
} }
@ -441,10 +445,7 @@ impl LifecycleService {
} }
/// Get forks of a proposal /// Get forks of a proposal
pub async fn get_forks( pub async fn get_forks(pool: &PgPool, proposal_id: Uuid) -> Result<Vec<Value>, PluginError> {
pool: &PgPool,
proposal_id: Uuid,
) -> Result<Vec<Value>, PluginError> {
let forks = sqlx::query!( let forks = sqlx::query!(
r#"SELECT r#"SELECT
pf.fork_proposal_id, pf.fork_proposal_id,
@ -464,14 +465,19 @@ impl LifecycleService {
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
Ok(forks.into_iter().map(|f| json!({ Ok(forks
"fork_id": f.fork_proposal_id, .into_iter()
"title": f.fork_title, .map(|f| {
"forked_by": f.forked_by_username, json!({
"forked_at": f.forked_at, "fork_id": f.fork_proposal_id,
"reason": f.fork_reason, "title": f.fork_title,
"is_competing": f.is_competing, "forked_by": f.forked_by_username,
"is_merged": f.is_merged "forked_at": f.forked_at,
})).collect()) "reason": f.fork_reason,
"is_competing": f.is_competing,
"is_merged": f.is_merged
})
})
.collect())
} }
} }

View file

@ -6,9 +6,7 @@ use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use crate::plugins::{ use crate::plugins::{
hooks::HookContext, hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
manager::PluginSystem,
Plugin, PluginError, PluginMetadata, PluginScope,
}; };
pub struct PublicDataExportPlugin; pub struct PublicDataExportPlugin;
@ -74,11 +72,13 @@ impl Plugin for PublicDataExportPlugin {
50, 50,
Arc::new(|ctx: HookContext, payload: Value| { Arc::new(|ctx: HookContext, payload: Value| {
Box::pin(async move { 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(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok()) .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(()) Ok(())
}) })
@ -120,17 +120,16 @@ impl ExportService {
date_from: Option<chrono::DateTime<chrono::Utc>>, date_from: Option<chrono::DateTime<chrono::Utc>>,
date_to: Option<chrono::DateTime<chrono::Utc>>, date_to: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Uuid, PluginError> { ) -> Result<Uuid, PluginError> {
let job_id: Uuid = sqlx::query_scalar( let job_id: Uuid =
"SELECT create_export_job($1, $2, $3::export_format, $4, $5, $6)" sqlx::query_scalar("SELECT create_export_job($1, $2, $3::export_format, $4, $5, $6)")
) .bind(community_id)
.bind(community_id) .bind(export_type)
.bind(export_type) .bind(format)
.bind(format) .bind(requested_by)
.bind(requested_by) .bind(date_from)
.bind(date_from) .bind(date_to)
.bind(date_to) .fetch_one(pool)
.fetch_one(pool) .await?;
.await?;
Ok(job_id) Ok(job_id)
} }
@ -160,7 +159,10 @@ impl ExportService {
"proposals" => Self::export_proposals(pool, job.community_id, &job.format).await, "proposals" => Self::export_proposals(pool, job.community_id, &job.format).await,
"votes" => Self::export_votes(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, "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 { match result {
@ -201,7 +203,8 @@ impl ExportService {
community_id: Option<Uuid>, community_id: Option<Uuid>,
format: &str, format: &str,
) -> Result<(i32, String), PluginError> { ) -> 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!( let proposals = sqlx::query!(
r#"SELECT * FROM get_exportable_proposals($1, true, NULL, NULL)"#, r#"SELECT * FROM get_exportable_proposals($1, true, NULL, NULL)"#,
@ -213,14 +216,19 @@ impl ExportService {
let count = proposals.len() as i32; let count = proposals.len() as i32;
let data = match format { let data = match format {
"json" => { "json" => {
let items: Vec<Value> = proposals.iter().map(|p| json!({ let items: Vec<Value> = proposals
"id": p.id, .iter()
"title": p.title, .map(|p| {
"author_id": p.author_id, json!({
"status": p.status, "id": p.id,
"created_at": p.created_at, "title": p.title,
"vote_count": p.vote_count "author_id": p.author_id,
})).collect(); "status": p.status,
"created_at": p.created_at,
"vote_count": p.vote_count
})
})
.collect();
serde_json::to_string_pretty(&items).unwrap_or_default() serde_json::to_string_pretty(&items).unwrap_or_default()
} }
"csv" => { "csv" => {
@ -250,7 +258,8 @@ impl ExportService {
community_id: Option<Uuid>, community_id: Option<Uuid>,
format: &str, format: &str,
) -> Result<(i32, String), PluginError> { ) -> 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!( let votes = sqlx::query!(
r#"SELECT r#"SELECT
@ -269,12 +278,17 @@ impl ExportService {
let count = votes.len() as i32; let count = votes.len() as i32;
let data = match format { let data = match format {
"json" => { "json" => {
let items: Vec<Value> = votes.iter().map(|v| json!({ let items: Vec<Value> = votes
"id": v.id, .iter()
"proposal_id": v.proposal_id, .map(|v| {
"voter_hash": v.voter_hash, json!({
"created_at": v.created_at "id": v.id,
})).collect(); "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() serde_json::to_string_pretty(&items).unwrap_or_default()
} }
"csv" => { "csv" => {
@ -282,7 +296,8 @@ impl ExportService {
for v in &votes { for v in &votes {
csv.push_str(&format!( csv.push_str(&format!(
"{},{},{},{}\n", "{},{},{},{}\n",
v.id, v.proposal_id, v.id,
v.proposal_id,
v.voter_hash.as_deref().unwrap_or(""), v.voter_hash.as_deref().unwrap_or(""),
v.created_at v.created_at
)); ));
@ -301,7 +316,8 @@ impl ExportService {
community_id: Option<Uuid>, community_id: Option<Uuid>,
format: &str, format: &str,
) -> Result<(i32, String), PluginError> { ) -> 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!( let analytics = sqlx::query!(
r#"SELECT snapshot_date, total_members, active_members, votes_cast r#"SELECT snapshot_date, total_members, active_members, votes_cast
@ -317,12 +333,17 @@ impl ExportService {
let count = analytics.len() as i32; let count = analytics.len() as i32;
let data = match format { let data = match format {
"json" => { "json" => {
let items: Vec<Value> = analytics.iter().map(|a| json!({ let items: Vec<Value> = analytics
"date": a.snapshot_date.to_string(), .iter()
"total_members": a.total_members, .map(|a| {
"active_members": a.active_members, json!({
"votes_cast": a.votes_cast "date": a.snapshot_date.to_string(),
})).collect(); "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() serde_json::to_string_pretty(&items).unwrap_or_default()
} }
"csv" => { "csv" => {
@ -381,7 +402,10 @@ impl ExportService {
} }
/// Get available exports for a community /// Get available exports for a community
pub async fn get_available(pool: &PgPool, community_id: Uuid) -> Result<Vec<ExportConfig>, PluginError> { pub async fn get_available(
pool: &PgPool,
community_id: Uuid,
) -> Result<Vec<ExportConfig>, PluginError> {
let configs = sqlx::query_as!( let configs = sqlx::query_as!(
ExportConfig, ExportConfig,
r#"SELECT id, community_id, name, export_type, public_access r#"SELECT id, community_id, name, export_type, public_access

View file

@ -6,9 +6,7 @@ use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use crate::plugins::{ use crate::plugins::{
hooks::HookContext, hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
manager::PluginSystem,
Plugin, PluginError, PluginMetadata, PluginScope,
}; };
pub struct SelfModerationPlugin; pub struct SelfModerationPlugin;
@ -67,12 +65,16 @@ impl Plugin for SelfModerationPlugin {
payload.get("action").and_then(|v| v.as_str()), payload.get("action").and_then(|v| v.as_str()),
) { ) {
let is_blocked = ModerationRulesService::check_user_blocked( let is_blocked = ModerationRulesService::check_user_blocked(
&ctx.pool, user_id, community_id, action &ctx.pool,
).await?; user_id,
community_id,
action,
)
.await?;
if is_blocked { if is_blocked {
return Err(PluginError::Message( 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"), Some("self_moderation_rules"),
"violation.reported", "violation.reported",
payload.clone(), payload.clone(),
).await?; )
.await?;
Ok(()) Ok(())
}) })
}), }),
@ -109,13 +112,13 @@ impl Plugin for SelfModerationPlugin {
Some("self_moderation_rules"), Some("self_moderation_rules"),
"sanction.applied", "sanction.applied",
payload.clone(), payload.clone(),
).await?; )
.await?;
Ok(()) Ok(())
}) })
}), }),
); );
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -182,20 +185,19 @@ impl ModerationRulesService {
}; };
if let Some(st) = sanction_type { if let Some(st) = sanction_type {
let blocked: bool = sqlx::query_scalar( let blocked: bool =
"SELECT user_has_active_sanction($1, $2, $3::sanction_type)" sqlx::query_scalar("SELECT user_has_active_sanction($1, $2, $3::sanction_type)")
) .bind(user_id)
.bind(user_id) .bind(community_id)
.bind(community_id) .bind(st)
.bind(st) .fetch_one(pool)
.fetch_one(pool) .await?;
.await?;
return Ok(blocked); return Ok(blocked);
} }
// Check for permanent ban // Check for permanent ban
let banned: bool = sqlx::query_scalar( 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(user_id)
.bind(community_id) .bind(community_id)
@ -251,14 +253,13 @@ impl ModerationRulesService {
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
let escalation_level: i32 = sqlx::query_scalar( let escalation_level: i32 =
"SELECT calculate_escalation_level($1, $2, $3)" sqlx::query_scalar("SELECT calculate_escalation_level($1, $2, $3)")
) .bind(violation.target_user_id)
.bind(violation.target_user_id) .bind(violation.community_id)
.bind(violation.community_id) .bind(violation.rule_id)
.bind(violation.rule_id) .fetch_one(pool)
.fetch_one(pool) .await?;
.await?;
// Check if community vote is required // Check if community vote is required
let rule = sqlx::query!( let rule = sqlx::query!(
@ -353,15 +354,14 @@ impl ModerationRulesService {
.await?; .await?;
// Apply the sanction // Apply the sanction
let sanction_id: Uuid = sqlx::query_scalar( let sanction_id: Uuid =
"SELECT apply_sanction($1, $2::sanction_type, $3, $4, 'manual')" sqlx::query_scalar("SELECT apply_sanction($1, $2::sanction_type, $3, $4, 'manual')")
) .bind(violation_id)
.bind(violation_id) .bind(&sanction.sanction_type)
.bind(&sanction.sanction_type) .bind(sanction.duration_hours)
.bind(sanction.duration_hours) .bind(applied_by)
.bind(applied_by) .fetch_one(pool)
.fetch_one(pool) .await?;
.await?;
Ok(sanction_id) Ok(sanction_id)
} }
@ -513,18 +513,20 @@ impl ModerationRulesService {
Ok(violations Ok(violations
.into_iter() .into_iter()
.map(|v| json!({ .map(|v| {
"id": v.id, json!({
"rule_code": v.rule_code, "id": v.id,
"rule_title": v.rule_title, "rule_code": v.rule_code,
"severity": v.severity, "rule_title": v.rule_title,
"target_user_id": v.target_user_id, "severity": v.severity,
"target_username": v.target_username, "target_user_id": v.target_user_id,
"reported_by": v.reported_by, "target_username": v.target_username,
"status": v.status, "reported_by": v.reported_by,
"reported_at": v.reported_at, "status": v.status,
"reason": v.report_reason "reported_at": v.reported_at,
})) "reason": v.report_reason
})
})
.collect()) .collect())
} }
} }

View file

@ -6,9 +6,7 @@ use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use crate::plugins::{ use crate::plugins::{
hooks::HookContext, hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
manager::PluginSystem,
Plugin, PluginError, PluginMetadata, PluginScope,
}; };
pub struct StructuredDeliberationPlugin; pub struct StructuredDeliberationPlugin;
@ -48,16 +46,23 @@ impl Plugin for StructuredDeliberationPlugin {
Arc::new(|ctx: HookContext, payload: Value| { Arc::new(|ctx: HookContext, payload: Value| {
Box::pin(async move { Box::pin(async move {
if let (Some(proposal_id), Some(user_id)) = ( 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, ctx.actor_user_id,
) { ) {
let can_comment = DeliberationService::check_can_participate( let can_comment = DeliberationService::check_can_participate(
&ctx.pool, proposal_id, user_id, "comment" &ctx.pool,
).await?; proposal_id,
user_id,
"comment",
)
.await?;
if !can_comment { if !can_comment {
return Err(PluginError::Message( 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"), Some("structured_deliberation"),
"argument.created", "argument.created",
payload.clone(), payload.clone(),
).await?; )
.await?;
Ok(()) Ok(())
}) })
}), }),
@ -189,7 +195,7 @@ impl DeliberationService {
author_id: Uuid, author_id: Uuid,
) -> Result<Uuid, PluginError> { ) -> Result<Uuid, PluginError> {
let argument_id: Uuid = sqlx::query_scalar( 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(proposal_id)
.bind(parent_id) .bind(parent_id)

View file

@ -1,9 +1,4 @@
use std::{ use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc};
collections::HashMap,
future::Future,
pin::Pin,
sync::Arc,
};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde_json::Value; use serde_json::Value;
@ -103,16 +98,10 @@ impl HookRegistry {
} }
pub fn actions_for(&self, hook: &str) -> &[ActionHandler] { pub fn actions_for(&self, hook: &str) -> &[ActionHandler] {
self.actions self.actions.get(hook).map(|v| v.as_slice()).unwrap_or(&[])
.get(hook)
.map(|v| v.as_slice())
.unwrap_or(&[])
} }
pub fn filters_for(&self, hook: &str) -> &[FilterHandler] { pub fn filters_for(&self, hook: &str) -> &[FilterHandler] {
self.filters self.filters.get(hook).map(|v| v.as_slice()).unwrap_or(&[])
.get(hook)
.map(|v| v.as_slice())
.unwrap_or(&[])
} }
} }

View file

@ -1,4 +1,7 @@
use std::{collections::{HashMap, HashSet}, sync::Arc}; use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use async_trait::async_trait; use async_trait::async_trait;
use serde_json::{json, Value}; use serde_json::{json, Value};
@ -6,9 +9,9 @@ use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::plugins::hooks::{ActionHandler, FilterHandler, HookContext, HookRegistry, PluginError}; 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::host_api::PluginManifest;
use crate::plugins::wasm::runtime::WasmRuntime; use crate::plugins::wasm::runtime::WasmRuntime;
use crate::plugins::wasm::WasmPlugin;
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum PluginScope { pub enum PluginScope {
@ -350,7 +353,8 @@ impl PluginManager {
.await?; .await?;
for community in communities { 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 { for plugin in &self.plugins {
@ -360,12 +364,16 @@ impl PluginManager {
Ok(Arc::new(self)) Ok(Arc::new(self))
} }
async fn active_plugins(&self, community_id: Option<Uuid>) -> Result<HashSet<String>, PluginError> { async fn active_plugins(
&self,
community_id: Option<Uuid>,
) -> Result<HashSet<String>, PluginError> {
let mut active: HashSet<String> = HashSet::new(); let mut active: HashSet<String> = HashSet::new();
let core = sqlx::query!("SELECT name FROM plugins WHERE is_active = true AND is_core = true") let core =
.fetch_all(&self.pool) sqlx::query!("SELECT name FROM plugins WHERE is_active = true AND is_core = true")
.await?; .fetch_all(&self.pool)
.await?;
for row in core { for row in core {
active.insert(row.name); 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!( let wasm = match sqlx::query!(
r#"SELECT DISTINCT pp.id r#"SELECT DISTINCT pp.id
FROM plugin_packages pp FROM plugin_packages pp
@ -468,7 +481,11 @@ impl PluginManager {
{ {
Ok(rows) => rows, Ok(rows) => rows,
Err(e) => { 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; return;
} }
}; };
@ -621,12 +638,8 @@ impl PluginManager {
if old_is_active && !new_is_active { if old_is_active && !new_is_active {
self.invoke_deactivate(plugin_name, ctx.clone(), old_settings.clone()) self.invoke_deactivate(plugin_name, ctx.clone(), old_settings.clone())
.await; .await;
self.do_action( self.do_action("plugin.deactivated", ctx, json!({"plugin": plugin_name}))
"plugin.deactivated", .await;
ctx,
json!({"plugin": plugin_name}),
)
.await;
} }
} }

View file

@ -10,8 +10,8 @@ use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use sqlx::PgPool; use sqlx::PgPool;
use tokio::runtime::Handle; use tokio::runtime::Handle;
use tokio::task::block_in_place;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::task::block_in_place;
use uuid::Uuid; use uuid::Uuid;
use wasmtime::{Linker, StoreLimits}; use wasmtime::{Linker, StoreLimits};
@ -70,7 +70,11 @@ impl HostState {
.find(|c| c.name == CAP_OUTBOUND_HTTP && c.allowed) .find(|c| c.name == CAP_OUTBOUND_HTTP && c.allowed)
.and_then(|c| c.config.get("allowlist")) .and_then(|c| c.config.get("allowlist"))
.and_then(|v| v.as_array()) .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(); .unwrap_or_default();
Self { Self {
@ -88,7 +92,9 @@ impl HostState {
} }
pub fn has_capability(&self, name: &str) -> bool { 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 { pub fn is_url_allowed(&self, url: &str) -> bool {
@ -158,31 +164,40 @@ impl HostStateWithLimits {
} }
/// Registers host functions for WASM plugins. /// Registers host functions for WASM plugins.
pub fn register_host_functions(linker: &mut Linker<HostStateWithLimits>) -> Result<(), PluginError> { pub fn register_host_functions(
linker: &mut Linker<HostStateWithLimits>,
) -> Result<(), PluginError> {
// host_log: Allow plugins to emit log messages // host_log: Allow plugins to emit log messages
linker linker
.func_wrap("env", "host_log", |mut caller: wasmtime::Caller<'_, HostStateWithLimits>, ptr: u32, len: u32, level: u32| { .func_wrap(
let memory = caller.get_export("memory").and_then(|e| e.into_memory()); "env",
if let Some(mem) = memory { "host_log",
if len > MAX_WASM_STRING_BYTES { |mut caller: wasmtime::Caller<'_, HostStateWithLimits>,
return; ptr: u32,
} len: u32,
let mut buf = vec![0u8; len as usize]; level: u32| {
if mem.read(&caller, ptr as usize, &mut buf).is_ok() { let memory = caller.get_export("memory").and_then(|e| e.into_memory());
if let Ok(msg) = String::from_utf8(buf) { if let Some(mem) = memory {
let level_str = match level { if len > MAX_WASM_STRING_BYTES {
0 => "TRACE", return;
1 => "DEBUG", }
2 => "INFO", let mut buf = vec![0u8; len as usize];
3 => "WARN", if mem.read(&caller, ptr as usize, &mut buf).is_ok() {
_ => "ERROR", if let Ok(msg) = String::from_utf8(buf) {
}; let level_str = match level {
let plugin_name = caller.data().inner.plugin_name.clone(); 0 => "TRACE",
tracing::info!(plugin = %plugin_name, level = %level_str, "{}", msg); 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}")))?; .map_err(|e| PluginError::Message(format!("Failed to register host_log: {e}")))?;
// host_get_setting: Retrieve plugin settings // host_get_setting: Retrieve plugin settings
@ -472,21 +487,31 @@ pub fn register_host_functions(linker: &mut Linker<HostStateWithLimits>) -> Resu
// host_get_result: Copy result buffer to WASM memory // host_get_result: Copy result buffer to WASM memory
// This is a helper for retrieving data from host functions // This is a helper for retrieving data from host functions
linker linker
.func_wrap("env", "host_get_result", |mut caller: wasmtime::Caller<'_, HostStateWithLimits>, dest_ptr: u32, max_len: u32| -> u32 { .func_wrap(
let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) { "env",
Some(m) => m, "host_get_result",
None => return 0, |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 result = caller.data().get_result();
let copy_len = std::cmp::min(result.len(), max_len as usize); 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() { if memory
return 0; .write(&mut caller, dest_ptr as usize, &result[..copy_len])
} .is_err()
{
return 0;
}
copy_len as u32 copy_len as u32
}) },
)
.map_err(|e| PluginError::Message(format!("Failed to register host_get_result: {e}")))?; .map_err(|e| PluginError::Message(format!("Failed to register host_get_result: {e}")))?;
// host_http_request: Make outbound HTTP request (capability-gated) // host_http_request: Make outbound HTTP request (capability-gated)

View file

@ -10,7 +10,10 @@ use serde_json::{json, Value};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use super::host_api::{Capability, HostState, PluginManifest, CAP_EMIT_EVENTS, CAP_KV_STORE, CAP_OUTBOUND_HTTP, CAP_SETTINGS}; 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 super::runtime::{CompiledPlugin, ExecutionLimits, PluginInstance};
use crate::plugins::hooks::{HookContext, PluginError}; use crate::plugins::hooks::{HookContext, PluginError};
use crate::plugins::manager::{Plugin, PluginMetadata, PluginScope, PluginSystem}; use crate::plugins::manager::{Plugin, PluginMetadata, PluginScope, PluginSystem};
@ -96,11 +99,7 @@ async fn capabilities_for_manifest(
impl WasmPlugin { impl WasmPlugin {
/// Creates a new WASM plugin from a manifest and compiled module. /// Creates a new WASM plugin from a manifest and compiled module.
pub fn new( pub fn new(package_id: Uuid, manifest: PluginManifest, compiled: Arc<CompiledPlugin>) -> Self {
package_id: Uuid,
manifest: PluginManifest,
compiled: Arc<CompiledPlugin>,
) -> Self {
Self { Self {
package_id, package_id,
manifest, manifest,
@ -116,7 +115,11 @@ impl WasmPlugin {
self self
} }
async fn capabilities_for(&self, pool: &PgPool, ctx: &HookContext) -> Result<Vec<Capability>, PluginError> { async fn capabilities_for(
&self,
pool: &PgPool,
ctx: &HookContext,
) -> Result<Vec<Capability>, PluginError> {
capabilities_for_manifest(pool, ctx.community_id, &self.manifest.capabilities).await capabilities_for_manifest(pool, ctx.community_id, &self.manifest.capabilities).await
} }
@ -197,8 +200,9 @@ impl Plugin for WasmPlugin {
let mut instance = PluginInstance::new(&compiled, host_state, lim).await?; let mut instance = PluginInstance::new(&compiled, host_state, lim).await?;
let payload_json = serde_json::to_string(&payload) let payload_json = serde_json::to_string(&payload).map_err(|e| {
.map_err(|e| PluginError::Message(format!("Failed to serialize payload: {e}")))?; PluginError::Message(format!("Failed to serialize payload: {e}"))
})?;
let _result = instance.call_hook(&hook, &payload_json).await?; let _result = instance.call_hook(&hook, &payload_json).await?;
@ -225,7 +229,10 @@ impl Plugin for WasmPlugin {
}); });
let payload_json = serde_json::to_string(&payload) let payload_json = serde_json::to_string(&payload)
.map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?; .map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?;
instance.call_hook("lifecycle.activate", &payload_json).await.ok(); instance
.call_hook("lifecycle.activate", &payload_json)
.await
.ok();
Ok(()) Ok(())
} }
@ -237,7 +244,10 @@ impl Plugin for WasmPlugin {
}); });
let payload_json = serde_json::to_string(&payload) let payload_json = serde_json::to_string(&payload)
.map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?; .map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?;
instance.call_hook("lifecycle.deactivate", &payload_json).await.ok(); instance
.call_hook("lifecycle.deactivate", &payload_json)
.await
.ok();
Ok(()) Ok(())
} }
@ -255,7 +265,10 @@ impl Plugin for WasmPlugin {
}); });
let payload_json = serde_json::to_string(&payload) let payload_json = serde_json::to_string(&payload)
.map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?; .map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?;
instance.call_hook("lifecycle.settings_updated", &payload_json).await.ok(); instance
.call_hook("lifecycle.settings_updated", &payload_json)
.await
.ok();
Ok(()) Ok(())
} }
} }

View file

@ -57,7 +57,7 @@ impl WasmRuntime {
let engine = Arc::new( let engine = Arc::new(
Engine::new(&config) 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 // Spawn epoch ticker for timeout enforcement
@ -141,9 +141,9 @@ impl PluginInstance {
let mut store = Store::new(compiled.engine(), state_with_limits); let mut store = Store::new(compiled.engine(), state_with_limits);
store.limiter(|state| &mut state.limits); store.limiter(|state| &mut state.limits);
store.set_fuel(limits.fuel).map_err(|e| { store
PluginError::Message(format!("Failed to set fuel limit: {e}")) .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); let epoch_deadline = (limits.timeout_ms / 10).max(1);
store.epoch_deadline_async_yield_and_update(epoch_deadline); store.epoch_deadline_async_yield_and_update(epoch_deadline);
@ -177,7 +177,9 @@ impl PluginInstance {
let handle_hook = self let handle_hook = self
.instance .instance
.get_typed_func::<(u32, u32, u32, u32), u64>(&mut self.store, "handle_hook") .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 let memory = self
.instance .instance
@ -185,15 +187,21 @@ impl PluginInstance {
.ok_or_else(|| PluginError::Message("Plugin missing 'memory' export".to_string()))?; .ok_or_else(|| PluginError::Message("Plugin missing 'memory' export".to_string()))?;
let hook_bytes = hook_name.as_bytes(); 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}")))?; .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}")))?; .map_err(|e| PluginError::Message(format!("Failed to write hook name: {e}")))?;
let payload_bytes = payload_json.as_bytes(); 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}")))?; .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}")))?; .map_err(|e| PluginError::Message(format!("Failed to write payload: {e}")))?;
let result = handle_hook let result = handle_hook
@ -213,12 +221,22 @@ impl PluginInstance {
let result_len = (result & 0xFFFFFFFF) as u32; let result_len = (result & 0xFFFFFFFF) as u32;
let mut result_bytes = vec![0u8; result_len as usize]; 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}")))?; .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
dealloc.call_async(&mut self.store, (payload_ptr, payload_bytes.len() as u32)).await.ok(); .call_async(&mut self.store, (hook_ptr, hook_bytes.len() as u32))
dealloc.call_async(&mut self.store, (result_ptr, result_len)).await.ok(); .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) String::from_utf8(result_bytes)
.map_err(|e| PluginError::Message(format!("Result is not valid UTF-8: {e}"))) .map_err(|e| PluginError::Message(format!("Result is not valid UTF-8: {e}")))

View file

@ -98,20 +98,14 @@ impl FixedWindowLimiter {
} }
fn parse_ip_from_headers(headers: &HeaderMap) -> Option<IpAddr> { fn parse_ip_from_headers(headers: &HeaderMap) -> Option<IpAddr> {
if let Some(forwarded) = headers if let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
{
let first = forwarded.split(',').next().map(|s| s.trim()).unwrap_or(""); let first = forwarded.split(',').next().map(|s| s.trim()).unwrap_or("");
if let Ok(ip) = first.parse::<IpAddr>() { if let Ok(ip) = first.parse::<IpAddr>() {
return Some(ip); return Some(ip);
} }
} }
if let Some(real_ip) = headers if let Some(real_ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
{
if let Ok(ip) = real_ip.trim().parse::<IpAddr>() { if let Ok(ip) = real_ip.trim().parse::<IpAddr>() {
return Some(ip); return Some(ip);
} }

View file

@ -6,10 +6,10 @@
//! - Quadratic Voting (intensity-weighted preferences) //! - Quadratic Voting (intensity-weighted preferences)
//! - Ranked Choice / Instant Runoff //! - Ranked Choice / Instant Runoff
pub mod schulze;
pub mod star;
pub mod quadratic; pub mod quadratic;
pub mod ranked_choice; pub mod ranked_choice;
pub mod schulze;
pub mod star;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;

View file

@ -78,12 +78,14 @@ pub fn calculate(options: &[Uuid], ballots: &[QuadraticBallot]) -> VotingResult
} }
// Sort by total votes (descending) // 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)) .map(|(&id, &votes)| (id, votes))
.collect(); .collect();
sorted_votes.sort_by(|a, b| b.1.cmp(&a.1)); sorted_votes.sort_by(|a, b| b.1.cmp(&a.1));
let ranking: Vec<RankedOption> = sorted_votes.iter() let ranking: Vec<RankedOption> = sorted_votes
.iter()
.enumerate() .enumerate()
.map(|(i, (id, votes))| RankedOption { .map(|(i, (id, votes))| RankedOption {
option_id: *id, option_id: *id,
@ -165,12 +167,10 @@ mod tests {
let a = Uuid::new_v4(); let a = Uuid::new_v4();
let options = vec![a]; let options = vec![a];
let ballots = vec![ let ballots = vec![QuadraticBallot {
QuadraticBallot { total_credits: 100,
total_credits: 100, allocations: vec![(a, 11)], // Costs 121, exceeds 100
allocations: vec![(a, 11)], // Costs 121, exceeds 100 }];
},
];
let result = calculate(&options, &ballots); let result = calculate(&options, &ballots);
// Invalid ballot should be skipped // Invalid ballot should be skipped

View file

@ -36,9 +36,8 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
loop { loop {
// Count first-choice votes among active options // Count first-choice votes among active options
let mut vote_counts: HashMap<Uuid, i64> = active_options.iter() let mut vote_counts: HashMap<Uuid, i64> =
.map(|&id| (id, 0)) active_options.iter().map(|&id| (id, 0)).collect();
.collect();
for ballot in ballots { for ballot in ballots {
// Find first choice among active options // Find first choice among active options
@ -53,7 +52,8 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
} }
// Sort by vote count // 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)) .map(|(&id, &count)| (id, count))
.collect(); .collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1)); 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 // Build final ranking
let mut final_ranking: Vec<RankedOption> = sorted.iter() let mut final_ranking: Vec<RankedOption> = sorted
.iter()
.enumerate() .enumerate()
.map(|(i, (id, count))| RankedOption { .map(|(i, (id, count))| RankedOption {
option_id: *id, option_id: *id,
@ -91,10 +92,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
return VotingResult { return VotingResult {
winner: Some(*winner), winner: Some(*winner),
ranking: final_ranking, ranking: final_ranking,
details: VotingDetails::RankedChoice { details: VotingDetails::RankedChoice { rounds, eliminated },
rounds,
eliminated,
},
total_ballots: ballots.len(), total_ballots: ballots.len(),
}; };
} }
@ -110,7 +108,8 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
eliminated: None, eliminated: None,
}); });
let mut final_ranking: Vec<RankedOption> = sorted.iter() let mut final_ranking: Vec<RankedOption> = sorted
.iter()
.enumerate() .enumerate()
.map(|(i, (id, count))| RankedOption { .map(|(i, (id, count))| RankedOption {
option_id: *id, option_id: *id,
@ -130,10 +129,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
return VotingResult { return VotingResult {
winner, winner,
ranking: final_ranking, ranking: final_ranking,
details: VotingDetails::RankedChoice { details: VotingDetails::RankedChoice { rounds, eliminated },
rounds,
eliminated,
},
total_ballots: ballots.len(), total_ballots: ballots.len(),
}; };
} }
@ -159,10 +155,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
VotingResult { VotingResult {
winner: None, winner: None,
ranking: vec![], ranking: vec![],
details: VotingDetails::RankedChoice { details: VotingDetails::RankedChoice { rounds, eliminated },
rounds,
eliminated,
},
total_ballots: ballots.len(), total_ballots: ballots.len(),
} }
} }
@ -180,11 +173,21 @@ mod tests {
// A has clear majority // A has clear majority
let ballots = vec![ let ballots = vec![
RankedBallot { rankings: vec![a, b, c] }, RankedBallot {
RankedBallot { rankings: vec![a, b, c] }, rankings: vec![a, b, c],
RankedBallot { rankings: vec![a, c, b] }, },
RankedBallot { rankings: vec![b, a, c] }, RankedBallot {
RankedBallot { rankings: vec![c, b, a] }, 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); let result = calculate(&options, &ballots);
@ -200,11 +203,21 @@ mod tests {
// No first-round majority, C eliminated, B wins // No first-round majority, C eliminated, B wins
let ballots = vec![ let ballots = vec![
RankedBallot { rankings: vec![a, b, c] }, RankedBallot {
RankedBallot { rankings: vec![a, b, c] }, rankings: vec![a, b, c],
RankedBallot { rankings: vec![b, a, c] }, },
RankedBallot { rankings: vec![b, c, a] }, RankedBallot {
RankedBallot { rankings: vec![c, b, a] }, // C's vote goes to B 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); let result = calculate(&options, &ballots);
@ -230,11 +243,21 @@ mod tests {
let options = vec![a, b, spoiler]; let options = vec![a, b, spoiler];
let ballots = vec![ let ballots = vec![
RankedBallot { rankings: vec![a, spoiler, b] }, RankedBallot {
RankedBallot { rankings: vec![a, spoiler, b] }, rankings: vec![a, spoiler, b],
RankedBallot { rankings: vec![spoiler, a, b] }, // Spoiler fans prefer A },
RankedBallot { rankings: vec![b, a, spoiler] }, RankedBallot {
RankedBallot { rankings: vec![b, a, spoiler] }, 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); let result = calculate(&options, &ballots);

View file

@ -32,11 +32,8 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
} }
// Create option index mapping // Create option index mapping
let _option_index: HashMap<Uuid, usize> = options let _option_index: HashMap<Uuid, usize> =
.iter() options.iter().enumerate().map(|(i, &id)| (id, i)).collect();
.enumerate()
.map(|(i, &id)| (id, i))
.collect();
// Build pairwise preference matrix // Build pairwise preference matrix
// d[i][j] = number of voters who prefer option i over option j // 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() .iter()
.enumerate() .enumerate()
.map(|(i, &opt_id)| { .map(|(i, &opt_id)| {
let win_count: i32 = (0..n) let win_count: i32 = (0..n).filter(|&j| i != j && p[i][j] > p[j][i]).count() as i32;
.filter(|&j| i != j && p[i][j] > p[j][i])
.count() as i32;
(opt_id, win_count) (opt_id, win_count)
}) })
.collect(); .collect();
@ -159,11 +154,21 @@ mod tests {
// 3 voters prefer A > B > C // 3 voters prefer A > B > C
// 2 voters prefer B > C > A // 2 voters prefer B > C > A
let ballots = vec![ let ballots = vec![
RankedBallot { rankings: vec![(a, 1), (b, 2), (c, 3)] }, RankedBallot {
RankedBallot { rankings: vec![(a, 1), (b, 2), (c, 3)] }, 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 {
RankedBallot { rankings: vec![(b, 1), (c, 2), (a, 3)] }, 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); let result = calculate(&options, &ballots);
@ -181,11 +186,21 @@ mod tests {
// A beats B, B beats C, C beats A // A beats B, B beats C, C beats A
let ballots = vec![ let ballots = vec![
RankedBallot { rankings: vec![(a, 1), (b, 2), (c, 3)] }, RankedBallot {
RankedBallot { rankings: vec![(a, 1), (b, 2), (c, 3)] }, 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 {
RankedBallot { rankings: vec![(c, 1), (a, 2), (b, 3)] }, 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); let result = calculate(&options, &ballots);

View file

@ -44,7 +44,8 @@ pub fn calculate(options: &[Uuid], ballots: &[ScoreBallot]) -> VotingResult {
} }
// Sort by total score (descending) // 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)) .map(|(&id, &score)| (id, score))
.collect(); .collect();
sorted_scores.sort_by(|a, b| b.1.cmp(&a.1)); 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); let winner = sorted_scores.first().map(|(id, _)| *id);
return VotingResult { return VotingResult {
winner, winner,
ranking: sorted_scores.iter().enumerate().map(|(i, (id, score))| { ranking: sorted_scores
RankedOption { .iter()
.enumerate()
.map(|(i, (id, score))| RankedOption {
option_id: *id, option_id: *id,
rank: i + 1, rank: i + 1,
score: *score as f64, score: *score as f64,
} })
}).collect(), .collect(),
details: VotingDetails::Star { details: VotingDetails::Star {
score_totals: sorted_scores, score_totals: sorted_scores,
finalists: (winner.unwrap_or(Uuid::nil()), Uuid::nil()), finalists: (winner.unwrap_or(Uuid::nil()), Uuid::nil()),
@ -100,13 +103,15 @@ pub fn calculate(options: &[Uuid], ballots: &[ScoreBallot]) -> VotingResult {
}; };
// Build final ranking // Build final ranking
let mut ranking: Vec<RankedOption> = sorted_scores.iter().enumerate().map(|(i, (id, score))| { let mut ranking: Vec<RankedOption> = sorted_scores
RankedOption { .iter()
.enumerate()
.map(|(i, (id, score))| RankedOption {
option_id: *id, option_id: *id,
rank: i + 1, rank: i + 1,
score: *score as f64, score: *score as f64,
} })
}).collect(); .collect();
// Adjust ranking for runoff result (swap if needed) // Adjust ranking for runoff result (swap if needed)
if winner == finalist_b && ranking.len() >= 2 { if winner == finalist_b && ranking.len() >= 2 {
@ -139,9 +144,15 @@ mod tests {
let options = vec![a, b, c]; let options = vec![a, b, c];
let ballots = vec![ let ballots = vec![
ScoreBallot { scores: vec![(a, 5), (b, 3), (c, 1)] }, ScoreBallot {
ScoreBallot { scores: vec![(a, 5), (b, 2), (c, 0)] }, scores: vec![(a, 5), (b, 3), (c, 1)],
ScoreBallot { scores: vec![(a, 4), (b, 4), (c, 2)] }, },
ScoreBallot {
scores: vec![(a, 5), (b, 2), (c, 0)],
},
ScoreBallot {
scores: vec![(a, 4), (b, 4), (c, 2)],
},
]; ];
let result = calculate(&options, &ballots); let result = calculate(&options, &ballots);
@ -157,11 +168,21 @@ mod tests {
// A gets high scores from few, B gets moderate scores from many // A gets high scores from few, B gets moderate scores from many
let ballots = vec![ let ballots = vec![
ScoreBallot { scores: vec![(a, 5), (b, 4)] }, // Prefers A ScoreBallot {
ScoreBallot { scores: vec![(a, 5), (b, 4)] }, // Prefers A scores: vec![(a, 5), (b, 4)],
ScoreBallot { scores: vec![(a, 0), (b, 3)] }, // Prefers B }, // Prefers A
ScoreBallot { scores: vec![(a, 0), (b, 3)] }, // Prefers B ScoreBallot {
ScoreBallot { scores: vec![(a, 0), (b, 3)] }, // Prefers B 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); let result = calculate(&options, &ballots);