mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-02-09 21:13:09 +00:00
fmt: rustfmt backend
This commit is contained in:
parent
a889bc3ff3
commit
99c0c300b5
56 changed files with 2692 additions and 1624 deletions
|
|
@ -136,12 +136,33 @@ async fn export_data(
|
||||||
|
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/communities/{community_id}/analytics/dashboard", get(get_dashboard))
|
.route(
|
||||||
.route("/api/communities/{community_id}/analytics/health", get(get_health))
|
"/api/communities/{community_id}/analytics/dashboard",
|
||||||
.route("/api/communities/{community_id}/analytics/participation", get(get_participation_trends))
|
get(get_dashboard),
|
||||||
.route("/api/communities/{community_id}/analytics/delegation", get(get_delegation_analytics))
|
)
|
||||||
.route("/api/communities/{community_id}/analytics/decision-load", get(get_decision_load))
|
.route(
|
||||||
.route("/api/communities/{community_id}/analytics/voting-methods", get(get_voting_method_comparison))
|
"/api/communities/{community_id}/analytics/health",
|
||||||
.route("/api/communities/{community_id}/analytics/export", get(export_data))
|
get(get_health),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/communities/{community_id}/analytics/participation",
|
||||||
|
get(get_participation_trends),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/communities/{community_id}/analytics/delegation",
|
||||||
|
get(get_delegation_analytics),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/communities/{community_id}/analytics/decision-load",
|
||||||
|
get(get_decision_load),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/communities/{community_id}/analytics/voting-methods",
|
||||||
|
get(get_voting_method_comparison),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/communities/{community_id}/analytics/export",
|
||||||
|
get(export_data),
|
||||||
|
)
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@ use axum::{
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
|
use super::permissions::{perms, require_permission};
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use super::permissions::{require_permission, perms};
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
|
|
@ -72,7 +72,7 @@ async fn list_pending_registrations(
|
||||||
require_permission(&pool, auth.user_id, perms::USER_MANAGE, None).await?;
|
require_permission(&pool, auth.user_id, perms::USER_MANAGE, None).await?;
|
||||||
|
|
||||||
let status_filter = query.status.unwrap_or_else(|| "pending".to_string());
|
let status_filter = query.status.unwrap_or_else(|| "pending".to_string());
|
||||||
|
|
||||||
let registrations = sqlx::query!(
|
let registrations = sqlx::query!(
|
||||||
r#"SELECT id, username, email, display_name, status, created_at, expires_at
|
r#"SELECT id, username, email, display_name, status, created_at, expires_at
|
||||||
FROM pending_registrations
|
FROM pending_registrations
|
||||||
|
|
@ -85,15 +85,20 @@ async fn list_pending_registrations(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(registrations.into_iter().map(|r| PendingRegistration {
|
Ok(Json(
|
||||||
id: r.id,
|
registrations
|
||||||
username: r.username,
|
.into_iter()
|
||||||
email: r.email,
|
.map(|r| PendingRegistration {
|
||||||
display_name: r.display_name,
|
id: r.id,
|
||||||
status: r.status.unwrap_or_default(),
|
username: r.username,
|
||||||
created_at: r.created_at.unwrap_or_else(Utc::now),
|
email: r.email,
|
||||||
expires_at: r.expires_at,
|
display_name: r.display_name,
|
||||||
}).collect()))
|
status: r.status.unwrap_or_default(),
|
||||||
|
created_at: r.created_at.unwrap_or_else(Utc::now),
|
||||||
|
expires_at: r.expires_at,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Review a pending registration (approve or reject)
|
/// Review a pending registration (approve or reject)
|
||||||
|
|
@ -117,9 +122,15 @@ async fn review_registration(
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
let msg = e.to_string();
|
let msg = e.to_string();
|
||||||
if msg.contains("not found") || msg.contains("already processed") {
|
if msg.contains("not found") || msg.contains("already processed") {
|
||||||
(StatusCode::NOT_FOUND, "Pending registration not found or already processed".to_string())
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Pending registration not found or already processed".to_string(),
|
||||||
|
)
|
||||||
} else if msg.contains("expired") {
|
} else if msg.contains("expired") {
|
||||||
(StatusCode::GONE, "Registration request has expired".to_string())
|
(
|
||||||
|
StatusCode::GONE,
|
||||||
|
"Registration request has expired".to_string(),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, msg)
|
(StatusCode::INTERNAL_SERVER_ERROR, msg)
|
||||||
}
|
}
|
||||||
|
|
@ -147,13 +158,11 @@ async fn review_registration(
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_admin: bool = sqlx::query_scalar!(
|
let is_admin: bool =
|
||||||
"SELECT is_admin FROM users WHERE id = $1",
|
sqlx::query_scalar!("SELECT is_admin FROM users WHERE id = $1", new_user_id)
|
||||||
new_user_id
|
.fetch_one(&pool)
|
||||||
)
|
.await
|
||||||
.fetch_one(&pool)
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
if is_admin {
|
if is_admin {
|
||||||
let admin_role_id: Option<Uuid> = sqlx::query_scalar!(
|
let admin_role_id: Option<Uuid> = sqlx::query_scalar!(
|
||||||
|
|
@ -204,7 +213,10 @@ async fn review_registration(
|
||||||
message: "Registration rejected".to_string(),
|
message: "Registration rejected".to_string(),
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
Err((StatusCode::NOT_FOUND, "Pending registration not found or already processed".to_string()))
|
Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Pending registration not found or already processed".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -222,7 +234,7 @@ async fn list_pending_communities(
|
||||||
require_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?;
|
require_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?;
|
||||||
|
|
||||||
let status_filter = query.status.unwrap_or_else(|| "pending".to_string());
|
let status_filter = query.status.unwrap_or_else(|| "pending".to_string());
|
||||||
|
|
||||||
let communities = sqlx::query!(
|
let communities = sqlx::query!(
|
||||||
r#"SELECT pc.id, pc.name, pc.slug, pc.description, pc.requested_by,
|
r#"SELECT pc.id, pc.name, pc.slug, pc.description, pc.requested_by,
|
||||||
pc.status, pc.created_at, u.username as requester_username
|
pc.status, pc.created_at, u.username as requester_username
|
||||||
|
|
@ -237,16 +249,21 @@ async fn list_pending_communities(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(communities.into_iter().map(|c| PendingCommunity {
|
Ok(Json(
|
||||||
id: c.id,
|
communities
|
||||||
name: c.name,
|
.into_iter()
|
||||||
slug: c.slug,
|
.map(|c| PendingCommunity {
|
||||||
description: c.description,
|
id: c.id,
|
||||||
requested_by: c.requested_by,
|
name: c.name,
|
||||||
requested_by_username: Some(c.requester_username),
|
slug: c.slug,
|
||||||
status: c.status.unwrap_or_default(),
|
description: c.description,
|
||||||
created_at: c.created_at.unwrap_or_else(Utc::now),
|
requested_by: c.requested_by,
|
||||||
}).collect()))
|
requested_by_username: Some(c.requester_username),
|
||||||
|
status: c.status.unwrap_or_default(),
|
||||||
|
created_at: c.created_at.unwrap_or_else(Utc::now),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Review a pending community request (approve or reject)
|
/// Review a pending community request (approve or reject)
|
||||||
|
|
@ -260,21 +277,21 @@ async fn review_community(
|
||||||
|
|
||||||
if req.approve {
|
if req.approve {
|
||||||
// Approve community
|
// Approve community
|
||||||
let result = sqlx::query_scalar!(
|
let result =
|
||||||
"SELECT approve_community($1, $2)",
|
sqlx::query_scalar!("SELECT approve_community($1, $2)", pending_id, auth.user_id)
|
||||||
pending_id,
|
.fetch_one(&pool)
|
||||||
auth.user_id
|
.await
|
||||||
)
|
.map_err(|e| {
|
||||||
.fetch_one(&pool)
|
let msg = e.to_string();
|
||||||
.await
|
if msg.contains("not found") || msg.contains("already processed") {
|
||||||
.map_err(|e| {
|
(
|
||||||
let msg = e.to_string();
|
StatusCode::NOT_FOUND,
|
||||||
if msg.contains("not found") || msg.contains("already processed") {
|
"Pending community not found or already processed".to_string(),
|
||||||
(StatusCode::NOT_FOUND, "Pending community not found or already processed".to_string())
|
)
|
||||||
} else {
|
} else {
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, msg)
|
(StatusCode::INTERNAL_SERVER_ERROR, msg)
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Json(ReviewResponse {
|
Ok(Json(ReviewResponse {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -301,7 +318,10 @@ async fn review_community(
|
||||||
message: "Community request rejected".to_string(),
|
message: "Community request rejected".to_string(),
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
Err((StatusCode::NOT_FOUND, "Pending community not found or already processed".to_string()))
|
Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Pending community not found or already processed".to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -312,8 +332,14 @@ async fn review_community(
|
||||||
|
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/approvals/registrations", get(list_pending_registrations))
|
.route(
|
||||||
.route("/api/approvals/registrations/{id}", post(review_registration))
|
"/api/approvals/registrations",
|
||||||
|
get(list_pending_registrations),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/approvals/registrations/{id}",
|
||||||
|
post(review_registration),
|
||||||
|
)
|
||||||
.route("/api/approvals/communities", get(list_pending_communities))
|
.route("/api/approvals/communities", get(list_pending_communities))
|
||||||
.route("/api/approvals/communities/{id}", post(review_community))
|
.route("/api/approvals/communities/{id}", post(review_community))
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
use axum::{extract::State, http::StatusCode, routing::{get, post}, Extension, Json, Router};
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
|
routing::{get, post},
|
||||||
|
Extension, Json, Router,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -68,10 +73,16 @@ async fn register(
|
||||||
|
|
||||||
let registration_mode = if let Some(s) = &settings {
|
let registration_mode = if let Some(s) = &settings {
|
||||||
if !s.registration_enabled {
|
if !s.registration_enabled {
|
||||||
return Err((StatusCode::FORBIDDEN, "Registration is currently disabled".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Registration is currently disabled".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if s.registration_mode == "invite_only" && req.invitation_code.is_none() {
|
if s.registration_mode == "invite_only" && req.invitation_code.is_none() {
|
||||||
return Err((StatusCode::FORBIDDEN, "Registration requires an invitation code".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Registration requires an invitation code".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
s.registration_mode.clone()
|
s.registration_mode.clone()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -90,24 +101,41 @@ async fn register(
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
match validation {
|
match validation {
|
||||||
None => return Err((StatusCode::BAD_REQUEST, "Invalid invitation code".to_string())),
|
None => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Invalid invitation code".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
Some(inv) => {
|
Some(inv) => {
|
||||||
if !inv.is_active.unwrap_or(false) {
|
if !inv.is_active.unwrap_or(false) {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Invitation is no longer active".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Invitation is no longer active".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if let Some(exp) = inv.expires_at {
|
if let Some(exp) = inv.expires_at {
|
||||||
if exp < chrono::Utc::now() {
|
if exp < chrono::Utc::now() {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Invitation has expired".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Invitation has expired".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(max) = inv.max_uses {
|
if let Some(max) = inv.max_uses {
|
||||||
if inv.uses_count.unwrap_or(0) >= max {
|
if inv.uses_count.unwrap_or(0) >= max {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Invitation has reached maximum uses".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Invitation has reached maximum uses".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(email) = &inv.email {
|
if let Some(email) = &inv.email {
|
||||||
if email != &req.email {
|
if email != &req.email {
|
||||||
return Err((StatusCode::BAD_REQUEST, "This invitation is for a different email address".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"This invitation is for a different email address".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inv.community_id
|
inv.community_id
|
||||||
|
|
@ -162,7 +190,10 @@ async fn register(
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Return a special response indicating pending approval
|
// Return a special response indicating pending approval
|
||||||
return Err((StatusCode::ACCEPTED, "Registration submitted for approval. You will be notified when approved.".to_string()));
|
return Err((
|
||||||
|
StatusCode::ACCEPTED,
|
||||||
|
"Registration submitted for approval. You will be notified when approved.".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct registration (open mode, invite_only with valid invite, or first user)
|
// Direct registration (open mode, invite_only with valid invite, or first user)
|
||||||
|
|
@ -183,7 +214,10 @@ async fn register(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
if e.to_string().contains("duplicate key") {
|
if e.to_string().contains("duplicate key") {
|
||||||
(StatusCode::CONFLICT, "Username or email already exists".to_string())
|
(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"Username or email already exists".to_string(),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||||
}
|
}
|
||||||
|
|
@ -234,10 +268,15 @@ async fn register(
|
||||||
|
|
||||||
// Use invitation if provided (records usage and links user)
|
// Use invitation if provided (records usage and links user)
|
||||||
if let Some(code) = &req.invitation_code {
|
if let Some(code) = &req.invitation_code {
|
||||||
sqlx::query!("SELECT use_invitation($1, $2, $3)", code, user.id, req.email.as_str())
|
sqlx::query!(
|
||||||
.fetch_one(&pool)
|
"SELECT use_invitation($1, $2, $3)",
|
||||||
.await
|
code,
|
||||||
.ok(); // Ignore errors - user is already created
|
user.id,
|
||||||
|
req.email.as_str()
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.ok(); // Ignore errors - user is already created
|
||||||
}
|
}
|
||||||
|
|
||||||
// If invitation was for a specific community, add user as member
|
// If invitation was for a specific community, add user as member
|
||||||
|
|
@ -283,7 +322,10 @@ async fn login(
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::UNAUTHORIZED, "Demo account not found".to_string()))?;
|
.ok_or((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Demo account not found".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
let token = create_token(user.id, &user.username, &config.jwt_secret)
|
let token = create_token(user.id, &user.username, &config.jwt_secret)
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
routing::get,
|
routing::get,
|
||||||
Extension,
|
Extension, Json, Router,
|
||||||
Json, Router,
|
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -14,8 +13,8 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use crate::plugins::HookContext;
|
use crate::plugins::HookContext;
|
||||||
use crate::plugins::PluginManager;
|
|
||||||
use crate::plugins::PluginError;
|
use crate::plugins::PluginError;
|
||||||
|
use crate::plugins::PluginManager;
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct Comment {
|
pub struct Comment {
|
||||||
|
|
@ -36,7 +35,10 @@ pub struct CreateComment {
|
||||||
|
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/proposals/{proposal_id}/comments", get(list_comments).post(create_comment))
|
.route(
|
||||||
|
"/api/proposals/{proposal_id}/comments",
|
||||||
|
get(list_comments).post(create_comment),
|
||||||
|
)
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Extension,
|
Extension, Json, Router,
|
||||||
Json, Router,
|
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -11,10 +10,10 @@ use sqlx::PgPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::permissions::{perms, user_has_permission};
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use crate::models::community::CommunityResponse;
|
use crate::models::community::CommunityResponse;
|
||||||
use crate::plugins::{HookContext, PluginManager};
|
use crate::plugins::{HookContext, PluginManager};
|
||||||
use super::permissions::{user_has_permission, perms};
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateCommunityRequest {
|
pub struct CreateCommunityRequest {
|
||||||
|
|
@ -35,7 +34,10 @@ use axum::routing::put;
|
||||||
|
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/communities", get(list_communities).post(create_community))
|
.route(
|
||||||
|
"/api/communities",
|
||||||
|
get(list_communities).post(create_community),
|
||||||
|
)
|
||||||
.route("/api/communities/{id}", put(update_community))
|
.route("/api/communities/{id}", put(update_community))
|
||||||
.route("/api/communities/{id}/details", get(get_community_details))
|
.route("/api/communities/{id}/details", get(get_community_details))
|
||||||
.route("/api/communities/{id}/join", post(join_community))
|
.route("/api/communities/{id}/join", post(join_community))
|
||||||
|
|
@ -58,7 +60,12 @@ async fn list_communities(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
Ok(Json(communities.into_iter().map(CommunityResponse::from).collect()))
|
Ok(Json(
|
||||||
|
communities
|
||||||
|
.into_iter()
|
||||||
|
.map(CommunityResponse::from)
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_community(
|
async fn create_community(
|
||||||
|
|
@ -68,28 +75,34 @@ async fn create_community(
|
||||||
Json(req): Json<CreateCommunityRequest>,
|
Json(req): Json<CreateCommunityRequest>,
|
||||||
) -> Result<Json<CommunityResponse>, (StatusCode, String)> {
|
) -> Result<Json<CommunityResponse>, (StatusCode, String)> {
|
||||||
// Check platform mode for community creation permissions
|
// Check platform mode for community creation permissions
|
||||||
let settings = sqlx::query!(
|
let settings = sqlx::query!("SELECT platform_mode FROM instance_settings LIMIT 1")
|
||||||
"SELECT platform_mode FROM instance_settings LIMIT 1"
|
.fetch_optional(&pool)
|
||||||
)
|
.await
|
||||||
.fetch_optional(&pool)
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
if let Some(s) = settings {
|
if let Some(s) = settings {
|
||||||
match s.platform_mode.as_str() {
|
match s.platform_mode.as_str() {
|
||||||
"single_community" => {
|
"single_community" => {
|
||||||
return Err((StatusCode::FORBIDDEN, "This platform is dedicated to a single community".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"This platform is dedicated to a single community".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
"admin_only" => {
|
"admin_only" => {
|
||||||
// Check platform admin or community create permission
|
// Check platform admin or community create permission
|
||||||
let can_create = user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?;
|
let can_create =
|
||||||
|
user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?;
|
||||||
if !can_create {
|
if !can_create {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only administrators can create communities".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only administrators can create communities".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"approval" => {
|
"approval" => {
|
||||||
// Check if user has direct create permission (admins bypass approval)
|
// Check if user has direct create permission (admins bypass approval)
|
||||||
let can_create = user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?;
|
let can_create =
|
||||||
|
user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?;
|
||||||
if !can_create {
|
if !can_create {
|
||||||
// Create pending community request instead
|
// Create pending community request instead
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
|
|
@ -104,13 +117,20 @@ async fn create_community(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
if e.to_string().contains("duplicate key") {
|
if e.to_string().contains("duplicate key") {
|
||||||
(StatusCode::CONFLICT, "A community with this slug already exists or is pending approval".to_string())
|
(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"A community with this slug already exists or is pending approval"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
return Err((StatusCode::ACCEPTED, "Community request submitted for approval".to_string()));
|
return Err((
|
||||||
|
StatusCode::ACCEPTED,
|
||||||
|
"Community request submitted for approval".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {} // "open" mode - anyone can create
|
_ => {} // "open" mode - anyone can create
|
||||||
|
|
@ -132,7 +152,10 @@ async fn create_community(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
if e.to_string().contains("duplicate key") {
|
if e.to_string().contains("duplicate key") {
|
||||||
(StatusCode::CONFLICT, "Community name or slug already exists".to_string())
|
(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"Community name or slug already exists".to_string(),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||||
}
|
}
|
||||||
|
|
@ -153,7 +176,11 @@ async fn create_community(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
tracing::info!("Community '{}' created by user {}", community.name, auth.username);
|
tracing::info!(
|
||||||
|
"Community '{}' created by user {}",
|
||||||
|
community.name,
|
||||||
|
auth.username
|
||||||
|
);
|
||||||
|
|
||||||
Ok(Json(CommunityResponse::from(community)))
|
Ok(Json(CommunityResponse::from(community)))
|
||||||
}
|
}
|
||||||
|
|
@ -237,7 +264,10 @@ async fn join_community(
|
||||||
return Err((StatusCode::BAD_REQUEST, err.to_string()));
|
return Err((StatusCode::BAD_REQUEST, err.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let role = filtered.get("role").and_then(|v| v.as_str()).unwrap_or("member");
|
let role = filtered
|
||||||
|
.get("role")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("member");
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"INSERT INTO community_members (user_id, community_id, role) VALUES ($1, $2, $3)",
|
"INSERT INTO community_members (user_id, community_id, role) VALUES ($1, $2, $3)",
|
||||||
|
|
@ -254,12 +284,18 @@ async fn join_community(
|
||||||
community_id: Some(community_id),
|
community_id: Some(community_id),
|
||||||
actor_user_id: Some(auth.user_id),
|
actor_user_id: Some(auth.user_id),
|
||||||
};
|
};
|
||||||
let _ = plugins.do_action("member.join", ctx, serde_json::json!({
|
let _ = plugins
|
||||||
"community_id": community_id.to_string(),
|
.do_action(
|
||||||
"user_id": auth.user_id.to_string(),
|
"member.join",
|
||||||
"username": auth.username.clone(),
|
ctx,
|
||||||
"role": role,
|
serde_json::json!({
|
||||||
})).await;
|
"community_id": community_id.to_string(),
|
||||||
|
"user_id": auth.user_id.to_string(),
|
||||||
|
"username": auth.username.clone(),
|
||||||
|
"role": role,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
tracing::info!("User {} joined community {}", auth.username, community_id);
|
tracing::info!("User {} joined community {}", auth.username, community_id);
|
||||||
Ok(Json(serde_json::json!({"status": "joined"})))
|
Ok(Json(serde_json::json!({"status": "joined"})))
|
||||||
|
|
@ -300,7 +336,10 @@ async fn leave_community(
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
if admin_count <= 1 {
|
if admin_count <= 1 {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Cannot leave: you are the only admin".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Cannot leave: you are the only admin".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -339,12 +378,18 @@ async fn leave_community(
|
||||||
community_id: Some(community_id),
|
community_id: Some(community_id),
|
||||||
actor_user_id: Some(auth.user_id),
|
actor_user_id: Some(auth.user_id),
|
||||||
};
|
};
|
||||||
let _ = plugins.do_action("member.leave", ctx, serde_json::json!({
|
let _ = plugins
|
||||||
"community_id": community_id.to_string(),
|
.do_action(
|
||||||
"user_id": auth.user_id.to_string(),
|
"member.leave",
|
||||||
"username": auth.username.clone(),
|
ctx,
|
||||||
"role": role,
|
serde_json::json!({
|
||||||
})).await;
|
"community_id": community_id.to_string(),
|
||||||
|
"user_id": auth.user_id.to_string(),
|
||||||
|
"username": auth.username.clone(),
|
||||||
|
"role": role,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
tracing::info!("User {} left community {}", auth.username, community_id);
|
tracing::info!("User {} left community {}", auth.username, community_id);
|
||||||
Ok(Json(serde_json::json!({"status": "left"})))
|
Ok(Json(serde_json::json!({"status": "left"})))
|
||||||
|
|
@ -429,7 +474,12 @@ async fn my_communities(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(communities.into_iter().map(CommunityResponse::from).collect()))
|
Ok(Json(
|
||||||
|
communities
|
||||||
|
.into_iter()
|
||||||
|
.map(CommunityResponse::from)
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|
@ -458,21 +508,24 @@ async fn recent_activity(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let mut activities: Vec<ActivityItem> = proposals.into_iter().map(|p| {
|
let mut activities: Vec<ActivityItem> = proposals
|
||||||
let desc = match p.status.as_str() {
|
.into_iter()
|
||||||
"voting" => "Now open for voting",
|
.map(|p| {
|
||||||
"discussion" => "Open for discussion",
|
let desc = match p.status.as_str() {
|
||||||
"closed" => "Voting completed",
|
"voting" => "Now open for voting",
|
||||||
_ => "New proposal created",
|
"discussion" => "Open for discussion",
|
||||||
};
|
"closed" => "Voting completed",
|
||||||
ActivityItem {
|
_ => "New proposal created",
|
||||||
activity_type: "proposal".to_string(),
|
};
|
||||||
title: p.title,
|
ActivityItem {
|
||||||
description: desc.to_string(),
|
activity_type: "proposal".to_string(),
|
||||||
link: format!("/proposals/{}", p.id),
|
title: p.title,
|
||||||
created_at: p.created_at,
|
description: desc.to_string(),
|
||||||
}
|
link: format!("/proposals/{}", p.id),
|
||||||
}).collect();
|
created_at: p.created_at,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let communities = sqlx::query!(
|
let communities = sqlx::query!(
|
||||||
"SELECT name, slug, created_at FROM communities WHERE is_active = true ORDER BY created_at DESC LIMIT 5"
|
"SELECT name, slug, created_at FROM communities WHERE is_active = true ORDER BY created_at DESC LIMIT 5"
|
||||||
|
|
@ -518,10 +571,16 @@ async fn update_community(
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::FORBIDDEN, "Not a member of this community".to_string()))?;
|
.ok_or((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Not a member of this community".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
if membership.role != "admin" {
|
if membership.role != "admin" {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only admins can edit the community".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only admins can edit the community".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated = sqlx::query_as!(
|
let updated = sqlx::query_as!(
|
||||||
|
|
|
||||||
|
|
@ -269,16 +269,37 @@ async fn add_to_mediator_pool(
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
// Community conflicts
|
// Community conflicts
|
||||||
.route("/api/communities/{community_id}/conflicts", get(get_active_conflicts).post(report_conflict))
|
.route(
|
||||||
.route("/api/communities/{community_id}/conflicts/stats", get(get_statistics))
|
"/api/communities/{community_id}/conflicts",
|
||||||
.route("/api/communities/{community_id}/mediators", post(add_to_mediator_pool))
|
get(get_active_conflicts).post(report_conflict),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/communities/{community_id}/conflicts/stats",
|
||||||
|
get(get_statistics),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/communities/{community_id}/mediators",
|
||||||
|
post(add_to_mediator_pool),
|
||||||
|
)
|
||||||
// Individual conflict operations
|
// Individual conflict operations
|
||||||
.route("/api/conflicts/{conflict_id}", get(get_conflict))
|
.route("/api/conflicts/{conflict_id}", get(get_conflict))
|
||||||
.route("/api/conflicts/{conflict_id}/status", post(transition_status))
|
.route(
|
||||||
.route("/api/conflicts/{conflict_id}/compromise", post(propose_compromise))
|
"/api/conflicts/{conflict_id}/status",
|
||||||
.route("/api/conflicts/{conflict_id}/session", post(schedule_session))
|
post(transition_status),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/conflicts/{conflict_id}/compromise",
|
||||||
|
post(propose_compromise),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/conflicts/{conflict_id}/session",
|
||||||
|
post(schedule_session),
|
||||||
|
)
|
||||||
.route("/api/conflicts/{conflict_id}/note", post(add_note))
|
.route("/api/conflicts/{conflict_id}/note", post(add_note))
|
||||||
// Compromise responses
|
// Compromise responses
|
||||||
.route("/api/compromises/{proposal_id}/respond", post(respond_to_compromise))
|
.route(
|
||||||
|
"/api/compromises/{proposal_id}/respond",
|
||||||
|
post(respond_to_compromise),
|
||||||
|
)
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ use axum::{
|
||||||
routing::{delete, get},
|
routing::{delete, get},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
|
|
||||||
|
|
@ -56,7 +56,9 @@ pub struct CreateDelegationRequest {
|
||||||
pub weight: f64,
|
pub weight: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_weight() -> f64 { 1.0 }
|
fn default_weight() -> f64 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct DelegateProfile {
|
pub struct DelegateProfile {
|
||||||
|
|
@ -134,35 +136,56 @@ async fn create_delegation(
|
||||||
) -> Result<Json<Delegation>, (StatusCode, String)> {
|
) -> Result<Json<Delegation>, (StatusCode, String)> {
|
||||||
// Validate weight
|
// Validate weight
|
||||||
if req.weight <= 0.0 || req.weight > 1.0 {
|
if req.weight <= 0.0 || req.weight > 1.0 {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Weight must be between 0 and 1".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Weight must be between 0 and 1".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate scope references to avoid DB constraint errors
|
// Validate scope references to avoid DB constraint errors
|
||||||
match req.scope {
|
match req.scope {
|
||||||
DelegationScope::Global => {
|
DelegationScope::Global => {
|
||||||
if req.community_id.is_some() || req.topic_id.is_some() || req.proposal_id.is_some() {
|
if req.community_id.is_some() || req.topic_id.is_some() || req.proposal_id.is_some() {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Global delegation cannot include community/topic/proposal".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Global delegation cannot include community/topic/proposal".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DelegationScope::Community => {
|
DelegationScope::Community => {
|
||||||
if req.community_id.is_none() {
|
if req.community_id.is_none() {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Community delegation requires community_id".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Community delegation requires community_id".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if req.topic_id.is_some() || req.proposal_id.is_some() {
|
if req.topic_id.is_some() || req.proposal_id.is_some() {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Community delegation cannot include topic_id/proposal_id".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Community delegation cannot include topic_id/proposal_id".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DelegationScope::Topic => {
|
DelegationScope::Topic => {
|
||||||
if req.topic_id.is_none() {
|
if req.topic_id.is_none() {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Topic delegation requires topic_id".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Topic delegation requires topic_id".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if req.proposal_id.is_some() {
|
if req.proposal_id.is_some() {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Topic delegation cannot include proposal_id".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Topic delegation cannot include proposal_id".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DelegationScope::Proposal => {
|
DelegationScope::Proposal => {
|
||||||
if req.proposal_id.is_none() {
|
if req.proposal_id.is_none() {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Proposal delegation requires proposal_id".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Proposal delegation requires proposal_id".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -210,7 +233,10 @@ async fn create_delegation(
|
||||||
|
|
||||||
if let Some(p) = profile {
|
if let Some(p) = profile {
|
||||||
if !p.accepting_delegations {
|
if !p.accepting_delegations {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Delegate is not accepting delegations".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Delegate is not accepting delegations".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,21 +366,24 @@ async fn list_my_delegations(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(delegations.into_iter().map(|d| {
|
Ok(Json(
|
||||||
Delegation {
|
delegations
|
||||||
id: d.id,
|
.into_iter()
|
||||||
delegator_id: d.delegator_id,
|
.map(|d| Delegation {
|
||||||
delegate_id: d.delegate_id,
|
id: d.id,
|
||||||
delegate_username: Some(d.delegate_username),
|
delegator_id: d.delegator_id,
|
||||||
scope: d.scope,
|
delegate_id: d.delegate_id,
|
||||||
community_id: d.community_id,
|
delegate_username: Some(d.delegate_username),
|
||||||
topic_id: d.topic_id,
|
scope: d.scope,
|
||||||
proposal_id: d.proposal_id,
|
community_id: d.community_id,
|
||||||
weight: d.weight,
|
topic_id: d.topic_id,
|
||||||
is_active: d.is_active,
|
proposal_id: d.proposal_id,
|
||||||
created_at: d.created_at,
|
weight: d.weight,
|
||||||
}
|
is_active: d.is_active,
|
||||||
}).collect()))
|
created_at: d.created_at,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List delegations TO a user (they are the delegate)
|
/// List delegations TO a user (they are the delegate)
|
||||||
|
|
@ -376,21 +405,24 @@ async fn list_delegations_to_me(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(delegations.into_iter().map(|d| {
|
Ok(Json(
|
||||||
Delegation {
|
delegations
|
||||||
id: d.id,
|
.into_iter()
|
||||||
delegator_id: d.delegator_id,
|
.map(|d| Delegation {
|
||||||
delegate_id: d.delegate_id,
|
id: d.id,
|
||||||
delegate_username: Some(d.delegator_username),
|
delegator_id: d.delegator_id,
|
||||||
scope: d.scope,
|
delegate_id: d.delegate_id,
|
||||||
community_id: d.community_id,
|
delegate_username: Some(d.delegator_username),
|
||||||
topic_id: d.topic_id,
|
scope: d.scope,
|
||||||
proposal_id: d.proposal_id,
|
community_id: d.community_id,
|
||||||
weight: d.weight,
|
topic_id: d.topic_id,
|
||||||
is_active: d.is_active,
|
proposal_id: d.proposal_id,
|
||||||
created_at: d.created_at,
|
weight: d.weight,
|
||||||
}
|
is_active: d.is_active,
|
||||||
}).collect()))
|
created_at: d.created_at,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Revoke a delegation
|
/// Revoke a delegation
|
||||||
|
|
@ -552,16 +584,21 @@ async fn list_delegates(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(profiles.into_iter().map(|p| DelegateProfile {
|
Ok(Json(
|
||||||
user_id: p.user_id,
|
profiles
|
||||||
username: p.username,
|
.into_iter()
|
||||||
display_name: p.display_name,
|
.map(|p| DelegateProfile {
|
||||||
bio: p.bio,
|
user_id: p.user_id,
|
||||||
accepting_delegations: p.accepting_delegations,
|
username: p.username,
|
||||||
delegation_policy: p.delegation_policy,
|
display_name: p.display_name,
|
||||||
total_delegators: p.total_delegators,
|
bio: p.bio,
|
||||||
total_votes_cast: p.total_votes_cast,
|
accepting_delegations: p.accepting_delegations,
|
||||||
}).collect()))
|
delegation_policy: p.delegation_policy,
|
||||||
|
total_delegators: p.total_delegators,
|
||||||
|
total_votes_cast: p.total_votes_cast,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -604,10 +641,16 @@ async fn create_topic(
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::FORBIDDEN, "Not a member of this community".to_string()))?;
|
.ok_or((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Not a member of this community".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
if membership.role != "admin" && membership.role != "moderator" {
|
if membership.role != "admin" && membership.role != "moderator" {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only admins/moderators can create topics".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only admins/moderators can create topics".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let topic = sqlx::query_as!(
|
let topic = sqlx::query_as!(
|
||||||
|
|
@ -635,13 +678,25 @@ async fn create_topic(
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
// Delegations
|
// Delegations
|
||||||
.route("/api/delegations", get(list_my_delegations).post(create_delegation))
|
.route(
|
||||||
|
"/api/delegations",
|
||||||
|
get(list_my_delegations).post(create_delegation),
|
||||||
|
)
|
||||||
.route("/api/delegations/to-me", get(list_delegations_to_me))
|
.route("/api/delegations/to-me", get(list_delegations_to_me))
|
||||||
.route("/api/delegations/{delegation_id}", delete(revoke_delegation))
|
.route(
|
||||||
|
"/api/delegations/{delegation_id}",
|
||||||
|
delete(revoke_delegation),
|
||||||
|
)
|
||||||
// Delegate profiles
|
// Delegate profiles
|
||||||
.route("/api/delegates", get(list_delegates))
|
.route("/api/delegates", get(list_delegates))
|
||||||
.route("/api/delegates/me", get(get_my_profile).put(update_my_profile))
|
.route(
|
||||||
|
"/api/delegates/me",
|
||||||
|
get(get_my_profile).put(update_my_profile),
|
||||||
|
)
|
||||||
// Topics
|
// Topics
|
||||||
.route("/api/communities/{community_id}/topics", get(list_topics).post(create_topic))
|
.route(
|
||||||
|
"/api/communities/{community_id}/topics",
|
||||||
|
get(list_topics).post(create_topic),
|
||||||
|
)
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,14 @@ use axum::{
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use crate::plugins::builtin::structured_deliberation::{Argument, Summary, DeliberationService};
|
use crate::plugins::builtin::structured_deliberation::{Argument, DeliberationService, Summary};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
|
|
@ -167,11 +167,14 @@ async fn add_resource(
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
||||||
|
|
||||||
let can_add = proposal.author_id == auth.user_id
|
let can_add =
|
||||||
|| proposal.facilitator_id == Some(auth.user_id);
|
proposal.author_id == auth.user_id || proposal.facilitator_id == Some(auth.user_id);
|
||||||
|
|
||||||
if !can_add {
|
if !can_add {
|
||||||
return Err((StatusCode::FORBIDDEN, "Not authorized to add resources".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Not authorized to add resources".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let resource = sqlx::query_as!(
|
let resource = sqlx::query_as!(
|
||||||
|
|
@ -246,11 +249,16 @@ async fn get_read_status(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(statuses.into_iter().map(|s| ResourceReadStatus {
|
Ok(Json(
|
||||||
resource_id: s.resource_id,
|
statuses
|
||||||
has_read: s.read_at.is_some(),
|
.into_iter()
|
||||||
read_at: s.read_at,
|
.map(|s| ResourceReadStatus {
|
||||||
}).collect()))
|
resource_id: s.resource_id,
|
||||||
|
has_read: s.read_at.is_some(),
|
||||||
|
read_at: s.read_at,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set user's position on a proposal
|
/// Set user's position on a proposal
|
||||||
|
|
@ -401,14 +409,10 @@ async fn list_arguments(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<Json<Vec<Argument>>, (StatusCode, String)> {
|
) -> Result<Json<Vec<Argument>>, (StatusCode, String)> {
|
||||||
let limit = query.limit.unwrap_or(50);
|
let limit = query.limit.unwrap_or(50);
|
||||||
let arguments = DeliberationService::get_arguments(
|
let arguments =
|
||||||
&pool,
|
DeliberationService::get_arguments(&pool, proposal_id, query.stance.as_deref(), limit)
|
||||||
proposal_id,
|
.await
|
||||||
query.stance.as_deref(),
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
limit,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(Json(arguments))
|
Ok(Json(arguments))
|
||||||
}
|
}
|
||||||
|
|
@ -421,12 +425,16 @@ async fn add_argument(
|
||||||
Json(req): Json<AddArgumentRequest>,
|
Json(req): Json<AddArgumentRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, String)> {
|
) -> Result<Json<Value>, (StatusCode, String)> {
|
||||||
// Check if user can participate
|
// Check if user can participate
|
||||||
let can = DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment")
|
let can =
|
||||||
.await
|
DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment")
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if !can {
|
if !can {
|
||||||
return Err((StatusCode::FORBIDDEN, "Must read proposal content before participating".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Must read proposal content before participating".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let argument_id = DeliberationService::add_argument(
|
let argument_id = DeliberationService::add_argument(
|
||||||
|
|
@ -539,15 +547,20 @@ async fn check_can_participate(
|
||||||
Path(proposal_id): Path<Uuid>,
|
Path(proposal_id): Path<Uuid>,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<Json<CanParticipateResponse>, (StatusCode, String)> {
|
) -> Result<Json<CanParticipateResponse>, (StatusCode, String)> {
|
||||||
let can_comment = DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment")
|
let can_comment =
|
||||||
.await
|
DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment")
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let can_vote = DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "vote")
|
let can_vote =
|
||||||
.await
|
DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "vote")
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(CanParticipateResponse { can_comment, can_vote }))
|
Ok(Json(CanParticipateResponse {
|
||||||
|
can_comment,
|
||||||
|
can_vote,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get deliberation overview (metrics + top arguments + summaries)
|
/// Get deliberation overview (metrics + top arguments + summaries)
|
||||||
|
|
@ -569,24 +582,51 @@ async fn get_overview(
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
// Resources (inform phase)
|
// Resources (inform phase)
|
||||||
.route("/api/proposals/{proposal_id}/resources", get(list_resources).post(add_resource))
|
.route(
|
||||||
.route("/api/proposals/{proposal_id}/resources/read-status", get(get_read_status))
|
"/api/proposals/{proposal_id}/resources",
|
||||||
.route("/api/resources/{resource_id}/read", post(mark_resource_read))
|
get(list_resources).post(add_resource),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/proposals/{proposal_id}/resources/read-status",
|
||||||
|
get(get_read_status),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/resources/{resource_id}/read",
|
||||||
|
post(mark_resource_read),
|
||||||
|
)
|
||||||
// Positions (agreement visualization)
|
// Positions (agreement visualization)
|
||||||
.route("/api/proposals/{proposal_id}/positions", post(set_position))
|
.route("/api/proposals/{proposal_id}/positions", post(set_position))
|
||||||
.route("/api/proposals/{proposal_id}/positions/summary", get(get_position_summary))
|
.route(
|
||||||
|
"/api/proposals/{proposal_id}/positions/summary",
|
||||||
|
get(get_position_summary),
|
||||||
|
)
|
||||||
// Comment reactions (quality scoring)
|
// Comment reactions (quality scoring)
|
||||||
.route("/api/comments/{comment_id}/reactions", get(get_comment_reactions).post(react_to_comment))
|
.route(
|
||||||
|
"/api/comments/{comment_id}/reactions",
|
||||||
|
get(get_comment_reactions).post(react_to_comment),
|
||||||
|
)
|
||||||
// Arguments (structured debate) - wired to DeliberationService
|
// Arguments (structured debate) - wired to DeliberationService
|
||||||
.route("/api/proposals/{proposal_id}/arguments", get(list_arguments).post(add_argument))
|
.route(
|
||||||
|
"/api/proposals/{proposal_id}/arguments",
|
||||||
|
get(list_arguments).post(add_argument),
|
||||||
|
)
|
||||||
.route("/api/arguments/{argument_id}/vote", post(vote_argument))
|
.route("/api/arguments/{argument_id}/vote", post(vote_argument))
|
||||||
// Summaries (collaborative summaries) - wired to DeliberationService
|
// Summaries (collaborative summaries) - wired to DeliberationService
|
||||||
.route("/api/proposals/{proposal_id}/summaries", get(list_summaries).post(upsert_summary))
|
.route(
|
||||||
|
"/api/proposals/{proposal_id}/summaries",
|
||||||
|
get(list_summaries).post(upsert_summary),
|
||||||
|
)
|
||||||
.route("/api/summaries/{summary_id}/approve", post(approve_summary))
|
.route("/api/summaries/{summary_id}/approve", post(approve_summary))
|
||||||
// Reading/participation tracking - wired to DeliberationService
|
// Reading/participation tracking - wired to DeliberationService
|
||||||
.route("/api/proposals/{proposal_id}/reading", post(record_reading))
|
.route("/api/proposals/{proposal_id}/reading", post(record_reading))
|
||||||
.route("/api/proposals/{proposal_id}/can-participate", get(check_can_participate))
|
.route(
|
||||||
|
"/api/proposals/{proposal_id}/can-participate",
|
||||||
|
get(check_can_participate),
|
||||||
|
)
|
||||||
// Overview (combined metrics)
|
// Overview (combined metrics)
|
||||||
.route("/api/proposals/{proposal_id}/deliberation", get(get_overview))
|
.route(
|
||||||
|
"/api/proposals/{proposal_id}/deliberation",
|
||||||
|
get(get_overview),
|
||||||
|
)
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@ use serde_json::json;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use super::permissions::{perms, require_permission};
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::demo::{self, DEMO_ACCOUNTS};
|
use crate::demo::{self, DEMO_ACCOUNTS};
|
||||||
use super::permissions::{require_permission, perms};
|
|
||||||
|
|
||||||
/// Combined state for demo endpoints
|
/// Combined state for demo endpoints
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -24,9 +24,7 @@ pub struct DemoState {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get demo mode status and available accounts
|
/// Get demo mode status and available accounts
|
||||||
async fn get_demo_status(
|
async fn get_demo_status(State(state): State<DemoState>) -> impl IntoResponse {
|
||||||
State(state): State<DemoState>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"demo_mode": state.config.is_demo(),
|
"demo_mode": state.config.is_demo(),
|
||||||
"accounts": if state.config.is_demo() {
|
"accounts": if state.config.is_demo() {
|
||||||
|
|
@ -41,7 +39,7 @@ async fn get_demo_status(
|
||||||
"restrictions": if state.config.is_demo() {
|
"restrictions": if state.config.is_demo() {
|
||||||
vec![
|
vec![
|
||||||
"Cannot delete communities",
|
"Cannot delete communities",
|
||||||
"Cannot delete users",
|
"Cannot delete users",
|
||||||
"Cannot modify instance settings",
|
"Cannot modify instance settings",
|
||||||
"Data resets periodically"
|
"Data resets periodically"
|
||||||
]
|
]
|
||||||
|
|
@ -52,45 +50,42 @@ async fn get_demo_status(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset demo data to initial state (only in demo mode)
|
/// Reset demo data to initial state (only in demo mode)
|
||||||
async fn reset_demo(
|
async fn reset_demo(State(state): State<DemoState>, auth: AuthUser) -> impl IntoResponse {
|
||||||
State(state): State<DemoState>,
|
|
||||||
auth: AuthUser,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
if !state.config.is_demo() {
|
if !state.config.is_demo() {
|
||||||
return (
|
return (
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
Json(json!({"error": "Demo mode not enabled"}))
|
Json(json!({"error": "Demo mode not enabled"})),
|
||||||
).into_response();
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err((status, msg)) = require_permission(&state.pool, auth.user_id, perms::PLATFORM_ADMIN, None).await {
|
if let Err((status, msg)) =
|
||||||
|
require_permission(&state.pool, auth.user_id, perms::PLATFORM_ADMIN, None).await
|
||||||
|
{
|
||||||
return (status, Json(json!({"error": msg}))).into_response();
|
return (status, Json(json!({"error": msg}))).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
match demo::reset_demo_data(&state.pool).await {
|
match demo::reset_demo_data(&state.pool).await {
|
||||||
Ok(_) => (
|
Ok(_) => (
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
Json(json!({"success": true, "message": "Demo data has been reset to initial state"}))
|
Json(json!({"success": true, "message": "Demo data has been reset to initial state"})),
|
||||||
).into_response(),
|
)
|
||||||
|
.into_response(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to reset demo data: {}", e);
|
tracing::error!("Failed to reset demo data: {}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(json!({"error": "Failed to reset demo data"}))
|
Json(json!({"error": "Failed to reset demo data"})),
|
||||||
).into_response()
|
)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get demo communities summary
|
/// Get demo communities summary
|
||||||
async fn get_demo_communities(
|
async fn get_demo_communities(State(state): State<DemoState>) -> impl IntoResponse {
|
||||||
State(state): State<DemoState>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
if !state.config.is_demo() {
|
if !state.config.is_demo() {
|
||||||
return (
|
return (StatusCode::OK, Json(json!({"communities": []}))).into_response();
|
||||||
StatusCode::OK,
|
|
||||||
Json(json!({"communities": []}))
|
|
||||||
).into_response();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let communities = sqlx::query_as::<_, (String, String, String, i64, i64)>(
|
let communities = sqlx::query_as::<_, (String, String, String, i64, i64)>(
|
||||||
|
|
@ -104,38 +99,42 @@ async fn get_demo_communities(
|
||||||
FROM communities c
|
FROM communities c
|
||||||
WHERE c.slug IN ('aurora', 'civic-commons', 'makers')
|
WHERE c.slug IN ('aurora', 'civic-commons', 'makers')
|
||||||
ORDER BY c.name
|
ORDER BY c.name
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match communities {
|
match communities {
|
||||||
Ok(rows) => {
|
Ok(rows) => {
|
||||||
let communities: Vec<_> = rows.iter().map(|(name, slug, desc, members, proposals)| {
|
let communities: Vec<_> = rows
|
||||||
json!({
|
.iter()
|
||||||
"name": name,
|
.map(|(name, slug, desc, members, proposals)| {
|
||||||
"slug": slug,
|
json!({
|
||||||
"description": desc,
|
"name": name,
|
||||||
"member_count": members,
|
"slug": slug,
|
||||||
"proposal_count": proposals
|
"description": desc,
|
||||||
|
"member_count": members,
|
||||||
|
"proposal_count": proposals
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
|
|
||||||
(StatusCode::OK, Json(json!({"communities": communities}))).into_response()
|
(StatusCode::OK, Json(json!({"communities": communities}))).into_response()
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to fetch demo communities: {}", e);
|
tracing::error!("Failed to fetch demo communities: {}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(json!({"error": "Failed to fetch communities"}))
|
Json(json!({"error": "Failed to fetch communities"})),
|
||||||
).into_response()
|
)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(pool: PgPool, config: Arc<Config>) -> Router {
|
pub fn router(pool: PgPool, config: Arc<Config>) -> Router {
|
||||||
let state = DemoState { pool, config };
|
let state = DemoState { pool, config };
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/demo/status", get(get_demo_status))
|
.route("/api/demo/status", get(get_demo_status))
|
||||||
.route("/api/demo/reset", post(reset_demo))
|
.route("/api/demo/reset", post(reset_demo))
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,10 @@ async fn get_job(
|
||||||
|
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/communities/{community_id}/exports", get(get_available).post(create_job))
|
.route(
|
||||||
|
"/api/communities/{community_id}/exports",
|
||||||
|
get(get_available).post(create_job),
|
||||||
|
)
|
||||||
.route("/api/exports/{job_id}", get(get_job))
|
.route("/api/exports/{job_id}", get(get_job))
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,10 @@ async fn receive_federation_request(
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if !community_exists {
|
if !community_exists {
|
||||||
return Err((StatusCode::NOT_FOUND, "Community not found or inactive".to_string()));
|
return Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Community not found or inactive".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let request_id = FederationService::request_federation(
|
let request_id = FederationService::request_federation(
|
||||||
|
|
@ -199,12 +202,30 @@ async fn receive_federation_request(
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
// Instances
|
// Instances
|
||||||
.route("/api/federation/instances", get(get_instances).post(register_instance))
|
.route(
|
||||||
.route("/api/federation/instances/{instance_id}/trust", post(set_trust_level))
|
"/api/federation/instances",
|
||||||
|
get(get_instances).post(register_instance),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/federation/instances/{instance_id}/trust",
|
||||||
|
post(set_trust_level),
|
||||||
|
)
|
||||||
// Community federations
|
// Community federations
|
||||||
.route("/api/communities/{community_id}/federation", get(get_community_federations).post(request_federation))
|
.route(
|
||||||
.route("/api/communities/{community_id}/federation/stats", get(get_stats))
|
"/api/communities/{community_id}/federation",
|
||||||
.route("/api/communities/{community_id}/federation/request", post(receive_federation_request))
|
get(get_community_federations).post(request_federation),
|
||||||
.route("/api/federation/{federation_id}/approve", post(approve_federation))
|
)
|
||||||
|
.route(
|
||||||
|
"/api/communities/{community_id}/federation/stats",
|
||||||
|
get(get_stats),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/communities/{community_id}/federation/request",
|
||||||
|
post(receive_federation_request),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/federation/{federation_id}/approve",
|
||||||
|
post(approve_federation),
|
||||||
|
)
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ use axum::{
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
|
|
||||||
|
|
@ -148,12 +148,18 @@ async fn create_connection(
|
||||||
.ok_or((StatusCode::FORBIDDEN, "Not a community member".to_string()))?;
|
.ok_or((StatusCode::FORBIDDEN, "Not a community member".to_string()))?;
|
||||||
|
|
||||||
if membership.role != "admin" {
|
if membership.role != "admin" {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only admins can configure GitLab".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only admins can configure GitLab".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate GitLab URL
|
// Validate GitLab URL
|
||||||
if !req.gitlab_url.starts_with("https://") {
|
if !req.gitlab_url.starts_with("https://") {
|
||||||
return Err((StatusCode::BAD_REQUEST, "GitLab URL must use HTTPS".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"GitLab URL must use HTTPS".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let connection = sqlx::query!(
|
let connection = sqlx::query!(
|
||||||
|
|
@ -212,17 +218,22 @@ async fn list_issues(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(issues.into_iter().map(|i| GitLabIssue {
|
Ok(Json(
|
||||||
id: i.id,
|
issues
|
||||||
gitlab_iid: i.gitlab_iid,
|
.into_iter()
|
||||||
title: i.title,
|
.map(|i| GitLabIssue {
|
||||||
description: i.description,
|
id: i.id,
|
||||||
state: i.state,
|
gitlab_iid: i.gitlab_iid,
|
||||||
author_username: i.author_username,
|
title: i.title,
|
||||||
labels: i.labels.unwrap_or_default(),
|
description: i.description,
|
||||||
proposal_id: i.proposal_id,
|
state: i.state,
|
||||||
gitlab_created_at: i.gitlab_created_at,
|
author_username: i.author_username,
|
||||||
}).collect()))
|
labels: i.labels.unwrap_or_default(),
|
||||||
|
proposal_id: i.proposal_id,
|
||||||
|
gitlab_created_at: i.gitlab_created_at,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List GitLab merge requests for a community
|
/// List GitLab merge requests for a community
|
||||||
|
|
@ -245,19 +256,23 @@ async fn list_merge_requests(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(mrs.into_iter().map(|m| GitLabMergeRequest {
|
Ok(Json(
|
||||||
id: m.id,
|
mrs.into_iter()
|
||||||
gitlab_iid: m.gitlab_iid,
|
.map(|m| GitLabMergeRequest {
|
||||||
title: m.title,
|
id: m.id,
|
||||||
description: m.description,
|
gitlab_iid: m.gitlab_iid,
|
||||||
state: m.state,
|
title: m.title,
|
||||||
author_username: m.author_username,
|
description: m.description,
|
||||||
source_branch: m.source_branch,
|
state: m.state,
|
||||||
target_branch: m.target_branch,
|
author_username: m.author_username,
|
||||||
labels: m.labels.unwrap_or_default(),
|
source_branch: m.source_branch,
|
||||||
proposal_id: m.proposal_id,
|
target_branch: m.target_branch,
|
||||||
gitlab_created_at: m.gitlab_created_at,
|
labels: m.labels.unwrap_or_default(),
|
||||||
}).collect()))
|
proposal_id: m.proposal_id,
|
||||||
|
gitlab_created_at: m.gitlab_created_at,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create proposal from GitLab issue
|
/// Create proposal from GitLab issue
|
||||||
|
|
@ -292,7 +307,10 @@ async fn create_proposal_from_issue(
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Issue not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Issue not found".to_string()))?;
|
||||||
|
|
||||||
if issue.proposal_id.is_some() {
|
if issue.proposal_id.is_some() {
|
||||||
return Err((StatusCode::CONFLICT, "Issue already linked to a proposal".to_string()));
|
return Err((
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"Issue already linked to a proposal".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create proposal
|
// Create proposal
|
||||||
|
|
@ -340,9 +358,21 @@ async fn create_proposal_from_issue(
|
||||||
|
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/communities/{community_id}/gitlab", get(get_connection).post(create_connection))
|
.route(
|
||||||
.route("/api/communities/{community_id}/gitlab/issues", get(list_issues))
|
"/api/communities/{community_id}/gitlab",
|
||||||
.route("/api/communities/{community_id}/gitlab/merge-requests", get(list_merge_requests))
|
get(get_connection).post(create_connection),
|
||||||
.route("/api/communities/{community_id}/gitlab/issues/{issue_id}/create-proposal", post(create_proposal_from_issue))
|
)
|
||||||
|
.route(
|
||||||
|
"/api/communities/{community_id}/gitlab/issues",
|
||||||
|
get(list_issues),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/communities/{community_id}/gitlab/merge-requests",
|
||||||
|
get(list_merge_requests),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/communities/{community_id}/gitlab/issues/{issue_id}/create-proposal",
|
||||||
|
post(create_proposal_from_issue),
|
||||||
|
)
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,16 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
routing::{get, delete},
|
routing::{delete, get},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
|
use super::permissions::{perms, require_permission, user_has_permission};
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use super::permissions::{require_permission, user_has_permission, perms};
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
|
|
@ -43,7 +43,9 @@ pub struct CreateInvitationRequest {
|
||||||
pub expires_in_hours: Option<i32>,
|
pub expires_in_hours: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_max_uses() -> Option<i32> { Some(1) }
|
fn default_max_uses() -> Option<i32> {
|
||||||
|
Some(1)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ListInvitationsQuery {
|
pub struct ListInvitationsQuery {
|
||||||
|
|
@ -83,12 +85,15 @@ async fn create_invitation(
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate invitation code".to_string()))?;
|
.ok_or((
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to generate invitation code".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
// Calculate expiration
|
// Calculate expiration
|
||||||
let expires_at = req.expires_in_hours.map(|h| {
|
let expires_at = req
|
||||||
Utc::now() + chrono::Duration::hours(h as i64)
|
.expires_in_hours
|
||||||
});
|
.map(|h| Utc::now() + chrono::Duration::hours(h as i64));
|
||||||
|
|
||||||
let invite = sqlx::query!(
|
let invite = sqlx::query!(
|
||||||
r#"INSERT INTO invitations (code, created_by, email, community_id, max_uses, expires_at)
|
r#"INSERT INTO invitations (code, created_by, email, community_id, max_uses, expires_at)
|
||||||
|
|
@ -167,20 +172,25 @@ async fn list_invitations(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(invites.into_iter().map(|i| Invitation {
|
Ok(Json(
|
||||||
id: i.id,
|
invites
|
||||||
code: i.code,
|
.into_iter()
|
||||||
created_by: i.created_by,
|
.map(|i| Invitation {
|
||||||
created_by_username: Some(i.creator_username),
|
id: i.id,
|
||||||
email: i.email,
|
code: i.code,
|
||||||
community_id: i.community_id,
|
created_by: i.created_by,
|
||||||
community_name: Some(i.community_name),
|
created_by_username: Some(i.creator_username),
|
||||||
max_uses: i.max_uses,
|
email: i.email,
|
||||||
uses_count: i.uses_count.unwrap_or(0),
|
community_id: i.community_id,
|
||||||
expires_at: i.expires_at,
|
community_name: Some(i.community_name),
|
||||||
is_active: i.is_active.unwrap_or(true),
|
max_uses: i.max_uses,
|
||||||
created_at: i.created_at.unwrap_or_else(Utc::now),
|
uses_count: i.uses_count.unwrap_or(0),
|
||||||
}).collect()))
|
expires_at: i.expires_at,
|
||||||
|
is_active: i.is_active.unwrap_or(true),
|
||||||
|
created_at: i.created_at.unwrap_or_else(Utc::now),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate an invitation code (public endpoint for registration)
|
/// Validate an invitation code (public endpoint for registration)
|
||||||
|
|
@ -247,22 +257,31 @@ async fn revoke_invitation(
|
||||||
Path(invitation_id): Path<Uuid>,
|
Path(invitation_id): Path<Uuid>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
// Check ownership or admin
|
// Check ownership or admin
|
||||||
let invite = sqlx::query!("SELECT created_by FROM invitations WHERE id = $1", invitation_id)
|
let invite = sqlx::query!(
|
||||||
.fetch_optional(&pool)
|
"SELECT created_by FROM invitations WHERE id = $1",
|
||||||
.await
|
invitation_id
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
)
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Invitation not found".to_string()))?;
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "Invitation not found".to_string()))?;
|
||||||
|
|
||||||
let is_admin = user_has_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?;
|
let is_admin = user_has_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?;
|
||||||
|
|
||||||
if invite.created_by != auth.user_id && !is_admin {
|
if invite.created_by != auth.user_id && !is_admin {
|
||||||
return Err((StatusCode::FORBIDDEN, "Not authorized to revoke this invitation".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Not authorized to revoke this invitation".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query!("UPDATE invitations SET is_active = FALSE WHERE id = $1", invitation_id)
|
sqlx::query!(
|
||||||
.execute(&pool)
|
"UPDATE invitations SET is_active = FALSE WHERE id = $1",
|
||||||
.await
|
invitation_id
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({"success": true})))
|
Ok(Json(serde_json::json!({"success": true})))
|
||||||
}
|
}
|
||||||
|
|
@ -273,7 +292,10 @@ async fn revoke_invitation(
|
||||||
|
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/invitations", get(list_invitations).post(create_invitation))
|
.route(
|
||||||
|
"/api/invitations",
|
||||||
|
get(list_invitations).post(create_invitation),
|
||||||
|
)
|
||||||
.route("/api/invitations/validate/{code}", get(validate_invitation))
|
.route("/api/invitations/validate/{code}", get(validate_invitation))
|
||||||
.route("/api/invitations/{id}", delete(revoke_invitation))
|
.route("/api/invitations/{id}", delete(revoke_invitation))
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
|
|
|
||||||
|
|
@ -91,9 +91,10 @@ async fn compare_versions(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(req): Json<CompareVersionsRequest>,
|
Json(req): Json<CompareVersionsRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, String)> {
|
) -> Result<Json<Value>, (StatusCode, String)> {
|
||||||
let diff = LifecycleService::compare_versions(&pool, proposal_id, req.from_version, req.to_version)
|
let diff =
|
||||||
.await
|
LifecycleService::compare_versions(&pool, proposal_id, req.from_version, req.to_version)
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(diff))
|
Ok(Json(diff))
|
||||||
}
|
}
|
||||||
|
|
@ -203,9 +204,15 @@ async fn vote_amendment(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(req): Json<VoteAmendmentRequest>,
|
Json(req): Json<VoteAmendmentRequest>,
|
||||||
) -> Result<Json<Value>, (StatusCode, String)> {
|
) -> Result<Json<Value>, (StatusCode, String)> {
|
||||||
LifecycleService::vote_amendment(&pool, amendment_id, auth.user_id, &req.vote, req.comment.as_deref())
|
LifecycleService::vote_amendment(
|
||||||
.await
|
&pool,
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
amendment_id,
|
||||||
|
auth.user_id,
|
||||||
|
&req.vote,
|
||||||
|
req.comment.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({"success": true})))
|
Ok(Json(serde_json::json!({"success": true})))
|
||||||
}
|
}
|
||||||
|
|
@ -232,16 +239,37 @@ pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
// Versions
|
// Versions
|
||||||
.route("/api/proposals/{proposal_id}/versions", get(get_versions))
|
.route("/api/proposals/{proposal_id}/versions", get(get_versions))
|
||||||
.route("/api/proposals/{proposal_id}/versions/{version_number}", get(get_version))
|
.route(
|
||||||
.route("/api/proposals/{proposal_id}/versions/compare", post(compare_versions))
|
"/api/proposals/{proposal_id}/versions/{version_number}",
|
||||||
|
get(get_version),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/proposals/{proposal_id}/versions/compare",
|
||||||
|
post(compare_versions),
|
||||||
|
)
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
.route("/api/proposals/{proposal_id}/lifecycle", get(get_lifecycle_summary))
|
.route(
|
||||||
.route("/api/proposals/{proposal_id}/lifecycle/transition", post(transition_status))
|
"/api/proposals/{proposal_id}/lifecycle",
|
||||||
.route("/api/proposals/{proposal_id}/lifecycle/fork", post(fork_proposal))
|
get(get_lifecycle_summary),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/proposals/{proposal_id}/lifecycle/transition",
|
||||||
|
post(transition_status),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/proposals/{proposal_id}/lifecycle/fork",
|
||||||
|
post(fork_proposal),
|
||||||
|
)
|
||||||
.route("/api/proposals/{proposal_id}/forks", get(get_forks))
|
.route("/api/proposals/{proposal_id}/forks", get(get_forks))
|
||||||
// Amendments
|
// Amendments
|
||||||
.route("/api/proposals/{proposal_id}/amendments", get(get_amendments).post(propose_amendment))
|
.route(
|
||||||
|
"/api/proposals/{proposal_id}/amendments",
|
||||||
|
get(get_amendments).post(propose_amendment),
|
||||||
|
)
|
||||||
.route("/api/amendments/{amendment_id}/vote", post(vote_amendment))
|
.route("/api/amendments/{amendment_id}/vote", post(vote_amendment))
|
||||||
.route("/api/amendments/{amendment_id}/accept", post(accept_amendment))
|
.route(
|
||||||
|
"/api/amendments/{amendment_id}/accept",
|
||||||
|
post(accept_amendment),
|
||||||
|
)
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ pub mod auth;
|
||||||
pub mod comments;
|
pub mod comments;
|
||||||
pub mod communities;
|
pub mod communities;
|
||||||
pub mod conflicts;
|
pub mod conflicts;
|
||||||
pub mod deliberation;
|
|
||||||
pub mod delegation;
|
pub mod delegation;
|
||||||
|
pub mod deliberation;
|
||||||
pub mod demo;
|
pub mod demo;
|
||||||
pub mod exports;
|
pub mod exports;
|
||||||
pub mod federation;
|
pub mod federation;
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@ use axum::{
|
||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
|
use crate::api::permissions::{perms, require_permission};
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use crate::api::permissions::{require_permission, perms};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ModerationEntry {
|
pub struct ModerationEntry {
|
||||||
|
|
@ -34,7 +34,10 @@ pub struct CreateModerationEntry {
|
||||||
|
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/communities/{community_id}/moderation", get(list_moderation).post(create_moderation))
|
.route(
|
||||||
|
"/api/communities/{community_id}/moderation",
|
||||||
|
get(list_moderation).post(create_moderation),
|
||||||
|
)
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,7 +47,13 @@ async fn list_moderation(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<Json<Vec<ModerationEntry>>, (StatusCode, String)> {
|
) -> Result<Json<Vec<ModerationEntry>>, (StatusCode, String)> {
|
||||||
// Require permission to view moderation reports
|
// Require permission to view moderation reports
|
||||||
require_permission(&pool, auth.user_id, perms::MOD_VIEW_REPORTS, Some(community_id)).await?;
|
require_permission(
|
||||||
|
&pool,
|
||||||
|
auth.user_id,
|
||||||
|
perms::MOD_VIEW_REPORTS,
|
||||||
|
Some(community_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let entries = sqlx::query!(
|
let entries = sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -93,22 +102,49 @@ async fn create_moderation(
|
||||||
// Check specific permission based on action type
|
// Check specific permission based on action type
|
||||||
let has_permission = match req.action_type.as_str() {
|
let has_permission = match req.action_type.as_str() {
|
||||||
"ban" | "unban" | "suspend" | "unsuspend" => {
|
"ban" | "unban" | "suspend" | "unsuspend" => {
|
||||||
user_has_permission(&pool, auth.user_id, perms::MOD_BAN_USERS, Some(community_id)).await?
|
user_has_permission(
|
||||||
|
&pool,
|
||||||
|
auth.user_id,
|
||||||
|
perms::MOD_BAN_USERS,
|
||||||
|
Some(community_id),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
"remove_content" | "hide_content" | "restore_content" => {
|
"remove_content" | "hide_content" | "restore_content" => {
|
||||||
user_has_permission(&pool, auth.user_id, perms::MOD_REMOVE_CONTENT, Some(community_id)).await?
|
user_has_permission(
|
||||||
|
&pool,
|
||||||
|
auth.user_id,
|
||||||
|
perms::MOD_REMOVE_CONTENT,
|
||||||
|
Some(community_id),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
"admin_action" | "settings_change" => {
|
"admin_action" | "settings_change" => {
|
||||||
user_has_permission(&pool, auth.user_id, perms::COMMUNITY_ADMIN, Some(community_id)).await?
|
user_has_permission(
|
||||||
|
&pool,
|
||||||
|
auth.user_id,
|
||||||
|
perms::COMMUNITY_ADMIN,
|
||||||
|
Some(community_id),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Default to requiring community.moderate permission
|
// Default to requiring community.moderate permission
|
||||||
user_has_permission(&pool, auth.user_id, perms::COMMUNITY_MODERATE, Some(community_id)).await?
|
user_has_permission(
|
||||||
|
&pool,
|
||||||
|
auth.user_id,
|
||||||
|
perms::COMMUNITY_MODERATE,
|
||||||
|
Some(community_id),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !has_permission {
|
if !has_permission {
|
||||||
return Err((StatusCode::FORBIDDEN, format!("Permission required for action '{}'", req.action_type)));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
format!("Permission required for action '{}'", req.action_type),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let entry = sqlx::query!(
|
let entry = sqlx::query!(
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,10 @@ pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/ledger", get(list_entries))
|
.route("/api/ledger", get(list_entries))
|
||||||
.route("/api/ledger/entry/{id}", get(get_entry))
|
.route("/api/ledger/entry/{id}", get(get_entry))
|
||||||
.route("/api/ledger/target/{target_type}/{target_id}", get(get_target_history))
|
.route(
|
||||||
|
"/api/ledger/target/{target_type}/{target_id}",
|
||||||
|
get(get_target_history),
|
||||||
|
)
|
||||||
.route("/api/ledger/verify", get(verify_chain))
|
.route("/api/ledger/verify", get(verify_chain))
|
||||||
.route("/api/ledger/stats", get(get_stats))
|
.route("/api/ledger/stats", get(get_stats))
|
||||||
.route("/api/ledger/export", get(export_ledger))
|
.route("/api/ledger/export", get(export_ledger))
|
||||||
|
|
@ -151,14 +154,12 @@ async fn get_entry(
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<Value>)> {
|
) -> Result<impl IntoResponse, (StatusCode, Json<Value>)> {
|
||||||
// Use LedgerService for consistency
|
// Use LedgerService for consistency
|
||||||
let entry = LedgerService::get_entry(&pool, id)
|
let entry = LedgerService::get_entry(&pool, id).await.map_err(|e| {
|
||||||
.await
|
(
|
||||||
.map_err(|e| {
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
(
|
Json(json!({"error": e.to_string()})),
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
)
|
||||||
Json(json!({"error": e.to_string()})),
|
})?;
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
match entry {
|
match entry {
|
||||||
Some(e) => {
|
Some(e) => {
|
||||||
|
|
@ -367,9 +368,8 @@ async fn create_entry(
|
||||||
|
|
||||||
let actor_role = get_actor_role(&pool, auth.user_id, req.community_id).await?;
|
let actor_role = get_actor_role(&pool, auth.user_id, req.community_id).await?;
|
||||||
|
|
||||||
let action_type = parse_action_type(&req.action_type).map_err(|e| {
|
let action_type = parse_action_type(&req.action_type)
|
||||||
(StatusCode::BAD_REQUEST, Json(json!({"error": e})))
|
.map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({"error": e}))))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
let entry_id = LedgerService::create_entry(
|
let entry_id = LedgerService::create_entry(
|
||||||
&pool,
|
&pool,
|
||||||
|
|
|
||||||
|
|
@ -49,15 +49,18 @@ async fn list_notifications(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let result = notifications.into_iter().map(|n| Notification {
|
let result = notifications
|
||||||
id: n.id,
|
.into_iter()
|
||||||
notification_type: n.notification_type,
|
.map(|n| Notification {
|
||||||
title: n.title,
|
id: n.id,
|
||||||
message: n.message,
|
notification_type: n.notification_type,
|
||||||
link: n.link,
|
title: n.title,
|
||||||
is_read: n.is_read,
|
message: n.message,
|
||||||
created_at: n.created_at,
|
link: n.link,
|
||||||
}).collect();
|
is_read: n.is_read,
|
||||||
|
created_at: n.created_at,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,16 +57,16 @@ pub async fn require_any_permission(
|
||||||
}
|
}
|
||||||
Err((
|
Err((
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
format!("One of these permissions required: {}", permissions.join(", ")),
|
format!(
|
||||||
|
"One of these permissions required: {}",
|
||||||
|
permissions.join(", ")
|
||||||
|
),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if user is a platform admin (has platform.admin permission).
|
/// Check if user is a platform admin (has platform.admin permission).
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn is_platform_admin(
|
pub async fn is_platform_admin(pool: &PgPool, user_id: Uuid) -> Result<bool, (StatusCode, String)> {
|
||||||
pool: &PgPool,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> Result<bool, (StatusCode, String)> {
|
|
||||||
user_has_permission(pool, user_id, "platform.admin", None).await
|
user_has_permission(pool, user_id, "platform.admin", None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,7 +86,8 @@ pub async fn is_community_staff(
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
community_id: Uuid,
|
community_id: Uuid,
|
||||||
) -> Result<bool, (StatusCode, String)> {
|
) -> Result<bool, (StatusCode, String)> {
|
||||||
let is_admin = user_has_permission(pool, user_id, "community.settings", Some(community_id)).await?;
|
let is_admin =
|
||||||
|
user_has_permission(pool, user_id, "community.settings", Some(community_id)).await?;
|
||||||
if is_admin {
|
if is_admin {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
@ -100,13 +101,13 @@ pub mod perms {
|
||||||
pub const PLATFORM_ADMIN: &str = "platform.admin";
|
pub const PLATFORM_ADMIN: &str = "platform.admin";
|
||||||
pub const PLATFORM_SETTINGS: &str = "platform.settings";
|
pub const PLATFORM_SETTINGS: &str = "platform.settings";
|
||||||
pub const PLATFORM_PLUGINS: &str = "plugins.configure";
|
pub const PLATFORM_PLUGINS: &str = "plugins.configure";
|
||||||
|
|
||||||
// Community-level
|
// Community-level
|
||||||
pub const COMMUNITY_CREATE: &str = "community.create";
|
pub const COMMUNITY_CREATE: &str = "community.create";
|
||||||
pub const COMMUNITY_ADMIN: &str = "community.settings";
|
pub const COMMUNITY_ADMIN: &str = "community.settings";
|
||||||
pub const COMMUNITY_SETTINGS: &str = "community.settings";
|
pub const COMMUNITY_SETTINGS: &str = "community.settings";
|
||||||
pub const COMMUNITY_MODERATE: &str = "moderation.users.warn";
|
pub const COMMUNITY_MODERATE: &str = "moderation.users.warn";
|
||||||
|
|
||||||
// Proposals
|
// Proposals
|
||||||
pub const PROPOSAL_CREATE: &str = "proposals.create";
|
pub const PROPOSAL_CREATE: &str = "proposals.create";
|
||||||
pub const PROPOSAL_EDIT_OWN: &str = "proposals.edit.own";
|
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_OWN: &str = "proposals.delete.own";
|
||||||
pub const PROPOSAL_DELETE_ANY: &str = "proposals.delete.any";
|
pub const PROPOSAL_DELETE_ANY: &str = "proposals.delete.any";
|
||||||
pub const PROPOSAL_MANAGE_STATUS: &str = "proposals.moderate";
|
pub const PROPOSAL_MANAGE_STATUS: &str = "proposals.moderate";
|
||||||
|
|
||||||
// Voting
|
// Voting
|
||||||
pub const VOTE_CAST: &str = "voting.vote";
|
pub const VOTE_CAST: &str = "voting.vote";
|
||||||
pub const VOTE_VIEW_RESULTS: &str = "voting.results.view";
|
pub const VOTE_VIEW_RESULTS: &str = "voting.results.view";
|
||||||
pub const VOTING_CONFIG: &str = "voting.configure";
|
pub const VOTING_CONFIG: &str = "voting.configure";
|
||||||
|
|
||||||
// Moderation
|
// Moderation
|
||||||
pub const MOD_BAN_USERS: &str = "platform.users.ban";
|
pub const MOD_BAN_USERS: &str = "platform.users.ban";
|
||||||
pub const MOD_REMOVE_CONTENT: &str = "moderation.comments.delete";
|
pub const MOD_REMOVE_CONTENT: &str = "moderation.comments.delete";
|
||||||
pub const MOD_VIEW_REPORTS: &str = "moderation.log.view";
|
pub const MOD_VIEW_REPORTS: &str = "moderation.log.view";
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
pub const USER_MANAGE: &str = "platform.users.manage";
|
pub const USER_MANAGE: &str = "platform.users.manage";
|
||||||
pub const USER_INVITE: &str = "community.members.invite";
|
pub const USER_INVITE: &str = "community.members.invite";
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,25 @@ use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
routing::{get, post, put},
|
routing::{get, post, put},
|
||||||
Extension,
|
Extension, Json, Router,
|
||||||
Json, Router,
|
|
||||||
};
|
};
|
||||||
use base64::{engine::general_purpose, Engine as _};
|
use base64::{engine::general_purpose, Engine as _};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use ed25519_dalek::{Signature, VerifyingKey};
|
use ed25519_dalek::{Signature, VerifyingKey};
|
||||||
use jsonschema::{Draft, JSONSchema};
|
use jsonschema::{Draft, JSONSchema};
|
||||||
|
use reqwest::Url;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use reqwest::Url;
|
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use crate::plugins::PluginManager;
|
|
||||||
use crate::plugins::wasm::host_api::PluginManifest;
|
use crate::plugins::wasm::host_api::PluginManifest;
|
||||||
|
use crate::plugins::PluginManager;
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct CommunityPluginInfo {
|
pub struct CommunityPluginInfo {
|
||||||
|
|
@ -51,7 +50,12 @@ async fn get_plugin_policy(
|
||||||
|
|
||||||
match membership {
|
match membership {
|
||||||
Some(m) if m.role == "admin" || m.role == "moderator" => {}
|
Some(m) if m.role == "admin" || m.role == "moderator" => {}
|
||||||
_ => return Err((StatusCode::FORBIDDEN, "Must be admin or moderator".to_string())),
|
_ => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Must be admin or moderator".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query!(
|
||||||
|
|
@ -91,7 +95,12 @@ async fn update_plugin_policy(
|
||||||
|
|
||||||
match membership {
|
match membership {
|
||||||
Some(m) if m.role == "admin" || m.role == "moderator" => {}
|
Some(m) if m.role == "admin" || m.role == "moderator" => {}
|
||||||
_ => return Err((StatusCode::FORBIDDEN, "Must be admin or moderator".to_string())),
|
_ => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Must be admin or moderator".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let current = sqlx::query!(
|
let current = sqlx::query!(
|
||||||
|
|
@ -160,9 +169,16 @@ async fn update_plugin_policy(
|
||||||
trust_policy: parse_trust_policy(¤t.settings),
|
trust_policy: parse_trust_policy(¤t.settings),
|
||||||
install_sources: parse_install_sources(¤t.settings),
|
install_sources: parse_install_sources(¤t.settings),
|
||||||
allow_outbound_http: parse_bool(¤t.settings, "plugin_allow_outbound_http", false),
|
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"),
|
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"),
|
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 matches!(trust_policy, PluginTrustPolicy::SignedOnly) {
|
||||||
if trusted_publishers.is_empty() {
|
if trusted_publishers.is_empty() {
|
||||||
return Err((StatusCode::FORBIDDEN, "No trusted publishers configured".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"No trusted publishers configured".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if !trusted_publishers.iter().any(|p| p == publisher) {
|
if !trusted_publishers.iter().any(|p| p == publisher) {
|
||||||
return Err((StatusCode::FORBIDDEN, "Publisher not trusted".to_string()));
|
return Err((StatusCode::FORBIDDEN, "Publisher not trusted".to_string()));
|
||||||
|
|
@ -425,29 +444,44 @@ fn verify_signature_if_required(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enforce_registry_allowlist(url: &Url, allowlist: &[String]) -> Result<(), (StatusCode, String)> {
|
fn enforce_registry_allowlist(url: &Url, allowlist: &[String]) -> Result<(), (StatusCode, String)> {
|
||||||
let host = url
|
let host = url.host_str().ok_or((
|
||||||
.host_str()
|
StatusCode::BAD_REQUEST,
|
||||||
.ok_or((StatusCode::BAD_REQUEST, "Registry URL must include host".to_string()))?;
|
"Registry URL must include host".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||||
let is_disallowed = match ip {
|
let is_disallowed = match ip {
|
||||||
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified(),
|
IpAddr::V4(v4) => {
|
||||||
|
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
|
||||||
|
}
|
||||||
IpAddr::V6(v6) => {
|
IpAddr::V6(v6) => {
|
||||||
v6.is_loopback() || v6.is_unique_local() || v6.is_unicast_link_local() || v6.is_unspecified()
|
v6.is_loopback()
|
||||||
|
|| v6.is_unique_local()
|
||||||
|
|| v6.is_unicast_link_local()
|
||||||
|
|| v6.is_unspecified()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if is_disallowed {
|
if is_disallowed {
|
||||||
return Err((StatusCode::FORBIDDEN, "Registry host is not allowed".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Registry host is not allowed".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if host.eq_ignore_ascii_case("localhost") {
|
if host.eq_ignore_ascii_case("localhost") {
|
||||||
return Err((StatusCode::FORBIDDEN, "Registry host is not allowed".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Registry host is not allowed".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !allowlist.is_empty() && !allowlist.iter().any(|h| h == host) {
|
if !allowlist.is_empty() && !allowlist.iter().any(|h| h == host) {
|
||||||
return Err((StatusCode::FORBIDDEN, "Registry host not in allowlist".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Registry host not in allowlist".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -469,7 +503,10 @@ async fn ensure_admin_or_moderator(
|
||||||
|
|
||||||
match membership {
|
match membership {
|
||||||
Some(m) if m.role == "admin" || m.role == "moderator" => Ok(()),
|
Some(m) if m.role == "admin" || m.role == "moderator" => Ok(()),
|
||||||
_ => Err((StatusCode::FORBIDDEN, "Must be admin or moderator".to_string())),
|
_ => Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Must be admin or moderator".to_string(),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -610,7 +647,10 @@ async fn update_community_plugin_package(
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Package not installed for community".to_string()))?;
|
.ok_or((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Package not installed for community".to_string(),
|
||||||
|
))?;
|
||||||
|
|
||||||
let link_is_active: bool = link_row
|
let link_is_active: bool = link_row
|
||||||
.try_get("is_active")
|
.try_get("is_active")
|
||||||
|
|
@ -622,8 +662,12 @@ async fn update_community_plugin_package(
|
||||||
.try_get("installed_at")
|
.try_get("installed_at")
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let manifest: PluginManifest = serde_json::from_value(pkg.manifest.clone())
|
let manifest: PluginManifest = serde_json::from_value(pkg.manifest.clone()).map_err(|e| {
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Stored manifest invalid: {e}")))?;
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Stored manifest invalid: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
if let Some(settings) = &req.settings {
|
if let Some(settings) = &req.settings {
|
||||||
if let Some(schema) = &manifest.settings_schema {
|
if let Some(schema) = &manifest.settings_schema {
|
||||||
|
|
@ -632,7 +676,10 @@ async fn update_community_plugin_package(
|
||||||
.with_draft(Draft::Draft7)
|
.with_draft(Draft::Draft7)
|
||||||
.compile(schema)
|
.compile(schema)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid settings schema: {e}"))
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Invalid settings schema: {e}"),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !compiled.is_valid(settings) {
|
if !compiled.is_valid(settings) {
|
||||||
|
|
@ -792,7 +839,10 @@ async fn upload_plugin_package(
|
||||||
let trusted_publishers = parse_string_list(&settings, "plugin_trusted_publishers");
|
let trusted_publishers = parse_string_list(&settings, "plugin_trusted_publishers");
|
||||||
|
|
||||||
if !sources.contains(&PluginInstallSource::Upload) {
|
if !sources.contains(&PluginInstallSource::Upload) {
|
||||||
return Err((StatusCode::FORBIDDEN, "Upload installs are disabled by policy".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Upload installs are disabled by policy".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let publisher = req.publisher.unwrap_or_default();
|
let publisher = req.publisher.unwrap_or_default();
|
||||||
|
|
@ -980,7 +1030,10 @@ async fn install_registry_plugin_package(
|
||||||
let registry_allowlist = parse_string_list(&settings, "plugin_registry_allowlist");
|
let registry_allowlist = parse_string_list(&settings, "plugin_registry_allowlist");
|
||||||
|
|
||||||
if !sources.contains(&PluginInstallSource::Registry) {
|
if !sources.contains(&PluginInstallSource::Registry) {
|
||||||
return Err((StatusCode::FORBIDDEN, "Registry installs are disabled by policy".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Registry installs are disabled by policy".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = Url::parse(&req.url)
|
let url = Url::parse(&req.url)
|
||||||
|
|
@ -988,7 +1041,12 @@ async fn install_registry_plugin_package(
|
||||||
|
|
||||||
match url.scheme() {
|
match url.scheme() {
|
||||||
"https" | "http" => {}
|
"https" | "http" => {}
|
||||||
_ => return Err((StatusCode::BAD_REQUEST, "Invalid registry URL scheme".to_string())),
|
_ => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Invalid registry URL scheme".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enforce_registry_allowlist(&url, ®istry_allowlist)?;
|
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()));
|
return Err((StatusCode::BAD_GATEWAY, "Registry fetch failed".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let bundle: UploadPluginPackageRequest = res
|
let bundle: UploadPluginPackageRequest = res.json().await.map_err(|_| {
|
||||||
.json()
|
(
|
||||||
.await
|
StatusCode::BAD_GATEWAY,
|
||||||
.map_err(|_| (StatusCode::BAD_GATEWAY, "Invalid registry response".to_string()))?;
|
"Invalid registry response".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let parsed_manifest: PluginManifest = serde_json::from_value(bundle.manifest.clone())
|
let parsed_manifest: PluginManifest =
|
||||||
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("Registry returned invalid manifest: {e}")))?;
|
serde_json::from_value(bundle.manifest.clone()).map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
format!("Registry returned invalid manifest: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
if parsed_manifest.name != bundle.name {
|
if parsed_manifest.name != bundle.name {
|
||||||
return Err((
|
return Err((
|
||||||
|
|
@ -1177,10 +1242,7 @@ async fn install_registry_plugin_package(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_trust_policy(settings: &Value) -> PluginTrustPolicy {
|
fn parse_trust_policy(settings: &Value) -> PluginTrustPolicy {
|
||||||
match settings
|
match settings.get("plugin_trust_policy").and_then(|v| v.as_str()) {
|
||||||
.get("plugin_trust_policy")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
{
|
|
||||||
Some("unsigned_allowed") => PluginTrustPolicy::UnsignedAllowed,
|
Some("unsigned_allowed") => PluginTrustPolicy::UnsignedAllowed,
|
||||||
_ => PluginTrustPolicy::SignedOnly,
|
_ => PluginTrustPolicy::SignedOnly,
|
||||||
}
|
}
|
||||||
|
|
@ -1194,7 +1256,10 @@ fn trust_policy_str(policy: PluginTrustPolicy) -> &'static str {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_install_sources(settings: &Value) -> Vec<PluginInstallSource> {
|
fn parse_install_sources(settings: &Value) -> Vec<PluginInstallSource> {
|
||||||
let Some(arr) = settings.get("plugin_install_sources").and_then(|v| v.as_array()) else {
|
let Some(arr) = settings
|
||||||
|
.get("plugin_install_sources")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
else {
|
||||||
return vec![PluginInstallSource::Upload, PluginInstallSource::Registry];
|
return vec![PluginInstallSource::Upload, PluginInstallSource::Registry];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1235,7 +1300,10 @@ fn install_sources_json(sources: &[PluginInstallSource]) -> Value {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_bool(settings: &Value, key: &str, default: bool) -> bool {
|
fn parse_bool(settings: &Value, key: &str, default: bool) -> bool {
|
||||||
settings.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
|
settings
|
||||||
|
.get(key)
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(default)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_string_list(settings: &Value, key: &str) -> Vec<String> {
|
fn parse_string_list(settings: &Value, key: &str) -> Vec<String> {
|
||||||
|
|
@ -1268,7 +1336,12 @@ async fn list_community_plugins(
|
||||||
|
|
||||||
match membership {
|
match membership {
|
||||||
Some(m) if m.role == "admin" || m.role == "moderator" => {}
|
Some(m) if m.role == "admin" || m.role == "moderator" => {}
|
||||||
_ => return Err((StatusCode::FORBIDDEN, "Must be admin or moderator".to_string())),
|
_ => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Must be admin or moderator".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query!(
|
||||||
|
|
@ -1334,7 +1407,12 @@ async fn update_community_plugin(
|
||||||
|
|
||||||
match membership {
|
match membership {
|
||||||
Some(m) if m.role == "admin" || m.role == "moderator" => {}
|
Some(m) if m.role == "admin" || m.role == "moderator" => {}
|
||||||
_ => return Err((StatusCode::FORBIDDEN, "Must be admin or moderator".to_string())),
|
_ => {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Must be admin or moderator".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let plugin = sqlx::query!(
|
let plugin = sqlx::query!(
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Extension,
|
Extension, Json, Router,
|
||||||
Json, Router,
|
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
@ -11,20 +10,36 @@ use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use crate::models::proposal::{CreateProposal, Proposal, ProposalOptionWithVotes, ProposalWithOptions};
|
use crate::models::proposal::{
|
||||||
|
CreateProposal, Proposal, ProposalOptionWithVotes, ProposalWithOptions,
|
||||||
|
};
|
||||||
use crate::plugins::{HookContext, PluginError, PluginManager};
|
use crate::plugins::{HookContext, PluginError, PluginManager};
|
||||||
|
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/proposals", get(list_all_proposals))
|
.route("/api/proposals", get(list_all_proposals))
|
||||||
.route("/api/proposals/my", get(my_proposals))
|
.route("/api/proposals/my", get(my_proposals))
|
||||||
.route("/api/communities/{community_id}/proposals", get(list_proposals).post(create_proposal))
|
.route(
|
||||||
.route("/api/proposals/{id}", get(get_proposal).delete(delete_proposal).put(update_proposal))
|
"/api/communities/{community_id}/proposals",
|
||||||
|
get(list_proposals).post(create_proposal),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/proposals/{id}",
|
||||||
|
get(get_proposal)
|
||||||
|
.delete(delete_proposal)
|
||||||
|
.put(update_proposal),
|
||||||
|
)
|
||||||
.route("/api/proposals/{id}/vote", post(cast_vote))
|
.route("/api/proposals/{id}/vote", post(cast_vote))
|
||||||
.route("/api/proposals/{id}/vote/ranked", post(cast_ranked_vote))
|
.route("/api/proposals/{id}/vote/ranked", post(cast_ranked_vote))
|
||||||
.route("/api/proposals/{id}/vote/quadratic", post(cast_quadratic_vote))
|
.route(
|
||||||
|
"/api/proposals/{id}/vote/quadratic",
|
||||||
|
post(cast_quadratic_vote),
|
||||||
|
)
|
||||||
.route("/api/proposals/{id}/vote/star", post(cast_star_vote))
|
.route("/api/proposals/{id}/vote/star", post(cast_star_vote))
|
||||||
.route("/api/proposals/{id}/start-discussion", post(start_discussion))
|
.route(
|
||||||
|
"/api/proposals/{id}/start-discussion",
|
||||||
|
post(start_discussion),
|
||||||
|
)
|
||||||
.route("/api/proposals/{id}/start-voting", post(start_voting))
|
.route("/api/proposals/{id}/start-voting", post(start_voting))
|
||||||
.route("/api/proposals/{id}/close-voting", post(close_voting))
|
.route("/api/proposals/{id}/close-voting", post(close_voting))
|
||||||
.route("/api/proposals/{id}/results", get(get_voting_results))
|
.route("/api/proposals/{id}/results", get(get_voting_results))
|
||||||
|
|
@ -66,17 +81,20 @@ async fn list_all_proposals(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let result = proposals.into_iter().map(|p| ProposalWithCommunity {
|
let result = proposals
|
||||||
id: p.id,
|
.into_iter()
|
||||||
title: p.title,
|
.map(|p| ProposalWithCommunity {
|
||||||
description: p.description,
|
id: p.id,
|
||||||
status: p.status,
|
title: p.title,
|
||||||
community_name: p.community_name,
|
description: p.description,
|
||||||
community_slug: p.community_slug,
|
status: p.status,
|
||||||
vote_count: p.vote_count.unwrap_or(0),
|
community_name: p.community_name,
|
||||||
comment_count: p.comment_count.unwrap_or(0),
|
community_slug: p.community_slug,
|
||||||
created_at: p.created_at,
|
vote_count: p.vote_count.unwrap_or(0),
|
||||||
}).collect();
|
comment_count: p.comment_count.unwrap_or(0),
|
||||||
|
created_at: p.created_at,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
@ -102,17 +120,20 @@ async fn my_proposals(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let result = proposals.into_iter().map(|p| ProposalWithCommunity {
|
let result = proposals
|
||||||
id: p.id,
|
.into_iter()
|
||||||
title: p.title,
|
.map(|p| ProposalWithCommunity {
|
||||||
description: p.description,
|
id: p.id,
|
||||||
status: p.status,
|
title: p.title,
|
||||||
community_name: p.community_name,
|
description: p.description,
|
||||||
community_slug: p.community_slug,
|
status: p.status,
|
||||||
vote_count: p.vote_count.unwrap_or(0),
|
community_name: p.community_name,
|
||||||
comment_count: p.comment_count.unwrap_or(0),
|
community_slug: p.community_slug,
|
||||||
created_at: p.created_at,
|
vote_count: p.vote_count.unwrap_or(0),
|
||||||
}).collect();
|
comment_count: p.comment_count.unwrap_or(0),
|
||||||
|
created_at: p.created_at,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
@ -147,10 +168,16 @@ async fn create_proposal(
|
||||||
Extension(plugins): Extension<Arc<PluginManager>>,
|
Extension(plugins): Extension<Arc<PluginManager>>,
|
||||||
Json(req): Json<CreateProposal>,
|
Json(req): Json<CreateProposal>,
|
||||||
) -> Result<Json<Proposal>, (StatusCode, String)> {
|
) -> Result<Json<Proposal>, (StatusCode, String)> {
|
||||||
use crate::api::permissions::{require_permission, perms};
|
use crate::api::permissions::{perms, require_permission};
|
||||||
|
|
||||||
// Require proposal.create permission in community
|
// Require proposal.create permission in community
|
||||||
require_permission(&pool, auth.user_id, perms::PROPOSAL_CREATE, Some(community_id)).await?;
|
require_permission(
|
||||||
|
&pool,
|
||||||
|
auth.user_id,
|
||||||
|
perms::PROPOSAL_CREATE,
|
||||||
|
Some(community_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let filtered = plugins
|
let filtered = plugins
|
||||||
.apply_filters(
|
.apply_filters(
|
||||||
|
|
@ -225,7 +252,10 @@ async fn create_proposal(
|
||||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Start transaction
|
// Start transaction
|
||||||
let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
let mut tx = pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Create proposal with community's default voting method
|
// Create proposal with community's default voting method
|
||||||
let proposal = sqlx::query_as!(
|
let proposal = sqlx::query_as!(
|
||||||
|
|
@ -260,7 +290,9 @@ async fn create_proposal(
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
tracing::info!("Proposal '{}' created by {}", proposal.title, auth.username);
|
tracing::info!("Proposal '{}' created by {}", proposal.title, auth.username);
|
||||||
Ok(Json(proposal))
|
Ok(Json(proposal))
|
||||||
|
|
@ -285,10 +317,13 @@ async fn get_proposal(
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
||||||
|
|
||||||
let author = sqlx::query_scalar!("SELECT username FROM users WHERE id = $1", proposal.author_id)
|
let author = sqlx::query_scalar!(
|
||||||
.fetch_one(&pool)
|
"SELECT username FROM users WHERE id = $1",
|
||||||
.await
|
proposal.author_id
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let options = sqlx::query!(
|
let options = sqlx::query!(
|
||||||
r#"SELECT o.id, o.label, o.description, COUNT(v.id) as vote_count
|
r#"SELECT o.id, o.label, o.description, COUNT(v.id) as vote_count
|
||||||
|
|
@ -363,7 +398,7 @@ async fn cast_vote(
|
||||||
Extension(plugins): Extension<Arc<PluginManager>>,
|
Extension(plugins): Extension<Arc<PluginManager>>,
|
||||||
Json(req): Json<VoteRequest>,
|
Json(req): Json<VoteRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
use crate::api::permissions::{require_permission, perms};
|
use crate::api::permissions::{perms, require_permission};
|
||||||
|
|
||||||
let proposal = sqlx::query_as!(
|
let proposal = sqlx::query_as!(
|
||||||
Proposal,
|
Proposal,
|
||||||
|
|
@ -381,10 +416,19 @@ async fn cast_vote(
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
||||||
|
|
||||||
// Require vote.cast permission in community
|
// Require vote.cast permission in community
|
||||||
require_permission(&pool, auth.user_id, perms::VOTE_CAST, Some(proposal.community_id)).await?;
|
require_permission(
|
||||||
|
&pool,
|
||||||
|
auth.user_id,
|
||||||
|
perms::VOTE_CAST,
|
||||||
|
Some(proposal.community_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
|
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Proposal is not in voting phase".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let vote_payload = serde_json::json!({
|
let vote_payload = serde_json::json!({
|
||||||
|
|
@ -427,7 +471,10 @@ async fn cast_vote(
|
||||||
RETURNING id"#,
|
RETURNING id"#,
|
||||||
auth.user_id,
|
auth.user_id,
|
||||||
proposal.community_id,
|
proposal.community_id,
|
||||||
format!("voter-{}", uuid::Uuid::new_v4().to_string()[..8].to_string())
|
format!(
|
||||||
|
"voter-{}",
|
||||||
|
uuid::Uuid::new_v4().to_string()[..8].to_string()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
|
|
@ -459,10 +506,16 @@ async fn cast_vote(
|
||||||
community_id: Some(proposal.community_id),
|
community_id: Some(proposal.community_id),
|
||||||
actor_user_id: Some(auth.user_id),
|
actor_user_id: Some(auth.user_id),
|
||||||
};
|
};
|
||||||
let _ = plugins.do_action("vote.cast", ctx, serde_json::json!({
|
let _ = plugins
|
||||||
"proposal_id": proposal_id.to_string(),
|
.do_action(
|
||||||
"voter_id": auth.user_id.to_string(),
|
"vote.cast",
|
||||||
})).await;
|
ctx,
|
||||||
|
serde_json::json!({
|
||||||
|
"proposal_id": proposal_id.to_string(),
|
||||||
|
"voter_id": auth.user_id.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({"status": "voted"})))
|
Ok(Json(serde_json::json!({"status": "voted"})))
|
||||||
}
|
}
|
||||||
|
|
@ -483,11 +536,17 @@ async fn cast_ranked_vote(
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
||||||
|
|
||||||
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
|
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Proposal is not in voting phase".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if proposal.voting_method != "ranked_choice" {
|
if proposal.voting_method != "ranked_choice" {
|
||||||
return Err((StatusCode::BAD_REQUEST, "This proposal uses a different voting method".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"This proposal uses a different voting method".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let voting_identity = sqlx::query_scalar!(
|
let voting_identity = sqlx::query_scalar!(
|
||||||
|
|
@ -497,15 +556,24 @@ async fn cast_ranked_vote(
|
||||||
RETURNING id"#,
|
RETURNING id"#,
|
||||||
auth.user_id,
|
auth.user_id,
|
||||||
proposal.community_id,
|
proposal.community_id,
|
||||||
format!("voter-{}", uuid::Uuid::new_v4().to_string()[..8].to_string())
|
format!(
|
||||||
|
"voter-{}",
|
||||||
|
uuid::Uuid::new_v4().to_string()[..8].to_string()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Clear existing ranked votes
|
// Clear existing ranked votes
|
||||||
sqlx::query!("DELETE FROM ranked_votes WHERE proposal_id = $1 AND voter_id = $2", proposal_id, voting_identity)
|
sqlx::query!(
|
||||||
.execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
"DELETE FROM ranked_votes WHERE proposal_id = $1 AND voter_id = $2",
|
||||||
|
proposal_id,
|
||||||
|
voting_identity
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Insert ranked votes
|
// Insert ranked votes
|
||||||
for ranking in req.rankings {
|
for ranking in req.rankings {
|
||||||
|
|
@ -515,7 +583,9 @@ async fn cast_ranked_vote(
|
||||||
).execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
).execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({"status": "voted", "method": "ranked_choice"})))
|
Ok(Json(
|
||||||
|
serde_json::json!({"status": "voted", "method": "ranked_choice"}),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cast_quadratic_vote(
|
async fn cast_quadratic_vote(
|
||||||
|
|
@ -524,7 +594,7 @@ async fn cast_quadratic_vote(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(req): Json<QuadraticVoteRequest>,
|
Json(req): Json<QuadraticVoteRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
use crate::voting::quadratic::{vote_cost, max_votes_for_credits};
|
use crate::voting::quadratic::{max_votes_for_credits, vote_cost};
|
||||||
|
|
||||||
let proposal = sqlx::query!(
|
let proposal = sqlx::query!(
|
||||||
"SELECT community_id, status as \"status: crate::models::ProposalStatus\", voting_method FROM proposals WHERE id = $1",
|
"SELECT community_id, status as \"status: crate::models::ProposalStatus\", voting_method FROM proposals WHERE id = $1",
|
||||||
|
|
@ -536,11 +606,17 @@ async fn cast_quadratic_vote(
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
||||||
|
|
||||||
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
|
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Proposal is not in voting phase".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if proposal.voting_method != "quadratic" {
|
if proposal.voting_method != "quadratic" {
|
||||||
return Err((StatusCode::BAD_REQUEST, "This proposal uses a different voting method".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"This proposal uses a different voting method".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate using quadratic voting module
|
// Validate using quadratic voting module
|
||||||
|
|
@ -548,10 +624,13 @@ async fn cast_quadratic_vote(
|
||||||
let total_cost: i32 = req.allocations.iter().map(|a| vote_cost(a.credits)).sum();
|
let total_cost: i32 = req.allocations.iter().map(|a| vote_cost(a.credits)).sum();
|
||||||
if total_cost > total_credits {
|
if total_cost > total_credits {
|
||||||
let max_single = max_votes_for_credits(total_credits);
|
let max_single = max_votes_for_credits(total_credits);
|
||||||
return Err((StatusCode::BAD_REQUEST, format!(
|
return Err((
|
||||||
"Total cost {} exceeds {} credits. Max votes on single option: {}",
|
StatusCode::BAD_REQUEST,
|
||||||
total_cost, total_credits, max_single
|
format!(
|
||||||
)));
|
"Total cost {} exceeds {} credits. Max votes on single option: {}",
|
||||||
|
total_cost, total_credits, max_single
|
||||||
|
),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let voting_identity = sqlx::query_scalar!(
|
let voting_identity = sqlx::query_scalar!(
|
||||||
|
|
@ -561,15 +640,24 @@ async fn cast_quadratic_vote(
|
||||||
RETURNING id"#,
|
RETURNING id"#,
|
||||||
auth.user_id,
|
auth.user_id,
|
||||||
proposal.community_id,
|
proposal.community_id,
|
||||||
format!("voter-{}", uuid::Uuid::new_v4().to_string()[..8].to_string())
|
format!(
|
||||||
|
"voter-{}",
|
||||||
|
uuid::Uuid::new_v4().to_string()[..8].to_string()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Clear existing quadratic votes
|
// Clear existing quadratic votes
|
||||||
sqlx::query!("DELETE FROM quadratic_votes WHERE proposal_id = $1 AND voter_id = $2", proposal_id, voting_identity)
|
sqlx::query!(
|
||||||
.execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
"DELETE FROM quadratic_votes WHERE proposal_id = $1 AND voter_id = $2",
|
||||||
|
proposal_id,
|
||||||
|
voting_identity
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Insert quadratic votes
|
// Insert quadratic votes
|
||||||
for alloc in req.allocations {
|
for alloc in req.allocations {
|
||||||
|
|
@ -581,7 +669,9 @@ async fn cast_quadratic_vote(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({"status": "voted", "method": "quadratic", "credits_used": total_cost})))
|
Ok(Json(
|
||||||
|
serde_json::json!({"status": "voted", "method": "quadratic", "credits_used": total_cost}),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cast_star_vote(
|
async fn cast_star_vote(
|
||||||
|
|
@ -600,17 +690,26 @@ async fn cast_star_vote(
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
||||||
|
|
||||||
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
|
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Proposal is not in voting phase".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if proposal.voting_method != "star" {
|
if proposal.voting_method != "star" {
|
||||||
return Err((StatusCode::BAD_REQUEST, "This proposal uses a different voting method".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"This proposal uses a different voting method".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate star ratings (0-5)
|
// Validate star ratings (0-5)
|
||||||
for rating in &req.ratings {
|
for rating in &req.ratings {
|
||||||
if rating.stars < 0 || rating.stars > 5 {
|
if rating.stars < 0 || rating.stars > 5 {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Star ratings must be between 0 and 5".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Star ratings must be between 0 and 5".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -621,15 +720,24 @@ async fn cast_star_vote(
|
||||||
RETURNING id"#,
|
RETURNING id"#,
|
||||||
auth.user_id,
|
auth.user_id,
|
||||||
proposal.community_id,
|
proposal.community_id,
|
||||||
format!("voter-{}", uuid::Uuid::new_v4().to_string()[..8].to_string())
|
format!(
|
||||||
|
"voter-{}",
|
||||||
|
uuid::Uuid::new_v4().to_string()[..8].to_string()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Clear existing star votes
|
// Clear existing star votes
|
||||||
sqlx::query!("DELETE FROM star_votes WHERE proposal_id = $1 AND voter_id = $2", proposal_id, voting_identity)
|
sqlx::query!(
|
||||||
.execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
"DELETE FROM star_votes WHERE proposal_id = $1 AND voter_id = $2",
|
||||||
|
proposal_id,
|
||||||
|
voting_identity
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Insert star votes
|
// Insert star votes
|
||||||
for rating in req.ratings {
|
for rating in req.ratings {
|
||||||
|
|
@ -639,7 +747,9 @@ async fn cast_star_vote(
|
||||||
).execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
).execute(&pool).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({"status": "voted", "method": "star"})))
|
Ok(Json(
|
||||||
|
serde_json::json!({"status": "voted", "method": "star"}),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start_discussion(
|
async fn start_discussion(
|
||||||
|
|
@ -663,7 +773,10 @@ async fn start_discussion(
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
||||||
|
|
||||||
if proposal.author_id != auth.user_id {
|
if proposal.author_id != auth.user_id {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only the author can start discussion".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only the author can start discussion".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated = sqlx::query_as!(
|
let updated = sqlx::query_as!(
|
||||||
|
|
@ -706,7 +819,10 @@ async fn start_voting(
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
||||||
|
|
||||||
if proposal.author_id != auth.user_id {
|
if proposal.author_id != auth.user_id {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only the author can start voting".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only the author can start voting".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated = sqlx::query_as!(
|
let updated = sqlx::query_as!(
|
||||||
|
|
@ -733,7 +849,7 @@ async fn close_voting(
|
||||||
Path(proposal_id): Path<Uuid>,
|
Path(proposal_id): Path<Uuid>,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<Json<Proposal>, (StatusCode, String)> {
|
) -> Result<Json<Proposal>, (StatusCode, String)> {
|
||||||
use crate::api::permissions::{user_has_permission, perms};
|
use crate::api::permissions::{perms, user_has_permission};
|
||||||
|
|
||||||
let proposal = sqlx::query_as!(
|
let proposal = sqlx::query_as!(
|
||||||
Proposal,
|
Proposal,
|
||||||
|
|
@ -752,14 +868,26 @@ async fn close_voting(
|
||||||
|
|
||||||
// Check if user can manage status: author or users with manage_status permission
|
// Check if user can manage status: author or users with manage_status permission
|
||||||
let is_author = proposal.author_id == auth.user_id;
|
let is_author = proposal.author_id == auth.user_id;
|
||||||
let can_manage = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_MANAGE_STATUS, Some(proposal.community_id)).await?;
|
let can_manage = user_has_permission(
|
||||||
|
&pool,
|
||||||
|
auth.user_id,
|
||||||
|
perms::PROPOSAL_MANAGE_STATUS,
|
||||||
|
Some(proposal.community_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
if !is_author && !can_manage {
|
if !is_author && !can_manage {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only the author or admins can close voting".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only the author or admins can close voting".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
|
if !matches!(proposal.status, crate::models::ProposalStatus::Voting) {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Proposal is not in voting phase".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Proposal is not in voting phase".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated = sqlx::query_as!(
|
let updated = sqlx::query_as!(
|
||||||
|
|
@ -787,7 +915,7 @@ async fn delete_proposal(
|
||||||
Path(proposal_id): Path<Uuid>,
|
Path(proposal_id): Path<Uuid>,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
use crate::api::permissions::{user_has_permission, perms};
|
use crate::api::permissions::{perms, user_has_permission};
|
||||||
|
|
||||||
let proposal = sqlx::query!(
|
let proposal = sqlx::query!(
|
||||||
"SELECT author_id, community_id, status as \"status: crate::models::ProposalStatus\", title FROM proposals WHERE id = $1",
|
"SELECT author_id, community_id, status as \"status: crate::models::ProposalStatus\", title FROM proposals WHERE id = $1",
|
||||||
|
|
@ -800,25 +928,49 @@ async fn delete_proposal(
|
||||||
|
|
||||||
// Check if user can delete: author needs delete_own, others need delete_any
|
// Check if user can delete: author needs delete_own, others need delete_any
|
||||||
let is_author = proposal.author_id == auth.user_id;
|
let is_author = proposal.author_id == auth.user_id;
|
||||||
let can_delete_own = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_DELETE_OWN, Some(proposal.community_id)).await?;
|
let can_delete_own = user_has_permission(
|
||||||
let can_delete_any = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_DELETE_ANY, Some(proposal.community_id)).await?;
|
&pool,
|
||||||
|
auth.user_id,
|
||||||
|
perms::PROPOSAL_DELETE_OWN,
|
||||||
|
Some(proposal.community_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let can_delete_any = user_has_permission(
|
||||||
|
&pool,
|
||||||
|
auth.user_id,
|
||||||
|
perms::PROPOSAL_DELETE_ANY,
|
||||||
|
Some(proposal.community_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
if is_author && !can_delete_own {
|
if is_author && !can_delete_own {
|
||||||
return Err((StatusCode::FORBIDDEN, "You don't have permission to delete proposals".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"You don't have permission to delete proposals".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if !is_author && !can_delete_any {
|
if !is_author && !can_delete_any {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only the author or admins can delete this proposal".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only the author or admins can delete this proposal".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !matches!(proposal.status, crate::models::ProposalStatus::Draft) {
|
if !matches!(proposal.status, crate::models::ProposalStatus::Draft) {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Only draft proposals can be deleted".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Only draft proposals can be deleted".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete related data first
|
// Delete related data first
|
||||||
sqlx::query!("DELETE FROM proposal_options WHERE proposal_id = $1", proposal_id)
|
sqlx::query!(
|
||||||
.execute(&pool)
|
"DELETE FROM proposal_options WHERE proposal_id = $1",
|
||||||
.await
|
proposal_id
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
sqlx::query!("DELETE FROM comments WHERE proposal_id = $1", proposal_id)
|
sqlx::query!("DELETE FROM comments WHERE proposal_id = $1", proposal_id)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
|
|
@ -846,7 +998,7 @@ async fn update_proposal(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
Json(payload): Json<UpdateProposal>,
|
Json(payload): Json<UpdateProposal>,
|
||||||
) -> Result<Json<Proposal>, (StatusCode, String)> {
|
) -> Result<Json<Proposal>, (StatusCode, String)> {
|
||||||
use crate::api::permissions::{user_has_permission, perms};
|
use crate::api::permissions::{perms, user_has_permission};
|
||||||
|
|
||||||
let proposal = sqlx::query!(
|
let proposal = sqlx::query!(
|
||||||
"SELECT author_id, community_id, status as \"status: crate::models::ProposalStatus\" FROM proposals WHERE id = $1",
|
"SELECT author_id, community_id, status as \"status: crate::models::ProposalStatus\" FROM proposals WHERE id = $1",
|
||||||
|
|
@ -859,18 +1011,42 @@ async fn update_proposal(
|
||||||
|
|
||||||
// Check edit permissions: author needs edit_own, others need edit_any
|
// Check edit permissions: author needs edit_own, others need edit_any
|
||||||
let is_author = proposal.author_id == auth.user_id;
|
let is_author = proposal.author_id == auth.user_id;
|
||||||
let can_edit_own = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_EDIT_OWN, Some(proposal.community_id)).await?;
|
let can_edit_own = user_has_permission(
|
||||||
let can_edit_any = user_has_permission(&pool, auth.user_id, perms::PROPOSAL_EDIT_ANY, Some(proposal.community_id)).await?;
|
&pool,
|
||||||
|
auth.user_id,
|
||||||
|
perms::PROPOSAL_EDIT_OWN,
|
||||||
|
Some(proposal.community_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let can_edit_any = user_has_permission(
|
||||||
|
&pool,
|
||||||
|
auth.user_id,
|
||||||
|
perms::PROPOSAL_EDIT_ANY,
|
||||||
|
Some(proposal.community_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
if is_author && !can_edit_own {
|
if is_author && !can_edit_own {
|
||||||
return Err((StatusCode::FORBIDDEN, "You don't have permission to edit proposals".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"You don't have permission to edit proposals".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if !is_author && !can_edit_any {
|
if !is_author && !can_edit_any {
|
||||||
return Err((StatusCode::FORBIDDEN, "Only the author or admins can edit this proposal".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Only the author or admins can edit this proposal".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !matches!(proposal.status, crate::models::ProposalStatus::Draft | crate::models::ProposalStatus::Discussion) {
|
if !matches!(
|
||||||
return Err((StatusCode::BAD_REQUEST, "Can only edit proposals in draft or discussion phase".to_string()));
|
proposal.status,
|
||||||
|
crate::models::ProposalStatus::Draft | crate::models::ProposalStatus::Discussion
|
||||||
|
) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Can only edit proposals in draft or discussion phase".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated = sqlx::query_as!(
|
let updated = sqlx::query_as!(
|
||||||
|
|
@ -921,7 +1097,7 @@ async fn get_voting_results(
|
||||||
Path(proposal_id): Path<Uuid>,
|
Path(proposal_id): Path<Uuid>,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<Json<VotingResultsResponse>, (StatusCode, String)> {
|
) -> Result<Json<VotingResultsResponse>, (StatusCode, String)> {
|
||||||
use crate::api::permissions::{require_permission, perms};
|
use crate::api::permissions::{perms, require_permission};
|
||||||
|
|
||||||
let proposal = sqlx::query!(
|
let proposal = sqlx::query!(
|
||||||
"SELECT id, community_id, voting_method, status as \"status: crate::models::ProposalStatus\" FROM proposals WHERE id = $1",
|
"SELECT id, community_id, voting_method, status as \"status: crate::models::ProposalStatus\" FROM proposals WHERE id = $1",
|
||||||
|
|
@ -933,7 +1109,13 @@ async fn get_voting_results(
|
||||||
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
.ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?;
|
||||||
|
|
||||||
// Require permission to view voting results
|
// Require permission to view voting results
|
||||||
require_permission(&pool, auth.user_id, perms::VOTE_VIEW_RESULTS, Some(proposal.community_id)).await?;
|
require_permission(
|
||||||
|
&pool,
|
||||||
|
auth.user_id,
|
||||||
|
perms::VOTE_VIEW_RESULTS,
|
||||||
|
Some(proposal.community_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let options: Vec<(Uuid, String)> = sqlx::query!(
|
let options: Vec<(Uuid, String)> = sqlx::query!(
|
||||||
"SELECT id, label FROM proposal_options WHERE proposal_id = $1 ORDER BY sort_order",
|
"SELECT id, label FROM proposal_options WHERE proposal_id = $1 ORDER BY sort_order",
|
||||||
|
|
@ -947,10 +1129,12 @@ async fn get_voting_results(
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let voting_method = proposal.voting_method.as_str();
|
let voting_method = proposal.voting_method.as_str();
|
||||||
|
|
||||||
let (results, total_votes, details) = match voting_method {
|
let (results, total_votes, details) = match voting_method {
|
||||||
"approval" => calculate_approval_results(&pool, proposal_id, &options).await?,
|
"approval" => calculate_approval_results(&pool, proposal_id, &options).await?,
|
||||||
"ranked_choice" | "schulze" => calculate_ranked_results(&pool, proposal_id, &options, voting_method).await?,
|
"ranked_choice" | "schulze" => {
|
||||||
|
calculate_ranked_results(&pool, proposal_id, &options, voting_method).await?
|
||||||
|
}
|
||||||
"star" => calculate_star_results(&pool, proposal_id, &options).await?,
|
"star" => calculate_star_results(&pool, proposal_id, &options).await?,
|
||||||
"quadratic" => calculate_quadratic_results(&pool, proposal_id, &options).await?,
|
"quadratic" => calculate_quadratic_results(&pool, proposal_id, &options).await?,
|
||||||
_ => calculate_approval_results(&pool, proposal_id, &options).await?,
|
_ => calculate_approval_results(&pool, proposal_id, &options).await?,
|
||||||
|
|
@ -992,30 +1176,40 @@ async fn calculate_approval_results(
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let mut results: Vec<OptionResult> = options.iter().map(|(opt_id, label)| {
|
let mut results: Vec<OptionResult> = options
|
||||||
let votes = vote_counts.iter()
|
.iter()
|
||||||
.find(|v| v.option_id == *opt_id)
|
.map(|(opt_id, label)| {
|
||||||
.map(|v| v.count.unwrap_or(0))
|
let votes = vote_counts
|
||||||
.unwrap_or(0);
|
.iter()
|
||||||
let percentage = if total_voters > 0 {
|
.find(|v| v.option_id == *opt_id)
|
||||||
(votes as f64 / total_voters as f64) * 100.0
|
.map(|v| v.count.unwrap_or(0))
|
||||||
} else { 0.0 };
|
.unwrap_or(0);
|
||||||
|
let percentage = if total_voters > 0 {
|
||||||
OptionResult {
|
(votes as f64 / total_voters as f64) * 100.0
|
||||||
option_id: *opt_id,
|
} else {
|
||||||
label: label.clone(),
|
0.0
|
||||||
votes,
|
};
|
||||||
percentage,
|
|
||||||
rank: 0,
|
OptionResult {
|
||||||
}
|
option_id: *opt_id,
|
||||||
}).collect();
|
label: label.clone(),
|
||||||
|
votes,
|
||||||
|
percentage,
|
||||||
|
rank: 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
results.sort_by(|a, b| b.votes.cmp(&a.votes));
|
results.sort_by(|a, b| b.votes.cmp(&a.votes));
|
||||||
for (i, r) in results.iter_mut().enumerate() {
|
for (i, r) in results.iter_mut().enumerate() {
|
||||||
r.rank = (i + 1) as i32;
|
r.rank = (i + 1) as i32;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((results, total_voters, serde_json::json!({"method": "approval"})))
|
Ok((
|
||||||
|
results,
|
||||||
|
total_voters,
|
||||||
|
serde_json::json!({"method": "approval"}),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn calculate_ranked_results(
|
async fn calculate_ranked_results(
|
||||||
|
|
@ -1024,7 +1218,7 @@ async fn calculate_ranked_results(
|
||||||
options: &[(Uuid, String)],
|
options: &[(Uuid, String)],
|
||||||
method: &str,
|
method: &str,
|
||||||
) -> Result<(Vec<OptionResult>, i64, serde_json::Value), (StatusCode, String)> {
|
) -> Result<(Vec<OptionResult>, i64, serde_json::Value), (StatusCode, String)> {
|
||||||
use crate::voting::{schulze, ranked_choice};
|
use crate::voting::{ranked_choice, schulze};
|
||||||
|
|
||||||
let ballots_raw = sqlx::query!(
|
let ballots_raw = sqlx::query!(
|
||||||
r#"SELECT voter_id, option_id, rank FROM ranked_votes
|
r#"SELECT voter_id, option_id, rank FROM ranked_votes
|
||||||
|
|
@ -1036,48 +1230,72 @@ async fn calculate_ranked_results(
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let option_ids: Vec<Uuid> = options.iter().map(|(id, _)| *id).collect();
|
let option_ids: Vec<Uuid> = options.iter().map(|(id, _)| *id).collect();
|
||||||
|
|
||||||
// Group ballots by voter
|
// Group ballots by voter
|
||||||
let mut voter_ballots: std::collections::HashMap<Uuid, Vec<(Uuid, i32)>> = std::collections::HashMap::new();
|
let mut voter_ballots: std::collections::HashMap<Uuid, Vec<(Uuid, i32)>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
for b in &ballots_raw {
|
for b in &ballots_raw {
|
||||||
voter_ballots.entry(b.voter_id).or_default().push((b.option_id, b.rank));
|
voter_ballots
|
||||||
|
.entry(b.voter_id)
|
||||||
|
.or_default()
|
||||||
|
.push((b.option_id, b.rank));
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_voters = voter_ballots.len() as i64;
|
let total_voters = voter_ballots.len() as i64;
|
||||||
|
|
||||||
let result = if method == "schulze" {
|
let result = if method == "schulze" {
|
||||||
let ballots: Vec<schulze::RankedBallot> = voter_ballots.values().map(|rankings| {
|
let ballots: Vec<schulze::RankedBallot> = voter_ballots
|
||||||
schulze::RankedBallot {
|
.values()
|
||||||
rankings: rankings.iter().map(|(id, rank)| (*id, *rank as usize)).collect()
|
.map(|rankings| schulze::RankedBallot {
|
||||||
}
|
rankings: rankings
|
||||||
}).collect();
|
.iter()
|
||||||
|
.map(|(id, rank)| (*id, *rank as usize))
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
schulze::calculate(&option_ids, &ballots)
|
schulze::calculate(&option_ids, &ballots)
|
||||||
} else {
|
} else {
|
||||||
let ballots: Vec<ranked_choice::RankedBallot> = voter_ballots.values().map(|rankings| {
|
let ballots: Vec<ranked_choice::RankedBallot> = voter_ballots
|
||||||
let mut sorted = rankings.clone();
|
.values()
|
||||||
sorted.sort_by_key(|(_, rank)| *rank);
|
.map(|rankings| {
|
||||||
ranked_choice::RankedBallot {
|
let mut sorted = rankings.clone();
|
||||||
rankings: sorted.iter().map(|(id, _)| *id).collect()
|
sorted.sort_by_key(|(_, rank)| *rank);
|
||||||
}
|
ranked_choice::RankedBallot {
|
||||||
}).collect();
|
rankings: sorted.iter().map(|(id, _)| *id).collect(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
ranked_choice::calculate(&option_ids, &ballots)
|
ranked_choice::calculate(&option_ids, &ballots)
|
||||||
};
|
};
|
||||||
|
|
||||||
let results: Vec<OptionResult> = result.ranking.iter().map(|r| {
|
let results: Vec<OptionResult> = result
|
||||||
let label = options.iter()
|
.ranking
|
||||||
.find(|(id, _)| *id == r.option_id)
|
.iter()
|
||||||
.map(|(_, l)| l.clone())
|
.map(|r| {
|
||||||
.unwrap_or_default();
|
let label = options
|
||||||
OptionResult {
|
.iter()
|
||||||
option_id: r.option_id,
|
.find(|(id, _)| *id == r.option_id)
|
||||||
label,
|
.map(|(_, l)| l.clone())
|
||||||
votes: r.score as i64,
|
.unwrap_or_default();
|
||||||
percentage: if total_voters > 0 { (r.score / total_voters as f64) * 100.0 } else { 0.0 },
|
OptionResult {
|
||||||
rank: r.rank as i32,
|
option_id: r.option_id,
|
||||||
}
|
label,
|
||||||
}).collect();
|
votes: r.score as i64,
|
||||||
|
percentage: if total_voters > 0 {
|
||||||
|
(r.score / total_voters as f64) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
rank: r.rank as i32,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok((results, total_voters, serde_json::to_value(&result.details).unwrap_or_default()))
|
Ok((
|
||||||
|
results,
|
||||||
|
total_voters,
|
||||||
|
serde_json::to_value(&result.details).unwrap_or_default(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn calculate_star_results(
|
async fn calculate_star_results(
|
||||||
|
|
@ -1098,35 +1316,49 @@ async fn calculate_star_results(
|
||||||
let option_ids: Vec<Uuid> = options.iter().map(|(id, _)| *id).collect();
|
let option_ids: Vec<Uuid> = options.iter().map(|(id, _)| *id).collect();
|
||||||
|
|
||||||
// Group by voter
|
// Group by voter
|
||||||
let mut voter_scores: std::collections::HashMap<Uuid, Vec<(Uuid, i32)>> = std::collections::HashMap::new();
|
let mut voter_scores: std::collections::HashMap<Uuid, Vec<(Uuid, i32)>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
for v in &votes_raw {
|
for v in &votes_raw {
|
||||||
voter_scores.entry(v.voter_id).or_default().push((v.option_id, v.stars));
|
voter_scores
|
||||||
|
.entry(v.voter_id)
|
||||||
|
.or_default()
|
||||||
|
.push((v.option_id, v.stars));
|
||||||
}
|
}
|
||||||
|
|
||||||
let ballots: Vec<star::ScoreBallot> = voter_scores.values().map(|scores| {
|
let ballots: Vec<star::ScoreBallot> = voter_scores
|
||||||
star::ScoreBallot {
|
.values()
|
||||||
scores: scores.clone()
|
.map(|scores| star::ScoreBallot {
|
||||||
}
|
scores: scores.clone(),
|
||||||
}).collect();
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let total_voters = ballots.len() as i64;
|
let total_voters = ballots.len() as i64;
|
||||||
let result = star::calculate(&option_ids, &ballots);
|
let result = star::calculate(&option_ids, &ballots);
|
||||||
|
|
||||||
let results: Vec<OptionResult> = result.ranking.iter().map(|r| {
|
let results: Vec<OptionResult> = result
|
||||||
let label = options.iter()
|
.ranking
|
||||||
.find(|(id, _)| *id == r.option_id)
|
.iter()
|
||||||
.map(|(_, l)| l.clone())
|
.map(|r| {
|
||||||
.unwrap_or_default();
|
let label = options
|
||||||
OptionResult {
|
.iter()
|
||||||
option_id: r.option_id,
|
.find(|(id, _)| *id == r.option_id)
|
||||||
label,
|
.map(|(_, l)| l.clone())
|
||||||
votes: r.score as i64,
|
.unwrap_or_default();
|
||||||
percentage: 0.0,
|
OptionResult {
|
||||||
rank: r.rank as i32,
|
option_id: r.option_id,
|
||||||
}
|
label,
|
||||||
}).collect();
|
votes: r.score as i64,
|
||||||
|
percentage: 0.0,
|
||||||
|
rank: r.rank as i32,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok((results, total_voters, serde_json::to_value(&result.details).unwrap_or_default()))
|
Ok((
|
||||||
|
results,
|
||||||
|
total_voters,
|
||||||
|
serde_json::to_value(&result.details).unwrap_or_default(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn calculate_quadratic_results(
|
async fn calculate_quadratic_results(
|
||||||
|
|
@ -1147,35 +1379,55 @@ async fn calculate_quadratic_results(
|
||||||
let option_ids: Vec<Uuid> = options.iter().map(|(id, _)| *id).collect();
|
let option_ids: Vec<Uuid> = options.iter().map(|(id, _)| *id).collect();
|
||||||
|
|
||||||
// Group by voter to build ballots
|
// Group by voter to build ballots
|
||||||
let mut voter_allocations: std::collections::HashMap<Uuid, Vec<(Uuid, i32)>> = std::collections::HashMap::new();
|
let mut voter_allocations: std::collections::HashMap<Uuid, Vec<(Uuid, i32)>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
for v in &votes_raw {
|
for v in &votes_raw {
|
||||||
voter_allocations.entry(v.voter_id).or_default().push((v.option_id, v.credits));
|
voter_allocations
|
||||||
|
.entry(v.voter_id)
|
||||||
|
.or_default()
|
||||||
|
.push((v.option_id, v.credits));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to QuadraticBallot format (100 credits per voter)
|
// Convert to QuadraticBallot format (100 credits per voter)
|
||||||
let ballots: Vec<quadratic::QuadraticBallot> = voter_allocations.values().map(|allocs| {
|
let ballots: Vec<quadratic::QuadraticBallot> = voter_allocations
|
||||||
quadratic::QuadraticBallot {
|
.values()
|
||||||
total_credits: 100, // Standard credit allocation
|
.map(|allocs| {
|
||||||
allocations: allocs.clone(),
|
quadratic::QuadraticBallot {
|
||||||
}
|
total_credits: 100, // Standard credit allocation
|
||||||
}).collect();
|
allocations: allocs.clone(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let total_voters = ballots.len() as i64;
|
let total_voters = ballots.len() as i64;
|
||||||
let result = quadratic::calculate(&option_ids, &ballots);
|
let result = quadratic::calculate(&option_ids, &ballots);
|
||||||
|
|
||||||
let results: Vec<OptionResult> = result.ranking.iter().map(|r| {
|
let results: Vec<OptionResult> = result
|
||||||
let label = options.iter()
|
.ranking
|
||||||
.find(|(id, _)| *id == r.option_id)
|
.iter()
|
||||||
.map(|(_, l)| l.clone())
|
.map(|r| {
|
||||||
.unwrap_or_default();
|
let label = options
|
||||||
OptionResult {
|
.iter()
|
||||||
option_id: r.option_id,
|
.find(|(id, _)| *id == r.option_id)
|
||||||
label,
|
.map(|(_, l)| l.clone())
|
||||||
votes: r.score as i64,
|
.unwrap_or_default();
|
||||||
percentage: if total_voters > 0 { (r.score / total_voters as f64) * 100.0 } else { 0.0 },
|
OptionResult {
|
||||||
rank: r.rank as i32,
|
option_id: r.option_id,
|
||||||
}
|
label,
|
||||||
}).collect();
|
votes: r.score as i64,
|
||||||
|
percentage: if total_voters > 0 {
|
||||||
|
(r.score / total_voters as f64) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
rank: r.rank as i32,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok((results, total_voters, serde_json::to_value(&result.details).unwrap_or_default()))
|
Ok((
|
||||||
|
results,
|
||||||
|
total_voters,
|
||||||
|
serde_json::to_value(&result.details).unwrap_or_default(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
routing::{get, post, delete},
|
routing::{delete, get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
|
|
||||||
|
|
@ -117,13 +117,18 @@ async fn list_permissions(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(perms.into_iter().map(|p| Permission {
|
Ok(Json(
|
||||||
id: p.id,
|
perms
|
||||||
name: p.name,
|
.into_iter()
|
||||||
category: p.category,
|
.map(|p| Permission {
|
||||||
description: p.description,
|
id: p.id,
|
||||||
is_system: p.is_system,
|
name: p.name,
|
||||||
}).collect()))
|
category: p.category,
|
||||||
|
description: p.description,
|
||||||
|
is_system: p.is_system,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -149,18 +154,23 @@ async fn list_platform_roles(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(roles.into_iter().map(|r| Role {
|
Ok(Json(
|
||||||
id: r.id,
|
roles
|
||||||
name: r.name,
|
.into_iter()
|
||||||
display_name: r.display_name,
|
.map(|r| Role {
|
||||||
description: r.description,
|
id: r.id,
|
||||||
color: r.color,
|
name: r.name,
|
||||||
community_id: None,
|
display_name: r.display_name,
|
||||||
is_system: r.is_system,
|
description: r.description,
|
||||||
is_default: r.is_default,
|
color: r.color,
|
||||||
priority: r.priority,
|
community_id: None,
|
||||||
permissions: r.permissions.unwrap_or_default(),
|
is_system: r.is_system,
|
||||||
}).collect()))
|
is_default: r.is_default,
|
||||||
|
priority: r.priority,
|
||||||
|
permissions: r.permissions.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -188,18 +198,23 @@ async fn list_community_roles(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(roles.into_iter().map(|r| Role {
|
Ok(Json(
|
||||||
id: r.id,
|
roles
|
||||||
name: r.name,
|
.into_iter()
|
||||||
display_name: r.display_name,
|
.map(|r| Role {
|
||||||
description: r.description,
|
id: r.id,
|
||||||
color: r.color,
|
name: r.name,
|
||||||
community_id: Some(community_id),
|
display_name: r.display_name,
|
||||||
is_system: r.is_system,
|
description: r.description,
|
||||||
is_default: r.is_default,
|
color: r.color,
|
||||||
priority: r.priority,
|
community_id: Some(community_id),
|
||||||
permissions: r.permissions.unwrap_or_default(),
|
is_system: r.is_system,
|
||||||
}).collect()))
|
is_default: r.is_default,
|
||||||
|
priority: r.priority,
|
||||||
|
permissions: r.permissions.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a community role
|
/// Create a community role
|
||||||
|
|
@ -221,7 +236,10 @@ async fn create_community_role(
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if !has_perm {
|
if !has_perm {
|
||||||
return Err((StatusCode::FORBIDDEN, "No permission to manage roles".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"No permission to manage roles".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create role
|
// Create role
|
||||||
|
|
@ -288,7 +306,10 @@ async fn assign_role(
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if !has_perm {
|
if !has_perm {
|
||||||
return Err((StatusCode::FORBIDDEN, "No permission to manage roles".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"No permission to manage roles".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify role belongs to community
|
// Verify role belongs to community
|
||||||
|
|
@ -339,7 +360,10 @@ async fn remove_role(
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if !has_perm {
|
if !has_perm {
|
||||||
return Err((StatusCode::FORBIDDEN, "No permission to manage roles".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"No permission to manage roles".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
|
|
@ -375,12 +399,17 @@ async fn get_user_roles(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(roles.into_iter().map(|r| RoleSummary {
|
Ok(Json(
|
||||||
id: r.id,
|
roles
|
||||||
name: r.name,
|
.into_iter()
|
||||||
display_name: r.display_name,
|
.map(|r| RoleSummary {
|
||||||
color: r.color,
|
id: r.id,
|
||||||
}).collect()))
|
name: r.name,
|
||||||
|
display_name: r.display_name,
|
||||||
|
color: r.color,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if current user has a specific permission
|
/// Check if current user has a specific permission
|
||||||
|
|
@ -417,10 +446,25 @@ pub fn router(pool: PgPool) -> Router {
|
||||||
// Platform roles
|
// Platform roles
|
||||||
.route("/api/roles", get(list_platform_roles))
|
.route("/api/roles", get(list_platform_roles))
|
||||||
// Community roles
|
// Community roles
|
||||||
.route("/api/communities/{community_id}/roles", get(list_community_roles).post(create_community_role))
|
.route(
|
||||||
.route("/api/communities/{community_id}/roles/{role_id}/assign", post(assign_role))
|
"/api/communities/{community_id}/roles",
|
||||||
.route("/api/communities/{community_id}/roles/{role_id}/users/{user_id}", delete(remove_role))
|
get(list_community_roles).post(create_community_role),
|
||||||
.route("/api/communities/{community_id}/users/{user_id}/roles", get(get_user_roles))
|
)
|
||||||
.route("/api/communities/{community_id}/permissions/{permission_name}/check", get(check_permission))
|
.route(
|
||||||
|
"/api/communities/{community_id}/roles/{role_id}/assign",
|
||||||
|
post(assign_role),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/communities/{community_id}/roles/{role_id}/users/{user_id}",
|
||||||
|
delete(remove_role),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/communities/{community_id}/users/{user_id}/roles",
|
||||||
|
get(get_user_roles),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/communities/{community_id}/permissions/{permission_name}/check",
|
||||||
|
get(check_permission),
|
||||||
|
)
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,7 @@ use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use crate::plugins::builtin::self_moderation::{
|
use crate::plugins::builtin::self_moderation::{CommunityRule, ModerationRulesService};
|
||||||
CommunityRule, ModerationRulesService,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Request Types
|
// Request Types
|
||||||
|
|
@ -174,12 +172,24 @@ async fn lift_sanction(
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
// Rules
|
// Rules
|
||||||
.route("/api/communities/{community_id}/rules", get(get_community_rules).post(create_rule))
|
.route(
|
||||||
|
"/api/communities/{community_id}/rules",
|
||||||
|
get(get_community_rules).post(create_rule),
|
||||||
|
)
|
||||||
// Violations
|
// Violations
|
||||||
.route("/api/communities/{community_id}/violations", get(get_pending_violations).post(report_violation))
|
.route(
|
||||||
.route("/api/violations/{violation_id}/review", post(review_violation))
|
"/api/communities/{community_id}/violations",
|
||||||
|
get(get_pending_violations).post(report_violation),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/violations/{violation_id}/review",
|
||||||
|
post(review_violation),
|
||||||
|
)
|
||||||
// User summary
|
// User summary
|
||||||
.route("/api/communities/{community_id}/users/{user_id}/moderation", get(get_user_summary))
|
.route(
|
||||||
|
"/api/communities/{community_id}/users/{user_id}/moderation",
|
||||||
|
get(get_user_summary),
|
||||||
|
)
|
||||||
// Sanctions
|
// Sanctions
|
||||||
.route("/api/sanctions/{sanction_id}/lift", post(lift_sanction))
|
.route("/api/sanctions/{sanction_id}/lift", post(lift_sanction))
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
routing::{get, patch, post},
|
routing::{get, patch, post},
|
||||||
Extension,
|
Extension, Json, Router,
|
||||||
Json, Router,
|
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
@ -12,9 +11,9 @@ use sqlx::PgPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::permissions::{perms, require_any_permission, require_permission};
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use super::permissions::{require_permission, require_any_permission, perms};
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -100,12 +99,10 @@ pub struct UpdateCommunitySettingsRequest {
|
||||||
|
|
||||||
/// Check if setup is required (public endpoint)
|
/// Check if setup is required (public endpoint)
|
||||||
async fn get_setup_status(State(pool): State<PgPool>) -> Result<Json<SetupStatus>, String> {
|
async fn get_setup_status(State(pool): State<PgPool>) -> Result<Json<SetupStatus>, String> {
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query!("SELECT setup_completed, instance_name FROM instance_settings LIMIT 1")
|
||||||
"SELECT setup_completed, instance_name FROM instance_settings LIMIT 1"
|
.fetch_optional(&pool)
|
||||||
)
|
.await
|
||||||
.fetch_optional(&pool)
|
.map_err(|e| e.to_string())?;
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
match row {
|
match row {
|
||||||
Some(r) => Ok(Json(SetupStatus {
|
Some(r) => Ok(Json(SetupStatus {
|
||||||
|
|
@ -120,7 +117,9 @@ async fn get_setup_status(State(pool): State<PgPool>) -> Result<Json<SetupStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public instance settings (no auth)
|
/// Public instance settings (no auth)
|
||||||
async fn get_public_settings(State(pool): State<PgPool>) -> Result<Json<PublicInstanceSettings>, String> {
|
async fn get_public_settings(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<PublicInstanceSettings>, String> {
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query!(
|
||||||
r#"SELECT setup_completed, instance_name, platform_mode,
|
r#"SELECT setup_completed, instance_name, platform_mode,
|
||||||
registration_enabled, registration_mode,
|
registration_enabled, registration_mode,
|
||||||
|
|
@ -186,12 +185,18 @@ async fn complete_setup(
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
if existing.map(|e| e.setup_completed).unwrap_or(false) {
|
if existing.map(|e| e.setup_completed).unwrap_or(false) {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Setup already completed".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Setup already completed".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle single_community mode
|
// Handle single_community mode
|
||||||
let single_community_id: Option<Uuid> = if req.platform_mode == "single_community" {
|
let single_community_id: Option<Uuid> = if req.platform_mode == "single_community" {
|
||||||
let name = req.single_community_name.as_deref().unwrap_or("Main Community");
|
let name = req
|
||||||
|
.single_community_name
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("Main Community");
|
||||||
let community = sqlx::query!(
|
let community = sqlx::query!(
|
||||||
r#"INSERT INTO communities (name, slug, description, is_active, created_by)
|
r#"INSERT INTO communities (name, slug, description, is_active, created_by)
|
||||||
VALUES ($1, $2, $3, true, $4)
|
VALUES ($1, $2, $3, true, $4)
|
||||||
|
|
@ -375,7 +380,8 @@ async fn update_community_settings(
|
||||||
auth.user_id,
|
auth.user_id,
|
||||||
&[perms::COMMUNITY_SETTINGS, perms::PLATFORM_ADMIN],
|
&[perms::COMMUNITY_SETTINGS, perms::PLATFORM_ADMIN],
|
||||||
Some(community_id),
|
Some(community_id),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Ensure settings exist
|
// Ensure settings exist
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
|
|
@ -426,7 +432,13 @@ pub fn router(pool: PgPool) -> Router {
|
||||||
.route("/api/settings/setup", post(complete_setup))
|
.route("/api/settings/setup", post(complete_setup))
|
||||||
.route("/api/settings/instance", get(get_instance_settings))
|
.route("/api/settings/instance", get(get_instance_settings))
|
||||||
.route("/api/settings/instance", patch(update_instance_settings))
|
.route("/api/settings/instance", patch(update_instance_settings))
|
||||||
.route("/api/settings/communities/{community_id}", get(get_community_settings))
|
.route(
|
||||||
.route("/api/settings/communities/{community_id}", patch(update_community_settings))
|
"/api/settings/communities/{community_id}",
|
||||||
|
get(get_community_settings),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/settings/communities/{community_id}",
|
||||||
|
patch(update_community_settings),
|
||||||
|
)
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,7 @@ pub fn router(pool: PgPool) -> Router {
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_users(
|
async fn list_users(State(pool): State<PgPool>) -> Result<Json<Vec<UserResponse>>, String> {
|
||||||
State(pool): State<PgPool>,
|
|
||||||
) -> Result<Json<Vec<UserResponse>>, String> {
|
|
||||||
let users = sqlx::query_as!(
|
let users = sqlx::query_as!(
|
||||||
crate::models::User,
|
crate::models::User,
|
||||||
"SELECT * FROM users WHERE is_active = true ORDER BY created_at DESC LIMIT 100"
|
"SELECT * FROM users WHERE is_active = true ORDER BY created_at DESC LIMIT 100"
|
||||||
|
|
@ -104,12 +102,15 @@ async fn get_user_profile(
|
||||||
username: user.username,
|
username: user.username,
|
||||||
display_name: user.display_name,
|
display_name: user.display_name,
|
||||||
created_at: user.created_at,
|
created_at: user.created_at,
|
||||||
communities: communities.into_iter().map(|c| CommunityMembership {
|
communities: communities
|
||||||
id: c.id,
|
.into_iter()
|
||||||
name: c.name,
|
.map(|c| CommunityMembership {
|
||||||
slug: c.slug,
|
id: c.id,
|
||||||
role: c.role,
|
name: c.name,
|
||||||
}).collect(),
|
slug: c.slug,
|
||||||
|
role: c.role,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
proposal_count,
|
proposal_count,
|
||||||
comment_count,
|
comment_count,
|
||||||
}))
|
}))
|
||||||
|
|
@ -176,13 +177,16 @@ async fn get_user_votes(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let result = votes.into_iter().map(|v| UserVote {
|
let result = votes
|
||||||
proposal_id: v.proposal_id,
|
.into_iter()
|
||||||
proposal_title: v.proposal_title,
|
.map(|v| UserVote {
|
||||||
community_name: v.community_name,
|
proposal_id: v.proposal_id,
|
||||||
option_label: v.option_label,
|
proposal_title: v.proposal_title,
|
||||||
voted_at: v.voted_at,
|
community_name: v.community_name,
|
||||||
}).collect();
|
option_label: v.option_label,
|
||||||
|
voted_at: v.voted_at,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,14 @@ use axum::{
|
||||||
routing::{get, post, put},
|
routing::{get, post, put},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
#[allow(unused_imports)]
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
|
use super::permissions::{perms, require_permission};
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use super::permissions::{require_permission, perms};
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
|
|
@ -100,19 +100,24 @@ async fn list_voting_methods(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(methods.into_iter().map(|m| VotingMethodPlugin {
|
Ok(Json(
|
||||||
id: m.id,
|
methods
|
||||||
name: m.name,
|
.into_iter()
|
||||||
display_name: m.display_name,
|
.map(|m| VotingMethodPlugin {
|
||||||
description: m.description,
|
id: m.id,
|
||||||
icon: m.icon,
|
name: m.name,
|
||||||
is_active: m.is_active,
|
display_name: m.display_name,
|
||||||
is_default: m.is_default,
|
description: m.description,
|
||||||
config_schema: m.config_schema,
|
icon: m.icon,
|
||||||
default_config: m.default_config.unwrap_or_default(),
|
is_active: m.is_active,
|
||||||
complexity_level: m.complexity_level.unwrap_or_default(),
|
is_default: m.is_default,
|
||||||
supports_delegation: m.supports_delegation,
|
config_schema: m.config_schema,
|
||||||
}).collect()))
|
default_config: m.default_config.unwrap_or_default(),
|
||||||
|
complexity_level: m.complexity_level.unwrap_or_default(),
|
||||||
|
supports_delegation: m.supports_delegation,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update platform voting method (admin only)
|
/// Update platform voting method (admin only)
|
||||||
|
|
@ -191,25 +196,30 @@ async fn list_community_voting_methods(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(methods.into_iter().map(|m| CommunityVotingMethod {
|
Ok(Json(
|
||||||
id: m.id,
|
methods
|
||||||
voting_method: VotingMethodPlugin {
|
.into_iter()
|
||||||
id: m.id,
|
.map(|m| CommunityVotingMethod {
|
||||||
name: m.name.clone(),
|
id: m.id,
|
||||||
display_name: m.display_name.clone(),
|
voting_method: VotingMethodPlugin {
|
||||||
description: m.description.clone(),
|
id: m.id,
|
||||||
icon: m.icon.clone(),
|
name: m.name.clone(),
|
||||||
is_active: m.platform_active,
|
display_name: m.display_name.clone(),
|
||||||
is_default: m.is_default.unwrap_or(false),
|
description: m.description.clone(),
|
||||||
config_schema: m.config_schema.clone(),
|
icon: m.icon.clone(),
|
||||||
default_config: m.default_config.clone().unwrap_or_default(),
|
is_active: m.platform_active,
|
||||||
complexity_level: m.complexity_level.clone().unwrap_or_default(),
|
is_default: m.is_default.unwrap_or(false),
|
||||||
supports_delegation: m.supports_delegation,
|
config_schema: m.config_schema.clone(),
|
||||||
},
|
default_config: m.default_config.clone().unwrap_or_default(),
|
||||||
is_enabled: m.is_enabled.unwrap_or(false),
|
complexity_level: m.complexity_level.clone().unwrap_or_default(),
|
||||||
is_default: m.is_default.unwrap_or(false),
|
supports_delegation: m.supports_delegation,
|
||||||
config: m.config.unwrap_or_default(),
|
},
|
||||||
}).collect()))
|
is_enabled: m.is_enabled.unwrap_or(false),
|
||||||
|
is_default: m.is_default.unwrap_or(false),
|
||||||
|
config: m.config.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configure voting method for a community
|
/// Configure voting method for a community
|
||||||
|
|
@ -231,7 +241,10 @@ async fn configure_community_voting_method(
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if !has_perm {
|
if !has_perm {
|
||||||
return Err((StatusCode::FORBIDDEN, "No permission to manage voting methods".to_string()));
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"No permission to manage voting methods".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// If setting as default, unset other defaults first
|
// If setting as default, unset other defaults first
|
||||||
|
|
@ -313,16 +326,21 @@ async fn list_default_plugins(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(plugins.into_iter().map(|p| DefaultPlugin {
|
Ok(Json(
|
||||||
plugin_name: p.plugin_name,
|
plugins
|
||||||
plugin_type: p.plugin_type,
|
.into_iter()
|
||||||
display_name: p.display_name,
|
.map(|p| DefaultPlugin {
|
||||||
description: p.description,
|
plugin_name: p.plugin_name,
|
||||||
is_core: p.is_core,
|
plugin_type: p.plugin_type,
|
||||||
is_recommended: p.is_recommended,
|
display_name: p.display_name,
|
||||||
default_enabled: p.default_enabled,
|
description: p.description,
|
||||||
category: p.category,
|
is_core: p.is_core,
|
||||||
}).collect()))
|
is_recommended: p.is_recommended,
|
||||||
|
default_enabled: p.default_enabled,
|
||||||
|
category: p.category,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List instance plugins
|
/// List instance plugins
|
||||||
|
|
@ -336,11 +354,16 @@ async fn list_instance_plugins(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
Ok(Json(plugins.into_iter().map(|p| InstancePlugin {
|
Ok(Json(
|
||||||
plugin_name: p.plugin_name,
|
plugins
|
||||||
is_enabled: p.is_enabled,
|
.into_iter()
|
||||||
config: p.config.unwrap_or_default(),
|
.map(|p| InstancePlugin {
|
||||||
}).collect()))
|
plugin_name: p.plugin_name,
|
||||||
|
is_enabled: p.is_enabled,
|
||||||
|
config: p.config.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update instance plugin
|
/// Update instance plugin
|
||||||
|
|
@ -364,7 +387,10 @@ async fn update_instance_plugin(
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if is_core && req.is_enabled == Some(false) {
|
if is_core && req.is_enabled == Some(false) {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Cannot disable core plugins".to_string()));
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Cannot disable core plugins".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let plugin = sqlx::query!(
|
let plugin = sqlx::query!(
|
||||||
|
|
@ -401,17 +427,16 @@ async fn initialize_default_plugins(
|
||||||
require_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?;
|
require_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?;
|
||||||
|
|
||||||
// Get all default plugins
|
// Get all default plugins
|
||||||
let defaults = sqlx::query!(
|
let defaults =
|
||||||
"SELECT plugin_name, is_core, default_enabled FROM default_plugins"
|
sqlx::query!("SELECT plugin_name, is_core, default_enabled FROM default_plugins")
|
||||||
)
|
.fetch_all(&pool)
|
||||||
.fetch_all(&pool)
|
.await
|
||||||
.await
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
// Initialize each plugin
|
// Initialize each plugin
|
||||||
for plugin in defaults {
|
for plugin in defaults {
|
||||||
let is_enabled = plugin.is_core || enabled_plugins.contains(&plugin.plugin_name);
|
let is_enabled = plugin.is_core || enabled_plugins.contains(&plugin.plugin_name);
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
r#"INSERT INTO instance_plugins (plugin_name, is_enabled, enabled_by, enabled_at)
|
r#"INSERT INTO instance_plugins (plugin_name, is_enabled, enabled_by, enabled_at)
|
||||||
VALUES ($1, $2, $3, NOW())
|
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", get(list_voting_methods))
|
||||||
.route("/api/voting-methods/{method_id}", put(update_voting_method))
|
.route("/api/voting-methods/{method_id}", put(update_voting_method))
|
||||||
// Community voting methods
|
// Community voting methods
|
||||||
.route("/api/communities/{community_id}/voting-methods", get(list_community_voting_methods))
|
.route(
|
||||||
.route("/api/communities/{community_id}/voting-methods/{method_id}", put(configure_community_voting_method))
|
"/api/communities/{community_id}/voting-methods",
|
||||||
|
get(list_community_voting_methods),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/communities/{community_id}/voting-methods/{method_id}",
|
||||||
|
put(configure_community_voting_method),
|
||||||
|
)
|
||||||
// Default plugins
|
// Default plugins
|
||||||
.route("/api/plugins/defaults", get(list_default_plugins))
|
.route("/api/plugins/defaults", get(list_default_plugins))
|
||||||
.route("/api/plugins/instance", get(list_instance_plugins))
|
.route("/api/plugins/instance", get(list_instance_plugins))
|
||||||
.route("/api/plugins/instance/{plugin_name}", put(update_instance_plugin))
|
.route(
|
||||||
|
"/api/plugins/instance/{plugin_name}",
|
||||||
|
put(update_instance_plugin),
|
||||||
|
)
|
||||||
.route("/api/plugins/initialize", post(initialize_default_plugins))
|
.route("/api/plugins/initialize", post(initialize_default_plugins))
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,11 +149,26 @@ async fn advance_phase(
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
// Templates
|
// Templates
|
||||||
.route("/api/communities/{community_id}/workflow-templates", get(list_templates).post(create_template))
|
.route(
|
||||||
.route("/api/workflow-templates/{template_id}/phases", get(get_template_phases).post(add_phase))
|
"/api/communities/{community_id}/workflow-templates",
|
||||||
|
get(list_templates).post(create_template),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/workflow-templates/{template_id}/phases",
|
||||||
|
get(get_template_phases).post(add_phase),
|
||||||
|
)
|
||||||
// Proposal workflows
|
// Proposal workflows
|
||||||
.route("/api/proposals/{proposal_id}/workflow", get(get_workflow_for_proposal))
|
.route(
|
||||||
.route("/api/proposals/{proposal_id}/workflow/progress", get(get_workflow_progress))
|
"/api/proposals/{proposal_id}/workflow",
|
||||||
.route("/api/proposals/{proposal_id}/workflow/advance", post(advance_phase))
|
get(get_workflow_for_proposal),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/proposals/{proposal_id}/workflow/progress",
|
||||||
|
get(get_workflow_progress),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/proposals/{proposal_id}/workflow/advance",
|
||||||
|
post(advance_phase),
|
||||||
|
)
|
||||||
.with_state(pool)
|
.with_state(pool)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@ pub struct Claims {
|
||||||
pub iat: i64,
|
pub iat: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_token(user_id: Uuid, username: &str, secret: &str) -> Result<String, jsonwebtoken::errors::Error> {
|
pub fn create_token(
|
||||||
|
user_id: Uuid,
|
||||||
|
username: &str,
|
||||||
|
secret: &str,
|
||||||
|
) -> Result<String, jsonwebtoken::errors::Error> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let exp = now + Duration::hours(24);
|
let exp = now + Duration::hours(24);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,10 @@ where
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header"))?;
|
.ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header"))?;
|
||||||
|
|
||||||
let token = auth_header
|
let token = auth_header.strip_prefix("Bearer ").ok_or((
|
||||||
.strip_prefix("Bearer ")
|
StatusCode::UNAUTHORIZED,
|
||||||
.ok_or((StatusCode::UNAUTHORIZED, "Invalid authorization header format"))?;
|
"Invalid authorization header format",
|
||||||
|
))?;
|
||||||
|
|
||||||
let secret = parts
|
let secret = parts
|
||||||
.extensions
|
.extensions
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
pub mod password;
|
|
||||||
pub mod jwt;
|
pub mod jwt;
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
|
pub mod password;
|
||||||
|
|
||||||
pub use password::{hash_password, verify_password};
|
|
||||||
pub use jwt::create_token;
|
pub use jwt::create_token;
|
||||||
pub use middleware::AuthUser;
|
pub use middleware::AuthUser;
|
||||||
|
pub use password::{hash_password, verify_password};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//! Demo mode functionality for Likwid
|
//! Demo mode functionality for Likwid
|
||||||
//!
|
//!
|
||||||
//! When DEMO_MODE=true, the system:
|
//! When DEMO_MODE=true, the system:
|
||||||
//! - Restricts certain destructive actions (delete community, change instance settings)
|
//! - Restricts certain destructive actions (delete community, change instance settings)
|
||||||
//! - Allows demo accounts to log in with known passwords
|
//! - 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?;
|
.await?;
|
||||||
|
|
||||||
// Votes and delegation artifacts
|
// Votes and delegation artifacts
|
||||||
sqlx::query("DELETE FROM votes")
|
sqlx::query("DELETE FROM votes").execute(&mut *tx).await?;
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
sqlx::query("DELETE FROM delegated_votes")
|
sqlx::query("DELETE FROM delegated_votes")
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -403,7 +401,7 @@ pub async fn reset_demo_data(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
|
||||||
tracing::info!("Demo data reset complete");
|
tracing::info!("Demo data reset complete");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@ mod plugins;
|
||||||
mod rate_limit;
|
mod rate_limit;
|
||||||
mod voting;
|
mod voting;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use axum::{middleware, Extension};
|
|
||||||
use axum::http::{HeaderName, HeaderValue};
|
use axum::http::{HeaderName, HeaderValue};
|
||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
|
use axum::{middleware, Extension};
|
||||||
use chrono::{Datelike, Timelike, Utc, Weekday};
|
use chrono::{Datelike, Timelike, Utc, Weekday};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
@ -65,7 +65,8 @@ async fn run() -> Result<(), StartupError> {
|
||||||
tracing::info!("🎭 DEMO MODE ENABLED - Some actions are restricted");
|
tracing::info!("🎭 DEMO MODE ENABLED - Some actions are restricted");
|
||||||
}
|
}
|
||||||
|
|
||||||
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| config.database_url.clone());
|
let database_url =
|
||||||
|
std::env::var("DATABASE_URL").unwrap_or_else(|_| config.database_url.clone());
|
||||||
|
|
||||||
let pool = db::create_pool(&database_url).await?;
|
let pool = db::create_pool(&database_url).await?;
|
||||||
|
|
||||||
|
|
@ -121,7 +122,9 @@ async fn run() -> Result<(), StartupError> {
|
||||||
};
|
};
|
||||||
|
|
||||||
let payload = json!({"ts": now.to_rfc3339()});
|
let payload = json!({"ts": now.to_rfc3339()});
|
||||||
cron_plugins.do_action("cron.minute", ctx.clone(), payload.clone()).await;
|
cron_plugins
|
||||||
|
.do_action("cron.minute", ctx.clone(), payload.clone())
|
||||||
|
.await;
|
||||||
cron_plugins
|
cron_plugins
|
||||||
.do_action("cron.minutely", ctx.clone(), payload.clone())
|
.do_action("cron.minutely", ctx.clone(), payload.clone())
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -166,18 +169,17 @@ async fn run() -> Result<(), StartupError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// WASM plugins need per-community context.
|
// WASM plugins need per-community context.
|
||||||
let community_ids: Vec<Uuid> = match sqlx::query_scalar(
|
let community_ids: Vec<Uuid> =
|
||||||
"SELECT id FROM communities WHERE is_active = true",
|
match sqlx::query_scalar("SELECT id FROM communities WHERE is_active = true")
|
||||||
)
|
.fetch_all(&cron_pool)
|
||||||
.fetch_all(&cron_pool)
|
.await
|
||||||
.await
|
{
|
||||||
{
|
Ok(ids) => ids,
|
||||||
Ok(ids) => ids,
|
Err(e) => {
|
||||||
Err(e) => {
|
tracing::error!("cron: failed to list communities: {}", e);
|
||||||
tracing::error!("cron: failed to list communities: {}", e);
|
continue;
|
||||||
continue;
|
}
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
|
||||||
let mut wasm_hooks: Vec<&'static str> = vec!["cron.minute", "cron.minutely"];
|
let mut wasm_hooks: Vec<&'static str> = vec!["cron.minute", "cron.minutely"];
|
||||||
if min15_key == last_15min_key {
|
if min15_key == last_15min_key {
|
||||||
|
|
@ -215,15 +217,20 @@ async fn run() -> Result<(), StartupError> {
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(middleware::map_response(add_security_headers));
|
.layer(middleware::map_response(add_security_headers));
|
||||||
|
|
||||||
let host: std::net::IpAddr = config.server_host.parse()
|
let host: std::net::IpAddr = config
|
||||||
|
.server_host
|
||||||
|
.parse()
|
||||||
.unwrap_or_else(|_| std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)));
|
.unwrap_or_else(|_| std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)));
|
||||||
let addr = SocketAddr::from((host, config.server_port));
|
let addr = SocketAddr::from((host, config.server_port));
|
||||||
tracing::info!("Likwid backend listening on http://{}", addr);
|
tracing::info!("Likwid backend listening on http://{}", addr);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
|
axum::serve(
|
||||||
.await
|
listener,
|
||||||
.map_err(|e| StartupError::Serve(e.to_string()))?;
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StartupError::Serve(e.to_string()))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
pub struct Community {
|
pub struct Community {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
pub mod user;
|
|
||||||
pub mod community;
|
pub mod community;
|
||||||
pub mod proposal;
|
pub mod proposal;
|
||||||
|
pub mod user;
|
||||||
|
|
||||||
pub use user::User;
|
|
||||||
pub use community::Community;
|
pub use community::Community;
|
||||||
pub use proposal::ProposalStatus;
|
pub use proposal::ProposalStatus;
|
||||||
|
pub use user::User;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::Type)]
|
||||||
#[sqlx(type_name = "proposal_status", rename_all = "lowercase")]
|
#[sqlx(type_name = "proposal_status", rename_all = "lowercase")]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::FromRow;
|
use sqlx::FromRow;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::plugins::{
|
use crate::plugins::{
|
||||||
hooks::HookContext,
|
hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
|
||||||
manager::PluginSystem,
|
|
||||||
Plugin, PluginError, PluginMetadata, PluginScope,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct ConflictResolutionPlugin;
|
pub struct ConflictResolutionPlugin;
|
||||||
|
|
@ -48,24 +46,33 @@ impl Plugin for ConflictResolutionPlugin {
|
||||||
50,
|
50,
|
||||||
Arc::new(|ctx: HookContext, payload: Value| {
|
Arc::new(|ctx: HookContext, payload: Value| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
if let Some(conflict_id) = payload.get("conflict_id")
|
if let Some(conflict_id) = payload
|
||||||
|
.get("conflict_id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| Uuid::parse_str(s).ok())
|
.and_then(|s| Uuid::parse_str(s).ok())
|
||||||
{
|
{
|
||||||
// Check if auto-assign is enabled
|
// Check if auto-assign is enabled
|
||||||
if let Some(community_id) = ctx.community_id {
|
if let Some(community_id) = ctx.community_id {
|
||||||
let auto_assign = ConflictService::should_auto_assign(&ctx.pool, community_id).await?;
|
let auto_assign =
|
||||||
|
ConflictService::should_auto_assign(&ctx.pool, community_id)
|
||||||
|
.await?;
|
||||||
if auto_assign {
|
if auto_assign {
|
||||||
ConflictService::assign_mediators(&ctx.pool, conflict_id, ctx.actor_user_id).await?;
|
ConflictService::assign_mediators(
|
||||||
|
&ctx.pool,
|
||||||
|
conflict_id,
|
||||||
|
ctx.actor_user_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.emit_public_event(
|
ctx.emit_public_event(
|
||||||
Some("conflict_resolution"),
|
Some("conflict_resolution"),
|
||||||
"conflict.reported",
|
"conflict.reported",
|
||||||
payload.clone(),
|
payload.clone(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
@ -82,7 +89,8 @@ impl Plugin for ConflictResolutionPlugin {
|
||||||
Some("conflict_resolution"),
|
Some("conflict_resolution"),
|
||||||
"compromise.proposed",
|
"compromise.proposed",
|
||||||
payload.clone(),
|
payload.clone(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
@ -99,7 +107,8 @@ impl Plugin for ConflictResolutionPlugin {
|
||||||
Some("conflict_resolution"),
|
Some("conflict_resolution"),
|
||||||
"conflict.resolved",
|
"conflict.resolved",
|
||||||
payload.clone(),
|
payload.clone(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
@ -135,7 +144,10 @@ pub struct ConflictService;
|
||||||
|
|
||||||
impl ConflictService {
|
impl ConflictService {
|
||||||
/// Check if auto-assign mediators is enabled
|
/// Check if auto-assign mediators is enabled
|
||||||
pub async fn should_auto_assign(pool: &PgPool, community_id: Uuid) -> Result<bool, PluginError> {
|
pub async fn should_auto_assign(
|
||||||
|
pool: &PgPool,
|
||||||
|
community_id: Uuid,
|
||||||
|
) -> Result<bool, PluginError> {
|
||||||
let result = sqlx::query_scalar!(
|
let result = sqlx::query_scalar!(
|
||||||
r#"SELECT COALESCE(
|
r#"SELECT COALESCE(
|
||||||
(SELECT (cp.settings->>'auto_assign_mediators')::boolean
|
(SELECT (cp.settings->>'auto_assign_mediators')::boolean
|
||||||
|
|
@ -148,7 +160,7 @@ impl ConflictService {
|
||||||
)
|
)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,7 +184,7 @@ impl ConflictService {
|
||||||
party_a_id, party_b_id, reported_by, reported_anonymously,
|
party_a_id, party_b_id, reported_by, reported_anonymously,
|
||||||
severity_level
|
severity_level
|
||||||
) VALUES ($1, $2, $3, $4::conflict_type, $5, $6, $7, $8, $9)
|
) VALUES ($1, $2, $3, $4::conflict_type, $5, $6, $7, $8, $9)
|
||||||
RETURNING id"#
|
RETURNING id"#,
|
||||||
)
|
)
|
||||||
.bind(community_id)
|
.bind(community_id)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
|
|
@ -210,7 +222,7 @@ impl ConflictService {
|
||||||
.bind(assigned_by)
|
.bind(assigned_by)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,7 +235,7 @@ impl ConflictService {
|
||||||
notes: Option<&str>,
|
notes: Option<&str>,
|
||||||
) -> Result<bool, PluginError> {
|
) -> Result<bool, PluginError> {
|
||||||
let success: bool = sqlx::query_scalar(
|
let success: bool = sqlx::query_scalar(
|
||||||
"SELECT transition_conflict_status($1, $2::conflict_status, $3, $4)"
|
"SELECT transition_conflict_status($1, $2::conflict_status, $3, $4)",
|
||||||
)
|
)
|
||||||
.bind(conflict_id)
|
.bind(conflict_id)
|
||||||
.bind(new_status)
|
.bind(new_status)
|
||||||
|
|
@ -231,7 +243,7 @@ impl ConflictService {
|
||||||
.bind(notes)
|
.bind(notes)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(success)
|
Ok(success)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,7 +288,7 @@ impl ConflictService {
|
||||||
pub async fn respond_to_compromise(
|
pub async fn respond_to_compromise(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
proposal_id: Uuid,
|
proposal_id: Uuid,
|
||||||
party: &str, // "a" or "b"
|
party: &str, // "a" or "b"
|
||||||
response: &str, // accept, reject, counter
|
response: &str, // accept, reject, counter
|
||||||
feedback: Option<&str>,
|
feedback: Option<&str>,
|
||||||
) -> Result<(), PluginError> {
|
) -> Result<(), PluginError> {
|
||||||
|
|
@ -319,8 +331,8 @@ impl ConflictService {
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if proposal.party_a_response.as_deref() == Some("accept")
|
if proposal.party_a_response.as_deref() == Some("accept")
|
||||||
&& proposal.party_b_response.as_deref() == Some("accept")
|
&& proposal.party_b_response.as_deref() == Some("accept")
|
||||||
{
|
{
|
||||||
// Mark proposal as accepted
|
// Mark proposal as accepted
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
|
|
@ -426,7 +438,10 @@ impl ConflictService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get conflict details
|
/// Get conflict details
|
||||||
pub async fn get_conflict(pool: &PgPool, conflict_id: Uuid) -> Result<Option<ConflictCase>, PluginError> {
|
pub async fn get_conflict(
|
||||||
|
pool: &PgPool,
|
||||||
|
conflict_id: Uuid,
|
||||||
|
) -> Result<Option<ConflictCase>, PluginError> {
|
||||||
let conflict = sqlx::query_as!(
|
let conflict = sqlx::query_as!(
|
||||||
ConflictCase,
|
ConflictCase,
|
||||||
r#"SELECT
|
r#"SELECT
|
||||||
|
|
@ -444,7 +459,10 @@ impl ConflictService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get active conflicts for a community
|
/// Get active conflicts for a community
|
||||||
pub async fn get_active_conflicts(pool: &PgPool, community_id: Uuid) -> Result<Vec<ConflictCase>, PluginError> {
|
pub async fn get_active_conflicts(
|
||||||
|
pool: &PgPool,
|
||||||
|
community_id: Uuid,
|
||||||
|
) -> Result<Vec<ConflictCase>, PluginError> {
|
||||||
let conflicts = sqlx::query_as!(
|
let conflicts = sqlx::query_as!(
|
||||||
ConflictCase,
|
ConflictCase,
|
||||||
r#"SELECT
|
r#"SELECT
|
||||||
|
|
@ -502,7 +520,7 @@ impl ConflictService {
|
||||||
certification_level = COALESCE($3, mediator_pool.certification_level),
|
certification_level = COALESCE($3, mediator_pool.certification_level),
|
||||||
is_trained = $4 OR mediator_pool.is_trained,
|
is_trained = $4 OR mediator_pool.is_trained,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
RETURNING id"#
|
RETURNING id"#,
|
||||||
)
|
)
|
||||||
.bind(community_id)
|
.bind(community_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::plugins::{
|
use crate::plugins::{
|
||||||
hooks::HookContext,
|
hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
|
||||||
manager::PluginSystem,
|
|
||||||
Plugin, PluginError, PluginMetadata, PluginScope,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct DecisionWorkflowsPlugin;
|
pub struct DecisionWorkflowsPlugin;
|
||||||
|
|
@ -61,17 +59,31 @@ impl Plugin for DecisionWorkflowsPlugin {
|
||||||
Arc::new(|ctx: HookContext, payload: Value| {
|
Arc::new(|ctx: HookContext, payload: Value| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
if let (Some(proposal_id), Some(community_id)) = (
|
if let (Some(proposal_id), Some(community_id)) = (
|
||||||
payload.get("proposal_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()),
|
payload
|
||||||
payload.get("community_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()),
|
.get("proposal_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| Uuid::parse_str(s).ok()),
|
||||||
|
payload
|
||||||
|
.get("community_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| Uuid::parse_str(s).ok()),
|
||||||
) {
|
) {
|
||||||
// Check if auto-workflow is enabled for this community
|
// Check if auto-workflow is enabled for this community
|
||||||
let auto_start = WorkflowService::should_auto_start_workflow(&ctx.pool, community_id).await?;
|
let auto_start =
|
||||||
|
WorkflowService::should_auto_start_workflow(&ctx.pool, community_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
if auto_start {
|
if auto_start {
|
||||||
let template_id = WorkflowService::get_default_template(&ctx.pool, Some(community_id)).await?;
|
let template_id = WorkflowService::get_default_template(
|
||||||
|
&ctx.pool,
|
||||||
|
Some(community_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
if let Some(tid) = template_id {
|
if let Some(tid) = template_id {
|
||||||
let instance_id = WorkflowService::start_workflow(&ctx.pool, proposal_id, tid).await?;
|
let instance_id =
|
||||||
|
WorkflowService::start_workflow(&ctx.pool, proposal_id, tid)
|
||||||
|
.await?;
|
||||||
|
|
||||||
ctx.emit_public_event(
|
ctx.emit_public_event(
|
||||||
Some("decision_workflows"),
|
Some("decision_workflows"),
|
||||||
"workflow.started",
|
"workflow.started",
|
||||||
|
|
@ -80,7 +92,8 @@ impl Plugin for DecisionWorkflowsPlugin {
|
||||||
"workflow_instance_id": instance_id,
|
"workflow_instance_id": instance_id,
|
||||||
"template_id": tid
|
"template_id": tid
|
||||||
}),
|
}),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,10 +123,19 @@ impl Plugin for DecisionWorkflowsPlugin {
|
||||||
Arc::new(|ctx: HookContext, payload: Value| {
|
Arc::new(|ctx: HookContext, payload: Value| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
if let (Some(proposal_id), Some(user_id)) = (
|
if let (Some(proposal_id), Some(user_id)) = (
|
||||||
payload.get("proposal_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()),
|
payload
|
||||||
|
.get("proposal_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| Uuid::parse_str(s).ok()),
|
||||||
ctx.actor_user_id,
|
ctx.actor_user_id,
|
||||||
) {
|
) {
|
||||||
WorkflowService::record_participation(&ctx.pool, proposal_id, user_id, "viewed").await?;
|
WorkflowService::record_participation(
|
||||||
|
&ctx.pool,
|
||||||
|
proposal_id,
|
||||||
|
user_id,
|
||||||
|
"viewed",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|
@ -127,10 +149,19 @@ impl Plugin for DecisionWorkflowsPlugin {
|
||||||
Arc::new(|ctx: HookContext, payload: Value| {
|
Arc::new(|ctx: HookContext, payload: Value| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
if let (Some(proposal_id), Some(user_id)) = (
|
if let (Some(proposal_id), Some(user_id)) = (
|
||||||
payload.get("proposal_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()),
|
payload
|
||||||
|
.get("proposal_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| Uuid::parse_str(s).ok()),
|
||||||
ctx.actor_user_id,
|
ctx.actor_user_id,
|
||||||
) {
|
) {
|
||||||
WorkflowService::record_participation(&ctx.pool, proposal_id, user_id, "commented").await?;
|
WorkflowService::record_participation(
|
||||||
|
&ctx.pool,
|
||||||
|
proposal_id,
|
||||||
|
user_id,
|
||||||
|
"commented",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|
@ -144,17 +175,25 @@ impl Plugin for DecisionWorkflowsPlugin {
|
||||||
Arc::new(|ctx: HookContext, payload: Value| {
|
Arc::new(|ctx: HookContext, payload: Value| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
if let (Some(proposal_id), Some(user_id)) = (
|
if let (Some(proposal_id), Some(user_id)) = (
|
||||||
payload.get("proposal_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()),
|
payload
|
||||||
|
.get("proposal_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| Uuid::parse_str(s).ok()),
|
||||||
ctx.actor_user_id,
|
ctx.actor_user_id,
|
||||||
) {
|
) {
|
||||||
WorkflowService::record_participation(&ctx.pool, proposal_id, user_id, "voted").await?;
|
WorkflowService::record_participation(
|
||||||
|
&ctx.pool,
|
||||||
|
proposal_id,
|
||||||
|
user_id,
|
||||||
|
"voted",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -228,7 +267,7 @@ impl WorkflowService {
|
||||||
)
|
)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,12 +286,12 @@ impl WorkflowService {
|
||||||
)
|
)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if community_default.is_some() {
|
if community_default.is_some() {
|
||||||
return Ok(community_default);
|
return Ok(community_default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to system default
|
// Fall back to system default
|
||||||
let system_default = sqlx::query_scalar!(
|
let system_default = sqlx::query_scalar!(
|
||||||
r#"SELECT id FROM workflow_templates
|
r#"SELECT id FROM workflow_templates
|
||||||
|
|
@ -261,7 +300,7 @@ impl WorkflowService {
|
||||||
)
|
)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(system_default)
|
Ok(system_default)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,14 +310,12 @@ impl WorkflowService {
|
||||||
proposal_id: Uuid,
|
proposal_id: Uuid,
|
||||||
template_id: Uuid,
|
template_id: Uuid,
|
||||||
) -> Result<Uuid, PluginError> {
|
) -> Result<Uuid, PluginError> {
|
||||||
let instance_id: Uuid = sqlx::query_scalar(
|
let instance_id: Uuid = sqlx::query_scalar("SELECT start_workflow($1, $2)")
|
||||||
"SELECT start_workflow($1, $2)"
|
.bind(proposal_id)
|
||||||
)
|
.bind(template_id)
|
||||||
.bind(proposal_id)
|
.fetch_one(pool)
|
||||||
.bind(template_id)
|
.await?;
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(instance_id)
|
Ok(instance_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,15 +326,14 @@ impl WorkflowService {
|
||||||
user_id: Option<Uuid>,
|
user_id: Option<Uuid>,
|
||||||
reason: Option<&str>,
|
reason: Option<&str>,
|
||||||
) -> Result<Option<Uuid>, PluginError> {
|
) -> Result<Option<Uuid>, PluginError> {
|
||||||
let new_phase_id: Option<Uuid> = sqlx::query_scalar(
|
let new_phase_id: Option<Uuid> =
|
||||||
"SELECT advance_workflow_phase($1, 'manual', $2, $3)"
|
sqlx::query_scalar("SELECT advance_workflow_phase($1, 'manual', $2, $3)")
|
||||||
)
|
.bind(workflow_instance_id)
|
||||||
.bind(workflow_instance_id)
|
.bind(user_id)
|
||||||
.bind(user_id)
|
.bind(reason)
|
||||||
.bind(reason)
|
.fetch_one(pool)
|
||||||
.fetch_one(pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(new_phase_id)
|
Ok(new_phase_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -321,7 +357,7 @@ impl WorkflowService {
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Update participant count
|
// Update participant count
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
r#"UPDATE phase_instances pi
|
r#"UPDATE phase_instances pi
|
||||||
|
|
@ -338,7 +374,7 @@ impl WorkflowService {
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -353,26 +389,25 @@ impl WorkflowService {
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for phase in expired_phases {
|
for phase in expired_phases {
|
||||||
if phase.auto_advance {
|
if phase.auto_advance {
|
||||||
// Auto-advance to next phase
|
// Auto-advance to next phase
|
||||||
let _: Option<Uuid> = sqlx::query_scalar(
|
let _: Option<Uuid> = sqlx::query_scalar(
|
||||||
"SELECT advance_workflow_phase($1, 'timeout', NULL, 'Phase duration expired')"
|
"SELECT advance_workflow_phase($1, 'timeout', NULL, 'Phase duration expired')",
|
||||||
)
|
)
|
||||||
.bind(phase.workflow_instance_id)
|
.bind(phase.workflow_instance_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check quorum for active phases and record snapshots
|
// Check quorum for active phases and record snapshots
|
||||||
let active_phases = sqlx::query!(
|
let active_phases =
|
||||||
r#"SELECT pi.id FROM phase_instances pi WHERE pi.status = 'active'"#
|
sqlx::query!(r#"SELECT pi.id FROM phase_instances pi WHERE pi.status = 'active'"#)
|
||||||
)
|
.fetch_all(pool)
|
||||||
.fetch_all(pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
for phase in active_phases {
|
for phase in active_phases {
|
||||||
// Calculate and record quorum
|
// Calculate and record quorum
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
|
|
@ -383,7 +418,7 @@ impl WorkflowService {
|
||||||
.bind(phase.id)
|
.bind(phase.id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Update quorum_reached flag
|
// Update quorum_reached flag
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
r#"UPDATE phase_instances
|
r#"UPDATE phase_instances
|
||||||
|
|
@ -406,7 +441,7 @@ impl WorkflowService {
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -426,7 +461,7 @@ impl WorkflowService {
|
||||||
)
|
)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(instance)
|
Ok(instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -449,7 +484,7 @@ impl WorkflowService {
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(phases)
|
Ok(phases)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -479,7 +514,7 @@ impl WorkflowService {
|
||||||
)
|
)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
match progress {
|
match progress {
|
||||||
Some(p) => Ok(json!({
|
Some(p) => Ok(json!({
|
||||||
"workflow_id": p.id,
|
"workflow_id": p.id,
|
||||||
|
|
@ -493,10 +528,10 @@ impl WorkflowService {
|
||||||
"total_phases": p.total_phases,
|
"total_phases": p.total_phases,
|
||||||
"completed_phases": p.completed_phases,
|
"completed_phases": p.completed_phases,
|
||||||
"progress_percentage": p.total_phases.map(|t| {
|
"progress_percentage": p.total_phases.map(|t| {
|
||||||
if t > 0 {
|
if t > 0 {
|
||||||
(p.completed_phases.unwrap_or(0) as f64 / t as f64 * 100.0).round()
|
(p.completed_phases.unwrap_or(0) as f64 / t as f64 * 100.0).round()
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})),
|
})),
|
||||||
|
|
@ -519,7 +554,7 @@ impl WorkflowService {
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(templates)
|
Ok(templates)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -542,7 +577,7 @@ impl WorkflowService {
|
||||||
)
|
)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(template_id)
|
Ok(template_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -562,7 +597,7 @@ impl WorkflowService {
|
||||||
default_duration_hours, quorum_value
|
default_duration_hours, quorum_value
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3::workflow_phase_type, $4, $5, $6::numeric)
|
VALUES ($1, $2, $3::workflow_phase_type, $4, $5, $6::numeric)
|
||||||
RETURNING id"#
|
RETURNING id"#,
|
||||||
)
|
)
|
||||||
.bind(template_id)
|
.bind(template_id)
|
||||||
.bind(name)
|
.bind(name)
|
||||||
|
|
@ -572,7 +607,7 @@ impl WorkflowService {
|
||||||
.bind(quorum_value)
|
.bind(quorum_value)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(phase_id)
|
Ok(phase_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::plugins::{
|
use crate::plugins::{
|
||||||
hooks::HookContext,
|
hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
|
||||||
manager::PluginSystem,
|
|
||||||
Plugin, PluginError, PluginMetadata, PluginScope,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct FederationPlugin;
|
pub struct FederationPlugin;
|
||||||
|
|
@ -61,11 +59,13 @@ impl Plugin for FederationPlugin {
|
||||||
100,
|
100,
|
||||||
Arc::new(|ctx: HookContext, payload: Value| {
|
Arc::new(|ctx: HookContext, payload: Value| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
if let Some(proposal_id) = payload.get("proposal_id")
|
if let Some(proposal_id) = payload
|
||||||
|
.get("proposal_id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| Uuid::parse_str(s).ok())
|
.and_then(|s| Uuid::parse_str(s).ok())
|
||||||
{
|
{
|
||||||
FederationService::share_proposal_if_federated(&ctx.pool, proposal_id).await?;
|
FederationService::share_proposal_if_federated(&ctx.pool, proposal_id)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|
@ -79,7 +79,8 @@ impl Plugin for FederationPlugin {
|
||||||
100,
|
100,
|
||||||
Arc::new(|ctx: HookContext, payload: Value| {
|
Arc::new(|ctx: HookContext, payload: Value| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
if let Some(proposal_id) = payload.get("proposal_id")
|
if let Some(proposal_id) = payload
|
||||||
|
.get("proposal_id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| Uuid::parse_str(s).ok())
|
.and_then(|s| Uuid::parse_str(s).ok())
|
||||||
{
|
{
|
||||||
|
|
@ -122,15 +123,14 @@ impl FederationService {
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
public_key: Option<&str>,
|
public_key: Option<&str>,
|
||||||
) -> Result<Uuid, PluginError> {
|
) -> Result<Uuid, PluginError> {
|
||||||
let instance_id: Uuid = sqlx::query_scalar(
|
let instance_id: Uuid =
|
||||||
"SELECT register_federated_instance($1, $2, $3, $4)"
|
sqlx::query_scalar("SELECT register_federated_instance($1, $2, $3, $4)")
|
||||||
)
|
.bind(url)
|
||||||
.bind(url)
|
.bind(name)
|
||||||
.bind(name)
|
.bind(description)
|
||||||
.bind(description)
|
.bind(public_key)
|
||||||
.bind(public_key)
|
.fetch_one(pool)
|
||||||
.fetch_one(pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(instance_id)
|
Ok(instance_id)
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +145,7 @@ impl FederationService {
|
||||||
sync_direction: &str,
|
sync_direction: &str,
|
||||||
) -> Result<Uuid, PluginError> {
|
) -> Result<Uuid, PluginError> {
|
||||||
let federation_id: Uuid = sqlx::query_scalar(
|
let federation_id: Uuid = sqlx::query_scalar(
|
||||||
"SELECT create_community_federation($1, $2, $3, $4, $5::sync_direction)"
|
"SELECT create_community_federation($1, $2, $3, $4, $5::sync_direction)",
|
||||||
)
|
)
|
||||||
.bind(local_community_id)
|
.bind(local_community_id)
|
||||||
.bind(remote_instance_id)
|
.bind(remote_instance_id)
|
||||||
|
|
@ -203,11 +203,11 @@ impl FederationService {
|
||||||
// In a real implementation, this would make HTTP requests to remote instances
|
// In a real implementation, this would make HTTP requests to remote instances
|
||||||
// For now, just log the sync attempt
|
// For now, just log the sync attempt
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"INSERT INTO federation_sync_log
|
r#"INSERT INTO federation_sync_log
|
||||||
(federation_id, instance_id, operation_type, direction, success, duration_ms)
|
(federation_id, instance_id, operation_type, direction, success, duration_ms)
|
||||||
VALUES ($1, $2, 'scheduled_sync', $3::sync_direction, true, $4)"#
|
VALUES ($1, $2, 'scheduled_sync', $3::sync_direction, true, $4)"#,
|
||||||
)
|
)
|
||||||
.bind(fed.id)
|
.bind(fed.id)
|
||||||
.bind(fed.remote_instance_id)
|
.bind(fed.remote_instance_id)
|
||||||
|
|
@ -277,10 +277,7 @@ impl FederationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Broadcast decision to federated communities
|
/// Broadcast decision to federated communities
|
||||||
pub async fn broadcast_decision(
|
pub async fn broadcast_decision(pool: &PgPool, proposal_id: Uuid) -> Result<(), PluginError> {
|
||||||
pool: &PgPool,
|
|
||||||
proposal_id: Uuid,
|
|
||||||
) -> Result<(), PluginError> {
|
|
||||||
// Find federated proposal
|
// Find federated proposal
|
||||||
let federated = sqlx::query!(
|
let federated = sqlx::query!(
|
||||||
r#"SELECT fp.id, fp.federation_id
|
r#"SELECT fp.id, fp.federation_id
|
||||||
|
|
@ -319,12 +316,9 @@ impl FederationService {
|
||||||
|
|
||||||
/// Get federation statistics for a community
|
/// Get federation statistics for a community
|
||||||
pub async fn get_stats(pool: &PgPool, community_id: Uuid) -> Result<Value, PluginError> {
|
pub async fn get_stats(pool: &PgPool, community_id: Uuid) -> Result<Value, PluginError> {
|
||||||
let stats = sqlx::query!(
|
let stats = sqlx::query!("SELECT * FROM get_federation_stats($1)", community_id)
|
||||||
"SELECT * FROM get_federation_stats($1)",
|
.fetch_one(pool)
|
||||||
community_id
|
.await?;
|
||||||
)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"total_federations": stats.total_federations,
|
"total_federations": stats.total_federations,
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::plugins::{
|
use crate::plugins::{
|
||||||
hooks::HookContext,
|
hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
|
||||||
manager::PluginSystem,
|
|
||||||
Plugin, PluginError, PluginMetadata, PluginScope,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct GovernanceAnalyticsPlugin;
|
pub struct GovernanceAnalyticsPlugin;
|
||||||
|
|
@ -66,7 +64,6 @@ impl Plugin for GovernanceAnalyticsPlugin {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -99,23 +96,20 @@ impl AnalyticsService {
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
community_id: Uuid,
|
community_id: Uuid,
|
||||||
) -> Result<Uuid, PluginError> {
|
) -> Result<Uuid, PluginError> {
|
||||||
let snapshot_id: Uuid = sqlx::query_scalar(
|
let snapshot_id: Uuid =
|
||||||
"SELECT calculate_participation_snapshot($1, CURRENT_DATE)"
|
sqlx::query_scalar("SELECT calculate_participation_snapshot($1, CURRENT_DATE)")
|
||||||
)
|
.bind(community_id)
|
||||||
.bind(community_id)
|
.fetch_one(pool)
|
||||||
.fetch_one(pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(snapshot_id)
|
Ok(snapshot_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate snapshots for all communities
|
/// Calculate snapshots for all communities
|
||||||
pub async fn calculate_all_snapshots(pool: &PgPool) -> Result<i32, PluginError> {
|
pub async fn calculate_all_snapshots(pool: &PgPool) -> Result<i32, PluginError> {
|
||||||
let communities = sqlx::query_scalar!(
|
let communities = sqlx::query_scalar!("SELECT id FROM communities WHERE is_active = true")
|
||||||
"SELECT id FROM communities WHERE is_active = true"
|
.fetch_all(pool)
|
||||||
)
|
.await?;
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
for community_id in communities {
|
for community_id in communities {
|
||||||
|
|
@ -130,27 +124,20 @@ impl AnalyticsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate governance health score for a community
|
/// Calculate governance health score for a community
|
||||||
pub async fn calculate_health(
|
pub async fn calculate_health(pool: &PgPool, community_id: Uuid) -> Result<Uuid, PluginError> {
|
||||||
pool: &PgPool,
|
let health_id: Uuid = sqlx::query_scalar("SELECT calculate_governance_health($1)")
|
||||||
community_id: Uuid,
|
.bind(community_id)
|
||||||
) -> Result<Uuid, PluginError> {
|
.fetch_one(pool)
|
||||||
let health_id: Uuid = sqlx::query_scalar(
|
.await?;
|
||||||
"SELECT calculate_governance_health($1)"
|
|
||||||
)
|
|
||||||
.bind(community_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(health_id)
|
Ok(health_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate health scores for all communities
|
/// Calculate health scores for all communities
|
||||||
pub async fn calculate_all_health_scores(pool: &PgPool) -> Result<i32, PluginError> {
|
pub async fn calculate_all_health_scores(pool: &PgPool) -> Result<i32, PluginError> {
|
||||||
let communities = sqlx::query_scalar!(
|
let communities = sqlx::query_scalar!("SELECT id FROM communities WHERE is_active = true")
|
||||||
"SELECT id FROM communities WHERE is_active = true"
|
.fetch_all(pool)
|
||||||
)
|
.await?;
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
for community_id in communities {
|
for community_id in communities {
|
||||||
|
|
@ -316,22 +303,24 @@ impl AnalyticsService {
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(methods.into_iter().map(|m| json!({
|
Ok(methods
|
||||||
"method": m.voting_method,
|
.into_iter()
|
||||||
"proposals": m.proposals_using_method,
|
.map(|m| {
|
||||||
"total_votes": m.total_votes_cast,
|
json!({
|
||||||
"avg_turnout": m.turnout,
|
"method": m.voting_method,
|
||||||
"avg_decision_time": m.avg_time,
|
"proposals": m.proposals_using_method,
|
||||||
"decisive_results": m.decisive_results,
|
"total_votes": m.total_votes_cast,
|
||||||
"close_results": m.close_results
|
"avg_turnout": m.turnout,
|
||||||
})).collect())
|
"avg_decision_time": m.avg_time,
|
||||||
|
"decisive_results": m.decisive_results,
|
||||||
|
"close_results": m.close_results
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get full dashboard data
|
/// Get full dashboard data
|
||||||
pub async fn get_dashboard(
|
pub async fn get_dashboard(pool: &PgPool, community_id: Uuid) -> Result<Value, PluginError> {
|
||||||
pool: &PgPool,
|
|
||||||
community_id: Uuid,
|
|
||||||
) -> Result<Value, PluginError> {
|
|
||||||
let health = Self::get_health(pool, community_id).await?;
|
let health = Self::get_health(pool, community_id).await?;
|
||||||
let trends = Self::get_participation_trends(pool, community_id, 30).await?;
|
let trends = Self::get_participation_trends(pool, community_id, 30).await?;
|
||||||
let delegation = Self::get_delegation_analytics(pool, community_id).await?;
|
let delegation = Self::get_delegation_analytics(pool, community_id).await?;
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ use crate::plugins::{
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Moderation Ledger Plugin
|
/// Moderation Ledger Plugin
|
||||||
///
|
///
|
||||||
/// Creates an immutable, cryptographically-chained log of all moderation decisions.
|
/// Creates an immutable, cryptographically-chained log of all moderation decisions.
|
||||||
/// This plugin is NON-DEACTIVATABLE by design - transparency is not optional.
|
/// This plugin is NON-DEACTIVATABLE by design - transparency is not optional.
|
||||||
///
|
///
|
||||||
/// Features:
|
/// Features:
|
||||||
/// - Immutable entries with SHA-256 hash chain
|
/// - Immutable entries with SHA-256 hash chain
|
||||||
/// - Full audit trail with actor, target, reason, evidence
|
/// - Full audit trail with actor, target, reason, evidence
|
||||||
|
|
@ -34,7 +34,7 @@ pub enum ModerationActionType {
|
||||||
ContentEdit,
|
ContentEdit,
|
||||||
ContentFlag,
|
ContentFlag,
|
||||||
ContentUnflag,
|
ContentUnflag,
|
||||||
|
|
||||||
// User moderation
|
// User moderation
|
||||||
UserWarn,
|
UserWarn,
|
||||||
UserMute,
|
UserMute,
|
||||||
|
|
@ -44,20 +44,20 @@ pub enum ModerationActionType {
|
||||||
UserBan,
|
UserBan,
|
||||||
UserUnban,
|
UserUnban,
|
||||||
UserRoleChange,
|
UserRoleChange,
|
||||||
|
|
||||||
// Community moderation
|
// Community moderation
|
||||||
CommunitySettingChange,
|
CommunitySettingChange,
|
||||||
CommunityRuleAdd,
|
CommunityRuleAdd,
|
||||||
CommunityRuleEdit,
|
CommunityRuleEdit,
|
||||||
CommunityRuleRemove,
|
CommunityRuleRemove,
|
||||||
|
|
||||||
// Proposal/voting moderation
|
// Proposal/voting moderation
|
||||||
ProposalClose,
|
ProposalClose,
|
||||||
ProposalReopen,
|
ProposalReopen,
|
||||||
ProposalArchive,
|
ProposalArchive,
|
||||||
VoteInvalidate,
|
VoteInvalidate,
|
||||||
VoteRestore,
|
VoteRestore,
|
||||||
|
|
||||||
// Escalation
|
// Escalation
|
||||||
EscalateToAdmin,
|
EscalateToAdmin,
|
||||||
EscalateToCommunity,
|
EscalateToCommunity,
|
||||||
|
|
@ -216,7 +216,10 @@ impl LedgerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a single entry by ID
|
/// Get a single entry by ID
|
||||||
pub async fn get_entry(pool: &PgPool, entry_id: Uuid) -> Result<Option<LedgerEntry>, PluginError> {
|
pub async fn get_entry(
|
||||||
|
pool: &PgPool,
|
||||||
|
entry_id: Uuid,
|
||||||
|
) -> Result<Option<LedgerEntry>, PluginError> {
|
||||||
let entry = sqlx::query_as!(
|
let entry = sqlx::query_as!(
|
||||||
LedgerEntry,
|
LedgerEntry,
|
||||||
r#"SELECT
|
r#"SELECT
|
||||||
|
|
@ -412,7 +415,8 @@ impl Plugin for ModerationLedgerPlugin {
|
||||||
Arc::new(move |ctx: HookContext, payload: Value| {
|
Arc::new(move |ctx: HookContext, payload: Value| {
|
||||||
let plugin_name = plugin_name.clone();
|
let plugin_name = plugin_name.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let actor_id = ctx.actor_user_id
|
let actor_id = ctx
|
||||||
|
.actor_user_id
|
||||||
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
|
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
|
||||||
|
|
||||||
let target_id = payload
|
let target_id = payload
|
||||||
|
|
@ -452,17 +456,20 @@ impl Plugin for ModerationLedgerPlugin {
|
||||||
"unilateral",
|
"unilateral",
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let _ = ctx.emit_public_event(
|
let _ = ctx
|
||||||
Some(&plugin_name),
|
.emit_public_event(
|
||||||
"ledger.entry_created",
|
Some(&plugin_name),
|
||||||
json!({
|
"ledger.entry_created",
|
||||||
"entry_id": entry_id,
|
json!({
|
||||||
"action_type": "content_remove",
|
"entry_id": entry_id,
|
||||||
"target_type": content_type,
|
"action_type": "content_remove",
|
||||||
}),
|
"target_type": content_type,
|
||||||
).await;
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|
@ -478,7 +485,8 @@ impl Plugin for ModerationLedgerPlugin {
|
||||||
Arc::new(move |ctx: HookContext, payload: Value| {
|
Arc::new(move |ctx: HookContext, payload: Value| {
|
||||||
let plugin_name = plugin_name2.clone();
|
let plugin_name = plugin_name2.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let actor_id = ctx.actor_user_id
|
let actor_id = ctx
|
||||||
|
.actor_user_id
|
||||||
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
|
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
|
||||||
|
|
||||||
let target_user_id = payload
|
let target_user_id = payload
|
||||||
|
|
@ -534,17 +542,20 @@ impl Plugin for ModerationLedgerPlugin {
|
||||||
"unilateral",
|
"unilateral",
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let _ = ctx.emit_public_event(
|
let _ = ctx
|
||||||
Some(&plugin_name),
|
.emit_public_event(
|
||||||
"ledger.entry_created",
|
Some(&plugin_name),
|
||||||
json!({
|
"ledger.entry_created",
|
||||||
"entry_id": entry_id,
|
json!({
|
||||||
"action_type": action,
|
"entry_id": entry_id,
|
||||||
"target_type": "user",
|
"action_type": action,
|
||||||
}),
|
"target_type": "user",
|
||||||
).await;
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|
@ -560,7 +571,8 @@ impl Plugin for ModerationLedgerPlugin {
|
||||||
Arc::new(move |ctx: HookContext, payload: Value| {
|
Arc::new(move |ctx: HookContext, payload: Value| {
|
||||||
let plugin_name = plugin_name3.clone();
|
let plugin_name = plugin_name3.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let actor_id = ctx.actor_user_id
|
let actor_id = ctx
|
||||||
|
.actor_user_id
|
||||||
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
|
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
|
||||||
|
|
||||||
let proposal_id = payload
|
let proposal_id = payload
|
||||||
|
|
@ -617,17 +629,20 @@ impl Plugin for ModerationLedgerPlugin {
|
||||||
decision_type,
|
decision_type,
|
||||||
vote_proposal_id,
|
vote_proposal_id,
|
||||||
payload.get("vote_result").cloned(),
|
payload.get("vote_result").cloned(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let _ = ctx.emit_public_event(
|
let _ = ctx
|
||||||
Some(&plugin_name),
|
.emit_public_event(
|
||||||
"ledger.entry_created",
|
Some(&plugin_name),
|
||||||
json!({
|
"ledger.entry_created",
|
||||||
"entry_id": entry_id,
|
json!({
|
||||||
"action_type": action,
|
"entry_id": entry_id,
|
||||||
"target_type": "proposal",
|
"action_type": action,
|
||||||
}),
|
"target_type": "proposal",
|
||||||
).await;
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::plugins::{
|
use crate::plugins::{
|
||||||
hooks::HookContext,
|
hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
|
||||||
manager::PluginSystem,
|
|
||||||
Plugin, PluginError, PluginMetadata, PluginScope,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct ProposalLifecyclePlugin;
|
pub struct ProposalLifecyclePlugin;
|
||||||
|
|
@ -48,19 +46,29 @@ impl Plugin for ProposalLifecyclePlugin {
|
||||||
10,
|
10,
|
||||||
Arc::new(|ctx: HookContext, payload: Value| {
|
Arc::new(|ctx: HookContext, payload: Value| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
if let Some(proposal_id) = payload.get("proposal_id")
|
if let Some(proposal_id) = payload
|
||||||
|
.get("proposal_id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| Uuid::parse_str(s).ok())
|
.and_then(|s| Uuid::parse_str(s).ok())
|
||||||
{
|
{
|
||||||
let title = payload.get("title").and_then(|v| v.as_str()).unwrap_or("");
|
let title = payload.get("title").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
let content = payload.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
let content = payload
|
||||||
|
.get("content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
let summary = payload.get("change_summary").and_then(|v| v.as_str());
|
let summary = payload.get("change_summary").and_then(|v| v.as_str());
|
||||||
|
|
||||||
if let Some(user_id) = ctx.actor_user_id {
|
if let Some(user_id) = ctx.actor_user_id {
|
||||||
LifecycleService::create_version(
|
LifecycleService::create_version(
|
||||||
&ctx.pool, proposal_id, title, content,
|
&ctx.pool,
|
||||||
user_id, "edit", summary
|
proposal_id,
|
||||||
).await?;
|
title,
|
||||||
|
content,
|
||||||
|
user_id,
|
||||||
|
"edit",
|
||||||
|
summary,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -79,7 +87,8 @@ impl Plugin for ProposalLifecyclePlugin {
|
||||||
Some("proposal_lifecycle"),
|
Some("proposal_lifecycle"),
|
||||||
"status.transition",
|
"status.transition",
|
||||||
payload.clone(),
|
payload.clone(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
@ -96,13 +105,13 @@ impl Plugin for ProposalLifecyclePlugin {
|
||||||
Some("proposal_lifecycle"),
|
Some("proposal_lifecycle"),
|
||||||
"proposal.forked",
|
"proposal.forked",
|
||||||
payload.clone(),
|
payload.clone(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -143,18 +152,17 @@ impl LifecycleService {
|
||||||
change_type: &str,
|
change_type: &str,
|
||||||
change_summary: Option<&str>,
|
change_summary: Option<&str>,
|
||||||
) -> Result<i32, PluginError> {
|
) -> Result<i32, PluginError> {
|
||||||
let version: i32 = sqlx::query_scalar(
|
let version: i32 =
|
||||||
"SELECT create_proposal_version($1, $2, $3, NULL, $4, $5, $6)"
|
sqlx::query_scalar("SELECT create_proposal_version($1, $2, $3, NULL, $4, $5, $6)")
|
||||||
)
|
.bind(proposal_id)
|
||||||
.bind(proposal_id)
|
.bind(title)
|
||||||
.bind(title)
|
.bind(content)
|
||||||
.bind(content)
|
.bind(created_by)
|
||||||
.bind(created_by)
|
.bind(change_type)
|
||||||
.bind(change_type)
|
.bind(change_summary)
|
||||||
.bind(change_summary)
|
.fetch_one(pool)
|
||||||
.fetch_one(pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(version)
|
Ok(version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,7 +176,7 @@ impl LifecycleService {
|
||||||
reason: Option<&str>,
|
reason: Option<&str>,
|
||||||
) -> Result<bool, PluginError> {
|
) -> Result<bool, PluginError> {
|
||||||
let success: bool = sqlx::query_scalar(
|
let success: bool = sqlx::query_scalar(
|
||||||
"SELECT transition_proposal_status($1, $2::proposal_lifecycle_status, $3, $4, $5)"
|
"SELECT transition_proposal_status($1, $2::proposal_lifecycle_status, $3, $4, $5)",
|
||||||
)
|
)
|
||||||
.bind(proposal_id)
|
.bind(proposal_id)
|
||||||
.bind(new_status)
|
.bind(new_status)
|
||||||
|
|
@ -177,7 +185,7 @@ impl LifecycleService {
|
||||||
.bind(reason)
|
.bind(reason)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(success)
|
Ok(success)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,16 +197,14 @@ impl LifecycleService {
|
||||||
reason: &str,
|
reason: &str,
|
||||||
community_id: Uuid,
|
community_id: Uuid,
|
||||||
) -> Result<Uuid, PluginError> {
|
) -> Result<Uuid, PluginError> {
|
||||||
let new_id: Uuid = sqlx::query_scalar(
|
let new_id: Uuid = sqlx::query_scalar("SELECT fork_proposal($1, $2, $3, $4)")
|
||||||
"SELECT fork_proposal($1, $2, $3, $4)"
|
.bind(source_proposal_id)
|
||||||
)
|
.bind(forked_by)
|
||||||
.bind(source_proposal_id)
|
.bind(reason)
|
||||||
.bind(forked_by)
|
.bind(community_id)
|
||||||
.bind(reason)
|
.fetch_one(pool)
|
||||||
.bind(community_id)
|
.await?;
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(new_id)
|
Ok(new_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,7 +225,7 @@ impl LifecycleService {
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(versions)
|
Ok(versions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,7 +247,7 @@ impl LifecycleService {
|
||||||
)
|
)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(version)
|
Ok(version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,21 +260,19 @@ impl LifecycleService {
|
||||||
) -> Result<Value, PluginError> {
|
) -> Result<Value, PluginError> {
|
||||||
let from = Self::get_version(pool, proposal_id, from_version).await?;
|
let from = Self::get_version(pool, proposal_id, from_version).await?;
|
||||||
let to = Self::get_version(pool, proposal_id, to_version).await?;
|
let to = Self::get_version(pool, proposal_id, to_version).await?;
|
||||||
|
|
||||||
match (from, to) {
|
match (from, to) {
|
||||||
(Some(f), Some(t)) => {
|
(Some(f), Some(t)) => Ok(json!({
|
||||||
Ok(json!({
|
"from_version": from_version,
|
||||||
"from_version": from_version,
|
"to_version": to_version,
|
||||||
"to_version": to_version,
|
"title_changed": f.title != t.title,
|
||||||
"title_changed": f.title != t.title,
|
"content_changed": f.content != t.content,
|
||||||
"content_changed": f.content != t.content,
|
"from_title": f.title,
|
||||||
"from_title": f.title,
|
"to_title": t.title,
|
||||||
"to_title": t.title,
|
"from_content_length": f.content.len(),
|
||||||
"from_content_length": f.content.len(),
|
"to_content_length": t.content.len(),
|
||||||
"to_content_length": t.content.len(),
|
"change_summary": t.change_summary
|
||||||
"change_summary": t.change_summary
|
})),
|
||||||
}))
|
|
||||||
}
|
|
||||||
_ => Ok(json!({"error": "Version not found"})),
|
_ => Ok(json!({"error": "Version not found"})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -441,10 +445,7 @@ impl LifecycleService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get forks of a proposal
|
/// Get forks of a proposal
|
||||||
pub async fn get_forks(
|
pub async fn get_forks(pool: &PgPool, proposal_id: Uuid) -> Result<Vec<Value>, PluginError> {
|
||||||
pool: &PgPool,
|
|
||||||
proposal_id: Uuid,
|
|
||||||
) -> Result<Vec<Value>, PluginError> {
|
|
||||||
let forks = sqlx::query!(
|
let forks = sqlx::query!(
|
||||||
r#"SELECT
|
r#"SELECT
|
||||||
pf.fork_proposal_id,
|
pf.fork_proposal_id,
|
||||||
|
|
@ -464,14 +465,19 @@ impl LifecycleService {
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(forks.into_iter().map(|f| json!({
|
Ok(forks
|
||||||
"fork_id": f.fork_proposal_id,
|
.into_iter()
|
||||||
"title": f.fork_title,
|
.map(|f| {
|
||||||
"forked_by": f.forked_by_username,
|
json!({
|
||||||
"forked_at": f.forked_at,
|
"fork_id": f.fork_proposal_id,
|
||||||
"reason": f.fork_reason,
|
"title": f.fork_title,
|
||||||
"is_competing": f.is_competing,
|
"forked_by": f.forked_by_username,
|
||||||
"is_merged": f.is_merged
|
"forked_at": f.forked_at,
|
||||||
})).collect())
|
"reason": f.fork_reason,
|
||||||
|
"is_competing": f.is_competing,
|
||||||
|
"is_merged": f.is_merged
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::plugins::{
|
use crate::plugins::{
|
||||||
hooks::HookContext,
|
hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
|
||||||
manager::PluginSystem,
|
|
||||||
Plugin, PluginError, PluginMetadata, PluginScope,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct PublicDataExportPlugin;
|
pub struct PublicDataExportPlugin;
|
||||||
|
|
@ -74,11 +72,13 @@ impl Plugin for PublicDataExportPlugin {
|
||||||
50,
|
50,
|
||||||
Arc::new(|ctx: HookContext, payload: Value| {
|
Arc::new(|ctx: HookContext, payload: Value| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
if let Some(job_id) = payload.get("job_id")
|
if let Some(job_id) = payload
|
||||||
|
.get("job_id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.and_then(|s| Uuid::parse_str(s).ok())
|
.and_then(|s| Uuid::parse_str(s).ok())
|
||||||
{
|
{
|
||||||
ExportService::record_download(&ctx.pool, job_id, ctx.actor_user_id).await?;
|
ExportService::record_download(&ctx.pool, job_id, ctx.actor_user_id)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|
@ -120,17 +120,16 @@ impl ExportService {
|
||||||
date_from: Option<chrono::DateTime<chrono::Utc>>,
|
date_from: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
date_to: Option<chrono::DateTime<chrono::Utc>>,
|
date_to: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
) -> Result<Uuid, PluginError> {
|
) -> Result<Uuid, PluginError> {
|
||||||
let job_id: Uuid = sqlx::query_scalar(
|
let job_id: Uuid =
|
||||||
"SELECT create_export_job($1, $2, $3::export_format, $4, $5, $6)"
|
sqlx::query_scalar("SELECT create_export_job($1, $2, $3::export_format, $4, $5, $6)")
|
||||||
)
|
.bind(community_id)
|
||||||
.bind(community_id)
|
.bind(export_type)
|
||||||
.bind(export_type)
|
.bind(format)
|
||||||
.bind(format)
|
.bind(requested_by)
|
||||||
.bind(requested_by)
|
.bind(date_from)
|
||||||
.bind(date_from)
|
.bind(date_to)
|
||||||
.bind(date_to)
|
.fetch_one(pool)
|
||||||
.fetch_one(pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(job_id)
|
Ok(job_id)
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +159,10 @@ impl ExportService {
|
||||||
"proposals" => Self::export_proposals(pool, job.community_id, &job.format).await,
|
"proposals" => Self::export_proposals(pool, job.community_id, &job.format).await,
|
||||||
"votes" => Self::export_votes(pool, job.community_id, &job.format).await,
|
"votes" => Self::export_votes(pool, job.community_id, &job.format).await,
|
||||||
"analytics" => Self::export_analytics(pool, job.community_id, &job.format).await,
|
"analytics" => Self::export_analytics(pool, job.community_id, &job.format).await,
|
||||||
_ => Err(PluginError::Message(format!("Unknown export type: {}", job.export_type))),
|
_ => Err(PluginError::Message(format!(
|
||||||
|
"Unknown export type: {}",
|
||||||
|
job.export_type
|
||||||
|
))),
|
||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
|
|
@ -201,8 +203,9 @@ impl ExportService {
|
||||||
community_id: Option<Uuid>,
|
community_id: Option<Uuid>,
|
||||||
format: &str,
|
format: &str,
|
||||||
) -> Result<(i32, String), PluginError> {
|
) -> Result<(i32, String), PluginError> {
|
||||||
let community_id = community_id.ok_or_else(|| PluginError::Message("Community ID required".into()))?;
|
let community_id =
|
||||||
|
community_id.ok_or_else(|| PluginError::Message("Community ID required".into()))?;
|
||||||
|
|
||||||
let proposals = sqlx::query!(
|
let proposals = sqlx::query!(
|
||||||
r#"SELECT * FROM get_exportable_proposals($1, true, NULL, NULL)"#,
|
r#"SELECT * FROM get_exportable_proposals($1, true, NULL, NULL)"#,
|
||||||
community_id
|
community_id
|
||||||
|
|
@ -213,14 +216,19 @@ impl ExportService {
|
||||||
let count = proposals.len() as i32;
|
let count = proposals.len() as i32;
|
||||||
let data = match format {
|
let data = match format {
|
||||||
"json" => {
|
"json" => {
|
||||||
let items: Vec<Value> = proposals.iter().map(|p| json!({
|
let items: Vec<Value> = proposals
|
||||||
"id": p.id,
|
.iter()
|
||||||
"title": p.title,
|
.map(|p| {
|
||||||
"author_id": p.author_id,
|
json!({
|
||||||
"status": p.status,
|
"id": p.id,
|
||||||
"created_at": p.created_at,
|
"title": p.title,
|
||||||
"vote_count": p.vote_count
|
"author_id": p.author_id,
|
||||||
})).collect();
|
"status": p.status,
|
||||||
|
"created_at": p.created_at,
|
||||||
|
"vote_count": p.vote_count
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
serde_json::to_string_pretty(&items).unwrap_or_default()
|
serde_json::to_string_pretty(&items).unwrap_or_default()
|
||||||
}
|
}
|
||||||
"csv" => {
|
"csv" => {
|
||||||
|
|
@ -250,8 +258,9 @@ impl ExportService {
|
||||||
community_id: Option<Uuid>,
|
community_id: Option<Uuid>,
|
||||||
format: &str,
|
format: &str,
|
||||||
) -> Result<(i32, String), PluginError> {
|
) -> Result<(i32, String), PluginError> {
|
||||||
let community_id = community_id.ok_or_else(|| PluginError::Message("Community ID required".into()))?;
|
let community_id =
|
||||||
|
community_id.ok_or_else(|| PluginError::Message("Community ID required".into()))?;
|
||||||
|
|
||||||
let votes = sqlx::query!(
|
let votes = sqlx::query!(
|
||||||
r#"SELECT
|
r#"SELECT
|
||||||
v.id, v.proposal_id,
|
v.id, v.proposal_id,
|
||||||
|
|
@ -269,12 +278,17 @@ impl ExportService {
|
||||||
let count = votes.len() as i32;
|
let count = votes.len() as i32;
|
||||||
let data = match format {
|
let data = match format {
|
||||||
"json" => {
|
"json" => {
|
||||||
let items: Vec<Value> = votes.iter().map(|v| json!({
|
let items: Vec<Value> = votes
|
||||||
"id": v.id,
|
.iter()
|
||||||
"proposal_id": v.proposal_id,
|
.map(|v| {
|
||||||
"voter_hash": v.voter_hash,
|
json!({
|
||||||
"created_at": v.created_at
|
"id": v.id,
|
||||||
})).collect();
|
"proposal_id": v.proposal_id,
|
||||||
|
"voter_hash": v.voter_hash,
|
||||||
|
"created_at": v.created_at
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
serde_json::to_string_pretty(&items).unwrap_or_default()
|
serde_json::to_string_pretty(&items).unwrap_or_default()
|
||||||
}
|
}
|
||||||
"csv" => {
|
"csv" => {
|
||||||
|
|
@ -282,7 +296,8 @@ impl ExportService {
|
||||||
for v in &votes {
|
for v in &votes {
|
||||||
csv.push_str(&format!(
|
csv.push_str(&format!(
|
||||||
"{},{},{},{}\n",
|
"{},{},{},{}\n",
|
||||||
v.id, v.proposal_id,
|
v.id,
|
||||||
|
v.proposal_id,
|
||||||
v.voter_hash.as_deref().unwrap_or(""),
|
v.voter_hash.as_deref().unwrap_or(""),
|
||||||
v.created_at
|
v.created_at
|
||||||
));
|
));
|
||||||
|
|
@ -301,8 +316,9 @@ impl ExportService {
|
||||||
community_id: Option<Uuid>,
|
community_id: Option<Uuid>,
|
||||||
format: &str,
|
format: &str,
|
||||||
) -> Result<(i32, String), PluginError> {
|
) -> Result<(i32, String), PluginError> {
|
||||||
let community_id = community_id.ok_or_else(|| PluginError::Message("Community ID required".into()))?;
|
let community_id =
|
||||||
|
community_id.ok_or_else(|| PluginError::Message("Community ID required".into()))?;
|
||||||
|
|
||||||
let analytics = sqlx::query!(
|
let analytics = sqlx::query!(
|
||||||
r#"SELECT snapshot_date, total_members, active_members, votes_cast
|
r#"SELECT snapshot_date, total_members, active_members, votes_cast
|
||||||
FROM participation_snapshots
|
FROM participation_snapshots
|
||||||
|
|
@ -317,12 +333,17 @@ impl ExportService {
|
||||||
let count = analytics.len() as i32;
|
let count = analytics.len() as i32;
|
||||||
let data = match format {
|
let data = match format {
|
||||||
"json" => {
|
"json" => {
|
||||||
let items: Vec<Value> = analytics.iter().map(|a| json!({
|
let items: Vec<Value> = analytics
|
||||||
"date": a.snapshot_date.to_string(),
|
.iter()
|
||||||
"total_members": a.total_members,
|
.map(|a| {
|
||||||
"active_members": a.active_members,
|
json!({
|
||||||
"votes_cast": a.votes_cast
|
"date": a.snapshot_date.to_string(),
|
||||||
})).collect();
|
"total_members": a.total_members,
|
||||||
|
"active_members": a.active_members,
|
||||||
|
"votes_cast": a.votes_cast
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
serde_json::to_string_pretty(&items).unwrap_or_default()
|
serde_json::to_string_pretty(&items).unwrap_or_default()
|
||||||
}
|
}
|
||||||
"csv" => {
|
"csv" => {
|
||||||
|
|
@ -381,7 +402,10 @@ impl ExportService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get available exports for a community
|
/// Get available exports for a community
|
||||||
pub async fn get_available(pool: &PgPool, community_id: Uuid) -> Result<Vec<ExportConfig>, PluginError> {
|
pub async fn get_available(
|
||||||
|
pool: &PgPool,
|
||||||
|
community_id: Uuid,
|
||||||
|
) -> Result<Vec<ExportConfig>, PluginError> {
|
||||||
let configs = sqlx::query_as!(
|
let configs = sqlx::query_as!(
|
||||||
ExportConfig,
|
ExportConfig,
|
||||||
r#"SELECT id, community_id, name, export_type, public_access
|
r#"SELECT id, community_id, name, export_type, public_access
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::plugins::{
|
use crate::plugins::{
|
||||||
hooks::HookContext,
|
hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
|
||||||
manager::PluginSystem,
|
|
||||||
Plugin, PluginError, PluginMetadata, PluginScope,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct SelfModerationPlugin;
|
pub struct SelfModerationPlugin;
|
||||||
|
|
@ -67,12 +65,16 @@ impl Plugin for SelfModerationPlugin {
|
||||||
payload.get("action").and_then(|v| v.as_str()),
|
payload.get("action").and_then(|v| v.as_str()),
|
||||||
) {
|
) {
|
||||||
let is_blocked = ModerationRulesService::check_user_blocked(
|
let is_blocked = ModerationRulesService::check_user_blocked(
|
||||||
&ctx.pool, user_id, community_id, action
|
&ctx.pool,
|
||||||
).await?;
|
user_id,
|
||||||
|
community_id,
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
if is_blocked {
|
if is_blocked {
|
||||||
return Err(PluginError::Message(
|
return Err(PluginError::Message(
|
||||||
"Action blocked due to active sanction".to_string()
|
"Action blocked due to active sanction".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +94,8 @@ impl Plugin for SelfModerationPlugin {
|
||||||
Some("self_moderation_rules"),
|
Some("self_moderation_rules"),
|
||||||
"violation.reported",
|
"violation.reported",
|
||||||
payload.clone(),
|
payload.clone(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
@ -109,13 +112,13 @@ impl Plugin for SelfModerationPlugin {
|
||||||
Some("self_moderation_rules"),
|
Some("self_moderation_rules"),
|
||||||
"sanction.applied",
|
"sanction.applied",
|
||||||
payload.clone(),
|
payload.clone(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -182,20 +185,19 @@ impl ModerationRulesService {
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(st) = sanction_type {
|
if let Some(st) = sanction_type {
|
||||||
let blocked: bool = sqlx::query_scalar(
|
let blocked: bool =
|
||||||
"SELECT user_has_active_sanction($1, $2, $3::sanction_type)"
|
sqlx::query_scalar("SELECT user_has_active_sanction($1, $2, $3::sanction_type)")
|
||||||
)
|
.bind(user_id)
|
||||||
.bind(user_id)
|
.bind(community_id)
|
||||||
.bind(community_id)
|
.bind(st)
|
||||||
.bind(st)
|
.fetch_one(pool)
|
||||||
.fetch_one(pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
return Ok(blocked);
|
return Ok(blocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for permanent ban
|
// Check for permanent ban
|
||||||
let banned: bool = sqlx::query_scalar(
|
let banned: bool = sqlx::query_scalar(
|
||||||
"SELECT user_has_active_sanction($1, $2, 'permanent_ban'::sanction_type)"
|
"SELECT user_has_active_sanction($1, $2, 'permanent_ban'::sanction_type)",
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(community_id)
|
.bind(community_id)
|
||||||
|
|
@ -251,14 +253,13 @@ impl ModerationRulesService {
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let escalation_level: i32 = sqlx::query_scalar(
|
let escalation_level: i32 =
|
||||||
"SELECT calculate_escalation_level($1, $2, $3)"
|
sqlx::query_scalar("SELECT calculate_escalation_level($1, $2, $3)")
|
||||||
)
|
.bind(violation.target_user_id)
|
||||||
.bind(violation.target_user_id)
|
.bind(violation.community_id)
|
||||||
.bind(violation.community_id)
|
.bind(violation.rule_id)
|
||||||
.bind(violation.rule_id)
|
.fetch_one(pool)
|
||||||
.fetch_one(pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Check if community vote is required
|
// Check if community vote is required
|
||||||
let rule = sqlx::query!(
|
let rule = sqlx::query!(
|
||||||
|
|
@ -353,15 +354,14 @@ impl ModerationRulesService {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Apply the sanction
|
// Apply the sanction
|
||||||
let sanction_id: Uuid = sqlx::query_scalar(
|
let sanction_id: Uuid =
|
||||||
"SELECT apply_sanction($1, $2::sanction_type, $3, $4, 'manual')"
|
sqlx::query_scalar("SELECT apply_sanction($1, $2::sanction_type, $3, $4, 'manual')")
|
||||||
)
|
.bind(violation_id)
|
||||||
.bind(violation_id)
|
.bind(&sanction.sanction_type)
|
||||||
.bind(&sanction.sanction_type)
|
.bind(sanction.duration_hours)
|
||||||
.bind(sanction.duration_hours)
|
.bind(applied_by)
|
||||||
.bind(applied_by)
|
.fetch_one(pool)
|
||||||
.fetch_one(pool)
|
.await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(sanction_id)
|
Ok(sanction_id)
|
||||||
}
|
}
|
||||||
|
|
@ -513,18 +513,20 @@ impl ModerationRulesService {
|
||||||
|
|
||||||
Ok(violations
|
Ok(violations
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|v| json!({
|
.map(|v| {
|
||||||
"id": v.id,
|
json!({
|
||||||
"rule_code": v.rule_code,
|
"id": v.id,
|
||||||
"rule_title": v.rule_title,
|
"rule_code": v.rule_code,
|
||||||
"severity": v.severity,
|
"rule_title": v.rule_title,
|
||||||
"target_user_id": v.target_user_id,
|
"severity": v.severity,
|
||||||
"target_username": v.target_username,
|
"target_user_id": v.target_user_id,
|
||||||
"reported_by": v.reported_by,
|
"target_username": v.target_username,
|
||||||
"status": v.status,
|
"reported_by": v.reported_by,
|
||||||
"reported_at": v.reported_at,
|
"status": v.status,
|
||||||
"reason": v.report_reason
|
"reported_at": v.reported_at,
|
||||||
}))
|
"reason": v.report_reason
|
||||||
|
})
|
||||||
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::plugins::{
|
use crate::plugins::{
|
||||||
hooks::HookContext,
|
hooks::HookContext, manager::PluginSystem, Plugin, PluginError, PluginMetadata, PluginScope,
|
||||||
manager::PluginSystem,
|
|
||||||
Plugin, PluginError, PluginMetadata, PluginScope,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct StructuredDeliberationPlugin;
|
pub struct StructuredDeliberationPlugin;
|
||||||
|
|
@ -48,16 +46,23 @@ impl Plugin for StructuredDeliberationPlugin {
|
||||||
Arc::new(|ctx: HookContext, payload: Value| {
|
Arc::new(|ctx: HookContext, payload: Value| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
if let (Some(proposal_id), Some(user_id)) = (
|
if let (Some(proposal_id), Some(user_id)) = (
|
||||||
payload.get("proposal_id").and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()),
|
payload
|
||||||
|
.get("proposal_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| Uuid::parse_str(s).ok()),
|
||||||
ctx.actor_user_id,
|
ctx.actor_user_id,
|
||||||
) {
|
) {
|
||||||
let can_comment = DeliberationService::check_can_participate(
|
let can_comment = DeliberationService::check_can_participate(
|
||||||
&ctx.pool, proposal_id, user_id, "comment"
|
&ctx.pool,
|
||||||
).await?;
|
proposal_id,
|
||||||
|
user_id,
|
||||||
|
"comment",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
if !can_comment {
|
if !can_comment {
|
||||||
return Err(PluginError::Message(
|
return Err(PluginError::Message(
|
||||||
"Please read the proposal before commenting".to_string()
|
"Please read the proposal before commenting".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +95,8 @@ impl Plugin for StructuredDeliberationPlugin {
|
||||||
Some("structured_deliberation"),
|
Some("structured_deliberation"),
|
||||||
"argument.created",
|
"argument.created",
|
||||||
payload.clone(),
|
payload.clone(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
@ -189,7 +195,7 @@ impl DeliberationService {
|
||||||
author_id: Uuid,
|
author_id: Uuid,
|
||||||
) -> Result<Uuid, PluginError> {
|
) -> Result<Uuid, PluginError> {
|
||||||
let argument_id: Uuid = sqlx::query_scalar(
|
let argument_id: Uuid = sqlx::query_scalar(
|
||||||
"SELECT add_deliberation_argument($1, $2, $3::argument_stance, $4, $5, $6)"
|
"SELECT add_deliberation_argument($1, $2, $3::argument_stance, $4, $5, $6)",
|
||||||
)
|
)
|
||||||
.bind(proposal_id)
|
.bind(proposal_id)
|
||||||
.bind(parent_id)
|
.bind(parent_id)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
use std::{
|
use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc};
|
||||||
collections::HashMap,
|
|
||||||
future::Future,
|
|
||||||
pin::Pin,
|
|
||||||
sync::Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
@ -103,16 +98,10 @@ impl HookRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn actions_for(&self, hook: &str) -> &[ActionHandler] {
|
pub fn actions_for(&self, hook: &str) -> &[ActionHandler] {
|
||||||
self.actions
|
self.actions.get(hook).map(|v| v.as_slice()).unwrap_or(&[])
|
||||||
.get(hook)
|
|
||||||
.map(|v| v.as_slice())
|
|
||||||
.unwrap_or(&[])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn filters_for(&self, hook: &str) -> &[FilterHandler] {
|
pub fn filters_for(&self, hook: &str) -> &[FilterHandler] {
|
||||||
self.filters
|
self.filters.get(hook).map(|v| v.as_slice()).unwrap_or(&[])
|
||||||
.get(hook)
|
|
||||||
.map(|v| v.as_slice())
|
|
||||||
.unwrap_or(&[])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
use std::{collections::{HashMap, HashSet}, sync::Arc};
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
@ -6,9 +9,9 @@ use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::plugins::hooks::{ActionHandler, FilterHandler, HookContext, HookRegistry, PluginError};
|
use crate::plugins::hooks::{ActionHandler, FilterHandler, HookContext, HookRegistry, PluginError};
|
||||||
use crate::plugins::wasm::WasmPlugin;
|
|
||||||
use crate::plugins::wasm::host_api::PluginManifest;
|
use crate::plugins::wasm::host_api::PluginManifest;
|
||||||
use crate::plugins::wasm::runtime::WasmRuntime;
|
use crate::plugins::wasm::runtime::WasmRuntime;
|
||||||
|
use crate::plugins::wasm::WasmPlugin;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum PluginScope {
|
pub enum PluginScope {
|
||||||
|
|
@ -350,7 +353,8 @@ impl PluginManager {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for community in communities {
|
for community in communities {
|
||||||
self.ensure_default_community_plugins(community.id, None).await?;
|
self.ensure_default_community_plugins(community.id, None)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
for plugin in &self.plugins {
|
for plugin in &self.plugins {
|
||||||
|
|
@ -360,12 +364,16 @@ impl PluginManager {
|
||||||
Ok(Arc::new(self))
|
Ok(Arc::new(self))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn active_plugins(&self, community_id: Option<Uuid>) -> Result<HashSet<String>, PluginError> {
|
async fn active_plugins(
|
||||||
|
&self,
|
||||||
|
community_id: Option<Uuid>,
|
||||||
|
) -> Result<HashSet<String>, PluginError> {
|
||||||
let mut active: HashSet<String> = HashSet::new();
|
let mut active: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
let core = sqlx::query!("SELECT name FROM plugins WHERE is_active = true AND is_core = true")
|
let core =
|
||||||
.fetch_all(&self.pool)
|
sqlx::query!("SELECT name FROM plugins WHERE is_active = true AND is_core = true")
|
||||||
.await?;
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
for row in core {
|
for row in core {
|
||||||
active.insert(row.name);
|
active.insert(row.name);
|
||||||
}
|
}
|
||||||
|
|
@ -454,7 +462,12 @@ impl PluginManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn do_wasm_action_for_community(&self, hook: &str, community_id: Uuid, payload: Value) {
|
pub async fn do_wasm_action_for_community(
|
||||||
|
&self,
|
||||||
|
hook: &str,
|
||||||
|
community_id: Uuid,
|
||||||
|
payload: Value,
|
||||||
|
) {
|
||||||
let wasm = match sqlx::query!(
|
let wasm = match sqlx::query!(
|
||||||
r#"SELECT DISTINCT pp.id
|
r#"SELECT DISTINCT pp.id
|
||||||
FROM plugin_packages pp
|
FROM plugin_packages pp
|
||||||
|
|
@ -468,7 +481,11 @@ impl PluginManager {
|
||||||
{
|
{
|
||||||
Ok(rows) => rows,
|
Ok(rows) => rows,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to resolve active WASM plugins for hook {}: {}", hook, e);
|
tracing::error!(
|
||||||
|
"Failed to resolve active WASM plugins for hook {}: {}",
|
||||||
|
hook,
|
||||||
|
e
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -621,12 +638,8 @@ impl PluginManager {
|
||||||
if old_is_active && !new_is_active {
|
if old_is_active && !new_is_active {
|
||||||
self.invoke_deactivate(plugin_name, ctx.clone(), old_settings.clone())
|
self.invoke_deactivate(plugin_name, ctx.clone(), old_settings.clone())
|
||||||
.await;
|
.await;
|
||||||
self.do_action(
|
self.do_action("plugin.deactivated", ctx, json!({"plugin": plugin_name}))
|
||||||
"plugin.deactivated",
|
.await;
|
||||||
ctx,
|
|
||||||
json!({"plugin": plugin_name}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tokio::runtime::Handle;
|
use tokio::runtime::Handle;
|
||||||
use tokio::task::block_in_place;
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
use tokio::task::block_in_place;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use wasmtime::{Linker, StoreLimits};
|
use wasmtime::{Linker, StoreLimits};
|
||||||
|
|
||||||
|
|
@ -70,7 +70,11 @@ impl HostState {
|
||||||
.find(|c| c.name == CAP_OUTBOUND_HTTP && c.allowed)
|
.find(|c| c.name == CAP_OUTBOUND_HTTP && c.allowed)
|
||||||
.and_then(|c| c.config.get("allowlist"))
|
.and_then(|c| c.config.get("allowlist"))
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(String::from))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -88,7 +92,9 @@ impl HostState {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_capability(&self, name: &str) -> bool {
|
pub fn has_capability(&self, name: &str) -> bool {
|
||||||
self.capabilities.iter().any(|c| c.name == name && c.allowed)
|
self.capabilities
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.name == name && c.allowed)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_url_allowed(&self, url: &str) -> bool {
|
pub fn is_url_allowed(&self, url: &str) -> bool {
|
||||||
|
|
@ -146,7 +152,7 @@ impl HostStateWithLimits {
|
||||||
guard.data = data.to_vec();
|
guard.data = data.to_vec();
|
||||||
data.len() as u32
|
data.len() as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the result buffer contents
|
/// Get the result buffer contents
|
||||||
pub fn get_result(&self) -> Vec<u8> {
|
pub fn get_result(&self) -> Vec<u8> {
|
||||||
let guard = match self.inner.result_buffer.lock() {
|
let guard = match self.inner.result_buffer.lock() {
|
||||||
|
|
@ -158,31 +164,40 @@ impl HostStateWithLimits {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers host functions for WASM plugins.
|
/// Registers host functions for WASM plugins.
|
||||||
pub fn register_host_functions(linker: &mut Linker<HostStateWithLimits>) -> Result<(), PluginError> {
|
pub fn register_host_functions(
|
||||||
|
linker: &mut Linker<HostStateWithLimits>,
|
||||||
|
) -> Result<(), PluginError> {
|
||||||
// host_log: Allow plugins to emit log messages
|
// host_log: Allow plugins to emit log messages
|
||||||
linker
|
linker
|
||||||
.func_wrap("env", "host_log", |mut caller: wasmtime::Caller<'_, HostStateWithLimits>, ptr: u32, len: u32, level: u32| {
|
.func_wrap(
|
||||||
let memory = caller.get_export("memory").and_then(|e| e.into_memory());
|
"env",
|
||||||
if let Some(mem) = memory {
|
"host_log",
|
||||||
if len > MAX_WASM_STRING_BYTES {
|
|mut caller: wasmtime::Caller<'_, HostStateWithLimits>,
|
||||||
return;
|
ptr: u32,
|
||||||
}
|
len: u32,
|
||||||
let mut buf = vec![0u8; len as usize];
|
level: u32| {
|
||||||
if mem.read(&caller, ptr as usize, &mut buf).is_ok() {
|
let memory = caller.get_export("memory").and_then(|e| e.into_memory());
|
||||||
if let Ok(msg) = String::from_utf8(buf) {
|
if let Some(mem) = memory {
|
||||||
let level_str = match level {
|
if len > MAX_WASM_STRING_BYTES {
|
||||||
0 => "TRACE",
|
return;
|
||||||
1 => "DEBUG",
|
}
|
||||||
2 => "INFO",
|
let mut buf = vec![0u8; len as usize];
|
||||||
3 => "WARN",
|
if mem.read(&caller, ptr as usize, &mut buf).is_ok() {
|
||||||
_ => "ERROR",
|
if let Ok(msg) = String::from_utf8(buf) {
|
||||||
};
|
let level_str = match level {
|
||||||
let plugin_name = caller.data().inner.plugin_name.clone();
|
0 => "TRACE",
|
||||||
tracing::info!(plugin = %plugin_name, level = %level_str, "{}", msg);
|
1 => "DEBUG",
|
||||||
|
2 => "INFO",
|
||||||
|
3 => "WARN",
|
||||||
|
_ => "ERROR",
|
||||||
|
};
|
||||||
|
let plugin_name = caller.data().inner.plugin_name.clone();
|
||||||
|
tracing::info!(plugin = %plugin_name, level = %level_str, "{}", msg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
)
|
||||||
.map_err(|e| PluginError::Message(format!("Failed to register host_log: {e}")))?;
|
.map_err(|e| PluginError::Message(format!("Failed to register host_log: {e}")))?;
|
||||||
|
|
||||||
// host_get_setting: Retrieve plugin settings
|
// host_get_setting: Retrieve plugin settings
|
||||||
|
|
@ -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 key_buf = vec![0u8; key_len as usize];
|
||||||
let mut val_buf = vec![0u8; val_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() {
|
if memory.read(&caller, key_ptr as usize, &mut key_buf).is_err() {
|
||||||
return 2;
|
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 event_buf = vec![0u8; event_len as usize];
|
||||||
let mut payload_buf = vec![0u8; payload_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() {
|
if memory.read(&caller, event_ptr as usize, &mut event_buf).is_err() {
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
@ -424,7 +439,7 @@ pub fn register_host_functions(linker: &mut Linker<HostStateWithLimits>) -> Resu
|
||||||
|
|
||||||
// Parse payload as JSON
|
// Parse payload as JSON
|
||||||
let payload: Value = serde_json::from_str(&payload_str).unwrap_or(Value::Null);
|
let payload: Value = serde_json::from_str(&payload_str).unwrap_or(Value::Null);
|
||||||
|
|
||||||
let plugin_name = state.inner.plugin_name.clone();
|
let plugin_name = state.inner.plugin_name.clone();
|
||||||
let community_id = state.inner.community_id;
|
let community_id = state.inner.community_id;
|
||||||
let actor_user_id = state.inner.actor_user_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
|
// host_get_result: Copy result buffer to WASM memory
|
||||||
// This is a helper for retrieving data from host functions
|
// This is a helper for retrieving data from host functions
|
||||||
linker
|
linker
|
||||||
.func_wrap("env", "host_get_result", |mut caller: wasmtime::Caller<'_, HostStateWithLimits>, dest_ptr: u32, max_len: u32| -> u32 {
|
.func_wrap(
|
||||||
let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) {
|
"env",
|
||||||
Some(m) => m,
|
"host_get_result",
|
||||||
None => return 0,
|
|mut caller: wasmtime::Caller<'_, HostStateWithLimits>,
|
||||||
};
|
dest_ptr: u32,
|
||||||
|
max_len: u32|
|
||||||
|
-> u32 {
|
||||||
|
let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) {
|
||||||
|
Some(m) => m,
|
||||||
|
None => return 0,
|
||||||
|
};
|
||||||
|
|
||||||
let result = caller.data().get_result();
|
let result = caller.data().get_result();
|
||||||
let copy_len = std::cmp::min(result.len(), max_len as usize);
|
let copy_len = std::cmp::min(result.len(), max_len as usize);
|
||||||
|
|
||||||
if memory.write(&mut caller, dest_ptr as usize, &result[..copy_len]).is_err() {
|
if memory
|
||||||
return 0;
|
.write(&mut caller, dest_ptr as usize, &result[..copy_len])
|
||||||
}
|
.is_err()
|
||||||
|
{
|
||||||
copy_len as u32
|
return 0;
|
||||||
})
|
}
|
||||||
|
|
||||||
|
copy_len as u32
|
||||||
|
},
|
||||||
|
)
|
||||||
.map_err(|e| PluginError::Message(format!("Failed to register host_get_result: {e}")))?;
|
.map_err(|e| PluginError::Message(format!("Failed to register host_get_result: {e}")))?;
|
||||||
|
|
||||||
// host_http_request: Make outbound HTTP request (capability-gated)
|
// host_http_request: Make outbound HTTP request (capability-gated)
|
||||||
|
|
@ -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 url_buf = vec![0u8; url_len as usize];
|
||||||
let mut method_buf = vec![0u8; method_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() {
|
if memory.read(&caller, url_ptr as usize, &mut url_buf).is_err() {
|
||||||
return pack_result(2, 0);
|
return pack_result(2, 0);
|
||||||
}
|
}
|
||||||
|
|
@ -544,7 +569,7 @@ pub fn register_host_functions(linker: &mut Linker<HostStateWithLimits>) -> Resu
|
||||||
};
|
};
|
||||||
|
|
||||||
let state = caller.data();
|
let state = caller.data();
|
||||||
|
|
||||||
if !state.inner.has_capability(CAP_OUTBOUND_HTTP) {
|
if !state.inner.has_capability(CAP_OUTBOUND_HTTP) {
|
||||||
tracing::warn!(plugin = %state.inner.plugin_name, url = %url, "HTTP denied: no capability");
|
tracing::warn!(plugin = %state.inner.plugin_name, url = %url, "HTTP denied: no capability");
|
||||||
return pack_result(6, 0);
|
return pack_result(6, 0);
|
||||||
|
|
|
||||||
|
|
@ -1,261 +1,274 @@
|
||||||
//! WASM plugin implementation.
|
//! WASM plugin implementation.
|
||||||
//!
|
//!
|
||||||
//! Wraps compiled WASM modules and implements the Plugin trait for
|
//! Wraps compiled WASM modules and implements the Plugin trait for
|
||||||
//! integration with the hook system.
|
//! integration with the hook system.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::host_api::{Capability, HostState, PluginManifest, CAP_EMIT_EVENTS, CAP_KV_STORE, CAP_OUTBOUND_HTTP, CAP_SETTINGS};
|
use super::host_api::{
|
||||||
use super::runtime::{CompiledPlugin, ExecutionLimits, PluginInstance};
|
Capability, HostState, PluginManifest, CAP_EMIT_EVENTS, CAP_KV_STORE, CAP_OUTBOUND_HTTP,
|
||||||
use crate::plugins::hooks::{HookContext, PluginError};
|
CAP_SETTINGS,
|
||||||
use crate::plugins::manager::{Plugin, PluginMetadata, PluginScope, PluginSystem};
|
};
|
||||||
|
use super::runtime::{CompiledPlugin, ExecutionLimits, PluginInstance};
|
||||||
/// A WASM-based plugin that can be loaded dynamically.
|
use crate::plugins::hooks::{HookContext, PluginError};
|
||||||
pub struct WasmPlugin {
|
use crate::plugins::manager::{Plugin, PluginMetadata, PluginScope, PluginSystem};
|
||||||
package_id: Uuid,
|
|
||||||
manifest: PluginManifest,
|
/// A WASM-based plugin that can be loaded dynamically.
|
||||||
compiled: Arc<CompiledPlugin>,
|
pub struct WasmPlugin {
|
||||||
limits: ExecutionLimits,
|
package_id: Uuid,
|
||||||
}
|
manifest: PluginManifest,
|
||||||
|
compiled: Arc<CompiledPlugin>,
|
||||||
async fn capabilities_for_manifest(
|
limits: ExecutionLimits,
|
||||||
pool: &PgPool,
|
}
|
||||||
community_id: Option<Uuid>,
|
|
||||||
manifest_capabilities: &[String],
|
async fn capabilities_for_manifest(
|
||||||
) -> Result<Vec<Capability>, PluginError> {
|
pool: &PgPool,
|
||||||
let mut out: Vec<Capability> = Vec::new();
|
community_id: Option<Uuid>,
|
||||||
|
manifest_capabilities: &[String],
|
||||||
let (allow_http, allowlist) = if let Some(cid) = community_id {
|
) -> Result<Vec<Capability>, PluginError> {
|
||||||
let row = sqlx::query!(
|
let mut out: Vec<Capability> = Vec::new();
|
||||||
r#"SELECT settings as "settings!: serde_json::Value" FROM communities WHERE id = $1"#,
|
|
||||||
cid
|
let (allow_http, allowlist) = if let Some(cid) = community_id {
|
||||||
)
|
let row = sqlx::query!(
|
||||||
.fetch_optional(pool)
|
r#"SELECT settings as "settings!: serde_json::Value" FROM communities WHERE id = $1"#,
|
||||||
.await?;
|
cid
|
||||||
|
)
|
||||||
if let Some(row) = row {
|
.fetch_optional(pool)
|
||||||
let allow_http = row
|
.await?;
|
||||||
.settings
|
|
||||||
.get("plugin_allow_outbound_http")
|
if let Some(row) = row {
|
||||||
.and_then(|v: &serde_json::Value| v.as_bool())
|
let allow_http = row
|
||||||
.unwrap_or(false);
|
.settings
|
||||||
|
.get("plugin_allow_outbound_http")
|
||||||
let allowlist: Vec<String> = row
|
.and_then(|v: &serde_json::Value| v.as_bool())
|
||||||
.settings
|
.unwrap_or(false);
|
||||||
.get("plugin_http_egress_allowlist")
|
|
||||||
.and_then(|v: &serde_json::Value| v.as_array())
|
let allowlist: Vec<String> = row
|
||||||
.map(|arr: &Vec<serde_json::Value>| {
|
.settings
|
||||||
arr.iter()
|
.get("plugin_http_egress_allowlist")
|
||||||
.filter_map(|v: &serde_json::Value| v.as_str().map(|s: &str| s.to_string()))
|
.and_then(|v: &serde_json::Value| v.as_array())
|
||||||
.collect()
|
.map(|arr: &Vec<serde_json::Value>| {
|
||||||
})
|
arr.iter()
|
||||||
.unwrap_or_default();
|
.filter_map(|v: &serde_json::Value| v.as_str().map(|s: &str| s.to_string()))
|
||||||
|
.collect()
|
||||||
(allow_http, allowlist)
|
})
|
||||||
} else {
|
.unwrap_or_default();
|
||||||
(false, Vec::new())
|
|
||||||
}
|
(allow_http, allowlist)
|
||||||
} else {
|
} else {
|
||||||
(false, Vec::new())
|
(false, Vec::new())
|
||||||
};
|
}
|
||||||
|
} else {
|
||||||
for cap in manifest_capabilities {
|
(false, Vec::new())
|
||||||
match cap.as_str() {
|
};
|
||||||
CAP_OUTBOUND_HTTP => {
|
|
||||||
let allowed = allow_http && !allowlist.is_empty();
|
for cap in manifest_capabilities {
|
||||||
out.push(Capability {
|
match cap.as_str() {
|
||||||
name: cap.clone(),
|
CAP_OUTBOUND_HTTP => {
|
||||||
allowed,
|
let allowed = allow_http && !allowlist.is_empty();
|
||||||
config: serde_json::json!({"allowlist": allowlist}),
|
out.push(Capability {
|
||||||
});
|
name: cap.clone(),
|
||||||
}
|
allowed,
|
||||||
CAP_SETTINGS | CAP_KV_STORE | CAP_EMIT_EVENTS => {
|
config: serde_json::json!({"allowlist": allowlist}),
|
||||||
out.push(Capability {
|
});
|
||||||
name: cap.clone(),
|
}
|
||||||
allowed: true,
|
CAP_SETTINGS | CAP_KV_STORE | CAP_EMIT_EVENTS => {
|
||||||
config: serde_json::json!({}),
|
out.push(Capability {
|
||||||
});
|
name: cap.clone(),
|
||||||
}
|
allowed: true,
|
||||||
_ => {
|
config: serde_json::json!({}),
|
||||||
out.push(Capability {
|
});
|
||||||
name: cap.clone(),
|
}
|
||||||
allowed: false,
|
_ => {
|
||||||
config: serde_json::json!({}),
|
out.push(Capability {
|
||||||
});
|
name: cap.clone(),
|
||||||
}
|
allowed: false,
|
||||||
}
|
config: serde_json::json!({}),
|
||||||
}
|
});
|
||||||
|
}
|
||||||
Ok(out)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WasmPlugin {
|
Ok(out)
|
||||||
/// Creates a new WASM plugin from a manifest and compiled module.
|
}
|
||||||
pub fn new(
|
|
||||||
package_id: Uuid,
|
impl WasmPlugin {
|
||||||
manifest: PluginManifest,
|
/// Creates a new WASM plugin from a manifest and compiled module.
|
||||||
compiled: Arc<CompiledPlugin>,
|
pub fn new(package_id: Uuid, manifest: PluginManifest, compiled: Arc<CompiledPlugin>) -> Self {
|
||||||
) -> Self {
|
Self {
|
||||||
Self {
|
package_id,
|
||||||
package_id,
|
manifest,
|
||||||
manifest,
|
compiled,
|
||||||
compiled,
|
limits: ExecutionLimits::default(),
|
||||||
limits: ExecutionLimits::default(),
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/// Sets custom execution limits for this plugin.
|
||||||
/// Sets custom execution limits for this plugin.
|
#[allow(dead_code)] // API for future use
|
||||||
#[allow(dead_code)] // API for future use
|
pub fn with_limits(mut self, limits: ExecutionLimits) -> Self {
|
||||||
pub fn with_limits(mut self, limits: ExecutionLimits) -> Self {
|
self.limits = limits;
|
||||||
self.limits = limits;
|
self
|
||||||
self
|
}
|
||||||
}
|
|
||||||
|
async fn capabilities_for(
|
||||||
async fn capabilities_for(&self, pool: &PgPool, ctx: &HookContext) -> Result<Vec<Capability>, PluginError> {
|
&self,
|
||||||
capabilities_for_manifest(pool, ctx.community_id, &self.manifest.capabilities).await
|
pool: &PgPool,
|
||||||
}
|
ctx: &HookContext,
|
||||||
|
) -> Result<Vec<Capability>, PluginError> {
|
||||||
async fn create_instance(&self, ctx: &HookContext) -> Result<PluginInstance, PluginError> {
|
capabilities_for_manifest(pool, ctx.community_id, &self.manifest.capabilities).await
|
||||||
let capabilities = self.capabilities_for(&ctx.pool, ctx).await?;
|
}
|
||||||
let host_state = HostState::new(
|
|
||||||
self.manifest.name.clone(),
|
async fn create_instance(&self, ctx: &HookContext) -> Result<PluginInstance, PluginError> {
|
||||||
ctx.community_id,
|
let capabilities = self.capabilities_for(&ctx.pool, ctx).await?;
|
||||||
ctx.actor_user_id,
|
let host_state = HostState::new(
|
||||||
ctx.pool.clone(),
|
self.manifest.name.clone(),
|
||||||
self.package_id,
|
ctx.community_id,
|
||||||
capabilities,
|
ctx.actor_user_id,
|
||||||
);
|
ctx.pool.clone(),
|
||||||
|
self.package_id,
|
||||||
PluginInstance::new(&self.compiled, host_state, self.limits.clone()).await
|
capabilities,
|
||||||
}
|
);
|
||||||
}
|
|
||||||
|
PluginInstance::new(&self.compiled, host_state, self.limits.clone()).await
|
||||||
#[async_trait]
|
}
|
||||||
impl Plugin for WasmPlugin {
|
}
|
||||||
fn metadata(&self) -> PluginMetadata {
|
|
||||||
PluginMetadata {
|
#[async_trait]
|
||||||
name: Box::leak(self.manifest.name.clone().into_boxed_str()),
|
impl Plugin for WasmPlugin {
|
||||||
version: Box::leak(self.manifest.version.clone().into_boxed_str()),
|
fn metadata(&self) -> PluginMetadata {
|
||||||
description: Box::leak(self.manifest.description.clone().into_boxed_str()),
|
PluginMetadata {
|
||||||
is_core: false,
|
name: Box::leak(self.manifest.name.clone().into_boxed_str()),
|
||||||
scope: PluginScope::Community,
|
version: Box::leak(self.manifest.version.clone().into_boxed_str()),
|
||||||
default_enabled: false,
|
description: Box::leak(self.manifest.description.clone().into_boxed_str()),
|
||||||
settings_schema: self.manifest.settings_schema.clone(),
|
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);
|
fn register(&self, system: &mut PluginSystem) {
|
||||||
let manifest_capabilities = self.manifest.capabilities.clone();
|
let plugin_name = self.manifest.name.clone();
|
||||||
let compiled = self.compiled.clone();
|
let package_id = self.package_id;
|
||||||
let limits = self.limits.clone();
|
let handler_plugin_id = format!("wasm:{}", package_id);
|
||||||
|
let manifest_capabilities = self.manifest.capabilities.clone();
|
||||||
for hook in &self.manifest.hooks {
|
let compiled = self.compiled.clone();
|
||||||
let hook_name = hook.clone();
|
let limits = self.limits.clone();
|
||||||
let hook_name_ref = hook_name.clone();
|
|
||||||
let plugin_name_clone = plugin_name.clone();
|
for hook in &self.manifest.hooks {
|
||||||
let handler_plugin_id_clone = handler_plugin_id.clone();
|
let hook_name = hook.clone();
|
||||||
let compiled_clone = compiled.clone();
|
let hook_name_ref = hook_name.clone();
|
||||||
let limits_clone = limits.clone();
|
let plugin_name_clone = plugin_name.clone();
|
||||||
let manifest_capabilities_for_hook = manifest_capabilities.clone();
|
let handler_plugin_id_clone = handler_plugin_id.clone();
|
||||||
|
let compiled_clone = compiled.clone();
|
||||||
system.add_action(
|
let limits_clone = limits.clone();
|
||||||
&hook_name_ref,
|
let manifest_capabilities_for_hook = manifest_capabilities.clone();
|
||||||
handler_plugin_id_clone.clone(),
|
|
||||||
50,
|
system.add_action(
|
||||||
Arc::new(move |ctx: HookContext, payload: Value| {
|
&hook_name_ref,
|
||||||
let hook = hook_name.clone();
|
handler_plugin_id_clone.clone(),
|
||||||
let plugin = plugin_name_clone.clone();
|
50,
|
||||||
let package_id = package_id;
|
Arc::new(move |ctx: HookContext, payload: Value| {
|
||||||
let manifest_capabilities = manifest_capabilities_for_hook.clone();
|
let hook = hook_name.clone();
|
||||||
let compiled = compiled_clone.clone();
|
let plugin = plugin_name_clone.clone();
|
||||||
let lim = limits_clone.clone();
|
let package_id = package_id;
|
||||||
|
let manifest_capabilities = manifest_capabilities_for_hook.clone();
|
||||||
Box::pin(async move {
|
let compiled = compiled_clone.clone();
|
||||||
let capabilities = capabilities_for_manifest(
|
let lim = limits_clone.clone();
|
||||||
&ctx.pool,
|
|
||||||
ctx.community_id,
|
Box::pin(async move {
|
||||||
&manifest_capabilities,
|
let capabilities = capabilities_for_manifest(
|
||||||
)
|
&ctx.pool,
|
||||||
.await?;
|
ctx.community_id,
|
||||||
|
&manifest_capabilities,
|
||||||
let host_state = HostState::new(
|
)
|
||||||
plugin.clone(),
|
.await?;
|
||||||
ctx.community_id,
|
|
||||||
ctx.actor_user_id,
|
let host_state = HostState::new(
|
||||||
ctx.pool.clone(),
|
plugin.clone(),
|
||||||
package_id,
|
ctx.community_id,
|
||||||
capabilities,
|
ctx.actor_user_id,
|
||||||
);
|
ctx.pool.clone(),
|
||||||
|
package_id,
|
||||||
let mut instance = PluginInstance::new(&compiled, host_state, lim).await?;
|
capabilities,
|
||||||
|
);
|
||||||
let payload_json = serde_json::to_string(&payload)
|
|
||||||
.map_err(|e| PluginError::Message(format!("Failed to serialize payload: {e}")))?;
|
let mut instance = PluginInstance::new(&compiled, host_state, lim).await?;
|
||||||
|
|
||||||
let _result = instance.call_hook(&hook, &payload_json).await?;
|
let payload_json = serde_json::to_string(&payload).map_err(|e| {
|
||||||
|
PluginError::Message(format!("Failed to serialize payload: {e}"))
|
||||||
let remaining_fuel = instance.get_fuel();
|
})?;
|
||||||
tracing::debug!(
|
|
||||||
plugin = %plugin,
|
let _result = instance.call_hook(&hook, &payload_json).await?;
|
||||||
hook = %hook,
|
|
||||||
fuel_remaining = remaining_fuel,
|
let remaining_fuel = instance.get_fuel();
|
||||||
"WASM plugin hook completed"
|
tracing::debug!(
|
||||||
);
|
plugin = %plugin,
|
||||||
|
hook = %hook,
|
||||||
Ok(())
|
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
|
async fn activate(&self, ctx: HookContext, settings: Value) -> Result<(), PluginError> {
|
||||||
});
|
let mut instance = self.create_instance(&ctx).await?;
|
||||||
let payload_json = serde_json::to_string(&payload)
|
let payload = json!({
|
||||||
.map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?;
|
"event": "activate",
|
||||||
instance.call_hook("lifecycle.activate", &payload_json).await.ok();
|
"settings": settings
|
||||||
Ok(())
|
});
|
||||||
}
|
let payload_json = serde_json::to_string(&payload)
|
||||||
|
.map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?;
|
||||||
async fn deactivate(&self, ctx: HookContext, settings: Value) -> Result<(), PluginError> {
|
instance
|
||||||
let mut instance = self.create_instance(&ctx).await?;
|
.call_hook("lifecycle.activate", &payload_json)
|
||||||
let payload = json!({
|
.await
|
||||||
"event": "deactivate",
|
.ok();
|
||||||
"settings": settings
|
Ok(())
|
||||||
});
|
}
|
||||||
let payload_json = serde_json::to_string(&payload)
|
|
||||||
.map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?;
|
async fn deactivate(&self, ctx: HookContext, settings: Value) -> Result<(), PluginError> {
|
||||||
instance.call_hook("lifecycle.deactivate", &payload_json).await.ok();
|
let mut instance = self.create_instance(&ctx).await?;
|
||||||
Ok(())
|
let payload = json!({
|
||||||
}
|
"event": "deactivate",
|
||||||
|
"settings": settings
|
||||||
async fn settings_updated(
|
});
|
||||||
&self,
|
let payload_json = serde_json::to_string(&payload)
|
||||||
ctx: HookContext,
|
.map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?;
|
||||||
old_settings: Value,
|
instance
|
||||||
new_settings: Value,
|
.call_hook("lifecycle.deactivate", &payload_json)
|
||||||
) -> Result<(), PluginError> {
|
.await
|
||||||
let mut instance = self.create_instance(&ctx).await?;
|
.ok();
|
||||||
let payload = json!({
|
Ok(())
|
||||||
"event": "settings_updated",
|
}
|
||||||
"old_settings": old_settings,
|
|
||||||
"new_settings": new_settings
|
async fn settings_updated(
|
||||||
});
|
&self,
|
||||||
let payload_json = serde_json::to_string(&payload)
|
ctx: HookContext,
|
||||||
.map_err(|e| PluginError::Message(format!("Failed to serialize: {e}")))?;
|
old_settings: Value,
|
||||||
instance.call_hook("lifecycle.settings_updated", &payload_json).await.ok();
|
new_settings: Value,
|
||||||
Ok(())
|
) -> 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(
|
let engine = Arc::new(
|
||||||
Engine::new(&config)
|
Engine::new(&config)
|
||||||
.map_err(|e| PluginError::Message(format!("Failed to create WASM engine: {e}")))?
|
.map_err(|e| PluginError::Message(format!("Failed to create WASM engine: {e}")))?,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Spawn epoch ticker for timeout enforcement
|
// Spawn epoch ticker for timeout enforcement
|
||||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||||
let engine_clone = engine.clone();
|
let engine_clone = engine.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut interval = tokio::time::interval(Duration::from_millis(10));
|
let mut interval = tokio::time::interval(Duration::from_millis(10));
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -78,7 +78,7 @@ impl WasmRuntime {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
engine,
|
engine,
|
||||||
_epoch_ticker_shutdown: Some(shutdown_tx),
|
_epoch_ticker_shutdown: Some(shutdown_tx),
|
||||||
})
|
})
|
||||||
|
|
@ -140,11 +140,11 @@ impl PluginInstance {
|
||||||
|
|
||||||
let mut store = Store::new(compiled.engine(), state_with_limits);
|
let mut store = Store::new(compiled.engine(), state_with_limits);
|
||||||
store.limiter(|state| &mut state.limits);
|
store.limiter(|state| &mut state.limits);
|
||||||
|
|
||||||
store.set_fuel(limits.fuel).map_err(|e| {
|
store
|
||||||
PluginError::Message(format!("Failed to set fuel limit: {e}"))
|
.set_fuel(limits.fuel)
|
||||||
})?;
|
.map_err(|e| PluginError::Message(format!("Failed to set fuel limit: {e}")))?;
|
||||||
|
|
||||||
let epoch_deadline = (limits.timeout_ms / 10).max(1);
|
let epoch_deadline = (limits.timeout_ms / 10).max(1);
|
||||||
store.epoch_deadline_async_yield_and_update(epoch_deadline);
|
store.epoch_deadline_async_yield_and_update(epoch_deadline);
|
||||||
|
|
||||||
|
|
@ -177,7 +177,9 @@ impl PluginInstance {
|
||||||
let handle_hook = self
|
let handle_hook = self
|
||||||
.instance
|
.instance
|
||||||
.get_typed_func::<(u32, u32, u32, u32), u64>(&mut self.store, "handle_hook")
|
.get_typed_func::<(u32, u32, u32, u32), u64>(&mut self.store, "handle_hook")
|
||||||
.map_err(|e| PluginError::Message(format!("Plugin missing 'handle_hook' export: {e}")))?;
|
.map_err(|e| {
|
||||||
|
PluginError::Message(format!("Plugin missing 'handle_hook' export: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
let memory = self
|
let memory = self
|
||||||
.instance
|
.instance
|
||||||
|
|
@ -185,15 +187,21 @@ impl PluginInstance {
|
||||||
.ok_or_else(|| PluginError::Message("Plugin missing 'memory' export".to_string()))?;
|
.ok_or_else(|| PluginError::Message("Plugin missing 'memory' export".to_string()))?;
|
||||||
|
|
||||||
let hook_bytes = hook_name.as_bytes();
|
let hook_bytes = hook_name.as_bytes();
|
||||||
let hook_ptr = alloc.call_async(&mut self.store, hook_bytes.len() as u32).await
|
let hook_ptr = alloc
|
||||||
|
.call_async(&mut self.store, hook_bytes.len() as u32)
|
||||||
|
.await
|
||||||
.map_err(|e| PluginError::Message(format!("alloc failed for hook name: {e}")))?;
|
.map_err(|e| PluginError::Message(format!("alloc failed for hook name: {e}")))?;
|
||||||
memory.write(&mut self.store, hook_ptr as usize, hook_bytes)
|
memory
|
||||||
|
.write(&mut self.store, hook_ptr as usize, hook_bytes)
|
||||||
.map_err(|e| PluginError::Message(format!("Failed to write hook name: {e}")))?;
|
.map_err(|e| PluginError::Message(format!("Failed to write hook name: {e}")))?;
|
||||||
|
|
||||||
let payload_bytes = payload_json.as_bytes();
|
let payload_bytes = payload_json.as_bytes();
|
||||||
let payload_ptr = alloc.call_async(&mut self.store, payload_bytes.len() as u32).await
|
let payload_ptr = alloc
|
||||||
|
.call_async(&mut self.store, payload_bytes.len() as u32)
|
||||||
|
.await
|
||||||
.map_err(|e| PluginError::Message(format!("alloc failed for payload: {e}")))?;
|
.map_err(|e| PluginError::Message(format!("alloc failed for payload: {e}")))?;
|
||||||
memory.write(&mut self.store, payload_ptr as usize, payload_bytes)
|
memory
|
||||||
|
.write(&mut self.store, payload_ptr as usize, payload_bytes)
|
||||||
.map_err(|e| PluginError::Message(format!("Failed to write payload: {e}")))?;
|
.map_err(|e| PluginError::Message(format!("Failed to write payload: {e}")))?;
|
||||||
|
|
||||||
let result = handle_hook
|
let result = handle_hook
|
||||||
|
|
@ -213,12 +221,22 @@ impl PluginInstance {
|
||||||
let result_len = (result & 0xFFFFFFFF) as u32;
|
let result_len = (result & 0xFFFFFFFF) as u32;
|
||||||
|
|
||||||
let mut result_bytes = vec![0u8; result_len as usize];
|
let mut result_bytes = vec![0u8; result_len as usize];
|
||||||
memory.read(&self.store, result_ptr as usize, &mut result_bytes)
|
memory
|
||||||
|
.read(&self.store, result_ptr as usize, &mut result_bytes)
|
||||||
.map_err(|e| PluginError::Message(format!("Failed to read result: {e}")))?;
|
.map_err(|e| PluginError::Message(format!("Failed to read result: {e}")))?;
|
||||||
|
|
||||||
dealloc.call_async(&mut self.store, (hook_ptr, hook_bytes.len() as u32)).await.ok();
|
dealloc
|
||||||
dealloc.call_async(&mut self.store, (payload_ptr, payload_bytes.len() as u32)).await.ok();
|
.call_async(&mut self.store, (hook_ptr, hook_bytes.len() as u32))
|
||||||
dealloc.call_async(&mut self.store, (result_ptr, result_len)).await.ok();
|
.await
|
||||||
|
.ok();
|
||||||
|
dealloc
|
||||||
|
.call_async(&mut self.store, (payload_ptr, payload_bytes.len() as u32))
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
dealloc
|
||||||
|
.call_async(&mut self.store, (result_ptr, result_len))
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
String::from_utf8(result_bytes)
|
String::from_utf8(result_bytes)
|
||||||
.map_err(|e| PluginError::Message(format!("Result is not valid UTF-8: {e}")))
|
.map_err(|e| PluginError::Message(format!("Result is not valid UTF-8: {e}")))
|
||||||
|
|
|
||||||
|
|
@ -98,20 +98,14 @@ impl FixedWindowLimiter {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_ip_from_headers(headers: &HeaderMap) -> Option<IpAddr> {
|
fn parse_ip_from_headers(headers: &HeaderMap) -> Option<IpAddr> {
|
||||||
if let Some(forwarded) = headers
|
if let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
|
||||||
.get("x-forwarded-for")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
{
|
|
||||||
let first = forwarded.split(',').next().map(|s| s.trim()).unwrap_or("");
|
let first = forwarded.split(',').next().map(|s| s.trim()).unwrap_or("");
|
||||||
if let Ok(ip) = first.parse::<IpAddr>() {
|
if let Ok(ip) = first.parse::<IpAddr>() {
|
||||||
return Some(ip);
|
return Some(ip);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(real_ip) = headers
|
if let Some(real_ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
|
||||||
.get("x-real-ip")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
{
|
|
||||||
if let Ok(ip) = real_ip.trim().parse::<IpAddr>() {
|
if let Ok(ip) = real_ip.trim().parse::<IpAddr>() {
|
||||||
return Some(ip);
|
return Some(ip);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@
|
||||||
//! - Quadratic Voting (intensity-weighted preferences)
|
//! - Quadratic Voting (intensity-weighted preferences)
|
||||||
//! - Ranked Choice / Instant Runoff
|
//! - Ranked Choice / Instant Runoff
|
||||||
|
|
||||||
pub mod schulze;
|
|
||||||
pub mod star;
|
|
||||||
pub mod quadratic;
|
pub mod quadratic;
|
||||||
pub mod ranked_choice;
|
pub mod ranked_choice;
|
||||||
|
pub mod schulze;
|
||||||
|
pub mod star;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
|
||||||
|
|
@ -78,12 +78,14 @@ pub fn calculate(options: &[Uuid], ballots: &[QuadraticBallot]) -> VotingResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by total votes (descending)
|
// Sort by total votes (descending)
|
||||||
let mut sorted_votes: Vec<(Uuid, i64)> = vote_totals.iter()
|
let mut sorted_votes: Vec<(Uuid, i64)> = vote_totals
|
||||||
|
.iter()
|
||||||
.map(|(&id, &votes)| (id, votes))
|
.map(|(&id, &votes)| (id, votes))
|
||||||
.collect();
|
.collect();
|
||||||
sorted_votes.sort_by(|a, b| b.1.cmp(&a.1));
|
sorted_votes.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
||||||
let ranking: Vec<RankedOption> = sorted_votes.iter()
|
let ranking: Vec<RankedOption> = sorted_votes
|
||||||
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, (id, votes))| RankedOption {
|
.map(|(i, (id, votes))| RankedOption {
|
||||||
option_id: *id,
|
option_id: *id,
|
||||||
|
|
@ -154,7 +156,7 @@ mod tests {
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = calculate(&options, &ballots);
|
let result = calculate(&options, &ballots);
|
||||||
|
|
||||||
// A: 9 votes, B: 6 votes -> A wins despite fewer supporters
|
// A: 9 votes, B: 6 votes -> A wins despite fewer supporters
|
||||||
// This demonstrates intensity expression
|
// This demonstrates intensity expression
|
||||||
assert_eq!(result.winner, Some(a));
|
assert_eq!(result.winner, Some(a));
|
||||||
|
|
@ -165,12 +167,10 @@ mod tests {
|
||||||
let a = Uuid::new_v4();
|
let a = Uuid::new_v4();
|
||||||
let options = vec![a];
|
let options = vec![a];
|
||||||
|
|
||||||
let ballots = vec![
|
let ballots = vec![QuadraticBallot {
|
||||||
QuadraticBallot {
|
total_credits: 100,
|
||||||
total_credits: 100,
|
allocations: vec![(a, 11)], // Costs 121, exceeds 100
|
||||||
allocations: vec![(a, 11)], // Costs 121, exceeds 100
|
}];
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let result = calculate(&options, &ballots);
|
let result = calculate(&options, &ballots);
|
||||||
// Invalid ballot should be skipped
|
// Invalid ballot should be skipped
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,8 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Count first-choice votes among active options
|
// Count first-choice votes among active options
|
||||||
let mut vote_counts: HashMap<Uuid, i64> = active_options.iter()
|
let mut vote_counts: HashMap<Uuid, i64> =
|
||||||
.map(|&id| (id, 0))
|
active_options.iter().map(|&id| (id, 0)).collect();
|
||||||
.collect();
|
|
||||||
|
|
||||||
for ballot in ballots {
|
for ballot in ballots {
|
||||||
// Find first choice among active options
|
// Find first choice among active options
|
||||||
|
|
@ -53,7 +52,8 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by vote count
|
// Sort by vote count
|
||||||
let mut sorted: Vec<(Uuid, i64)> = vote_counts.iter()
|
let mut sorted: Vec<(Uuid, i64)> = vote_counts
|
||||||
|
.iter()
|
||||||
.map(|(&id, &count)| (id, count))
|
.map(|(&id, &count)| (id, count))
|
||||||
.collect();
|
.collect();
|
||||||
sorted.sort_by(|a, b| b.1.cmp(&a.1));
|
sorted.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
@ -70,7 +70,8 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build final ranking
|
// Build final ranking
|
||||||
let mut final_ranking: Vec<RankedOption> = sorted.iter()
|
let mut final_ranking: Vec<RankedOption> = sorted
|
||||||
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, (id, count))| RankedOption {
|
.map(|(i, (id, count))| RankedOption {
|
||||||
option_id: *id,
|
option_id: *id,
|
||||||
|
|
@ -91,10 +92,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
|
||||||
return VotingResult {
|
return VotingResult {
|
||||||
winner: Some(*winner),
|
winner: Some(*winner),
|
||||||
ranking: final_ranking,
|
ranking: final_ranking,
|
||||||
details: VotingDetails::RankedChoice {
|
details: VotingDetails::RankedChoice { rounds, eliminated },
|
||||||
rounds,
|
|
||||||
eliminated,
|
|
||||||
},
|
|
||||||
total_ballots: ballots.len(),
|
total_ballots: ballots.len(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -103,14 +101,15 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
|
||||||
// Only one option left - it wins
|
// Only one option left - it wins
|
||||||
if active_options.len() <= 1 {
|
if active_options.len() <= 1 {
|
||||||
let winner = active_options.iter().next().cloned();
|
let winner = active_options.iter().next().cloned();
|
||||||
|
|
||||||
rounds.push(RoundResult {
|
rounds.push(RoundResult {
|
||||||
round: round_num,
|
round: round_num,
|
||||||
vote_counts: sorted.clone(),
|
vote_counts: sorted.clone(),
|
||||||
eliminated: None,
|
eliminated: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut final_ranking: Vec<RankedOption> = sorted.iter()
|
let mut final_ranking: Vec<RankedOption> = sorted
|
||||||
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, (id, count))| RankedOption {
|
.map(|(i, (id, count))| RankedOption {
|
||||||
option_id: *id,
|
option_id: *id,
|
||||||
|
|
@ -130,10 +129,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
|
||||||
return VotingResult {
|
return VotingResult {
|
||||||
winner,
|
winner,
|
||||||
ranking: final_ranking,
|
ranking: final_ranking,
|
||||||
details: VotingDetails::RankedChoice {
|
details: VotingDetails::RankedChoice { rounds, eliminated },
|
||||||
rounds,
|
|
||||||
eliminated,
|
|
||||||
},
|
|
||||||
total_ballots: ballots.len(),
|
total_ballots: ballots.len(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +137,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
|
||||||
// Eliminate lowest-ranked option
|
// Eliminate lowest-ranked option
|
||||||
if let Some((loser, _)) = sorted.last() {
|
if let Some((loser, _)) = sorted.last() {
|
||||||
let loser_id = *loser;
|
let loser_id = *loser;
|
||||||
|
|
||||||
rounds.push(RoundResult {
|
rounds.push(RoundResult {
|
||||||
round: round_num,
|
round: round_num,
|
||||||
vote_counts: sorted,
|
vote_counts: sorted,
|
||||||
|
|
@ -159,10 +155,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
|
||||||
VotingResult {
|
VotingResult {
|
||||||
winner: None,
|
winner: None,
|
||||||
ranking: vec![],
|
ranking: vec![],
|
||||||
details: VotingDetails::RankedChoice {
|
details: VotingDetails::RankedChoice { rounds, eliminated },
|
||||||
rounds,
|
|
||||||
eliminated,
|
|
||||||
},
|
|
||||||
total_ballots: ballots.len(),
|
total_ballots: ballots.len(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -180,11 +173,21 @@ mod tests {
|
||||||
|
|
||||||
// A has clear majority
|
// A has clear majority
|
||||||
let ballots = vec![
|
let ballots = vec![
|
||||||
RankedBallot { rankings: vec![a, b, c] },
|
RankedBallot {
|
||||||
RankedBallot { rankings: vec![a, b, c] },
|
rankings: vec![a, b, c],
|
||||||
RankedBallot { rankings: vec![a, c, b] },
|
},
|
||||||
RankedBallot { rankings: vec![b, a, c] },
|
RankedBallot {
|
||||||
RankedBallot { rankings: vec![c, b, a] },
|
rankings: vec![a, b, c],
|
||||||
|
},
|
||||||
|
RankedBallot {
|
||||||
|
rankings: vec![a, c, b],
|
||||||
|
},
|
||||||
|
RankedBallot {
|
||||||
|
rankings: vec![b, a, c],
|
||||||
|
},
|
||||||
|
RankedBallot {
|
||||||
|
rankings: vec![c, b, a],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = calculate(&options, &ballots);
|
let result = calculate(&options, &ballots);
|
||||||
|
|
@ -200,19 +203,29 @@ mod tests {
|
||||||
|
|
||||||
// No first-round majority, C eliminated, B wins
|
// No first-round majority, C eliminated, B wins
|
||||||
let ballots = vec![
|
let ballots = vec![
|
||||||
RankedBallot { rankings: vec![a, b, c] },
|
RankedBallot {
|
||||||
RankedBallot { rankings: vec![a, b, c] },
|
rankings: vec![a, b, c],
|
||||||
RankedBallot { rankings: vec![b, a, c] },
|
},
|
||||||
RankedBallot { rankings: vec![b, c, a] },
|
RankedBallot {
|
||||||
RankedBallot { rankings: vec![c, b, a] }, // C's vote goes to B
|
rankings: vec![a, b, c],
|
||||||
|
},
|
||||||
|
RankedBallot {
|
||||||
|
rankings: vec![b, a, c],
|
||||||
|
},
|
||||||
|
RankedBallot {
|
||||||
|
rankings: vec![b, c, a],
|
||||||
|
},
|
||||||
|
RankedBallot {
|
||||||
|
rankings: vec![c, b, a],
|
||||||
|
}, // C's vote goes to B
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = calculate(&options, &ballots);
|
let result = calculate(&options, &ballots);
|
||||||
|
|
||||||
// Round 1: A=2, B=2, C=1 -> C eliminated
|
// Round 1: A=2, B=2, C=1 -> C eliminated
|
||||||
// Round 2: A=2, B=3 -> B wins with majority
|
// Round 2: A=2, B=3 -> B wins with majority
|
||||||
assert_eq!(result.winner, Some(b));
|
assert_eq!(result.winner, Some(b));
|
||||||
|
|
||||||
if let VotingDetails::RankedChoice { rounds, eliminated } = &result.details {
|
if let VotingDetails::RankedChoice { rounds, eliminated } = &result.details {
|
||||||
assert_eq!(rounds.len(), 2);
|
assert_eq!(rounds.len(), 2);
|
||||||
assert_eq!(eliminated, &vec![c]);
|
assert_eq!(eliminated, &vec![c]);
|
||||||
|
|
@ -230,15 +243,25 @@ mod tests {
|
||||||
let options = vec![a, b, spoiler];
|
let options = vec![a, b, spoiler];
|
||||||
|
|
||||||
let ballots = vec![
|
let ballots = vec![
|
||||||
RankedBallot { rankings: vec![a, spoiler, b] },
|
RankedBallot {
|
||||||
RankedBallot { rankings: vec![a, spoiler, b] },
|
rankings: vec![a, spoiler, b],
|
||||||
RankedBallot { rankings: vec![spoiler, a, b] }, // Spoiler fans prefer A
|
},
|
||||||
RankedBallot { rankings: vec![b, a, spoiler] },
|
RankedBallot {
|
||||||
RankedBallot { rankings: vec![b, a, spoiler] },
|
rankings: vec![a, spoiler, b],
|
||||||
|
},
|
||||||
|
RankedBallot {
|
||||||
|
rankings: vec![spoiler, a, b],
|
||||||
|
}, // Spoiler fans prefer A
|
||||||
|
RankedBallot {
|
||||||
|
rankings: vec![b, a, spoiler],
|
||||||
|
},
|
||||||
|
RankedBallot {
|
||||||
|
rankings: vec![b, a, spoiler],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = calculate(&options, &ballots);
|
let result = calculate(&options, &ballots);
|
||||||
|
|
||||||
// Without RCV, spoiler might split A's vote
|
// Without RCV, spoiler might split A's vote
|
||||||
// With RCV, spoiler eliminated, vote goes to A
|
// With RCV, spoiler eliminated, vote goes to A
|
||||||
// Round 1: A=2, B=2, Spoiler=1 -> Spoiler eliminated
|
// 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
|
// Create option index mapping
|
||||||
let _option_index: HashMap<Uuid, usize> = options
|
let _option_index: HashMap<Uuid, usize> =
|
||||||
.iter()
|
options.iter().enumerate().map(|(i, &id)| (id, i)).collect();
|
||||||
.enumerate()
|
|
||||||
.map(|(i, &id)| (id, i))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Build pairwise preference matrix
|
// Build pairwise preference matrix
|
||||||
// d[i][j] = number of voters who prefer option i over option j
|
// d[i][j] = number of voters who prefer option i over option j
|
||||||
|
|
@ -111,9 +108,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, &opt_id)| {
|
.map(|(i, &opt_id)| {
|
||||||
let win_count: i32 = (0..n)
|
let win_count: i32 = (0..n).filter(|&j| i != j && p[i][j] > p[j][i]).count() as i32;
|
||||||
.filter(|&j| i != j && p[i][j] > p[j][i])
|
|
||||||
.count() as i32;
|
|
||||||
(opt_id, win_count)
|
(opt_id, win_count)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -159,11 +154,21 @@ mod tests {
|
||||||
// 3 voters prefer A > B > C
|
// 3 voters prefer A > B > C
|
||||||
// 2 voters prefer B > C > A
|
// 2 voters prefer B > C > A
|
||||||
let ballots = vec![
|
let ballots = vec![
|
||||||
RankedBallot { rankings: vec![(a, 1), (b, 2), (c, 3)] },
|
RankedBallot {
|
||||||
RankedBallot { rankings: vec![(a, 1), (b, 2), (c, 3)] },
|
rankings: vec![(a, 1), (b, 2), (c, 3)],
|
||||||
RankedBallot { rankings: vec![(a, 1), (b, 2), (c, 3)] },
|
},
|
||||||
RankedBallot { rankings: vec![(b, 1), (c, 2), (a, 3)] },
|
RankedBallot {
|
||||||
RankedBallot { rankings: vec![(b, 1), (c, 2), (a, 3)] },
|
rankings: vec![(a, 1), (b, 2), (c, 3)],
|
||||||
|
},
|
||||||
|
RankedBallot {
|
||||||
|
rankings: vec![(a, 1), (b, 2), (c, 3)],
|
||||||
|
},
|
||||||
|
RankedBallot {
|
||||||
|
rankings: vec![(b, 1), (c, 2), (a, 3)],
|
||||||
|
},
|
||||||
|
RankedBallot {
|
||||||
|
rankings: vec![(b, 1), (c, 2), (a, 3)],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = calculate(&options, &ballots);
|
let result = calculate(&options, &ballots);
|
||||||
|
|
@ -181,11 +186,21 @@ mod tests {
|
||||||
|
|
||||||
// A beats B, B beats C, C beats A
|
// A beats B, B beats C, C beats A
|
||||||
let ballots = vec![
|
let ballots = vec![
|
||||||
RankedBallot { rankings: vec![(a, 1), (b, 2), (c, 3)] },
|
RankedBallot {
|
||||||
RankedBallot { rankings: vec![(a, 1), (b, 2), (c, 3)] },
|
rankings: vec![(a, 1), (b, 2), (c, 3)],
|
||||||
RankedBallot { rankings: vec![(b, 1), (c, 2), (a, 3)] },
|
},
|
||||||
RankedBallot { rankings: vec![(b, 1), (c, 2), (a, 3)] },
|
RankedBallot {
|
||||||
RankedBallot { rankings: vec![(c, 1), (a, 2), (b, 3)] },
|
rankings: vec![(a, 1), (b, 2), (c, 3)],
|
||||||
|
},
|
||||||
|
RankedBallot {
|
||||||
|
rankings: vec![(b, 1), (c, 2), (a, 3)],
|
||||||
|
},
|
||||||
|
RankedBallot {
|
||||||
|
rankings: vec![(b, 1), (c, 2), (a, 3)],
|
||||||
|
},
|
||||||
|
RankedBallot {
|
||||||
|
rankings: vec![(c, 1), (a, 2), (b, 3)],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = calculate(&options, &ballots);
|
let result = calculate(&options, &ballots);
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@ pub fn calculate(options: &[Uuid], ballots: &[ScoreBallot]) -> VotingResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by total score (descending)
|
// Sort by total score (descending)
|
||||||
let mut sorted_scores: Vec<(Uuid, i64)> = score_totals.iter()
|
let mut sorted_scores: Vec<(Uuid, i64)> = score_totals
|
||||||
|
.iter()
|
||||||
.map(|(&id, &score)| (id, score))
|
.map(|(&id, &score)| (id, score))
|
||||||
.collect();
|
.collect();
|
||||||
sorted_scores.sort_by(|a, b| b.1.cmp(&a.1));
|
sorted_scores.sort_by(|a, b| b.1.cmp(&a.1));
|
||||||
|
|
@ -54,13 +55,15 @@ pub fn calculate(options: &[Uuid], ballots: &[ScoreBallot]) -> VotingResult {
|
||||||
let winner = sorted_scores.first().map(|(id, _)| *id);
|
let winner = sorted_scores.first().map(|(id, _)| *id);
|
||||||
return VotingResult {
|
return VotingResult {
|
||||||
winner,
|
winner,
|
||||||
ranking: sorted_scores.iter().enumerate().map(|(i, (id, score))| {
|
ranking: sorted_scores
|
||||||
RankedOption {
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (id, score))| RankedOption {
|
||||||
option_id: *id,
|
option_id: *id,
|
||||||
rank: i + 1,
|
rank: i + 1,
|
||||||
score: *score as f64,
|
score: *score as f64,
|
||||||
}
|
})
|
||||||
}).collect(),
|
.collect(),
|
||||||
details: VotingDetails::Star {
|
details: VotingDetails::Star {
|
||||||
score_totals: sorted_scores,
|
score_totals: sorted_scores,
|
||||||
finalists: (winner.unwrap_or(Uuid::nil()), Uuid::nil()),
|
finalists: (winner.unwrap_or(Uuid::nil()), Uuid::nil()),
|
||||||
|
|
@ -80,7 +83,7 @@ pub fn calculate(options: &[Uuid], ballots: &[ScoreBallot]) -> VotingResult {
|
||||||
|
|
||||||
for ballot in ballots {
|
for ballot in ballots {
|
||||||
let ballot_scores: HashMap<Uuid, i32> = ballot.scores.iter().cloned().collect();
|
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_a = ballot_scores.get(&finalist_a).copied().unwrap_or(0);
|
||||||
let score_b = ballot_scores.get(&finalist_b).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
|
// Build final ranking
|
||||||
let mut ranking: Vec<RankedOption> = sorted_scores.iter().enumerate().map(|(i, (id, score))| {
|
let mut ranking: Vec<RankedOption> = sorted_scores
|
||||||
RankedOption {
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (id, score))| RankedOption {
|
||||||
option_id: *id,
|
option_id: *id,
|
||||||
rank: i + 1,
|
rank: i + 1,
|
||||||
score: *score as f64,
|
score: *score as f64,
|
||||||
}
|
})
|
||||||
}).collect();
|
.collect();
|
||||||
|
|
||||||
// Adjust ranking for runoff result (swap if needed)
|
// Adjust ranking for runoff result (swap if needed)
|
||||||
if winner == finalist_b && ranking.len() >= 2 {
|
if winner == finalist_b && ranking.len() >= 2 {
|
||||||
|
|
@ -139,9 +144,15 @@ mod tests {
|
||||||
let options = vec![a, b, c];
|
let options = vec![a, b, c];
|
||||||
|
|
||||||
let ballots = vec![
|
let ballots = vec![
|
||||||
ScoreBallot { scores: vec![(a, 5), (b, 3), (c, 1)] },
|
ScoreBallot {
|
||||||
ScoreBallot { scores: vec![(a, 5), (b, 2), (c, 0)] },
|
scores: vec![(a, 5), (b, 3), (c, 1)],
|
||||||
ScoreBallot { scores: vec![(a, 4), (b, 4), (c, 2)] },
|
},
|
||||||
|
ScoreBallot {
|
||||||
|
scores: vec![(a, 5), (b, 2), (c, 0)],
|
||||||
|
},
|
||||||
|
ScoreBallot {
|
||||||
|
scores: vec![(a, 4), (b, 4), (c, 2)],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = calculate(&options, &ballots);
|
let result = calculate(&options, &ballots);
|
||||||
|
|
@ -157,11 +168,21 @@ mod tests {
|
||||||
|
|
||||||
// A gets high scores from few, B gets moderate scores from many
|
// A gets high scores from few, B gets moderate scores from many
|
||||||
let ballots = vec![
|
let ballots = vec![
|
||||||
ScoreBallot { scores: vec![(a, 5), (b, 4)] }, // Prefers A
|
ScoreBallot {
|
||||||
ScoreBallot { scores: vec![(a, 5), (b, 4)] }, // Prefers A
|
scores: vec![(a, 5), (b, 4)],
|
||||||
ScoreBallot { scores: vec![(a, 0), (b, 3)] }, // Prefers B
|
}, // Prefers A
|
||||||
ScoreBallot { scores: vec![(a, 0), (b, 3)] }, // Prefers B
|
ScoreBallot {
|
||||||
ScoreBallot { scores: vec![(a, 0), (b, 3)] }, // Prefers B
|
scores: vec![(a, 5), (b, 4)],
|
||||||
|
}, // Prefers A
|
||||||
|
ScoreBallot {
|
||||||
|
scores: vec![(a, 0), (b, 3)],
|
||||||
|
}, // Prefers B
|
||||||
|
ScoreBallot {
|
||||||
|
scores: vec![(a, 0), (b, 3)],
|
||||||
|
}, // Prefers B
|
||||||
|
ScoreBallot {
|
||||||
|
scores: vec![(a, 0), (b, 3)],
|
||||||
|
}, // Prefers B
|
||||||
];
|
];
|
||||||
|
|
||||||
let result = calculate(&options, &ballots);
|
let result = calculate(&options, &ballots);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue