backend: delegation, moderation, and voting improvements

This commit is contained in:
Marco Allegretti 2026-01-29 00:46:43 +01:00
parent f3a5edd91d
commit 89a6e9eaa7
20 changed files with 800 additions and 127 deletions

View file

@ -125,6 +125,60 @@ async fn review_registration(
} }
})?; })?;
if let Some(new_user_id) = result {
let user_role_id: Option<Uuid> = 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<Uuid> = 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 { Ok(Json(ReviewResponse {
success: true, success: true,
created_id: result, created_id: result,

View file

@ -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 serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
@ -41,13 +41,15 @@ pub struct UserInfo {
pub struct MeResponse { pub struct MeResponse {
pub id: Uuid, pub id: Uuid,
pub username: String, pub username: String,
pub email: String,
pub display_name: Option<String>,
} }
pub fn router(pool: PgPool) -> Router { pub fn router(pool: PgPool) -> Router {
Router::new() Router::new()
.route("/api/auth/register", post(register)) .route("/api/auth/register", post(register))
.route("/api/auth/login", post(login)) .route("/api/auth/login", post(login))
.route("/api/auth/me", post(me)) .route("/api/auth/me", get(me).post(me))
.with_state(pool) .with_state(pool)
} }
@ -187,6 +189,49 @@ async fn register(
} }
})?; })?;
// Assign default platform role(s)
let user_role_id: Option<Uuid> = 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<Uuid> = 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) // 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!("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<MeResponse> { async fn me(
Json(MeResponse { auth: AuthUser,
id: auth.user_id, State(pool): State<PgPool>,
username: auth.username, ) -> Result<Json<MeResponse>, (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,
}))
} }

View file

@ -284,7 +284,10 @@ async fn leave_community(
return Ok(Json(serde_json::json!({"status": "not_member"}))); 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" { if role == "admin" {
let admin_count = sqlx::query_scalar!( let admin_count = sqlx::query_scalar!(

View file

@ -21,6 +21,7 @@ use crate::auth::AuthUser;
// ============================================================================ // ============================================================================
#[derive(Debug, Clone, Copy, Serialize, Deserialize, sqlx::Type)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, sqlx::Type)]
#[serde(rename_all = "lowercase")]
#[sqlx(type_name = "delegation_scope", rename_all = "lowercase")] #[sqlx(type_name = "delegation_scope", rename_all = "lowercase")]
pub enum DelegationScope { pub enum DelegationScope {
Global, Global,
@ -136,6 +137,61 @@ async fn create_delegation(
return Err((StatusCode::BAD_REQUEST, "Weight must be between 0 and 1".to_string())); return Err((StatusCode::BAD_REQUEST, "Weight must be between 0 and 1".to_string()));
} }
// Validate scope references to avoid DB constraint errors
match req.scope {
DelegationScope::Global => {
if req.community_id.is_some() || req.topic_id.is_some() || req.proposal_id.is_some() {
return Err((StatusCode::BAD_REQUEST, "Global delegation cannot include community/topic/proposal".to_string()));
}
}
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 // Check delegate exists
let delegate = sqlx::query!("SELECT username FROM users WHERE id = $1", req.delegate_id) let delegate = sqlx::query!("SELECT username FROM users WHERE id = $1", req.delegate_id)
.fetch_optional(&pool) .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 (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))"#, AND (proposal_id = $5 OR ($5 IS NULL AND proposal_id IS NULL))"#,
auth.user_id, auth.user_id,
req.scope.clone() as DelegationScope, req.scope as DelegationScope,
req.community_id, req.community_id,
req.topic_id, req.topic_id,
req.proposal_id req.proposal_id
@ -178,18 +234,19 @@ async fn create_delegation(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .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!( let delegation = sqlx::query!(
r#"INSERT INTO delegations (delegator_id, delegate_id, scope, community_id, topic_id, proposal_id) 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) VALUES ($1, $2, $3::delegation_scope, $4, $5, $6, ($7::float8)::numeric)
RETURNING id, delegator_id, delegate_id, scope as "scope: DelegationScope", 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, auth.user_id,
req.delegate_id, req.delegate_id,
req.scope as DelegationScope, req.scope as DelegationScope,
req.community_id, req.community_id,
req.topic_id, req.topic_id,
req.proposal_id req.proposal_id,
req.weight
) )
.fetch_one(&pool) .fetch_one(&pool)
.await .await
@ -217,11 +274,25 @@ async fn create_delegation(
.await .await
.ok(); .ok();
// Update delegate's delegator count // Update delegate's delegator count (avoid drift)
sqlx::query!( sqlx::query!(
r#"INSERT INTO delegate_profiles (user_id, total_delegators) r#"INSERT INTO delegate_profiles (user_id, total_delegators)
VALUES ($1, 1) VALUES ($1, 0)
ON CONFLICT (user_id) DO UPDATE SET total_delegators = delegate_profiles.total_delegators + 1"#, 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 req.delegate_id
) )
.execute(&pool) .execute(&pool)
@ -237,7 +308,7 @@ async fn create_delegation(
community_id: delegation.community_id, community_id: delegation.community_id,
topic_id: delegation.topic_id, topic_id: delegation.topic_id,
proposal_id: delegation.proposal_id, proposal_id: delegation.proposal_id,
weight: req.weight, weight: delegation.weight,
is_active: delegation.is_active, is_active: delegation.is_active,
created_at: delegation.created_at, created_at: delegation.created_at,
})) }))
@ -254,7 +325,7 @@ async fn list_my_delegations(
let delegations = sqlx::query!( let delegations = sqlx::query!(
r#"SELECT d.id, d.delegator_id, d.delegate_id, u.username as delegate_username, 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.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 FROM delegations d
JOIN users u ON d.delegate_id = u.id JOIN users u ON d.delegate_id = u.id
WHERE d.delegator_id = $1 WHERE d.delegator_id = $1
@ -279,7 +350,7 @@ async fn list_my_delegations(
community_id: d.community_id, community_id: d.community_id,
topic_id: d.topic_id, topic_id: d.topic_id,
proposal_id: d.proposal_id, proposal_id: d.proposal_id,
weight: 1.0, weight: d.weight,
is_active: d.is_active, is_active: d.is_active,
created_at: d.created_at, created_at: d.created_at,
} }
@ -294,7 +365,7 @@ async fn list_delegations_to_me(
let delegations = sqlx::query!( let delegations = sqlx::query!(
r#"SELECT d.id, d.delegator_id, d.delegate_id, u.username as delegator_username, 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.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 FROM delegations d
JOIN users u ON d.delegator_id = u.id JOIN users u ON d.delegator_id = u.id
WHERE d.delegate_id = $1 AND d.is_active = TRUE 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, community_id: d.community_id,
topic_id: d.topic_id, topic_id: d.topic_id,
proposal_id: d.proposal_id, proposal_id: d.proposal_id,
weight: 1.0, weight: d.weight,
is_active: d.is_active, is_active: d.is_active,
created_at: d.created_at, created_at: d.created_at,
} }
@ -357,9 +428,15 @@ async fn revoke_delegation(
.await .await
.ok(); .ok();
// Update delegate's delegator count // Update delegate's delegator count (avoid drift)
sqlx::query!( 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 result.delegate_id
) )
.execute(&pool) .execute(&pool)
@ -457,13 +534,18 @@ async fn list_delegates(
Query(_query): Query<ListDelegationsQuery>, Query(_query): Query<ListDelegationsQuery>,
) -> Result<Json<Vec<DelegateProfile>>, (StatusCode, String)> { ) -> Result<Json<Vec<DelegateProfile>>, (StatusCode, String)> {
let profiles = sqlx::query!( let profiles = sqlx::query!(
r#"SELECT dp.user_id, u.username, dp.display_name, dp.bio, r#"SELECT u.id as user_id,
dp.accepting_delegations, dp.delegation_policy, u.username,
dp.total_delegators, dp.total_votes_cast dp.display_name,
FROM delegate_profiles dp dp.bio,
JOIN users u ON dp.user_id = u.id COALESCE(dp.accepting_delegations, TRUE) as "accepting_delegations!",
WHERE dp.accepting_delegations = TRUE dp.delegation_policy,
ORDER BY dp.total_delegators DESC 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"# LIMIT 50"#
) )
.fetch_all(&pool) .fetch_all(&pool)

View file

@ -11,8 +11,10 @@ use serde_json::json;
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
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)]
@ -52,6 +54,7 @@ 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>, State(state): State<DemoState>,
auth: AuthUser,
) -> impl IntoResponse { ) -> impl IntoResponse {
if !state.config.is_demo() { if !state.config.is_demo() {
return ( return (
@ -60,6 +63,10 @@ async fn reset_demo(
).into_response(); ).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 { match demo::reset_demo_data(&state.pool).await {
Ok(_) => ( Ok(_) => (
StatusCode::OK, StatusCode::OK,

View file

@ -50,8 +50,8 @@ async fn list_moderation(
r#" r#"
SELECT SELECT
m.id, m.community_id, m.action_type, m.reason, m.details, m.created_at, m.id, m.community_id, m.action_type, m.reason, m.details, m.created_at,
mod_user.username as moderator_username, mod_user.username as "moderator_username?",
target_user.username as target_username target_user.username as "target_username?"
FROM moderation_log m FROM moderation_log m
LEFT JOIN users mod_user ON m.moderator_id = mod_user.id 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 LEFT JOIN users target_user ON m.target_user_id = target_user.id
@ -70,8 +70,8 @@ async fn list_moderation(
.map(|row| ModerationEntry { .map(|row| ModerationEntry {
id: row.id, id: row.id,
community_id: row.community_id, community_id: row.community_id,
moderator_username: Some(row.moderator_username), moderator_username: row.moderator_username,
target_username: Some(row.target_username), target_username: row.target_username,
action_type: row.action_type, action_type: row.action_type,
reason: row.reason, reason: row.reason,
details: row.details, details: row.details,

View file

@ -86,11 +86,11 @@ 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.admin", 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);
} }
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. /// Permission constants for common operations.
@ -99,33 +99,33 @@ pub mod perms {
// Platform-level // Platform-level
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 = "platform.plugins"; 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.admin"; 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 = "community.moderate"; pub const COMMUNITY_MODERATE: &str = "moderation.users.warn";
// Proposals // Proposals
pub const PROPOSAL_CREATE: &str = "proposal.create"; pub const PROPOSAL_CREATE: &str = "proposals.create";
pub const PROPOSAL_EDIT_OWN: &str = "proposal.edit_own"; pub const PROPOSAL_EDIT_OWN: &str = "proposals.edit.own";
pub const PROPOSAL_EDIT_ANY: &str = "proposal.edit_any"; pub const PROPOSAL_EDIT_ANY: &str = "proposals.edit.any";
pub const PROPOSAL_DELETE_OWN: &str = "proposal.delete_own"; pub const PROPOSAL_DELETE_OWN: &str = "proposals.delete.own";
pub const PROPOSAL_DELETE_ANY: &str = "proposal.delete_any"; pub const PROPOSAL_DELETE_ANY: &str = "proposals.delete.any";
pub const PROPOSAL_MANAGE_STATUS: &str = "proposal.manage_status"; pub const PROPOSAL_MANAGE_STATUS: &str = "proposals.moderate";
// Voting // Voting
pub const VOTE_CAST: &str = "vote.cast"; pub const VOTE_CAST: &str = "voting.vote";
pub const VOTE_VIEW_RESULTS: &str = "vote.view_results"; 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 = "moderation.ban_users"; pub const MOD_BAN_USERS: &str = "platform.users.ban";
pub const MOD_REMOVE_CONTENT: &str = "moderation.remove_content"; pub const MOD_REMOVE_CONTENT: &str = "moderation.comments.delete";
pub const MOD_VIEW_REPORTS: &str = "moderation.view_reports"; pub const MOD_VIEW_REPORTS: &str = "moderation.log.view";
// Users // Users
pub const USER_MANAGE: &str = "users.manage"; pub const USER_MANAGE: &str = "platform.users.manage";
pub const USER_INVITE: &str = "users.invite"; pub const USER_INVITE: &str = "community.members.invite";
} }

View file

@ -636,8 +636,10 @@ async fn update_community_plugin_package(
})?; })?;
if !compiled.is_valid(settings) { if !compiled.is_valid(settings) {
let errors = compiled.validate(settings).err().unwrap(); let msgs: Vec<String> = match compiled.validate(settings) {
let msgs: Vec<String> = errors.take(5).map(|e| e.to_string()).collect(); Ok(()) => vec!["Invalid settings".to_string()],
Err(errors) => errors.take(5).map(|e| e.to_string()).collect(),
};
return Err(( return Err((
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
format!("Invalid settings: {}", msgs.join("; ")), format!("Invalid settings: {}", msgs.join("; ")),
@ -732,8 +734,8 @@ async fn update_community_plugin_package(
) )
.await; .await;
if req.settings.is_some() { if let Some(settings) = req.settings.as_ref() {
let keys = redacted_settings_keys(req.settings.as_ref().unwrap()); let keys = redacted_settings_keys(settings);
let _ = sqlx::query!( let _ = sqlx::query!(
r#"INSERT INTO public_events (community_id, actor_user_id, plugin_name, event_type, payload) r#"INSERT INTO public_events (community_id, actor_user_id, plugin_name, event_type, payload)
VALUES ($1, $2, NULL, 'plugin.package_settings_updated', $3)"#, VALUES ($1, $2, NULL, 'plugin.package_settings_updated', $3)"#,
@ -1373,8 +1375,10 @@ async fn update_community_plugin(
})?; })?;
if !compiled.is_valid(settings) { if !compiled.is_valid(settings) {
let errors = compiled.validate(settings).err().unwrap(); let msgs: Vec<String> = match compiled.validate(settings) {
let msgs: Vec<String> = errors.take(5).map(|e| e.to_string()).collect(); Ok(()) => vec!["Invalid settings".to_string()],
Err(errors) => errors.take(5).map(|e| e.to_string()).collect(),
};
return Err(( return Err((
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
format!("Invalid settings: {}", msgs.join("; ")), format!("Invalid settings: {}", msgs.join("; ")),
@ -1451,8 +1455,8 @@ async fn update_community_plugin(
.await; .await;
} }
if req.settings.is_some() { if let Some(settings) = req.settings.as_ref() {
let keys = redacted_settings_keys(req.settings.as_ref().unwrap()); let keys = redacted_settings_keys(settings);
let _ = sqlx::query!( let _ = sqlx::query!(
r#"INSERT INTO public_events (community_id, actor_user_id, plugin_name, event_type, payload) r#"INSERT INTO public_events (community_id, actor_user_id, plugin_name, event_type, payload)
VALUES ($1, $2, $3, 'plugin.settings_updated', $4)"#, VALUES ($1, $2, $3, 'plugin.settings_updated', $4)"#,

View file

@ -3,14 +3,17 @@
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
routing::{get, patch, post}, routing::{get, patch, post},
Extension,
Json, Router, Json, Router,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::AuthUser; use crate::auth::AuthUser;
use crate::config::Config;
use super::permissions::{require_permission, require_any_permission, perms}; use super::permissions::{require_permission, require_any_permission, perms};
use axum::http::StatusCode; use axum::http::StatusCode;
@ -24,6 +27,17 @@ pub struct SetupStatus {
pub instance_name: Option<String>, pub instance_name: Option<String>,
} }
#[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<String>,
pub single_community_name: Option<String>,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct InstanceSettings { pub struct InstanceSettings {
pub id: Uuid, pub id: Uuid,
@ -105,6 +119,57 @@ async fn get_setup_status(State(pool): State<PgPool>) -> Result<Json<SetupStatus
} }
} }
/// Public instance settings (no auth)
async fn get_public_settings(State(pool): State<PgPool>) -> Result<Json<PublicInstanceSettings>, String> {
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<String> = None;
let mut single_community_name: Option<String> = 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 /// Complete initial setup
async fn complete_setup( async fn complete_setup(
State(pool): State<PgPool>, State(pool): State<PgPool>,
@ -217,11 +282,19 @@ async fn get_instance_settings(
async fn update_instance_settings( async fn update_instance_settings(
State(pool): State<PgPool>, State(pool): State<PgPool>,
auth: AuthUser, auth: AuthUser,
Extension(config): Extension<Arc<Config>>,
Json(req): Json<UpdateInstanceRequest>, Json(req): Json<UpdateInstanceRequest>,
) -> Result<Json<InstanceSettings>, (StatusCode, String)> { ) -> Result<Json<InstanceSettings>, (StatusCode, String)> {
// Check platform settings permission // Check platform settings permission
require_permission(&pool, auth.user_id, perms::PLATFORM_SETTINGS, None).await?; 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!( let s = sqlx::query!(
r#"UPDATE instance_settings SET r#"UPDATE instance_settings SET
instance_name = COALESCE($1, instance_name), instance_name = COALESCE($1, instance_name),
@ -348,6 +421,7 @@ async fn update_community_settings(
pub fn router(pool: PgPool) -> Router { pub fn router(pool: PgPool) -> Router {
Router::new() Router::new()
.route("/api/settings/public", get(get_public_settings))
.route("/api/settings/setup/status", get(get_setup_status)) .route("/api/settings/setup/status", get(get_setup_status))
.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))

View file

@ -2,9 +2,11 @@ use axum::{
extract::FromRequestParts, extract::FromRequestParts,
http::{request::Parts, StatusCode}, http::{request::Parts, StatusCode},
}; };
use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use super::jwt::{verify_token, Claims}; use super::jwt::{verify_token, Claims};
use crate::config::Config;
pub struct AuthUser { pub struct AuthUser {
pub user_id: Uuid, pub user_id: Uuid,
@ -28,7 +30,12 @@ where
.strip_prefix("Bearer ") .strip_prefix("Bearer ")
.ok_or((StatusCode::UNAUTHORIZED, "Invalid authorization header format"))?; .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::<Arc<Config>>()
.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) let claims: Claims = verify_token(token, &secret)
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token"))?; .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token"))?;

View file

@ -14,7 +14,7 @@ pub struct Config {
} }
fn default_jwt_secret() -> String { fn default_jwt_secret() -> String {
"change-me-in-production".to_string() "".to_string()
} }
impl Config { impl Config {

View file

@ -31,34 +31,378 @@ pub fn verify_demo_password(username: &str, password: &str) -> bool {
pub async fn reset_demo_data(pool: &PgPool) -> Result<(), sqlx::Error> { pub async fn reset_demo_data(pool: &PgPool) -> Result<(), sqlx::Error> {
tracing::info!("Resetting demo data to initial state..."); tracing::info!("Resetting demo data to initial state...");
// Delete data that might have been modified (in reverse dependency order) let mut tx = pool.begin().await?;
// Keep the demo users and communities, but reset their state
// Clear votes // Remove volatile/user-generated data
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')))") sqlx::query("DELETE FROM public_events")
.execute(pool) .execute(&mut *tx)
.await?; .await?;
// Clear delegated votes sqlx::query("DELETE FROM notifications")
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(&mut *tx)
.execute(pool)
.await?; .await?;
// Clear comments added after seed (keep seed comments) // Deliberation system
sqlx::query("DELETE FROM comments WHERE id::text NOT LIKE 'com00001-%'") sqlx::query("DELETE FROM comment_reactions")
.execute(pool) .execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM proposal_positions")
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM discussion_group_members")
.execute(&mut *tx)
.await?;
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?; .await?;
// Reset proposal statuses for active proposals // Decision workflow plugin
sqlx::query("UPDATE proposals SET status = 'voting', voting_ends_at = NOW() + INTERVAL '5 days' WHERE id = 'p0000001-0000-0000-0000-000000000002'::uuid") sqlx::query("DELETE FROM quorum_snapshots")
.execute(pool) .execute(&mut *tx)
.await?; .await?;
sqlx::query("UPDATE proposals SET status = 'voting', voting_ends_at = NOW() + INTERVAL '5 days' WHERE id = 'p0000001-0000-0000-0000-000000000005'::uuid") sqlx::query("DELETE FROM phase_participation")
.execute(pool) .execute(&mut *tx)
.await?; .await?;
sqlx::query("UPDATE proposals SET status = 'voting', voting_ends_at = NOW() + INTERVAL '5 days' WHERE id = 'p0000001-0000-0000-0000-000000000007'::uuid") sqlx::query("DELETE FROM workflow_transitions")
.execute(pool) .execute(&mut *tx)
.await?; .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"); tracing::info!("Demo data reset complete");
Ok(()) Ok(())

View file

@ -12,6 +12,7 @@ use std::sync::Arc;
use axum::Extension; use axum::Extension;
use chrono::{Datelike, Timelike, Utc, Weekday}; use chrono::{Datelike, Timelike, Utc, Weekday};
use serde_json::json; use serde_json::json;
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;
use uuid::Uuid; use uuid::Uuid;
@ -19,33 +20,65 @@ use uuid::Uuid;
use crate::config::Config; use crate::config::Config;
use crate::plugins::HookContext; 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] #[tokio::main]
async fn main() { async fn main() {
tracing_subscriber::fmt::init(); 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(); dotenvy::dotenv().ok();
// Load configuration // Load configuration
let config = Config::from_env().unwrap_or_default(); let config = Arc::new(Config::from_env()?);
let config = Arc::new(config);
if config.jwt_secret.trim().is_empty() {
return Err(StartupError::MissingJwtSecret);
}
if config.is_demo() { if config.is_demo() {
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") let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| config.database_url.clone());
.unwrap_or_else(|_| config.database_url.clone());
let pool = db::create_pool(&database_url) let pool = db::create_pool(&database_url).await?;
.await
.expect("Failed to create database pool");
tracing::info!("Connected to database"); tracing::info!("Connected to database");
sqlx::migrate!("./migrations") let mut migrator = sqlx::migrate!("./migrations");
.run(&pool) if config.is_demo() {
.await migrator.set_ignore_missing(true);
.expect("Failed to run database migrations"); }
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() let cors = CorsLayer::new()
.allow_origin(Any) .allow_origin(Any)
@ -55,8 +88,7 @@ async fn main() {
let plugins = plugins::PluginManager::new(pool.clone()) let plugins = plugins::PluginManager::new(pool.clone())
.register_builtin_plugins() .register_builtin_plugins()
.initialize() .initialize()
.await .await?;
.expect("Failed to initialize plugins");
{ {
let cron_plugins = plugins.clone(); let cron_plugins = plugins.clone();
@ -180,6 +212,10 @@ async fn main() {
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.unwrap(); let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app)
.await
.map_err(|e| StartupError::Serve(e.to_string()))?;
Ok(())
} }

View file

@ -153,6 +153,7 @@ impl ConflictService {
} }
/// Report a new conflict /// Report a new conflict
#[allow(clippy::too_many_arguments)]
pub async fn report_conflict( pub async fn report_conflict(
pool: &PgPool, pool: &PgPool,
community_id: Uuid, community_id: Uuid,

View file

@ -133,6 +133,7 @@ pub struct LedgerService;
impl LedgerService { impl LedgerService {
/// Create a new ledger entry /// Create a new ledger entry
#[allow(clippy::too_many_arguments)]
pub async fn create_entry( pub async fn create_entry(
pool: &PgPool, pool: &PgPool,
community_id: Option<Uuid>, community_id: Option<Uuid>,

View file

@ -573,6 +573,7 @@ impl PluginManager {
} }
} }
#[allow(clippy::too_many_arguments)]
pub async fn handle_community_plugin_change( pub async fn handle_community_plugin_change(
&self, &self,
community_id: Uuid, community_id: Uuid,
@ -629,6 +630,7 @@ impl PluginManager {
} }
} }
#[allow(clippy::too_many_arguments)]
pub async fn handle_community_plugin_package_change( pub async fn handle_community_plugin_package_change(
&self, &self,
community_id: Uuid, community_id: Uuid,

View file

@ -122,13 +122,21 @@ pub struct ResultBuffer {
impl HostStateWithLimits { impl HostStateWithLimits {
/// Store a result in the result buffer and return its length /// Store a result in the result buffer and return its length
pub fn store_result(&mut self, data: &[u8]) -> u32 { 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 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> {
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<HostStateWithLimits>) -> Resu
let package_id = state.inner.package_id; let package_id = state.inner.package_id;
let pool = state.inner.pool.clone(); let pool = state.inner.pool.clone();
let fetched: Value = match block_in_place(|| { let fetched: Value = block_in_place(|| {
Handle::current().block_on(async { Handle::current().block_on(async {
if let Some(cid) = community_id { if let Some(cid) = community_id {
// First: per-community WASM package settings. // First: per-community WASM package settings.
@ -223,9 +231,7 @@ pub fn register_host_functions(linker: &mut Linker<HostStateWithLimits>) -> Resu
v v
}) })
}) { });
v => v,
};
let result = serde_json::to_string(&fetched).unwrap_or("null".to_string()); let result = serde_json::to_string(&fetched).unwrap_or("null".to_string());
let len = caller.data_mut().store_result(result.as_bytes()); let len = caller.data_mut().store_result(result.as_bytes());
@ -466,10 +472,8 @@ pub fn register_host_functions(linker: &mut Linker<HostStateWithLimits>) -> Resu
} }
let mut body_buf = vec![0u8; body_len as usize]; let mut body_buf = vec![0u8; body_len as usize];
if body_len > 0 { if body_len > 0 && memory.read(&caller, body_ptr as usize, &mut body_buf).is_err() {
if memory.read(&caller, body_ptr as usize, &mut body_buf).is_err() { return pack_result(8, 0);
return pack_result(8, 0);
}
} }
let url = match String::from_utf8(url_buf) { let url = match String::from_utf8(url_buf) {

View file

@ -17,10 +17,11 @@ use uuid::Uuid;
/// Voting method types /// Voting method types
/// Used by voting calculation services when tallying results. /// Used by voting calculation services when tallying results.
#[allow(dead_code)] #[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")] #[serde(rename_all = "snake_case")]
pub enum VotingMethod { pub enum VotingMethod {
/// Simple approval voting (vote for multiple options) /// Simple approval voting (vote for multiple options)
#[default]
Approval, Approval,
/// Ranked choice / instant runoff /// Ranked choice / instant runoff
RankedChoice, RankedChoice,
@ -32,12 +33,6 @@ pub enum VotingMethod {
Quadratic, Quadratic,
} }
impl Default for VotingMethod {
fn default() -> Self {
Self::Approval
}
}
impl std::fmt::Display for VotingMethod { impl std::fmt::Display for VotingMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {

View file

@ -44,7 +44,9 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
// Find first choice among active options // Find first choice among active options
for opt in &ballot.rankings { for opt in &ballot.rankings {
if active_options.contains(opt) { if active_options.contains(opt) {
*vote_counts.get_mut(opt).unwrap() += 1; if let Some(c) = vote_counts.get_mut(opt) {
*c += 1;
}
break; break;
} }
} }
@ -78,7 +80,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
.collect(); .collect();
// Add eliminated options at the end (in reverse elimination order) // 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 { final_ranking.push(RankedOption {
option_id: opt, option_id: opt,
rank: final_ranking.len() + 1, rank: final_ranking.len() + 1,
@ -117,7 +119,7 @@ pub fn calculate(options: &[Uuid], ballots: &[RankedBallot]) -> VotingResult {
}) })
.collect(); .collect();
for (_i, &opt) in eliminated.iter().rev().enumerate() { for &opt in eliminated.iter().rev() {
final_ranking.push(RankedOption { final_ranking.push(RankedOption {
option_id: opt, option_id: opt,
rank: final_ranking.len() + 1, rank: final_ranking.len() + 1,

View file

@ -109,12 +109,10 @@ pub fn calculate(options: &[Uuid], ballots: &[ScoreBallot]) -> VotingResult {
}).collect(); }).collect();
// Adjust ranking for runoff result (swap if needed) // Adjust ranking for runoff result (swap if needed)
if winner == finalist_b { if winner == finalist_b && ranking.len() >= 2 {
if ranking.len() >= 2 { ranking[0].rank = 2;
ranking[0].rank = 2; ranking[1].rank = 1;
ranking[1].rank = 1; ranking.swap(0, 1);
ranking.swap(0, 1);
}
} }
VotingResult { VotingResult {