feat(backend): update admin custom services handler

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-18 16:26:11 +01:00
co-authored by Sisyphus
parent 7ef3f6dcf7
commit f689202984
2 changed files with 95 additions and 105 deletions
+51 -60
View File
@@ -50,19 +50,18 @@ type CustomServiceListResponse struct {
Total int64 `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
NextCursor *string `json:"next_cursor,omitempty"`
}
// parseCursor splits a "createdAt|id" cursor string into its components.
func GetCustomServices(w http.ResponseWriter, r *http.Request) {
pageStr := r.URL.Query().Get("page")
perPageStr := r.URL.Query().Get("per_page")
q := r.URL.Query().Get("q")
popularStr := r.URL.Query().Get("popular")
cursorStr := r.URL.Query().Get("cursor")
page := 1
perPage := 20
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
perPage = pp
}
@@ -73,7 +72,7 @@ func GetCustomServices(w http.ResponseWriter, r *http.Request) {
n = 3
}
rows, err := db.DB.Query(r.Context(), `
SELECT id, name, description, price, duration_minutes, minimum_age_required, notes, created_at, created_by, usage_count, last_used_at
SELECT id, name, price, duration_minutes, minimum_age_required, created_at, usage_count
FROM custom_services
WHERE usage_count > 0
ORDER BY usage_count DESC, last_used_at DESC NULLS LAST
@@ -88,23 +87,9 @@ func GetCustomServices(w http.ResponseWriter, r *http.Request) {
var services []CustomService
for rows.Next() {
var cs CustomService
var desc, notes, createdBy sql.NullString
var lastUsedAt sql.NullTime
if err := rows.Scan(&cs.ID, &cs.Name, &desc, &cs.Price, &cs.DurationMinutes, &cs.MinimumAgeRequired, &notes, &cs.CreatedAt, &createdBy, &cs.UsageCount, &lastUsedAt); err != nil {
if err := rows.Scan(&cs.ID, &cs.Name, &cs.Price, &cs.DurationMinutes, &cs.MinimumAgeRequired, &cs.CreatedAt, &cs.UsageCount); err != nil {
continue
}
if desc.Valid {
cs.Description = &desc.String
}
if notes.Valid {
cs.Notes = &notes.String
}
if createdBy.Valid {
cs.CreatedBy = &createdBy.String
}
if lastUsedAt.Valid {
cs.LastUsedAt = &lastUsedAt.Time
}
services = append(services, cs)
}
@@ -116,40 +101,38 @@ func GetCustomServices(w http.ResponseWriter, r *http.Request) {
return
}
offset := (page - 1) * perPage
var countQuery string
var countArgs []interface{}
var dataQuery string
var dataArgs []interface{}
if q != "" {
countQuery = "SELECT COUNT(*) FROM custom_services WHERE name ILIKE $1 OR description ILIKE $1"
countArgs = []interface{}{"%" + q + "%"}
dataQuery = `
SELECT id, name, description, price, duration_minutes, minimum_age_required, notes, created_at, created_by, usage_count, last_used_at
SELECT id, name, price, duration_minutes, minimum_age_required, created_at, usage_count
FROM custom_services
WHERE name ILIKE $1 OR description ILIKE $1
ORDER BY usage_count DESC, created_at DESC
LIMIT $2 OFFSET $3
`
dataArgs = []interface{}{"%" + q + "%", perPage, offset}
} else {
countQuery = "SELECT COUNT(*) FROM custom_services"
dataQuery = `
SELECT id, name, description, price, duration_minutes, minimum_age_required, notes, created_at, created_by, usage_count, last_used_at
FROM custom_services
ORDER BY usage_count DESC, created_at DESC
LIMIT $1 OFFSET $2
`
dataArgs = []interface{}{perPage, offset}
}
dataArgs = []interface{}{"%" + q + "%"}
var total int64
if err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total); err != nil {
http.Error(w, "Failed to count custom services: "+err.Error(), http.StatusInternalServerError)
dataQuery += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(dataArgs)+1)
dataArgs = append(dataArgs, perPage+1)
} else {
dataQuery = `
SELECT id, name, price, duration_minutes, minimum_age_required, created_at, usage_count
FROM custom_services
`
// Cursor-based pagination
if cursorStr != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
if err != nil {
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
return
}
dataQuery += " WHERE (created_at, id) < ($1, $2)"
dataArgs = append(dataArgs, cursorCreatedAt, cursorID)
}
dataQuery += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(dataArgs)+1)
dataArgs = append(dataArgs, perPage+1)
}
rows, err := db.DB.Query(r.Context(), dataQuery, dataArgs...)
if err != nil {
@@ -159,28 +142,36 @@ func GetCustomServices(w http.ResponseWriter, r *http.Request) {
defer rows.Close()
var services []CustomService
var total int64
// Compute total from whichever count query ran above.
if q != "" {
var countTotal int64
db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM custom_services WHERE name ILIKE $1 OR description ILIKE $1", "%"+q+"%").Scan(&countTotal)
total = countTotal
} else {
var countTotal int64
db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM custom_services").Scan(&countTotal)
total = countTotal
}
for rows.Next() {
var cs CustomService
var desc, notes, createdBy sql.NullString
var lastUsedAt sql.NullTime
if err := rows.Scan(&cs.ID, &cs.Name, &desc, &cs.Price, &cs.DurationMinutes, &cs.MinimumAgeRequired, &notes, &cs.CreatedAt, &createdBy, &cs.UsageCount, &lastUsedAt); err != nil {
if err := rows.Scan(&cs.ID, &cs.Name, &cs.Price, &cs.DurationMinutes, &cs.MinimumAgeRequired, &cs.CreatedAt, &cs.UsageCount); err != nil {
continue
}
if desc.Valid {
cs.Description = &desc.String
}
if notes.Valid {
cs.Notes = &notes.String
}
if createdBy.Valid {
cs.CreatedBy = &createdBy.String
}
if lastUsedAt.Valid {
cs.LastUsedAt = &lastUsedAt.Time
}
services = append(services, cs)
}
// nextCursor is set only when we fetched perPage+1 items, proving a next page exists.
var nextCursor *string
if len(services) > perPage {
services = services[:perPage]
last := services[len(services)-1]
cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID
nextCursor = &cursor
}
w.Header().Set("Content-Type", "application/json")
if services == nil {
services = []CustomService{}
@@ -188,8 +179,8 @@ func GetCustomServices(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(CustomServiceListResponse{
Services: services,
Total: total,
Page: page,
PerPage: perPage,
NextCursor: nextCursor,
})
}
@@ -261,8 +261,7 @@ func TestCustomServices_List_Pagination(t *testing.T) {
handler := http.HandlerFunc(GetCustomServices)
// First page with 2 per page
w := makeAdminRequest(handler, "GET", "/api/admin/custom-services?page=1&per_page=2", nil)
w := makeAdminRequest(handler, "GET", "/api/admin/custom-services?per_page=2", nil)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
@@ -272,10 +271,6 @@ func TestCustomServices_List_Pagination(t *testing.T) {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Page != 1 {
t.Errorf("expected page 1, got %d", resp.Page)
}
if resp.PerPage != 2 {
t.Errorf("expected per_page 2, got %d", resp.PerPage)
}
@@ -285,7 +280,11 @@ func TestCustomServices_List_Pagination(t *testing.T) {
}
if len(resp.Services) != 2 {
t.Errorf("expected 2 services on page 1, got %d", len(resp.Services))
t.Errorf("expected 2 services on first page, got %d", len(resp.Services))
}
if resp.NextCursor == nil {
t.Fatal("expected next_cursor when items remain")
}
}