diff --git a/backend/handlers/admin/custom_services.go b/backend/handlers/admin/custom_services.go index c882255..d9670ca 100644 --- a/backend/handlers/admin/custom_services.go +++ b/backend/handlers/admin/custom_services.go @@ -14,17 +14,17 @@ import ( ) type CustomService struct { - ID string `json:"id"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` - Price float64 `json:"price"` - DurationMinutes int `json:"duration_minutes"` - MinimumAgeRequired int `json:"minimum_age_required"` - Notes *string `json:"notes,omitempty"` - CreatedAt time.Time `json:"created_at"` - CreatedBy *string `json:"created_by,omitempty"` - UsageCount int `json:"usage_count"` - LastUsedAt *time.Time `json:"last_used_at,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Price float64 `json:"price"` + DurationMinutes int `json:"duration_minutes"` + MinimumAgeRequired int `json:"minimum_age_required"` + Notes *string `json:"notes,omitempty"` + CreatedAt time.Time `json:"created_at"` + CreatedBy *string `json:"created_by,omitempty"` + UsageCount int `json:"usage_count"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` } type CreateCustomServiceRequest struct { @@ -37,32 +37,31 @@ type CreateCustomServiceRequest struct { } type UpdateCustomServiceRequest struct { - Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"` - Description *string `json:"description,omitempty" validate:"omitempty,max=1000"` + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"` + Description *string `json:"description,omitempty" validate:"omitempty,max=1000"` Price *float64 `json:"price,omitempty" validate:"omitempty,gt=0"` - DurationMinutes *int `json:"duration_minutes,omitempty" validate:"omitempty,gt=0,lte=480"` - MinimumAgeRequired *int `json:"minimum_age_required,omitempty"` - Notes *string `json:"notes,omitempty"` + DurationMinutes *int `json:"duration_minutes,omitempty" validate:"omitempty,gt=0,lte=480"` + MinimumAgeRequired *int `json:"minimum_age_required,omitempty"` + Notes *string `json:"notes,omitempty"` } type CustomServiceListResponse struct { - Services []CustomService `json:"services"` - Total int64 `json:"total"` - Page int `json:"page"` - PerPage int `json:"per_page"` + Services []CustomService `json:"services"` + 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, ¬es, &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 = ¬es.String - } - if createdBy.Valid { - cs.CreatedBy = &createdBy.String - } - if lastUsedAt.Valid { - cs.LastUsedAt = &lastUsedAt.Time - } services = append(services, cs) } @@ -116,39 +101,37 @@ 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) - return + 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...) @@ -159,37 +142,45 @@ 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, ¬es, &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 = ¬es.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{} } json.NewEncoder(w).Encode(CustomServiceListResponse{ - Services: services, - Total: total, - Page: page, - PerPage: perPage, + Services: services, + Total: total, + PerPage: perPage, + NextCursor: nextCursor, }) } @@ -448,8 +439,8 @@ func PromoteCustomService(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ - "message": "Custom service promoted to regular service", - "new_service_id": newServiceID, + "message": "Custom service promoted to regular service", + "new_service_id": newServiceID, "custom_service_id": id, }) } diff --git a/backend/handlers/admin/custom_services_test.go b/backend/handlers/admin/custom_services_test.go index be71527..4086d09 100644 --- a/backend/handlers/admin/custom_services_test.go +++ b/backend/handlers/admin/custom_services_test.go @@ -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") } } @@ -369,40 +368,40 @@ func TestCustomServices_Create_Validation(t *testing.T) { { name: "empty name", req: CreateCustomServiceRequest{ - Name: "", - Price: 50.00, + Name: "", + Price: 50.00, DurationMinutes: 60, }, }, { name: "zero price", req: CreateCustomServiceRequest{ - Name: "Test Service", - Price: 0, + Name: "Test Service", + Price: 0, DurationMinutes: 60, }, }, { name: "negative price", req: CreateCustomServiceRequest{ - Name: "Test Service", - Price: -10.00, + Name: "Test Service", + Price: -10.00, DurationMinutes: 60, }, }, { name: "zero duration", req: CreateCustomServiceRequest{ - Name: "Test Service", - Price: 50.00, + Name: "Test Service", + Price: 50.00, DurationMinutes: 0, }, }, { name: "excessive duration", req: CreateCustomServiceRequest{ - Name: "Test Service", - Price: 50.00, + Name: "Test Service", + Price: 50.00, DurationMinutes: 481, }, }, @@ -822,8 +821,8 @@ func TestCustomServices_NonAdmin(t *testing.T) { // Test CREATE createReq := CreateCustomServiceRequest{ - Name: "Test Service", - Price: 50.00, + Name: "Test Service", + Price: 50.00, DurationMinutes: 60, } w = makeUserRequest(mw.RequireAdmin(http.HandlerFunc(CreateCustomService)), "POST", "/api/admin/custom-services", createReq)