mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-02-09 13:03:10 +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
|
||||
|
|
@ -72,7 +72,7 @@ async fn list_pending_registrations(
|
|||
require_permission(&pool, auth.user_id, perms::USER_MANAGE, None).await?;
|
||||
|
||||
let status_filter = query.status.unwrap_or_else(|| "pending".to_string());
|
||||
|
||||
|
||||
let registrations = sqlx::query!(
|
||||
r#"SELECT id, username, email, display_name, status, created_at, expires_at
|
||||
FROM pending_registrations
|
||||
|
|
@ -85,15 +85,20 @@ async fn list_pending_registrations(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(registrations.into_iter().map(|r| PendingRegistration {
|
||||
id: r.id,
|
||||
username: r.username,
|
||||
email: r.email,
|
||||
display_name: r.display_name,
|
||||
status: r.status.unwrap_or_default(),
|
||||
created_at: r.created_at.unwrap_or_else(Utc::now),
|
||||
expires_at: r.expires_at,
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
registrations
|
||||
.into_iter()
|
||||
.map(|r| PendingRegistration {
|
||||
id: r.id,
|
||||
username: r.username,
|
||||
email: r.email,
|
||||
display_name: r.display_name,
|
||||
status: r.status.unwrap_or_default(),
|
||||
created_at: r.created_at.unwrap_or_else(Utc::now),
|
||||
expires_at: r.expires_at,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Review a pending registration (approve or reject)
|
||||
|
|
@ -117,9 +122,15 @@ async fn review_registration(
|
|||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("not found") || msg.contains("already processed") {
|
||||
(StatusCode::NOT_FOUND, "Pending registration not found or already processed".to_string())
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
"Pending registration not found or already processed".to_string(),
|
||||
)
|
||||
} else if msg.contains("expired") {
|
||||
(StatusCode::GONE, "Registration request has expired".to_string())
|
||||
(
|
||||
StatusCode::GONE,
|
||||
"Registration request has expired".to_string(),
|
||||
)
|
||||
} else {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, msg)
|
||||
}
|
||||
|
|
@ -147,13 +158,11 @@ async fn review_registration(
|
|||
.ok();
|
||||
}
|
||||
|
||||
let is_admin: bool = sqlx::query_scalar!(
|
||||
"SELECT is_admin FROM users WHERE id = $1",
|
||||
new_user_id
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let is_admin: bool =
|
||||
sqlx::query_scalar!("SELECT is_admin FROM users WHERE id = $1", new_user_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if is_admin {
|
||||
let admin_role_id: Option<Uuid> = sqlx::query_scalar!(
|
||||
|
|
@ -204,7 +213,10 @@ async fn review_registration(
|
|||
message: "Registration rejected".to_string(),
|
||||
}))
|
||||
} else {
|
||||
Err((StatusCode::NOT_FOUND, "Pending registration not found or already processed".to_string()))
|
||||
Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
"Pending registration not found or already processed".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -222,7 +234,7 @@ async fn list_pending_communities(
|
|||
require_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?;
|
||||
|
||||
let status_filter = query.status.unwrap_or_else(|| "pending".to_string());
|
||||
|
||||
|
||||
let communities = sqlx::query!(
|
||||
r#"SELECT pc.id, pc.name, pc.slug, pc.description, pc.requested_by,
|
||||
pc.status, pc.created_at, u.username as requester_username
|
||||
|
|
@ -237,16 +249,21 @@ async fn list_pending_communities(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(communities.into_iter().map(|c| PendingCommunity {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
description: c.description,
|
||||
requested_by: c.requested_by,
|
||||
requested_by_username: Some(c.requester_username),
|
||||
status: c.status.unwrap_or_default(),
|
||||
created_at: c.created_at.unwrap_or_else(Utc::now),
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
communities
|
||||
.into_iter()
|
||||
.map(|c| PendingCommunity {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
description: c.description,
|
||||
requested_by: c.requested_by,
|
||||
requested_by_username: Some(c.requester_username),
|
||||
status: c.status.unwrap_or_default(),
|
||||
created_at: c.created_at.unwrap_or_else(Utc::now),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Review a pending community request (approve or reject)
|
||||
|
|
@ -260,21 +277,21 @@ async fn review_community(
|
|||
|
||||
if req.approve {
|
||||
// Approve community
|
||||
let result = sqlx::query_scalar!(
|
||||
"SELECT approve_community($1, $2)",
|
||||
pending_id,
|
||||
auth.user_id
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("not found") || msg.contains("already processed") {
|
||||
(StatusCode::NOT_FOUND, "Pending community not found or already processed".to_string())
|
||||
} else {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, msg)
|
||||
}
|
||||
})?;
|
||||
let result =
|
||||
sqlx::query_scalar!("SELECT approve_community($1, $2)", pending_id, auth.user_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("not found") || msg.contains("already processed") {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
"Pending community not found or already processed".to_string(),
|
||||
)
|
||||
} else {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, msg)
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Json(ReviewResponse {
|
||||
success: true,
|
||||
|
|
@ -301,7 +318,10 @@ async fn review_community(
|
|||
message: "Community request rejected".to_string(),
|
||||
}))
|
||||
} else {
|
||||
Err((StatusCode::NOT_FOUND, "Pending community not found or already processed".to_string()))
|
||||
Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
"Pending community not found or already processed".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -312,8 +332,14 @@ async fn review_community(
|
|||
|
||||
pub fn router(pool: PgPool) -> Router {
|
||||
Router::new()
|
||||
.route("/api/approvals/registrations", get(list_pending_registrations))
|
||||
.route("/api/approvals/registrations/{id}", post(review_registration))
|
||||
.route(
|
||||
"/api/approvals/registrations",
|
||||
get(list_pending_registrations),
|
||||
)
|
||||
.route(
|
||||
"/api/approvals/registrations/{id}",
|
||||
post(review_registration),
|
||||
)
|
||||
.route("/api/approvals/communities", get(list_pending_communities))
|
||||
.route("/api/approvals/communities/{id}", post(review_community))
|
||||
.with_state(pool)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
use axum::{extract::State, http::StatusCode, routing::{get, post}, Extension, Json, Router};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -68,10 +73,16 @@ async fn register(
|
|||
|
||||
let registration_mode = if let Some(s) = &settings {
|
||||
if !s.registration_enabled {
|
||||
return Err((StatusCode::FORBIDDEN, "Registration is currently disabled".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Registration is currently disabled".to_string(),
|
||||
));
|
||||
}
|
||||
if s.registration_mode == "invite_only" && req.invitation_code.is_none() {
|
||||
return Err((StatusCode::FORBIDDEN, "Registration requires an invitation code".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Registration requires an invitation code".to_string(),
|
||||
));
|
||||
}
|
||||
s.registration_mode.clone()
|
||||
} else {
|
||||
|
|
@ -90,24 +101,41 @@ async fn register(
|
|||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
match validation {
|
||||
None => return Err((StatusCode::BAD_REQUEST, "Invalid invitation code".to_string())),
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid invitation code".to_string(),
|
||||
))
|
||||
}
|
||||
Some(inv) => {
|
||||
if !inv.is_active.unwrap_or(false) {
|
||||
return Err((StatusCode::BAD_REQUEST, "Invitation is no longer active".to_string()));
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invitation is no longer active".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(exp) = inv.expires_at {
|
||||
if exp < chrono::Utc::now() {
|
||||
return Err((StatusCode::BAD_REQUEST, "Invitation has expired".to_string()));
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invitation has expired".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(max) = inv.max_uses {
|
||||
if inv.uses_count.unwrap_or(0) >= max {
|
||||
return Err((StatusCode::BAD_REQUEST, "Invitation has reached maximum uses".to_string()));
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invitation has reached maximum uses".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(email) = &inv.email {
|
||||
if email != &req.email {
|
||||
return Err((StatusCode::BAD_REQUEST, "This invitation is for a different email address".to_string()));
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"This invitation is for a different email address".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
inv.community_id
|
||||
|
|
@ -162,7 +190,10 @@ async fn register(
|
|||
})?;
|
||||
|
||||
// Return a special response indicating pending approval
|
||||
return Err((StatusCode::ACCEPTED, "Registration submitted for approval. You will be notified when approved.".to_string()));
|
||||
return Err((
|
||||
StatusCode::ACCEPTED,
|
||||
"Registration submitted for approval. You will be notified when approved.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Direct registration (open mode, invite_only with valid invite, or first user)
|
||||
|
|
@ -183,7 +214,10 @@ async fn register(
|
|||
.await
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("duplicate key") {
|
||||
(StatusCode::CONFLICT, "Username or email already exists".to_string())
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
"Username or email already exists".to_string(),
|
||||
)
|
||||
} else {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
}
|
||||
|
|
@ -234,10 +268,15 @@ async fn register(
|
|||
|
||||
// Use invitation if provided (records usage and links user)
|
||||
if let Some(code) = &req.invitation_code {
|
||||
sqlx::query!("SELECT use_invitation($1, $2, $3)", code, user.id, req.email.as_str())
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.ok(); // Ignore errors - user is already created
|
||||
sqlx::query!(
|
||||
"SELECT use_invitation($1, $2, $3)",
|
||||
code,
|
||||
user.id,
|
||||
req.email.as_str()
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.ok(); // Ignore errors - user is already created
|
||||
}
|
||||
|
||||
// If invitation was for a specific community, add user as member
|
||||
|
|
@ -283,7 +322,10 @@ async fn login(
|
|||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Demo account not found".to_string()))?;
|
||||
.ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Demo account not found".to_string(),
|
||||
))?;
|
||||
|
||||
let token = create_token(user.id, &user.username, &config.jwt_secret)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
|
|
|||
|
|
@ -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,28 +75,34 @@ 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"
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let settings = sqlx::query!("SELECT platform_mode FROM instance_settings LIMIT 1")
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if let Some(s) = settings {
|
||||
match s.platform_mode.as_str() {
|
||||
"single_community" => {
|
||||
return Err((StatusCode::FORBIDDEN, "This platform is dedicated to a single community".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"This platform is dedicated to a single community".to_string(),
|
||||
));
|
||||
}
|
||||
"admin_only" => {
|
||||
// Check platform admin or community create permission
|
||||
let can_create = user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?;
|
||||
let can_create =
|
||||
user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?;
|
||||
if !can_create {
|
||||
return Err((StatusCode::FORBIDDEN, "Only administrators can create communities".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Only administrators can create communities".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
"approval" => {
|
||||
// Check if user has direct create permission (admins bypass approval)
|
||||
let can_create = user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?;
|
||||
let can_create =
|
||||
user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?;
|
||||
if !can_create {
|
||||
// Create pending community request instead
|
||||
sqlx::query!(
|
||||
|
|
@ -104,13 +117,20 @@ async fn create_community(
|
|||
.await
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("duplicate key") {
|
||||
(StatusCode::CONFLICT, "A community with this slug already exists or is pending approval".to_string())
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
"A community with this slug already exists or is pending approval"
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
return Err((StatusCode::ACCEPTED, "Community request submitted for approval".to_string()));
|
||||
|
||||
return Err((
|
||||
StatusCode::ACCEPTED,
|
||||
"Community request submitted for approval".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {} // "open" mode - anyone can create
|
||||
|
|
@ -132,7 +152,10 @@ async fn create_community(
|
|||
.await
|
||||
.map_err(|e| {
|
||||
if e.to_string().contains("duplicate key") {
|
||||
(StatusCode::CONFLICT, "Community name or slug already exists".to_string())
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
"Community name or slug already exists".to_string(),
|
||||
)
|
||||
} else {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
}
|
||||
|
|
@ -153,7 +176,11 @@ async fn create_community(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
tracing::info!("Community '{}' created by user {}", community.name, auth.username);
|
||||
tracing::info!(
|
||||
"Community '{}' created by user {}",
|
||||
community.name,
|
||||
auth.username
|
||||
);
|
||||
|
||||
Ok(Json(CommunityResponse::from(community)))
|
||||
}
|
||||
|
|
@ -237,7 +264,10 @@ async fn join_community(
|
|||
return Err((StatusCode::BAD_REQUEST, err.to_string()));
|
||||
}
|
||||
|
||||
let role = filtered.get("role").and_then(|v| v.as_str()).unwrap_or("member");
|
||||
let role = filtered
|
||||
.get("role")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("member");
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO community_members (user_id, community_id, role) VALUES ($1, $2, $3)",
|
||||
|
|
@ -254,12 +284,18 @@ async fn join_community(
|
|||
community_id: Some(community_id),
|
||||
actor_user_id: Some(auth.user_id),
|
||||
};
|
||||
let _ = plugins.do_action("member.join", ctx, serde_json::json!({
|
||||
"community_id": community_id.to_string(),
|
||||
"user_id": auth.user_id.to_string(),
|
||||
"username": auth.username.clone(),
|
||||
"role": role,
|
||||
})).await;
|
||||
let _ = plugins
|
||||
.do_action(
|
||||
"member.join",
|
||||
ctx,
|
||||
serde_json::json!({
|
||||
"community_id": community_id.to_string(),
|
||||
"user_id": auth.user_id.to_string(),
|
||||
"username": auth.username.clone(),
|
||||
"role": role,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
tracing::info!("User {} joined community {}", auth.username, community_id);
|
||||
Ok(Json(serde_json::json!({"status": "joined"})))
|
||||
|
|
@ -300,7 +336,10 @@ async fn leave_community(
|
|||
.unwrap_or(0);
|
||||
|
||||
if admin_count <= 1 {
|
||||
return Err((StatusCode::BAD_REQUEST, "Cannot leave: you are the only admin".to_string()));
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Cannot leave: you are the only admin".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -339,12 +378,18 @@ async fn leave_community(
|
|||
community_id: Some(community_id),
|
||||
actor_user_id: Some(auth.user_id),
|
||||
};
|
||||
let _ = plugins.do_action("member.leave", ctx, serde_json::json!({
|
||||
"community_id": community_id.to_string(),
|
||||
"user_id": auth.user_id.to_string(),
|
||||
"username": auth.username.clone(),
|
||||
"role": role,
|
||||
})).await;
|
||||
let _ = plugins
|
||||
.do_action(
|
||||
"member.leave",
|
||||
ctx,
|
||||
serde_json::json!({
|
||||
"community_id": community_id.to_string(),
|
||||
"user_id": auth.user_id.to_string(),
|
||||
"username": auth.username.clone(),
|
||||
"role": role,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
tracing::info!("User {} left community {}", auth.username, community_id);
|
||||
Ok(Json(serde_json::json!({"status": "left"})))
|
||||
|
|
@ -429,7 +474,12 @@ async fn my_communities(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(communities.into_iter().map(CommunityResponse::from).collect()))
|
||||
Ok(Json(
|
||||
communities
|
||||
.into_iter()
|
||||
.map(CommunityResponse::from)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
|
@ -458,21 +508,24 @@ async fn recent_activity(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let mut activities: Vec<ActivityItem> = proposals.into_iter().map(|p| {
|
||||
let desc = match p.status.as_str() {
|
||||
"voting" => "Now open for voting",
|
||||
"discussion" => "Open for discussion",
|
||||
"closed" => "Voting completed",
|
||||
_ => "New proposal created",
|
||||
};
|
||||
ActivityItem {
|
||||
activity_type: "proposal".to_string(),
|
||||
title: p.title,
|
||||
description: desc.to_string(),
|
||||
link: format!("/proposals/{}", p.id),
|
||||
created_at: p.created_at,
|
||||
}
|
||||
}).collect();
|
||||
let mut activities: Vec<ActivityItem> = proposals
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let desc = match p.status.as_str() {
|
||||
"voting" => "Now open for voting",
|
||||
"discussion" => "Open for discussion",
|
||||
"closed" => "Voting completed",
|
||||
_ => "New proposal created",
|
||||
};
|
||||
ActivityItem {
|
||||
activity_type: "proposal".to_string(),
|
||||
title: p.title,
|
||||
description: desc.to_string(),
|
||||
link: format!("/proposals/{}", p.id),
|
||||
created_at: p.created_at,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let communities = sqlx::query!(
|
||||
"SELECT name, slug, created_at FROM communities WHERE is_active = true ORDER BY created_at DESC LIMIT 5"
|
||||
|
|
@ -518,10 +571,16 @@ async fn update_community(
|
|||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::FORBIDDEN, "Not a member of this community".to_string()))?;
|
||||
.ok_or((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Not a member of this community".to_string(),
|
||||
))?;
|
||||
|
||||
if membership.role != "admin" {
|
||||
return Err((StatusCode::FORBIDDEN, "Only admins can edit the community".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Only admins can edit the community".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let updated = sqlx::query_as!(
|
||||
|
|
|
|||
|
|
@ -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,21 +366,24 @@ async fn list_my_delegations(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(delegations.into_iter().map(|d| {
|
||||
Delegation {
|
||||
id: d.id,
|
||||
delegator_id: d.delegator_id,
|
||||
delegate_id: d.delegate_id,
|
||||
delegate_username: Some(d.delegate_username),
|
||||
scope: d.scope,
|
||||
community_id: d.community_id,
|
||||
topic_id: d.topic_id,
|
||||
proposal_id: d.proposal_id,
|
||||
weight: d.weight,
|
||||
is_active: d.is_active,
|
||||
created_at: d.created_at,
|
||||
}
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
delegations
|
||||
.into_iter()
|
||||
.map(|d| Delegation {
|
||||
id: d.id,
|
||||
delegator_id: d.delegator_id,
|
||||
delegate_id: d.delegate_id,
|
||||
delegate_username: Some(d.delegate_username),
|
||||
scope: d.scope,
|
||||
community_id: d.community_id,
|
||||
topic_id: d.topic_id,
|
||||
proposal_id: d.proposal_id,
|
||||
weight: d.weight,
|
||||
is_active: d.is_active,
|
||||
created_at: d.created_at,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// List delegations TO a user (they are the delegate)
|
||||
|
|
@ -376,21 +405,24 @@ async fn list_delegations_to_me(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(delegations.into_iter().map(|d| {
|
||||
Delegation {
|
||||
id: d.id,
|
||||
delegator_id: d.delegator_id,
|
||||
delegate_id: d.delegate_id,
|
||||
delegate_username: Some(d.delegator_username),
|
||||
scope: d.scope,
|
||||
community_id: d.community_id,
|
||||
topic_id: d.topic_id,
|
||||
proposal_id: d.proposal_id,
|
||||
weight: d.weight,
|
||||
is_active: d.is_active,
|
||||
created_at: d.created_at,
|
||||
}
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
delegations
|
||||
.into_iter()
|
||||
.map(|d| Delegation {
|
||||
id: d.id,
|
||||
delegator_id: d.delegator_id,
|
||||
delegate_id: d.delegate_id,
|
||||
delegate_username: Some(d.delegator_username),
|
||||
scope: d.scope,
|
||||
community_id: d.community_id,
|
||||
topic_id: d.topic_id,
|
||||
proposal_id: d.proposal_id,
|
||||
weight: d.weight,
|
||||
is_active: d.is_active,
|
||||
created_at: d.created_at,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Revoke a delegation
|
||||
|
|
@ -552,16 +584,21 @@ async fn list_delegates(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(profiles.into_iter().map(|p| DelegateProfile {
|
||||
user_id: p.user_id,
|
||||
username: p.username,
|
||||
display_name: p.display_name,
|
||||
bio: p.bio,
|
||||
accepting_delegations: p.accepting_delegations,
|
||||
delegation_policy: p.delegation_policy,
|
||||
total_delegators: p.total_delegators,
|
||||
total_votes_cast: p.total_votes_cast,
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
profiles
|
||||
.into_iter()
|
||||
.map(|p| DelegateProfile {
|
||||
user_id: p.user_id,
|
||||
username: p.username,
|
||||
display_name: p.display_name,
|
||||
bio: p.bio,
|
||||
accepting_delegations: p.accepting_delegations,
|
||||
delegation_policy: p.delegation_policy,
|
||||
total_delegators: p.total_delegators,
|
||||
total_votes_cast: p.total_votes_cast,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -604,10 +641,16 @@ async fn create_topic(
|
|||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::FORBIDDEN, "Not a member of this community".to_string()))?;
|
||||
.ok_or((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Not a member of this community".to_string(),
|
||||
))?;
|
||||
|
||||
if membership.role != "admin" && membership.role != "moderator" {
|
||||
return Err((StatusCode::FORBIDDEN, "Only admins/moderators can create topics".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Only admins/moderators can create topics".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let topic = sqlx::query_as!(
|
||||
|
|
@ -635,13 +678,25 @@ async fn create_topic(
|
|||
pub fn router(pool: PgPool) -> Router {
|
||||
Router::new()
|
||||
// Delegations
|
||||
.route("/api/delegations", get(list_my_delegations).post(create_delegation))
|
||||
.route(
|
||||
"/api/delegations",
|
||||
get(list_my_delegations).post(create_delegation),
|
||||
)
|
||||
.route("/api/delegations/to-me", get(list_delegations_to_me))
|
||||
.route("/api/delegations/{delegation_id}", delete(revoke_delegation))
|
||||
.route(
|
||||
"/api/delegations/{delegation_id}",
|
||||
delete(revoke_delegation),
|
||||
)
|
||||
// Delegate profiles
|
||||
.route("/api/delegates", get(list_delegates))
|
||||
.route("/api/delegates/me", get(get_my_profile).put(update_my_profile))
|
||||
.route(
|
||||
"/api/delegates/me",
|
||||
get(get_my_profile).put(update_my_profile),
|
||||
)
|
||||
// Topics
|
||||
.route("/api/communities/{community_id}/topics", get(list_topics).post(create_topic))
|
||||
.route(
|
||||
"/api/communities/{community_id}/topics",
|
||||
get(list_topics).post(create_topic),
|
||||
)
|
||||
.with_state(pool)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ use axum::{
|
|||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::plugins::builtin::structured_deliberation::{Argument, Summary, DeliberationService};
|
||||
use crate::plugins::builtin::structured_deliberation::{Argument, DeliberationService, Summary};
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
|
|
@ -167,11 +167,14 @@ async fn add_resource(
|
|||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
||||
|
||||
let can_add = proposal.author_id == auth.user_id
|
||||
|| proposal.facilitator_id == Some(auth.user_id);
|
||||
let can_add =
|
||||
proposal.author_id == auth.user_id || proposal.facilitator_id == Some(auth.user_id);
|
||||
|
||||
if !can_add {
|
||||
return Err((StatusCode::FORBIDDEN, "Not authorized to add resources".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Not authorized to add resources".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let resource = sqlx::query_as!(
|
||||
|
|
@ -246,11 +249,16 @@ async fn get_read_status(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(statuses.into_iter().map(|s| ResourceReadStatus {
|
||||
resource_id: s.resource_id,
|
||||
has_read: s.read_at.is_some(),
|
||||
read_at: s.read_at,
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
statuses
|
||||
.into_iter()
|
||||
.map(|s| ResourceReadStatus {
|
||||
resource_id: s.resource_id,
|
||||
has_read: s.read_at.is_some(),
|
||||
read_at: s.read_at,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Set user's position on a proposal
|
||||
|
|
@ -401,14 +409,10 @@ async fn list_arguments(
|
|||
State(pool): State<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,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let arguments =
|
||||
DeliberationService::get_arguments(&pool, proposal_id, query.stance.as_deref(), limit)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(arguments))
|
||||
}
|
||||
|
|
@ -421,12 +425,16 @@ async fn add_argument(
|
|||
Json(req): Json<AddArgumentRequest>,
|
||||
) -> Result<Json<Value>, (StatusCode, String)> {
|
||||
// Check if user can participate
|
||||
let can = DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let can =
|
||||
DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if !can {
|
||||
return Err((StatusCode::FORBIDDEN, "Must read proposal content before participating".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Must read proposal content before participating".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let argument_id = DeliberationService::add_argument(
|
||||
|
|
@ -539,15 +547,20 @@ async fn check_can_participate(
|
|||
Path(proposal_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<CanParticipateResponse>, (StatusCode, String)> {
|
||||
let can_comment = DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let can_comment =
|
||||
DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let can_vote = DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "vote")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let can_vote =
|
||||
DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "vote")
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(CanParticipateResponse { can_comment, can_vote }))
|
||||
Ok(Json(CanParticipateResponse {
|
||||
can_comment,
|
||||
can_vote,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get deliberation overview (metrics + top arguments + summaries)
|
||||
|
|
@ -569,24 +582,51 @@ async fn get_overview(
|
|||
pub fn router(pool: PgPool) -> Router {
|
||||
Router::new()
|
||||
// Resources (inform phase)
|
||||
.route("/api/proposals/{proposal_id}/resources", get(list_resources).post(add_resource))
|
||||
.route("/api/proposals/{proposal_id}/resources/read-status", get(get_read_status))
|
||||
.route("/api/resources/{resource_id}/read", post(mark_resource_read))
|
||||
.route(
|
||||
"/api/proposals/{proposal_id}/resources",
|
||||
get(list_resources).post(add_resource),
|
||||
)
|
||||
.route(
|
||||
"/api/proposals/{proposal_id}/resources/read-status",
|
||||
get(get_read_status),
|
||||
)
|
||||
.route(
|
||||
"/api/resources/{resource_id}/read",
|
||||
post(mark_resource_read),
|
||||
)
|
||||
// Positions (agreement visualization)
|
||||
.route("/api/proposals/{proposal_id}/positions", post(set_position))
|
||||
.route("/api/proposals/{proposal_id}/positions/summary", get(get_position_summary))
|
||||
.route(
|
||||
"/api/proposals/{proposal_id}/positions/summary",
|
||||
get(get_position_summary),
|
||||
)
|
||||
// Comment reactions (quality scoring)
|
||||
.route("/api/comments/{comment_id}/reactions", get(get_comment_reactions).post(react_to_comment))
|
||||
.route(
|
||||
"/api/comments/{comment_id}/reactions",
|
||||
get(get_comment_reactions).post(react_to_comment),
|
||||
)
|
||||
// Arguments (structured debate) - wired to DeliberationService
|
||||
.route("/api/proposals/{proposal_id}/arguments", get(list_arguments).post(add_argument))
|
||||
.route(
|
||||
"/api/proposals/{proposal_id}/arguments",
|
||||
get(list_arguments).post(add_argument),
|
||||
)
|
||||
.route("/api/arguments/{argument_id}/vote", post(vote_argument))
|
||||
// Summaries (collaborative summaries) - wired to DeliberationService
|
||||
.route("/api/proposals/{proposal_id}/summaries", get(list_summaries).post(upsert_summary))
|
||||
.route(
|
||||
"/api/proposals/{proposal_id}/summaries",
|
||||
get(list_summaries).post(upsert_summary),
|
||||
)
|
||||
.route("/api/summaries/{summary_id}/approve", post(approve_summary))
|
||||
// Reading/participation tracking - wired to DeliberationService
|
||||
.route("/api/proposals/{proposal_id}/reading", post(record_reading))
|
||||
.route("/api/proposals/{proposal_id}/can-participate", get(check_can_participate))
|
||||
.route(
|
||||
"/api/proposals/{proposal_id}/can-participate",
|
||||
get(check_can_participate),
|
||||
)
|
||||
// Overview (combined metrics)
|
||||
.route("/api/proposals/{proposal_id}/deliberation", get(get_overview))
|
||||
.route(
|
||||
"/api/proposals/{proposal_id}/deliberation",
|
||||
get(get_overview),
|
||||
)
|
||||
.with_state(pool)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
@ -41,7 +39,7 @@ async fn get_demo_status(
|
|||
"restrictions": if state.config.is_demo() {
|
||||
vec![
|
||||
"Cannot delete communities",
|
||||
"Cannot delete users",
|
||||
"Cannot delete users",
|
||||
"Cannot modify instance settings",
|
||||
"Data resets periodically"
|
||||
]
|
||||
|
|
@ -52,45 +50,42 @@ async fn get_demo_status(
|
|||
}
|
||||
|
||||
/// Reset demo data to initial state (only in demo mode)
|
||||
async fn reset_demo(
|
||||
State(state): State<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,38 +99,42 @@ async fn get_demo_communities(
|
|||
FROM communities c
|
||||
WHERE c.slug IN ('aurora', 'civic-commons', 'makers')
|
||||
ORDER BY c.name
|
||||
"#
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await;
|
||||
|
||||
match communities {
|
||||
Ok(rows) => {
|
||||
let communities: Vec<_> = rows.iter().map(|(name, slug, desc, members, proposals)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"description": desc,
|
||||
"member_count": members,
|
||||
"proposal_count": proposals
|
||||
let communities: Vec<_> = rows
|
||||
.iter()
|
||||
.map(|(name, slug, desc, members, proposals)| {
|
||||
json!({
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"description": desc,
|
||||
"member_count": members,
|
||||
"proposal_count": proposals
|
||||
})
|
||||
})
|
||||
}).collect();
|
||||
|
||||
.collect();
|
||||
|
||||
(StatusCode::OK, Json(json!({"communities": communities}))).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to fetch demo communities: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "Failed to fetch communities"}))
|
||||
).into_response()
|
||||
Json(json!({"error": "Failed to fetch communities"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router(pool: PgPool, config: Arc<Config>) -> Router {
|
||||
let state = DemoState { pool, config };
|
||||
|
||||
|
||||
Router::new()
|
||||
.route("/api/demo/status", get(get_demo_status))
|
||||
.route("/api/demo/reset", post(reset_demo))
|
||||
|
|
|
|||
|
|
@ -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,17 +218,22 @@ async fn list_issues(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(issues.into_iter().map(|i| GitLabIssue {
|
||||
id: i.id,
|
||||
gitlab_iid: i.gitlab_iid,
|
||||
title: i.title,
|
||||
description: i.description,
|
||||
state: i.state,
|
||||
author_username: i.author_username,
|
||||
labels: i.labels.unwrap_or_default(),
|
||||
proposal_id: i.proposal_id,
|
||||
gitlab_created_at: i.gitlab_created_at,
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
issues
|
||||
.into_iter()
|
||||
.map(|i| GitLabIssue {
|
||||
id: i.id,
|
||||
gitlab_iid: i.gitlab_iid,
|
||||
title: i.title,
|
||||
description: i.description,
|
||||
state: i.state,
|
||||
author_username: i.author_username,
|
||||
labels: i.labels.unwrap_or_default(),
|
||||
proposal_id: i.proposal_id,
|
||||
gitlab_created_at: i.gitlab_created_at,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// List GitLab merge requests for a community
|
||||
|
|
@ -245,19 +256,23 @@ async fn list_merge_requests(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(mrs.into_iter().map(|m| GitLabMergeRequest {
|
||||
id: m.id,
|
||||
gitlab_iid: m.gitlab_iid,
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
state: m.state,
|
||||
author_username: m.author_username,
|
||||
source_branch: m.source_branch,
|
||||
target_branch: m.target_branch,
|
||||
labels: m.labels.unwrap_or_default(),
|
||||
proposal_id: m.proposal_id,
|
||||
gitlab_created_at: m.gitlab_created_at,
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
mrs.into_iter()
|
||||
.map(|m| GitLabMergeRequest {
|
||||
id: m.id,
|
||||
gitlab_iid: m.gitlab_iid,
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
state: m.state,
|
||||
author_username: m.author_username,
|
||||
source_branch: m.source_branch,
|
||||
target_branch: m.target_branch,
|
||||
labels: m.labels.unwrap_or_default(),
|
||||
proposal_id: m.proposal_id,
|
||||
gitlab_created_at: m.gitlab_created_at,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Create proposal from GitLab issue
|
||||
|
|
@ -292,7 +307,10 @@ async fn create_proposal_from_issue(
|
|||
.ok_or((StatusCode::NOT_FOUND, "Issue not found".to_string()))?;
|
||||
|
||||
if issue.proposal_id.is_some() {
|
||||
return Err((StatusCode::CONFLICT, "Issue already linked to a proposal".to_string()));
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
"Issue already linked to a proposal".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Create proposal
|
||||
|
|
@ -340,9 +358,21 @@ async fn create_proposal_from_issue(
|
|||
|
||||
pub fn router(pool: PgPool) -> Router {
|
||||
Router::new()
|
||||
.route("/api/communities/{community_id}/gitlab", get(get_connection).post(create_connection))
|
||||
.route("/api/communities/{community_id}/gitlab/issues", get(list_issues))
|
||||
.route("/api/communities/{community_id}/gitlab/merge-requests", get(list_merge_requests))
|
||||
.route("/api/communities/{community_id}/gitlab/issues/{issue_id}/create-proposal", post(create_proposal_from_issue))
|
||||
.route(
|
||||
"/api/communities/{community_id}/gitlab",
|
||||
get(get_connection).post(create_connection),
|
||||
)
|
||||
.route(
|
||||
"/api/communities/{community_id}/gitlab/issues",
|
||||
get(list_issues),
|
||||
)
|
||||
.route(
|
||||
"/api/communities/{community_id}/gitlab/merge-requests",
|
||||
get(list_merge_requests),
|
||||
)
|
||||
.route(
|
||||
"/api/communities/{community_id}/gitlab/issues/{issue_id}/create-proposal",
|
||||
post(create_proposal_from_issue),
|
||||
)
|
||||
.with_state(pool)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,20 +172,25 @@ async fn list_invitations(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(invites.into_iter().map(|i| Invitation {
|
||||
id: i.id,
|
||||
code: i.code,
|
||||
created_by: i.created_by,
|
||||
created_by_username: Some(i.creator_username),
|
||||
email: i.email,
|
||||
community_id: i.community_id,
|
||||
community_name: Some(i.community_name),
|
||||
max_uses: i.max_uses,
|
||||
uses_count: i.uses_count.unwrap_or(0),
|
||||
expires_at: i.expires_at,
|
||||
is_active: i.is_active.unwrap_or(true),
|
||||
created_at: i.created_at.unwrap_or_else(Utc::now),
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
invites
|
||||
.into_iter()
|
||||
.map(|i| Invitation {
|
||||
id: i.id,
|
||||
code: i.code,
|
||||
created_by: i.created_by,
|
||||
created_by_username: Some(i.creator_username),
|
||||
email: i.email,
|
||||
community_id: i.community_id,
|
||||
community_name: Some(i.community_name),
|
||||
max_uses: i.max_uses,
|
||||
uses_count: i.uses_count.unwrap_or(0),
|
||||
expires_at: i.expires_at,
|
||||
is_active: i.is_active.unwrap_or(true),
|
||||
created_at: i.created_at.unwrap_or_else(Utc::now),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Validate an invitation code (public endpoint for registration)
|
||||
|
|
@ -247,22 +257,31 @@ async fn revoke_invitation(
|
|||
Path(invitation_id): Path<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)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Invitation not found".to_string()))?;
|
||||
let invite = sqlx::query!(
|
||||
"SELECT created_by FROM invitations WHERE id = $1",
|
||||
invitation_id
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Invitation not found".to_string()))?;
|
||||
|
||||
let is_admin = user_has_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?;
|
||||
|
||||
|
||||
if invite.created_by != auth.user_id && !is_admin {
|
||||
return Err((StatusCode::FORBIDDEN, "Not authorized to revoke this invitation".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Not authorized to revoke this invitation".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query!("UPDATE invitations SET is_active = FALSE WHERE id = $1", invitation_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
sqlx::query!(
|
||||
"UPDATE invitations SET is_active = FALSE WHERE id = $1",
|
||||
invitation_id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(serde_json::json!({"success": true})))
|
||||
}
|
||||
|
|
@ -273,7 +292,10 @@ async fn revoke_invitation(
|
|||
|
||||
pub fn router(pool: PgPool) -> Router {
|
||||
Router::new()
|
||||
.route("/api/invitations", get(list_invitations).post(create_invitation))
|
||||
.route(
|
||||
"/api/invitations",
|
||||
get(list_invitations).post(create_invitation),
|
||||
)
|
||||
.route("/api/invitations/validate/{code}", get(validate_invitation))
|
||||
.route("/api/invitations/{id}", delete(revoke_invitation))
|
||||
.with_state(pool)
|
||||
|
|
|
|||
|
|
@ -91,9 +91,10 @@ 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)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let diff =
|
||||
LifecycleService::compare_versions(&pool, proposal_id, req.from_version, req.to_version)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(diff))
|
||||
}
|
||||
|
|
@ -203,9 +204,15 @@ async fn vote_amendment(
|
|||
State(pool): State<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())
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
LifecycleService::vote_amendment(
|
||||
&pool,
|
||||
amendment_id,
|
||||
auth.user_id,
|
||||
&req.vote,
|
||||
req.comment.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(serde_json::json!({"success": true})))
|
||||
}
|
||||
|
|
@ -232,16 +239,37 @@ pub fn router(pool: PgPool) -> Router {
|
|||
Router::new()
|
||||
// Versions
|
||||
.route("/api/proposals/{proposal_id}/versions", get(get_versions))
|
||||
.route("/api/proposals/{proposal_id}/versions/{version_number}", get(get_version))
|
||||
.route("/api/proposals/{proposal_id}/versions/compare", post(compare_versions))
|
||||
.route(
|
||||
"/api/proposals/{proposal_id}/versions/{version_number}",
|
||||
get(get_version),
|
||||
)
|
||||
.route(
|
||||
"/api/proposals/{proposal_id}/versions/compare",
|
||||
post(compare_versions),
|
||||
)
|
||||
// Lifecycle
|
||||
.route("/api/proposals/{proposal_id}/lifecycle", get(get_lifecycle_summary))
|
||||
.route("/api/proposals/{proposal_id}/lifecycle/transition", post(transition_status))
|
||||
.route("/api/proposals/{proposal_id}/lifecycle/fork", post(fork_proposal))
|
||||
.route(
|
||||
"/api/proposals/{proposal_id}/lifecycle",
|
||||
get(get_lifecycle_summary),
|
||||
)
|
||||
.route(
|
||||
"/api/proposals/{proposal_id}/lifecycle/transition",
|
||||
post(transition_status),
|
||||
)
|
||||
.route(
|
||||
"/api/proposals/{proposal_id}/lifecycle/fork",
|
||||
post(fork_proposal),
|
||||
)
|
||||
.route("/api/proposals/{proposal_id}/forks", get(get_forks))
|
||||
// Amendments
|
||||
.route("/api/proposals/{proposal_id}/amendments", get(get_amendments).post(propose_amendment))
|
||||
.route(
|
||||
"/api/proposals/{proposal_id}/amendments",
|
||||
get(get_amendments).post(propose_amendment),
|
||||
)
|
||||
.route("/api/amendments/{amendment_id}/vote", post(vote_amendment))
|
||||
.route("/api/amendments/{amendment_id}/accept", post(accept_amendment))
|
||||
.route(
|
||||
"/api/amendments/{amendment_id}/accept",
|
||||
post(accept_amendment),
|
||||
)
|
||||
.with_state(pool)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,14 +154,12 @@ 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| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
let entry = LedgerService::get_entry(&pool, id).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": e.to_string()})),
|
||||
)
|
||||
})?;
|
||||
|
||||
match entry {
|
||||
Some(e) => {
|
||||
|
|
@ -367,9 +368,8 @@ async fn create_entry(
|
|||
|
||||
let actor_role = get_actor_role(&pool, auth.user_id, req.community_id).await?;
|
||||
|
||||
let action_type = parse_action_type(&req.action_type).map_err(|e| {
|
||||
(StatusCode::BAD_REQUEST, Json(json!({"error": e})))
|
||||
})?;
|
||||
let action_type = parse_action_type(&req.action_type)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({"error": e}))))?;
|
||||
|
||||
let entry_id = LedgerService::create_entry(
|
||||
&pool,
|
||||
|
|
|
|||
|
|
@ -49,15 +49,18 @@ async fn list_notifications(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let result = notifications.into_iter().map(|n| Notification {
|
||||
id: n.id,
|
||||
notification_type: n.notification_type,
|
||||
title: n.title,
|
||||
message: n.message,
|
||||
link: n.link,
|
||||
is_read: n.is_read,
|
||||
created_at: n.created_at,
|
||||
}).collect();
|
||||
let result = notifications
|
||||
.into_iter()
|
||||
.map(|n| Notification {
|
||||
id: n.id,
|
||||
notification_type: n.notification_type,
|
||||
title: n.title,
|
||||
message: n.message,
|
||||
link: n.link,
|
||||
is_read: n.is_read,
|
||||
created_at: n.created_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -100,13 +101,13 @@ pub mod perms {
|
|||
pub const PLATFORM_ADMIN: &str = "platform.admin";
|
||||
pub const PLATFORM_SETTINGS: &str = "platform.settings";
|
||||
pub const PLATFORM_PLUGINS: &str = "plugins.configure";
|
||||
|
||||
|
||||
// Community-level
|
||||
pub const COMMUNITY_CREATE: &str = "community.create";
|
||||
pub const COMMUNITY_ADMIN: &str = "community.settings";
|
||||
pub const COMMUNITY_SETTINGS: &str = "community.settings";
|
||||
pub const COMMUNITY_MODERATE: &str = "moderation.users.warn";
|
||||
|
||||
|
||||
// Proposals
|
||||
pub const PROPOSAL_CREATE: &str = "proposals.create";
|
||||
pub const PROPOSAL_EDIT_OWN: &str = "proposals.edit.own";
|
||||
|
|
@ -114,17 +115,17 @@ pub mod perms {
|
|||
pub const PROPOSAL_DELETE_OWN: &str = "proposals.delete.own";
|
||||
pub const PROPOSAL_DELETE_ANY: &str = "proposals.delete.any";
|
||||
pub const PROPOSAL_MANAGE_STATUS: &str = "proposals.moderate";
|
||||
|
||||
|
||||
// Voting
|
||||
pub const VOTE_CAST: &str = "voting.vote";
|
||||
pub const VOTE_VIEW_RESULTS: &str = "voting.results.view";
|
||||
pub const VOTING_CONFIG: &str = "voting.configure";
|
||||
|
||||
|
||||
// Moderation
|
||||
pub const MOD_BAN_USERS: &str = "platform.users.ban";
|
||||
pub const MOD_REMOVE_CONTENT: &str = "moderation.comments.delete";
|
||||
pub const MOD_VIEW_REPORTS: &str = "moderation.log.view";
|
||||
|
||||
|
||||
// Users
|
||||
pub const USER_MANAGE: &str = "platform.users.manage";
|
||||
pub const USER_INVITE: &str = "community.members.invite";
|
||||
|
|
|
|||
|
|
@ -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,17 +81,20 @@ async fn list_all_proposals(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let result = proposals.into_iter().map(|p| ProposalWithCommunity {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
community_name: p.community_name,
|
||||
community_slug: p.community_slug,
|
||||
vote_count: p.vote_count.unwrap_or(0),
|
||||
comment_count: p.comment_count.unwrap_or(0),
|
||||
created_at: p.created_at,
|
||||
}).collect();
|
||||
let result = proposals
|
||||
.into_iter()
|
||||
.map(|p| ProposalWithCommunity {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
community_name: p.community_name,
|
||||
community_slug: p.community_slug,
|
||||
vote_count: p.vote_count.unwrap_or(0),
|
||||
comment_count: p.comment_count.unwrap_or(0),
|
||||
created_at: p.created_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
|
@ -102,17 +120,20 @@ async fn my_proposals(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let result = proposals.into_iter().map(|p| ProposalWithCommunity {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
community_name: p.community_name,
|
||||
community_slug: p.community_slug,
|
||||
vote_count: p.vote_count.unwrap_or(0),
|
||||
comment_count: p.comment_count.unwrap_or(0),
|
||||
created_at: p.created_at,
|
||||
}).collect();
|
||||
let result = proposals
|
||||
.into_iter()
|
||||
.map(|p| ProposalWithCommunity {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
community_name: p.community_name,
|
||||
community_slug: p.community_slug,
|
||||
vote_count: p.vote_count.unwrap_or(0),
|
||||
comment_count: p.comment_count.unwrap_or(0),
|
||||
created_at: p.created_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
|
@ -147,10 +168,16 @@ async fn create_proposal(
|
|||
Extension(plugins): Extension<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,10 +317,13 @@ async fn get_proposal(
|
|||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
||||
|
||||
let author = sqlx::query_scalar!("SELECT username FROM users WHERE id = $1", proposal.author_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let author = sqlx::query_scalar!(
|
||||
"SELECT username FROM users WHERE id = $1",
|
||||
proposal.author_id
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let options = sqlx::query!(
|
||||
r#"SELECT o.id, o.label, o.description, COUNT(v.id) as vote_count
|
||||
|
|
@ -363,7 +398,7 @@ async fn cast_vote(
|
|||
Extension(plugins): Extension<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!({
|
||||
"proposal_id": proposal_id.to_string(),
|
||||
"voter_id": auth.user_id.to_string(),
|
||||
})).await;
|
||||
let _ = plugins
|
||||
.do_action(
|
||||
"vote.cast",
|
||||
ctx,
|
||||
serde_json::json!({
|
||||
"proposal_id": proposal_id.to_string(),
|
||||
"voter_id": auth.user_id.to_string(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(serde_json::json!({"status": "voted"})))
|
||||
}
|
||||
|
|
@ -483,11 +536,17 @@ async fn cast_ranked_vote(
|
|||
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
||||
|
||||
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
|
||||
return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string()));
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Proposal is not in voting phase".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if proposal.voting_method != "ranked_choice" {
|
||||
return Err((StatusCode::BAD_REQUEST, "This proposal uses a different voting method".to_string()));
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"This proposal uses a different voting method".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let voting_identity = sqlx::query_scalar!(
|
||||
|
|
@ -497,15 +556,24 @@ async fn cast_ranked_vote(
|
|||
RETURNING id"#,
|
||||
auth.user_id,
|
||||
proposal.community_id,
|
||||
format!("voter-{}", uuid::Uuid::new_v4().to_string()[..8].to_string())
|
||||
format!(
|
||||
"voter-{}",
|
||||
uuid::Uuid::new_v4().to_string()[..8].to_string()
|
||||
)
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Clear existing ranked votes
|
||||
sqlx::query!("DELETE FROM ranked_votes WHERE proposal_id = $1 AND voter_id = $2", proposal_id, voting_identity)
|
||||
.execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM ranked_votes WHERE proposal_id = $1 AND voter_id = $2",
|
||||
proposal_id,
|
||||
voting_identity
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Insert ranked votes
|
||||
for ranking in req.rankings {
|
||||
|
|
@ -515,7 +583,9 @@ async fn cast_ranked_vote(
|
|||
).execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"status": "voted", "method": "ranked_choice"})))
|
||||
Ok(Json(
|
||||
serde_json::json!({"status": "voted", "method": "ranked_choice"}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn cast_quadratic_vote(
|
||||
|
|
@ -524,7 +594,7 @@ async fn cast_quadratic_vote(
|
|||
State(pool): State<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!(
|
||||
"Total cost {} exceeds {} credits. Max votes on single option: {}",
|
||||
total_cost, total_credits, max_single
|
||||
)));
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!(
|
||||
"Total cost {} exceeds {} credits. Max votes on single option: {}",
|
||||
total_cost, total_credits, max_single
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let voting_identity = sqlx::query_scalar!(
|
||||
|
|
@ -561,15 +640,24 @@ async fn cast_quadratic_vote(
|
|||
RETURNING id"#,
|
||||
auth.user_id,
|
||||
proposal.community_id,
|
||||
format!("voter-{}", uuid::Uuid::new_v4().to_string()[..8].to_string())
|
||||
format!(
|
||||
"voter-{}",
|
||||
uuid::Uuid::new_v4().to_string()[..8].to_string()
|
||||
)
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Clear existing quadratic votes
|
||||
sqlx::query!("DELETE FROM quadratic_votes WHERE proposal_id = $1 AND voter_id = $2", proposal_id, voting_identity)
|
||||
.execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM quadratic_votes WHERE proposal_id = $1 AND voter_id = $2",
|
||||
proposal_id,
|
||||
voting_identity
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Insert quadratic votes
|
||||
for alloc in req.allocations {
|
||||
|
|
@ -581,7 +669,9 @@ async fn cast_quadratic_vote(
|
|||
}
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"status": "voted", "method": "quadratic", "credits_used": total_cost})))
|
||||
Ok(Json(
|
||||
serde_json::json!({"status": "voted", "method": "quadratic", "credits_used": total_cost}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn cast_star_vote(
|
||||
|
|
@ -600,17 +690,26 @@ async fn cast_star_vote(
|
|||
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
||||
|
||||
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
|
||||
return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string()));
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Proposal is not in voting phase".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if proposal.voting_method != "star" {
|
||||
return Err((StatusCode::BAD_REQUEST, "This proposal uses a different voting method".to_string()));
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"This proposal uses a different voting method".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate star ratings (0-5)
|
||||
for rating in &req.ratings {
|
||||
if rating.stars < 0 || rating.stars > 5 {
|
||||
return Err((StatusCode::BAD_REQUEST, "Star ratings must be between 0 and 5".to_string()));
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Star ratings must be between 0 and 5".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -621,15 +720,24 @@ async fn cast_star_vote(
|
|||
RETURNING id"#,
|
||||
auth.user_id,
|
||||
proposal.community_id,
|
||||
format!("voter-{}", uuid::Uuid::new_v4().to_string()[..8].to_string())
|
||||
format!(
|
||||
"voter-{}",
|
||||
uuid::Uuid::new_v4().to_string()[..8].to_string()
|
||||
)
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Clear existing star votes
|
||||
sqlx::query!("DELETE FROM star_votes WHERE proposal_id = $1 AND voter_id = $2", proposal_id, voting_identity)
|
||||
.execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM star_votes WHERE proposal_id = $1 AND voter_id = $2",
|
||||
proposal_id,
|
||||
voting_identity
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Insert star votes
|
||||
for rating in req.ratings {
|
||||
|
|
@ -639,7 +747,9 @@ async fn cast_star_vote(
|
|||
).execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"status": "voted", "method": "star"})))
|
||||
Ok(Json(
|
||||
serde_json::json!({"status": "voted", "method": "star"}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn start_discussion(
|
||||
|
|
@ -663,7 +773,10 @@ async fn start_discussion(
|
|||
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
||||
|
||||
if proposal.author_id != auth.user_id {
|
||||
return Err((StatusCode::FORBIDDEN, "Only the author can start discussion".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Only the author can start discussion".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let updated = sqlx::query_as!(
|
||||
|
|
@ -706,7 +819,10 @@ async fn start_voting(
|
|||
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
||||
|
||||
if proposal.author_id != auth.user_id {
|
||||
return Err((StatusCode::FORBIDDEN, "Only the author can start voting".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Only the author can start voting".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let updated = sqlx::query_as!(
|
||||
|
|
@ -733,7 +849,7 @@ async fn close_voting(
|
|||
Path(proposal_id): Path<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,25 +928,49 @@ async fn delete_proposal(
|
|||
|
||||
// Check if user can delete: author needs delete_own, others need delete_any
|
||||
let is_author = proposal.author_id == auth.user_id;
|
||||
let can_delete_own = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_DELETE_OWN, Some(proposal.community_id)).await?;
|
||||
let can_delete_any = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_DELETE_ANY, Some(proposal.community_id)).await?;
|
||||
|
||||
let can_delete_own = user_has_permission(
|
||||
&pool,
|
||||
auth.user_id,
|
||||
perms::PROPOSAL_DELETE_OWN,
|
||||
Some(proposal.community_id),
|
||||
)
|
||||
.await?;
|
||||
let can_delete_any = user_has_permission(
|
||||
&pool,
|
||||
auth.user_id,
|
||||
perms::PROPOSAL_DELETE_ANY,
|
||||
Some(proposal.community_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if is_author && !can_delete_own {
|
||||
return Err((StatusCode::FORBIDDEN, "You don't have permission to delete proposals".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"You don't have permission to delete proposals".to_string(),
|
||||
));
|
||||
}
|
||||
if !is_author && !can_delete_any {
|
||||
return Err((StatusCode::FORBIDDEN, "Only the author or admins can delete this proposal".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Only the author or admins can delete this proposal".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !matches!(proposal.status, crate::models::ProposalStatus::Draft) {
|
||||
return Err((StatusCode::BAD_REQUEST, "Only draft proposals can be deleted".to_string()));
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Only draft proposals can be deleted".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Delete related data first
|
||||
sqlx::query!("DELETE FROM proposal_options WHERE proposal_id = $1", proposal_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM proposal_options WHERE proposal_id = $1",
|
||||
proposal_id
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
sqlx::query!("DELETE FROM comments WHERE proposal_id = $1", proposal_id)
|
||||
.execute(&pool)
|
||||
|
|
@ -846,7 +998,7 @@ async fn update_proposal(
|
|||
State(pool): State<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",
|
||||
|
|
@ -947,10 +1129,12 @@ async fn get_voting_results(
|
|||
.collect();
|
||||
|
||||
let voting_method = proposal.voting_method.as_str();
|
||||
|
||||
|
||||
let (results, total_votes, details) = match voting_method {
|
||||
"approval" => calculate_approval_results(&pool, proposal_id, &options).await?,
|
||||
"ranked_choice" | "schulze" => calculate_ranked_results(&pool, proposal_id, &options, voting_method).await?,
|
||||
"ranked_choice" | "schulze" => {
|
||||
calculate_ranked_results(&pool, proposal_id, &options, voting_method).await?
|
||||
}
|
||||
"star" => calculate_star_results(&pool, proposal_id, &options).await?,
|
||||
"quadratic" => calculate_quadratic_results(&pool, proposal_id, &options).await?,
|
||||
_ => calculate_approval_results(&pool, proposal_id, &options).await?,
|
||||
|
|
@ -992,30 +1176,40 @@ async fn calculate_approval_results(
|
|||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut results: Vec<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 };
|
||||
|
||||
OptionResult {
|
||||
option_id: *opt_id,
|
||||
label: label.clone(),
|
||||
votes,
|
||||
percentage,
|
||||
rank: 0,
|
||||
}
|
||||
}).collect();
|
||||
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
|
||||
};
|
||||
|
||||
OptionResult {
|
||||
option_id: *opt_id,
|
||||
label: label.clone(),
|
||||
votes,
|
||||
percentage,
|
||||
rank: 0,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.sort_by(|a, b| b.votes.cmp(&a.votes));
|
||||
for (i, r) in results.iter_mut().enumerate() {
|
||||
r.rank = (i + 1) as i32;
|
||||
}
|
||||
|
||||
Ok((results, total_voters, serde_json::json!({"method": "approval"})))
|
||||
Ok((
|
||||
results,
|
||||
total_voters,
|
||||
serde_json::json!({"method": "approval"}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn calculate_ranked_results(
|
||||
|
|
@ -1024,7 +1218,7 @@ async fn calculate_ranked_results(
|
|||
options: &[(Uuid, String)],
|
||||
method: &str,
|
||||
) -> Result<(Vec<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
|
||||
|
|
@ -1036,48 +1230,72 @@ async fn calculate_ranked_results(
|
|||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
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 mut sorted = rankings.clone();
|
||||
sorted.sort_by_key(|(_, rank)| *rank);
|
||||
ranked_choice::RankedBallot {
|
||||
rankings: sorted.iter().map(|(id, _)| *id).collect()
|
||||
}
|
||||
}).collect();
|
||||
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(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
ranked_choice::calculate(&option_ids, &ballots)
|
||||
};
|
||||
|
||||
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();
|
||||
OptionResult {
|
||||
option_id: r.option_id,
|
||||
label,
|
||||
votes: r.score as i64,
|
||||
percentage: if total_voters > 0 { (r.score / total_voters as f64) * 100.0 } else { 0.0 },
|
||||
rank: r.rank as i32,
|
||||
}
|
||||
}).collect();
|
||||
let results: Vec<OptionResult> = result
|
||||
.ranking
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let label = options
|
||||
.iter()
|
||||
.find(|(id, _)| *id == r.option_id)
|
||||
.map(|(_, l)| l.clone())
|
||||
.unwrap_or_default();
|
||||
OptionResult {
|
||||
option_id: r.option_id,
|
||||
label,
|
||||
votes: r.score as i64,
|
||||
percentage: if total_voters > 0 {
|
||||
(r.score / total_voters as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
rank: r.rank as i32,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok((results, total_voters, serde_json::to_value(&result.details).unwrap_or_default()))
|
||||
Ok((
|
||||
results,
|
||||
total_voters,
|
||||
serde_json::to_value(&result.details).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn calculate_star_results(
|
||||
|
|
@ -1098,35 +1316,49 @@ async fn calculate_star_results(
|
|||
let option_ids: Vec<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()
|
||||
.find(|(id, _)| *id == r.option_id)
|
||||
.map(|(_, l)| l.clone())
|
||||
.unwrap_or_default();
|
||||
OptionResult {
|
||||
option_id: r.option_id,
|
||||
label,
|
||||
votes: r.score as i64,
|
||||
percentage: 0.0,
|
||||
rank: r.rank as i32,
|
||||
}
|
||||
}).collect();
|
||||
let results: Vec<OptionResult> = result
|
||||
.ranking
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let label = options
|
||||
.iter()
|
||||
.find(|(id, _)| *id == r.option_id)
|
||||
.map(|(_, l)| l.clone())
|
||||
.unwrap_or_default();
|
||||
OptionResult {
|
||||
option_id: r.option_id,
|
||||
label,
|
||||
votes: r.score as i64,
|
||||
percentage: 0.0,
|
||||
rank: r.rank as i32,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok((results, total_voters, serde_json::to_value(&result.details).unwrap_or_default()))
|
||||
Ok((
|
||||
results,
|
||||
total_voters,
|
||||
serde_json::to_value(&result.details).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn calculate_quadratic_results(
|
||||
|
|
@ -1147,35 +1379,55 @@ async fn calculate_quadratic_results(
|
|||
let option_ids: Vec<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| {
|
||||
quadratic::QuadraticBallot {
|
||||
total_credits: 100, // Standard credit allocation
|
||||
allocations: allocs.clone(),
|
||||
}
|
||||
}).collect();
|
||||
let ballots: Vec<quadratic::QuadraticBallot> = voter_allocations
|
||||
.values()
|
||||
.map(|allocs| {
|
||||
quadratic::QuadraticBallot {
|
||||
total_credits: 100, // Standard credit allocation
|
||||
allocations: allocs.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total_voters = ballots.len() as i64;
|
||||
let result = quadratic::calculate(&option_ids, &ballots);
|
||||
|
||||
let results: Vec<OptionResult> = result.ranking.iter().map(|r| {
|
||||
let label = options.iter()
|
||||
.find(|(id, _)| *id == r.option_id)
|
||||
.map(|(_, l)| l.clone())
|
||||
.unwrap_or_default();
|
||||
OptionResult {
|
||||
option_id: r.option_id,
|
||||
label,
|
||||
votes: r.score as i64,
|
||||
percentage: if total_voters > 0 { (r.score / total_voters as f64) * 100.0 } else { 0.0 },
|
||||
rank: r.rank as i32,
|
||||
}
|
||||
}).collect();
|
||||
let results: Vec<OptionResult> = result
|
||||
.ranking
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let label = options
|
||||
.iter()
|
||||
.find(|(id, _)| *id == r.option_id)
|
||||
.map(|(_, l)| l.clone())
|
||||
.unwrap_or_default();
|
||||
OptionResult {
|
||||
option_id: r.option_id,
|
||||
label,
|
||||
votes: r.score as i64,
|
||||
percentage: if total_voters > 0 {
|
||||
(r.score / total_voters as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
rank: r.rank as i32,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok((results, total_voters, serde_json::to_value(&result.details).unwrap_or_default()))
|
||||
Ok((
|
||||
results,
|
||||
total_voters,
|
||||
serde_json::to_value(&result.details).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@
|
|||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post, delete},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
|
||||
|
|
@ -117,13 +117,18 @@ async fn list_permissions(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(perms.into_iter().map(|p| Permission {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
category: p.category,
|
||||
description: p.description,
|
||||
is_system: p.is_system,
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
perms
|
||||
.into_iter()
|
||||
.map(|p| Permission {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
category: p.category,
|
||||
description: p.description,
|
||||
is_system: p.is_system,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -149,18 +154,23 @@ async fn list_platform_roles(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(roles.into_iter().map(|r| Role {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
display_name: r.display_name,
|
||||
description: r.description,
|
||||
color: r.color,
|
||||
community_id: None,
|
||||
is_system: r.is_system,
|
||||
is_default: r.is_default,
|
||||
priority: r.priority,
|
||||
permissions: r.permissions.unwrap_or_default(),
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
roles
|
||||
.into_iter()
|
||||
.map(|r| Role {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
display_name: r.display_name,
|
||||
description: r.description,
|
||||
color: r.color,
|
||||
community_id: None,
|
||||
is_system: r.is_system,
|
||||
is_default: r.is_default,
|
||||
priority: r.priority,
|
||||
permissions: r.permissions.unwrap_or_default(),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -188,18 +198,23 @@ async fn list_community_roles(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(roles.into_iter().map(|r| Role {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
display_name: r.display_name,
|
||||
description: r.description,
|
||||
color: r.color,
|
||||
community_id: Some(community_id),
|
||||
is_system: r.is_system,
|
||||
is_default: r.is_default,
|
||||
priority: r.priority,
|
||||
permissions: r.permissions.unwrap_or_default(),
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
roles
|
||||
.into_iter()
|
||||
.map(|r| Role {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
display_name: r.display_name,
|
||||
description: r.description,
|
||||
color: r.color,
|
||||
community_id: Some(community_id),
|
||||
is_system: r.is_system,
|
||||
is_default: r.is_default,
|
||||
priority: r.priority,
|
||||
permissions: r.permissions.unwrap_or_default(),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Create a community role
|
||||
|
|
@ -221,7 +236,10 @@ async fn create_community_role(
|
|||
.unwrap_or(false);
|
||||
|
||||
if !has_perm {
|
||||
return Err((StatusCode::FORBIDDEN, "No permission to manage roles".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"No permission to manage roles".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Create role
|
||||
|
|
@ -288,7 +306,10 @@ async fn assign_role(
|
|||
.unwrap_or(false);
|
||||
|
||||
if !has_perm {
|
||||
return Err((StatusCode::FORBIDDEN, "No permission to manage roles".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"No permission to manage roles".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Verify role belongs to community
|
||||
|
|
@ -339,7 +360,10 @@ async fn remove_role(
|
|||
.unwrap_or(false);
|
||||
|
||||
if !has_perm {
|
||||
return Err((StatusCode::FORBIDDEN, "No permission to manage roles".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"No permission to manage roles".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
|
|
@ -375,12 +399,17 @@ async fn get_user_roles(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(roles.into_iter().map(|r| RoleSummary {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
display_name: r.display_name,
|
||||
color: r.color,
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
roles
|
||||
.into_iter()
|
||||
.map(|r| RoleSummary {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
display_name: r.display_name,
|
||||
color: r.color,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Check if current user has a specific permission
|
||||
|
|
@ -417,10 +446,25 @@ pub fn router(pool: PgPool) -> Router {
|
|||
// Platform roles
|
||||
.route("/api/roles", get(list_platform_roles))
|
||||
// Community roles
|
||||
.route("/api/communities/{community_id}/roles", get(list_community_roles).post(create_community_role))
|
||||
.route("/api/communities/{community_id}/roles/{role_id}/assign", post(assign_role))
|
||||
.route("/api/communities/{community_id}/roles/{role_id}/users/{user_id}", delete(remove_role))
|
||||
.route("/api/communities/{community_id}/users/{user_id}/roles", get(get_user_roles))
|
||||
.route("/api/communities/{community_id}/permissions/{permission_name}/check", get(check_permission))
|
||||
.route(
|
||||
"/api/communities/{community_id}/roles",
|
||||
get(list_community_roles).post(create_community_role),
|
||||
)
|
||||
.route(
|
||||
"/api/communities/{community_id}/roles/{role_id}/assign",
|
||||
post(assign_role),
|
||||
)
|
||||
.route(
|
||||
"/api/communities/{community_id}/roles/{role_id}/users/{user_id}",
|
||||
delete(remove_role),
|
||||
)
|
||||
.route(
|
||||
"/api/communities/{community_id}/users/{user_id}/roles",
|
||||
get(get_user_roles),
|
||||
)
|
||||
.route(
|
||||
"/api/communities/{community_id}/permissions/{permission_name}/check",
|
||||
get(check_permission),
|
||||
)
|
||||
.with_state(pool)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,12 +99,10 @@ 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"
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let row = sqlx::query!("SELECT setup_completed, instance_name FROM instance_settings LIMIT 1")
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Json(SetupStatus {
|
||||
|
|
@ -120,7 +117,9 @@ async fn get_setup_status(State(pool): State<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 {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
role: c.role,
|
||||
}).collect(),
|
||||
communities: communities
|
||||
.into_iter()
|
||||
.map(|c| CommunityMembership {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
role: c.role,
|
||||
})
|
||||
.collect(),
|
||||
proposal_count,
|
||||
comment_count,
|
||||
}))
|
||||
|
|
@ -176,13 +177,16 @@ async fn get_user_votes(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let result = votes.into_iter().map(|v| UserVote {
|
||||
proposal_id: v.proposal_id,
|
||||
proposal_title: v.proposal_title,
|
||||
community_name: v.community_name,
|
||||
option_label: v.option_label,
|
||||
voted_at: v.voted_at,
|
||||
}).collect();
|
||||
let result = votes
|
||||
.into_iter()
|
||||
.map(|v| UserVote {
|
||||
proposal_id: v.proposal_id,
|
||||
proposal_title: v.proposal_title,
|
||||
community_name: v.community_name,
|
||||
option_label: v.option_label,
|
||||
voted_at: v.voted_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ use axum::{
|
|||
routing::{get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
#[allow(unused_imports)]
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::permissions::{perms, require_permission};
|
||||
use crate::auth::AuthUser;
|
||||
use super::permissions::{require_permission, perms};
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
|
|
@ -100,19 +100,24 @@ async fn list_voting_methods(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(methods.into_iter().map(|m| VotingMethodPlugin {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
display_name: m.display_name,
|
||||
description: m.description,
|
||||
icon: m.icon,
|
||||
is_active: m.is_active,
|
||||
is_default: m.is_default,
|
||||
config_schema: m.config_schema,
|
||||
default_config: m.default_config.unwrap_or_default(),
|
||||
complexity_level: m.complexity_level.unwrap_or_default(),
|
||||
supports_delegation: m.supports_delegation,
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
methods
|
||||
.into_iter()
|
||||
.map(|m| VotingMethodPlugin {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
display_name: m.display_name,
|
||||
description: m.description,
|
||||
icon: m.icon,
|
||||
is_active: m.is_active,
|
||||
is_default: m.is_default,
|
||||
config_schema: m.config_schema,
|
||||
default_config: m.default_config.unwrap_or_default(),
|
||||
complexity_level: m.complexity_level.unwrap_or_default(),
|
||||
supports_delegation: m.supports_delegation,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Update platform voting method (admin only)
|
||||
|
|
@ -191,25 +196,30 @@ async fn list_community_voting_methods(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(methods.into_iter().map(|m| CommunityVotingMethod {
|
||||
id: m.id,
|
||||
voting_method: VotingMethodPlugin {
|
||||
id: m.id,
|
||||
name: m.name.clone(),
|
||||
display_name: m.display_name.clone(),
|
||||
description: m.description.clone(),
|
||||
icon: m.icon.clone(),
|
||||
is_active: m.platform_active,
|
||||
is_default: m.is_default.unwrap_or(false),
|
||||
config_schema: m.config_schema.clone(),
|
||||
default_config: m.default_config.clone().unwrap_or_default(),
|
||||
complexity_level: m.complexity_level.clone().unwrap_or_default(),
|
||||
supports_delegation: m.supports_delegation,
|
||||
},
|
||||
is_enabled: m.is_enabled.unwrap_or(false),
|
||||
is_default: m.is_default.unwrap_or(false),
|
||||
config: m.config.unwrap_or_default(),
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
methods
|
||||
.into_iter()
|
||||
.map(|m| CommunityVotingMethod {
|
||||
id: m.id,
|
||||
voting_method: VotingMethodPlugin {
|
||||
id: m.id,
|
||||
name: m.name.clone(),
|
||||
display_name: m.display_name.clone(),
|
||||
description: m.description.clone(),
|
||||
icon: m.icon.clone(),
|
||||
is_active: m.platform_active,
|
||||
is_default: m.is_default.unwrap_or(false),
|
||||
config_schema: m.config_schema.clone(),
|
||||
default_config: m.default_config.clone().unwrap_or_default(),
|
||||
complexity_level: m.complexity_level.clone().unwrap_or_default(),
|
||||
supports_delegation: m.supports_delegation,
|
||||
},
|
||||
is_enabled: m.is_enabled.unwrap_or(false),
|
||||
is_default: m.is_default.unwrap_or(false),
|
||||
config: m.config.unwrap_or_default(),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Configure voting method for a community
|
||||
|
|
@ -231,7 +241,10 @@ async fn configure_community_voting_method(
|
|||
.unwrap_or(false);
|
||||
|
||||
if !has_perm {
|
||||
return Err((StatusCode::FORBIDDEN, "No permission to manage voting methods".to_string()));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"No permission to manage voting methods".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// If setting as default, unset other defaults first
|
||||
|
|
@ -313,16 +326,21 @@ async fn list_default_plugins(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(plugins.into_iter().map(|p| DefaultPlugin {
|
||||
plugin_name: p.plugin_name,
|
||||
plugin_type: p.plugin_type,
|
||||
display_name: p.display_name,
|
||||
description: p.description,
|
||||
is_core: p.is_core,
|
||||
is_recommended: p.is_recommended,
|
||||
default_enabled: p.default_enabled,
|
||||
category: p.category,
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
plugins
|
||||
.into_iter()
|
||||
.map(|p| DefaultPlugin {
|
||||
plugin_name: p.plugin_name,
|
||||
plugin_type: p.plugin_type,
|
||||
display_name: p.display_name,
|
||||
description: p.description,
|
||||
is_core: p.is_core,
|
||||
is_recommended: p.is_recommended,
|
||||
default_enabled: p.default_enabled,
|
||||
category: p.category,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// List instance plugins
|
||||
|
|
@ -336,11 +354,16 @@ async fn list_instance_plugins(
|
|||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(plugins.into_iter().map(|p| InstancePlugin {
|
||||
plugin_name: p.plugin_name,
|
||||
is_enabled: p.is_enabled,
|
||||
config: p.config.unwrap_or_default(),
|
||||
}).collect()))
|
||||
Ok(Json(
|
||||
plugins
|
||||
.into_iter()
|
||||
.map(|p| InstancePlugin {
|
||||
plugin_name: p.plugin_name,
|
||||
is_enabled: p.is_enabled,
|
||||
config: p.config.unwrap_or_default(),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Update instance plugin
|
||||
|
|
@ -364,7 +387,10 @@ async fn update_instance_plugin(
|
|||
.unwrap_or(false);
|
||||
|
||||
if is_core && req.is_enabled == Some(false) {
|
||||
return Err((StatusCode::BAD_REQUEST, "Cannot disable core plugins".to_string()));
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Cannot disable core plugins".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let plugin = sqlx::query!(
|
||||
|
|
@ -401,17 +427,16 @@ async fn initialize_default_plugins(
|
|||
require_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?;
|
||||
|
||||
// Get all default plugins
|
||||
let defaults = sqlx::query!(
|
||||
"SELECT plugin_name, is_core, default_enabled FROM default_plugins"
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let defaults =
|
||||
sqlx::query!("SELECT plugin_name, is_core, default_enabled FROM default_plugins")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Initialize each plugin
|
||||
for plugin in defaults {
|
||||
let is_enabled = plugin.is_core || enabled_plugins.contains(&plugin.plugin_name);
|
||||
|
||||
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO instance_plugins (plugin_name, is_enabled, enabled_by, enabled_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
|
|
@ -441,12 +466,21 @@ pub fn router(pool: PgPool) -> Router {
|
|||
.route("/api/voting-methods", get(list_voting_methods))
|
||||
.route("/api/voting-methods/{method_id}", put(update_voting_method))
|
||||
// Community voting methods
|
||||
.route("/api/communities/{community_id}/voting-methods", get(list_community_voting_methods))
|
||||
.route("/api/communities/{community_id}/voting-methods/{method_id}", put(configure_community_voting_method))
|
||||
.route(
|
||||
"/api/communities/{community_id}/voting-methods",
|
||||
get(list_community_voting_methods),
|
||||
)
|
||||
.route(
|
||||
"/api/communities/{community_id}/voting-methods/{method_id}",
|
||||
put(configure_community_voting_method),
|
||||
)
|
||||
// Default plugins
|
||||
.route("/api/plugins/defaults", get(list_default_plugins))
|
||||
.route("/api/plugins/instance", get(list_instance_plugins))
|
||||
.route("/api/plugins/instance/{plugin_name}", put(update_instance_plugin))
|
||||
.route(
|
||||
"/api/plugins/instance/{plugin_name}",
|
||||
put(update_instance_plugin),
|
||||
)
|
||||
.route("/api/plugins/initialize", post(initialize_default_plugins))
|
||||
.with_state(pool)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Demo mode functionality for Likwid
|
||||
//!
|
||||
//!
|
||||
//! When DEMO_MODE=true, the system:
|
||||
//! - Restricts certain destructive actions (delete community, change instance settings)
|
||||
//! - Allows demo accounts to log in with known passwords
|
||||
|
|
@ -293,9 +293,7 @@ pub async fn reset_demo_data(pool: &PgPool) -> Result<(), sqlx::Error> {
|
|||
.await?;
|
||||
|
||||
// Votes and delegation artifacts
|
||||
sqlx::query("DELETE FROM votes")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM votes").execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM delegated_votes")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
|
@ -403,7 +401,7 @@ pub async fn reset_demo_data(pool: &PgPool) -> Result<(), sqlx::Error> {
|
|||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
|
||||
tracing::info!("Demo data reset complete");
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ mod plugins;
|
|||
mod rate_limit;
|
||||
mod voting;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use axum::{middleware, Extension};
|
||||
use axum::http::{HeaderName, HeaderValue};
|
||||
use axum::response::Response;
|
||||
use axum::{middleware, Extension};
|
||||
use chrono::{Datelike, Timelike, Utc, Weekday};
|
||||
use serde_json::json;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
|
@ -65,7 +65,8 @@ async fn run() -> Result<(), StartupError> {
|
|||
tracing::info!("🎭 DEMO MODE ENABLED - Some actions are restricted");
|
||||
}
|
||||
|
||||
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| config.database_url.clone());
|
||||
let database_url =
|
||||
std::env::var("DATABASE_URL").unwrap_or_else(|_| config.database_url.clone());
|
||||
|
||||
let pool = db::create_pool(&database_url).await?;
|
||||
|
||||
|
|
@ -121,7 +122,9 @@ async fn run() -> Result<(), StartupError> {
|
|||
};
|
||||
|
||||
let payload = json!({"ts": now.to_rfc3339()});
|
||||
cron_plugins.do_action("cron.minute", ctx.clone(), payload.clone()).await;
|
||||
cron_plugins
|
||||
.do_action("cron.minute", ctx.clone(), payload.clone())
|
||||
.await;
|
||||
cron_plugins
|
||||
.do_action("cron.minutely", ctx.clone(), payload.clone())
|
||||
.await;
|
||||
|
|
@ -166,18 +169,17 @@ async fn run() -> Result<(), StartupError> {
|
|||
}
|
||||
|
||||
// WASM plugins need per-community context.
|
||||
let community_ids: Vec<Uuid> = match sqlx::query_scalar(
|
||||
"SELECT id FROM communities WHERE is_active = true",
|
||||
)
|
||||
.fetch_all(&cron_pool)
|
||||
.await
|
||||
{
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
tracing::error!("cron: failed to list communities: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let community_ids: Vec<Uuid> =
|
||||
match sqlx::query_scalar("SELECT id FROM communities WHERE is_active = true")
|
||||
.fetch_all(&cron_pool)
|
||||
.await
|
||||
{
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
tracing::error!("cron: failed to list communities: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut wasm_hooks: Vec<&'static str> = vec!["cron.minute", "cron.minutely"];
|
||||
if min15_key == last_15min_key {
|
||||
|
|
@ -215,15 +217,20 @@ async fn run() -> Result<(), StartupError> {
|
|||
.layer(TraceLayer::new_for_http())
|
||||
.layer(middleware::map_response(add_security_headers));
|
||||
|
||||
let host: std::net::IpAddr = config.server_host.parse()
|
||||
let host: std::net::IpAddr = config
|
||||
.server_host
|
||||
.parse()
|
||||
.unwrap_or_else(|_| std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)));
|
||||
let addr = SocketAddr::from((host, config.server_port));
|
||||
tracing::info!("Likwid backend listening on http://{}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.await
|
||||
.map_err(|e| StartupError::Serve(e.to_string()))?;
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| StartupError::Serve(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,24 +46,33 @@ impl Plugin for ConflictResolutionPlugin {
|
|||
50,
|
||||
Arc::new(|ctx: HookContext, payload: Value| {
|
||||
Box::pin(async move {
|
||||
if let Some(conflict_id) = payload.get("conflict_id")
|
||||
if let Some(conflict_id) = payload
|
||||
.get("conflict_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| Uuid::parse_str(s).ok())
|
||||
{
|
||||
// Check if auto-assign is enabled
|
||||
if let Some(community_id) = ctx.community_id {
|
||||
let auto_assign = ConflictService::should_auto_assign(&ctx.pool, community_id).await?;
|
||||
let auto_assign =
|
||||
ConflictService::should_auto_assign(&ctx.pool, community_id)
|
||||
.await?;
|
||||
if auto_assign {
|
||||
ConflictService::assign_mediators(&ctx.pool, conflict_id, ctx.actor_user_id).await?;
|
||||
ConflictService::assign_mediators(
|
||||
&ctx.pool,
|
||||
conflict_id,
|
||||
ctx.actor_user_id,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ctx.emit_public_event(
|
||||
Some("conflict_resolution"),
|
||||
"conflict.reported",
|
||||
payload.clone(),
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
|
|
@ -82,7 +89,8 @@ impl Plugin for ConflictResolutionPlugin {
|
|||
Some("conflict_resolution"),
|
||||
"compromise.proposed",
|
||||
payload.clone(),
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
|
|
@ -99,7 +107,8 @@ impl Plugin for ConflictResolutionPlugin {
|
|||
Some("conflict_resolution"),
|
||||
"conflict.resolved",
|
||||
payload.clone(),
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
|
|
@ -135,7 +144,10 @@ pub struct ConflictService;
|
|||
|
||||
impl ConflictService {
|
||||
/// Check if auto-assign mediators is enabled
|
||||
pub async fn should_auto_assign(pool: &PgPool, community_id: Uuid) -> Result<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
|
||||
|
|
@ -148,7 +160,7 @@ impl ConflictService {
|
|||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +184,7 @@ impl ConflictService {
|
|||
party_a_id, party_b_id, reported_by, reported_anonymously,
|
||||
severity_level
|
||||
) VALUES ($1, $2, $3, $4::conflict_type, $5, $6, $7, $8, $9)
|
||||
RETURNING id"#
|
||||
RETURNING id"#,
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(title)
|
||||
|
|
@ -210,7 +222,7 @@ impl ConflictService {
|
|||
.bind(assigned_by)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +235,7 @@ impl ConflictService {
|
|||
notes: Option<&str>,
|
||||
) -> Result<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)
|
||||
|
|
@ -231,7 +243,7 @@ impl ConflictService {
|
|||
.bind(notes)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
Ok(success)
|
||||
}
|
||||
|
||||
|
|
@ -276,7 +288,7 @@ impl ConflictService {
|
|||
pub async fn respond_to_compromise(
|
||||
pool: &PgPool,
|
||||
proposal_id: Uuid,
|
||||
party: &str, // "a" or "b"
|
||||
party: &str, // "a" or "b"
|
||||
response: &str, // accept, reject, counter
|
||||
feedback: Option<&str>,
|
||||
) -> Result<(), PluginError> {
|
||||
|
|
@ -319,8 +331,8 @@ impl ConflictService {
|
|||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
if proposal.party_a_response.as_deref() == Some("accept")
|
||||
&& proposal.party_b_response.as_deref() == Some("accept")
|
||||
if proposal.party_a_response.as_deref() == Some("accept")
|
||||
&& proposal.party_b_response.as_deref() == Some("accept")
|
||||
{
|
||||
// Mark proposal as accepted
|
||||
sqlx::query!(
|
||||
|
|
@ -426,7 +438,10 @@ impl ConflictService {
|
|||
}
|
||||
|
||||
/// Get conflict details
|
||||
pub async fn get_conflict(pool: &PgPool, conflict_id: Uuid) -> Result<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,17 +59,31 @@ impl Plugin for DecisionWorkflowsPlugin {
|
|||
Arc::new(|ctx: HookContext, payload: Value| {
|
||||
Box::pin(async move {
|
||||
if let (Some(proposal_id), Some(community_id)) = (
|
||||
payload.get("proposal_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()),
|
||||
payload.get("community_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()),
|
||||
payload
|
||||
.get("proposal_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| Uuid::parse_str(s).ok()),
|
||||
payload
|
||||
.get("community_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| Uuid::parse_str(s).ok()),
|
||||
) {
|
||||
// Check if auto-workflow is enabled for this community
|
||||
let auto_start = WorkflowService::should_auto_start_workflow(&ctx.pool, community_id).await?;
|
||||
|
||||
let auto_start =
|
||||
WorkflowService::should_auto_start_workflow(&ctx.pool, community_id)
|
||||
.await?;
|
||||
|
||||
if auto_start {
|
||||
let template_id = WorkflowService::get_default_template(&ctx.pool, Some(community_id)).await?;
|
||||
let template_id = WorkflowService::get_default_template(
|
||||
&ctx.pool,
|
||||
Some(community_id),
|
||||
)
|
||||
.await?;
|
||||
if let Some(tid) = template_id {
|
||||
let instance_id = WorkflowService::start_workflow(&ctx.pool, proposal_id, tid).await?;
|
||||
|
||||
let instance_id =
|
||||
WorkflowService::start_workflow(&ctx.pool, proposal_id, tid)
|
||||
.await?;
|
||||
|
||||
ctx.emit_public_event(
|
||||
Some("decision_workflows"),
|
||||
"workflow.started",
|
||||
|
|
@ -80,7 +92,8 @@ impl Plugin for DecisionWorkflowsPlugin {
|
|||
"workflow_instance_id": instance_id,
|
||||
"template_id": tid
|
||||
}),
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -110,10 +123,19 @@ impl Plugin for DecisionWorkflowsPlugin {
|
|||
Arc::new(|ctx: HookContext, payload: Value| {
|
||||
Box::pin(async move {
|
||||
if let (Some(proposal_id), Some(user_id)) = (
|
||||
payload.get("proposal_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()),
|
||||
payload
|
||||
.get("proposal_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| Uuid::parse_str(s).ok()),
|
||||
ctx.actor_user_id,
|
||||
) {
|
||||
WorkflowService::record_participation(&ctx.pool, proposal_id, user_id, "viewed").await?;
|
||||
WorkflowService::record_participation(
|
||||
&ctx.pool,
|
||||
proposal_id,
|
||||
user_id,
|
||||
"viewed",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
|
|
@ -127,10 +149,19 @@ impl Plugin for DecisionWorkflowsPlugin {
|
|||
Arc::new(|ctx: HookContext, payload: Value| {
|
||||
Box::pin(async move {
|
||||
if let (Some(proposal_id), Some(user_id)) = (
|
||||
payload.get("proposal_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()),
|
||||
payload
|
||||
.get("proposal_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| Uuid::parse_str(s).ok()),
|
||||
ctx.actor_user_id,
|
||||
) {
|
||||
WorkflowService::record_participation(&ctx.pool, proposal_id, user_id, "commented").await?;
|
||||
WorkflowService::record_participation(
|
||||
&ctx.pool,
|
||||
proposal_id,
|
||||
user_id,
|
||||
"commented",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
|
|
@ -144,17 +175,25 @@ impl Plugin for DecisionWorkflowsPlugin {
|
|||
Arc::new(|ctx: HookContext, payload: Value| {
|
||||
Box::pin(async move {
|
||||
if let (Some(proposal_id), Some(user_id)) = (
|
||||
payload.get("proposal_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()),
|
||||
payload
|
||||
.get("proposal_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| Uuid::parse_str(s).ok()),
|
||||
ctx.actor_user_id,
|
||||
) {
|
||||
WorkflowService::record_participation(&ctx.pool, proposal_id, user_id, "voted").await?;
|
||||
WorkflowService::record_participation(
|
||||
&ctx.pool,
|
||||
proposal_id,
|
||||
user_id,
|
||||
"voted",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -228,7 +267,7 @@ impl WorkflowService {
|
|||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
|
@ -247,12 +286,12 @@ impl WorkflowService {
|
|||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
if community_default.is_some() {
|
||||
return Ok(community_default);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Fall back to system default
|
||||
let system_default = sqlx::query_scalar!(
|
||||
r#"SELECT id FROM workflow_templates
|
||||
|
|
@ -261,7 +300,7 @@ impl WorkflowService {
|
|||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
Ok(system_default)
|
||||
}
|
||||
|
||||
|
|
@ -271,14 +310,12 @@ impl WorkflowService {
|
|||
proposal_id: Uuid,
|
||||
template_id: Uuid,
|
||||
) -> Result<Uuid, PluginError> {
|
||||
let instance_id: Uuid = sqlx::query_scalar(
|
||||
"SELECT start_workflow($1, $2)"
|
||||
)
|
||||
.bind(proposal_id)
|
||||
.bind(template_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let instance_id: Uuid = sqlx::query_scalar("SELECT start_workflow($1, $2)")
|
||||
.bind(proposal_id)
|
||||
.bind(template_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(instance_id)
|
||||
}
|
||||
|
||||
|
|
@ -289,15 +326,14 @@ impl WorkflowService {
|
|||
user_id: Option<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)"
|
||||
)
|
||||
.bind(workflow_instance_id)
|
||||
.bind(user_id)
|
||||
.bind(reason)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
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)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(new_phase_id)
|
||||
}
|
||||
|
||||
|
|
@ -321,7 +357,7 @@ impl WorkflowService {
|
|||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
// Update participant count
|
||||
sqlx::query!(
|
||||
r#"UPDATE phase_instances pi
|
||||
|
|
@ -338,7 +374,7 @@ impl WorkflowService {
|
|||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -353,26 +389,25 @@ impl WorkflowService {
|
|||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
for phase in expired_phases {
|
||||
if phase.auto_advance {
|
||||
// Auto-advance to next phase
|
||||
let _: Option<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)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check quorum for active phases and record snapshots
|
||||
let active_phases = sqlx::query!(
|
||||
r#"SELECT pi.id FROM phase_instances pi WHERE pi.status = 'active'"#
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let active_phases =
|
||||
sqlx::query!(r#"SELECT pi.id FROM phase_instances pi WHERE pi.status = 'active'"#)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
for phase in active_phases {
|
||||
// Calculate and record quorum
|
||||
sqlx::query(
|
||||
|
|
@ -383,7 +418,7 @@ impl WorkflowService {
|
|||
.bind(phase.id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
// Update quorum_reached flag
|
||||
sqlx::query!(
|
||||
r#"UPDATE phase_instances
|
||||
|
|
@ -406,7 +441,7 @@ impl WorkflowService {
|
|||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -426,7 +461,7 @@ impl WorkflowService {
|
|||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
|
|
@ -449,7 +484,7 @@ impl WorkflowService {
|
|||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
Ok(phases)
|
||||
}
|
||||
|
||||
|
|
@ -479,7 +514,7 @@ impl WorkflowService {
|
|||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
match progress {
|
||||
Some(p) => Ok(json!({
|
||||
"workflow_id": p.id,
|
||||
|
|
@ -493,10 +528,10 @@ impl WorkflowService {
|
|||
"total_phases": p.total_phases,
|
||||
"completed_phases": p.completed_phases,
|
||||
"progress_percentage": p.total_phases.map(|t| {
|
||||
if t > 0 {
|
||||
(p.completed_phases.unwrap_or(0) as f64 / t as f64 * 100.0).round()
|
||||
} else {
|
||||
0.0
|
||||
if t > 0 {
|
||||
(p.completed_phases.unwrap_or(0) as f64 / t as f64 * 100.0).round()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
})),
|
||||
|
|
@ -519,7 +554,7 @@ impl WorkflowService {
|
|||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
Ok(templates)
|
||||
}
|
||||
|
||||
|
|
@ -542,7 +577,7 @@ impl WorkflowService {
|
|||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
Ok(template_id)
|
||||
}
|
||||
|
||||
|
|
@ -562,7 +597,7 @@ impl WorkflowService {
|
|||
default_duration_hours, quorum_value
|
||||
)
|
||||
VALUES ($1, $2, $3::workflow_phase_type, $4, $5, $6::numeric)
|
||||
RETURNING id"#
|
||||
RETURNING id"#,
|
||||
)
|
||||
.bind(template_id)
|
||||
.bind(name)
|
||||
|
|
@ -572,7 +607,7 @@ impl WorkflowService {
|
|||
.bind(quorum_value)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
Ok(phase_id)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ use std::sync::Arc;
|
|||
use uuid::Uuid;
|
||||
|
||||
use crate::plugins::{
|
||||
hooks::HookContext,
|
||||
manager::PluginSystem,
|
||||
Plugin, PluginError, PluginMetadata, PluginScope,
|
||||
hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
|
||||
};
|
||||
|
||||
pub struct FederationPlugin;
|
||||
|
|
@ -61,11 +59,13 @@ impl Plugin for FederationPlugin {
|
|||
100,
|
||||
Arc::new(|ctx: HookContext, payload: Value| {
|
||||
Box::pin(async move {
|
||||
if let Some(proposal_id) = payload.get("proposal_id")
|
||||
if let Some(proposal_id) = payload
|
||||
.get("proposal_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| Uuid::parse_str(s).ok())
|
||||
{
|
||||
FederationService::share_proposal_if_federated(&ctx.pool, proposal_id).await?;
|
||||
FederationService::share_proposal_if_federated(&ctx.pool, proposal_id)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
|
|
@ -79,7 +79,8 @@ impl Plugin for FederationPlugin {
|
|||
100,
|
||||
Arc::new(|ctx: HookContext, payload: Value| {
|
||||
Box::pin(async move {
|
||||
if let Some(proposal_id) = payload.get("proposal_id")
|
||||
if let Some(proposal_id) = payload
|
||||
.get("proposal_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| Uuid::parse_str(s).ok())
|
||||
{
|
||||
|
|
@ -122,15 +123,14 @@ impl FederationService {
|
|||
description: Option<&str>,
|
||||
public_key: Option<&str>,
|
||||
) -> Result<Uuid, PluginError> {
|
||||
let instance_id: Uuid = sqlx::query_scalar(
|
||||
"SELECT register_federated_instance($1, $2, $3, $4)"
|
||||
)
|
||||
.bind(url)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(public_key)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let instance_id: Uuid =
|
||||
sqlx::query_scalar("SELECT register_federated_instance($1, $2, $3, $4)")
|
||||
.bind(url)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(public_key)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(instance_id)
|
||||
}
|
||||
|
|
@ -145,7 +145,7 @@ impl FederationService {
|
|||
sync_direction: &str,
|
||||
) -> Result<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)
|
||||
|
|
@ -203,11 +203,11 @@ impl FederationService {
|
|||
// In a real implementation, this would make HTTP requests to remote instances
|
||||
// For now, just log the sync attempt
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
|
||||
sqlx::query(
|
||||
r#"INSERT INTO federation_sync_log
|
||||
(federation_id, instance_id, operation_type, direction, success, duration_ms)
|
||||
VALUES ($1, $2, 'scheduled_sync', $3::sync_direction, true, $4)"#
|
||||
VALUES ($1, $2, 'scheduled_sync', $3::sync_direction, true, $4)"#,
|
||||
)
|
||||
.bind(fed.id)
|
||||
.bind(fed.remote_instance_id)
|
||||
|
|
@ -277,10 +277,7 @@ impl FederationService {
|
|||
}
|
||||
|
||||
/// Broadcast decision to federated communities
|
||||
pub async fn broadcast_decision(
|
||||
pool: &PgPool,
|
||||
proposal_id: Uuid,
|
||||
) -> Result<(), PluginError> {
|
||||
pub async fn broadcast_decision(pool: &PgPool, proposal_id: Uuid) -> Result<(), PluginError> {
|
||||
// Find federated proposal
|
||||
let federated = sqlx::query!(
|
||||
r#"SELECT fp.id, fp.federation_id
|
||||
|
|
@ -319,12 +316,9 @@ impl FederationService {
|
|||
|
||||
/// Get federation statistics for a community
|
||||
pub async fn get_stats(pool: &PgPool, community_id: Uuid) -> Result<Value, PluginError> {
|
||||
let stats = sqlx::query!(
|
||||
"SELECT * FROM get_federation_stats($1)",
|
||||
community_id
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let stats = sqlx::query!("SELECT * FROM get_federation_stats($1)", community_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(json!({
|
||||
"total_federations": stats.total_federations,
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ use std::sync::Arc;
|
|||
use uuid::Uuid;
|
||||
|
||||
use crate::plugins::{
|
||||
hooks::HookContext,
|
||||
manager::PluginSystem,
|
||||
Plugin, PluginError, PluginMetadata, PluginScope,
|
||||
hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
|
||||
};
|
||||
|
||||
pub struct GovernanceAnalyticsPlugin;
|
||||
|
|
@ -66,7 +64,6 @@ impl Plugin for GovernanceAnalyticsPlugin {
|
|||
}),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -99,23 +96,20 @@ impl AnalyticsService {
|
|||
pool: &PgPool,
|
||||
community_id: Uuid,
|
||||
) -> Result<Uuid, PluginError> {
|
||||
let snapshot_id: Uuid = sqlx::query_scalar(
|
||||
"SELECT calculate_participation_snapshot($1, CURRENT_DATE)"
|
||||
)
|
||||
.bind(community_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let snapshot_id: Uuid =
|
||||
sqlx::query_scalar("SELECT calculate_participation_snapshot($1, CURRENT_DATE)")
|
||||
.bind(community_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(snapshot_id)
|
||||
}
|
||||
|
||||
/// Calculate snapshots for all communities
|
||||
pub async fn calculate_all_snapshots(pool: &PgPool) -> Result<i32, PluginError> {
|
||||
let communities = sqlx::query_scalar!(
|
||||
"SELECT id FROM communities WHERE is_active = true"
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let communities = sqlx::query_scalar!("SELECT id FROM communities WHERE is_active = true")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut count = 0;
|
||||
for community_id in communities {
|
||||
|
|
@ -130,27 +124,20 @@ impl AnalyticsService {
|
|||
}
|
||||
|
||||
/// Calculate governance health score for a community
|
||||
pub async fn calculate_health(
|
||||
pool: &PgPool,
|
||||
community_id: Uuid,
|
||||
) -> Result<Uuid, PluginError> {
|
||||
let health_id: Uuid = sqlx::query_scalar(
|
||||
"SELECT calculate_governance_health($1)"
|
||||
)
|
||||
.bind(community_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
pub async fn calculate_health(pool: &PgPool, community_id: Uuid) -> Result<Uuid, PluginError> {
|
||||
let health_id: Uuid = sqlx::query_scalar("SELECT calculate_governance_health($1)")
|
||||
.bind(community_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(health_id)
|
||||
}
|
||||
|
||||
/// Calculate health scores for all communities
|
||||
pub async fn calculate_all_health_scores(pool: &PgPool) -> Result<i32, PluginError> {
|
||||
let communities = sqlx::query_scalar!(
|
||||
"SELECT id FROM communities WHERE is_active = true"
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let communities = sqlx::query_scalar!("SELECT id FROM communities WHERE is_active = true")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut count = 0;
|
||||
for community_id in communities {
|
||||
|
|
@ -316,22 +303,24 @@ impl AnalyticsService {
|
|||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(methods.into_iter().map(|m| json!({
|
||||
"method": m.voting_method,
|
||||
"proposals": m.proposals_using_method,
|
||||
"total_votes": m.total_votes_cast,
|
||||
"avg_turnout": m.turnout,
|
||||
"avg_decision_time": m.avg_time,
|
||||
"decisive_results": m.decisive_results,
|
||||
"close_results": m.close_results
|
||||
})).collect())
|
||||
Ok(methods
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
json!({
|
||||
"method": m.voting_method,
|
||||
"proposals": m.proposals_using_method,
|
||||
"total_votes": m.total_votes_cast,
|
||||
"avg_turnout": m.turnout,
|
||||
"avg_decision_time": m.avg_time,
|
||||
"decisive_results": m.decisive_results,
|
||||
"close_results": m.close_results
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Get full dashboard data
|
||||
pub async fn get_dashboard(
|
||||
pool: &PgPool,
|
||||
community_id: Uuid,
|
||||
) -> Result<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?;
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ use crate::plugins::{
|
|||
};
|
||||
|
||||
/// Moderation Ledger Plugin
|
||||
///
|
||||
///
|
||||
/// Creates an immutable, cryptographically-chained log of all moderation decisions.
|
||||
/// This plugin is NON-DEACTIVATABLE by design - transparency is not optional.
|
||||
///
|
||||
///
|
||||
/// Features:
|
||||
/// - Immutable entries with SHA-256 hash chain
|
||||
/// - Full audit trail with actor, target, reason, evidence
|
||||
|
|
@ -34,7 +34,7 @@ pub enum ModerationActionType {
|
|||
ContentEdit,
|
||||
ContentFlag,
|
||||
ContentUnflag,
|
||||
|
||||
|
||||
// User moderation
|
||||
UserWarn,
|
||||
UserMute,
|
||||
|
|
@ -44,20 +44,20 @@ pub enum ModerationActionType {
|
|||
UserBan,
|
||||
UserUnban,
|
||||
UserRoleChange,
|
||||
|
||||
|
||||
// Community moderation
|
||||
CommunitySettingChange,
|
||||
CommunityRuleAdd,
|
||||
CommunityRuleEdit,
|
||||
CommunityRuleRemove,
|
||||
|
||||
|
||||
// Proposal/voting moderation
|
||||
ProposalClose,
|
||||
ProposalReopen,
|
||||
ProposalArchive,
|
||||
VoteInvalidate,
|
||||
VoteRestore,
|
||||
|
||||
|
||||
// Escalation
|
||||
EscalateToAdmin,
|
||||
EscalateToCommunity,
|
||||
|
|
@ -216,7 +216,10 @@ impl LedgerService {
|
|||
}
|
||||
|
||||
/// Get a single entry by ID
|
||||
pub async fn get_entry(pool: &PgPool, entry_id: Uuid) -> Result<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,17 +456,20 @@ impl Plugin for ModerationLedgerPlugin {
|
|||
"unilateral",
|
||||
None,
|
||||
None,
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _ = ctx.emit_public_event(
|
||||
Some(&plugin_name),
|
||||
"ledger.entry_created",
|
||||
json!({
|
||||
"entry_id": entry_id,
|
||||
"action_type": "content_remove",
|
||||
"target_type": content_type,
|
||||
}),
|
||||
).await;
|
||||
let _ = ctx
|
||||
.emit_public_event(
|
||||
Some(&plugin_name),
|
||||
"ledger.entry_created",
|
||||
json!({
|
||||
"entry_id": entry_id,
|
||||
"action_type": "content_remove",
|
||||
"target_type": content_type,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
|
|
@ -478,7 +485,8 @@ impl Plugin for ModerationLedgerPlugin {
|
|||
Arc::new(move |ctx: HookContext, payload: Value| {
|
||||
let plugin_name = plugin_name2.clone();
|
||||
Box::pin(async move {
|
||||
let actor_id = ctx.actor_user_id
|
||||
let actor_id = ctx
|
||||
.actor_user_id
|
||||
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
|
||||
|
||||
let target_user_id = payload
|
||||
|
|
@ -534,17 +542,20 @@ impl Plugin for ModerationLedgerPlugin {
|
|||
"unilateral",
|
||||
None,
|
||||
None,
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _ = ctx.emit_public_event(
|
||||
Some(&plugin_name),
|
||||
"ledger.entry_created",
|
||||
json!({
|
||||
"entry_id": entry_id,
|
||||
"action_type": action,
|
||||
"target_type": "user",
|
||||
}),
|
||||
).await;
|
||||
let _ = ctx
|
||||
.emit_public_event(
|
||||
Some(&plugin_name),
|
||||
"ledger.entry_created",
|
||||
json!({
|
||||
"entry_id": entry_id,
|
||||
"action_type": action,
|
||||
"target_type": "user",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
|
|
@ -560,7 +571,8 @@ impl Plugin for ModerationLedgerPlugin {
|
|||
Arc::new(move |ctx: HookContext, payload: Value| {
|
||||
let plugin_name = plugin_name3.clone();
|
||||
Box::pin(async move {
|
||||
let actor_id = ctx.actor_user_id
|
||||
let actor_id = ctx
|
||||
.actor_user_id
|
||||
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
|
||||
|
||||
let proposal_id = payload
|
||||
|
|
@ -617,17 +629,20 @@ impl Plugin for ModerationLedgerPlugin {
|
|||
decision_type,
|
||||
vote_proposal_id,
|
||||
payload.get("vote_result").cloned(),
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _ = ctx.emit_public_event(
|
||||
Some(&plugin_name),
|
||||
"ledger.entry_created",
|
||||
json!({
|
||||
"entry_id": entry_id,
|
||||
"action_type": action,
|
||||
"target_type": "proposal",
|
||||
}),
|
||||
).await;
|
||||
let _ = ctx
|
||||
.emit_public_event(
|
||||
Some(&plugin_name),
|
||||
"ledger.entry_created",
|
||||
json!({
|
||||
"entry_id": entry_id,
|
||||
"action_type": action,
|
||||
"target_type": "proposal",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ use std::sync::Arc;
|
|||
use uuid::Uuid;
|
||||
|
||||
use crate::plugins::{
|
||||
hooks::HookContext,
|
||||
manager::PluginSystem,
|
||||
Plugin, PluginError, PluginMetadata, PluginScope,
|
||||
hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
|
||||
};
|
||||
|
||||
pub struct ProposalLifecyclePlugin;
|
||||
|
|
@ -48,19 +46,29 @@ impl Plugin for ProposalLifecyclePlugin {
|
|||
10,
|
||||
Arc::new(|ctx: HookContext, payload: Value| {
|
||||
Box::pin(async move {
|
||||
if let Some(proposal_id) = payload.get("proposal_id")
|
||||
if let Some(proposal_id) = payload
|
||||
.get("proposal_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| Uuid::parse_str(s).ok())
|
||||
{
|
||||
let title = payload.get("title").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let content = payload.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let content = payload
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let summary = payload.get("change_summary").and_then(|v| v.as_str());
|
||||
|
||||
|
||||
if let Some(user_id) = ctx.actor_user_id {
|
||||
LifecycleService::create_version(
|
||||
&ctx.pool, proposal_id, title, content,
|
||||
user_id, "edit", summary
|
||||
).await?;
|
||||
&ctx.pool,
|
||||
proposal_id,
|
||||
title,
|
||||
content,
|
||||
user_id,
|
||||
"edit",
|
||||
summary,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -79,7 +87,8 @@ impl Plugin for ProposalLifecyclePlugin {
|
|||
Some("proposal_lifecycle"),
|
||||
"status.transition",
|
||||
payload.clone(),
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
|
|
@ -96,13 +105,13 @@ impl Plugin for ProposalLifecyclePlugin {
|
|||
Some("proposal_lifecycle"),
|
||||
"proposal.forked",
|
||||
payload.clone(),
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -143,18 +152,17 @@ impl LifecycleService {
|
|||
change_type: &str,
|
||||
change_summary: Option<&str>,
|
||||
) -> Result<i32, PluginError> {
|
||||
let version: i32 = sqlx::query_scalar(
|
||||
"SELECT create_proposal_version($1, $2, $3, NULL, $4, $5, $6)"
|
||||
)
|
||||
.bind(proposal_id)
|
||||
.bind(title)
|
||||
.bind(content)
|
||||
.bind(created_by)
|
||||
.bind(change_type)
|
||||
.bind(change_summary)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let version: i32 =
|
||||
sqlx::query_scalar("SELECT create_proposal_version($1, $2, $3, NULL, $4, $5, $6)")
|
||||
.bind(proposal_id)
|
||||
.bind(title)
|
||||
.bind(content)
|
||||
.bind(created_by)
|
||||
.bind(change_type)
|
||||
.bind(change_summary)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
|
|
@ -168,7 +176,7 @@ impl LifecycleService {
|
|||
reason: Option<&str>,
|
||||
) -> Result<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)
|
||||
|
|
@ -177,7 +185,7 @@ impl LifecycleService {
|
|||
.bind(reason)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
Ok(success)
|
||||
}
|
||||
|
||||
|
|
@ -189,16 +197,14 @@ impl LifecycleService {
|
|||
reason: &str,
|
||||
community_id: Uuid,
|
||||
) -> Result<Uuid, PluginError> {
|
||||
let new_id: Uuid = sqlx::query_scalar(
|
||||
"SELECT fork_proposal($1, $2, $3, $4)"
|
||||
)
|
||||
.bind(source_proposal_id)
|
||||
.bind(forked_by)
|
||||
.bind(reason)
|
||||
.bind(community_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let new_id: Uuid = sqlx::query_scalar("SELECT fork_proposal($1, $2, $3, $4)")
|
||||
.bind(source_proposal_id)
|
||||
.bind(forked_by)
|
||||
.bind(reason)
|
||||
.bind(community_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(new_id)
|
||||
}
|
||||
|
||||
|
|
@ -219,7 +225,7 @@ impl LifecycleService {
|
|||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
Ok(versions)
|
||||
}
|
||||
|
||||
|
|
@ -241,7 +247,7 @@ impl LifecycleService {
|
|||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
|
|
@ -254,21 +260,19 @@ impl LifecycleService {
|
|||
) -> Result<Value, PluginError> {
|
||||
let from = Self::get_version(pool, proposal_id, from_version).await?;
|
||||
let to = Self::get_version(pool, proposal_id, to_version).await?;
|
||||
|
||||
|
||||
match (from, to) {
|
||||
(Some(f), Some(t)) => {
|
||||
Ok(json!({
|
||||
"from_version": from_version,
|
||||
"to_version": to_version,
|
||||
"title_changed": f.title != t.title,
|
||||
"content_changed": f.content != t.content,
|
||||
"from_title": f.title,
|
||||
"to_title": t.title,
|
||||
"from_content_length": f.content.len(),
|
||||
"to_content_length": t.content.len(),
|
||||
"change_summary": t.change_summary
|
||||
}))
|
||||
}
|
||||
(Some(f), Some(t)) => Ok(json!({
|
||||
"from_version": from_version,
|
||||
"to_version": to_version,
|
||||
"title_changed": f.title != t.title,
|
||||
"content_changed": f.content != t.content,
|
||||
"from_title": f.title,
|
||||
"to_title": t.title,
|
||||
"from_content_length": f.content.len(),
|
||||
"to_content_length": t.content.len(),
|
||||
"change_summary": t.change_summary
|
||||
})),
|
||||
_ => Ok(json!({"error": "Version not found"})),
|
||||
}
|
||||
}
|
||||
|
|
@ -441,10 +445,7 @@ impl LifecycleService {
|
|||
}
|
||||
|
||||
/// Get forks of a proposal
|
||||
pub async fn get_forks(
|
||||
pool: &PgPool,
|
||||
proposal_id: Uuid,
|
||||
) -> Result<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,14 +465,19 @@ impl LifecycleService {
|
|||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(forks.into_iter().map(|f| json!({
|
||||
"fork_id": f.fork_proposal_id,
|
||||
"title": f.fork_title,
|
||||
"forked_by": f.forked_by_username,
|
||||
"forked_at": f.forked_at,
|
||||
"reason": f.fork_reason,
|
||||
"is_competing": f.is_competing,
|
||||
"is_merged": f.is_merged
|
||||
})).collect())
|
||||
Ok(forks
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
json!({
|
||||
"fork_id": f.fork_proposal_id,
|
||||
"title": f.fork_title,
|
||||
"forked_by": f.forked_by_username,
|
||||
"forked_at": f.forked_at,
|
||||
"reason": f.fork_reason,
|
||||
"is_competing": f.is_competing,
|
||||
"is_merged": f.is_merged
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ use std::sync::Arc;
|
|||
use uuid::Uuid;
|
||||
|
||||
use crate::plugins::{
|
||||
hooks::HookContext,
|
||||
manager::PluginSystem,
|
||||
Plugin, PluginError, PluginMetadata, PluginScope,
|
||||
hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
|
||||
};
|
||||
|
||||
pub struct PublicDataExportPlugin;
|
||||
|
|
@ -74,11 +72,13 @@ impl Plugin for PublicDataExportPlugin {
|
|||
50,
|
||||
Arc::new(|ctx: HookContext, payload: Value| {
|
||||
Box::pin(async move {
|
||||
if let Some(job_id) = payload.get("job_id")
|
||||
if let Some(job_id) = payload
|
||||
.get("job_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| Uuid::parse_str(s).ok())
|
||||
{
|
||||
ExportService::record_download(&ctx.pool, job_id, ctx.actor_user_id).await?;
|
||||
ExportService::record_download(&ctx.pool, job_id, ctx.actor_user_id)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
|
|
@ -120,17 +120,16 @@ impl ExportService {
|
|||
date_from: Option<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)"
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(export_type)
|
||||
.bind(format)
|
||||
.bind(requested_by)
|
||||
.bind(date_from)
|
||||
.bind(date_to)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let job_id: Uuid =
|
||||
sqlx::query_scalar("SELECT create_export_job($1, $2, $3::export_format, $4, $5, $6)")
|
||||
.bind(community_id)
|
||||
.bind(export_type)
|
||||
.bind(format)
|
||||
.bind(requested_by)
|
||||
.bind(date_from)
|
||||
.bind(date_to)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(job_id)
|
||||
}
|
||||
|
|
@ -160,7 +159,10 @@ impl ExportService {
|
|||
"proposals" => Self::export_proposals(pool, job.community_id, &job.format).await,
|
||||
"votes" => Self::export_votes(pool, job.community_id, &job.format).await,
|
||||
"analytics" => Self::export_analytics(pool, job.community_id, &job.format).await,
|
||||
_ => Err(PluginError::Message(format!("Unknown export type: {}", job.export_type))),
|
||||
_ => Err(PluginError::Message(format!(
|
||||
"Unknown export type: {}",
|
||||
job.export_type
|
||||
))),
|
||||
};
|
||||
|
||||
match result {
|
||||
|
|
@ -201,8 +203,9 @@ impl ExportService {
|
|||
community_id: Option<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)"#,
|
||||
community_id
|
||||
|
|
@ -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!({
|
||||
"id": p.id,
|
||||
"title": p.title,
|
||||
"author_id": p.author_id,
|
||||
"status": p.status,
|
||||
"created_at": p.created_at,
|
||||
"vote_count": p.vote_count
|
||||
})).collect();
|
||||
let items: Vec<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();
|
||||
serde_json::to_string_pretty(&items).unwrap_or_default()
|
||||
}
|
||||
"csv" => {
|
||||
|
|
@ -250,8 +258,9 @@ 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
|
||||
v.id, v.proposal_id,
|
||||
|
|
@ -269,12 +278,17 @@ impl ExportService {
|
|||
let count = votes.len() as i32;
|
||||
let data = match format {
|
||||
"json" => {
|
||||
let items: Vec<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();
|
||||
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();
|
||||
serde_json::to_string_pretty(&items).unwrap_or_default()
|
||||
}
|
||||
"csv" => {
|
||||
|
|
@ -282,7 +296,8 @@ impl ExportService {
|
|||
for v in &votes {
|
||||
csv.push_str(&format!(
|
||||
"{},{},{},{}\n",
|
||||
v.id, v.proposal_id,
|
||||
v.id,
|
||||
v.proposal_id,
|
||||
v.voter_hash.as_deref().unwrap_or(""),
|
||||
v.created_at
|
||||
));
|
||||
|
|
@ -301,8 +316,9 @@ impl ExportService {
|
|||
community_id: Option<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
|
||||
FROM participation_snapshots
|
||||
|
|
@ -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!({
|
||||
"date": a.snapshot_date.to_string(),
|
||||
"total_members": a.total_members,
|
||||
"active_members": a.active_members,
|
||||
"votes_cast": a.votes_cast
|
||||
})).collect();
|
||||
let items: Vec<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();
|
||||
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,20 +185,19 @@ impl ModerationRulesService {
|
|||
};
|
||||
|
||||
if let Some(st) = sanction_type {
|
||||
let blocked: bool = sqlx::query_scalar(
|
||||
"SELECT user_has_active_sanction($1, $2, $3::sanction_type)"
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(community_id)
|
||||
.bind(st)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let blocked: bool =
|
||||
sqlx::query_scalar("SELECT user_has_active_sanction($1, $2, $3::sanction_type)")
|
||||
.bind(user_id)
|
||||
.bind(community_id)
|
||||
.bind(st)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
return Ok(blocked);
|
||||
}
|
||||
|
||||
// Check for permanent ban
|
||||
let banned: bool = sqlx::query_scalar(
|
||||
"SELECT user_has_active_sanction($1, $2, 'permanent_ban'::sanction_type)"
|
||||
"SELECT user_has_active_sanction($1, $2, 'permanent_ban'::sanction_type)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(community_id)
|
||||
|
|
@ -251,14 +253,13 @@ impl ModerationRulesService {
|
|||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let escalation_level: i32 = sqlx::query_scalar(
|
||||
"SELECT calculate_escalation_level($1, $2, $3)"
|
||||
)
|
||||
.bind(violation.target_user_id)
|
||||
.bind(violation.community_id)
|
||||
.bind(violation.rule_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let escalation_level: i32 =
|
||||
sqlx::query_scalar("SELECT calculate_escalation_level($1, $2, $3)")
|
||||
.bind(violation.target_user_id)
|
||||
.bind(violation.community_id)
|
||||
.bind(violation.rule_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
// Check if community vote is required
|
||||
let rule = sqlx::query!(
|
||||
|
|
@ -353,15 +354,14 @@ impl ModerationRulesService {
|
|||
.await?;
|
||||
|
||||
// Apply the sanction
|
||||
let sanction_id: Uuid = sqlx::query_scalar(
|
||||
"SELECT apply_sanction($1, $2::sanction_type, $3, $4, 'manual')"
|
||||
)
|
||||
.bind(violation_id)
|
||||
.bind(&sanction.sanction_type)
|
||||
.bind(sanction.duration_hours)
|
||||
.bind(applied_by)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let sanction_id: Uuid =
|
||||
sqlx::query_scalar("SELECT apply_sanction($1, $2::sanction_type, $3, $4, 'manual')")
|
||||
.bind(violation_id)
|
||||
.bind(&sanction.sanction_type)
|
||||
.bind(sanction.duration_hours)
|
||||
.bind(applied_by)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(sanction_id)
|
||||
}
|
||||
|
|
@ -513,18 +513,20 @@ impl ModerationRulesService {
|
|||
|
||||
Ok(violations
|
||||
.into_iter()
|
||||
.map(|v| json!({
|
||||
"id": v.id,
|
||||
"rule_code": v.rule_code,
|
||||
"rule_title": v.rule_title,
|
||||
"severity": v.severity,
|
||||
"target_user_id": v.target_user_id,
|
||||
"target_username": v.target_username,
|
||||
"reported_by": v.reported_by,
|
||||
"status": v.status,
|
||||
"reported_at": v.reported_at,
|
||||
"reason": v.report_reason
|
||||
}))
|
||||
.map(|v| {
|
||||
json!({
|
||||
"id": v.id,
|
||||
"rule_code": v.rule_code,
|
||||
"rule_title": v.rule_title,
|
||||
"severity": v.severity,
|
||||
"target_user_id": v.target_user_id,
|
||||
"target_username": v.target_username,
|
||||
"reported_by": v.reported_by,
|
||||
"status": v.status,
|
||||
"reported_at": v.reported_at,
|
||||
"reason": v.report_reason
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,12 +364,16 @@ 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")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let core =
|
||||
sqlx::query!("SELECT name FROM plugins WHERE is_active = true AND is_core = true")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
for row in core {
|
||||
active.insert(row.name);
|
||||
}
|
||||
|
|
@ -454,7 +462,12 @@ impl PluginManager {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn do_wasm_action_for_community(&self, hook: &str, community_id: Uuid, payload: Value) {
|
||||
pub async fn do_wasm_action_for_community(
|
||||
&self,
|
||||
hook: &str,
|
||||
community_id: Uuid,
|
||||
payload: Value,
|
||||
) {
|
||||
let wasm = match sqlx::query!(
|
||||
r#"SELECT DISTINCT pp.id
|
||||
FROM plugin_packages pp
|
||||
|
|
@ -468,7 +481,11 @@ impl PluginManager {
|
|||
{
|
||||
Ok(rows) => rows,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to resolve active WASM plugins for hook {}: {}", hook, e);
|
||||
tracing::error!(
|
||||
"Failed to resolve active WASM plugins for hook {}: {}",
|
||||
hook,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
|
@ -621,12 +638,8 @@ impl PluginManager {
|
|||
if old_is_active && !new_is_active {
|
||||
self.invoke_deactivate(plugin_name, ctx.clone(), old_settings.clone())
|
||||
.await;
|
||||
self.do_action(
|
||||
"plugin.deactivated",
|
||||
ctx,
|
||||
json!({"plugin": plugin_name}),
|
||||
)
|
||||
.await;
|
||||
self.do_action("plugin.deactivated", ctx, json!({"plugin": plugin_name}))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::task::block_in_place;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::block_in_place;
|
||||
use uuid::Uuid;
|
||||
use wasmtime::{Linker, StoreLimits};
|
||||
|
||||
|
|
@ -70,7 +70,11 @@ impl HostState {
|
|||
.find(|c| c.name == CAP_OUTBOUND_HTTP && c.allowed)
|
||||
.and_then(|c| c.config.get("allowlist"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Self {
|
||||
|
|
@ -88,7 +92,9 @@ impl HostState {
|
|||
}
|
||||
|
||||
pub fn has_capability(&self, name: &str) -> bool {
|
||||
self.capabilities.iter().any(|c| c.name == name && c.allowed)
|
||||
self.capabilities
|
||||
.iter()
|
||||
.any(|c| c.name == name && c.allowed)
|
||||
}
|
||||
|
||||
pub fn is_url_allowed(&self, url: &str) -> bool {
|
||||
|
|
@ -146,7 +152,7 @@ impl HostStateWithLimits {
|
|||
guard.data = data.to_vec();
|
||||
data.len() as u32
|
||||
}
|
||||
|
||||
|
||||
/// Get the result buffer contents
|
||||
pub fn get_result(&self) -> Vec<u8> {
|
||||
let guard = match self.inner.result_buffer.lock() {
|
||||
|
|
@ -158,31 +164,40 @@ impl HostStateWithLimits {
|
|||
}
|
||||
|
||||
/// Registers host functions for WASM plugins.
|
||||
pub fn register_host_functions(linker: &mut Linker<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| {
|
||||
let memory = caller.get_export("memory").and_then(|e| e.into_memory());
|
||||
if let Some(mem) = memory {
|
||||
if len > MAX_WASM_STRING_BYTES {
|
||||
return;
|
||||
}
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
if mem.read(&caller, ptr as usize, &mut buf).is_ok() {
|
||||
if let Ok(msg) = String::from_utf8(buf) {
|
||||
let level_str = match level {
|
||||
0 => "TRACE",
|
||||
1 => "DEBUG",
|
||||
2 => "INFO",
|
||||
3 => "WARN",
|
||||
_ => "ERROR",
|
||||
};
|
||||
let plugin_name = caller.data().inner.plugin_name.clone();
|
||||
tracing::info!(plugin = %plugin_name, level = %level_str, "{}", msg);
|
||||
.func_wrap(
|
||||
"env",
|
||||
"host_log",
|
||||
|mut caller: wasmtime::Caller<'_, HostStateWithLimits>,
|
||||
ptr: u32,
|
||||
len: u32,
|
||||
level: u32| {
|
||||
let memory = caller.get_export("memory").and_then(|e| e.into_memory());
|
||||
if let Some(mem) = memory {
|
||||
if len > MAX_WASM_STRING_BYTES {
|
||||
return;
|
||||
}
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
if mem.read(&caller, ptr as usize, &mut buf).is_ok() {
|
||||
if let Ok(msg) = String::from_utf8(buf) {
|
||||
let level_str = match level {
|
||||
0 => "TRACE",
|
||||
1 => "DEBUG",
|
||||
2 => "INFO",
|
||||
3 => "WARN",
|
||||
_ => "ERROR",
|
||||
};
|
||||
let plugin_name = caller.data().inner.plugin_name.clone();
|
||||
tracing::info!(plugin = %plugin_name, level = %level_str, "{}", msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| PluginError::Message(format!("Failed to register host_log: {e}")))?;
|
||||
|
||||
// host_get_setting: Retrieve plugin settings
|
||||
|
|
@ -333,7 +348,7 @@ pub fn register_host_functions(linker: &mut Linker<HostStateWithLimits>) -> Resu
|
|||
|
||||
let mut key_buf = vec![0u8; key_len as usize];
|
||||
let mut val_buf = vec![0u8; val_len as usize];
|
||||
|
||||
|
||||
if memory.read(&caller, key_ptr as usize, &mut key_buf).is_err() {
|
||||
return 2;
|
||||
}
|
||||
|
|
@ -399,7 +414,7 @@ pub fn register_host_functions(linker: &mut Linker<HostStateWithLimits>) -> Resu
|
|||
|
||||
let mut event_buf = vec![0u8; event_len as usize];
|
||||
let mut payload_buf = vec![0u8; payload_len as usize];
|
||||
|
||||
|
||||
if memory.read(&caller, event_ptr as usize, &mut event_buf).is_err() {
|
||||
return 2;
|
||||
}
|
||||
|
|
@ -424,7 +439,7 @@ pub fn register_host_functions(linker: &mut Linker<HostStateWithLimits>) -> Resu
|
|||
|
||||
// Parse payload as JSON
|
||||
let payload: Value = serde_json::from_str(&payload_str).unwrap_or(Value::Null);
|
||||
|
||||
|
||||
let plugin_name = state.inner.plugin_name.clone();
|
||||
let community_id = state.inner.community_id;
|
||||
let actor_user_id = state.inner.actor_user_id;
|
||||
|
|
@ -472,21 +487,31 @@ pub fn register_host_functions(linker: &mut Linker<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 {
|
||||
let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) {
|
||||
Some(m) => m,
|
||||
None => return 0,
|
||||
};
|
||||
.func_wrap(
|
||||
"env",
|
||||
"host_get_result",
|
||||
|mut caller: wasmtime::Caller<'_, HostStateWithLimits>,
|
||||
dest_ptr: u32,
|
||||
max_len: u32|
|
||||
-> u32 {
|
||||
let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) {
|
||||
Some(m) => m,
|
||||
None => return 0,
|
||||
};
|
||||
|
||||
let result = caller.data().get_result();
|
||||
let copy_len = std::cmp::min(result.len(), max_len as usize);
|
||||
|
||||
if memory.write(&mut caller, dest_ptr as usize, &result[..copy_len]).is_err() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
copy_len as u32
|
||||
})
|
||||
let result = caller.data().get_result();
|
||||
let copy_len = std::cmp::min(result.len(), max_len as usize);
|
||||
|
||||
if memory
|
||||
.write(&mut caller, dest_ptr as usize, &result[..copy_len])
|
||||
.is_err()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
copy_len as u32
|
||||
},
|
||||
)
|
||||
.map_err(|e| PluginError::Message(format!("Failed to register host_get_result: {e}")))?;
|
||||
|
||||
// host_http_request: Make outbound HTTP request (capability-gated)
|
||||
|
|
@ -503,7 +528,7 @@ pub fn register_host_functions(linker: &mut Linker<HostStateWithLimits>) -> Resu
|
|||
|
||||
let mut url_buf = vec![0u8; url_len as usize];
|
||||
let mut method_buf = vec![0u8; method_len as usize];
|
||||
|
||||
|
||||
if memory.read(&caller, url_ptr as usize, &mut url_buf).is_err() {
|
||||
return pack_result(2, 0);
|
||||
}
|
||||
|
|
@ -544,7 +569,7 @@ pub fn register_host_functions(linker: &mut Linker<HostStateWithLimits>) -> Resu
|
|||
};
|
||||
|
||||
let state = caller.data();
|
||||
|
||||
|
||||
if !state.inner.has_capability(CAP_OUTBOUND_HTTP) {
|
||||
tracing::warn!(plugin = %state.inner.plugin_name, url = %url, "HTTP denied: no capability");
|
||||
return pack_result(6, 0);
|
||||
|
|
|
|||
|
|
@ -1,261 +1,274 @@
|
|||
//! WASM plugin implementation.
|
||||
//!
|
||||
//! Wraps compiled WASM modules and implements the Plugin trait for
|
||||
//! integration with the hook system.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::host_api::{Capability, HostState, PluginManifest, CAP_EMIT_EVENTS, CAP_KV_STORE, CAP_OUTBOUND_HTTP, CAP_SETTINGS};
|
||||
use super::runtime::{CompiledPlugin, ExecutionLimits, PluginInstance};
|
||||
use crate::plugins::hooks::{HookContext, PluginError};
|
||||
use crate::plugins::manager::{Plugin, PluginMetadata, PluginScope, PluginSystem};
|
||||
|
||||
/// A WASM-based plugin that can be loaded dynamically.
|
||||
pub struct WasmPlugin {
|
||||
package_id: Uuid,
|
||||
manifest: PluginManifest,
|
||||
compiled: Arc<CompiledPlugin>,
|
||||
limits: ExecutionLimits,
|
||||
}
|
||||
|
||||
async fn capabilities_for_manifest(
|
||||
pool: &PgPool,
|
||||
community_id: Option<Uuid>,
|
||||
manifest_capabilities: &[String],
|
||||
) -> Result<Vec<Capability>, PluginError> {
|
||||
let mut out: Vec<Capability> = Vec::new();
|
||||
|
||||
let (allow_http, allowlist) = if let Some(cid) = community_id {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT settings as "settings!: serde_json::Value" FROM communities WHERE id = $1"#,
|
||||
cid
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let Some(row) = row {
|
||||
let allow_http = row
|
||||
.settings
|
||||
.get("plugin_allow_outbound_http")
|
||||
.and_then(|v: &serde_json::Value| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let allowlist: Vec<String> = row
|
||||
.settings
|
||||
.get("plugin_http_egress_allowlist")
|
||||
.and_then(|v: &serde_json::Value| v.as_array())
|
||||
.map(|arr: &Vec<serde_json::Value>| {
|
||||
arr.iter()
|
||||
.filter_map(|v: &serde_json::Value| v.as_str().map(|s: &str| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
(allow_http, allowlist)
|
||||
} else {
|
||||
(false, Vec::new())
|
||||
}
|
||||
} else {
|
||||
(false, Vec::new())
|
||||
};
|
||||
|
||||
for cap in manifest_capabilities {
|
||||
match cap.as_str() {
|
||||
CAP_OUTBOUND_HTTP => {
|
||||
let allowed = allow_http && !allowlist.is_empty();
|
||||
out.push(Capability {
|
||||
name: cap.clone(),
|
||||
allowed,
|
||||
config: serde_json::json!({"allowlist": allowlist}),
|
||||
});
|
||||
}
|
||||
CAP_SETTINGS | CAP_KV_STORE | CAP_EMIT_EVENTS => {
|
||||
out.push(Capability {
|
||||
name: cap.clone(),
|
||||
allowed: true,
|
||||
config: serde_json::json!({}),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
out.push(Capability {
|
||||
name: cap.clone(),
|
||||
allowed: false,
|
||||
config: serde_json::json!({}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl WasmPlugin {
|
||||
/// Creates a new WASM plugin from a manifest and compiled module.
|
||||
pub fn new(
|
||||
package_id: Uuid,
|
||||
manifest: PluginManifest,
|
||||
compiled: Arc<CompiledPlugin>,
|
||||
) -> Self {
|
||||
Self {
|
||||
package_id,
|
||||
manifest,
|
||||
compiled,
|
||||
limits: ExecutionLimits::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets custom execution limits for this plugin.
|
||||
#[allow(dead_code)] // API for future use
|
||||
pub fn with_limits(mut self, limits: ExecutionLimits) -> Self {
|
||||
self.limits = limits;
|
||||
self
|
||||
}
|
||||
|
||||
async fn capabilities_for(&self, pool: &PgPool, ctx: &HookContext) -> Result<Vec<Capability>, PluginError> {
|
||||
capabilities_for_manifest(pool, ctx.community_id, &self.manifest.capabilities).await
|
||||
}
|
||||
|
||||
async fn create_instance(&self, ctx: &HookContext) -> Result<PluginInstance, PluginError> {
|
||||
let capabilities = self.capabilities_for(&ctx.pool, ctx).await?;
|
||||
let host_state = HostState::new(
|
||||
self.manifest.name.clone(),
|
||||
ctx.community_id,
|
||||
ctx.actor_user_id,
|
||||
ctx.pool.clone(),
|
||||
self.package_id,
|
||||
capabilities,
|
||||
);
|
||||
|
||||
PluginInstance::new(&self.compiled, host_state, self.limits.clone()).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for WasmPlugin {
|
||||
fn metadata(&self) -> PluginMetadata {
|
||||
PluginMetadata {
|
||||
name: Box::leak(self.manifest.name.clone().into_boxed_str()),
|
||||
version: Box::leak(self.manifest.version.clone().into_boxed_str()),
|
||||
description: Box::leak(self.manifest.description.clone().into_boxed_str()),
|
||||
is_core: false,
|
||||
scope: PluginScope::Community,
|
||||
default_enabled: false,
|
||||
settings_schema: self.manifest.settings_schema.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn register(&self, system: &mut PluginSystem) {
|
||||
let plugin_name = self.manifest.name.clone();
|
||||
let package_id = self.package_id;
|
||||
let handler_plugin_id = format!("wasm:{}", package_id);
|
||||
let manifest_capabilities = self.manifest.capabilities.clone();
|
||||
let compiled = self.compiled.clone();
|
||||
let limits = self.limits.clone();
|
||||
|
||||
for hook in &self.manifest.hooks {
|
||||
let hook_name = hook.clone();
|
||||
let hook_name_ref = hook_name.clone();
|
||||
let plugin_name_clone = plugin_name.clone();
|
||||
let handler_plugin_id_clone = handler_plugin_id.clone();
|
||||
let compiled_clone = compiled.clone();
|
||||
let limits_clone = limits.clone();
|
||||
let manifest_capabilities_for_hook = manifest_capabilities.clone();
|
||||
|
||||
system.add_action(
|
||||
&hook_name_ref,
|
||||
handler_plugin_id_clone.clone(),
|
||||
50,
|
||||
Arc::new(move |ctx: HookContext, payload: Value| {
|
||||
let hook = hook_name.clone();
|
||||
let plugin = plugin_name_clone.clone();
|
||||
let package_id = package_id;
|
||||
let manifest_capabilities = manifest_capabilities_for_hook.clone();
|
||||
let compiled = compiled_clone.clone();
|
||||
let lim = limits_clone.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
let capabilities = capabilities_for_manifest(
|
||||
&ctx.pool,
|
||||
ctx.community_id,
|
||||
&manifest_capabilities,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let host_state = HostState::new(
|
||||
plugin.clone(),
|
||||
ctx.community_id,
|
||||
ctx.actor_user_id,
|
||||
ctx.pool.clone(),
|
||||
package_id,
|
||||
capabilities,
|
||||
);
|
||||
|
||||
let mut instance = PluginInstance::new(&compiled, host_state, lim).await?;
|
||||
|
||||
let payload_json = serde_json::to_string(&payload)
|
||||
.map_err(|e| PluginError::Message(format!("Failed to serialize payload: {e}")))?;
|
||||
|
||||
let _result = instance.call_hook(&hook, &payload_json).await?;
|
||||
|
||||
let remaining_fuel = instance.get_fuel();
|
||||
tracing::debug!(
|
||||
plugin = %plugin,
|
||||
hook = %hook,
|
||||
fuel_remaining = remaining_fuel,
|
||||
"WASM plugin hook completed"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn activate(&self, ctx: HookContext, settings: Value) -> Result<(), PluginError> {
|
||||
let mut instance = self.create_instance(&ctx).await?;
|
||||
let payload = json!({
|
||||
"event": "activate",
|
||||
"settings": settings
|
||||
});
|
||||
let payload_json = serde_json::to_string(&payload)
|
||||
.map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?;
|
||||
instance.call_hook("lifecycle.activate", &payload_json).await.ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn deactivate(&self, ctx: HookContext, settings: Value) -> Result<(), PluginError> {
|
||||
let mut instance = self.create_instance(&ctx).await?;
|
||||
let payload = json!({
|
||||
"event": "deactivate",
|
||||
"settings": settings
|
||||
});
|
||||
let payload_json = serde_json::to_string(&payload)
|
||||
.map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?;
|
||||
instance.call_hook("lifecycle.deactivate", &payload_json).await.ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn settings_updated(
|
||||
&self,
|
||||
ctx: HookContext,
|
||||
old_settings: Value,
|
||||
new_settings: Value,
|
||||
) -> Result<(), PluginError> {
|
||||
let mut instance = self.create_instance(&ctx).await?;
|
||||
let payload = json!({
|
||||
"event": "settings_updated",
|
||||
"old_settings": old_settings,
|
||||
"new_settings": new_settings
|
||||
});
|
||||
let payload_json = serde_json::to_string(&payload)
|
||||
.map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?;
|
||||
instance.call_hook("lifecycle.settings_updated", &payload_json).await.ok();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
//! WASM plugin implementation.
|
||||
//!
|
||||
//! Wraps compiled WASM modules and implements the Plugin trait for
|
||||
//! integration with the hook system.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::host_api::{
|
||||
Capability, HostState, PluginManifest, CAP_EMIT_EVENTS, CAP_KV_STORE, CAP_OUTBOUND_HTTP,
|
||||
CAP_SETTINGS,
|
||||
};
|
||||
use super::runtime::{CompiledPlugin, ExecutionLimits, PluginInstance};
|
||||
use crate::plugins::hooks::{HookContext, PluginError};
|
||||
use crate::plugins::manager::{Plugin, PluginMetadata, PluginScope, PluginSystem};
|
||||
|
||||
/// A WASM-based plugin that can be loaded dynamically.
|
||||
pub struct WasmPlugin {
|
||||
package_id: Uuid,
|
||||
manifest: PluginManifest,
|
||||
compiled: Arc<CompiledPlugin>,
|
||||
limits: ExecutionLimits,
|
||||
}
|
||||
|
||||
async fn capabilities_for_manifest(
|
||||
pool: &PgPool,
|
||||
community_id: Option<Uuid>,
|
||||
manifest_capabilities: &[String],
|
||||
) -> Result<Vec<Capability>, PluginError> {
|
||||
let mut out: Vec<Capability> = Vec::new();
|
||||
|
||||
let (allow_http, allowlist) = if let Some(cid) = community_id {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT settings as "settings!: serde_json::Value" FROM communities WHERE id = $1"#,
|
||||
cid
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let Some(row) = row {
|
||||
let allow_http = row
|
||||
.settings
|
||||
.get("plugin_allow_outbound_http")
|
||||
.and_then(|v: &serde_json::Value| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let allowlist: Vec<String> = row
|
||||
.settings
|
||||
.get("plugin_http_egress_allowlist")
|
||||
.and_then(|v: &serde_json::Value| v.as_array())
|
||||
.map(|arr: &Vec<serde_json::Value>| {
|
||||
arr.iter()
|
||||
.filter_map(|v: &serde_json::Value| v.as_str().map(|s: &str| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
(allow_http, allowlist)
|
||||
} else {
|
||||
(false, Vec::new())
|
||||
}
|
||||
} else {
|
||||
(false, Vec::new())
|
||||
};
|
||||
|
||||
for cap in manifest_capabilities {
|
||||
match cap.as_str() {
|
||||
CAP_OUTBOUND_HTTP => {
|
||||
let allowed = allow_http && !allowlist.is_empty();
|
||||
out.push(Capability {
|
||||
name: cap.clone(),
|
||||
allowed,
|
||||
config: serde_json::json!({"allowlist": allowlist}),
|
||||
});
|
||||
}
|
||||
CAP_SETTINGS | CAP_KV_STORE | CAP_EMIT_EVENTS => {
|
||||
out.push(Capability {
|
||||
name: cap.clone(),
|
||||
allowed: true,
|
||||
config: serde_json::json!({}),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
out.push(Capability {
|
||||
name: cap.clone(),
|
||||
allowed: false,
|
||||
config: serde_json::json!({}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl WasmPlugin {
|
||||
/// Creates a new WASM plugin from a manifest and compiled module.
|
||||
pub fn new(package_id: Uuid, manifest: PluginManifest, compiled: Arc<CompiledPlugin>) -> Self {
|
||||
Self {
|
||||
package_id,
|
||||
manifest,
|
||||
compiled,
|
||||
limits: ExecutionLimits::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets custom execution limits for this plugin.
|
||||
#[allow(dead_code)] // API for future use
|
||||
pub fn with_limits(mut self, limits: ExecutionLimits) -> Self {
|
||||
self.limits = limits;
|
||||
self
|
||||
}
|
||||
|
||||
async fn capabilities_for(
|
||||
&self,
|
||||
pool: &PgPool,
|
||||
ctx: &HookContext,
|
||||
) -> Result<Vec<Capability>, PluginError> {
|
||||
capabilities_for_manifest(pool, ctx.community_id, &self.manifest.capabilities).await
|
||||
}
|
||||
|
||||
async fn create_instance(&self, ctx: &HookContext) -> Result<PluginInstance, PluginError> {
|
||||
let capabilities = self.capabilities_for(&ctx.pool, ctx).await?;
|
||||
let host_state = HostState::new(
|
||||
self.manifest.name.clone(),
|
||||
ctx.community_id,
|
||||
ctx.actor_user_id,
|
||||
ctx.pool.clone(),
|
||||
self.package_id,
|
||||
capabilities,
|
||||
);
|
||||
|
||||
PluginInstance::new(&self.compiled, host_state, self.limits.clone()).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for WasmPlugin {
|
||||
fn metadata(&self) -> PluginMetadata {
|
||||
PluginMetadata {
|
||||
name: Box::leak(self.manifest.name.clone().into_boxed_str()),
|
||||
version: Box::leak(self.manifest.version.clone().into_boxed_str()),
|
||||
description: Box::leak(self.manifest.description.clone().into_boxed_str()),
|
||||
is_core: false,
|
||||
scope: PluginScope::Community,
|
||||
default_enabled: false,
|
||||
settings_schema: self.manifest.settings_schema.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn register(&self, system: &mut PluginSystem) {
|
||||
let plugin_name = self.manifest.name.clone();
|
||||
let package_id = self.package_id;
|
||||
let handler_plugin_id = format!("wasm:{}", package_id);
|
||||
let manifest_capabilities = self.manifest.capabilities.clone();
|
||||
let compiled = self.compiled.clone();
|
||||
let limits = self.limits.clone();
|
||||
|
||||
for hook in &self.manifest.hooks {
|
||||
let hook_name = hook.clone();
|
||||
let hook_name_ref = hook_name.clone();
|
||||
let plugin_name_clone = plugin_name.clone();
|
||||
let handler_plugin_id_clone = handler_plugin_id.clone();
|
||||
let compiled_clone = compiled.clone();
|
||||
let limits_clone = limits.clone();
|
||||
let manifest_capabilities_for_hook = manifest_capabilities.clone();
|
||||
|
||||
system.add_action(
|
||||
&hook_name_ref,
|
||||
handler_plugin_id_clone.clone(),
|
||||
50,
|
||||
Arc::new(move |ctx: HookContext, payload: Value| {
|
||||
let hook = hook_name.clone();
|
||||
let plugin = plugin_name_clone.clone();
|
||||
let package_id = package_id;
|
||||
let manifest_capabilities = manifest_capabilities_for_hook.clone();
|
||||
let compiled = compiled_clone.clone();
|
||||
let lim = limits_clone.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
let capabilities = capabilities_for_manifest(
|
||||
&ctx.pool,
|
||||
ctx.community_id,
|
||||
&manifest_capabilities,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let host_state = HostState::new(
|
||||
plugin.clone(),
|
||||
ctx.community_id,
|
||||
ctx.actor_user_id,
|
||||
ctx.pool.clone(),
|
||||
package_id,
|
||||
capabilities,
|
||||
);
|
||||
|
||||
let mut instance = PluginInstance::new(&compiled, host_state, lim).await?;
|
||||
|
||||
let payload_json = serde_json::to_string(&payload).map_err(|e| {
|
||||
PluginError::Message(format!("Failed to serialize payload: {e}"))
|
||||
})?;
|
||||
|
||||
let _result = instance.call_hook(&hook, &payload_json).await?;
|
||||
|
||||
let remaining_fuel = instance.get_fuel();
|
||||
tracing::debug!(
|
||||
plugin = %plugin,
|
||||
hook = %hook,
|
||||
fuel_remaining = remaining_fuel,
|
||||
"WASM plugin hook completed"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn activate(&self, ctx: HookContext, settings: Value) -> Result<(), PluginError> {
|
||||
let mut instance = self.create_instance(&ctx).await?;
|
||||
let payload = json!({
|
||||
"event": "activate",
|
||||
"settings": settings
|
||||
});
|
||||
let payload_json = serde_json::to_string(&payload)
|
||||
.map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?;
|
||||
instance
|
||||
.call_hook("lifecycle.activate", &payload_json)
|
||||
.await
|
||||
.ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn deactivate(&self, ctx: HookContext, settings: Value) -> Result<(), PluginError> {
|
||||
let mut instance = self.create_instance(&ctx).await?;
|
||||
let payload = json!({
|
||||
"event": "deactivate",
|
||||
"settings": settings
|
||||
});
|
||||
let payload_json = serde_json::to_string(&payload)
|
||||
.map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?;
|
||||
instance
|
||||
.call_hook("lifecycle.deactivate", &payload_json)
|
||||
.await
|
||||
.ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn settings_updated(
|
||||
&self,
|
||||
ctx: HookContext,
|
||||
old_settings: Value,
|
||||
new_settings: Value,
|
||||
) -> Result<(), PluginError> {
|
||||
let mut instance = self.create_instance(&ctx).await?;
|
||||
let payload = json!({
|
||||
"event": "settings_updated",
|
||||
"old_settings": old_settings,
|
||||
"new_settings": new_settings
|
||||
});
|
||||
let payload_json = serde_json::to_string(&payload)
|
||||
.map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?;
|
||||
instance
|
||||
.call_hook("lifecycle.settings_updated", &payload_json)
|
||||
.await
|
||||
.ok();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,13 +57,13 @@ impl WasmRuntime {
|
|||
|
||||
let engine = Arc::new(
|
||||
Engine::new(&config)
|
||||
.map_err(|e| PluginError::Message(format!("Failed to create WASM engine: {e}")))?
|
||||
.map_err(|e| PluginError::Message(format!("Failed to create WASM engine: {e}")))?,
|
||||
);
|
||||
|
||||
// Spawn epoch ticker for timeout enforcement
|
||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||
let engine_clone = engine.clone();
|
||||
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(10));
|
||||
loop {
|
||||
|
|
@ -78,7 +78,7 @@ impl WasmRuntime {
|
|||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
Ok(Self {
|
||||
engine,
|
||||
_epoch_ticker_shutdown: Some(shutdown_tx),
|
||||
})
|
||||
|
|
@ -140,11 +140,11 @@ impl PluginInstance {
|
|||
|
||||
let mut store = Store::new(compiled.engine(), state_with_limits);
|
||||
store.limiter(|state| &mut state.limits);
|
||||
|
||||
store.set_fuel(limits.fuel).map_err(|e| {
|
||||
PluginError::Message(format!("Failed to set fuel limit: {e}"))
|
||||
})?;
|
||||
|
||||
|
||||
store
|
||||
.set_fuel(limits.fuel)
|
||||
.map_err(|e| PluginError::Message(format!("Failed to set fuel limit: {e}")))?;
|
||||
|
||||
let epoch_deadline = (limits.timeout_ms / 10).max(1);
|
||||
store.epoch_deadline_async_yield_and_update(epoch_deadline);
|
||||
|
||||
|
|
@ -177,7 +177,9 @@ impl PluginInstance {
|
|||
let handle_hook = self
|
||||
.instance
|
||||
.get_typed_func::<(u32, u32, u32, u32), u64>(&mut self.store, "handle_hook")
|
||||
.map_err(|e| PluginError::Message(format!("Plugin missing 'handle_hook' export: {e}")))?;
|
||||
.map_err(|e| {
|
||||
PluginError::Message(format!("Plugin missing 'handle_hook' export: {e}"))
|
||||
})?;
|
||||
|
||||
let memory = self
|
||||
.instance
|
||||
|
|
@ -185,15 +187,21 @@ impl PluginInstance {
|
|||
.ok_or_else(|| PluginError::Message("Plugin missing 'memory' export".to_string()))?;
|
||||
|
||||
let hook_bytes = hook_name.as_bytes();
|
||||
let hook_ptr = alloc.call_async(&mut self.store, hook_bytes.len() as u32).await
|
||||
let hook_ptr = alloc
|
||||
.call_async(&mut self.store, hook_bytes.len() as u32)
|
||||
.await
|
||||
.map_err(|e| PluginError::Message(format!("alloc failed for hook name: {e}")))?;
|
||||
memory.write(&mut self.store, hook_ptr as usize, hook_bytes)
|
||||
memory
|
||||
.write(&mut self.store, hook_ptr as usize, hook_bytes)
|
||||
.map_err(|e| PluginError::Message(format!("Failed to write hook name: {e}")))?;
|
||||
|
||||
let payload_bytes = payload_json.as_bytes();
|
||||
let payload_ptr = alloc.call_async(&mut self.store, payload_bytes.len() as u32).await
|
||||
let payload_ptr = alloc
|
||||
.call_async(&mut self.store, payload_bytes.len() as u32)
|
||||
.await
|
||||
.map_err(|e| PluginError::Message(format!("alloc failed for payload: {e}")))?;
|
||||
memory.write(&mut self.store, payload_ptr as usize, payload_bytes)
|
||||
memory
|
||||
.write(&mut self.store, payload_ptr as usize, payload_bytes)
|
||||
.map_err(|e| PluginError::Message(format!("Failed to write payload: {e}")))?;
|
||||
|
||||
let result = handle_hook
|
||||
|
|
@ -213,12 +221,22 @@ impl PluginInstance {
|
|||
let result_len = (result & 0xFFFFFFFF) as u32;
|
||||
|
||||
let mut result_bytes = vec![0u8; result_len as usize];
|
||||
memory.read(&self.store, result_ptr as usize, &mut result_bytes)
|
||||
memory
|
||||
.read(&self.store, result_ptr as usize, &mut result_bytes)
|
||||
.map_err(|e| PluginError::Message(format!("Failed to read result: {e}")))?;
|
||||
|
||||
dealloc.call_async(&mut self.store, (hook_ptr, hook_bytes.len() as u32)).await.ok();
|
||||
dealloc.call_async(&mut self.store, (payload_ptr, payload_bytes.len() as u32)).await.ok();
|
||||
dealloc.call_async(&mut self.store, (result_ptr, result_len)).await.ok();
|
||||
dealloc
|
||||
.call_async(&mut self.store, (hook_ptr, hook_bytes.len() as u32))
|
||||
.await
|
||||
.ok();
|
||||
dealloc
|
||||
.call_async(&mut self.store, (payload_ptr, payload_bytes.len() as u32))
|
||||
.await
|
||||
.ok();
|
||||
dealloc
|
||||
.call_async(&mut self.store, (result_ptr, result_len))
|
||||
.await
|
||||
.ok();
|
||||
|
||||
String::from_utf8(result_bytes)
|
||||
.map_err(|e| PluginError::Message(format!("Result is not valid UTF-8: {e}")))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -154,7 +156,7 @@ mod tests {
|
|||
];
|
||||
|
||||
let result = calculate(&options, &ballots);
|
||||
|
||||
|
||||
// A: 9 votes, B: 6 votes -> A wins despite fewer supporters
|
||||
// This demonstrates intensity expression
|
||||
assert_eq!(result.winner, Some(a));
|
||||
|
|
@ -165,12 +167,10 @@ mod tests {
|
|||
let a = Uuid::new_v4();
|
||||
let options = vec![a];
|
||||
|
||||
let ballots = vec![
|
||||
QuadraticBallot {
|
||||
total_credits: 100,
|
||||
allocations: vec![(a, 11)], // Costs 121, exceeds 100
|
||||
},
|
||||
];
|
||||
let ballots = vec![QuadraticBallot {
|
||||
total_credits: 100,
|
||||
allocations: vec![(a, 11)], // Costs 121, exceeds 100
|
||||
}];
|
||||
|
||||
let result = calculate(&options, &ballots);
|
||||
// Invalid ballot should be skipped
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -103,14 +101,15 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
|
|||
// Only one option left - it wins
|
||||
if active_options.len() <= 1 {
|
||||
let winner = active_options.iter().next().cloned();
|
||||
|
||||
|
||||
rounds.push(RoundResult {
|
||||
round: round_num,
|
||||
vote_counts: sorted.clone(),
|
||||
eliminated: None,
|
||||
});
|
||||
|
||||
let mut final_ranking: Vec<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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -141,7 +137,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
|
|||
// Eliminate lowest-ranked option
|
||||
if let Some((loser, _)) = sorted.last() {
|
||||
let loser_id = *loser;
|
||||
|
||||
|
||||
rounds.push(RoundResult {
|
||||
round: round_num,
|
||||
vote_counts: sorted,
|
||||
|
|
@ -159,10 +155,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
|
|||
VotingResult {
|
||||
winner: None,
|
||||
ranking: vec![],
|
||||
details: VotingDetails::RankedChoice {
|
||||
rounds,
|
||||
eliminated,
|
||||
},
|
||||
details: VotingDetails::RankedChoice { rounds, eliminated },
|
||||
total_ballots: ballots.len(),
|
||||
}
|
||||
}
|
||||
|
|
@ -180,11 +173,21 @@ mod tests {
|
|||
|
||||
// A has clear majority
|
||||
let ballots = vec![
|
||||
RankedBallot { rankings: vec![a, b, c] },
|
||||
RankedBallot { rankings: vec![a, b, c] },
|
||||
RankedBallot { rankings: vec![a, c, b] },
|
||||
RankedBallot { rankings: vec![b, a, c] },
|
||||
RankedBallot { rankings: vec![c, b, a] },
|
||||
RankedBallot {
|
||||
rankings: vec![a, b, c],
|
||||
},
|
||||
RankedBallot {
|
||||
rankings: vec![a, b, c],
|
||||
},
|
||||
RankedBallot {
|
||||
rankings: vec![a, c, b],
|
||||
},
|
||||
RankedBallot {
|
||||
rankings: vec![b, a, c],
|
||||
},
|
||||
RankedBallot {
|
||||
rankings: vec![c, b, a],
|
||||
},
|
||||
];
|
||||
|
||||
let result = calculate(&options, &ballots);
|
||||
|
|
@ -200,19 +203,29 @@ mod tests {
|
|||
|
||||
// No first-round majority, C eliminated, B wins
|
||||
let ballots = vec![
|
||||
RankedBallot { rankings: vec![a, b, c] },
|
||||
RankedBallot { rankings: vec![a, b, c] },
|
||||
RankedBallot { rankings: vec![b, a, c] },
|
||||
RankedBallot { rankings: vec![b, c, a] },
|
||||
RankedBallot { rankings: vec![c, b, a] }, // C's vote goes to B
|
||||
RankedBallot {
|
||||
rankings: vec![a, b, c],
|
||||
},
|
||||
RankedBallot {
|
||||
rankings: vec![a, b, c],
|
||||
},
|
||||
RankedBallot {
|
||||
rankings: vec![b, a, c],
|
||||
},
|
||||
RankedBallot {
|
||||
rankings: vec![b, c, a],
|
||||
},
|
||||
RankedBallot {
|
||||
rankings: vec![c, b, a],
|
||||
}, // C's vote goes to B
|
||||
];
|
||||
|
||||
let result = calculate(&options, &ballots);
|
||||
|
||||
|
||||
// Round 1: A=2, B=2, C=1 -> C eliminated
|
||||
// Round 2: A=2, B=3 -> B wins with majority
|
||||
assert_eq!(result.winner, Some(b));
|
||||
|
||||
|
||||
if let VotingDetails::RankedChoice { rounds, eliminated } = &result.details {
|
||||
assert_eq!(rounds.len(), 2);
|
||||
assert_eq!(eliminated, &vec![c]);
|
||||
|
|
@ -230,15 +243,25 @@ mod tests {
|
|||
let options = vec![a, b, spoiler];
|
||||
|
||||
let ballots = vec![
|
||||
RankedBallot { rankings: vec![a, spoiler, b] },
|
||||
RankedBallot { rankings: vec![a, spoiler, b] },
|
||||
RankedBallot { rankings: vec![spoiler, a, b] }, // Spoiler fans prefer A
|
||||
RankedBallot { rankings: vec![b, a, spoiler] },
|
||||
RankedBallot { rankings: vec![b, a, spoiler] },
|
||||
RankedBallot {
|
||||
rankings: vec![a, spoiler, b],
|
||||
},
|
||||
RankedBallot {
|
||||
rankings: vec![a, spoiler, b],
|
||||
},
|
||||
RankedBallot {
|
||||
rankings: vec![spoiler, a, b],
|
||||
}, // Spoiler fans prefer A
|
||||
RankedBallot {
|
||||
rankings: vec![b, a, spoiler],
|
||||
},
|
||||
RankedBallot {
|
||||
rankings: vec![b, a, spoiler],
|
||||
},
|
||||
];
|
||||
|
||||
let result = calculate(&options, &ballots);
|
||||
|
||||
|
||||
// Without RCV, spoiler might split A's vote
|
||||
// With RCV, spoiler eliminated, vote goes to A
|
||||
// Round 1: A=2, B=2, Spoiler=1 -> Spoiler eliminated
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
@ -80,7 +83,7 @@ pub fn calculate(options: &[Uuid], ballots: &[ScoreBallot]) -> VotingResult {
|
|||
|
||||
for ballot in ballots {
|
||||
let ballot_scores: HashMap<Uuid, i32> = ballot.scores.iter().cloned().collect();
|
||||
|
||||
|
||||
let score_a = ballot_scores.get(&finalist_a).copied().unwrap_or(0);
|
||||
let score_b = ballot_scores.get(&finalist_b).copied().unwrap_or(0);
|
||||
|
||||
|
|
@ -100,13 +103,15 @@ pub fn calculate(options: &[Uuid], ballots: &[ScoreBallot]) -> VotingResult {
|
|||
};
|
||||
|
||||
// Build final ranking
|
||||
let mut ranking: Vec<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