From ede0873c087b0ecac78a2aab99173838281a96c3 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 15 Jun 2026 16:58:01 +0100 Subject: [PATCH] feat(backend): implement custom services CRUD handlers Add 6 admin endpoints for custom services: list (with search/popular/pagination), create, get, update, promote to regular service, and delete. Each handler validates admin role via middleware. Ultraworked with Sisyphus (https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- backend/handlers/admin/custom_services.go | 502 ++++++++++++++++++++++ backend/main.go | 9 + 2 files changed, 511 insertions(+) create mode 100644 backend/handlers/admin/custom_services.go diff --git a/backend/handlers/admin/custom_services.go b/backend/handlers/admin/custom_services.go new file mode 100644 index 0000000..c882255 --- /dev/null +++ b/backend/handlers/admin/custom_services.go @@ -0,0 +1,502 @@ +package admin + +import ( + "crussell/db" + "crussell/internal/validators" + "crussell/mw" + "database/sql" + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" +) + +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"` +} + +type CreateCustomServiceRequest struct { + Name string `json:"name" validate:"required,min=1,max=100"` + Description *string `json:"description,omitempty" validate:"omitempty,max=1000"` + Price float64 `json:"price" validate:"required,gt=0"` + DurationMinutes int `json:"duration_minutes" validate:"required,gt=0,lte=480"` + MinimumAgeRequired int `json:"minimum_age_required"` + Notes *string `json:"notes,omitempty"` +} + +type UpdateCustomServiceRequest struct { + 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"` +} + +type CustomServiceListResponse struct { + Services []CustomService `json:"services"` + Total int64 `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` +} + +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") + + 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 + } + + if popularStr != "" { + n, err := strconv.Atoi(popularStr) + if err != nil || n <= 0 || n > 20 { + 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 + FROM custom_services + WHERE usage_count > 0 + ORDER BY usage_count DESC, last_used_at DESC NULLS LAST + LIMIT $1 + `, n) + if err != nil { + http.Error(w, "Failed to fetch custom services: "+err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + 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 { + 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) + } + + w.Header().Set("Content-Type", "application/json") + if services == nil { + services = []CustomService{} + } + json.NewEncoder(w).Encode(services) + 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 + 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} + } + + 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 + } + + rows, err := db.DB.Query(r.Context(), dataQuery, dataArgs...) + if err != nil { + http.Error(w, "Failed to fetch custom services: "+err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + 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 { + 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) + } + + 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, + }) +} + +func CreateCustomService(w http.ResponseWriter, r *http.Request) { + var req CreateCustomServiceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + if err := validators.Validate.Struct(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.Name == "" { + http.Error(w, "Name is required", http.StatusBadRequest) + return + } + if req.Price <= 0 { + http.Error(w, "Price must be greater than 0", http.StatusBadRequest) + return + } + if req.DurationMinutes <= 0 || req.DurationMinutes > 480 { + http.Error(w, "Duration must be between 1 and 480 minutes", http.StatusBadRequest) + return + } + if req.MinimumAgeRequired < 0 || req.MinimumAgeRequired > 100 { + http.Error(w, "Minimum age must be between 0 and 100", http.StatusBadRequest) + return + } + + var createdBy *string + if userID, ok := r.Context().Value(mw.UserIDKey).(string); ok { + createdBy = &userID + } + + query := ` + INSERT INTO custom_services (name, description, price, duration_minutes, minimum_age_required, notes, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, name, description, price, duration_minutes, minimum_age_required, notes, created_at, created_by, usage_count, last_used_at + ` + + var cs CustomService + var desc, notes, createdByDB sql.NullString + var lastUsedAt sql.NullTime + + err := db.DB.QueryRow(r.Context(), query, req.Name, req.Description, req.Price, req.DurationMinutes, req.MinimumAgeRequired, req.Notes, createdBy).Scan( + &cs.ID, &cs.Name, &desc, &cs.Price, &cs.DurationMinutes, &cs.MinimumAgeRequired, ¬es, &cs.CreatedAt, &createdByDB, &cs.UsageCount, &lastUsedAt, + ) + if err != nil { + http.Error(w, "Failed to create custom service: "+err.Error(), http.StatusInternalServerError) + return + } + + if desc.Valid { + cs.Description = &desc.String + } + if notes.Valid { + cs.Notes = ¬es.String + } + if createdByDB.Valid { + cs.CreatedBy = &createdByDB.String + } + if lastUsedAt.Valid { + cs.LastUsedAt = &lastUsedAt.Time + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(cs) +} + +func GetCustomService(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" || !validators.IsValidID(id) { + http.Error(w, "Custom service not found", http.StatusNotFound) + return + } + + var cs CustomService + var desc, notes, createdBy sql.NullString + var lastUsedAt sql.NullTime + + err := db.DB.QueryRow(r.Context(), ` + SELECT id, name, description, price, duration_minutes, minimum_age_required, notes, created_at, created_by, usage_count, last_used_at + FROM custom_services WHERE id = $1 + `, id).Scan(&cs.ID, &cs.Name, &desc, &cs.Price, &cs.DurationMinutes, &cs.MinimumAgeRequired, ¬es, &cs.CreatedAt, &createdBy, &cs.UsageCount, &lastUsedAt) + if err == sql.ErrNoRows { + http.Error(w, "Custom service not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, "Failed to fetch custom service: "+err.Error(), http.StatusInternalServerError) + return + } + + 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 + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(cs) +} + +func UpdateCustomService(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" || !validators.IsValidID(id) { + http.Error(w, "Custom service not found", http.StatusNotFound) + return + } + + var req UpdateCustomServiceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + if err := validators.Validate.Struct(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + updates := make(map[string]interface{}) + if req.Name != nil { + updates["name"] = *req.Name + } + if req.Description != nil { + updates["description"] = *req.Description + } + if req.Price != nil { + updates["price"] = *req.Price + } + if req.DurationMinutes != nil { + updates["duration_minutes"] = *req.DurationMinutes + } + if req.MinimumAgeRequired != nil { + updates["minimum_age_required"] = *req.MinimumAgeRequired + } + if req.Notes != nil { + updates["notes"] = *req.Notes + } + + if len(updates) == 0 { + http.Error(w, "No fields to update", http.StatusBadRequest) + return + } + + setClauses := make([]string, 0, len(updates)) + args := make([]interface{}, 0, len(updates)+1) + argIdx := 1 + for field, val := range updates { + setClauses = append(setClauses, field+" = $"+strconv.Itoa(argIdx)) + args = append(args, val) + argIdx++ + } + args = append(args, id) + + query := "UPDATE custom_services SET " + joinStrings(setClauses, ", ") + " WHERE id = $" + strconv.Itoa(argIdx) + + result, err := db.DB.Exec(r.Context(), query, args...) + if err != nil { + http.Error(w, "Failed to update custom service: "+err.Error(), http.StatusInternalServerError) + return + } + if result.RowsAffected() == 0 { + http.Error(w, "Custom service not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Custom service updated"}) +} + +func PromoteCustomService(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" || !validators.IsValidID(id) { + http.Error(w, "Custom service not found", http.StatusNotFound) + return + } + + tx, err := db.DB.Begin(r.Context()) + if err != nil { + http.Error(w, "Failed to start transaction", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + var name, desc, notes sql.NullString + var price float64 + var durationMinutes int + var minimumAgeRequired int + var createdBy sql.NullString + + err = tx.QueryRow(r.Context(), ` + SELECT name, description, price, duration_minutes, minimum_age_required, notes, created_by + FROM custom_services WHERE id = $1 + `, id).Scan(&name, &desc, &price, &durationMinutes, &minimumAgeRequired, ¬es, &createdBy) + if err == sql.ErrNoRows { + http.Error(w, "Custom service not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, "Failed to fetch custom service: "+err.Error(), http.StatusInternalServerError) + return + } + + var newServiceID string + err = tx.QueryRow(r.Context(), ` + INSERT INTO services (name, description, price, duration_minutes, minimum_age_required, created_by) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + `, name.String, desc, price, durationMinutes, minimumAgeRequired, createdBy).Scan(&newServiceID) + if err != nil { + if err.Error() == "pq: duplicate key value violates unique constraint" || err.Error() == "duplicate key value violates unique constraint" { + http.Error(w, "A service with this name already exists", http.StatusConflict) + return + } + http.Error(w, "Failed to create service: "+err.Error(), http.StatusInternalServerError) + return + } + + _, err = tx.Exec(r.Context(), ` + INSERT INTO booking_services (booking_id, service_id, override_price, override_duration_minutes) + SELECT booking_id, $1, override_price, override_duration_minutes + FROM booking_custom_services WHERE custom_service_id = $2 + `, newServiceID, id) + if err != nil { + http.Error(w, "Failed to migrate booking references: "+err.Error(), http.StatusInternalServerError) + return + } + + _, err = tx.Exec(r.Context(), `DELETE FROM booking_custom_services WHERE custom_service_id = $1`, id) + if err != nil { + http.Error(w, "Failed to clean up booking references: "+err.Error(), http.StatusInternalServerError) + return + } + + _, err = tx.Exec(r.Context(), `DELETE FROM custom_services WHERE id = $1`, id) + if err != nil { + http.Error(w, "Failed to delete custom service: "+err.Error(), http.StatusInternalServerError) + return + } + + if err := tx.Commit(r.Context()); err != nil { + http.Error(w, "Failed to commit transaction", http.StatusInternalServerError) + return + } + + 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, + "custom_service_id": id, + }) +} + +func DeleteCustomService(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" || !validators.IsValidID(id) { + http.Error(w, "Custom service not found", http.StatusNotFound) + return + } + + var usageCount int + err := db.DB.QueryRow(r.Context(), `SELECT usage_count FROM custom_services WHERE id = $1`, id).Scan(&usageCount) + if err == sql.ErrNoRows { + http.Error(w, "Custom service not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, "Failed to check usage: "+err.Error(), http.StatusInternalServerError) + return + } + if usageCount > 0 { + http.Error(w, "Cannot delete: this custom service has been used in bookings. Promote it first.", http.StatusConflict) + return + } + + result, err := db.DB.Exec(r.Context(), `DELETE FROM custom_services WHERE id = $1`, id) + if err != nil { + http.Error(w, "Failed to delete custom service: "+err.Error(), http.StatusInternalServerError) + return + } + if result.RowsAffected() == 0 { + http.Error(w, "Custom service not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Custom service deleted"}) +} + +func joinStrings(strs []string, sep string) string { + if len(strs) == 0 { + return "" + } + result := strs[0] + for _, s := range strs[1:] { + result += sep + s + } + return result +} diff --git a/backend/main.go b/backend/main.go index 54f7b6a..1c36cc2 100644 --- a/backend/main.go +++ b/backend/main.go @@ -291,6 +291,15 @@ func main() { r.Delete("/{id}", admin.DeletePatchTest) }) + r.Route("/admin/custom-services", func(r chi.Router) { + r.Get("/", admin.GetCustomServices) + r.Post("/", admin.CreateCustomService) + r.Get("/{id}", admin.GetCustomService) + r.Put("/{id}", admin.UpdateCustomService) + r.Post("/{id}/promote", admin.PromoteCustomService) + r.Delete("/{id}", admin.DeleteCustomService) + }) + r.Route("/admin/bookings", func(r chi.Router) { r.Get("/", bookings.GetAllAdminBookingsHandler) r.Post("/", bookings.AdminCreateBookingForUserHandler)