likwid/backend/src/api/delegation.rs

566 lines
18 KiB
Rust
Raw Normal View History

//! 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<String>,
pub scope: DelegationScope,
pub community_id: Option<Uuid>,
pub topic_id: Option<Uuid>,
pub proposal_id: Option<Uuid>,
pub weight: f64,
pub is_active: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateDelegationRequest {
pub delegate_id: Uuid,
pub scope: DelegationScope,
pub community_id: Option<Uuid>,
pub topic_id: Option<Uuid>,
pub proposal_id: Option<Uuid>,
#[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<String>,
pub bio: Option<String>,
pub accepting_delegations: bool,
pub delegation_policy: Option<String>,
pub total_delegators: i32,
pub total_votes_cast: i32,
}
#[derive(Debug, Deserialize)]
pub struct UpdateProfileRequest {
pub display_name: Option<String>,
pub bio: Option<String>,
pub accepting_delegations: Option<bool>,
pub delegation_policy: Option<String>,
}
/// 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<ChainLink>,
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<String>,
pub community_id: Option<Uuid>,
pub active_only: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct Topic {
pub id: Uuid,
pub community_id: Uuid,
pub name: String,
pub slug: String,
pub description: Option<String>,
pub parent_id: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
pub struct CreateTopicRequest {
pub name: String,
pub slug: String,
pub description: Option<String>,
pub parent_id: Option<Uuid>,
}
// ============================================================================
// Delegation Handlers
// ============================================================================
/// Create a new delegation
async fn create_delegation(
auth: AuthUser,
State(pool): State<PgPool>,
Json(req): Json<CreateDelegationRequest>,
) -> Result<Json<Delegation>, (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<PgPool>,
Query(query): Query<ListDelegationsQuery>,
) -> Result<Json<Vec<Delegation>>, (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<PgPool>,
) -> Result<Json<Vec<Delegation>>, (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<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<serde_json::Value>, (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<PgPool>,
) -> Result<Json<DelegateProfile>, (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<PgPool>,
Json(req): Json<UpdateProfileRequest>,
) -> Result<Json<DelegateProfile>, (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<PgPool>,
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
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<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<Vec<Topic>>, (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<Uuid>,
State(pool): State<PgPool>,
Json(req): Json<CreateTopicRequest>,
) -> Result<Json<Topic>, (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)
}