mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-02-09 21:13:09 +00:00
backend: delegation, moderation, and voting improvements
This commit is contained in:
parent
f3a5edd91d
commit
89a6e9eaa7
20 changed files with 800 additions and 127 deletions
|
|
@ -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 {
|
||||
success: true,
|
||||
created_id: result,
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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<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)
|
||||
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<MeResponse> {
|
||||
Json(MeResponse {
|
||||
id: auth.user_id,
|
||||
username: auth.username,
|
||||
})
|
||||
async fn me(
|
||||
auth: AuthUser,
|
||||
State(pool): State<PgPool>,
|
||||
) -> 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,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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<ListDelegationsQuery>,
|
||||
) -> Result<Json<Vec<DelegateProfile>>, (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)
|
||||
|
|
|
|||
|
|
@ -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<DemoState>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -86,11 +86,11 @@ pub async fn is_community_staff(
|
|||
user_id: Uuid,
|
||||
community_id: Uuid,
|
||||
) -> Result<bool, (StatusCode, String)> {
|
||||
let is_admin = user_has_permission(pool, user_id, "community.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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> = errors.take(5).map(|e| e.to_string()).collect();
|
||||
let msgs: Vec<String> = 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<String> = errors.take(5).map(|e| e.to_string()).collect();
|
||||
let msgs: Vec<String> = 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)"#,
|
||||
|
|
|
|||
|
|
@ -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<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)]
|
||||
pub struct InstanceSettings {
|
||||
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
|
||||
async fn complete_setup(
|
||||
State(pool): State<PgPool>,
|
||||
|
|
@ -217,11 +282,19 @@ async fn get_instance_settings(
|
|||
async fn update_instance_settings(
|
||||
State(pool): State<PgPool>,
|
||||
auth: AuthUser,
|
||||
Extension(config): Extension<Arc<Config>>,
|
||||
Json(req): Json<UpdateInstanceRequest>,
|
||||
) -> Result<Json<InstanceSettings>, (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))
|
||||
|
|
|
|||
|
|
@ -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::<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)
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token"))?;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ pub struct Config {
|
|||
}
|
||||
|
||||
fn default_jwt_secret() -> String {
|
||||
"change-me-in-production".to_string()
|
||||
"".to_string()
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
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
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// 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)
|
||||
// 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?;
|
||||
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?;
|
||||
|
||||
// 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)
|
||||
// Decision workflow plugin
|
||||
sqlx::query("DELETE FROM quorum_snapshots")
|
||||
.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 phase_participation")
|
||||
.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 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(())
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Uuid>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<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 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<HostStateWithLimits>) -> 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,11 +472,9 @@ pub fn register_host_functions(linker: &mut Linker<HostStateWithLimits>) -> 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() {
|
||||
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) {
|
||||
Ok(s) => s,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -109,13 +109,11 @@ 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 {
|
||||
if winner == finalist_b && ranking.len() >= 2 {
|
||||
ranking[0].rank = 2;
|
||||
ranking[1].rank = 1;
|
||||
ranking.swap(0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
VotingResult {
|
||||
winner: Some(winner),
|
||||
|
|
|
|||
Loading…
Reference in a new issue