package services import ( "context" "crussell/clock" "crussell/db" "crussell/internal/validators" "crussell/mw" "database/sql" "encoding/json" "errors" "log" "log/slog" "net/http" "time" "github.com/jackc/pgx/v5" "github.com/go-chi/chi/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"` MinimumAgeRequired int `json:"minimum_age_required"` PatchTestDurationHours int `json:"patch_test_duration_hours"` 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"` MinimumAgeRequired int `json:"minimum_age_required"` PatchTestDurationHours int `json:"patch_test_duration_hours"` // Patch test status for non-admin logged-in users PatchTestStatus *string `json:"patch_test_status,omitempty"` } // 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"` 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 } tx, err := db.Conn.Begin(r.Context()) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() query := "UPDATE services SET is_active = NOT is_active WHERE id = $1" result, err := tx.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 } if err := tx.Commit(r.Context()); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(map[string]any{ "message": "Service toggled successfully", "id": serviceID, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // 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 } if err := validators.Validate.Struct(&req); err != nil { log.Printf("Failed to process request: %v", err) http.Error(w, "Invalid request", 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.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 tx, err := db.Conn.Begin(r.Context()) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() query := ` INSERT INTO services ( name, description, price, duration_minutes, minimum_age_required, created_by ) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, name, description, price, duration_minutes, is_active, minimum_age_required, created_at, created_by ` var service Service var createdByDB sql.NullString err = tx.QueryRow(r.Context(), query, req.Name, req.Description, req.Price, req.DurationMinutes, req.MinimumAgeRequired, createdBy, ).Scan( &service.ID, &service.Name, &service.Description, &service.Price, &service.DurationMinutes, &service.IsActive, &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 } if err := tx.Commit(r.Context()); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Convert nullable fields to pointers if createdByDB.Valid { service.CreatedBy = &createdByDB.String } // Return created service 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 tx, err := db.Conn.Begin(r.Context()) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) { slog.Error("failed to rollback transaction", "err", err) } }() query := "UPDATE services SET is_active = FALSE WHERE id = $1" result, err := tx.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 } if err := tx.Commit(r.Context()); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(map[string]any{ "message": "Service deleted successfully", "id": serviceID, }); err != nil { log.Printf("Failed to encode JSON response: %v", err) } } // 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 — context is set by OptionalAuth middleware userID, hasUser := r.Context().Value(mw.UserIDKey).(string) role, _ := r.Context().Value(mw.UserRoleKey).(string) // If not logged in or admin, return all services (current behavior) if !hasUser || userID == "" || role == "admin" { query := ` SELECT s.id, s.name, s.description, s.price, s.duration_minutes, s.minimum_age_required, COALESCE(pt.notice_duration_hours, 0) FROM services s LEFT JOIN patch_tests pt ON s.id = ANY(pt.service_ids) WHERE s.is_active = TRUE ORDER BY s.name ` rows, err := db.Conn.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.MinimumAgeRequired, &service.PatchTestDurationHours, ) 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.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.Conn.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 := clock.Now() age := now.Year() - dob.Year() if now.YearDay() < dob.YearDay() { age-- } // Preload patch test data once to avoid N+1 queries. // Must be done BEFORE querying services so the tx connection isn't busy. patchTests := loadPatchTests(r.Context(), userID) // Get all active services query := ` SELECT s.id, s.name, s.description, s.price, s.duration_minutes, s.minimum_age_required, COALESCE(pt.notice_duration_hours, 0) FROM services s LEFT JOIN patch_tests pt ON s.id = ANY(pt.service_ids) WHERE s.is_active = TRUE ORDER BY s.name ` rows, err := db.Conn.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.MinimumAgeRequired, &service.PatchTestDurationHours, ) 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 requirement using new schema patchTestStatus := checkPatchTestStatus(r.Context(), userID, service.ID, patchTests) if patchTestStatus != nil { // Patch test required - add status and put in ineligible list service.PatchTestStatus = patchTestStatus ineligibleServices = append(ineligibleServices, service) continue } // No patch test required or valid - 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.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.Conn.QueryRow(r.Context(), `SELECT date_of_birth FROM users WHERE id = $1`, userID).Scan(&dob) if errors.Is(err, pgx.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 := clock.Now() age := now.Year() - dob.Year() if now.YearDay() < dob.YearDay() { age-- } // Preload patch test data once to avoid N+1 queries. // Must be done BEFORE querying services so the tx connection isn't busy. patchTests := loadPatchTests(r.Context(), userID) // Get all active services query := ` SELECT s.id, s.name, s.description, s.price, s.duration_minutes, s.minimum_age_required, COALESCE(pt.notice_duration_hours, 0) FROM services s LEFT JOIN patch_tests pt ON s.id = ANY(pt.service_ids) WHERE s.is_active = TRUE ORDER BY s.name ` rows, err := db.Conn.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.MinimumAgeRequired, &service.PatchTestDurationHours, ) 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 requirement using new schema patchTestStatus := checkPatchTestStatus(r.Context(), userID, service.ID, patchTests) if patchTestStatus != nil { // Patch test required - add status and put in grayed out list service.PatchTestStatus = patchTestStatus grayedOutServices = append(grayedOutServices, service) continue } // No patch test required or valid - 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 and combine: valid first, then grayed out services = append(services, grayedOutServices...) 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) } } // patchTestInfo holds preloaded patch test data for a service type patchTestInfo struct { patchTestID string noticeDurationHours int expiryMonths int testedAt *time.Time // nil if no user_patch_test record } // loadPatchTests preloads all patch test data for a user into a serviceID-keyed map. // Performs exactly 2 queries total regardless of the number of services. func loadPatchTests(ctx context.Context, userID string) map[string]*patchTestInfo { result := make(map[string]*patchTestInfo) // Query 1: load all patch_test records rows, err := db.Conn.Query(ctx, ` SELECT id, notice_duration_hours, expiry_months, service_ids FROM patch_tests `) if err != nil { return result } defer rows.Close() type ptRow struct { id string noticeDurationHours int expiryMonths int serviceIDs []string } var patchTests []ptRow for rows.Next() { var pt ptRow if err := rows.Scan(&pt.id, &pt.noticeDurationHours, &pt.expiryMonths, &pt.serviceIDs); err != nil { continue } patchTests = append(patchTests, pt) } if err = rows.Err(); err != nil { return result } // Query 2: load user_patch_test records for this user testedAtMap := make(map[string]time.Time) if len(patchTests) > 0 { uRows, err := db.Conn.Query(ctx, ` SELECT patch_test_id, tested_at FROM user_patch_tests WHERE user_id = $1 `, userID) if err != nil { return result } defer uRows.Close() for uRows.Next() { var ptID string var testedAt time.Time if err := uRows.Scan(&ptID, &testedAt); err == nil { testedAtMap[ptID] = testedAt } } } // Build serviceID → patchTestInfo map for _, pt := range patchTests { info := &patchTestInfo{ patchTestID: pt.id, noticeDurationHours: pt.noticeDurationHours, expiryMonths: pt.expiryMonths, } if testedAt, ok := testedAtMap[pt.id]; ok { info.testedAt = &testedAt } for _, sid := range pt.serviceIDs { result[sid] = info } } return result } // checkPatchTestStatus checks if a service requires a patch test and if the user has a valid one. // Uses preloaded patch test data to avoid per-service DB queries. // Returns: nil = no patch test required, "required" = no record, "expired" = record too old func checkPatchTestStatus(ctx context.Context, userID, serviceID string, patchTests map[string]*patchTestInfo) *string { info, ok := patchTests[serviceID] if !ok { // No patch test required for this service return nil } if info.testedAt == nil { // No patch test record - required status := "required" return &status } // Check if notice period has passed (can only book after this time) eligibleFrom := info.testedAt.Add(time.Duration(info.noticeDurationHours) * time.Hour) if clock.Now().Before(eligibleFrom) { // Not yet eligible (within notice period) status := "required" return &status } // Check if patch test has expired expiresAt := info.testedAt.AddDate(0, info.expiryMonths, 0) if clock.Now().After(expiresAt) { // Patch test expired status := "expired" return &status } // Patch test is valid status := "ok" return &status } // 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 s.id, s.name, s.description, s.price, s.duration_minutes, s.is_active, s.minimum_age_required, COALESCE(pt.notice_duration_hours, 0), s.created_at, s.created_by FROM services s LEFT JOIN patch_tests pt ON s.id = ANY(pt.service_ids) ORDER BY s.is_active DESC, s.name ` rows, err := db.Conn.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.MinimumAgeRequired, &service.PatchTestDurationHours, &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.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 } }