//! Inquiry management API routes use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, routing::{get, post}, Json, Router, }; use std::sync::Arc; use validator::Validate; use attune_common::{ mq::{InquiryRespondedPayload, MessageEnvelope, MessageType}, repositories::{ execution::ExecutionRepository, inquiry::{CreateInquiryInput, InquiryRepository, UpdateInquiryInput}, Create, Delete, FindById, List, Update, }, }; use crate::auth::RequireAuth; use crate::{ dto::{ common::{PaginatedResponse, PaginationParams}, inquiry::{ CreateInquiryRequest, InquiryQueryParams, InquiryRespondRequest, InquiryResponse, InquirySummary, UpdateInquiryRequest, }, ApiResponse, SuccessResponse, }, middleware::{ApiError, ApiResult}, state::AppState, }; /// List all inquiries with pagination and optional filters #[utoipa::path( get, path = "/api/v1/inquiries", tag = "inquiries", params(InquiryQueryParams), security(("bearer_auth" = [])), responses( (status = 200, description = "List of inquiries", body = PaginatedResponse), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ) )] pub async fn list_inquiries( _user: RequireAuth, State(state): State>, Query(query): Query, ) -> ApiResult { // Get inquiries based on filters let inquiries = if let Some(status) = query.status { // Filter by status InquiryRepository::find_by_status(&state.db, status).await? } else if let Some(execution_id) = query.execution { // Filter by execution InquiryRepository::find_by_execution(&state.db, execution_id).await? } else { // Get all inquiries InquiryRepository::list(&state.db).await? }; // Apply additional filters in memory let mut filtered_inquiries = inquiries; if let Some(assigned_to) = query.assigned_to { filtered_inquiries.retain(|i| i.assigned_to == Some(assigned_to)); } // Calculate pagination let total = filtered_inquiries.len() as u64; let offset = query.offset.unwrap_or(0); let limit = query.limit.unwrap_or(50).min(500); let start = offset; let end = (start + limit).min(filtered_inquiries.len()); // Get paginated slice let paginated_inquiries: Vec = filtered_inquiries[start..end] .iter() .map(|inquiry| InquirySummary::from(inquiry.clone())) .collect(); // Convert to pagination params for response let pagination_params = PaginationParams { page: (offset / limit.max(1)) as u32 + 1, page_size: limit as u32, }; let response = PaginatedResponse::new(paginated_inquiries, &pagination_params, total); Ok((StatusCode::OK, Json(response))) } /// Get a single inquiry by ID #[utoipa::path( get, path = "/api/v1/inquiries/{id}", tag = "inquiries", params( ("id" = i64, Path, description = "Inquiry ID") ), security(("bearer_auth" = [])), responses( (status = 200, description = "Inquiry details", body = ApiResponse), (status = 401, description = "Unauthorized"), (status = 404, description = "Inquiry not found"), (status = 500, description = "Internal server error") ) )] pub async fn get_inquiry( _user: RequireAuth, State(state): State>, Path(id): Path, ) -> ApiResult { let inquiry = InquiryRepository::find_by_id(&state.db, id) .await? .ok_or_else(|| ApiError::NotFound(format!("Inquiry with ID {} not found", id)))?; let response = ApiResponse::new(InquiryResponse::from(inquiry)); Ok((StatusCode::OK, Json(response))) } /// List inquiries by status #[utoipa::path( get, path = "/api/v1/inquiries/status/{status}", tag = "inquiries", params( ("status" = String, Path, description = "Inquiry status (pending, responded, timeout, canceled)"), PaginationParams ), security(("bearer_auth" = [])), responses( (status = 200, description = "List of inquiries with specified status", body = PaginatedResponse), (status = 400, description = "Invalid status"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ) )] pub async fn list_inquiries_by_status( _user: RequireAuth, State(state): State>, Path(status_str): Path, Query(pagination): Query, ) -> ApiResult { // Parse status from string let status = match status_str.to_lowercase().as_str() { "pending" => attune_common::models::enums::InquiryStatus::Pending, "responded" => attune_common::models::enums::InquiryStatus::Responded, "timeout" => attune_common::models::enums::InquiryStatus::Timeout, "canceled" => attune_common::models::enums::InquiryStatus::Cancelled, _ => { return Err(ApiError::BadRequest(format!( "Invalid inquiry status: '{}'. Valid values are: pending, responded, timeout, canceled", status_str ))) } }; let inquiries = InquiryRepository::find_by_status(&state.db, status).await?; // Calculate pagination let total = inquiries.len() as u64; let start = ((pagination.page - 1) * pagination.limit()) as usize; let end = (start + pagination.limit() as usize).min(inquiries.len()); // Get paginated slice let paginated_inquiries: Vec = inquiries[start..end] .iter() .map(|inquiry| InquirySummary::from(inquiry.clone())) .collect(); let response = PaginatedResponse::new(paginated_inquiries, &pagination, total); Ok((StatusCode::OK, Json(response))) } /// List inquiries for a specific execution #[utoipa::path( get, path = "/api/v1/executions/{execution_id}/inquiries", tag = "inquiries", params( ("execution_id" = i64, Path, description = "Execution ID"), PaginationParams ), security(("bearer_auth" = [])), responses( (status = 200, description = "List of inquiries for execution", body = PaginatedResponse), (status = 401, description = "Unauthorized"), (status = 404, description = "Execution not found"), (status = 500, description = "Internal server error") ) )] pub async fn list_inquiries_by_execution( _user: RequireAuth, State(state): State>, Path(execution_id): Path, Query(pagination): Query, ) -> ApiResult { // Verify execution exists let _execution = ExecutionRepository::find_by_id(&state.db, execution_id) .await? .ok_or_else(|| { ApiError::NotFound(format!("Execution with ID {} not found", execution_id)) })?; let inquiries = InquiryRepository::find_by_execution(&state.db, execution_id).await?; // Calculate pagination let total = inquiries.len() as u64; let start = ((pagination.page - 1) * pagination.limit()) as usize; let end = (start + pagination.limit() as usize).min(inquiries.len()); // Get paginated slice let paginated_inquiries: Vec = inquiries[start..end] .iter() .map(|inquiry| InquirySummary::from(inquiry.clone())) .collect(); let response = PaginatedResponse::new(paginated_inquiries, &pagination, total); Ok((StatusCode::OK, Json(response))) } /// Create a new inquiry #[utoipa::path( post, path = "/api/v1/inquiries", tag = "inquiries", request_body = CreateInquiryRequest, security(("bearer_auth" = [])), responses( (status = 201, description = "Inquiry created successfully", body = ApiResponse), (status = 400, description = "Invalid request"), (status = 401, description = "Unauthorized"), (status = 404, description = "Execution not found"), (status = 500, description = "Internal server error") ) )] pub async fn create_inquiry( _user: RequireAuth, State(state): State>, Json(request): Json, ) -> ApiResult { // Validate request request.validate()?; // Verify execution exists let _execution = ExecutionRepository::find_by_id(&state.db, request.execution) .await? .ok_or_else(|| { ApiError::NotFound(format!("Execution with ID {} not found", request.execution)) })?; // Create inquiry input let inquiry_input = CreateInquiryInput { execution: request.execution, prompt: request.prompt, response_schema: request.response_schema, assigned_to: request.assigned_to, status: attune_common::models::enums::InquiryStatus::Pending, response: None, timeout_at: request.timeout_at, }; let inquiry = InquiryRepository::create(&state.db, inquiry_input).await?; let response = ApiResponse::with_message( InquiryResponse::from(inquiry), "Inquiry created successfully", ); Ok((StatusCode::CREATED, Json(response))) } /// Update an existing inquiry #[utoipa::path( put, path = "/api/v1/inquiries/{id}", tag = "inquiries", params( ("id" = i64, Path, description = "Inquiry ID") ), request_body = UpdateInquiryRequest, security(("bearer_auth" = [])), responses( (status = 200, description = "Inquiry updated successfully", body = ApiResponse), (status = 400, description = "Invalid request"), (status = 401, description = "Unauthorized"), (status = 404, description = "Inquiry not found"), (status = 500, description = "Internal server error") ) )] pub async fn update_inquiry( _user: RequireAuth, State(state): State>, Path(id): Path, Json(request): Json, ) -> ApiResult { // Validate request request.validate()?; // Verify inquiry exists let _existing = InquiryRepository::find_by_id(&state.db, id) .await? .ok_or_else(|| ApiError::NotFound(format!("Inquiry with ID {} not found", id)))?; // Create update input let update_input = UpdateInquiryInput { status: request.status, response: request.response, responded_at: None, // Let the database handle this if needed assigned_to: request.assigned_to, }; let updated_inquiry = InquiryRepository::update(&state.db, id, update_input).await?; let response = ApiResponse::with_message( InquiryResponse::from(updated_inquiry), "Inquiry updated successfully", ); Ok((StatusCode::OK, Json(response))) } /// Respond to an inquiry (user-facing endpoint) #[utoipa::path( post, path = "/api/v1/inquiries/{id}/respond", tag = "inquiries", params( ("id" = i64, Path, description = "Inquiry ID") ), request_body = InquiryRespondRequest, security(("bearer_auth" = [])), responses( (status = 200, description = "Response submitted successfully", body = ApiResponse), (status = 400, description = "Invalid request or inquiry cannot be responded to"), (status = 401, description = "Unauthorized"), (status = 403, description = "Not authorized to respond to this inquiry"), (status = 404, description = "Inquiry not found"), (status = 500, description = "Internal server error") ) )] pub async fn respond_to_inquiry( user: RequireAuth, State(state): State>, Path(id): Path, Json(request): Json, ) -> ApiResult { // Validate request request.validate()?; // Verify inquiry exists and is in pending status let inquiry = InquiryRepository::find_by_id(&state.db, id) .await? .ok_or_else(|| ApiError::NotFound(format!("Inquiry with ID {} not found", id)))?; // Check if inquiry is still pending if inquiry.status != attune_common::models::enums::InquiryStatus::Pending { return Err(ApiError::BadRequest(format!( "Cannot respond to inquiry with status '{:?}'. Only pending inquiries can be responded to.", inquiry.status ))); } // Check if inquiry is assigned to this user (optional enforcement) if let Some(assigned_to) = inquiry.assigned_to { let user_id = user .0 .identity_id() .map_err(|_| ApiError::InternalServerError("Invalid user identity".to_string()))?; if assigned_to != user_id { return Err(ApiError::Forbidden( "You are not authorized to respond to this inquiry".to_string(), )); } } // Check if inquiry has timed out if let Some(timeout_at) = inquiry.timeout_at { if timeout_at < chrono::Utc::now() { // Update inquiry to timeout status let timeout_input = UpdateInquiryInput { status: Some(attune_common::models::enums::InquiryStatus::Timeout), response: None, responded_at: None, assigned_to: None, }; let _ = InquiryRepository::update(&state.db, id, timeout_input).await?; return Err(ApiError::BadRequest( "Inquiry has timed out and can no longer be responded to".to_string(), )); } } // TODO: Validate response against response_schema if present // For now, just accept the response as-is // Create update input with response let update_input = UpdateInquiryInput { status: Some(attune_common::models::enums::InquiryStatus::Responded), response: Some(request.response.clone()), responded_at: Some(chrono::Utc::now()), assigned_to: None, }; let updated_inquiry = InquiryRepository::update(&state.db, id, update_input).await?; // Publish InquiryResponded message if publisher is available if let Some(publisher) = &state.publisher { let user_id = user .0 .identity_id() .map_err(|_| ApiError::InternalServerError("Invalid user identity".to_string()))?; let payload = InquiryRespondedPayload { inquiry_id: id, execution_id: inquiry.execution, response: request.response.clone(), responded_by: Some(user_id), responded_at: chrono::Utc::now(), }; let envelope = MessageEnvelope::new(MessageType::InquiryResponded, payload).with_source("api"); if let Err(e) = publisher.publish_envelope(&envelope).await { tracing::error!("Failed to publish InquiryResponded message: {}", e); // Don't fail the request - inquiry is already saved } else { tracing::info!("Published InquiryResponded message for inquiry {}", id); } } else { tracing::warn!("No publisher available to publish InquiryResponded message"); } let response = ApiResponse::with_message( InquiryResponse::from(updated_inquiry), "Response submitted successfully", ); Ok((StatusCode::OK, Json(response))) } /// Delete an inquiry #[utoipa::path( delete, path = "/api/v1/inquiries/{id}", tag = "inquiries", params( ("id" = i64, Path, description = "Inquiry ID") ), security(("bearer_auth" = [])), responses( (status = 200, description = "Inquiry deleted successfully", body = SuccessResponse), (status = 401, description = "Unauthorized"), (status = 404, description = "Inquiry not found"), (status = 500, description = "Internal server error") ) )] pub async fn delete_inquiry( _user: RequireAuth, State(state): State>, Path(id): Path, ) -> ApiResult { // Verify inquiry exists let _inquiry = InquiryRepository::find_by_id(&state.db, id) .await? .ok_or_else(|| ApiError::NotFound(format!("Inquiry with ID {} not found", id)))?; // Delete the inquiry let deleted = InquiryRepository::delete(&state.db, id).await?; if !deleted { return Err(ApiError::NotFound(format!( "Inquiry with ID {} not found", id ))); } let response = SuccessResponse::new("Inquiry deleted successfully"); Ok((StatusCode::OK, Json(response))) } /// Register inquiry routes pub fn routes() -> Router> { Router::new() .route("/inquiries", get(list_inquiries).post(create_inquiry)) .route( "/inquiries/{id}", get(get_inquiry).put(update_inquiry).delete(delete_inquiry), ) .route("/inquiries/status/{status}", get(list_inquiries_by_status)) .route( "/executions/{execution_id}/inquiries", get(list_inquiries_by_execution), ) .route("/inquiries/{id}/respond", post(respond_to_inquiry)) }