package handler import ( "database/sql" "encoding/json" "fmt" "net/http" "strconv" "strings" "github.com/go-chi/chi/v5" "github.com/johnzastrow/actalog/internal/domain" "github.com/johnzastrow/actalog/internal/service" "github.com/johnzastrow/actalog/pkg/logger" ) // AdminHandler handles admin-only operations type AdminHandler struct { db *sql.DB userWorkoutWODRepo domain.UserWorkoutWODRepository wodRepo domain.WODRepository movementRepo domain.MovementRepository workoutRepo domain.WorkoutRepository userRepo domain.UserRepository wodService *service.WODService movementService *service.MovementService workoutTemplateService *service.WorkoutTemplateService logger *logger.Logger } // NewAdminHandler creates a new admin handler func NewAdminHandler( db *sql.DB, userWorkoutWODRepo domain.UserWorkoutWODRepository, wodRepo domain.WODRepository, movementRepo domain.MovementRepository, workoutRepo domain.WorkoutRepository, userRepo domain.UserRepository, wodService *service.WODService, movementService *service.MovementService, workoutTemplateService *service.WorkoutTemplateService, logger *logger.Logger, ) *AdminHandler { return &AdminHandler{ db: db, userWorkoutWODRepo: userWorkoutWODRepo, wodRepo: wodRepo, movementRepo: movementRepo, workoutRepo: workoutRepo, userRepo: userRepo, wodService: wodService, movementService: movementService, workoutTemplateService: workoutTemplateService, logger: logger, } } // WODMismatch represents a WOD score_type mismatch type WODMismatch struct { ID int64 `json:"id"` WODID int64 `json:"wod_id"` WODName string `json:"wod_name"` UserEmail string `json:"user_email"` WorkoutDate string `json:"workout_date"` ExpectedScoreType string `json:"expected_score_type"` Issue string `json:"issue"` TimeSeconds *int `json:"time_seconds,omitempty"` Rounds *int `json:"rounds,omitempty"` Reps *int `json:"reps,omitempty"` Weight *float64 `json:"weight,omitempty"` } // DetectWODScoreTypeMismatches detects WOD records that don't match their score_type // @Summary Detect WOD score type mismatches (Admin) // @Description Find WOD records where data doesn't match the expected score_type // @Tags admin // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} map[string]interface{} "List of mismatched records" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /admin/wod-mismatches [get] func (h *AdminHandler) DetectWODScoreTypeMismatches(w http.ResponseWriter, r *http.Request) { // Get all WOD performance records with WOD definitions // Note: This query needs to be run across all users query := ` SELECT uww.id, uww.wod_id, uww.time_seconds, uww.rounds, uww.reps, uww.weight, w.name, w.score_type, u.email, uw.workout_date FROM user_workout_wods uww JOIN wods w ON uww.wod_id = w.id JOIN user_workouts uw ON uww.user_workout_id = uw.id JOIN users u ON uw.user_id = u.id ORDER BY uw.workout_date DESC` rows, err := h.db.Query(query) if err != nil { h.logger.Error("Failed to query WOD records: %v", err) w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"message": "Failed to query WOD records"}) return } defer rows.Close() var mismatches []WODMismatch for rows.Next() { var ( id int64 wodID int64 timeSeconds *int rounds *int reps *int weight *float64 wodName string scoreType string userEmail string workoutDate string ) err := rows.Scan(&id, &wodID, &timeSeconds, &rounds, &reps, &weight, &wodName, &scoreType, &userEmail, &workoutDate) if err != nil { h.logger.Error("Failed to scan WOD record: %v", err) continue } // Check for mismatches based on score_type var issue string if scoreType == "Time (HH:MM:SS)" { // Must have time_seconds, must NOT have rounds/reps/weight if timeSeconds == nil { issue = "Missing time_seconds for Time-based WOD" } else if rounds != nil || reps != nil || weight != nil { issue = "Has invalid fields (rounds/reps/weight) for Time-based WOD" } } else if scoreType == "Rounds+Reps" { // Must have rounds, must NOT have time_seconds/weight if rounds == nil { issue = "Missing rounds for Rounds+Reps WOD" } else if timeSeconds != nil || weight != nil { issue = "Has invalid fields (time_seconds/weight) for Rounds+Reps WOD" } } else if scoreType == "Max Weight" { // Must have weight, must NOT have time_seconds/rounds/reps if weight == nil { issue = "Missing weight for Max Weight WOD" } else if timeSeconds != nil || rounds != nil || reps != nil { issue = "Has invalid fields (time_seconds/rounds/reps) for Max Weight WOD" } } // If there's an issue, add to mismatches if issue != "" { mismatches = append(mismatches, WODMismatch{ ID: id, WODID: wodID, WODName: wodName, UserEmail: userEmail, WorkoutDate: workoutDate, ExpectedScoreType: scoreType, Issue: issue, TimeSeconds: timeSeconds, Rounds: rounds, Reps: reps, Weight: weight, }) } } if err := rows.Err(); err != nil { h.logger.Error("Error iterating WOD records: %v", err) w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"message": "Error processing WOD records"}) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "mismatches": mismatches, "count": len(mismatches), }) } // FixWODScoreTypeMismatches deletes WOD records that don't match their score_type // @Summary Fix WOD score type mismatches (Admin) // @Description Delete WOD records that don't match their expected score_type // @Tags admin // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} map[string]interface{} "Count of deleted records" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /admin/wod-mismatches/fix [post] func (h *AdminHandler) FixWODScoreTypeMismatches(w http.ResponseWriter, r *http.Request) { // First, get all mismatches query := ` SELECT uww.id, uww.wod_id, uww.time_seconds, uww.rounds, uww.reps, uww.weight, w.score_type FROM user_workout_wods uww JOIN wods w ON uww.wod_id = w.id` rows, err := h.db.Query(query) if err != nil { h.logger.Error("Failed to query WOD records: %v", err) w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"message": "Failed to query WOD records"}) return } defer rows.Close() var idsToDelete []int64 for rows.Next() { var ( id int64 wodID int64 timeSeconds *int rounds *int reps *int weight *float64 scoreType string ) err := rows.Scan(&id, &wodID, &timeSeconds, &rounds, &reps, &weight, &scoreType) if err != nil { h.logger.Error("Failed to scan WOD record: %v", err) continue } // Check for mismatches based on score_type isMismatch := false if scoreType == "Time (HH:MM:SS)" { if timeSeconds == nil || rounds != nil || reps != nil || weight != nil { isMismatch = true } } else if scoreType == "Rounds+Reps" { if rounds == nil || timeSeconds != nil || weight != nil { isMismatch = true } } else if scoreType == "Max Weight" { if weight == nil || timeSeconds != nil || rounds != nil || reps != nil { isMismatch = true } } if isMismatch { idsToDelete = append(idsToDelete, id) } } if err := rows.Err(); err != nil { h.logger.Error("Error iterating WOD records: %v", err) w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"message": "Error processing WOD records"}) return } // Delete mismatched records deletedCount := 0 for _, id := range idsToDelete { err := h.userWorkoutWODRepo.Delete(id) if err != nil { h.logger.Error("Failed to delete WOD record: id=%v error=%v", id, err) continue } deletedCount++ } h.logger.Info("Deleted mismatched WOD records: count=%v", deletedCount) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "deleted_count": deletedCount, "total_found": len(idsToDelete), }) } // UpdateWODRecordRequest represents the request payload for updating a WOD record type UpdateWODRecordRequest struct { TimeSeconds *int `json:"time_seconds"` Rounds *int `json:"rounds"` Reps *int `json:"reps"` Weight *float64 `json:"weight"` Notes string `json:"notes"` } // UpdateWODRecord updates an individual WOD record // @Summary Update WOD record (Admin) // @Description Update a specific WOD performance record // @Tags admin // @Accept json // @Produce json // @Security BearerAuth // @Param id path int true "WOD Record ID" // @Param request body UpdateWODRecordRequest true "Updated record data" // @Success 200 {object} map[string]interface{} "Success message" // @Failure 400 {object} ErrorResponse "Invalid request or score type mismatch" // @Failure 404 {object} ErrorResponse "Record not found" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /admin/wod-records/{id} [put] func (h *AdminHandler) UpdateWODRecord(w http.ResponseWriter, r *http.Request) { // Get record ID from URL idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { h.logger.Error("Invalid record ID: id=%v error=%v", idStr, err) w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"message": "Invalid record ID"}) return } // Parse request body var req UpdateWODRecordRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.logger.Error("Failed to parse request body: %v", err) w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"message": "Invalid request body"}) return } // Get the existing record to find the WOD ID existingRecord, err := h.userWorkoutWODRepo.GetByID(id) if err != nil { h.logger.Error("Failed to get existing WOD record: id=%v error=%v", id, err) w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"message": "WOD record not found"}) return } // Get the WOD definition to validate score_type wod, err := h.wodRepo.GetByID(existingRecord.WODID) if err != nil { h.logger.Error("Failed to get WOD definition: wod_id=%d error=%v", existingRecord.WODID, err) w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"message": "Failed to get WOD definition"}) return } // Validate that the update matches the score_type scoreType := wod.ScoreType if scoreType == "Time (HH:MM:SS)" { if req.TimeSeconds == nil { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "message": fmt.Sprintf("WOD '%s' has score_type '%s' but time_seconds is missing", wod.Name, scoreType), }) return } if req.Rounds != nil || req.Reps != nil || req.Weight != nil { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "message": fmt.Sprintf("WOD '%s' has score_type '%s' but contains invalid fields (rounds/reps/weight)", wod.Name, scoreType), }) return } } else if scoreType == "Rounds+Reps" { if req.Rounds == nil { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "message": fmt.Sprintf("WOD '%s' has score_type '%s' but rounds is missing", wod.Name, scoreType), }) return } if req.TimeSeconds != nil || req.Weight != nil { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "message": fmt.Sprintf("WOD '%s' has score_type '%s' but contains invalid fields (time_seconds/weight)", wod.Name, scoreType), }) return } } else if scoreType == "Max Weight" { if req.Weight == nil { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "message": fmt.Sprintf("WOD '%s' has score_type '%s' but weight is missing", wod.Name, scoreType), }) return } if req.TimeSeconds != nil || req.Rounds != nil || req.Reps != nil { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "message": fmt.Sprintf("WOD '%s' has score_type '%s' but contains invalid fields (time_seconds/rounds/reps)", wod.Name, scoreType), }) return } } // Update the record updatedRecord := &domain.UserWorkoutWOD{ ID: id, UserWorkoutID: existingRecord.UserWorkoutID, WODID: existingRecord.WODID, TimeSeconds: req.TimeSeconds, Rounds: req.Rounds, Reps: req.Reps, Weight: req.Weight, Notes: req.Notes, IsPR: existingRecord.IsPR, OrderIndex: existingRecord.OrderIndex, } if err := h.userWorkoutWODRepo.Update(updatedRecord); err != nil { h.logger.Error("Failed to update WOD record: id=%v error=%v", id, err) w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"message": "Failed to update WOD record"}) return } h.logger.Info("Updated WOD record: id=%v wod_name=%v score_type=%v", id, wod.Name, scoreType) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "WOD record updated successfully", "id": id, }) } // ========== User-Created Content Management ========== // CopyToStandardRequest represents the request to copy an item to standard type CopyToStandardRequest struct { NewName string `json:"new_name"` } // ListUserCreatedWODs lists all user-created WODs with creator info // @Summary List user-created WODs (Admin) // @Description Get all user-created WODs with creator information // @Tags admin // @Accept json // @Produce json // @Security BearerAuth // @Param limit query int false "Max results (default 100)" // @Param offset query int false "Skip N results (default 0)" // @Param search query string false "Search query" // @Param score_type query string false "Filter by score type" // @Param creator query string false "Filter by creator email" // @Success 200 {object} map[string]interface{} "List of user-created WODs" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /admin/user-created/wods [get] func (h *AdminHandler) ListUserCreatedWODs(w http.ResponseWriter, r *http.Request) { // Parse pagination params limit := 100 offset := 0 if l := r.URL.Query().Get("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed } } if o := r.URL.Query().Get("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } // Parse filter params search := r.URL.Query().Get("search") scoreType := r.URL.Query().Get("score_type") creator := r.URL.Query().Get("creator") wods, count, err := h.wodService.ListAllUserCreatedWithUserInfoFiltered(limit, offset, search, scoreType, creator) if err != nil { h.logger.Error("Failed to list user-created WODs: %v", err) w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": "Failed to list user-created WODs"}) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "data": wods, "count": count, }) } // CopyWODToStandard converts a user-created WOD to become a standard WOD // @Summary Copy WOD to standard (Admin) // @Description Create a standard WOD from a user-created WOD // @Tags admin // @Accept json // @Produce json // @Security BearerAuth // @Param id path int true "WOD ID" // @Param request body CopyToStandardRequest true "New name for standard WOD" // @Success 201 {object} domain.WOD "Created standard WOD" // @Failure 400 {object} ErrorResponse "Invalid request" // @Failure 404 {object} ErrorResponse "WOD not found" // @Failure 409 {object} ErrorResponse "Name already exists" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /admin/user-created/wods/{id}/copy-to-standard [post] func (h *AdminHandler) CopyWODToStandard(w http.ResponseWriter, r *http.Request) { // Get WOD ID from URL idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { h.logger.Error("Invalid WOD ID: id=%v error=%v", idStr, err) w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "Invalid WOD ID"}) return } // Parse request body var req CopyToStandardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.logger.Error("Failed to parse request body: %v", err) w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body"}) return } // Copy to standard wod, err := h.wodService.CopyToStandard(id, req.NewName) if err != nil { h.logger.Error("Failed to copy WOD to standard: id=%v error=%v", id, err) // Check if it's a duplicate name error if strings.Contains(err.Error(), "already exists") { w.WriteHeader(http.StatusConflict) } else if strings.Contains(err.Error(), "not found") { w.WriteHeader(http.StatusNotFound) } else { w.WriteHeader(http.StatusInternalServerError) } json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } h.logger.Info("Converted WOD to standard: source_id=%v new_id=%v name=%v", id, wod.ID, wod.Name) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(wod) } // ListUserCreatedMovements lists all user-created movements with creator info // @Summary List user-created movements (Admin) // @Description Get all user-created movements with creator information // @Tags admin // @Accept json // @Produce json // @Security BearerAuth // @Param limit query int false "Max results (default 100)" // @Param offset query int false "Skip N results (default 0)" // @Param search query string false "Search query" // @Param type query string false "Filter by movement type" // @Param creator query string false "Filter by creator email" // @Success 200 {object} map[string]interface{} "List of user-created movements" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /admin/user-created/movements [get] func (h *AdminHandler) ListUserCreatedMovements(w http.ResponseWriter, r *http.Request) { // Parse pagination params limit := 100 offset := 0 if l := r.URL.Query().Get("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed } } if o := r.URL.Query().Get("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } // Parse filter params search := r.URL.Query().Get("search") movementType := r.URL.Query().Get("type") creator := r.URL.Query().Get("creator") movements, count, err := h.movementService.ListAllUserCreatedWithUserInfoFiltered(limit, offset, search, movementType, creator) if err != nil { h.logger.Error("Failed to list user-created movements: %v", err) w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": "Failed to list user-created movements"}) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "data": movements, "count": count, }) } // CopyMovementToStandard converts a user-created movement to become a standard movement // @Summary Copy movement to standard (Admin) // @Description Create a standard movement from a user-created movement // @Tags admin // @Accept json // @Produce json // @Security BearerAuth // @Param id path int true "Movement ID" // @Param request body CopyToStandardRequest true "New name for standard movement" // @Success 201 {object} domain.Movement "Created standard movement" // @Failure 400 {object} ErrorResponse "Invalid request" // @Failure 404 {object} ErrorResponse "Movement not found" // @Failure 409 {object} ErrorResponse "Name already exists" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /admin/user-created/movements/{id}/copy-to-standard [post] func (h *AdminHandler) CopyMovementToStandard(w http.ResponseWriter, r *http.Request) { // Get movement ID from URL idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { h.logger.Error("Invalid movement ID: id=%v error=%v", idStr, err) w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "Invalid movement ID"}) return } // Parse request body var req CopyToStandardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.logger.Error("Failed to parse request body: %v", err) w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body"}) return } // Copy to standard movement, err := h.movementService.CopyToStandard(id, req.NewName) if err != nil { h.logger.Error("Failed to copy movement to standard: id=%v error=%v", id, err) // Check if it's a duplicate name error if strings.Contains(err.Error(), "already exists") { w.WriteHeader(http.StatusConflict) } else if strings.Contains(err.Error(), "not found") { w.WriteHeader(http.StatusNotFound) } else { w.WriteHeader(http.StatusInternalServerError) } json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } h.logger.Info("Converted movement to standard: source_id=%v new_id=%v name=%v", id, movement.ID, movement.Name) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(movement) } // ListUserCreatedWorkouts lists all user-created workout templates with creator info // @Summary List user-created workouts (Admin) // @Description Get all user-created workout templates with creator information // @Tags admin // @Accept json // @Produce json // @Security BearerAuth // @Param limit query int false "Max results (default 100)" // @Param offset query int false "Skip N results (default 0)" // @Param search query string false "Search query" // @Param creator query string false "Filter by creator email" // @Success 200 {object} map[string]interface{} "List of user-created workouts" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /admin/user-created/workouts [get] func (h *AdminHandler) ListUserCreatedWorkouts(w http.ResponseWriter, r *http.Request) { // Parse pagination params limit := 100 offset := 0 if l := r.URL.Query().Get("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed } } if o := r.URL.Query().Get("offset"); o != "" { if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { offset = parsed } } // Parse filter params search := r.URL.Query().Get("search") creator := r.URL.Query().Get("creator") workouts, count, err := h.workoutTemplateService.ListAllUserCreatedWithUserInfoFiltered(limit, offset, search, creator) if err != nil { h.logger.Error("Failed to list user-created workouts: %v", err) w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": "Failed to list user-created workouts"}) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "data": workouts, "count": count, }) } // CopyWorkoutToStandard converts a user-created workout to become a standard workout // @Summary Copy workout to standard (Admin) // @Description Create a standard workout template from a user-created workout // @Tags admin // @Accept json // @Produce json // @Security BearerAuth // @Param id path int true "Workout ID" // @Param request body CopyToStandardRequest true "New name for standard workout" // @Success 201 {object} domain.Workout "Created standard workout" // @Failure 400 {object} ErrorResponse "Invalid request" // @Failure 404 {object} ErrorResponse "Workout not found" // @Failure 409 {object} ErrorResponse "Name already exists" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /admin/user-created/workouts/{id}/copy-to-standard [post] func (h *AdminHandler) CopyWorkoutToStandard(w http.ResponseWriter, r *http.Request) { // Get workout ID from URL idStr := chi.URLParam(r, "id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { h.logger.Error("Invalid workout ID: id=%v error=%v", idStr, err) w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "Invalid workout ID"}) return } // Parse request body var req CopyToStandardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.logger.Error("Failed to parse request body: %v", err) w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body"}) return } // Copy to standard workout, err := h.workoutTemplateService.CopyToStandard(id, req.NewName) if err != nil { h.logger.Error("Failed to copy workout to standard: id=%v error=%v", id, err) // Check if it's a duplicate name error if strings.Contains(err.Error(), "already exists") { w.WriteHeader(http.StatusConflict) } else if strings.Contains(err.Error(), "not found") { w.WriteHeader(http.StatusNotFound) } else { w.WriteHeader(http.StatusInternalServerError) } json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) return } h.logger.Info("Converted workout to standard: source_id=%v new_id=%v name=%v", id, workout.ID, workout.Name) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(workout) }