use axum::{ extract::{Path, State}, http::StatusCode, routing::get, Extension, Json, Router, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::PgPool; use std::sync::Arc; use uuid::Uuid; use crate::auth::AuthUser; use crate::plugins::HookContext; use crate::plugins::PluginManager; use crate::plugins::PluginError; #[derive(Debug, Serialize)] pub struct Comment { pub id: Uuid, pub proposal_id: Uuid, pub author_id: Uuid, pub author_name: String, pub content: String, pub parent_id: Option, pub created_at: DateTime, } #[derive(Debug, Deserialize)] pub struct CreateComment { pub content: String, pub parent_id: Option, } pub fn router(pool: PgPool) -> Router { Router::new() .route("/api/proposals/{proposal_id}/comments", get(list_comments).post(create_comment)) .with_state(pool) } async fn list_comments( Path(proposal_id): Path, State(pool): State, ) -> Result>, (StatusCode, String)> { let comments = sqlx::query!( r#" SELECT c.id, c.proposal_id, c.author_id, c.content, c.parent_id, c.created_at, u.username as author_name FROM comments c JOIN users u ON c.author_id = u.id WHERE c.proposal_id = $1 ORDER BY c.created_at ASC "#, proposal_id ) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let result = comments .into_iter() .map(|c| Comment { id: c.id, proposal_id: c.proposal_id, author_id: c.author_id, author_name: c.author_name, content: c.content, parent_id: c.parent_id, created_at: c.created_at, }) .collect(); Ok(Json(result)) } async fn create_comment( auth: AuthUser, Path(proposal_id): Path, State(pool): State, Extension(plugins): Extension>, Json(req): Json, ) -> Result, (StatusCode, String)> { // Get proposal author for notification let proposal = sqlx::query!( "SELECT author_id, community_id, title 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 filtered = plugins .apply_filters( "comment.create", HookContext { pool: pool.clone(), community_id: Some(proposal.community_id), actor_user_id: Some(auth.user_id), }, json!({ "proposal_id": proposal_id, "content": req.content, "parent_id": req.parent_id, }), ) .await .map_err(|e| match e { PluginError::Message(m) => (StatusCode::BAD_REQUEST, m), PluginError::Sqlx(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), })?; let content = filtered .get("content") .and_then(|v| v.as_str()) .ok_or(( StatusCode::BAD_REQUEST, "Invalid comment.create filter output".to_string(), ))? .to_string(); let parent_id = match filtered.get("parent_id") { Some(v) if v.is_null() => None, Some(v) => v .as_str() .and_then(|s| Uuid::parse_str(s).ok()) .ok_or(( StatusCode::BAD_REQUEST, "Invalid comment.create filter output".to_string(), )) .map(Some)?, None => None, }; let comment = sqlx::query!( r#" INSERT INTO comments (proposal_id, author_id, content, parent_id) VALUES ($1, $2, $3, $4) RETURNING id, created_at "#, proposal_id, auth.user_id, content, parent_id ) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; plugins .do_action( "comment.created", HookContext { pool: pool.clone(), community_id: Some(proposal.community_id), actor_user_id: Some(auth.user_id), }, serde_json::json!({ "proposal_id": proposal_id, "proposal_title": proposal.title, "proposal_author_id": proposal.author_id, "commenter_id": auth.user_id, "commenter_name": auth.username, "content": content, }), ) .await; Ok(Json(Comment { id: comment.id, proposal_id, author_id: auth.user_id, author_name: auth.username.clone(), content, parent_id, created_at: comment.created_at, })) }