use axum::{ Json, extract::{Path, State}, http::StatusCode, }; use common::auth::Claims; use common::middleware::Org; use common::models::{AnnouncementWithAuthor, CourseAnnouncement}; use serde::Deserialize; use sqlx::PgPool; use uuid::Uuid; // ========== Request/Response DTOs ========== #[derive(Deserialize)] pub struct CreateAnnouncementPayload { pub title: String, pub content: String, pub is_pinned: Option, pub cohort_ids: Option>, } #[derive(Deserialize)] pub struct UpdateAnnouncementPayload { pub title: Option, pub content: Option, pub is_pinned: Option, } // ========== HANDLERS ========== pub async fn list_announcements( Org(org_ctx): Org, Path(course_id): Path, State(pool): State, ) -> Result>, (StatusCode, String)> { let mut announcements = sqlx::query_as::<_, AnnouncementWithAuthor>( "SELECT a.*, u.full_name as author_name, u.avatar_url as author_avatar FROM course_announcements a LEFT JOIN users u ON a.author_id = u.id WHERE a.course_id = $1 AND a.organization_id = $2 ORDER BY a.is_pinned DESC, a.created_at DESC", ) .bind(course_id) .bind(org_ctx.id) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Attach cohort_ids to each announcement for a in &mut announcements { let cohorts: Vec<(Uuid,)> = sqlx::query_as( "SELECT cohort_id FROM announcement_cohorts WHERE announcement_id = $1" ) .bind(a.id) .fetch_all(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if !cohorts.is_empty() { a.cohort_ids = Some(cohorts.into_iter().map(|c| c.0).collect()); } } Ok(Json(announcements)) } pub async fn create_announcement( Org(org_ctx): Org, claims: Claims, Path(course_id): Path, State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { // Check if user is instructor or admin let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1") .bind(claims.sub) .fetch_one(&pool) .await .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; if user.0 != "instructor" && user.0 != "admin" { return Err(( StatusCode::FORBIDDEN, "Only instructors can create announcements".to_string(), )); } let mut tx = pool .begin() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // 1. Create announcement let mut announcement = sqlx::query_as::<_, CourseAnnouncement>( "INSERT INTO course_announcements (organization_id, course_id, author_id, title, content, is_pinned) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *" ) .bind(org_ctx.id) .bind(course_id) .bind(claims.sub) .bind(&payload.title) .bind(&payload.content) .bind(payload.is_pinned.unwrap_or(false)) .fetch_one(&mut *tx) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // 2. Link cohorts if provided if let Some(ref cohort_ids) = payload.cohort_ids { for cohort_id in cohort_ids { sqlx::query( "INSERT INTO announcement_cohorts (announcement_id, cohort_id) VALUES ($1, $2)", ) .bind(announcement.id) .bind(cohort_id) .execute(&mut *tx) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; } announcement.cohort_ids = Some(cohort_ids.clone()); } tx.commit() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // 3. Get target students for notifications let enrolled_students = if let Some(ref cohort_ids) = payload.cohort_ids { if !cohort_ids.is_empty() { sqlx::query_as::<_, (Uuid,)>( "SELECT DISTINCT uc.user_id FROM user_cohorts uc JOIN enrollments e ON uc.user_id = e.user_id AND e.course_id = $1 WHERE uc.cohort_id = ANY($2) AND uc.user_id != $3", ) .bind(course_id) .bind(cohort_ids) .bind(claims.sub) .fetch_all(&pool) .await } else { // Fallback to everyone if empty list provided (though UI should prevent) sqlx::query_as::<_, (Uuid,)>( "SELECT user_id FROM enrollments WHERE course_id = $1 AND user_id != $2", ) .bind(course_id) .bind(claims.sub) .fetch_all(&pool) .await } } else { // No segment provided -> everyone in the course sqlx::query_as::<_, (Uuid,)>( "SELECT user_id FROM enrollments WHERE course_id = $1 AND user_id != $2", ) .bind(course_id) .bind(claims.sub) .fetch_all(&pool) .await } .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Create notification for each enrolled student for (student_id,) in enrolled_students { let notification_title = format!("Nuevo Anuncio: {}", payload.title); let notification_message = if payload.content.len() > 100 { format!("{}...", &payload.content[..100]) } else { payload.content.clone() }; let _ = sqlx::query( "INSERT INTO notifications (organization_id, user_id, title, message, notification_type, link_url) VALUES ($1, $2, $3, $4, $5, $6)" ) .bind(org_ctx.id) .bind(student_id) .bind(notification_title) .bind(notification_message) .bind("announcement") .bind(format!("/courses/{}#announcements", course_id)) .execute(&pool) .await; } Ok(Json(announcement)) } pub async fn update_announcement( Org(org_ctx): Org, claims: Claims, Path(announcement_id): Path, State(pool): State, Json(payload): Json, ) -> Result, (StatusCode, String)> { // Check if user is instructor or admin let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1") .bind(claims.sub) .fetch_one(&pool) .await .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; if user.0 != "instructor" && user.0 != "admin" { return Err(( StatusCode::FORBIDDEN, "Only instructors can update announcements".to_string(), )); } // Get current announcement to verify ownership let current = sqlx::query_as::<_, CourseAnnouncement>( "SELECT * FROM course_announcements WHERE id = $1 AND organization_id = $2", ) .bind(announcement_id) .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|_| (StatusCode::NOT_FOUND, "Announcement not found".to_string()))?; let title = payload.title.unwrap_or(current.title); let content = payload.content.unwrap_or(current.content); let is_pinned = payload.is_pinned.unwrap_or(current.is_pinned); let announcement = sqlx::query_as::<_, CourseAnnouncement>( "UPDATE course_announcements SET title = $1, content = $2, is_pinned = $3 WHERE id = $4 AND organization_id = $5 RETURNING *", ) .bind(title) .bind(content) .bind(is_pinned) .bind(announcement_id) .bind(org_ctx.id) .fetch_one(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(announcement)) } pub async fn delete_announcement( Org(org_ctx): Org, claims: Claims, Path(announcement_id): Path, State(pool): State, ) -> Result { // Check if user is instructor or admin let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1") .bind(claims.sub) .fetch_one(&pool) .await .map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?; if user.0 != "instructor" && user.0 != "admin" { return Err(( StatusCode::FORBIDDEN, "Only instructors can delete announcements".to_string(), )); } sqlx::query( "DELETE FROM course_announcements WHERE id = $1 AND organization_id = $2", ) .bind(announcement_id) .bind(org_ctx.id) .execute(&pool) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(StatusCode::OK) }