feat: object list sort/filter/quick-search (server-side, injection-safe) (#44)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@ use domain::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{AppState, admin_vocab::LabelInput, pagination::Pagination, reindex};
|
||||
use crate::{AppState, admin_vocab::LabelInput, reindex};
|
||||
|
||||
/// A localized label `{ lang, label }` (shared across admin views).
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -100,12 +100,73 @@ pub(crate) fn parse_date(s: &str) -> Result<time::Date, StatusCode> {
|
||||
time::Date::parse(s, &fmt).map_err(|_| StatusCode::UNPROCESSABLE_ENTITY)
|
||||
}
|
||||
|
||||
/// Query parameters for the object list: pagination plus whitelisted sort/order and
|
||||
/// optional visibility/quick-filter. All values are validated/clamped server-side; the
|
||||
/// `sort` token maps onto an enum (never a raw column name) before reaching SQL.
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ObjectListParams {
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
pub sort: Option<String>,
|
||||
pub order: Option<String>,
|
||||
pub visibility: Option<String>,
|
||||
pub q: Option<String>,
|
||||
}
|
||||
|
||||
impl ObjectListParams {
|
||||
fn limit(&self) -> i64 {
|
||||
self.limit
|
||||
.unwrap_or(crate::pagination::DEFAULT_LIMIT)
|
||||
.clamp(1, crate::pagination::MAX_LIMIT)
|
||||
}
|
||||
|
||||
fn offset(&self) -> i64 {
|
||||
self.offset.unwrap_or(0).max(0)
|
||||
}
|
||||
|
||||
fn sort(&self) -> db::catalog::ObjectSort {
|
||||
use db::catalog::ObjectSort;
|
||||
|
||||
match self.sort.as_deref() {
|
||||
Some("object_name") => ObjectSort::ObjectName,
|
||||
Some("updated_at") => ObjectSort::UpdatedAt,
|
||||
Some("created_at") => ObjectSort::CreatedAt,
|
||||
Some("visibility") => ObjectSort::Visibility,
|
||||
// Unknown or absent → stable default.
|
||||
_ => ObjectSort::ObjectNumber,
|
||||
}
|
||||
}
|
||||
|
||||
fn descending(&self) -> bool {
|
||||
self.order.as_deref() == Some("desc")
|
||||
}
|
||||
|
||||
/// Validate `visibility` against the domain enum; an unknown value is ignored
|
||||
/// (treated as no filter) so hand-edited URLs degrade gracefully instead of 500ing.
|
||||
fn visibility(&self) -> Option<&str> {
|
||||
self.visibility
|
||||
.as_deref()
|
||||
.filter(|v| Visibility::from_db(v).is_some())
|
||||
}
|
||||
|
||||
fn q(&self) -> Option<&str> {
|
||||
self.q.as_deref().map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// List objects (paginated, all visibility levels). Requires `ViewInternal`.
|
||||
#[utoipa::path(
|
||||
get, path = "/api/admin/objects",
|
||||
params(
|
||||
("limit" = Option<i64>, Query, description = "1..=200, default 50"),
|
||||
("offset" = Option<i64>, Query, description = "default 0")
|
||||
("offset" = Option<i64>, Query, description = "default 0"),
|
||||
("sort" = Option<String>, Query,
|
||||
description = "object_number | object_name | updated_at | created_at | visibility (default object_number)"),
|
||||
("order" = Option<String>, Query, description = "asc | desc (default asc)"),
|
||||
("visibility" = Option<String>, Query,
|
||||
description = "draft | internal | public — filter; unknown values ignored"),
|
||||
("q" = Option<String>, Query,
|
||||
description = "quick filter: ILIKE match on object_number or object_name")
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = AdminObjectPage),
|
||||
@@ -116,15 +177,22 @@ pub(crate) fn parse_date(s: &str) -> Result<time::Date, StatusCode> {
|
||||
pub(crate) async fn list_objects(
|
||||
_auth: Authorized<ViewInternal>,
|
||||
State(state): State<AppState>,
|
||||
Query(page): Query<Pagination>,
|
||||
Query(params): Query<ObjectListParams>,
|
||||
) -> Result<Json<AdminObjectPage>, StatusCode> {
|
||||
let (limit, offset) = (page.limit(), page.offset());
|
||||
let (limit, offset) = (params.limit(), params.offset());
|
||||
|
||||
let objects = db::catalog::list_objects_paged(state.db.pool(), limit, offset)
|
||||
let query = db::catalog::ObjectQuery {
|
||||
sort: params.sort(),
|
||||
descending: params.descending(),
|
||||
visibility: params.visibility(),
|
||||
q: params.q(),
|
||||
};
|
||||
|
||||
let objects = db::catalog::list_objects_query(state.db.pool(), &query, limit, offset)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let total = db::catalog::count_objects(state.db.pool())
|
||||
let total = db::catalog::count_objects_query(state.db.pool(), query.visibility, query.q)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user