package admin import ( "crussell/db" "crussell/internal/validators" "crussell/mw" "database/sql" "encoding/json" "errors" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/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"` NextCursor *string `json:"next_cursor,omitempty"` } // parseCursor splits a "createdAt|id" cursor string into its components. func GetCustomServices(w http.ResponseWriter, r *http.Request) { 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") perPage := 20 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.Conn.Query(r.Context(), ` 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 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 if err := rows.Scan(&cs.ID, &cs.Name, &cs.Price, &cs.DurationMinutes, &cs.MinimumAgeRequired, &cs.CreatedAt, &cs.UsageCount); err != nil { continue } services = append(services, cs) } if err := rows.Err(); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } if services == nil { services = []CustomService{} } json.NewEncoder(w).Encode(services) return } var dataQuery string var dataArgs []interface{} if q != "" { dataQuery = ` SELECT id, name, price, duration_minutes, minimum_age_required, created_at, usage_count FROM custom_services WHERE name ILIKE $1 OR description ILIKE $1 ` dataArgs = []interface{}{"%" + q + "%"} 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.Conn.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 var total int64 for rows.Next() { var cs CustomService if err := rows.Scan(&cs.ID, &cs.Name, &cs.Price, &cs.DurationMinutes, &cs.MinimumAgeRequired, &cs.CreatedAt, &cs.UsageCount); err != nil { continue } services = append(services, cs) } if err := rows.Err(); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Run count query ONLY after consuming the data query result set, // so pgx does not return "conn busy" on the same transaction. if q != "" { var countTotal int64 db.Conn.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.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM custom_services").Scan(&countTotal) total = countTotal } // 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 } if services == nil { services = []CustomService{} } json.NewEncoder(w).Encode(CustomServiceListResponse{ Services: services, Total: total, PerPage: perPage, NextCursor: nextCursor, }) } 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.Conn.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.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.Conn.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 errors.Is(err, pgx.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 } 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 } // Whitelist validation: only allow known column names to prevent SQL injection // via dynamic map keys used as column identifiers. var allowedCustomServiceFields = map[string]bool{ "name": true, "description": true, "price": true, "duration_minutes": true, "minimum_age_required": true, "notes": true, } for field := range updates { if !allowedCustomServiceFields[field] { http.Error(w, "Invalid field: "+field, 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) tx, err := db.Conn.Begin(r.Context()) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer tx.Rollback(r.Context()) result, err := tx.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 } if err := tx.Commit(r.Context()); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } 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.Conn.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 errors.Is(err, pgx.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 } 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.Conn.QueryRow(r.Context(), `SELECT usage_count FROM custom_services WHERE id = $1`, id).Scan(&usageCount) if errors.Is(err, pgx.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 } tx, err := db.Conn.Begin(r.Context()) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer tx.Rollback(r.Context()) result, 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 result.RowsAffected() == 0 { http.Error(w, "Custom service not found", http.StatusNotFound) return } if err := tx.Commit(r.Context()); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } 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 }