likwid/backend/src/api/deliberation.rs
Marco Allegretti d4bcba405b backend: modify 56 files
Verified changes:
- modify backend/src/api/analytics.rs
- modify backend/src/api/approvals.rs
- modify backend/src/api/auth.rs
- modify backend/src/api/comments.rs
- modify backend/src/api/communities.rs
- modify backend/src/api/conflicts.rs
- modify backend/src/api/delegation.rs
- modify backend/src/api/deliberation.rs
- modify backend/src/api/demo.rs
- modify backend/src/api/exports.rs
- modify backend/src/api/federation.rs
- modify backend/src/api/gitlab.rs
- modify backend/src/api/invitations.rs
- modify backend/src/api/lifecycle.rs
- modify backend/src/api/mod.rs
- modify backend/src/api/moderation.rs
- modify backend/src/api/moderation_ledger.rs
- modify backend/src/api/notifications.rs
- modify backend/src/api/permissions.rs
- modify backend/src/api/plugins.rs
- modify backend/src/api/proposals.rs
- modify backend/src/api/roles.rs
- modify backend/src/api/self_moderation.rs
- modify backend/src/api/settings.rs
- modify backend/src/api/users.rs
- modify backend/src/api/voting_config.rs
- modify backend/src/api/workflows.rs
- modify backend/src/auth/jwt.rs
- modify backend/src/auth/middleware.rs
- modify backend/src/auth/mod.rs
- modify backend/src/demo/mod.rs
- modify backend/src/main.rs
- modify backend/src/models/community.rs
- modify backend/src/models/mod.rs
- modify backend/src/models/proposal.rs
- modify backend/src/models/user.rs
- modify backend/src/plugins/builtin/conflict_resolution.rs
- modify backend/src/plugins/builtin/decision_workflows.rs
- modify backend/src/plugins/builtin/federation.rs
- modify backend/src/plugins/builtin/governance_analytics.rs
- modify backend/src/plugins/builtin/moderation_ledger.rs
- modify backend/src/plugins/builtin/proposal_lifecycle.rs
- modify backend/src/plugins/builtin/public_data_export.rs
- modify backend/src/plugins/builtin/self_moderation.rs
- modify backend/src/plugins/builtin/structured_deliberation.rs
- modify backend/src/plugins/hooks.rs
- modify backend/src/plugins/manager.rs
- modify backend/src/plugins/wasm/host_api.rs
- modify backend/src/plugins/wasm/plugin.rs
- modify backend/src/plugins/wasm/runtime.rs
- modify backend/src/rate_limit.rs
- modify backend/src/voting/mod.rs
- modify backend/src/voting/quadratic.rs
- modify backend/src/voting/ranked_choice.rs
- modify backend/src/voting/schulze.rs
- modify backend/src/voting/star.rs

Diffstat:
- 56 files changed, 2697 insertions(+), 1629 deletions(-)
2026-02-03 17:54:39 +01:00

632 lines
19 KiB
Rust

//! Deliberation API endpoints for structured democratic discourse.
use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sqlx::PgPool;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::plugins::builtin::structured_deliberation::{Argument, DeliberationService, Summary};
// ============================================================================
// Types
// ============================================================================
#[derive(Debug, Serialize)]
pub struct ProposalResource {
pub id: Uuid,
pub proposal_id: Uuid,
pub title: String,
pub resource_type: String,
pub content: Option<String>,
pub url: Option<String>,
pub author_name: Option<String>,
pub sort_order: i32,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateResourceRequest {
pub title: String,
pub resource_type: String,
pub content: Option<String>,
pub url: Option<String>,
pub author_name: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ResourceReadStatus {
pub resource_id: Uuid,
pub has_read: bool,
pub read_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize)]
pub struct ProposalPosition {
pub id: Uuid,
pub proposal_id: Uuid,
pub user_id: Uuid,
pub position: String,
pub reasoning: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct SetPositionRequest {
pub position: String,
pub reasoning: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct PositionSummary {
pub strongly_support: i64,
pub support: i64,
pub neutral: i64,
pub oppose: i64,
pub strongly_oppose: i64,
pub total: i64,
}
#[derive(Debug, Deserialize)]
pub struct ReactToCommentRequest {
pub reaction_type: String,
}
#[derive(Debug, Serialize)]
pub struct CommentReactions {
pub agree: i64,
pub disagree: i64,
pub insightful: i64,
pub off_topic: i64,
pub constructive: i64,
}
#[derive(Debug, Deserialize)]
pub struct AddArgumentRequest {
pub stance: String,
pub title: String,
pub content: String,
pub parent_id: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
pub struct VoteArgumentRequest {
pub vote_type: String,
}
#[derive(Debug, Deserialize)]
pub struct ArgumentsQuery {
pub stance: Option<String>,
pub limit: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct UpsertSummaryRequest {
pub summary_type: String,
pub content: String,
pub key_points: Option<Value>,
}
#[derive(Debug, Deserialize)]
pub struct RecordReadingRequest {
pub read_type: String,
pub time_seconds: i32,
}
#[derive(Debug, Serialize)]
pub struct CanParticipateResponse {
pub can_comment: bool,
pub can_vote: bool,
}
// ============================================================================
// Handlers
// ============================================================================
/// List resources for a proposal's inform phase
async fn list_resources(
Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<Vec<ProposalResource>>, (StatusCode, String)> {
let resources = sqlx::query_as!(
ProposalResource,
r#"SELECT id, proposal_id, title, resource_type, content, url, author_name, sort_order, created_at
FROM proposal_resources
WHERE proposal_id = $1
ORDER BY sort_order"#,
proposal_id
)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(resources))
}
/// Add a resource to a proposal
async fn add_resource(
auth: AuthUser,
Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>,
Json(req): Json<CreateResourceRequest>,
) -> Result<Json<ProposalResource>, (StatusCode, String)> {
// Check if user is proposal author or facilitator
let proposal = sqlx::query!(
"SELECT author_id, facilitator_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()))?;
let can_add =
proposal.author_id == auth.user_id || proposal.facilitator_id == Some(auth.user_id);
if !can_add {
return Err((
StatusCode::FORBIDDEN,
"Not authorized to add resources".to_string(),
));
}
let resource = sqlx::query_as!(
ProposalResource,
r#"INSERT INTO proposal_resources (proposal_id, title, resource_type, content, url, author_name, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, proposal_id, title, resource_type, content, url, author_name, sort_order, created_at"#,
proposal_id,
req.title,
req.resource_type,
req.content,
req.url,
req.author_name,
auth.user_id
)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(resource))
}
/// Mark a resource as read
async fn mark_resource_read(
auth: AuthUser,
Path(resource_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<ResourceReadStatus>, (StatusCode, String)> {
sqlx::query!(
r#"INSERT INTO proposal_resource_reads (resource_id, user_id)
VALUES ($1, $2)
ON CONFLICT (resource_id, user_id) DO NOTHING"#,
resource_id,
auth.user_id
)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let read = sqlx::query!(
"SELECT read_at FROM proposal_resource_reads WHERE resource_id = $1 AND user_id = $2",
resource_id,
auth.user_id
)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(ResourceReadStatus {
resource_id,
has_read: read.is_some(),
read_at: read.map(|r| r.read_at),
}))
}
/// Get user's read status for all resources of a proposal
async fn get_read_status(
auth: AuthUser,
Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<Vec<ResourceReadStatus>>, (StatusCode, String)> {
let statuses = sqlx::query!(
r#"SELECT r.id as resource_id, rr.read_at as "read_at?"
FROM proposal_resources r
LEFT JOIN proposal_resource_reads rr ON r.id = rr.resource_id AND rr.user_id = $2
WHERE r.proposal_id = $1
ORDER BY r.sort_order"#,
proposal_id,
auth.user_id
)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(
statuses
.into_iter()
.map(|s| ResourceReadStatus {
resource_id: s.resource_id,
has_read: s.read_at.is_some(),
read_at: s.read_at,
})
.collect(),
))
}
/// Set user's position on a proposal
async fn set_position(
auth: AuthUser,
Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>,
Json(req): Json<SetPositionRequest>,
) -> Result<Json<ProposalPosition>, (StatusCode, String)> {
let position = sqlx::query_as!(
ProposalPosition,
r#"INSERT INTO proposal_positions (proposal_id, user_id, position, reasoning)
VALUES ($1, $2, $3, $4)
ON CONFLICT (proposal_id, user_id)
DO UPDATE SET position = $3, reasoning = $4, updated_at = NOW()
RETURNING id, proposal_id, user_id, position, reasoning, created_at"#,
proposal_id,
auth.user_id,
req.position,
req.reasoning
)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(position))
}
/// Get position summary for a proposal (agreement visualization)
async fn get_position_summary(
Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<PositionSummary>, (StatusCode, String)> {
let counts = sqlx::query!(
r#"SELECT
COUNT(*) FILTER (WHERE position = 'strongly_support') as strongly_support,
COUNT(*) FILTER (WHERE position = 'support') as support,
COUNT(*) FILTER (WHERE position = 'neutral') as neutral,
COUNT(*) FILTER (WHERE position = 'oppose') as oppose,
COUNT(*) FILTER (WHERE position = 'strongly_oppose') as strongly_oppose,
COUNT(*) as total
FROM proposal_positions
WHERE proposal_id = $1"#,
proposal_id
)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(PositionSummary {
strongly_support: counts.strongly_support.unwrap_or(0),
support: counts.support.unwrap_or(0),
neutral: counts.neutral.unwrap_or(0),
oppose: counts.oppose.unwrap_or(0),
strongly_oppose: counts.strongly_oppose.unwrap_or(0),
total: counts.total.unwrap_or(0),
}))
}
/// React to a comment
async fn react_to_comment(
auth: AuthUser,
Path(comment_id): Path<Uuid>,
State(pool): State<PgPool>,
Json(req): Json<ReactToCommentRequest>,
) -> Result<Json<CommentReactions>, (StatusCode, String)> {
// Add reaction (toggle - remove if exists, add if not)
let existing = sqlx::query!(
"SELECT id FROM comment_reactions WHERE comment_id = $1 AND user_id = $2 AND reaction_type = $3",
comment_id,
auth.user_id,
req.reaction_type
)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if existing.is_some() {
sqlx::query!(
"DELETE FROM comment_reactions WHERE comment_id = $1 AND user_id = $2 AND reaction_type = $3",
comment_id,
auth.user_id,
req.reaction_type
)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
} else {
sqlx::query!(
"INSERT INTO comment_reactions (comment_id, user_id, reaction_type) VALUES ($1, $2, $3)",
comment_id,
auth.user_id,
req.reaction_type
)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
// Return updated counts
get_comment_reactions_internal(&pool, comment_id).await
}
/// Get reactions for a comment
async fn get_comment_reactions(
Path(comment_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<CommentReactions>, (StatusCode, String)> {
get_comment_reactions_internal(&pool, comment_id).await
}
async fn get_comment_reactions_internal(
pool: &PgPool,
comment_id: Uuid,
) -> Result<Json<CommentReactions>, (StatusCode, String)> {
let counts = sqlx::query!(
r#"SELECT
COUNT(*) FILTER (WHERE reaction_type = 'agree') as agree,
COUNT(*) FILTER (WHERE reaction_type = 'disagree') as disagree,
COUNT(*) FILTER (WHERE reaction_type = 'insightful') as insightful,
COUNT(*) FILTER (WHERE reaction_type = 'off_topic') as off_topic,
COUNT(*) FILTER (WHERE reaction_type = 'constructive') as constructive
FROM comment_reactions
WHERE comment_id = $1"#,
comment_id
)
.fetch_one(pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(CommentReactions {
agree: counts.agree.unwrap_or(0),
disagree: counts.disagree.unwrap_or(0),
insightful: counts.insightful.unwrap_or(0),
off_topic: counts.off_topic.unwrap_or(0),
constructive: counts.constructive.unwrap_or(0),
}))
}
// ============================================================================
// Argument Handlers (wired to DeliberationService)
// ============================================================================
/// Get arguments for a proposal
async fn list_arguments(
Path(proposal_id): Path<Uuid>,
Query(query): Query<ArgumentsQuery>,
State(pool): State<PgPool>,
) -> Result<Json<Vec<Argument>>, (StatusCode, String)> {
let limit = query.limit.unwrap_or(50);
let arguments =
DeliberationService::get_arguments(&pool, proposal_id, query.stance.as_deref(), limit)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(arguments))
}
/// Add an argument to a proposal
async fn add_argument(
auth: AuthUser,
Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>,
Json(req): Json<AddArgumentRequest>,
) -> Result<Json<Value>, (StatusCode, String)> {
// Check if user can participate
let can =
DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment")
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !can {
return Err((
StatusCode::FORBIDDEN,
"Must read proposal content before participating".to_string(),
));
}
let argument_id = DeliberationService::add_argument(
&pool,
proposal_id,
req.parent_id,
&req.stance,
&req.title,
&req.content,
auth.user_id,
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(json!({"id": argument_id})))
}
/// Vote on an argument
async fn vote_argument(
auth: AuthUser,
Path(argument_id): Path<Uuid>,
State(pool): State<PgPool>,
Json(req): Json<VoteArgumentRequest>,
) -> Result<Json<Value>, (StatusCode, String)> {
DeliberationService::vote_argument(&pool, argument_id, auth.user_id, &req.vote_type)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(json!({"success": true})))
}
// ============================================================================
// Summary Handlers (wired to DeliberationService)
// ============================================================================
/// Get summaries for a proposal
async fn list_summaries(
Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<Vec<Summary>>, (StatusCode, String)> {
let summaries = DeliberationService::get_summaries(&pool, proposal_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(summaries))
}
/// Create or update a summary
async fn upsert_summary(
auth: AuthUser,
Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>,
Json(req): Json<UpsertSummaryRequest>,
) -> Result<Json<Value>, (StatusCode, String)> {
let key_points = req.key_points.unwrap_or(json!([]));
let summary_id = DeliberationService::upsert_summary(
&pool,
proposal_id,
&req.summary_type,
&req.content,
key_points,
auth.user_id,
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(json!({"id": summary_id})))
}
/// Approve a summary
async fn approve_summary(
auth: AuthUser,
Path(summary_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<Value>, (StatusCode, String)> {
DeliberationService::approve_summary(&pool, summary_id, auth.user_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(json!({"success": true})))
}
// ============================================================================
// Reading/Participation Handlers (wired to DeliberationService)
// ============================================================================
/// Record reading progress
async fn record_reading(
auth: AuthUser,
Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>,
Json(req): Json<RecordReadingRequest>,
) -> Result<Json<Value>, (StatusCode, String)> {
DeliberationService::record_reading(
&pool,
proposal_id,
auth.user_id,
&req.read_type,
req.time_seconds,
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(json!({"success": true})))
}
/// Check if user can participate
async fn check_can_participate(
auth: AuthUser,
Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<CanParticipateResponse>, (StatusCode, String)> {
let can_comment =
DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment")
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let can_vote =
DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "vote")
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(CanParticipateResponse {
can_comment,
can_vote,
}))
}
/// Get deliberation overview (metrics + top arguments + summaries)
async fn get_overview(
Path(proposal_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<Value>, (StatusCode, String)> {
let overview = DeliberationService::get_overview(&pool, proposal_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(overview))
}
// ============================================================================
// Router
// ============================================================================
pub fn router(pool: PgPool) -> Router {
Router::new()
// Resources (inform phase)
.route(
"/api/proposals/{proposal_id}/resources",
get(list_resources).post(add_resource),
)
.route(
"/api/proposals/{proposal_id}/resources/read-status",
get(get_read_status),
)
.route(
"/api/resources/{resource_id}/read",
post(mark_resource_read),
)
// Positions (agreement visualization)
.route("/api/proposals/{proposal_id}/positions", post(set_position))
.route(
"/api/proposals/{proposal_id}/positions/summary",
get(get_position_summary),
)
// Comment reactions (quality scoring)
.route(
"/api/comments/{comment_id}/reactions",
get(get_comment_reactions).post(react_to_comment),
)
// Arguments (structured debate) - wired to DeliberationService
.route(
"/api/proposals/{proposal_id}/arguments",
get(list_arguments).post(add_argument),
)
.route("/api/arguments/{argument_id}/vote", post(vote_argument))
// Summaries (collaborative summaries) - wired to DeliberationService
.route(
"/api/proposals/{proposal_id}/summaries",
get(list_summaries).post(upsert_summary),
)
.route("/api/summaries/{summary_id}/approve", post(approve_summary))
// Reading/participation tracking - wired to DeliberationService
.route("/api/proposals/{proposal_id}/reading", post(record_reading))
.route(
"/api/proposals/{proposal_id}/can-participate",
get(check_can_participate),
)
// Overview (combined metrics)
.route(
"/api/proposals/{proposal_id}/deliberation",
get(get_overview),
)
.with_state(pool)
}