feat(admin): sortable product list column headers
CI / Go (api) (pull_request) Successful in 11s
CI / Python (ingestion) (pull_request) Successful in 9s
CI / Migrations (postgres) (pull_request) Successful in 14s

Click a column header (名称/品牌/条码/品类/状态/质量分) to sort asc, click
again for desc, and a third time to clear back to the default
most-recently-updated order. Sort key/direction are whitelisted server-side.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
sulaimaannaasif6866
2026-06-21 08:00:36 +00:00
parent 7434e5195e
commit 241fd38a56
4 changed files with 112 additions and 14 deletions
+3 -1
View File
@@ -164,8 +164,10 @@ func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
// ListProducts returns a paginated product list.
func (h *Handler) ListProducts(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
sort := r.URL.Query().Get("sort")
order := r.URL.Query().Get("order")
page, size := pageParams(r)
items, total, err := h.store.ListProducts(r.Context(), q, size, (page-1)*size)
items, total, err := h.store.ListProducts(r.Context(), q, sort, order, size, (page-1)*size)
if h.handleErr(w, err) {
return
}
+30 -2
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -50,8 +51,34 @@ type ProductRow struct {
UpdatedAt string `json:"updated_at"`
}
// productSortColumns whitelists the sortable list columns, mapping the API sort
// key to a SQL expression. NULLs sort last regardless of direction.
var productSortColumns = map[string]string{
"name": "p.name",
"brand": "b.name",
"gtin": "p.gtin",
"category_path": "c.path",
"status": "p.status",
"quality_score": "p.quality_score",
"updated_at": "p.updated_at",
}
// productOrderBy returns a safe ORDER BY clause for the given sort key/direction,
// falling back to the default (most recently updated first) for unknown keys.
func productOrderBy(sort, order string) string {
col, ok := productSortColumns[sort]
if !ok {
return "p.updated_at DESC"
}
dir := "ASC"
if strings.EqualFold(order, "desc") {
dir = "DESC"
}
return col + " " + dir + " NULLS LAST, p.updated_at DESC"
}
// ListProducts returns a paginated, optionally name/gtin-filtered list.
func (s *Store) ListProducts(ctx context.Context, q string, limit, offset int) ([]ProductRow, int, error) {
func (s *Store) ListProducts(ctx context.Context, q, sort, order string, limit, offset int) ([]ProductRow, int, error) {
args := []any{}
where := "WHERE 1=1"
if q != "" {
@@ -84,7 +111,8 @@ FROM product p
LEFT JOIN brand b ON b.id = p.brand_id
LEFT JOIN category c ON c.id = p.category_id
LEFT JOIN food_detail f ON f.product_id = p.id ` + where +
" ORDER BY p.updated_at DESC LIMIT $" + strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args))
" ORDER BY " + productOrderBy(sort, order) +
" LIMIT $" + strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args))
rows, err := s.pool.Query(ctx, sql, args...)
if err != nil {