re-uploading work

This commit is contained in:
2026-02-04 17:46:30 -06:00
commit 3b14c65998
1388 changed files with 381262 additions and 0 deletions

View File

@@ -0,0 +1,507 @@
//! 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<InquirySummary>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
)
)]
pub async fn list_inquiries(
_user: RequireAuth,
State(state): State<Arc<AppState>>,
Query(query): Query<InquiryQueryParams>,
) -> ApiResult<impl IntoResponse> {
// 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<InquirySummary> = 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<InquiryResponse>),
(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<Arc<AppState>>,
Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
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<InquirySummary>),
(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<Arc<AppState>>,
Path(status_str): Path<String>,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<impl IntoResponse> {
// 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<InquirySummary> = 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<InquirySummary>),
(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<Arc<AppState>>,
Path(execution_id): Path<i64>,
Query(pagination): Query<PaginationParams>,
) -> ApiResult<impl IntoResponse> {
// 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<InquirySummary> = 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<InquiryResponse>),
(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<Arc<AppState>>,
Json(request): Json<CreateInquiryRequest>,
) -> ApiResult<impl IntoResponse> {
// 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<InquiryResponse>),
(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<Arc<AppState>>,
Path(id): Path<i64>,
Json(request): Json<UpdateInquiryRequest>,
) -> ApiResult<impl IntoResponse> {
// 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<InquiryResponse>),
(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<Arc<AppState>>,
Path(id): Path<i64>,
Json(request): Json<InquiryRespondRequest>,
) -> ApiResult<impl IntoResponse> {
// 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<Arc<AppState>>,
Path(id): Path<i64>,
) -> ApiResult<impl IntoResponse> {
// 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<Arc<AppState>> {
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))
}