//! Operator question answer/cancel endpoints for the dashboard. //! //! `POST /answer-question/{id}` records the operator's answer and fires a //! `QuestionAnswered` event to the asker; `POST /cancel-question/{id}` //! resolves a pending question with a `[cancelled]` sentinel. Both carry a //! permissive CORS header so the per-agent web UI (different origin) can //! POST here until the unifying gateway makes it same-origin. use axum::{ extract::{Form, Path as AxumPath, State}, http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; use utoipa::ToSchema; use problem_details::ProblemDetails; use super::{AppState, error_response}; #[derive(Deserialize, ToSchema)] pub(super) struct AnswerForm { answer: String, } /// Attach a permissive CORS header so the per-agent web UI — served on /// a different port — can POST an operator answer here and read the /// result. The dashboard has no auth, so `*` exposes nothing a plain /// cross-origin form-POST couldn't already reach. This shim disappears /// once the unifying gateway makes the agent page same-origin; see /// `docs/boundary.md`. fn with_cors(resp: impl IntoResponse) -> Response { let mut resp = resp.into_response(); resp.headers_mut().insert( axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, axum::http::HeaderValue::from_static("*"), ); resp } /// Record the operator's answer and /// notify the asker. #[utoipa::path( post, path = "/api/answer-question/{id}", params(("id" = i64, Path, description = "question row id")), request_body(content = AnswerForm, content_type = "application/x-www-form-urlencoded"), responses( (status = 200, description = "answered", body = String), (status = 400, description = "empty answer"), (status = 500, description = "answer failed (already answered, unknown id, ...)"), ), tag = "questions" )] pub(super) async fn post_answer_question( State(state): State, AxumPath(id): AxumPath, Form(form): Form, ) -> Response { let answer = form.answer.trim(); if answer.is_empty() { return with_cors( ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) .with_detail("answer: required"), ); } let resp = match state .coord .questions .answer(id, answer, hive_sh4re::manager::OPERATOR_RECIPIENT) { Ok((question, asker, target)) => { tracing::info!(%id, %asker, "operator answered question"); state.coord.notify_agent( &asker, &hive_sh4re::manager::HelperEvent::QuestionAnswered { id, question, answer: answer.to_owned(), answerer: hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned(), }, ); state.coord.emit_question_resolved( id, answer, hive_sh4re::manager::OPERATOR_RECIPIENT, false, target.as_deref(), ); (StatusCode::OK, "ok").into_response() } Err(e) => error_response(&format!("answer {id} failed: {e:#}")), }; with_cors(resp) } /// Resolve a pending question with the /// `[cancelled]` sentinel answer. /// /// Used when the operator decides not to / can't answer. The asker /// harness receives a `QuestionAnswered` event with /// `answer = "[cancelled]"` so it can fall back on whatever default /// it had. Same code path as a real answer — just lets the operator /// close the loop instead of letting the question dangle forever. #[utoipa::path( post, path = "/api/cancel-question/{id}", params(("id" = i64, Path, description = "question row id")), responses( (status = 200, description = "cancelled", body = String), (status = 500, description = "cancel failed (already answered, unknown id, ...)"), ), tag = "questions" )] pub(super) async fn post_cancel_question( State(state): State, AxumPath(id): AxumPath, ) -> Response { const SENTINEL: &str = "[cancelled]"; match state .coord .questions .answer(id, SENTINEL, hive_sh4re::manager::OPERATOR_RECIPIENT) { Ok((question, asker, target)) => { tracing::info!(%id, %asker, "operator cancelled question"); state.coord.emit_question_resolved( id, SENTINEL, hive_sh4re::manager::OPERATOR_RECIPIENT, true, target.as_deref(), ); state.coord.notify_agent_from( hive_sh4re::manager::OPERATOR_RECIPIENT, &asker, &hive_sh4re::manager::HelperEvent::QuestionAnswered { id, question, answer: SENTINEL.to_owned(), answerer: hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned(), }, ); (StatusCode::OK, "ok").into_response() } Err(e) => error_response(&format!("cancel-question {id} failed: {e:#}")), } }