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:
2026-06-06 23:21:04 +02:00
parent 5efa7b8a16
commit 60a1b8dccf
4 changed files with 388 additions and 29 deletions
+74 -6
View File
@@ -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)?;
+71
View File
@@ -877,6 +877,77 @@ async fn listed_object_carries_timestamps(pool: PgPool) {
assert!(!updated_at.is_empty(), "updated_at must be non-empty");
}
#[sqlx::test(migrations = "../db/migrations")]
async fn list_objects_sort_filter_quick_search(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await;
let app = build_app(state(pool));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let create = |number: &str, name: &str| {
format!(
r#"{{"object_number":"{number}","object_name":"{name}","number_of_objects":1,"visibility":"draft"}}"#
)
};
for (number, name) in [
("FOO-1", "foo apple"),
("FOO-2", "foo banana"),
("BAR-1", "bar cherry"),
] {
let resp = send(
&app,
&cookie,
"POST",
"/api/admin/objects",
Some(&create(number, name)),
)
.await;
assert_eq!(resp.status(), StatusCode::CREATED);
}
// No params → default order is object_number ascending.
let default = send(&app, &cookie, "GET", "/api/admin/objects", None).await;
let body: serde_json::Value =
serde_json::from_slice(&default.into_body().collect().await.unwrap().to_bytes()).unwrap();
let numbers: Vec<&str> = body["items"]
.as_array()
.unwrap()
.iter()
.map(|i| i["object_number"].as_str().unwrap())
.collect();
assert_eq!(numbers, ["BAR-1", "FOO-1", "FOO-2"]);
assert_eq!(body["total"], 3);
// sort=object_name&order=desc&visibility=draft&q=foo
let filtered = send(
&app,
&cookie,
"GET",
"/api/admin/objects?sort=object_name&order=desc&visibility=draft&q=foo",
None,
)
.await;
assert_eq!(filtered.status(), StatusCode::OK);
let body: serde_json::Value =
serde_json::from_slice(&filtered.into_body().collect().await.unwrap().to_bytes()).unwrap();
let names: Vec<&str> = body["items"]
.as_array()
.unwrap()
.iter()
.map(|i| i["object_name"].as_str().unwrap())
.collect();
// Only the two "foo …" objects, name descending.
assert_eq!(names, ["foo banana", "foo apple"]);
assert_eq!(body["total"], 2);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn field_definition_edit_delete_requires_auth(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))