2026-01-27 16:21:58 +00:00
|
|
|
//! 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)]
|
2026-01-28 23:46:43 +00:00
|
|
|
#[serde(rename_all = "lowercase")]
|
2026-01-27 16:21:58 +00:00
|
|
|
#[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()));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-28 23:46:43 +00:00
|
|
|
// 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()))?;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 16:21:58 +00:00
|
|
|
// 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,
|
2026-01-28 23:46:43 +00:00
|
|
|
req.scope as DelegationScope,
|
2026-01-27 16:21:58 +00:00
|
|
|
req.community_id,
|
|
|
|
|
req.topic_id,
|
|
|
|
|
req.proposal_id
|
|
|
|
|
)
|
|
|
|
|
.execute(&pool)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
|
|
|
2026-01-28 23:46:43 +00:00
|
|
|
// Create new delegation
|
2026-01-27 16:21:58 +00:00
|
|
|
let delegation = sqlx::query!(
|
2026-01-28 23:46:43 +00:00
|
|
|
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)
|
2026-01-27 16:21:58 +00:00
|
|
|
RETURNING id, delegator_id, delegate_id, scope as "scope: DelegationScope",
|
2026-01-28 23:46:43 +00:00
|
|
|
community_id, topic_id, proposal_id, weight::float8 as "weight!", is_active, created_at"#,
|
2026-01-27 16:21:58 +00:00
|
|
|
auth.user_id,
|
|
|
|
|
req.delegate_id,
|
|
|
|
|
req.scope as DelegationScope,
|
|
|
|
|
req.community_id,
|
|
|
|
|
req.topic_id,
|
2026-01-28 23:46:43 +00:00
|
|
|
req.proposal_id,
|
|
|
|
|
req.weight
|
2026-01-27 16:21:58 +00:00
|
|
|
)
|
|
|
|
|
.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();
|
|
|
|
|
|
2026-01-28 23:46:43 +00:00
|
|
|
// Update delegate's delegator count (avoid drift)
|
2026-01-27 16:21:58 +00:00
|
|
|
sqlx::query!(
|
|
|
|
|
r#"INSERT INTO delegate_profiles (user_id, total_delegators)
|
2026-01-28 23:46:43 +00:00
|
|
|
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"#,
|
2026-01-27 16:21:58 +00:00
|
|
|
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,
|
2026-01-28 23:46:43 +00:00
|
|
|
weight: delegation.weight,
|
2026-01-27 16:21:58 +00:00
|
|
|
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,
|
2026-01-28 23:46:43 +00:00
|
|
|
d.proposal_id, d.weight::float8 as "weight!", d.is_active, d.created_at
|
2026-01-27 16:21:58 +00:00
|
|
|
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,
|
2026-01-28 23:46:43 +00:00
|
|
|
weight: d.weight,
|
2026-01-27 16:21:58 +00:00
|
|
|
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,
|
2026-01-28 23:46:43 +00:00
|
|
|
d.proposal_id, d.weight::float8 as "weight!", d.is_active, d.created_at
|
2026-01-27 16:21:58 +00:00
|
|
|
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,
|
2026-01-28 23:46:43 +00:00
|
|
|
weight: d.weight,
|
2026-01-27 16:21:58 +00:00
|
|
|
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();
|
|
|
|
|
|
2026-01-28 23:46:43 +00:00
|
|
|
// Update delegate's delegator count (avoid drift)
|
2026-01-27 16:21:58 +00:00
|
|
|
sqlx::query!(
|
2026-01-28 23:46:43 +00:00
|
|
|
r#"UPDATE delegate_profiles
|
|
|
|
|
SET total_delegators = (
|
|
|
|
|
SELECT COUNT(*)::int
|
|
|
|
|
FROM delegations
|
|
|
|
|
WHERE delegate_id = $1 AND is_active = TRUE
|
|
|
|
|
)
|
|
|
|
|
WHERE user_id = $1"#,
|
2026-01-27 16:21:58 +00:00
|
|
|
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!(
|
2026-01-28 23:46:43 +00:00
|
|
|
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
|
2026-01-27 16:21:58 +00:00
|
|
|
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)
|
|
|
|
|
}
|