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 }