//! Admin full-text search over catalogue objects. Read capability: `ViewInternal` //! (admins search across all visibility levels). Backed by the Meilisearch index. use auth::{Authorized, ViewInternal}; use axum::{ Json, Router, extract::{Query, State}, http::StatusCode, routing::get, }; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::AppState; #[derive(Deserialize)] pub(crate) struct SearchParams { #[serde(default)] q: String, visibility: Option, offset: Option, limit: Option, } #[derive(Serialize, ToSchema)] pub(crate) struct SearchHitView { pub id: String, pub object_number: String, pub object_name: String, pub brief_description: Option, pub visibility: String, pub snippet: Option, } #[derive(Serialize, ToSchema)] pub(crate) struct SearchResultsView { pub hits: Vec, /// Meilisearch's estimate of the total number of matches. pub estimated_total: usize, } #[utoipa::path( get, path = "/api/admin/search", params( ("q" = String, Query, description = "Search query text"), ("visibility" = Option, Query, description = "Filter: draft|internal|public"), ("offset" = Option, Query, description = "default 0"), ("limit" = Option, Query, description = "1..=50, default 20") ), responses( (status = 200, body = SearchResultsView), (status = 400, description = "Invalid visibility value"), (status = 401), (status = 403), (status = 503, description = "Search is not configured") ) )] pub(crate) async fn search_objects( _auth: Authorized, State(state): State, Query(params): Query, ) -> Result, StatusCode> { let Some(search) = &state.search else { return Err(StatusCode::SERVICE_UNAVAILABLE); }; let visibility = match params.visibility.as_deref() { None | Some("") => None, Some(v @ ("draft" | "internal" | "public")) => Some(v), Some(_) => return Err(StatusCode::BAD_REQUEST), }; let q = params.q.trim(); if q.is_empty() { return Ok(Json(SearchResultsView { hits: Vec::new(), estimated_total: 0, })); } let offset = params.offset.unwrap_or(0).max(0) as usize; let limit = params.limit.unwrap_or(20).clamp(1, 50) as usize; let results = search .search_objects(q, visibility, offset, limit) .await .map_err(|err| { tracing::error!(?err, "search query failed"); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(SearchResultsView { hits: results .hits .into_iter() .map(|h| SearchHitView { id: h.id, object_number: h.object_number, object_name: h.object_name, brief_description: h.brief_description, visibility: h.visibility, snippet: h.snippet, }) .collect(), estimated_total: results.estimated_total, })) } pub(crate) fn routes() -> Router { Router::new().route("/api/admin/search", get(search_objects)) }