2026-01-27 16:21:58 +00:00
|
|
|
//! Deliberation API endpoints for structured democratic discourse.
|
|
|
|
|
|
|
|
|
|
use axum::{
|
|
|
|
|
extract::{Path, Query, State},
|
|
|
|
|
http::StatusCode,
|
|
|
|
|
routing::{get, post},
|
|
|
|
|
Json, Router,
|
|
|
|
|
};
|
2026-02-03 16:54:39 +00:00
|
|
|
use chrono::{DateTime, Utc};
|
2026-01-27 16:21:58 +00:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
use sqlx::PgPool;
|
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
|
|
|
use crate::auth::AuthUser;
|
2026-02-03 16:54:39 +00:00
|
|
|
use crate::plugins::builtin::structured_deliberation::{Argument, DeliberationService, Summary};
|
2026-01-27 16:21:58 +00:00
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// 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()))?;
|
|
|
|
|
|
2026-02-03 16:54:39 +00:00
|
|
|
let can_add =
|
|
|
|
|
proposal.author_id == auth.user_id || proposal.facilitator_id == Some(auth.user_id);
|
2026-01-27 16:21:58 +00:00
|
|
|
|
|
|
|
|
if !can_add {
|
2026-02-03 16:54:39 +00:00
|
|
|
return Err((
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"Not authorized to add resources".to_string(),
|
|
|
|
|
));
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()))?;
|
|
|
|
|
|
2026-02-03 16:54:39 +00:00
|
|
|
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(),
|
|
|
|
|
))
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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);
|
2026-02-03 16:54:39 +00:00
|
|
|
let arguments =
|
|
|
|
|
DeliberationService::get_arguments(&pool, proposal_id, query.stance.as_deref(), limit)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
2026-01-27 16:21:58 +00:00
|
|
|
|
|
|
|
|
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
|
2026-02-03 16:54:39 +00:00
|
|
|
let can =
|
|
|
|
|
DeliberationService::check_can_participate(&pool, proposal_id, auth.user_id, "comment")
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
2026-01-27 16:21:58 +00:00
|
|
|
|
|
|
|
|
if !can {
|
2026-02-03 16:54:39 +00:00
|
|
|
return Err((
|
|
|
|
|
StatusCode::FORBIDDEN,
|
|
|
|
|
"Must read proposal content before participating".to_string(),
|
|
|
|
|
));
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)> {
|
2026-02-03 16:54:39 +00:00
|
|
|
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,
|
|
|
|
|
}))
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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)
|
2026-02-03 16:54:39 +00:00
|
|
|
.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),
|
|
|
|
|
)
|
2026-01-27 16:21:58 +00:00
|
|
|
// Positions (agreement visualization)
|
|
|
|
|
.route("/api/proposals/{proposal_id}/positions", post(set_position))
|
2026-02-03 16:54:39 +00:00
|
|
|
.route(
|
|
|
|
|
"/api/proposals/{proposal_id}/positions/summary",
|
|
|
|
|
get(get_position_summary),
|
|
|
|
|
)
|
2026-01-27 16:21:58 +00:00
|
|
|
// Comment reactions (quality scoring)
|
2026-02-03 16:54:39 +00:00
|
|
|
.route(
|
|
|
|
|
"/api/comments/{comment_id}/reactions",
|
|
|
|
|
get(get_comment_reactions).post(react_to_comment),
|
|
|
|
|
)
|
2026-01-27 16:21:58 +00:00
|
|
|
// Arguments (structured debate) - wired to DeliberationService
|
2026-02-03 16:54:39 +00:00
|
|
|
.route(
|
|
|
|
|
"/api/proposals/{proposal_id}/arguments",
|
|
|
|
|
get(list_arguments).post(add_argument),
|
|
|
|
|
)
|
2026-01-27 16:21:58 +00:00
|
|
|
.route("/api/arguments/{argument_id}/vote", post(vote_argument))
|
|
|
|
|
// Summaries (collaborative summaries) - wired to DeliberationService
|
2026-02-03 16:54:39 +00:00
|
|
|
.route(
|
|
|
|
|
"/api/proposals/{proposal_id}/summaries",
|
|
|
|
|
get(list_summaries).post(upsert_summary),
|
|
|
|
|
)
|
2026-01-27 16:21:58 +00:00
|
|
|
.route("/api/summaries/{summary_id}/approve", post(approve_summary))
|
|
|
|
|
// Reading/participation tracking - wired to DeliberationService
|
|
|
|
|
.route("/api/proposals/{proposal_id}/reading", post(record_reading))
|
2026-02-03 16:54:39 +00:00
|
|
|
.route(
|
|
|
|
|
"/api/proposals/{proposal_id}/can-participate",
|
|
|
|
|
get(check_can_participate),
|
|
|
|
|
)
|
2026-01-27 16:21:58 +00:00
|
|
|
// Overview (combined metrics)
|
2026-02-03 16:54:39 +00:00
|
|
|
.route(
|
|
|
|
|
"/api/proposals/{proposal_id}/deliberation",
|
|
|
|
|
get(get_overview),
|
|
|
|
|
)
|
2026-01-27 16:21:58 +00:00
|
|
|
.with_state(pool)
|
|
|
|
|
}
|