From 89a6e9eaa7be24bc9c9eed5fe2b85f698b9e8ee6 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Thu, 29 Jan 2026 00:46:43 +0100 Subject: [PATCH] backend: delegation, moderation, and voting improvements --- backend/src/api/approvals.rs | 54 +++ backend/src/api/auth.rs | 73 +++- backend/src/api/communities.rs | 5 +- backend/src/api/delegation.rs | 128 ++++-- backend/src/api/demo.rs | 7 + backend/src/api/moderation.rs | 8 +- backend/src/api/permissions.rs | 36 +- backend/src/api/plugins.rs | 20 +- backend/src/api/settings.rs | 74 ++++ backend/src/auth/middleware.rs | 9 +- backend/src/config/mod.rs | 2 +- backend/src/demo/mod.rs | 390 ++++++++++++++++-- backend/src/main.rs | 66 ++- .../plugins/builtin/conflict_resolution.rs | 1 + .../src/plugins/builtin/moderation_ledger.rs | 1 + backend/src/plugins/manager.rs | 2 + backend/src/plugins/wasm/host_api.rs | 24 +- backend/src/voting/mod.rs | 9 +- backend/src/voting/ranked_choice.rs | 8 +- backend/src/voting/star.rs | 10 +- 20 files changed, 800 insertions(+), 127 deletions(-) diff --git a/backend/src/api/approvals.rs b/backend/src/api/approvals.rs index d679a36..ace1f11 100644 --- a/backend/src/api/approvals.rs +++ b/backend/src/api/approvals.rs @@ -125,6 +125,60 @@ async fn review_registration( } })?; + if let Some(new_user_id) = result { + let user_role_id: Option = sqlx::query_scalar!( + "SELECT id FROM roles WHERE name = 'user' AND community_id IS NULL LIMIT 1" + ) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if let Some(role_id) = user_role_id { + sqlx::query!( + r#"INSERT INTO user_roles (user_id, role_id, community_id, granted_by) + VALUES ($1, $2, NULL, $3) + ON CONFLICT (user_id, role_id, community_id) DO NOTHING"#, + new_user_id, + role_id, + auth.user_id + ) + .execute(&pool) + .await + .ok(); + } + + let is_admin: bool = sqlx::query_scalar!( + "SELECT is_admin FROM users WHERE id = $1", + new_user_id + ) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if is_admin { + let admin_role_id: Option = sqlx::query_scalar!( + "SELECT id FROM roles WHERE name = 'platform_admin' AND community_id IS NULL LIMIT 1" + ) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if let Some(role_id) = admin_role_id { + sqlx::query!( + r#"INSERT INTO user_roles (user_id, role_id, community_id, granted_by) + VALUES ($1, $2, NULL, $3) + ON CONFLICT (user_id, role_id, community_id) DO NOTHING"#, + new_user_id, + role_id, + auth.user_id + ) + .execute(&pool) + .await + .ok(); + } + } + } + Ok(Json(ReviewResponse { success: true, created_id: result, diff --git a/backend/src/api/auth.rs b/backend/src/api/auth.rs index 38b137a..3a1df21 100644 --- a/backend/src/api/auth.rs +++ b/backend/src/api/auth.rs @@ -1,4 +1,4 @@ -use axum::{extract::State, http::StatusCode, routing::post, Extension, Json, Router}; +use axum::{extract::State, http::StatusCode, routing::{get, post}, Extension, Json, Router}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::sync::Arc; @@ -41,13 +41,15 @@ pub struct UserInfo { pub struct MeResponse { pub id: Uuid, pub username: String, + pub email: String, + pub display_name: Option, } pub fn router(pool: PgPool) -> Router { Router::new() .route("/api/auth/register", post(register)) .route("/api/auth/login", post(login)) - .route("/api/auth/me", post(me)) + .route("/api/auth/me", get(me).post(me)) .with_state(pool) } @@ -187,6 +189,49 @@ async fn register( } })?; + // Assign default platform role(s) + let user_role_id: Option = sqlx::query_scalar!( + "SELECT id FROM roles WHERE name = 'user' AND community_id IS NULL LIMIT 1" + ) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if let Some(role_id) = user_role_id { + sqlx::query!( + r#"INSERT INTO user_roles (user_id, role_id, community_id, granted_by) + VALUES ($1, $2, NULL, $1) + ON CONFLICT (user_id, role_id, community_id) DO NOTHING"#, + user.id, + role_id + ) + .execute(&pool) + .await + .ok(); + } + + if is_first_user { + let admin_role_id: Option = sqlx::query_scalar!( + "SELECT id FROM roles WHERE name = 'platform_admin' AND community_id IS NULL LIMIT 1" + ) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if let Some(role_id) = admin_role_id { + sqlx::query!( + r#"INSERT INTO user_roles (user_id, role_id, community_id, granted_by) + VALUES ($1, $2, NULL, $1) + ON CONFLICT (user_id, role_id, community_id) DO NOTHING"#, + user.id, + role_id + ) + .execute(&pool) + .await + .ok(); + } + } + // Use invitation if provided (records usage and links user) if let Some(code) = &req.invitation_code { sqlx::query!("SELECT use_invitation($1, $2, $3)", code, user.id, req.email.as_str()) @@ -289,9 +334,23 @@ async fn login( })) } -async fn me(auth: AuthUser) -> Json { - Json(MeResponse { - id: auth.user_id, - username: auth.username, - }) +async fn me( + auth: AuthUser, + State(pool): State, +) -> Result, (StatusCode, String)> { + let u = sqlx::query!( + "SELECT id, username, email, display_name FROM users WHERE id = $1 AND is_active = true", + auth.user_id + ) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::UNAUTHORIZED, "User not found".to_string()))?; + + Ok(Json(MeResponse { + id: u.id, + username: u.username, + email: u.email, + display_name: u.display_name, + })) } diff --git a/backend/src/api/communities.rs b/backend/src/api/communities.rs index bc08e4a..1695c4a 100644 --- a/backend/src/api/communities.rs +++ b/backend/src/api/communities.rs @@ -284,7 +284,10 @@ async fn leave_community( return Ok(Json(serde_json::json!({"status": "not_member"}))); } - let role = membership.unwrap().role.clone(); + let Some(membership) = membership else { + return Ok(Json(serde_json::json!({"status": "not_member"}))); + }; + let role = membership.role.clone(); if role == "admin" { let admin_count = sqlx::query_scalar!( diff --git a/backend/src/api/delegation.rs b/backend/src/api/delegation.rs index 688e836..32c889b 100644 --- a/backend/src/api/delegation.rs +++ b/backend/src/api/delegation.rs @@ -21,6 +21,7 @@ use crate::auth::AuthUser; // ============================================================================ #[derive(Debug, Clone, Copy, Serialize, Deserialize, sqlx::Type)] +#[serde(rename_all = "lowercase")] #[sqlx(type_name = "delegation_scope", rename_all = "lowercase")] pub enum DelegationScope { Global, @@ -136,6 +137,61 @@ async fn create_delegation( return Err((StatusCode::BAD_REQUEST, "Weight must be between 0 and 1".to_string())); } + // Validate scope references to avoid DB constraint errors + match req.scope { + DelegationScope::Global => { + if req.community_id.is_some() || req.topic_id.is_some() || req.proposal_id.is_some() { + return Err((StatusCode::BAD_REQUEST, "Global delegation cannot include community/topic/proposal".to_string())); + } + } + DelegationScope::Community => { + if req.community_id.is_none() { + return Err((StatusCode::BAD_REQUEST, "Community delegation requires community_id".to_string())); + } + if req.topic_id.is_some() || req.proposal_id.is_some() { + return Err((StatusCode::BAD_REQUEST, "Community delegation cannot include topic_id/proposal_id".to_string())); + } + } + DelegationScope::Topic => { + if req.topic_id.is_none() { + return Err((StatusCode::BAD_REQUEST, "Topic delegation requires topic_id".to_string())); + } + if req.proposal_id.is_some() { + return Err((StatusCode::BAD_REQUEST, "Topic delegation cannot include proposal_id".to_string())); + } + } + DelegationScope::Proposal => { + if req.proposal_id.is_none() { + return Err((StatusCode::BAD_REQUEST, "Proposal delegation requires proposal_id".to_string())); + } + } + } + + // Validate referenced entities exist (best-effort) + if let Some(community_id) = req.community_id { + sqlx::query!("SELECT id FROM communities WHERE id = $1", community_id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Community not found".to_string()))?; + } + + if let Some(topic_id) = req.topic_id { + sqlx::query!("SELECT id FROM topics WHERE id = $1", topic_id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Topic not found".to_string()))?; + } + + if let Some(proposal_id) = req.proposal_id { + sqlx::query!("SELECT id FROM proposals WHERE id = $1", proposal_id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Proposal not found".to_string()))?; + } + // Check delegate exists let delegate = sqlx::query!("SELECT username FROM users WHERE id = $1", req.delegate_id) .fetch_optional(&pool) @@ -169,7 +225,7 @@ async fn create_delegation( AND (topic_id = $4 OR ($4 IS NULL AND topic_id IS NULL)) AND (proposal_id = $5 OR ($5 IS NULL AND proposal_id IS NULL))"#, auth.user_id, - req.scope.clone() as DelegationScope, + req.scope as DelegationScope, req.community_id, req.topic_id, req.proposal_id @@ -178,18 +234,19 @@ async fn create_delegation( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - // Create new delegation (weight stored as default, simplified for now) + // Create new delegation let delegation = sqlx::query!( - r#"INSERT INTO delegations (delegator_id, delegate_id, scope, community_id, topic_id, proposal_id) - VALUES ($1, $2, $3::delegation_scope, $4, $5, $6) + r#"INSERT INTO delegations (delegator_id, delegate_id, scope, community_id, topic_id, proposal_id, weight) + VALUES ($1, $2, $3::delegation_scope, $4, $5, $6, ($7::float8)::numeric) RETURNING id, delegator_id, delegate_id, scope as "scope: DelegationScope", - community_id, topic_id, proposal_id, is_active, created_at"#, + community_id, topic_id, proposal_id, weight::float8 as "weight!", is_active, created_at"#, auth.user_id, req.delegate_id, req.scope as DelegationScope, req.community_id, req.topic_id, - req.proposal_id + req.proposal_id, + req.weight ) .fetch_one(&pool) .await @@ -217,11 +274,25 @@ async fn create_delegation( .await .ok(); - // Update delegate's delegator count + // Update delegate's delegator count (avoid drift) sqlx::query!( r#"INSERT INTO delegate_profiles (user_id, total_delegators) - VALUES ($1, 1) - ON CONFLICT (user_id) DO UPDATE SET total_delegators = delegate_profiles.total_delegators + 1"#, + VALUES ($1, 0) + ON CONFLICT (user_id) DO NOTHING"#, + req.delegate_id + ) + .execute(&pool) + .await + .ok(); + + sqlx::query!( + r#"UPDATE delegate_profiles + SET total_delegators = ( + SELECT COUNT(*)::int + FROM delegations + WHERE delegate_id = $1 AND is_active = TRUE + ) + WHERE user_id = $1"#, req.delegate_id ) .execute(&pool) @@ -237,7 +308,7 @@ async fn create_delegation( community_id: delegation.community_id, topic_id: delegation.topic_id, proposal_id: delegation.proposal_id, - weight: req.weight, + weight: delegation.weight, is_active: delegation.is_active, created_at: delegation.created_at, })) @@ -254,7 +325,7 @@ async fn list_my_delegations( let delegations = sqlx::query!( r#"SELECT d.id, d.delegator_id, d.delegate_id, u.username as delegate_username, d.scope as "scope: DelegationScope", d.community_id, d.topic_id, - d.proposal_id, d.is_active, d.created_at + d.proposal_id, d.weight::float8 as "weight!", d.is_active, d.created_at FROM delegations d JOIN users u ON d.delegate_id = u.id WHERE d.delegator_id = $1 @@ -279,7 +350,7 @@ async fn list_my_delegations( community_id: d.community_id, topic_id: d.topic_id, proposal_id: d.proposal_id, - weight: 1.0, + weight: d.weight, is_active: d.is_active, created_at: d.created_at, } @@ -294,7 +365,7 @@ async fn list_delegations_to_me( let delegations = sqlx::query!( r#"SELECT d.id, d.delegator_id, d.delegate_id, u.username as delegator_username, d.scope as "scope: DelegationScope", d.community_id, d.topic_id, - d.proposal_id, d.is_active, d.created_at + d.proposal_id, d.weight::float8 as "weight!", d.is_active, d.created_at FROM delegations d JOIN users u ON d.delegator_id = u.id WHERE d.delegate_id = $1 AND d.is_active = TRUE @@ -315,7 +386,7 @@ async fn list_delegations_to_me( community_id: d.community_id, topic_id: d.topic_id, proposal_id: d.proposal_id, - weight: 1.0, + weight: d.weight, is_active: d.is_active, created_at: d.created_at, } @@ -357,9 +428,15 @@ async fn revoke_delegation( .await .ok(); - // Update delegate's delegator count + // Update delegate's delegator count (avoid drift) sqlx::query!( - "UPDATE delegate_profiles SET total_delegators = GREATEST(0, total_delegators - 1) WHERE user_id = $1", + r#"UPDATE delegate_profiles + SET total_delegators = ( + SELECT COUNT(*)::int + FROM delegations + WHERE delegate_id = $1 AND is_active = TRUE + ) + WHERE user_id = $1"#, result.delegate_id ) .execute(&pool) @@ -457,13 +534,18 @@ async fn list_delegates( Query(_query): Query, ) -> Result>, (StatusCode, String)> { let profiles = sqlx::query!( - r#"SELECT dp.user_id, u.username, dp.display_name, dp.bio, - dp.accepting_delegations, dp.delegation_policy, - dp.total_delegators, dp.total_votes_cast - FROM delegate_profiles dp - JOIN users u ON dp.user_id = u.id - WHERE dp.accepting_delegations = TRUE - ORDER BY dp.total_delegators DESC + r#"SELECT u.id as user_id, + u.username, + dp.display_name, + dp.bio, + COALESCE(dp.accepting_delegations, TRUE) as "accepting_delegations!", + dp.delegation_policy, + COALESCE(dp.total_delegators, 0) as "total_delegators!", + COALESCE(dp.total_votes_cast, 0) as "total_votes_cast!" + FROM users u + LEFT JOIN delegate_profiles dp ON dp.user_id = u.id + WHERE COALESCE(dp.accepting_delegations, TRUE) = TRUE + ORDER BY COALESCE(dp.total_delegators, 0) DESC LIMIT 50"# ) .fetch_all(&pool) diff --git a/backend/src/api/demo.rs b/backend/src/api/demo.rs index 95b1e1b..f748905 100644 --- a/backend/src/api/demo.rs +++ b/backend/src/api/demo.rs @@ -11,8 +11,10 @@ use serde_json::json; use sqlx::PgPool; use std::sync::Arc; +use crate::auth::AuthUser; use crate::config::Config; use crate::demo::{self, DEMO_ACCOUNTS}; +use super::permissions::{require_permission, perms}; /// Combined state for demo endpoints #[derive(Clone)] @@ -52,6 +54,7 @@ async fn get_demo_status( /// Reset demo data to initial state (only in demo mode) async fn reset_demo( State(state): State, + auth: AuthUser, ) -> impl IntoResponse { if !state.config.is_demo() { return ( @@ -60,6 +63,10 @@ async fn reset_demo( ).into_response(); } + if let Err((status, msg)) = require_permission(&state.pool, auth.user_id, perms::PLATFORM_ADMIN, None).await { + return (status, Json(json!({"error": msg}))).into_response(); + } + match demo::reset_demo_data(&state.pool).await { Ok(_) => ( StatusCode::OK, diff --git a/backend/src/api/moderation.rs b/backend/src/api/moderation.rs index d1e8b7c..1dec376 100644 --- a/backend/src/api/moderation.rs +++ b/backend/src/api/moderation.rs @@ -50,8 +50,8 @@ async fn list_moderation( r#" SELECT m.id, m.community_id, m.action_type, m.reason, m.details, m.created_at, - mod_user.username as moderator_username, - target_user.username as target_username + mod_user.username as "moderator_username?", + target_user.username as "target_username?" FROM moderation_log m LEFT JOIN users mod_user ON m.moderator_id = mod_user.id LEFT JOIN users target_user ON m.target_user_id = target_user.id @@ -70,8 +70,8 @@ async fn list_moderation( .map(|row| ModerationEntry { id: row.id, community_id: row.community_id, - moderator_username: Some(row.moderator_username), - target_username: Some(row.target_username), + moderator_username: row.moderator_username, + target_username: row.target_username, action_type: row.action_type, reason: row.reason, details: row.details, diff --git a/backend/src/api/permissions.rs b/backend/src/api/permissions.rs index fd8c66b..befd765 100644 --- a/backend/src/api/permissions.rs +++ b/backend/src/api/permissions.rs @@ -86,11 +86,11 @@ pub async fn is_community_staff( user_id: Uuid, community_id: Uuid, ) -> Result { - let is_admin = user_has_permission(pool, user_id, "community.admin", Some(community_id)).await?; + let is_admin = user_has_permission(pool, user_id, "community.settings", Some(community_id)).await?; if is_admin { return Ok(true); } - user_has_permission(pool, user_id, "community.moderate", Some(community_id)).await + user_has_permission(pool, user_id, "moderation.users.warn", Some(community_id)).await } /// Permission constants for common operations. @@ -99,33 +99,33 @@ pub mod perms { // Platform-level pub const PLATFORM_ADMIN: &str = "platform.admin"; pub const PLATFORM_SETTINGS: &str = "platform.settings"; - pub const PLATFORM_PLUGINS: &str = "platform.plugins"; + pub const PLATFORM_PLUGINS: &str = "plugins.configure"; // Community-level pub const COMMUNITY_CREATE: &str = "community.create"; - pub const COMMUNITY_ADMIN: &str = "community.admin"; + pub const COMMUNITY_ADMIN: &str = "community.settings"; pub const COMMUNITY_SETTINGS: &str = "community.settings"; - pub const COMMUNITY_MODERATE: &str = "community.moderate"; + pub const COMMUNITY_MODERATE: &str = "moderation.users.warn"; // Proposals - pub const PROPOSAL_CREATE: &str = "proposal.create"; - pub const PROPOSAL_EDIT_OWN: &str = "proposal.edit_own"; - pub const PROPOSAL_EDIT_ANY: &str = "proposal.edit_any"; - pub const PROPOSAL_DELETE_OWN: &str = "proposal.delete_own"; - pub const PROPOSAL_DELETE_ANY: &str = "proposal.delete_any"; - pub const PROPOSAL_MANAGE_STATUS: &str = "proposal.manage_status"; + pub const PROPOSAL_CREATE: &str = "proposals.create"; + pub const PROPOSAL_EDIT_OWN: &str = "proposals.edit.own"; + pub const PROPOSAL_EDIT_ANY: &str = "proposals.edit.any"; + pub const PROPOSAL_DELETE_OWN: &str = "proposals.delete.own"; + pub const PROPOSAL_DELETE_ANY: &str = "proposals.delete.any"; + pub const PROPOSAL_MANAGE_STATUS: &str = "proposals.moderate"; // Voting - pub const VOTE_CAST: &str = "vote.cast"; - pub const VOTE_VIEW_RESULTS: &str = "vote.view_results"; + pub const VOTE_CAST: &str = "voting.vote"; + pub const VOTE_VIEW_RESULTS: &str = "voting.results.view"; pub const VOTING_CONFIG: &str = "voting.configure"; // Moderation - pub const MOD_BAN_USERS: &str = "moderation.ban_users"; - pub const MOD_REMOVE_CONTENT: &str = "moderation.remove_content"; - pub const MOD_VIEW_REPORTS: &str = "moderation.view_reports"; + pub const MOD_BAN_USERS: &str = "platform.users.ban"; + pub const MOD_REMOVE_CONTENT: &str = "moderation.comments.delete"; + pub const MOD_VIEW_REPORTS: &str = "moderation.log.view"; // Users - pub const USER_MANAGE: &str = "users.manage"; - pub const USER_INVITE: &str = "users.invite"; + pub const USER_MANAGE: &str = "platform.users.manage"; + pub const USER_INVITE: &str = "community.members.invite"; } diff --git a/backend/src/api/plugins.rs b/backend/src/api/plugins.rs index eda0527..7458484 100644 --- a/backend/src/api/plugins.rs +++ b/backend/src/api/plugins.rs @@ -636,8 +636,10 @@ async fn update_community_plugin_package( })?; if !compiled.is_valid(settings) { - let errors = compiled.validate(settings).err().unwrap(); - let msgs: Vec = errors.take(5).map(|e| e.to_string()).collect(); + let msgs: Vec = match compiled.validate(settings) { + Ok(()) => vec!["Invalid settings".to_string()], + Err(errors) => errors.take(5).map(|e| e.to_string()).collect(), + }; return Err(( StatusCode::BAD_REQUEST, format!("Invalid settings: {}", msgs.join("; ")), @@ -732,8 +734,8 @@ async fn update_community_plugin_package( ) .await; - if req.settings.is_some() { - let keys = redacted_settings_keys(req.settings.as_ref().unwrap()); + if let Some(settings) = req.settings.as_ref() { + let keys = redacted_settings_keys(settings); let _ = sqlx::query!( r#"INSERT INTO public_events (community_id, actor_user_id, plugin_name, event_type, payload) VALUES ($1, $2, NULL, 'plugin.package_settings_updated', $3)"#, @@ -1373,8 +1375,10 @@ async fn update_community_plugin( })?; if !compiled.is_valid(settings) { - let errors = compiled.validate(settings).err().unwrap(); - let msgs: Vec = errors.take(5).map(|e| e.to_string()).collect(); + let msgs: Vec = match compiled.validate(settings) { + Ok(()) => vec!["Invalid settings".to_string()], + Err(errors) => errors.take(5).map(|e| e.to_string()).collect(), + }; return Err(( StatusCode::BAD_REQUEST, format!("Invalid settings: {}", msgs.join("; ")), @@ -1451,8 +1455,8 @@ async fn update_community_plugin( .await; } - if req.settings.is_some() { - let keys = redacted_settings_keys(req.settings.as_ref().unwrap()); + if let Some(settings) = req.settings.as_ref() { + let keys = redacted_settings_keys(settings); let _ = sqlx::query!( r#"INSERT INTO public_events (community_id, actor_user_id, plugin_name, event_type, payload) VALUES ($1, $2, $3, 'plugin.settings_updated', $4)"#, diff --git a/backend/src/api/settings.rs b/backend/src/api/settings.rs index e931ad3..65018a7 100644 --- a/backend/src/api/settings.rs +++ b/backend/src/api/settings.rs @@ -3,14 +3,17 @@ use axum::{ extract::{Path, State}, routing::{get, patch, post}, + Extension, Json, Router, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::PgPool; +use std::sync::Arc; use uuid::Uuid; use crate::auth::AuthUser; +use crate::config::Config; use super::permissions::{require_permission, require_any_permission, perms}; use axum::http::StatusCode; @@ -24,6 +27,17 @@ pub struct SetupStatus { pub instance_name: Option, } +#[derive(Debug, Serialize)] +pub struct PublicInstanceSettings { + pub setup_completed: bool, + pub instance_name: String, + pub platform_mode: String, + pub registration_enabled: bool, + pub registration_mode: String, + pub single_community_slug: Option, + pub single_community_name: Option, +} + #[derive(Debug, Serialize)] pub struct InstanceSettings { pub id: Uuid, @@ -105,6 +119,57 @@ async fn get_setup_status(State(pool): State) -> Result) -> Result, String> { + let row = sqlx::query!( + r#"SELECT setup_completed, instance_name, platform_mode, + registration_enabled, registration_mode, + single_community_id + FROM instance_settings + LIMIT 1"# + ) + .fetch_optional(&pool) + .await + .map_err(|e| e.to_string())?; + + let Some(r) = row else { + return Ok(Json(PublicInstanceSettings { + setup_completed: false, + instance_name: "Likwid".to_string(), + platform_mode: "open".to_string(), + registration_enabled: true, + registration_mode: "open".to_string(), + single_community_slug: None, + single_community_name: None, + })); + }; + + let mut single_community_slug: Option = None; + let mut single_community_name: Option = None; + + if let Some(cid) = r.single_community_id { + let comm = sqlx::query!("SELECT slug, name FROM communities WHERE id = $1", cid) + .fetch_optional(&pool) + .await + .map_err(|e| e.to_string())?; + + if let Some(c) = comm { + single_community_slug = Some(c.slug); + single_community_name = Some(c.name); + } + } + + Ok(Json(PublicInstanceSettings { + setup_completed: r.setup_completed, + instance_name: r.instance_name, + platform_mode: r.platform_mode, + registration_enabled: r.registration_enabled, + registration_mode: r.registration_mode, + single_community_slug, + single_community_name, + })) +} + /// Complete initial setup async fn complete_setup( State(pool): State, @@ -217,11 +282,19 @@ async fn get_instance_settings( async fn update_instance_settings( State(pool): State, auth: AuthUser, + Extension(config): Extension>, Json(req): Json, ) -> Result, (StatusCode, String)> { // Check platform settings permission require_permission(&pool, auth.user_id, perms::PLATFORM_SETTINGS, None).await?; + if config.is_demo() { + return Err(( + StatusCode::FORBIDDEN, + "Instance settings cannot be modified in demo mode".to_string(), + )); + } + let s = sqlx::query!( r#"UPDATE instance_settings SET instance_name = COALESCE($1, instance_name), @@ -348,6 +421,7 @@ async fn update_community_settings( pub fn router(pool: PgPool) -> Router { Router::new() + .route("/api/settings/public", get(get_public_settings)) .route("/api/settings/setup/status", get(get_setup_status)) .route("/api/settings/setup", post(complete_setup)) .route("/api/settings/instance", get(get_instance_settings)) diff --git a/backend/src/auth/middleware.rs b/backend/src/auth/middleware.rs index d14046f..48a52ca 100644 --- a/backend/src/auth/middleware.rs +++ b/backend/src/auth/middleware.rs @@ -2,9 +2,11 @@ use axum::{ extract::FromRequestParts, http::{request::Parts, StatusCode}, }; +use std::sync::Arc; use uuid::Uuid; use super::jwt::{verify_token, Claims}; +use crate::config::Config; pub struct AuthUser { pub user_id: Uuid, @@ -28,7 +30,12 @@ where .strip_prefix("Bearer ") .ok_or((StatusCode::UNAUTHORIZED, "Invalid authorization header format"))?; - let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret-change-in-production".to_string()); + let secret = parts + .extensions + .get::>() + .map(|c| c.jwt_secret.clone()) + .or_else(|| std::env::var("JWT_SECRET").ok()) + .unwrap_or_else(|| "dev-secret-change-in-production".to_string()); let claims: Claims = verify_token(token, &secret) .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token"))?; diff --git a/backend/src/config/mod.rs b/backend/src/config/mod.rs index 9d0bb85..8b3db52 100644 --- a/backend/src/config/mod.rs +++ b/backend/src/config/mod.rs @@ -14,7 +14,7 @@ pub struct Config { } fn default_jwt_secret() -> String { - "change-me-in-production".to_string() + "".to_string() } impl Config { diff --git a/backend/src/demo/mod.rs b/backend/src/demo/mod.rs index b50929b..e97f680 100644 --- a/backend/src/demo/mod.rs +++ b/backend/src/demo/mod.rs @@ -30,35 +30,379 @@ pub fn verify_demo_password(username: &str, password: &str) -> bool { /// This clears user-created data and reloads the seed data pub async fn reset_demo_data(pool: &PgPool) -> Result<(), sqlx::Error> { tracing::info!("Resetting demo data to initial state..."); - - // Delete data that might have been modified (in reverse dependency order) - // Keep the demo users and communities, but reset their state - - // Clear votes - sqlx::query("DELETE FROM votes WHERE proposal_id IN (SELECT id FROM proposals WHERE community_id IN (SELECT id FROM communities WHERE slug IN ('aurora', 'civic-commons', 'makers')))") - .execute(pool) + + let mut tx = pool.begin().await?; + + // Remove volatile/user-generated data + sqlx::query("DELETE FROM public_events") + .execute(&mut *tx) .await?; - - // Clear delegated votes - sqlx::query("DELETE FROM delegated_votes WHERE proposal_id IN (SELECT id FROM proposals WHERE community_id IN (SELECT id FROM communities WHERE slug IN ('aurora', 'civic-commons', 'makers')))") - .execute(pool) + + sqlx::query("DELETE FROM notifications") + .execute(&mut *tx) .await?; - - // Clear comments added after seed (keep seed comments) - sqlx::query("DELETE FROM comments WHERE id::text NOT LIKE 'com00001-%'") - .execute(pool) + + // Deliberation system + sqlx::query("DELETE FROM comment_reactions") + .execute(&mut *tx) .await?; - - // Reset proposal statuses for active proposals - sqlx::query("UPDATE proposals SET status = 'voting', voting_ends_at = NOW() + INTERVAL '5 days' WHERE id = 'p0000001-0000-0000-0000-000000000002'::uuid") - .execute(pool) + sqlx::query("DELETE FROM proposal_positions") + .execute(&mut *tx) .await?; - sqlx::query("UPDATE proposals SET status = 'voting', voting_ends_at = NOW() + INTERVAL '5 days' WHERE id = 'p0000001-0000-0000-0000-000000000005'::uuid") - .execute(pool) + sqlx::query("DELETE FROM discussion_group_members") + .execute(&mut *tx) .await?; - sqlx::query("UPDATE proposals SET status = 'voting', voting_ends_at = NOW() + INTERVAL '5 days' WHERE id = 'p0000001-0000-0000-0000-000000000007'::uuid") - .execute(pool) + sqlx::query("DELETE FROM discussion_groups") + .execute(&mut *tx) .await?; + sqlx::query("DELETE FROM proposal_resource_reads") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM proposal_resources") + .execute(&mut *tx) + .await?; + + // Decision workflow plugin + sqlx::query("DELETE FROM quorum_snapshots") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM phase_participation") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM workflow_transitions") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM phase_instances") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM workflow_instances") + .execute(&mut *tx) + .await?; + + // Self-moderation rules plugin + sqlx::query("DELETE FROM sanctions") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM rule_violations") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM community_rules") + .execute(&mut *tx) + .await?; + + // Conflict resolution plugin + sqlx::query("DELETE FROM conflict_mediators") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM mediation_notes") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM mediation_sessions") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM compromise_proposals") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM conflict_cases") + .execute(&mut *tx) + .await?; + + // Structured deliberation plugin + sqlx::query("DELETE FROM argument_votes") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM deliberation_arguments") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM summary_edit_history") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM deliberation_summaries") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM deliberation_reading_log") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM deliberation_metrics") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM facilitation_prompts") + .execute(&mut *tx) + .await?; + + // Public data export plugin (preserve export_data_dictionary baseline) + sqlx::query("DELETE FROM export_audit_log") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM scheduled_exports") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM export_jobs") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM export_configurations") + .execute(&mut *tx) + .await?; + + // Federation plugin + sqlx::query("DELETE FROM federated_decisions") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM federated_proposals") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM federation_sync_log") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM federation_requests") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM community_federations") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM federated_instances") + .execute(&mut *tx) + .await?; + + // Governance analytics plugin + sqlx::query("DELETE FROM governance_health_indicators") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM voting_method_analytics") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM decision_load_metrics") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM delegation_analytics") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM participation_snapshots") + .execute(&mut *tx) + .await?; + + // Plugin runtime storage/events + sqlx::query("DELETE FROM plugin_events") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM plugin_kv_store") + .execute(&mut *tx) + .await?; + + // Third-party plugin packages (uploaded/registry) + sqlx::query("DELETE FROM community_plugin_packages") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM plugin_packages") + .execute(&mut *tx) + .await?; + + // Plugin registry auxiliary tables (preserve plugins + plugin_capabilities baseline) + sqlx::query("DELETE FROM plugin_reviews") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM plugin_installs") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM plugin_versions") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM plugin_files") + .execute(&mut *tx) + .await?; + + // GitLab integration plugin + sqlx::query("DELETE FROM gitlab_webhook_events") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM gitlab_merge_requests") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM gitlab_issues") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM gitlab_connections") + .execute(&mut *tx) + .await?; + + // Proposal lifecycle plugin + sqlx::query("DELETE FROM amendment_support") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM proposal_amendments") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM proposal_forks") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM proposal_status_transitions") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM proposal_versions") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM proposal_lifecycle") + .execute(&mut *tx) + .await?; + + // Moderation log: delete user-generated entries but keep seeded baseline. + // We match on the known seeded rows by content so existing demo DBs remain safe + // even before the deterministic-ID demo migration has been applied. + sqlx::query( + "DELETE FROM moderation_log WHERE NOT (\ + (community_id = 'c0000001-0000-0000-0000-000000000001'::uuid AND moderator_id = 'd0000001-0000-0000-0000-000000000002'::uuid AND target_user_id IS NULL AND action_type = 'content_edit' AND reason = 'Updated RFC-001 description for clarity' AND details = '{\"proposal_id\": \"b0000001-0000-0000-0000-000000000001\", \"field\": \"description\", \"change_type\": \"formatting\"}'::jsonb)\ + OR (community_id = 'c0000001-0000-0000-0000-000000000002'::uuid AND moderator_id = 'd0000001-0000-0000-0000-000000000002'::uuid AND target_user_id = 'd0000002-0000-0000-0000-000000000009'::uuid AND action_type = 'warning' AND reason = 'Off-topic discussion in budget proposal thread' AND details = '{\"rule\": \"community_guidelines_3\", \"comment_id\": \"comment_example_001\"}'::jsonb)\ + OR (community_id = 'c0000001-0000-0000-0000-000000000003'::uuid AND moderator_id = 'd0000001-0000-0000-0000-000000000002'::uuid AND target_user_id IS NULL AND action_type = 'proposal_extended' AND reason = 'Extended voting deadline by 48 hours due to technical issues' AND details = '{\"proposal_id\": \"b0000001-0000-0000-0000-000000000006\", \"original_end\": \"2026-01-10T00:00:00Z\", \"new_end\": \"2026-01-12T00:00:00Z\"}'::jsonb)\ + )", + ) + .execute(&mut *tx) + .await?; + + // Moderation ledger is immutable by design, but demo reset must clear any accumulated entries. + // This is scoped to this transaction only. + sqlx::query("SET LOCAL likwid.allow_ledger_delete = 'true'") + .execute(&mut *tx) + .await?; + sqlx::query("SET LOCAL likwid.deletion_reason = 'demo reset'") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM moderation_ledger") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM ledger_deletion_log") + .execute(&mut *tx) + .await?; + + // Clear approval workflow queues + sqlx::query("DELETE FROM pending_registrations") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM pending_communities") + .execute(&mut *tx) + .await?; + + // Invitations: must null out users.invited_by first + sqlx::query("UPDATE users SET invited_by = NULL WHERE invited_by IS NOT NULL") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM invitation_uses") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM invitations") + .execute(&mut *tx) + .await?; + + // Votes and delegation artifacts + sqlx::query("DELETE FROM votes") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM delegated_votes") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM delegation_chains") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM delegation_log") + .execute(&mut *tx) + .await?; + + // Remove delegations that are not part of the seeded demo baseline + sqlx::query("DELETE FROM delegations WHERE id::text NOT LIKE 'de000001-%'") + .execute(&mut *tx) + .await?; + + // Remove comments that are not part of the seeded demo baseline + sqlx::query("DELETE FROM comments WHERE id::text NOT LIKE 'cc000001-%'") + .execute(&mut *tx) + .await?; + + // Remove user-created proposals/options in demo communities (keep seeded b0000001/e0000001) + sqlx::query( + "DELETE FROM proposals WHERE community_id IN (SELECT id FROM communities WHERE id::text LIKE 'c0000001-%') AND id::text NOT LIKE 'b0000001-%'", + ) + .execute(&mut *tx) + .await?; + + // Topics created by users in demo communities (keep seeded a0000001) + sqlx::query( + "DELETE FROM topics WHERE community_id IN (SELECT id FROM communities WHERE id::text LIKE 'c0000001-%') AND id::text NOT LIKE 'a0000001-%'", + ) + .execute(&mut *tx) + .await?; + + // Remove non-seeded communities (also deletes proposals, members, etc via CASCADE) + sqlx::query("DELETE FROM communities WHERE id::text NOT LIKE 'c0000001-%'") + .execute(&mut *tx) + .await?; + + // Remove non-seeded users (removes memberships/roles/delegations via CASCADE) + sqlx::query("DELETE FROM users WHERE id::text NOT LIKE 'd000%'") + .execute(&mut *tx) + .await?; + + // Reset instance-level plugin toggles to default behavior + sqlx::query("DELETE FROM instance_plugins") + .execute(&mut *tx) + .await?; + + // Reset seeded delegations to their initial state + sqlx::query( + "UPDATE delegations SET is_active = TRUE, revoked_at = NULL WHERE id::text IN ('de000001-0000-0000-0000-000000000001','de000001-0000-0000-0000-000000000002','de000001-0000-0000-0000-000000000003')", + ) + .execute(&mut *tx) + .await?; + sqlx::query( + "UPDATE delegations SET is_active = FALSE, revoked_at = NOW() - INTERVAL '20 days' WHERE id::text = 'de000001-0000-0000-0000-000000000004'", + ) + .execute(&mut *tx) + .await?; + + // Reset proposal statuses/timers for seeded active proposals + sqlx::query("UPDATE proposals SET status = 'closed', voting_starts_at = NOW() - INTERVAL '30 days', voting_ends_at = NOW() - INTERVAL '23 days' WHERE id = 'b0000001-0000-0000-0000-000000000001'::uuid") + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE proposals SET status = 'voting', voting_starts_at = NOW() - INTERVAL '2 days', voting_ends_at = NOW() + INTERVAL '5 days' WHERE id = 'b0000001-0000-0000-0000-000000000002'::uuid") + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE proposals SET status = 'discussion', voting_starts_at = NULL, voting_ends_at = NULL WHERE id = 'b0000001-0000-0000-0000-000000000003'::uuid") + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE proposals SET status = 'closed', voting_starts_at = NOW() - INTERVAL '60 days', voting_ends_at = NOW() - INTERVAL '53 days' WHERE id = 'b0000001-0000-0000-0000-000000000004'::uuid") + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE proposals SET status = 'voting', voting_starts_at = NOW() - INTERVAL '1 day', voting_ends_at = NOW() + INTERVAL '6 days' WHERE id = 'b0000001-0000-0000-0000-000000000005'::uuid") + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE proposals SET status = 'closed', voting_starts_at = NOW() - INTERVAL '45 days', voting_ends_at = NOW() - INTERVAL '38 days' WHERE id = 'b0000001-0000-0000-0000-000000000006'::uuid") + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE proposals SET status = 'voting', voting_starts_at = NOW() - INTERVAL '3 days', voting_ends_at = NOW() + INTERVAL '4 days' WHERE id = 'b0000001-0000-0000-0000-000000000007'::uuid") + .execute(&mut *tx) + .await?; + + // Re-initialize proposal_lifecycle rows for remaining proposals. + // This ensures the lifecycle API doesn't break after we clear lifecycle tables. + sqlx::query( + r#" + INSERT INTO proposal_lifecycle (proposal_id, current_status) + SELECT + p.id, + CASE p.status + WHEN 'draft' THEN 'draft'::proposal_lifecycle_status + WHEN 'discussion' THEN 'active'::proposal_lifecycle_status + WHEN 'voting' THEN 'voting'::proposal_lifecycle_status + WHEN 'closed' THEN 'archived'::proposal_lifecycle_status + WHEN 'archived' THEN 'archived'::proposal_lifecycle_status + ELSE 'draft'::proposal_lifecycle_status + END + FROM proposals p + ON CONFLICT (proposal_id) DO NOTHING + "#, + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; tracing::info!("Demo data reset complete"); Ok(()) diff --git a/backend/src/main.rs b/backend/src/main.rs index d1630d6..54c439a 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use axum::Extension; use chrono::{Datelike, Timelike, Utc, Weekday}; use serde_json::json; +use thiserror::Error; use tower_http::cors::{Any, CorsLayer}; use tower_http::trace::TraceLayer; use uuid::Uuid; @@ -19,33 +20,65 @@ use uuid::Uuid; use crate::config::Config; use crate::plugins::HookContext; +#[derive(Debug, Error)] +enum StartupError { + #[error("Failed to load configuration: {0}")] + Config(#[from] envy::Error), + #[error("JWT_SECRET must be set")] + MissingJwtSecret, + #[error("Failed to create database pool: {0}")] + Db(#[from] sqlx::Error), + #[error("Failed to run database migrations: {0}")] + Migrations(#[from] sqlx::migrate::MigrateError), + #[error("Failed to initialize plugins: {0}")] + Plugins(#[from] crate::plugins::PluginError), + #[error("Failed to bind server listener: {0}")] + Bind(#[from] std::io::Error), + #[error("Server error: {0}")] + Serve(String), +} + #[tokio::main] async fn main() { tracing_subscriber::fmt::init(); + if let Err(e) = run().await { + tracing::error!("{e}"); + std::process::exit(1); + } +} + +async fn run() -> Result<(), StartupError> { dotenvy::dotenv().ok(); // Load configuration - let config = Config::from_env().unwrap_or_default(); - let config = Arc::new(config); + let config = Arc::new(Config::from_env()?); + + if config.jwt_secret.trim().is_empty() { + return Err(StartupError::MissingJwtSecret); + } if config.is_demo() { 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 - .expect("Failed to create database pool"); + let pool = db::create_pool(&database_url).await?; tracing::info!("Connected to database"); - sqlx::migrate!("./migrations") - .run(&pool) - .await - .expect("Failed to run database migrations"); + let mut migrator = sqlx::migrate!("./migrations"); + if config.is_demo() { + migrator.set_ignore_missing(true); + } + migrator.run(&pool).await?; + + if config.is_demo() { + let mut demo_migrator = sqlx::migrate!("./migrations_demo"); + demo_migrator.set_ignore_missing(true); + demo_migrator.run(&pool).await?; + } let cors = CorsLayer::new() .allow_origin(Any) @@ -55,8 +88,7 @@ async fn main() { let plugins = plugins::PluginManager::new(pool.clone()) .register_builtin_plugins() .initialize() - .await - .expect("Failed to initialize plugins"); + .await?; { let cron_plugins = plugins.clone(); @@ -180,6 +212,10 @@ async fn main() { let addr = SocketAddr::from((host, config.server_port)); tracing::info!("Likwid backend listening on http://{}", addr); - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app) + .await + .map_err(|e| StartupError::Serve(e.to_string()))?; + + Ok(()) } diff --git a/backend/src/plugins/builtin/conflict_resolution.rs b/backend/src/plugins/builtin/conflict_resolution.rs index 0d25543..ed78085 100644 --- a/backend/src/plugins/builtin/conflict_resolution.rs +++ b/backend/src/plugins/builtin/conflict_resolution.rs @@ -153,6 +153,7 @@ impl ConflictService { } /// Report a new conflict + #[allow(clippy::too_many_arguments)] pub async fn report_conflict( pool: &PgPool, community_id: Uuid, diff --git a/backend/src/plugins/builtin/moderation_ledger.rs b/backend/src/plugins/builtin/moderation_ledger.rs index fd00729..508748c 100644 --- a/backend/src/plugins/builtin/moderation_ledger.rs +++ b/backend/src/plugins/builtin/moderation_ledger.rs @@ -133,6 +133,7 @@ pub struct LedgerService; impl LedgerService { /// Create a new ledger entry + #[allow(clippy::too_many_arguments)] pub async fn create_entry( pool: &PgPool, community_id: Option, diff --git a/backend/src/plugins/manager.rs b/backend/src/plugins/manager.rs index 08aa934..c049586 100644 --- a/backend/src/plugins/manager.rs +++ b/backend/src/plugins/manager.rs @@ -573,6 +573,7 @@ impl PluginManager { } } + #[allow(clippy::too_many_arguments)] pub async fn handle_community_plugin_change( &self, community_id: Uuid, @@ -629,6 +630,7 @@ impl PluginManager { } } + #[allow(clippy::too_many_arguments)] pub async fn handle_community_plugin_package_change( &self, community_id: Uuid, diff --git a/backend/src/plugins/wasm/host_api.rs b/backend/src/plugins/wasm/host_api.rs index 0875f4d..49d5770 100644 --- a/backend/src/plugins/wasm/host_api.rs +++ b/backend/src/plugins/wasm/host_api.rs @@ -122,13 +122,21 @@ pub struct ResultBuffer { impl HostStateWithLimits { /// Store a result in the result buffer and return its length pub fn store_result(&mut self, data: &[u8]) -> u32 { - self.inner.result_buffer.lock().unwrap().data = data.to_vec(); + let mut guard = match self.inner.result_buffer.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + guard.data = data.to_vec(); data.len() as u32 } /// Get the result buffer contents pub fn get_result(&self) -> Vec { - self.inner.result_buffer.lock().unwrap().data.clone() + let guard = match self.inner.result_buffer.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + guard.data.clone() } } @@ -188,7 +196,7 @@ pub fn register_host_functions(linker: &mut Linker) -> Resu let package_id = state.inner.package_id; let pool = state.inner.pool.clone(); - let fetched: Value = match block_in_place(|| { + let fetched: Value = block_in_place(|| { Handle::current().block_on(async { if let Some(cid) = community_id { // First: per-community WASM package settings. @@ -223,9 +231,7 @@ pub fn register_host_functions(linker: &mut Linker) -> Resu v }) - }) { - v => v, - }; + }); let result = serde_json::to_string(&fetched).unwrap_or("null".to_string()); let len = caller.data_mut().store_result(result.as_bytes()); @@ -466,10 +472,8 @@ pub fn register_host_functions(linker: &mut Linker) -> Resu } let mut body_buf = vec![0u8; body_len as usize]; - if body_len > 0 { - if memory.read(&caller, body_ptr as usize, &mut body_buf).is_err() { - return pack_result(8, 0); - } + if body_len > 0 && memory.read(&caller, body_ptr as usize, &mut body_buf).is_err() { + return pack_result(8, 0); } let url = match String::from_utf8(url_buf) { diff --git a/backend/src/voting/mod.rs b/backend/src/voting/mod.rs index b029450..3c81be8 100644 --- a/backend/src/voting/mod.rs +++ b/backend/src/voting/mod.rs @@ -17,10 +17,11 @@ use uuid::Uuid; /// Voting method types /// Used by voting calculation services when tallying results. #[allow(dead_code)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum VotingMethod { /// Simple approval voting (vote for multiple options) + #[default] Approval, /// Ranked choice / instant runoff RankedChoice, @@ -32,12 +33,6 @@ pub enum VotingMethod { Quadratic, } -impl Default for VotingMethod { - fn default() -> Self { - Self::Approval - } -} - impl std::fmt::Display for VotingMethod { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/backend/src/voting/ranked_choice.rs b/backend/src/voting/ranked_choice.rs index 3d358d1..1d956a3 100644 --- a/backend/src/voting/ranked_choice.rs +++ b/backend/src/voting/ranked_choice.rs @@ -44,7 +44,9 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult { // Find first choice among active options for opt in &ballot.rankings { if active_options.contains(opt) { - *vote_counts.get_mut(opt).unwrap() += 1; + if let Some(c) = vote_counts.get_mut(opt) { + *c += 1; + } break; } } @@ -78,7 +80,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult { .collect(); // Add eliminated options at the end (in reverse elimination order) - for (_i, &opt) in eliminated.iter().rev().enumerate() { + for &opt in eliminated.iter().rev() { final_ranking.push(RankedOption { option_id: opt, rank: final_ranking.len() + 1, @@ -117,7 +119,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult { }) .collect(); - for (_i, &opt) in eliminated.iter().rev().enumerate() { + for &opt in eliminated.iter().rev() { final_ranking.push(RankedOption { option_id: opt, rank: final_ranking.len() + 1, diff --git a/backend/src/voting/star.rs b/backend/src/voting/star.rs index bb3c991..d0c6dbe 100644 --- a/backend/src/voting/star.rs +++ b/backend/src/voting/star.rs @@ -109,12 +109,10 @@ pub fn calculate(options: &[Uuid], ballots: &[ScoreBallot]) -> VotingResult { }).collect(); // Adjust ranking for runoff result (swap if needed) - if winner == finalist_b { - if ranking.len() >= 2 { - ranking[0].rank = 2; - ranking[1].rank = 1; - ranking.swap(0, 1); - } + if winner == finalist_b && ranking.len() >= 2 { + ranking[0].rank = 2; + ranking[1].rank = 1; + ranking.swap(0, 1); } VotingResult {