package services import ( "crussell/auth" "crussell/db" "crussell/internal/validators" "crussell/mw" "database/sql" "encoding/json" "errors" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" ) // Service represents a service in the system type Service struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Price float64 `json:"price"` DurationMinutes int `json:"duration_minutes"` IsActive bool `json:"is_active"` PatchTestDurationHours int `json:"patch_test_duration_hours"` MinimumAgeRequired int `json:"minimum_age_required"` CreatedAt time.Time `json:"created_at"` CreatedBy *string `json:"created_by,omitempty"` } type ServiceResponse struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Price float64 `json:"price"` DurationMinutes int `json:"duration_minutes"` PatchTestDurationHours int `json:"patch_test_duration_hours"` MinimumAgeRequired int `json:"minimum_age_required"` // Patch test status for non-admin users PatchTestStatus *string `json:"patch_test_status,omitempty"` // nil = not checked, "ok" = valid, "required" = no record, "expired" = record too old } // CreateServiceRequest represents the request payload for creating a new service type CreateServiceRequest struct { Name string `json:"name" validate:"required,min=1,max=100"` Description *string `json:"description,omitempty"` Price float64 `json:"price" validate:"required,gt=0"` DurationMinutes int `json:"duration_minutes" validate:"required,gt=0"` PatchTestDurationHours int `json:"patch_test_duration_hours" validate:"gte=0"` MinimumAgeRequired int `json:"minimum_age_required" validate:"gte=0,lte=100"` } // ToggleServiceHandler handles toggling a service's active status func ToggleService(w http.ResponseWriter, r *http.Request) { serviceID := chi.URLParam(r, "id") if serviceID == "" || !validators.IsValidID(serviceID) { http.Error(w, "Service not found", http.StatusNotFound) return } query := "UPDATE services SET is_active = NOT is_active WHERE id = $1" result, err := db.DB.Exec(r.Context(), query, serviceID) if err != nil { http.Error(w, "Failed to toggle service: "+err.Error(), http.StatusInternalServerError) return } if result.RowsAffected() == 0 { http.Error(w, "Service not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Service toggled successfully", "id": serviceID, }) } // POST /api/admin/services func CreateServiceHandler(w http.ResponseWriter, r *http.Request) { // Parse and validate request var req CreateServiceRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) return } // Basic validation if req.Name == "" { http.Error(w, "Name is required", http.StatusBadRequest) return } if len(req.Name) > 100 { http.Error(w, "Name must be 100 characters or less", 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.PatchTestDurationHours < 0 || req.PatchTestDurationHours > 168 { http.Error(w, "Patch test duration must be between 0 and 168 hours", http.StatusBadRequest) return } if req.MinimumAgeRequired < 0 || req.MinimumAgeRequired > 100 { http.Error(w, "Minimum age must be between 0 and 100", http.StatusBadRequest) return } // Get user ID from context (if authentication is added later) var createdBy *string if userID, ok := r.Context().Value(mw.UserIDKey).(string); ok { createdBy = &userID } // Insert new service query := ` INSERT INTO services ( name, description, price, duration_minutes, patch_test_duration_hours, minimum_age_required, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required, created_at, created_by ` var service Service var createdByDB sql.NullString err := db.DB.QueryRow(r.Context(), query, req.Name, req.Description, req.Price, req.DurationMinutes, req.PatchTestDurationHours, req.MinimumAgeRequired, createdBy, ).Scan( &service.ID, &service.Name, &service.Description, &service.Price, &service.DurationMinutes, &service.IsActive, &service.PatchTestDurationHours, &service.MinimumAgeRequired, &service.CreatedAt, &createdByDB, ) if err != nil { // Check for duplicate name or other constraints if err.Error() == "pq: 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 } // Convert nullable fields to pointers if createdByDB.Valid { service.CreatedBy = &createdByDB.String } // Return created service w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(service); err != nil { http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError) return } } // DeleteServiceHandler handles soft deleting a service (setting is_active to false) func DeleteServiceHandler(w http.ResponseWriter, r *http.Request) { serviceID := chi.URLParam(r, "id") if serviceID == "" || !validators.IsValidID(serviceID) { http.Error(w, "Service not found", http.StatusNotFound) return } // Use soft delete - set is_active to FALSE instead of hard delete // This preserves referential integrity with booking_services query := "UPDATE services SET is_active = FALSE WHERE id = $1" result, err := db.DB.Exec(r.Context(), query, serviceID) if err != nil { http.Error(w, "Failed to delete service: "+err.Error(), http.StatusInternalServerError) return } if result.RowsAffected() == 0 { http.Error(w, "Service not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Service deleted successfully", "id": serviceID, }) } // ServicesHandler returns all services from the database // For non-admin logged-in users, filters based on age and patch test eligibility func ServicesHandler(w http.ResponseWriter, r *http.Request) { // Check if user is authenticated - try context first, then optional token userID, hasUser := r.Context().Value(mw.UserIDKey).(string) role, _ := r.Context().Value(mw.UserRoleKey).(string) // If no user in context, try to parse token from header if !hasUser || userID == "" { authHeader := r.Header.Get("Authorization") if strings.HasPrefix(authHeader, "Bearer ") { tokenString := strings.TrimPrefix(authHeader, "Bearer ") var err error userID, role, err = auth.VerifyToken(tokenString, r.Context()) if err != nil { // Invalid token - treat as unauthenticated userID = "" role = "" } hasUser = userID != "" } } // If not logged in or admin, return all services (current behavior) if !hasUser || userID == "" || role == "admin" { query := ` SELECT id, name, description, price, duration_minutes, patch_test_duration_hours, minimum_age_required FROM services WHERE is_active = TRUE ORDER BY name ` rows, err := db.DB.Query(r.Context(), query) if err != nil { http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError) return } defer rows.Close() var services []ServiceResponse for rows.Next() { var service ServiceResponse err := rows.Scan( &service.ID, &service.Name, &service.Description, &service.Price, &service.DurationMinutes, &service.PatchTestDurationHours, &service.MinimumAgeRequired, ) if err != nil { http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError) return } services = append(services, service) } if err = rows.Err(); err != nil { http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if services == nil { services = []ServiceResponse{} } if err := json.NewEncoder(w).Encode(services); err != nil { http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError) } return } // User is logged in and not admin - check eligibility // Get user's date of birth var dob time.Time err := db.DB.QueryRow(r.Context(), `SELECT date_of_birth FROM users WHERE id = $1`, userID).Scan(&dob) if err != nil { http.Error(w, "Failed to get user data: "+err.Error(), http.StatusInternalServerError) return } // Calculate age now := time.Now() age := now.Year() - dob.Year() if now.YearDay() < dob.YearDay() { age-- } // Get all active services query := ` SELECT id, name, description, price, duration_minutes, patch_test_duration_hours, minimum_age_required FROM services WHERE is_active = TRUE ORDER BY name ` rows, err := db.DB.Query(r.Context(), query) if err != nil { http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError) return } defer rows.Close() var services []ServiceResponse var ineligibleServices []ServiceResponse for rows.Next() { var service ServiceResponse err := rows.Scan( &service.ID, &service.Name, &service.Description, &service.Price, &service.DurationMinutes, &service.PatchTestDurationHours, &service.MinimumAgeRequired, ) if err != nil { http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError) return } // Check age eligibility - EXCLUDE if user is too young (can't be fixed by user) if age < service.MinimumAgeRequired { continue } // Check patch test if required - GRAY OUT if not valid if service.PatchTestDurationHours > 0 { var lastTime time.Time err := db.DB.QueryRow(r.Context(), `SELECT last_time FROM user_service_patch_tests WHERE user_id = $1 AND service_id = $2`, userID, service.ID).Scan(&lastTime) if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) { // No patch test record status := "required" service.PatchTestStatus = &status ineligibleServices = append(ineligibleServices, service) continue } else if err != nil { http.Error(w, "Failed to check patch test: "+err.Error(), http.StatusInternalServerError) return } // Check if patch test is still valid requiredSince := lastTime.Add(time.Duration(service.PatchTestDurationHours) * time.Hour) if now.After(requiredSince) { // Patch test expired - gray out status := "expired" service.PatchTestStatus = &status ineligibleServices = append(ineligibleServices, service) continue } // Patch test is valid - include normally status := "ok" service.PatchTestStatus = &status services = append(services, service) } else { // No patch test required - include normally services = append(services, service) } } if err = rows.Err(); err != nil { http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError) return } // Sort: eligible first (by name), then ineligible (by name) // Combine: eligible + ineligible services = append(services, ineligibleServices...) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if services == nil { services = []ServiceResponse{} } if err := json.NewEncoder(w).Encode(services); err != nil { http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError) } } // ServicesEligibleForUserHandler returns services with eligibility calculated for a specific user // Used by admin booking flows when booking on behalf of a user func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "user_id") if userID == "" || !validators.IsValidID(userID) { http.Error(w, "User not found", http.StatusNotFound) return } // Get user's date of birth var dob time.Time err := db.DB.QueryRow(r.Context(), `SELECT date_of_birth FROM users WHERE id = $1`, userID).Scan(&dob) if errors.Is(err, sql.ErrNoRows) { http.Error(w, "User not found", http.StatusNotFound) return } if err != nil { http.Error(w, "Failed to get user data: "+err.Error(), http.StatusInternalServerError) return } // Calculate age now := time.Now() age := now.Year() - dob.Year() if now.YearDay() < dob.YearDay() { age-- } // Get all active services query := ` SELECT id, name, description, price, duration_minutes, patch_test_duration_hours, minimum_age_required FROM services WHERE is_active = TRUE ORDER BY name ` rows, err := db.DB.Query(r.Context(), query) if err != nil { http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError) return } defer rows.Close() var services []ServiceResponse var grayedOutServices []ServiceResponse for rows.Next() { var service ServiceResponse err := rows.Scan( &service.ID, &service.Name, &service.Description, &service.Price, &service.DurationMinutes, &service.PatchTestDurationHours, &service.MinimumAgeRequired, ) if err != nil { http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError) return } // Check age eligibility - EXCLUDE if user is too young if age < service.MinimumAgeRequired { continue } // Check patch test if required if service.PatchTestDurationHours > 0 { var lastTime time.Time err := db.DB.QueryRow(r.Context(), `SELECT last_time FROM user_service_patch_tests WHERE user_id = $1 AND service_id = $2`, userID, service.ID).Scan(&lastTime) if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) { // No patch test record - gray out status := "required" service.PatchTestStatus = &status grayedOutServices = append(grayedOutServices, service) continue } else if err != nil { http.Error(w, "Failed to check patch test: "+err.Error(), http.StatusInternalServerError) return } // Check if patch test is still valid requiredSince := lastTime.Add(time.Duration(service.PatchTestDurationHours) * time.Hour) if now.After(requiredSince) { // Patch test expired - gray out status := "expired" service.PatchTestStatus = &status grayedOutServices = append(grayedOutServices, service) continue } // Patch test is valid status := "ok" service.PatchTestStatus = &status services = append(services, service) } else { // No patch test required services = append(services, service) } } if err = rows.Err(); err != nil { http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError) return } // Sort and combine: valid first, then grayed out services = append(services, grayedOutServices...) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if services == nil { services = []ServiceResponse{} } if err := json.NewEncoder(w).Encode(services); err != nil { http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError) } } // AllServicesHandler returns all services including inactive ones (useful for admin) func AllServicesHandler(w http.ResponseWriter, r *http.Request) { // Query all services including inactive ones query := ` SELECT id, name, description, price, duration_minutes, is_active, patch_test_duration_hours, minimum_age_required, created_at, created_by FROM services ORDER BY is_active DESC, name ` rows, err := db.DB.Query(r.Context(), query) if err != nil { http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError) return } defer rows.Close() var services []Service for rows.Next() { var service Service var createdBy sql.NullString err := rows.Scan( &service.ID, &service.Name, &service.Description, &service.Price, &service.DurationMinutes, &service.IsActive, &service.PatchTestDurationHours, &service.MinimumAgeRequired, &service.CreatedAt, &createdBy, ) if err != nil { http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError) return } // Convert nullable fields to pointers if createdBy.Valid { service.CreatedBy = &createdBy.String } services = append(services, service) } if err = rows.Err(); err != nil { http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError) return } // Set response headers w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) // Return empty array instead of null if no services found if services == nil { services = []Service{} } // Encode response if err := json.NewEncoder(w).Encode(services); err != nil { http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError) return } }