//! Liquid Delegation API //! //! Implements fluid, reversible, topic-based vote delegation as described //! in the Democracy Design manifesto. use axum::{ extract::{Path, Query, State}, http::StatusCode, routing::{delete, get}, Json, Router, }; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; use chrono::{DateTime, Utc}; use crate::auth::AuthUser; // ============================================================================ // Types // ============================================================================ #[derive(Debug, Clone, Copy, Serialize, Deserialize, sqlx::Type)] #[sqlx(type_name = "delegation_scope", rename_all = "lowercase")] pub enum DelegationScope { Global, Community, Topic, Proposal, } #[derive(Debug, Serialize)] pub struct Delegation { pub id: Uuid, pub delegator_id: Uuid, pub delegate_id: Uuid, pub delegate_username: Option, pub scope: DelegationScope, pub community_id: Option, pub topic_id: Option, pub proposal_id: Option, pub weight: f64, pub is_active: bool, pub created_at: DateTime, } #[derive(Debug, Deserialize)] pub struct CreateDelegationRequest { pub delegate_id: Uuid, pub scope: DelegationScope, pub community_id: Option, pub topic_id: Option, pub proposal_id: Option, #[serde(default = "default_weight")] pub weight: f64, } fn default_weight() -> f64 { 1.0 } #[derive(Debug, Serialize)] pub struct DelegateProfile { pub user_id: Uuid, pub username: String, pub display_name: Option, pub bio: Option, pub accepting_delegations: bool, pub delegation_policy: Option, pub total_delegators: i32, pub total_votes_cast: i32, } #[derive(Debug, Deserialize)] pub struct UpdateProfileRequest { pub display_name: Option, pub bio: Option, pub accepting_delegations: Option, pub delegation_policy: Option, } /// Represents a delegation chain from original delegator to final delegate. /// Used for delegation chain visualization. #[allow(dead_code)] #[derive(Debug, Serialize)] pub struct DelegationChain { pub original_delegator_id: Uuid, pub final_delegate_id: Uuid, pub chain: Vec, pub effective_weight: f64, } #[allow(dead_code)] #[derive(Debug, Serialize)] pub struct ChainLink { pub user_id: Uuid, pub username: String, } #[derive(Debug, Deserialize)] pub struct ListDelegationsQuery { #[allow(dead_code)] pub scope: Option, pub community_id: Option, pub active_only: Option, } #[derive(Debug, Serialize)] pub struct Topic { pub id: Uuid, pub community_id: Uuid, pub name: String, pub slug: String, pub description: Option, pub parent_id: Option, } #[derive(Debug, Deserialize)] pub struct CreateTopicRequest { pub name: String, pub slug: String, pub description: Option, pub parent_id: Option, } // ============================================================================ // Delegation Handlers // ============================================================================ /// Create a new delegation async fn create_delegation( auth: AuthUser, State(pool): State, Json(req): Json, ) -> Result, (StatusCode, String)> { // Validate weight if req.weight <= 0.0 || req.weight > 1.0 { return Err((StatusCode::BAD_REQUEST, "Weight must be between 0 and 1".to_string())); } // Check delegate exists let delegate = sqlx::query!("SELECT username FROM users WHERE id = $1", req.delegate_id) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Delegate not found".to_string()))?; // Check if delegate is accepting delegations let profile = sqlx::query!( "SELECT accepting_delegations FROM delegate_profiles WHERE user_id = $1", req.delegate_id ) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if let Some(p) = profile { if !p.accepting_delegations { return Err((StatusCode::BAD_REQUEST, "Delegate is not accepting delegations".to_string())); } } // Deactivate existing delegation with same scope sqlx::query!( r#"UPDATE delegations SET is_active = FALSE, revoked_at = NOW() WHERE delegator_id = $1 AND scope = $2::delegation_scope AND is_active = TRUE AND (community_id = $3 OR ($3 IS NULL AND community_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))"#, auth.user_id, req.scope.clone() as DelegationScope, req.community_id, req.topic_id, req.proposal_id ) .execute(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Create new delegation (weight stored as default, simplified for now) 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) RETURNING id, delegator_id, delegate_id, scope as "scope: DelegationScope", community_id, topic_id, proposal_id, is_active, created_at"#, auth.user_id, req.delegate_id, req.scope as DelegationScope, req.community_id, req.topic_id, req.proposal_id ) .fetch_one(&pool) .await .map_err(|e: sqlx::Error| { if e.to_string().contains("cycle") { (StatusCode::BAD_REQUEST, "Delegation would create a cycle".to_string()) } else { (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) } })?; // Log the delegation sqlx::query!( r#"INSERT INTO delegation_log (delegation_id, delegator_id, delegate_id, action, scope, community_id, topic_id, proposal_id) VALUES ($1, $2, $3, 'created', $4::delegation_scope, $5, $6, $7)"#, delegation.id, auth.user_id, req.delegate_id, req.scope as DelegationScope, req.community_id, req.topic_id, req.proposal_id ) .execute(&pool) .await .ok(); // Update delegate's delegator count 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"#, req.delegate_id ) .execute(&pool) .await .ok(); Ok(Json(Delegation { id: delegation.id, delegator_id: delegation.delegator_id, delegate_id: delegation.delegate_id, delegate_username: Some(delegate.username), scope: delegation.scope, community_id: delegation.community_id, topic_id: delegation.topic_id, proposal_id: delegation.proposal_id, weight: req.weight, is_active: delegation.is_active, created_at: delegation.created_at, })) } /// List user's outgoing delegations async fn list_my_delegations( auth: AuthUser, State(pool): State, Query(query): Query, ) -> Result>, (StatusCode, String)> { let active_only = query.active_only.unwrap_or(true); 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 FROM delegations d JOIN users u ON d.delegate_id = u.id WHERE d.delegator_id = $1 AND ($2 = FALSE OR d.is_active = TRUE) AND ($3::uuid IS NULL OR d.community_id = $3) ORDER BY d.created_at DESC"#, auth.user_id, active_only, query.community_id ) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(delegations.into_iter().map(|d| { Delegation { id: d.id, delegator_id: d.delegator_id, delegate_id: d.delegate_id, delegate_username: Some(d.delegate_username), scope: d.scope, community_id: d.community_id, topic_id: d.topic_id, proposal_id: d.proposal_id, weight: 1.0, is_active: d.is_active, created_at: d.created_at, } }).collect())) } /// List delegations TO a user (they are the delegate) async fn list_delegations_to_me( auth: AuthUser, State(pool): State, ) -> Result>, (StatusCode, String)> { 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 FROM delegations d JOIN users u ON d.delegator_id = u.id WHERE d.delegate_id = $1 AND d.is_active = TRUE ORDER BY d.created_at DESC"#, auth.user_id ) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(delegations.into_iter().map(|d| { Delegation { id: d.id, delegator_id: d.delegator_id, delegate_id: d.delegate_id, delegate_username: Some(d.delegator_username), scope: d.scope, community_id: d.community_id, topic_id: d.topic_id, proposal_id: d.proposal_id, weight: 1.0, is_active: d.is_active, created_at: d.created_at, } }).collect())) } /// Revoke a delegation async fn revoke_delegation( auth: AuthUser, Path(delegation_id): Path, State(pool): State, ) -> Result, (StatusCode, String)> { let result = sqlx::query!( r#"UPDATE delegations SET is_active = FALSE, revoked_at = NOW() WHERE id = $1 AND delegator_id = $2 AND is_active = TRUE RETURNING delegate_id, scope as "scope: DelegationScope", community_id, topic_id, proposal_id"#, delegation_id, auth.user_id ) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Delegation not found or already revoked".to_string()))?; // Log revocation sqlx::query!( r#"INSERT INTO delegation_log (delegation_id, delegator_id, delegate_id, action, scope, community_id, topic_id, proposal_id) VALUES ($1, $2, $3, 'revoked', $4::delegation_scope, $5, $6, $7)"#, delegation_id, auth.user_id, result.delegate_id, result.scope as DelegationScope, result.community_id, result.topic_id, result.proposal_id ) .execute(&pool) .await .ok(); // Update delegate's delegator count sqlx::query!( "UPDATE delegate_profiles SET total_delegators = GREATEST(0, total_delegators - 1) WHERE user_id = $1", result.delegate_id ) .execute(&pool) .await .ok(); Ok(Json(serde_json::json!({"success": true}))) } // ============================================================================ // Delegate Profile Handlers // ============================================================================ /// Get or create delegate profile async fn get_my_profile( auth: AuthUser, State(pool): State, ) -> Result, (StatusCode, String)> { let user = sqlx::query!("SELECT username FROM users WHERE id = $1", auth.user_id) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let profile = sqlx::query!( r#"INSERT INTO delegate_profiles (user_id) VALUES ($1) ON CONFLICT (user_id) DO UPDATE SET user_id = $1 RETURNING display_name, bio, accepting_delegations, delegation_policy, total_delegators, total_votes_cast"#, auth.user_id ) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(DelegateProfile { user_id: auth.user_id, username: user.username, display_name: profile.display_name, bio: profile.bio, accepting_delegations: profile.accepting_delegations, delegation_policy: profile.delegation_policy, total_delegators: profile.total_delegators, total_votes_cast: profile.total_votes_cast, })) } /// Update delegate profile async fn update_my_profile( auth: AuthUser, State(pool): State, Json(req): Json, ) -> Result, (StatusCode, String)> { let user = sqlx::query!("SELECT username FROM users WHERE id = $1", auth.user_id) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let profile = sqlx::query!( r#"INSERT INTO delegate_profiles (user_id, display_name, bio, accepting_delegations, delegation_policy) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id) DO UPDATE SET display_name = COALESCE($2, delegate_profiles.display_name), bio = COALESCE($3, delegate_profiles.bio), accepting_delegations = COALESCE($4, delegate_profiles.accepting_delegations), delegation_policy = COALESCE($5, delegate_profiles.delegation_policy), updated_at = NOW() RETURNING display_name, bio, accepting_delegations, delegation_policy, total_delegators, total_votes_cast"#, auth.user_id, req.display_name, req.bio, req.accepting_delegations, req.delegation_policy ) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(DelegateProfile { user_id: auth.user_id, username: user.username, display_name: profile.display_name, bio: profile.bio, accepting_delegations: profile.accepting_delegations, delegation_policy: profile.delegation_policy, total_delegators: profile.total_delegators, total_votes_cast: profile.total_votes_cast, })) } /// List delegates (users accepting delegations) async fn list_delegates( State(pool): State, Query(_query): Query, ) -> Result>, (StatusCode, String)> { let profiles = sqlx::query!( r#"SELECT dp.user_id, u.username, dp.display_name, dp.bio, dp.accepting_delegations, dp.delegation_policy, dp.total_delegators, dp.total_votes_cast FROM delegate_profiles dp JOIN users u ON dp.user_id = u.id WHERE dp.accepting_delegations = TRUE ORDER BY dp.total_delegators DESC LIMIT 50"# ) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(profiles.into_iter().map(|p| DelegateProfile { user_id: p.user_id, username: p.username, display_name: p.display_name, bio: p.bio, accepting_delegations: p.accepting_delegations, delegation_policy: p.delegation_policy, total_delegators: p.total_delegators, total_votes_cast: p.total_votes_cast, }).collect())) } // ============================================================================ // Topic Handlers // ============================================================================ /// List topics for a community async fn list_topics( Path(community_id): Path, State(pool): State, ) -> Result>, (StatusCode, String)> { let topics = sqlx::query_as!( Topic, r#"SELECT id, community_id, name, slug, description, parent_id FROM topics WHERE community_id = $1 ORDER BY name"#, community_id ) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(topics)) } /// Create a topic async fn create_topic( auth: AuthUser, Path(community_id): Path, State(pool): State, Json(req): Json, ) -> Result, (StatusCode, String)> { // Check user is community admin/moderator let membership = sqlx::query!( "SELECT role FROM community_members WHERE community_id = $1 AND user_id = $2", community_id, auth.user_id ) .fetch_optional(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::FORBIDDEN, "Not a member of this community".to_string()))?; if membership.role != "admin" && membership.role != "moderator" { return Err((StatusCode::FORBIDDEN, "Only admins/moderators can create topics".to_string())); } let topic = sqlx::query_as!( Topic, r#"INSERT INTO topics (community_id, name, slug, description, parent_id) VALUES ($1, $2, $3, $4, $5) RETURNING id, community_id, name, slug, description, parent_id"#, community_id, req.name, req.slug, req.description, req.parent_id ) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(topic)) } // ============================================================================ // Router // ============================================================================ pub fn router(pool: PgPool) -> Router { Router::new() // Delegations .route("/api/delegations", get(list_my_delegations).post(create_delegation)) .route("/api/delegations/to-me", get(list_delegations_to_me)) .route("/api/delegations/{delegation_id}", delete(revoke_delegation)) // Delegate profiles .route("/api/delegates", get(list_delegates)) .route("/api/delegates/me", get(get_my_profile).put(update_my_profile)) // Topics .route("/api/communities/{community_id}/topics", get(list_topics).post(create_topic)) .with_state(pool) }