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

View file

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

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 sqlx::PgPool;
use std::sync::Arc;
@ -68,10 +73,16 @@ async fn register(
let registration_mode = if let Some(s) = &settings {
if !s.registration_enabled {
return Err((StatusCode::FORBIDDEN, "Registration is currently disabled".to_string()));
return Err((
StatusCode::FORBIDDEN,
"Registration is currently disabled".to_string(),
));
}
if s.registration_mode == "invite_only" && req.invitation_code.is_none() {
return Err((StatusCode::FORBIDDEN, "Registration requires an invitation code".to_string()));
return Err((
StatusCode::FORBIDDEN,
"Registration requires an invitation code".to_string(),
));
}
s.registration_mode.clone()
} else {
@ -90,24 +101,41 @@ async fn register(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
match validation {
None => return Err((StatusCode::BAD_REQUEST, "Invalid invitation code".to_string())),
None => {
return Err((
StatusCode::BAD_REQUEST,
"Invalid invitation code".to_string(),
))
}
Some(inv) => {
if !inv.is_active.unwrap_or(false) {
return Err((StatusCode::BAD_REQUEST, "Invitation is no longer active".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Invitation is no longer active".to_string(),
));
}
if let Some(exp) = inv.expires_at {
if exp < chrono::Utc::now() {
return Err((StatusCode::BAD_REQUEST, "Invitation has expired".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Invitation has expired".to_string(),
));
}
}
if let Some(max) = inv.max_uses {
if inv.uses_count.unwrap_or(0) >= max {
return Err((StatusCode::BAD_REQUEST, "Invitation has reached maximum uses".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Invitation has reached maximum uses".to_string(),
));
}
}
if let Some(email) = &inv.email {
if email != &req.email {
return Err((StatusCode::BAD_REQUEST, "This invitation is for a different email address".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"This invitation is for a different email address".to_string(),
));
}
}
inv.community_id
@ -162,7 +190,10 @@ async fn register(
})?;
// Return a special response indicating pending approval
return Err((StatusCode::ACCEPTED, "Registration submitted for approval. You will be notified when approved.".to_string()));
return Err((
StatusCode::ACCEPTED,
"Registration submitted for approval. You will be notified when approved.".to_string(),
));
}
// Direct registration (open mode, invite_only with valid invite, or first user)
@ -183,7 +214,10 @@ async fn register(
.await
.map_err(|e| {
if e.to_string().contains("duplicate key") {
(StatusCode::CONFLICT, "Username or email already exists".to_string())
(
StatusCode::CONFLICT,
"Username or email already exists".to_string(),
)
} else {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}
@ -234,7 +268,12 @@ async fn register(
// Use invitation if provided (records usage and links user)
if let Some(code) = &req.invitation_code {
sqlx::query!("SELECT use_invitation($1, $2, $3)", code, user.id, req.email.as_str())
sqlx::query!(
"SELECT use_invitation($1, $2, $3)",
code,
user.id,
req.email.as_str()
)
.fetch_one(&pool)
.await
.ok(); // Ignore errors - user is already created
@ -283,7 +322,10 @@ async fn login(
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::UNAUTHORIZED, "Demo account not found".to_string()))?;
.ok_or((
StatusCode::UNAUTHORIZED,
"Demo account not found".to_string(),
))?;
let token = create_token(user.id, &user.username, &config.jwt_secret)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

View file

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

View file

@ -2,8 +2,7 @@ use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, post},
Extension,
Json, Router,
Extension, Json, Router,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@ -11,10 +10,10 @@ use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
use super::permissions::{perms, user_has_permission};
use crate::auth::AuthUser;
use crate::models::community::CommunityResponse;
use crate::plugins::{HookContext, PluginManager};
use super::permissions::{user_has_permission, perms};
#[derive(Debug, Deserialize)]
pub struct CreateCommunityRequest {
@ -35,7 +34,10 @@ use axum::routing::put;
pub fn router(pool: PgPool) -> Router {
Router::new()
.route("/api/communities", get(list_communities).post(create_community))
.route(
"/api/communities",
get(list_communities).post(create_community),
)
.route("/api/communities/{id}", put(update_community))
.route("/api/communities/{id}/details", get(get_community_details))
.route("/api/communities/{id}/join", post(join_community))
@ -58,7 +60,12 @@ async fn list_communities(
.await
.map_err(|e| e.to_string())?;
Ok(Json(communities.into_iter().map(CommunityResponse::from).collect()))
Ok(Json(
communities
.into_iter()
.map(CommunityResponse::from)
.collect(),
))
}
async fn create_community(
@ -68,9 +75,7 @@ async fn create_community(
Json(req): Json<CreateCommunityRequest>,
) -> Result<Json<CommunityResponse>, (StatusCode, String)> {
// Check platform mode for community creation permissions
let settings = sqlx::query!(
"SELECT platform_mode FROM instance_settings LIMIT 1"
)
let settings = sqlx::query!("SELECT platform_mode FROM instance_settings LIMIT 1")
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@ -78,18 +83,26 @@ async fn create_community(
if let Some(s) = settings {
match s.platform_mode.as_str() {
"single_community" => {
return Err((StatusCode::FORBIDDEN, "This platform is dedicated to a single community".to_string()));
return Err((
StatusCode::FORBIDDEN,
"This platform is dedicated to a single community".to_string(),
));
}
"admin_only" => {
// Check platform admin or community create permission
let can_create = user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?;
let can_create =
user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?;
if !can_create {
return Err((StatusCode::FORBIDDEN, "Only administrators can create communities".to_string()));
return Err((
StatusCode::FORBIDDEN,
"Only administrators can create communities".to_string(),
));
}
}
"approval" => {
// Check if user has direct create permission (admins bypass approval)
let can_create = user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?;
let can_create =
user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?;
if !can_create {
// Create pending community request instead
sqlx::query!(
@ -104,13 +117,20 @@ async fn create_community(
.await
.map_err(|e| {
if e.to_string().contains("duplicate key") {
(StatusCode::CONFLICT, "A community with this slug already exists or is pending approval".to_string())
(
StatusCode::CONFLICT,
"A community with this slug already exists or is pending approval"
.to_string(),
)
} else {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}
})?;
return Err((StatusCode::ACCEPTED, "Community request submitted for approval".to_string()));
return Err((
StatusCode::ACCEPTED,
"Community request submitted for approval".to_string(),
));
}
}
_ => {} // "open" mode - anyone can create
@ -132,7 +152,10 @@ async fn create_community(
.await
.map_err(|e| {
if e.to_string().contains("duplicate key") {
(StatusCode::CONFLICT, "Community name or slug already exists".to_string())
(
StatusCode::CONFLICT,
"Community name or slug already exists".to_string(),
)
} else {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}
@ -153,7 +176,11 @@ async fn create_community(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
tracing::info!("Community '{}' created by user {}", community.name, auth.username);
tracing::info!(
"Community '{}' created by user {}",
community.name,
auth.username
);
Ok(Json(CommunityResponse::from(community)))
}
@ -237,7 +264,10 @@ async fn join_community(
return Err((StatusCode::BAD_REQUEST, err.to_string()));
}
let role = filtered.get("role").and_then(|v| v.as_str()).unwrap_or("member");
let role = filtered
.get("role")
.and_then(|v| v.as_str())
.unwrap_or("member");
sqlx::query!(
"INSERT INTO community_members (user_id, community_id, role) VALUES ($1, $2, $3)",
@ -254,12 +284,18 @@ async fn join_community(
community_id: Some(community_id),
actor_user_id: Some(auth.user_id),
};
let _ = plugins.do_action("member.join", ctx, serde_json::json!({
let _ = plugins
.do_action(
"member.join",
ctx,
serde_json::json!({
"community_id": community_id.to_string(),
"user_id": auth.user_id.to_string(),
"username": auth.username.clone(),
"role": role,
})).await;
}),
)
.await;
tracing::info!("User {} joined community {}", auth.username, community_id);
Ok(Json(serde_json::json!({"status": "joined"})))
@ -300,7 +336,10 @@ async fn leave_community(
.unwrap_or(0);
if admin_count <= 1 {
return Err((StatusCode::BAD_REQUEST, "Cannot leave: you are the only admin".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Cannot leave: you are the only admin".to_string(),
));
}
}
@ -339,12 +378,18 @@ async fn leave_community(
community_id: Some(community_id),
actor_user_id: Some(auth.user_id),
};
let _ = plugins.do_action("member.leave", ctx, serde_json::json!({
let _ = plugins
.do_action(
"member.leave",
ctx,
serde_json::json!({
"community_id": community_id.to_string(),
"user_id": auth.user_id.to_string(),
"username": auth.username.clone(),
"role": role,
})).await;
}),
)
.await;
tracing::info!("User {} left community {}", auth.username, community_id);
Ok(Json(serde_json::json!({"status": "left"})))
@ -429,7 +474,12 @@ async fn my_communities(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(communities.into_iter().map(CommunityResponse::from).collect()))
Ok(Json(
communities
.into_iter()
.map(CommunityResponse::from)
.collect(),
))
}
#[derive(Debug, Serialize)]
@ -458,7 +508,9 @@ async fn recent_activity(
.await
.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
.into_iter()
.map(|p| {
let desc = match p.status.as_str() {
"voting" => "Now open for voting",
"discussion" => "Open for discussion",
@ -472,7 +524,8 @@ async fn recent_activity(
link: format!("/proposals/{}", p.id),
created_at: p.created_at,
}
}).collect();
})
.collect();
let communities = sqlx::query!(
"SELECT name, slug, created_at FROM communities WHERE is_active = true ORDER BY created_at DESC LIMIT 5"
@ -518,10 +571,16 @@ async fn update_community(
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::FORBIDDEN, "Not a member of this community".to_string()))?;
.ok_or((
StatusCode::FORBIDDEN,
"Not a member of this community".to_string(),
))?;
if membership.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Only admins can edit the community".to_string()));
return Err((
StatusCode::FORBIDDEN,
"Only admins can edit the community".to_string(),
));
}
let updated = sqlx::query_as!(

View file

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

View file

@ -9,10 +9,10 @@ use axum::{
routing::{delete, get},
Json, Router,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::auth::AuthUser;
@ -56,7 +56,9 @@ pub struct CreateDelegationRequest {
pub weight: f64,
}
fn default_weight() -> f64 { 1.0 }
fn default_weight() -> f64 {
1.0
}
#[derive(Debug, Serialize)]
pub struct DelegateProfile {
@ -134,35 +136,56 @@ async fn create_delegation(
) -> Result<Json<Delegation>, (StatusCode, String)> {
// Validate weight
if req.weight <= 0.0 || req.weight > 1.0 {
return Err((StatusCode::BAD_REQUEST, "Weight must be between 0 and 1".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Weight must be between 0 and 1".to_string(),
));
}
// Validate scope references to avoid DB constraint errors
match req.scope {
DelegationScope::Global => {
if req.community_id.is_some() || req.topic_id.is_some() || req.proposal_id.is_some() {
return Err((StatusCode::BAD_REQUEST, "Global delegation cannot include community/topic/proposal".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Global delegation cannot include community/topic/proposal".to_string(),
));
}
}
DelegationScope::Community => {
if req.community_id.is_none() {
return Err((StatusCode::BAD_REQUEST, "Community delegation requires community_id".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Community delegation requires community_id".to_string(),
));
}
if req.topic_id.is_some() || req.proposal_id.is_some() {
return Err((StatusCode::BAD_REQUEST, "Community delegation cannot include topic_id/proposal_id".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Community delegation cannot include topic_id/proposal_id".to_string(),
));
}
}
DelegationScope::Topic => {
if req.topic_id.is_none() {
return Err((StatusCode::BAD_REQUEST, "Topic delegation requires topic_id".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Topic delegation requires topic_id".to_string(),
));
}
if req.proposal_id.is_some() {
return Err((StatusCode::BAD_REQUEST, "Topic delegation cannot include proposal_id".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Topic delegation cannot include proposal_id".to_string(),
));
}
}
DelegationScope::Proposal => {
if req.proposal_id.is_none() {
return Err((StatusCode::BAD_REQUEST, "Proposal delegation requires proposal_id".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Proposal delegation requires proposal_id".to_string(),
));
}
}
}
@ -210,7 +233,10 @@ async fn create_delegation(
if let Some(p) = profile {
if !p.accepting_delegations {
return Err((StatusCode::BAD_REQUEST, "Delegate is not accepting delegations".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Delegate is not accepting delegations".to_string(),
));
}
}
@ -340,8 +366,10 @@ async fn list_my_delegations(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(delegations.into_iter().map(|d| {
Delegation {
Ok(Json(
delegations
.into_iter()
.map(|d| Delegation {
id: d.id,
delegator_id: d.delegator_id,
delegate_id: d.delegate_id,
@ -353,8 +381,9 @@ async fn list_my_delegations(
weight: d.weight,
is_active: d.is_active,
created_at: d.created_at,
}
}).collect()))
})
.collect(),
))
}
/// List delegations TO a user (they are the delegate)
@ -376,8 +405,10 @@ async fn list_delegations_to_me(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(delegations.into_iter().map(|d| {
Delegation {
Ok(Json(
delegations
.into_iter()
.map(|d| Delegation {
id: d.id,
delegator_id: d.delegator_id,
delegate_id: d.delegate_id,
@ -389,8 +420,9 @@ async fn list_delegations_to_me(
weight: d.weight,
is_active: d.is_active,
created_at: d.created_at,
}
}).collect()))
})
.collect(),
))
}
/// Revoke a delegation
@ -552,7 +584,10 @@ async fn list_delegates(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(profiles.into_iter().map(|p| DelegateProfile {
Ok(Json(
profiles
.into_iter()
.map(|p| DelegateProfile {
user_id: p.user_id,
username: p.username,
display_name: p.display_name,
@ -561,7 +596,9 @@ async fn list_delegates(
delegation_policy: p.delegation_policy,
total_delegators: p.total_delegators,
total_votes_cast: p.total_votes_cast,
}).collect()))
})
.collect(),
))
}
// ============================================================================
@ -604,10 +641,16 @@ async fn create_topic(
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::FORBIDDEN, "Not a member of this community".to_string()))?;
.ok_or((
StatusCode::FORBIDDEN,
"Not a member of this community".to_string(),
))?;
if membership.role != "admin" && membership.role != "moderator" {
return Err((StatusCode::FORBIDDEN, "Only admins/moderators can create topics".to_string()));
return Err((
StatusCode::FORBIDDEN,
"Only admins/moderators can create topics".to_string(),
));
}
let topic = sqlx::query_as!(
@ -635,13 +678,25 @@ async fn create_topic(
pub fn router(pool: PgPool) -> Router {
Router::new()
// Delegations
.route("/api/delegations", get(list_my_delegations).post(create_delegation))
.route(
"/api/delegations",
get(list_my_delegations).post(create_delegation),
)
.route("/api/delegations/to-me", get(list_delegations_to_me))
.route("/api/delegations/{delegation_id}", delete(revoke_delegation))
.route(
"/api/delegations/{delegation_id}",
delete(revoke_delegation),
)
// Delegate profiles
.route("/api/delegates", get(list_delegates))
.route("/api/delegates/me", get(get_my_profile).put(update_my_profile))
.route(
"/api/delegates/me",
get(get_my_profile).put(update_my_profile),
)
// Topics
.route("/api/communities/{community_id}/topics", get(list_topics).post(create_topic))
.route(
"/api/communities/{community_id}/topics",
get(list_topics).post(create_topic),
)
.with_state(pool)
}

View file

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

View file

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

View file

@ -85,7 +85,10 @@ async fn get_job(
pub fn router(pool: PgPool) -> Router {
Router::new()
.route("/api/communities/{community_id}/exports", get(get_available).post(create_job))
.route(
"/api/communities/{community_id}/exports",
get(get_available).post(create_job),
)
.route("/api/exports/{job_id}", get(get_job))
.with_state(pool)
}

View file

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

View file

@ -9,10 +9,10 @@ use axum::{
routing::{get, post},
Json, Router,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::auth::AuthUser;
@ -148,12 +148,18 @@ async fn create_connection(
.ok_or((StatusCode::FORBIDDEN, "Not a community member".to_string()))?;
if membership.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Only admins can configure GitLab".to_string()));
return Err((
StatusCode::FORBIDDEN,
"Only admins can configure GitLab".to_string(),
));
}
// Validate GitLab URL
if !req.gitlab_url.starts_with("https://") {
return Err((StatusCode::BAD_REQUEST, "GitLab URL must use HTTPS".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"GitLab URL must use HTTPS".to_string(),
));
}
let connection = sqlx::query!(
@ -212,7 +218,10 @@ async fn list_issues(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(issues.into_iter().map(|i| GitLabIssue {
Ok(Json(
issues
.into_iter()
.map(|i| GitLabIssue {
id: i.id,
gitlab_iid: i.gitlab_iid,
title: i.title,
@ -222,7 +231,9 @@ async fn list_issues(
labels: i.labels.unwrap_or_default(),
proposal_id: i.proposal_id,
gitlab_created_at: i.gitlab_created_at,
}).collect()))
})
.collect(),
))
}
/// List GitLab merge requests for a community
@ -245,7 +256,9 @@ async fn list_merge_requests(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(mrs.into_iter().map(|m| GitLabMergeRequest {
Ok(Json(
mrs.into_iter()
.map(|m| GitLabMergeRequest {
id: m.id,
gitlab_iid: m.gitlab_iid,
title: m.title,
@ -257,7 +270,9 @@ async fn list_merge_requests(
labels: m.labels.unwrap_or_default(),
proposal_id: m.proposal_id,
gitlab_created_at: m.gitlab_created_at,
}).collect()))
})
.collect(),
))
}
/// Create proposal from GitLab issue
@ -292,7 +307,10 @@ async fn create_proposal_from_issue(
.ok_or((StatusCode::NOT_FOUND, "Issue not found".to_string()))?;
if issue.proposal_id.is_some() {
return Err((StatusCode::CONFLICT, "Issue already linked to a proposal".to_string()));
return Err((
StatusCode::CONFLICT,
"Issue already linked to a proposal".to_string(),
));
}
// Create proposal
@ -340,9 +358,21 @@ async fn create_proposal_from_issue(
pub fn router(pool: PgPool) -> Router {
Router::new()
.route("/api/communities/{community_id}/gitlab", get(get_connection).post(create_connection))
.route("/api/communities/{community_id}/gitlab/issues", get(list_issues))
.route("/api/communities/{community_id}/gitlab/merge-requests", get(list_merge_requests))
.route("/api/communities/{community_id}/gitlab/issues/{issue_id}/create-proposal", post(create_proposal_from_issue))
.route(
"/api/communities/{community_id}/gitlab",
get(get_connection).post(create_connection),
)
.route(
"/api/communities/{community_id}/gitlab/issues",
get(list_issues),
)
.route(
"/api/communities/{community_id}/gitlab/merge-requests",
get(list_merge_requests),
)
.route(
"/api/communities/{community_id}/gitlab/issues/{issue_id}/create-proposal",
post(create_proposal_from_issue),
)
.with_state(pool)
}

View file

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

View file

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

View file

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

View file

@ -4,13 +4,13 @@ use axum::{
routing::get,
Json, Router,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::api::permissions::{perms, require_permission};
use crate::auth::AuthUser;
use crate::api::permissions::{require_permission, perms};
#[derive(Debug, Serialize)]
pub struct ModerationEntry {
@ -34,7 +34,10 @@ pub struct CreateModerationEntry {
pub fn router(pool: PgPool) -> Router {
Router::new()
.route("/api/communities/{community_id}/moderation", get(list_moderation).post(create_moderation))
.route(
"/api/communities/{community_id}/moderation",
get(list_moderation).post(create_moderation),
)
.with_state(pool)
}
@ -44,7 +47,13 @@ async fn list_moderation(
State(pool): State<PgPool>,
) -> Result<Json<Vec<ModerationEntry>>, (StatusCode, String)> {
// Require permission to view moderation reports
require_permission(&pool, auth.user_id, perms::MOD_VIEW_REPORTS, Some(community_id)).await?;
require_permission(
&pool,
auth.user_id,
perms::MOD_VIEW_REPORTS,
Some(community_id),
)
.await?;
let entries = sqlx::query!(
r#"
@ -93,22 +102,49 @@ async fn create_moderation(
// Check specific permission based on action type
let has_permission = match req.action_type.as_str() {
"ban" | "unban" | "suspend" | "unsuspend" => {
user_has_permission(&pool, auth.user_id, perms::MOD_BAN_USERS, Some(community_id)).await?
user_has_permission(
&pool,
auth.user_id,
perms::MOD_BAN_USERS,
Some(community_id),
)
.await?
}
"remove_content" | "hide_content" | "restore_content" => {
user_has_permission(&pool, auth.user_id, perms::MOD_REMOVE_CONTENT, Some(community_id)).await?
user_has_permission(
&pool,
auth.user_id,
perms::MOD_REMOVE_CONTENT,
Some(community_id),
)
.await?
}
"admin_action" | "settings_change" => {
user_has_permission(&pool, auth.user_id, perms::COMMUNITY_ADMIN, Some(community_id)).await?
user_has_permission(
&pool,
auth.user_id,
perms::COMMUNITY_ADMIN,
Some(community_id),
)
.await?
}
_ => {
// Default to requiring community.moderate permission
user_has_permission(&pool, auth.user_id, perms::COMMUNITY_MODERATE, Some(community_id)).await?
user_has_permission(
&pool,
auth.user_id,
perms::COMMUNITY_MODERATE,
Some(community_id),
)
.await?
}
};
if !has_permission {
return Err((StatusCode::FORBIDDEN, format!("Permission required for action '{}'", req.action_type)));
return Err((
StatusCode::FORBIDDEN,
format!("Permission required for action '{}'", req.action_type),
));
}
let entry = sqlx::query!(

View file

@ -17,7 +17,10 @@ pub fn router(pool: PgPool) -> Router {
Router::new()
.route("/api/ledger", get(list_entries))
.route("/api/ledger/entry/{id}", get(get_entry))
.route("/api/ledger/target/{target_type}/{target_id}", get(get_target_history))
.route(
"/api/ledger/target/{target_type}/{target_id}",
get(get_target_history),
)
.route("/api/ledger/verify", get(verify_chain))
.route("/api/ledger/stats", get(get_stats))
.route("/api/ledger/export", get(export_ledger))
@ -151,9 +154,7 @@ async fn get_entry(
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, Json<Value>)> {
// Use LedgerService for consistency
let entry = LedgerService::get_entry(&pool, id)
.await
.map_err(|e| {
let entry = LedgerService::get_entry(&pool, id).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
@ -367,9 +368,8 @@ async fn create_entry(
let actor_role = get_actor_role(&pool, auth.user_id, req.community_id).await?;
let action_type = parse_action_type(&req.action_type).map_err(|e| {
(StatusCode::BAD_REQUEST, Json(json!({"error": e})))
})?;
let action_type = parse_action_type(&req.action_type)
.map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({"error": e}))))?;
let entry_id = LedgerService::create_entry(
&pool,

View file

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

View file

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

View file

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

View file

@ -2,8 +2,7 @@ use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, post},
Extension,
Json, Router,
Extension, Json, Router,
};
use serde::Deserialize;
use sqlx::PgPool;
@ -11,20 +10,36 @@ use std::sync::Arc;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::models::proposal::{CreateProposal, Proposal, ProposalOptionWithVotes, ProposalWithOptions};
use crate::models::proposal::{
CreateProposal, Proposal, ProposalOptionWithVotes, ProposalWithOptions,
};
use crate::plugins::{HookContext, PluginError, PluginManager};
pub fn router(pool: PgPool) -> Router {
Router::new()
.route("/api/proposals", get(list_all_proposals))
.route("/api/proposals/my", get(my_proposals))
.route("/api/communities/{community_id}/proposals", get(list_proposals).post(create_proposal))
.route("/api/proposals/{id}", get(get_proposal).delete(delete_proposal).put(update_proposal))
.route(
"/api/communities/{community_id}/proposals",
get(list_proposals).post(create_proposal),
)
.route(
"/api/proposals/{id}",
get(get_proposal)
.delete(delete_proposal)
.put(update_proposal),
)
.route("/api/proposals/{id}/vote", post(cast_vote))
.route("/api/proposals/{id}/vote/ranked", post(cast_ranked_vote))
.route("/api/proposals/{id}/vote/quadratic", post(cast_quadratic_vote))
.route(
"/api/proposals/{id}/vote/quadratic",
post(cast_quadratic_vote),
)
.route("/api/proposals/{id}/vote/star", post(cast_star_vote))
.route("/api/proposals/{id}/start-discussion", post(start_discussion))
.route(
"/api/proposals/{id}/start-discussion",
post(start_discussion),
)
.route("/api/proposals/{id}/start-voting", post(start_voting))
.route("/api/proposals/{id}/close-voting", post(close_voting))
.route("/api/proposals/{id}/results", get(get_voting_results))
@ -66,7 +81,9 @@ async fn list_all_proposals(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let result = proposals.into_iter().map(|p| ProposalWithCommunity {
let result = proposals
.into_iter()
.map(|p| ProposalWithCommunity {
id: p.id,
title: p.title,
description: p.description,
@ -76,7 +93,8 @@ async fn list_all_proposals(
vote_count: p.vote_count.unwrap_or(0),
comment_count: p.comment_count.unwrap_or(0),
created_at: p.created_at,
}).collect();
})
.collect();
Ok(Json(result))
}
@ -102,7 +120,9 @@ async fn my_proposals(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let result = proposals.into_iter().map(|p| ProposalWithCommunity {
let result = proposals
.into_iter()
.map(|p| ProposalWithCommunity {
id: p.id,
title: p.title,
description: p.description,
@ -112,7 +132,8 @@ async fn my_proposals(
vote_count: p.vote_count.unwrap_or(0),
comment_count: p.comment_count.unwrap_or(0),
created_at: p.created_at,
}).collect();
})
.collect();
Ok(Json(result))
}
@ -147,10 +168,16 @@ async fn create_proposal(
Extension(plugins): Extension<Arc<PluginManager>>,
Json(req): Json<CreateProposal>,
) -> 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_permission(&pool, auth.user_id, perms::PROPOSAL_CREATE, Some(community_id)).await?;
require_permission(
&pool,
auth.user_id,
perms::PROPOSAL_CREATE,
Some(community_id),
)
.await?;
let filtered = plugins
.apply_filters(
@ -225,7 +252,10 @@ async fn create_proposal(
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Start transaction
let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mut tx = pool
.begin()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Create proposal with community's default voting method
let proposal = sqlx::query_as!(
@ -260,7 +290,9 @@ async fn create_proposal(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
tx.commit()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
tracing::info!("Proposal '{}' created by {}", proposal.title, auth.username);
Ok(Json(proposal))
@ -285,7 +317,10 @@ async fn get_proposal(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
let author = sqlx::query_scalar!("SELECT username FROM users WHERE id = $1", proposal.author_id)
let author = sqlx::query_scalar!(
"SELECT username FROM users WHERE id = $1",
proposal.author_id
)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@ -363,7 +398,7 @@ async fn cast_vote(
Extension(plugins): Extension<Arc<PluginManager>>,
Json(req): Json<VoteRequest>,
) -> 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!(
Proposal,
@ -381,10 +416,19 @@ async fn cast_vote(
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
// Require vote.cast permission in community
require_permission(&pool, auth.user_id, perms::VOTE_CAST, Some(proposal.community_id)).await?;
require_permission(
&pool,
auth.user_id,
perms::VOTE_CAST,
Some(proposal.community_id),
)
.await?;
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Proposal is not in voting phase".to_string(),
));
}
let vote_payload = serde_json::json!({
@ -427,7 +471,10 @@ async fn cast_vote(
RETURNING id"#,
auth.user_id,
proposal.community_id,
format!("voter-{}", uuid::Uuid::new_v4().to_string()[..8].to_string())
format!(
"voter-{}",
uuid::Uuid::new_v4().to_string()[..8].to_string()
)
)
.fetch_one(&pool)
.await
@ -459,10 +506,16 @@ async fn cast_vote(
community_id: Some(proposal.community_id),
actor_user_id: Some(auth.user_id),
};
let _ = plugins.do_action("vote.cast", ctx, serde_json::json!({
let _ = plugins
.do_action(
"vote.cast",
ctx,
serde_json::json!({
"proposal_id": proposal_id.to_string(),
"voter_id": auth.user_id.to_string(),
})).await;
}),
)
.await;
Ok(Json(serde_json::json!({"status": "voted"})))
}
@ -483,11 +536,17 @@ async fn cast_ranked_vote(
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Proposal is not in voting phase".to_string(),
));
}
if proposal.voting_method != "ranked_choice" {
return Err((StatusCode::BAD_REQUEST, "This proposal uses a different voting method".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"This proposal uses a different voting method".to_string(),
));
}
let voting_identity = sqlx::query_scalar!(
@ -497,15 +556,24 @@ async fn cast_ranked_vote(
RETURNING id"#,
auth.user_id,
proposal.community_id,
format!("voter-{}", uuid::Uuid::new_v4().to_string()[..8].to_string())
format!(
"voter-{}",
uuid::Uuid::new_v4().to_string()[..8].to_string()
)
)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Clear existing ranked votes
sqlx::query!("DELETE FROM ranked_votes WHERE proposal_id = $1 AND voter_id = $2", proposal_id, voting_identity)
.execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
sqlx::query!(
"DELETE FROM ranked_votes WHERE proposal_id = $1 AND voter_id = $2",
proposal_id,
voting_identity
)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Insert ranked votes
for ranking in req.rankings {
@ -515,7 +583,9 @@ async fn cast_ranked_vote(
).execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
Ok(Json(serde_json::json!({"status": "voted", "method": "ranked_choice"})))
Ok(Json(
serde_json::json!({"status": "voted", "method": "ranked_choice"}),
))
}
async fn cast_quadratic_vote(
@ -524,7 +594,7 @@ async fn cast_quadratic_vote(
State(pool): State<PgPool>,
Json(req): Json<QuadraticVoteRequest>,
) -> 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!(
"SELECT community_id, status as \"status: crate::models::ProposalStatus\", voting_method FROM proposals WHERE id = $1",
@ -536,11 +606,17 @@ async fn cast_quadratic_vote(
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Proposal is not in voting phase".to_string(),
));
}
if proposal.voting_method != "quadratic" {
return Err((StatusCode::BAD_REQUEST, "This proposal uses a different voting method".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"This proposal uses a different voting method".to_string(),
));
}
// Validate using quadratic voting module
@ -548,10 +624,13 @@ async fn cast_quadratic_vote(
let total_cost: i32 = req.allocations.iter().map(|a| vote_cost(a.credits)).sum();
if total_cost > total_credits {
let max_single = max_votes_for_credits(total_credits);
return Err((StatusCode::BAD_REQUEST, format!(
return Err((
StatusCode::BAD_REQUEST,
format!(
"Total cost {} exceeds {} credits. Max votes on single option: {}",
total_cost, total_credits, max_single
)));
),
));
}
let voting_identity = sqlx::query_scalar!(
@ -561,15 +640,24 @@ async fn cast_quadratic_vote(
RETURNING id"#,
auth.user_id,
proposal.community_id,
format!("voter-{}", uuid::Uuid::new_v4().to_string()[..8].to_string())
format!(
"voter-{}",
uuid::Uuid::new_v4().to_string()[..8].to_string()
)
)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Clear existing quadratic votes
sqlx::query!("DELETE FROM quadratic_votes WHERE proposal_id = $1 AND voter_id = $2", proposal_id, voting_identity)
.execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
sqlx::query!(
"DELETE FROM quadratic_votes WHERE proposal_id = $1 AND voter_id = $2",
proposal_id,
voting_identity
)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Insert quadratic votes
for alloc in req.allocations {
@ -581,7 +669,9 @@ async fn cast_quadratic_vote(
}
}
Ok(Json(serde_json::json!({"status": "voted", "method": "quadratic", "credits_used": total_cost})))
Ok(Json(
serde_json::json!({"status": "voted", "method": "quadratic", "credits_used": total_cost}),
))
}
async fn cast_star_vote(
@ -600,17 +690,26 @@ async fn cast_star_vote(
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Proposal is not in voting phase".to_string(),
));
}
if proposal.voting_method != "star" {
return Err((StatusCode::BAD_REQUEST, "This proposal uses a different voting method".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"This proposal uses a different voting method".to_string(),
));
}
// Validate star ratings (0-5)
for rating in &req.ratings {
if rating.stars < 0 || rating.stars > 5 {
return Err((StatusCode::BAD_REQUEST, "Star ratings must be between 0 and 5".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Star ratings must be between 0 and 5".to_string(),
));
}
}
@ -621,15 +720,24 @@ async fn cast_star_vote(
RETURNING id"#,
auth.user_id,
proposal.community_id,
format!("voter-{}", uuid::Uuid::new_v4().to_string()[..8].to_string())
format!(
"voter-{}",
uuid::Uuid::new_v4().to_string()[..8].to_string()
)
)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Clear existing star votes
sqlx::query!("DELETE FROM star_votes WHERE proposal_id = $1 AND voter_id = $2", proposal_id, voting_identity)
.execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
sqlx::query!(
"DELETE FROM star_votes WHERE proposal_id = $1 AND voter_id = $2",
proposal_id,
voting_identity
)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Insert star votes
for rating in req.ratings {
@ -639,7 +747,9 @@ async fn cast_star_vote(
).execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
Ok(Json(serde_json::json!({"status": "voted", "method": "star"})))
Ok(Json(
serde_json::json!({"status": "voted", "method": "star"}),
))
}
async fn start_discussion(
@ -663,7 +773,10 @@ async fn start_discussion(
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
if proposal.author_id != auth.user_id {
return Err((StatusCode::FORBIDDEN, "Only the author can start discussion".to_string()));
return Err((
StatusCode::FORBIDDEN,
"Only the author can start discussion".to_string(),
));
}
let updated = sqlx::query_as!(
@ -706,7 +819,10 @@ async fn start_voting(
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
if proposal.author_id != auth.user_id {
return Err((StatusCode::FORBIDDEN, "Only the author can start voting".to_string()));
return Err((
StatusCode::FORBIDDEN,
"Only the author can start voting".to_string(),
));
}
let updated = sqlx::query_as!(
@ -733,7 +849,7 @@ async fn close_voting(
Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> 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!(
Proposal,
@ -752,14 +868,26 @@ async fn close_voting(
// Check if user can manage status: author or users with manage_status permission
let is_author = proposal.author_id == auth.user_id;
let can_manage = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_MANAGE_STATUS, Some(proposal.community_id)).await?;
let can_manage = user_has_permission(
&pool,
auth.user_id,
perms::PROPOSAL_MANAGE_STATUS,
Some(proposal.community_id),
)
.await?;
if !is_author && !can_manage {
return Err((StatusCode::FORBIDDEN, "Only the author or admins can close voting".to_string()));
return Err((
StatusCode::FORBIDDEN,
"Only the author or admins can close voting".to_string(),
));
}
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Proposal is not in voting phase".to_string(),
));
}
let updated = sqlx::query_as!(
@ -787,7 +915,7 @@ async fn delete_proposal(
Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> 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!(
"SELECT author_id, community_id, status as \"status: crate::models::ProposalStatus\", title FROM proposals WHERE id = $1",
@ -800,22 +928,46 @@ async fn delete_proposal(
// Check if user can delete: author needs delete_own, others need delete_any
let is_author = proposal.author_id == auth.user_id;
let can_delete_own = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_DELETE_OWN, Some(proposal.community_id)).await?;
let can_delete_any = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_DELETE_ANY, Some(proposal.community_id)).await?;
let can_delete_own = user_has_permission(
&pool,
auth.user_id,
perms::PROPOSAL_DELETE_OWN,
Some(proposal.community_id),
)
.await?;
let can_delete_any = user_has_permission(
&pool,
auth.user_id,
perms::PROPOSAL_DELETE_ANY,
Some(proposal.community_id),
)
.await?;
if is_author && !can_delete_own {
return Err((StatusCode::FORBIDDEN, "You don't have permission to delete proposals".to_string()));
return Err((
StatusCode::FORBIDDEN,
"You don't have permission to delete proposals".to_string(),
));
}
if !is_author && !can_delete_any {
return Err((StatusCode::FORBIDDEN, "Only the author or admins can delete this proposal".to_string()));
return Err((
StatusCode::FORBIDDEN,
"Only the author or admins can delete this proposal".to_string(),
));
}
if !matches!(proposal.status, crate::models::ProposalStatus::Draft) {
return Err((StatusCode::BAD_REQUEST, "Only draft proposals can be deleted".to_string()));
return Err((
StatusCode::BAD_REQUEST,
"Only draft proposals can be deleted".to_string(),
));
}
// Delete related data first
sqlx::query!("DELETE FROM proposal_options WHERE proposal_id = $1", proposal_id)
sqlx::query!(
"DELETE FROM proposal_options WHERE proposal_id = $1",
proposal_id
)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@ -846,7 +998,7 @@ async fn update_proposal(
State(pool): State<PgPool>,
Json(payload): Json<UpdateProposal>,
) -> 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!(
"SELECT author_id, community_id, status as \"status: crate::models::ProposalStatus\" FROM proposals WHERE id = $1",
@ -859,18 +1011,42 @@ async fn update_proposal(
// Check edit permissions: author needs edit_own, others need edit_any
let is_author = proposal.author_id == auth.user_id;
let can_edit_own = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_EDIT_OWN, Some(proposal.community_id)).await?;
let can_edit_any = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_EDIT_ANY, Some(proposal.community_id)).await?;
let can_edit_own = user_has_permission(
&pool,
auth.user_id,
perms::PROPOSAL_EDIT_OWN,
Some(proposal.community_id),
)
.await?;
let can_edit_any = user_has_permission(
&pool,
auth.user_id,
perms::PROPOSAL_EDIT_ANY,
Some(proposal.community_id),
)
.await?;
if is_author && !can_edit_own {
return Err((StatusCode::FORBIDDEN, "You don't have permission to edit proposals".to_string()));
return Err((
StatusCode::FORBIDDEN,
"You don't have permission to edit proposals".to_string(),
));
}
if !is_author && !can_edit_any {
return Err((StatusCode::FORBIDDEN, "Only the author or admins can edit this proposal".to_string()));
return Err((
StatusCode::FORBIDDEN,
"Only the author or admins can edit this proposal".to_string(),
));
}
if !matches!(proposal.status, crate::models::ProposalStatus::Draft | crate::models::ProposalStatus::Discussion) {
return Err((StatusCode::BAD_REQUEST, "Can only edit proposals in draft or discussion phase".to_string()));
if !matches!(
proposal.status,
crate::models::ProposalStatus::Draft | crate::models::ProposalStatus::Discussion
) {
return Err((
StatusCode::BAD_REQUEST,
"Can only edit proposals in draft or discussion phase".to_string(),
));
}
let updated = sqlx::query_as!(
@ -921,7 +1097,7 @@ async fn get_voting_results(
Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<VotingResultsResponse>, (StatusCode, String)> {
use crate::api::permissions::{require_permission, perms};
use crate::api::permissions::{perms, require_permission};
let proposal = sqlx::query!(
"SELECT id, community_id, voting_method, status as \"status: crate::models::ProposalStatus\" FROM proposals WHERE id = $1",
@ -933,7 +1109,13 @@ async fn get_voting_results(
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
// Require permission to view voting results
require_permission(&pool, auth.user_id, perms::VOTE_VIEW_RESULTS, Some(proposal.community_id)).await?;
require_permission(
&pool,
auth.user_id,
perms::VOTE_VIEW_RESULTS,
Some(proposal.community_id),
)
.await?;
let options: Vec<(Uuid, String)> = sqlx::query!(
"SELECT id, label FROM proposal_options WHERE proposal_id = $1 ORDER BY sort_order",
@ -950,7 +1132,9 @@ async fn get_voting_results(
let (results, total_votes, details) = match voting_method {
"approval" => calculate_approval_results(&pool, proposal_id, &options).await?,
"ranked_choice" | "schulze" => calculate_ranked_results(&pool, proposal_id, &options, voting_method).await?,
"ranked_choice" | "schulze" => {
calculate_ranked_results(&pool, proposal_id, &options, voting_method).await?
}
"star" => calculate_star_results(&pool, proposal_id, &options).await?,
"quadratic" => calculate_quadratic_results(&pool, proposal_id, &options).await?,
_ => calculate_approval_results(&pool, proposal_id, &options).await?,
@ -992,14 +1176,19 @@ async fn calculate_approval_results(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.unwrap_or(0);
let mut results: Vec<OptionResult> = options.iter().map(|(opt_id, label)| {
let votes = vote_counts.iter()
let mut results: Vec<OptionResult> = options
.iter()
.map(|(opt_id, label)| {
let votes = vote_counts
.iter()
.find(|v| v.option_id == *opt_id)
.map(|v| v.count.unwrap_or(0))
.unwrap_or(0);
let percentage = if total_voters > 0 {
(votes as f64 / total_voters as f64) * 100.0
} else { 0.0 };
} else {
0.0
};
OptionResult {
option_id: *opt_id,
@ -1008,14 +1197,19 @@ async fn calculate_approval_results(
percentage,
rank: 0,
}
}).collect();
})
.collect();
results.sort_by(|a, b| b.votes.cmp(&a.votes));
for (i, r) in results.iter_mut().enumerate() {
r.rank = (i + 1) as i32;
}
Ok((results, total_voters, serde_json::json!({"method": "approval"})))
Ok((
results,
total_voters,
serde_json::json!({"method": "approval"}),
))
}
async fn calculate_ranked_results(
@ -1024,7 +1218,7 @@ async fn calculate_ranked_results(
options: &[(Uuid, String)],
method: &str,
) -> Result<(Vec<OptionResult>, i64, serde_json::Value), (StatusCode, String)> {
use crate::voting::{schulze, ranked_choice};
use crate::voting::{ranked_choice, schulze};
let ballots_raw = sqlx::query!(
r#"SELECT voter_id, option_id, rank FROM ranked_votes
@ -1038,33 +1232,48 @@ async fn calculate_ranked_results(
let option_ids: Vec<Uuid> = options.iter().map(|(id, _)| *id).collect();
// 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 {
voter_ballots.entry(b.voter_id).or_default().push((b.option_id, b.rank));
voter_ballots
.entry(b.voter_id)
.or_default()
.push((b.option_id, b.rank));
}
let total_voters = voter_ballots.len() as i64;
let result = if method == "schulze" {
let ballots: Vec<schulze::RankedBallot> = voter_ballots.values().map(|rankings| {
schulze::RankedBallot {
rankings: rankings.iter().map(|(id, rank)| (*id, *rank as usize)).collect()
}
}).collect();
let ballots: Vec<schulze::RankedBallot> = voter_ballots
.values()
.map(|rankings| schulze::RankedBallot {
rankings: rankings
.iter()
.map(|(id, rank)| (*id, *rank as usize))
.collect(),
})
.collect();
schulze::calculate(&option_ids, &ballots)
} else {
let ballots: Vec<ranked_choice::RankedBallot> = voter_ballots.values().map(|rankings| {
let ballots: Vec<ranked_choice::RankedBallot> = voter_ballots
.values()
.map(|rankings| {
let mut sorted = rankings.clone();
sorted.sort_by_key(|(_, rank)| *rank);
ranked_choice::RankedBallot {
rankings: sorted.iter().map(|(id, _)| *id).collect()
rankings: sorted.iter().map(|(id, _)| *id).collect(),
}
}).collect();
})
.collect();
ranked_choice::calculate(&option_ids, &ballots)
};
let results: Vec<OptionResult> = result.ranking.iter().map(|r| {
let label = options.iter()
let results: Vec<OptionResult> = result
.ranking
.iter()
.map(|r| {
let label = options
.iter()
.find(|(id, _)| *id == r.option_id)
.map(|(_, l)| l.clone())
.unwrap_or_default();
@ -1072,12 +1281,21 @@ async fn calculate_ranked_results(
option_id: r.option_id,
label,
votes: r.score as i64,
percentage: if total_voters > 0 { (r.score / total_voters as f64) * 100.0 } else { 0.0 },
percentage: if total_voters > 0 {
(r.score / total_voters as f64) * 100.0
} else {
0.0
},
rank: r.rank as i32,
}
}).collect();
})
.collect();
Ok((results, total_voters, serde_json::to_value(&result.details).unwrap_or_default()))
Ok((
results,
total_voters,
serde_json::to_value(&result.details).unwrap_or_default(),
))
}
async fn calculate_star_results(
@ -1098,22 +1316,31 @@ async fn calculate_star_results(
let option_ids: Vec<Uuid> = options.iter().map(|(id, _)| *id).collect();
// 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 {
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| {
star::ScoreBallot {
scores: scores.clone()
}
}).collect();
let ballots: Vec<star::ScoreBallot> = voter_scores
.values()
.map(|scores| star::ScoreBallot {
scores: scores.clone(),
})
.collect();
let total_voters = ballots.len() as i64;
let result = star::calculate(&option_ids, &ballots);
let results: Vec<OptionResult> = result.ranking.iter().map(|r| {
let label = options.iter()
let results: Vec<OptionResult> = result
.ranking
.iter()
.map(|r| {
let label = options
.iter()
.find(|(id, _)| *id == r.option_id)
.map(|(_, l)| l.clone())
.unwrap_or_default();
@ -1124,9 +1351,14 @@ async fn calculate_star_results(
percentage: 0.0,
rank: r.rank as i32,
}
}).collect();
})
.collect();
Ok((results, total_voters, serde_json::to_value(&result.details).unwrap_or_default()))
Ok((
results,
total_voters,
serde_json::to_value(&result.details).unwrap_or_default(),
))
}
async fn calculate_quadratic_results(
@ -1147,24 +1379,35 @@ async fn calculate_quadratic_results(
let option_ids: Vec<Uuid> = options.iter().map(|(id, _)| *id).collect();
// 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 {
voter_allocations.entry(v.voter_id).or_default().push((v.option_id, v.credits));
voter_allocations
.entry(v.voter_id)
.or_default()
.push((v.option_id, v.credits));
}
// Convert to QuadraticBallot format (100 credits per voter)
let ballots: Vec<quadratic::QuadraticBallot> = voter_allocations.values().map(|allocs| {
let ballots: Vec<quadratic::QuadraticBallot> = voter_allocations
.values()
.map(|allocs| {
quadratic::QuadraticBallot {
total_credits: 100, // Standard credit allocation
allocations: allocs.clone(),
}
}).collect();
})
.collect();
let total_voters = ballots.len() as i64;
let result = quadratic::calculate(&option_ids, &ballots);
let results: Vec<OptionResult> = result.ranking.iter().map(|r| {
let label = options.iter()
let results: Vec<OptionResult> = result
.ranking
.iter()
.map(|r| {
let label = options
.iter()
.find(|(id, _)| *id == r.option_id)
.map(|(_, l)| l.clone())
.unwrap_or_default();
@ -1172,10 +1415,19 @@ async fn calculate_quadratic_results(
option_id: r.option_id,
label,
votes: r.score as i64,
percentage: if total_voters > 0 { (r.score / total_voters as f64) * 100.0 } else { 0.0 },
percentage: if total_voters > 0 {
(r.score / total_voters as f64) * 100.0
} else {
0.0
},
rank: r.rank as i32,
}
}).collect();
})
.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::{
extract::{Path, State},
http::StatusCode,
routing::{get, post, delete},
routing::{delete, get, post},
Json, Router,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use chrono::{DateTime, Utc};
use crate::auth::AuthUser;
@ -117,13 +117,18 @@ async fn list_permissions(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(perms.into_iter().map(|p| Permission {
Ok(Json(
perms
.into_iter()
.map(|p| Permission {
id: p.id,
name: p.name,
category: p.category,
description: p.description,
is_system: p.is_system,
}).collect()))
})
.collect(),
))
}
// ============================================================================
@ -149,7 +154,10 @@ async fn list_platform_roles(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(roles.into_iter().map(|r| Role {
Ok(Json(
roles
.into_iter()
.map(|r| Role {
id: r.id,
name: r.name,
display_name: r.display_name,
@ -160,7 +168,9 @@ async fn list_platform_roles(
is_default: r.is_default,
priority: r.priority,
permissions: r.permissions.unwrap_or_default(),
}).collect()))
})
.collect(),
))
}
// ============================================================================
@ -188,7 +198,10 @@ async fn list_community_roles(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(roles.into_iter().map(|r| Role {
Ok(Json(
roles
.into_iter()
.map(|r| Role {
id: r.id,
name: r.name,
display_name: r.display_name,
@ -199,7 +212,9 @@ async fn list_community_roles(
is_default: r.is_default,
priority: r.priority,
permissions: r.permissions.unwrap_or_default(),
}).collect()))
})
.collect(),
))
}
/// Create a community role
@ -221,7 +236,10 @@ async fn create_community_role(
.unwrap_or(false);
if !has_perm {
return Err((StatusCode::FORBIDDEN, "No permission to manage roles".to_string()));
return Err((
StatusCode::FORBIDDEN,
"No permission to manage roles".to_string(),
));
}
// Create role
@ -288,7 +306,10 @@ async fn assign_role(
.unwrap_or(false);
if !has_perm {
return Err((StatusCode::FORBIDDEN, "No permission to manage roles".to_string()));
return Err((
StatusCode::FORBIDDEN,
"No permission to manage roles".to_string(),
));
}
// Verify role belongs to community
@ -339,7 +360,10 @@ async fn remove_role(
.unwrap_or(false);
if !has_perm {
return Err((StatusCode::FORBIDDEN, "No permission to manage roles".to_string()));
return Err((
StatusCode::FORBIDDEN,
"No permission to manage roles".to_string(),
));
}
sqlx::query!(
@ -375,12 +399,17 @@ async fn get_user_roles(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(roles.into_iter().map(|r| RoleSummary {
Ok(Json(
roles
.into_iter()
.map(|r| RoleSummary {
id: r.id,
name: r.name,
display_name: r.display_name,
color: r.color,
}).collect()))
})
.collect(),
))
}
/// Check if current user has a specific permission
@ -417,10 +446,25 @@ pub fn router(pool: PgPool) -> Router {
// Platform roles
.route("/api/roles", get(list_platform_roles))
// Community roles
.route("/api/communities/{community_id}/roles", get(list_community_roles).post(create_community_role))
.route("/api/communities/{community_id}/roles/{role_id}/assign", post(assign_role))
.route("/api/communities/{community_id}/roles/{role_id}/users/{user_id}", delete(remove_role))
.route("/api/communities/{community_id}/users/{user_id}/roles", get(get_user_roles))
.route("/api/communities/{community_id}/permissions/{permission_name}/check", get(check_permission))
.route(
"/api/communities/{community_id}/roles",
get(list_community_roles).post(create_community_role),
)
.route(
"/api/communities/{community_id}/roles/{role_id}/assign",
post(assign_role),
)
.route(
"/api/communities/{community_id}/roles/{role_id}/users/{user_id}",
delete(remove_role),
)
.route(
"/api/communities/{community_id}/users/{user_id}/roles",
get(get_user_roles),
)
.route(
"/api/communities/{community_id}/permissions/{permission_name}/check",
get(check_permission),
)
.with_state(pool)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,7 +11,11 @@ pub struct Claims {
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 exp = now + Duration::hours(24);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -216,7 +216,10 @@ impl LedgerService {
}
/// 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!(
LedgerEntry,
r#"SELECT
@ -412,7 +415,8 @@ impl Plugin for ModerationLedgerPlugin {
Arc::new(move |ctx: HookContext, payload: Value| {
let plugin_name = plugin_name.clone();
Box::pin(async move {
let actor_id = ctx.actor_user_id
let actor_id = ctx
.actor_user_id
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
let target_id = payload
@ -452,9 +456,11 @@ impl Plugin for ModerationLedgerPlugin {
"unilateral",
None,
None,
).await?;
)
.await?;
let _ = ctx.emit_public_event(
let _ = ctx
.emit_public_event(
Some(&plugin_name),
"ledger.entry_created",
json!({
@ -462,7 +468,8 @@ impl Plugin for ModerationLedgerPlugin {
"action_type": "content_remove",
"target_type": content_type,
}),
).await;
)
.await;
Ok(())
})
@ -478,7 +485,8 @@ impl Plugin for ModerationLedgerPlugin {
Arc::new(move |ctx: HookContext, payload: Value| {
let plugin_name = plugin_name2.clone();
Box::pin(async move {
let actor_id = ctx.actor_user_id
let actor_id = ctx
.actor_user_id
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
let target_user_id = payload
@ -534,9 +542,11 @@ impl Plugin for ModerationLedgerPlugin {
"unilateral",
None,
None,
).await?;
)
.await?;
let _ = ctx.emit_public_event(
let _ = ctx
.emit_public_event(
Some(&plugin_name),
"ledger.entry_created",
json!({
@ -544,7 +554,8 @@ impl Plugin for ModerationLedgerPlugin {
"action_type": action,
"target_type": "user",
}),
).await;
)
.await;
Ok(())
})
@ -560,7 +571,8 @@ impl Plugin for ModerationLedgerPlugin {
Arc::new(move |ctx: HookContext, payload: Value| {
let plugin_name = plugin_name3.clone();
Box::pin(async move {
let actor_id = ctx.actor_user_id
let actor_id = ctx
.actor_user_id
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
let proposal_id = payload
@ -617,9 +629,11 @@ impl Plugin for ModerationLedgerPlugin {
decision_type,
vote_proposal_id,
payload.get("vote_result").cloned(),
).await?;
)
.await?;
let _ = ctx.emit_public_event(
let _ = ctx
.emit_public_event(
Some(&plugin_name),
"ledger.entry_created",
json!({
@ -627,7 +641,8 @@ impl Plugin for ModerationLedgerPlugin {
"action_type": action,
"target_type": "proposal",
}),
).await;
)
.await;
Ok(())
})

View file

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

View file

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

View file

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

View file

@ -6,9 +6,7 @@ use std::sync::Arc;
use uuid::Uuid;
use crate::plugins::{
hooks::HookContext,
manager::PluginSystem,
Plugin, PluginError, PluginMetadata, PluginScope,
hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
};
pub struct StructuredDeliberationPlugin;
@ -48,16 +46,23 @@ impl Plugin for StructuredDeliberationPlugin {
Arc::new(|ctx: HookContext, payload: Value| {
Box::pin(async move {
if let (Some(proposal_id), Some(user_id)) = (
payload.get("proposal_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()),
payload
.get("proposal_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok()),
ctx.actor_user_id,
) {
let can_comment = DeliberationService::check_can_participate(
&ctx.pool, proposal_id, user_id, "comment"
).await?;
&ctx.pool,
proposal_id,
user_id,
"comment",
)
.await?;
if !can_comment {
return Err(PluginError::Message(
"Please read the proposal before commenting".to_string()
"Please read the proposal before commenting".to_string(),
));
}
}
@ -90,7 +95,8 @@ impl Plugin for StructuredDeliberationPlugin {
Some("structured_deliberation"),
"argument.created",
payload.clone(),
).await?;
)
.await?;
Ok(())
})
}),
@ -189,7 +195,7 @@ impl DeliberationService {
author_id: Uuid,
) -> Result<Uuid, PluginError> {
let argument_id: Uuid = sqlx::query_scalar(
"SELECT add_deliberation_argument($1, $2, $3::argument_stance, $4, $5, $6)"
"SELECT add_deliberation_argument($1, $2, $3::argument_stance, $4, $5, $6)",
)
.bind(proposal_id)
.bind(parent_id)

View file

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

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 serde_json::{json, Value};
@ -6,9 +9,9 @@ use sqlx::PgPool;
use uuid::Uuid;
use crate::plugins::hooks::{ActionHandler, FilterHandler, HookContext, HookRegistry, PluginError};
use crate::plugins::wasm::WasmPlugin;
use crate::plugins::wasm::host_api::PluginManifest;
use crate::plugins::wasm::runtime::WasmRuntime;
use crate::plugins::wasm::WasmPlugin;
#[derive(Debug, Clone, Copy)]
pub enum PluginScope {
@ -350,7 +353,8 @@ impl PluginManager {
.await?;
for community in communities {
self.ensure_default_community_plugins(community.id, None).await?;
self.ensure_default_community_plugins(community.id, None)
.await?;
}
for plugin in &self.plugins {
@ -360,10 +364,14 @@ impl PluginManager {
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 core = sqlx::query!("SELECT name FROM plugins WHERE is_active = true AND is_core = true")
let core =
sqlx::query!("SELECT name FROM plugins WHERE is_active = true AND is_core = true")
.fetch_all(&self.pool)
.await?;
for row in core {
@ -454,7 +462,12 @@ impl PluginManager {
}
}
pub async fn do_wasm_action_for_community(&self, hook: &str, community_id: Uuid, payload: Value) {
pub async fn do_wasm_action_for_community(
&self,
hook: &str,
community_id: Uuid,
payload: Value,
) {
let wasm = match sqlx::query!(
r#"SELECT DISTINCT pp.id
FROM plugin_packages pp
@ -468,7 +481,11 @@ impl PluginManager {
{
Ok(rows) => rows,
Err(e) => {
tracing::error!("Failed to resolve active WASM plugins for hook {}: {}", hook, e);
tracing::error!(
"Failed to resolve active WASM plugins for hook {}: {}",
hook,
e
);
return;
}
};
@ -621,11 +638,7 @@ impl PluginManager {
if old_is_active && !new_is_active {
self.invoke_deactivate(plugin_name, ctx.clone(), old_settings.clone())
.await;
self.do_action(
"plugin.deactivated",
ctx,
json!({"plugin": plugin_name}),
)
self.do_action("plugin.deactivated", ctx, json!({"plugin": plugin_name}))
.await;
}
}

View file

@ -10,8 +10,8 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::PgPool;
use tokio::runtime::Handle;
use tokio::task::block_in_place;
use tokio::sync::Mutex;
use tokio::task::block_in_place;
use uuid::Uuid;
use wasmtime::{Linker, StoreLimits};
@ -70,7 +70,11 @@ impl HostState {
.find(|c| c.name == CAP_OUTBOUND_HTTP && c.allowed)
.and_then(|c| c.config.get("allowlist"))
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
Self {
@ -88,7 +92,9 @@ impl HostState {
}
pub fn has_capability(&self, name: &str) -> bool {
self.capabilities.iter().any(|c| c.name == name && c.allowed)
self.capabilities
.iter()
.any(|c| c.name == name && c.allowed)
}
pub fn is_url_allowed(&self, url: &str) -> bool {
@ -158,10 +164,18 @@ impl HostStateWithLimits {
}
/// 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
linker
.func_wrap("env", "host_log", |mut caller: wasmtime::Caller<'_, HostStateWithLimits>, ptr: u32, len: u32, level: u32| {
.func_wrap(
"env",
"host_log",
|mut caller: wasmtime::Caller<'_, HostStateWithLimits>,
ptr: u32,
len: u32,
level: u32| {
let memory = caller.get_export("memory").and_then(|e| e.into_memory());
if let Some(mem) = memory {
if len > MAX_WASM_STRING_BYTES {
@ -182,7 +196,8 @@ pub fn register_host_functions(linker: &mut Linker<HostStateWithLimits>) -> Resu
}
}
}
})
},
)
.map_err(|e| PluginError::Message(format!("Failed to register host_log: {e}")))?;
// host_get_setting: Retrieve plugin settings
@ -472,7 +487,13 @@ pub fn register_host_functions(linker: &mut Linker<HostStateWithLimits>) -> Resu
// host_get_result: Copy result buffer to WASM memory
// This is a helper for retrieving data from host functions
linker
.func_wrap("env", "host_get_result", |mut caller: wasmtime::Caller<'_, HostStateWithLimits>, dest_ptr: u32, max_len: u32| -> u32 {
.func_wrap(
"env",
"host_get_result",
|mut caller: wasmtime::Caller<'_, HostStateWithLimits>,
dest_ptr: u32,
max_len: u32|
-> u32 {
let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) {
Some(m) => m,
None => return 0,
@ -481,12 +502,16 @@ pub fn register_host_functions(linker: &mut Linker<HostStateWithLimits>) -> Resu
let result = caller.data().get_result();
let copy_len = std::cmp::min(result.len(), max_len as usize);
if memory.write(&mut caller, dest_ptr as usize, &result[..copy_len]).is_err() {
if memory
.write(&mut caller, dest_ptr as usize, &result[..copy_len])
.is_err()
{
return 0;
}
copy_len as u32
})
},
)
.map_err(|e| PluginError::Message(format!("Failed to register host_get_result: {e}")))?;
// host_http_request: Make outbound HTTP request (capability-gated)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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