mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-02-09 21:13:09 +00:00
fmt: rustfmt backend
This commit is contained in:
parent
a889bc3ff3
commit
99c0c300b5
56 changed files with 2692 additions and 1624 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()))?;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,26 +2,25 @@ use axum::{
|
|||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post, put},
|
||||
Extension,
|
||||
Json, Router,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use chrono::{DateTime, Utc};
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
use jsonschema::{Draft, JSONSchema};
|
||||
use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::PgPool;
|
||||
use sqlx::Row;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
use sha2::{Digest, Sha256};
|
||||
use reqwest::Url;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::plugins::PluginManager;
|
||||
use crate::plugins::wasm::host_api::PluginManifest;
|
||||
use crate::plugins::PluginManager;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CommunityPluginInfo {
|
||||
|
|
@ -51,7 +50,12 @@ async fn get_plugin_policy(
|
|||
|
||||
match membership {
|
||||
Some(m) if m.role == "admin" || m.role == "moderator" => {}
|
||||
_ => return Err((StatusCode::FORBIDDEN, "Must be admin or moderator".to_string())),
|
||||
_ => {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Must be admin or moderator".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let row = sqlx::query!(
|
||||
|
|
@ -91,7 +95,12 @@ async fn update_plugin_policy(
|
|||
|
||||
match membership {
|
||||
Some(m) if m.role == "admin" || m.role == "moderator" => {}
|
||||
_ => return Err((StatusCode::FORBIDDEN, "Must be admin or moderator".to_string())),
|
||||
_ => {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Must be admin or moderator".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let current = sqlx::query!(
|
||||
|
|
@ -160,9 +169,16 @@ async fn update_plugin_policy(
|
|||
trust_policy: parse_trust_policy(¤t.settings),
|
||||
install_sources: parse_install_sources(¤t.settings),
|
||||
allow_outbound_http: parse_bool(¤t.settings, "plugin_allow_outbound_http", false),
|
||||
http_egress_allowlist: parse_string_list(¤t.settings, "plugin_http_egress_allowlist"),
|
||||
http_egress_allowlist: parse_string_list(
|
||||
¤t.settings,
|
||||
"plugin_http_egress_allowlist",
|
||||
),
|
||||
registry_allowlist: parse_string_list(¤t.settings, "plugin_registry_allowlist"),
|
||||
allow_background_jobs: parse_bool(¤t.settings, "plugin_allow_background_jobs", false),
|
||||
allow_background_jobs: parse_bool(
|
||||
¤t.settings,
|
||||
"plugin_allow_background_jobs",
|
||||
false,
|
||||
),
|
||||
trusted_publishers: parse_string_list(¤t.settings, "plugin_trusted_publishers"),
|
||||
}));
|
||||
}
|
||||
|
|
@ -402,7 +418,10 @@ fn verify_signature_if_required(
|
|||
|
||||
if matches!(trust_policy, PluginTrustPolicy::SignedOnly) {
|
||||
if trusted_publishers.is_empty() {
|
||||
return Err((StatusCode::FORBIDDEN, "No trusted publishers configured".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"No trusted publishers configured".to_string(),
|
||||
));
|
||||
}
|
||||
if !trusted_publishers.iter().any(|p| p == publisher) {
|
||||
return Err((StatusCode::FORBIDDEN, "Publisher not trusted".to_string()));
|
||||
|
|
@ -425,29 +444,44 @@ fn verify_signature_if_required(
|
|||
}
|
||||
|
||||
fn enforce_registry_allowlist(url: &Url, allowlist: &[String]) -> Result<(), (StatusCode, String)> {
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or((StatusCode::BAD_REQUEST, "Registry URL must include host".to_string()))?;
|
||||
let host = url.host_str().ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Registry URL must include host".to_string(),
|
||||
))?;
|
||||
|
||||
if let Ok(ip) = host.parse::<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, ®istry_allowlist)?;
|
||||
|
|
@ -1001,13 +1059,20 @@ async fn install_registry_plugin_package(
|
|||
return Err((StatusCode::BAD_GATEWAY, "Registry fetch failed".to_string()));
|
||||
}
|
||||
|
||||
let bundle: UploadPluginPackageRequest = res
|
||||
.json()
|
||||
.await
|
||||
.map_err(|_| (StatusCode::BAD_GATEWAY, "Invalid registry response".to_string()))?;
|
||||
let bundle: UploadPluginPackageRequest = res.json().await.map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Invalid registry response".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let parsed_manifest: PluginManifest = serde_json::from_value(bundle.manifest.clone())
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Registry returned invalid manifest: {e}")))?;
|
||||
let parsed_manifest: PluginManifest =
|
||||
serde_json::from_value(bundle.manifest.clone()).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("Registry returned invalid manifest: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
if parsed_manifest.name != bundle.name {
|
||||
return Err((
|
||||
|
|
@ -1177,10 +1242,7 @@ async fn install_registry_plugin_package(
|
|||
}
|
||||
|
||||
fn parse_trust_policy(settings: &Value) -> PluginTrustPolicy {
|
||||
match settings
|
||||
.get("plugin_trust_policy")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
match settings.get("plugin_trust_policy").and_then(|v| v.as_str()) {
|
||||
Some("unsigned_allowed") => PluginTrustPolicy::UnsignedAllowed,
|
||||
_ => PluginTrustPolicy::SignedOnly,
|
||||
}
|
||||
|
|
@ -1194,7 +1256,10 @@ fn trust_policy_str(policy: PluginTrustPolicy) -> &'static str {
|
|||
}
|
||||
|
||||
fn parse_install_sources(settings: &Value) -> Vec<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!(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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()))?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(&[])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}")))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue