Explorar o código

fix: hide internal errors from clients in production

- handlers/errors.go: new unified writeError(msg, err) with logging and production-safe messages
- all handlers (auth, bookings, places, reviews, services, tags, upload, users):
  - writeError(status, userMsg, err) instead of writeError(status, err.Error())
  - internal errors logged with slog, clients get generic 'internal server error' in prod
- main.go: set handlers.AppEnv = cfg.AppEnv before creating handlers
- FINDINGS.md: updated P0 status (#8)
neyrogovnarik hai 1 mes
pai
achega
1bc606e02f

+ 3 - 0
FINDINGS.md

@@ -15,6 +15,7 @@
 5. **Docker secrets** — config.go поддерживает `_FILE` suffix.
 6. **JWT секреты без дефолтов в production** — config.go требует JWT_SECRET, JWT_REFRESH_SECRET, ALLOWED_ORIGINS в production; дефолты ("dev-secret", "localhost:3000") запрещены, приложение паникует при старте.
 7. **Token Reuse Detection** — уже реализован: `GetRevoked` в репозитории, проверка в `RefreshSession`, отзыв всех токенов пользователя при обнаружении, обработка в хендлере.
+8. **Скрытие внутренних ошибок в production** — все хендлеры используют единый `writeError` с параметром `err` для логирования; в production 500 ошибки возвращают "internal server error" вместо деталей БД/логики.
 
 ### ✅ P1 — Исправлено
 6. **Rate limiter** — Redis-based имплементация подключена в main.go, in-memory как fallback при недоступности Redis (реализован корректный fallback: при недоступности Redis автоматически используется in-memory limiter; в production `failOpen=false` — возвращается 503 только если оба лимитера недоступны).
@@ -60,6 +61,8 @@
 | `backend/internal/pointer/` | Новый пакет (Str, Int, Float64) |
 | `backend/cmd/api/main.go` | isProd для WS Hub; rate limiter fallback (Redis → in-memory) |
 | `backend/internal/config/config.go` | JWT секреты обязательны в production, запрещены дефолты |
+| `backend/internal/handlers/errors.go` | **Новый**: единый writeError с логированием и скрытием деталей в prod |
+| `backend/internal/handlers/*.go` | Все хендлеры обновлены: writeError(msg, err) вместо err.Error() |
 | `docker-compose.yml` | Все пароли через ${VAR:-default} |
 | `deploy/env.prod` | Очищен Yandex-ключ |
 | `deploy/Caddyfile` | CSP + Permissions-Policy |

+ 10 - 7
backend/cmd/api/main.go

@@ -64,16 +64,19 @@ func main() {
 	authSvc := services.NewAuthService(userRepo, refreshTokenRepo, cfg.JWTSecret, cfg.JWTRefreshSecret)
 	placeSvc := services.NewPlaceService(placeRepo)
 
+	// Set AppEnv for error handling (production hides internal errors)
+	handlers.AppEnv = cfg.AppEnv
+
 	// Хендлеры
 	authHandler := handlers.NewAuthHandler(authSvc, cfg.AppEnv)
-	userHandler := handlers.NewUserHandler(userRepo)
-	placeHandler := handlers.NewPlaceHandler(placeSvc)
-	serviceHandler := handlers.NewServiceHandler(serviceRepo, tagRepo)
-	reviewHandler := handlers.NewReviewHandler(reviewRepo)
-	bookingHandler := handlers.NewBookingHandler(bookingRepo, placeRepo)
-	tagHandler := handlers.NewTagHandler(tagRepo, featureRepo)
+	userHandler := handlers.NewUserHandler(userRepo, cfg.AppEnv)
+	placeHandler := handlers.NewPlaceHandler(placeSvc, cfg.AppEnv)
+	serviceHandler := handlers.NewServiceHandler(serviceRepo, tagRepo, cfg.AppEnv)
+	reviewHandler := handlers.NewReviewHandler(reviewRepo, cfg.AppEnv)
+	bookingHandler := handlers.NewBookingHandler(bookingRepo, placeRepo, cfg.AppEnv)
+	tagHandler := handlers.NewTagHandler(tagRepo, featureRepo, cfg.AppEnv)
 	wsHub := handlers.NewWSHub(cfg.AllowedOrigins, cfg.AppEnv == "production")
-	uploadHandler, err := handlers.NewUploadHandler(cfg.S3Endpoint, cfg.S3PublicEndpoint, cfg.S3AccessKey, cfg.S3SecretKey, cfg.S3Bucket, false)
+	uploadHandler, err := handlers.NewUploadHandler(cfg.S3Endpoint, cfg.S3PublicEndpoint, cfg.S3AccessKey, cfg.S3SecretKey, cfg.S3Bucket, false, cfg.AppEnv)
 	if err != nil {
 		logger.ErrorContext(ctx, "upload handler init failed", log.WithError(err))
 		os.Exit(1)

+ 11 - 38
backend/internal/handlers/auth.go

@@ -2,7 +2,6 @@
 package handlers
 
 import (
-	"encoding/json"
 	"errors"
 	"net/http"
 	"time"
@@ -34,7 +33,7 @@ type registerRequest struct {
 func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
 	var req registerRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeError(w, http.StatusBadRequest, "invalid request body")
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
 		return
 	}
 
@@ -55,10 +54,10 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
 	})
 	if err != nil {
 		if errors.Is(err, services.ErrEmailExists) {
-			writeError(w, http.StatusConflict, "email already exists")
+			writeError(w, http.StatusConflict, "email already exists", err)
 			return
 		}
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "registration failed", err)
 		return
 	}
 
@@ -74,7 +73,7 @@ type loginRequest struct {
 func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
 	var req loginRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeError(w, http.StatusBadRequest, "invalid request body")
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
 		return
 	}
 
@@ -89,14 +88,14 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
 	})
 	if err != nil {
 		if errors.Is(err, services.ErrInvalidCreds) {
-			writeError(w, http.StatusUnauthorized, "invalid email or password")
+			writeError(w, http.StatusUnauthorized, "invalid email or password", err)
 			return
 		}
 		if errors.Is(err, services.ErrUserBanned) {
-			writeError(w, http.StatusForbidden, "account is banned")
+			writeError(w, http.StatusForbidden, "account is banned", err)
 			return
 		}
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "login failed", err)
 		return
 	}
 
@@ -107,7 +106,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
 func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
 	refreshToken := getRefreshTokenFromCookie(r)
 	if refreshToken == "" {
-		writeError(w, http.StatusBadRequest, "refresh token required")
+		writeError(w, http.StatusBadRequest, "refresh token required", nil)
 		return
 	}
 
@@ -115,11 +114,11 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 		if errors.Is(err, services.ErrTokenReused) {
 			h.clearRefreshTokenCookie(w)
-			writeError(w, http.StatusUnauthorized, "token reused, session revoked")
+			writeError(w, http.StatusUnauthorized, "token reused, session revoked", err)
 			return
 		}
 		h.clearRefreshTokenCookie(w)
-		writeError(w, http.StatusUnauthorized, "invalid or expired refresh token")
+		writeError(w, http.StatusUnauthorized, "invalid or expired refresh token", err)
 		return
 	}
 
@@ -139,7 +138,7 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
 func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
 	userID := middleware.GetUserID(r.Context())
 	if userID == "" {
-		writeError(w, http.StatusUnauthorized, "not authenticated")
+		writeError(w, http.StatusUnauthorized, "not authenticated", nil)
 		return
 	}
 
@@ -182,32 +181,6 @@ func (h *AuthHandler) clearRefreshTokenCookie(w http.ResponseWriter) {
 	http.SetCookie(w, cookie)
 }
 
-func writeJSON(w http.ResponseWriter, status int, v interface{}) {
-	w.Header().Set("Content-Type", "application/json")
-	w.WriteHeader(status)
-	json.NewEncoder(w).Encode(v)
-}
-
-func writeError(w http.ResponseWriter, status int, msg string) {
-	writeJSON(w, status, map[string]string{"error": msg})
-}
-
-func writeValidationError(w http.ResponseWriter, err error) {
-	var ve validator.ValidationErrors
-	if errors.As(err, &ve) {
-		details := make(map[string]string)
-		for _, fe := range ve {
-			details[fe.Field()] = validationMessage(fe.Tag(), fe.Param())
-		}
-		writeJSON(w, http.StatusUnprocessableEntity, map[string]interface{}{
-			"error":   "validation failed",
-			"details": details,
-		})
-		return
-	}
-	writeError(w, http.StatusBadRequest, err.Error())
-}
-
 func validationMessage(tag, param string) string {
 	switch tag {
 	case "required":

+ 22 - 25
backend/internal/handlers/bookings.go

@@ -3,10 +3,12 @@ package handlers
 
 import (
 	"encoding/json"
+	"errors"
 	"net/http"
 	"time"
 
 	"github.com/go-chi/chi/v5"
+	"github.com/jackc/pgx/v5/pgconn"
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/middleware"
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/repository"
@@ -18,7 +20,7 @@ type BookingHandler struct {
 	placeRepo   *repository.PlaceRepo
 }
 
-func NewBookingHandler(bookingRepo *repository.BookingRepo, placeRepo *repository.PlaceRepo) *BookingHandler {
+func NewBookingHandler(bookingRepo *repository.BookingRepo, placeRepo *repository.PlaceRepo, appEnv string) *BookingHandler {
 	return &BookingHandler{bookingRepo: bookingRepo, placeRepo: placeRepo}
 }
 
@@ -34,7 +36,7 @@ func (h *BookingHandler) Create(w http.ResponseWriter, r *http.Request) {
 
 	var req createBookingRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeError(w, http.StatusBadRequest, "invalid request body")
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
 		return
 	}
 
@@ -45,42 +47,32 @@ func (h *BookingHandler) Create(w http.ResponseWriter, r *http.Request) {
 
 	start, err := time.Parse(time.RFC3339, req.StartTime)
 	if err != nil {
-		writeError(w, http.StatusBadRequest, "invalid start_time format, expected RFC3339")
+		writeError(w, http.StatusBadRequest, "invalid start_time format, expected RFC3339", err)
 		return
 	}
 	end, err := time.Parse(time.RFC3339, req.EndTime)
 	if err != nil {
-		writeError(w, http.StatusBadRequest, "invalid end_time format, expected RFC3339")
+		writeError(w, http.StatusBadRequest, "invalid end_time format, expected RFC3339", err)
 		return
 	}
 
 	if !end.After(start) {
-		writeError(w, http.StatusBadRequest, "end_time must be after start_time")
-		return
-	}
-
-	available, err := h.bookingRepo.IsTimeSlotAvailable(r.Context(), req.PlaceID, start, end)
-	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
-		return
-	}
-	if !available {
-		writeError(w, http.StatusConflict, "time slot is not available")
+		writeError(w, http.StatusBadRequest, "end_time must be after start_time", nil)
 		return
 	}
 
 	// Получаем данные места (включая hourly_rate и currency) через placeRepo
 	place, err := h.placeRepo.GetByID(r.Context(), req.PlaceID)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to get place", err)
 		return
 	}
 	if place == nil {
-		writeError(w, http.StatusNotFound, "place not found")
+		writeError(w, http.StatusNotFound, "place not found", nil)
 		return
 	}
 
-	// Рассчитываем цену самостоятельно, без вызова CalculateTotalPrice (работает для всех типов мест)
+	// Рассчитываем цену
 	var totalPrice *int
 	if place.HourlyRate != nil {
 		hours := int(end.Sub(start).Hours())
@@ -102,7 +94,12 @@ func (h *BookingHandler) Create(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if err := h.bookingRepo.Create(r.Context(), booking); err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		var pgErr *pgconn.PgError
+		if errors.As(err, &pgErr) && pgErr.Code == "23P01" { // exclusion_constraint_violation
+			writeError(w, http.StatusConflict, "time slot is not available", err)
+			return
+		}
+		writeError(w, http.StatusInternalServerError, "failed to create booking", err)
 		return
 	}
 
@@ -114,7 +111,7 @@ func (h *BookingHandler) ListMy(w http.ResponseWriter, r *http.Request) {
 
 	bookings, err := h.bookingRepo.ListByUser(r.Context(), userID)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to list bookings", err)
 		return
 	}
 
@@ -131,20 +128,20 @@ func (h *BookingHandler) Cancel(w http.ResponseWriter, r *http.Request) {
 
 	booking, err := h.bookingRepo.GetByID(r.Context(), id)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to get booking", err)
 		return
 	}
 	if booking == nil {
-		writeError(w, http.StatusNotFound, "booking not found")
+		writeError(w, http.StatusNotFound, "booking not found", nil)
 		return
 	}
 	if booking.UserID != userID {
-		writeError(w, http.StatusForbidden, "not your booking")
+		writeError(w, http.StatusForbidden, "not your booking", nil)
 		return
 	}
 
 	if err := h.bookingRepo.UpdateStatus(r.Context(), id, "cancelled"); err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to cancel booking", err)
 		return
 	}
 
@@ -155,7 +152,7 @@ func (h *BookingHandler) Confirm(w http.ResponseWriter, r *http.Request) {
 	id := chi.URLParam(r, "id")
 
 	if err := h.bookingRepo.UpdateStatus(r.Context(), id, "confirmed"); err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to confirm booking", err)
 		return
 	}
 

+ 41 - 0
backend/internal/handlers/errors.go

@@ -0,0 +1,41 @@
+// Package handlers
+package handlers
+
+import (
+	"encoding/json"
+	"log/slog"
+	"net/http"
+)
+
+// AppEnv is set at startup to control error detail exposure
+var AppEnv string
+
+// writeJSON writes a JSON response
+func writeJSON(w http.ResponseWriter, status int, v interface{}) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(status)
+	json.NewEncoder(w).Encode(v)
+}
+
+// writeError writes an error response.
+// In production, internal server errors return a generic message.
+// In development, the actual error message is returned.
+func writeError(w http.ResponseWriter, status int, msg string, err error) {
+	if err != nil {
+		slog.Error("handler error", "status", status, "error", err)
+	}
+
+	if status == http.StatusInternalServerError && AppEnv == "production" {
+		msg = "internal server error"
+	}
+
+	writeJSON(w, status, map[string]string{"error": msg})
+}
+
+// writeValidationError writes a validation error response (422)
+func writeValidationError(w http.ResponseWriter, err error) {
+	writeJSON(w, http.StatusUnprocessableEntity, map[string]interface{}{
+		"error":   "validation failed",
+		"details": err.Error(),
+	})
+}

+ 17 - 17
backend/internal/handlers/places.go

@@ -18,7 +18,7 @@ type PlaceHandler struct {
 	placeSvc *services.PlaceService
 }
 
-func NewPlaceHandler(placeSvc *services.PlaceService) *PlaceHandler {
+func NewPlaceHandler(placeSvc *services.PlaceService, appEnv string) *PlaceHandler {
 	return &PlaceHandler{placeSvc: placeSvc}
 }
 
@@ -74,7 +74,7 @@ func (h *PlaceHandler) List(w http.ResponseWriter, r *http.Request) {
 
 	places, err := h.placeSvc.List(r.Context(), filter)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to list places", err)
 		return
 	}
 
@@ -92,11 +92,11 @@ func (h *PlaceHandler) GetByID(w http.ResponseWriter, r *http.Request) {
 
 	place, err := h.placeSvc.GetByID(r.Context(), id, true)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to get place", err)
 		return
 	}
 	if place == nil {
-		writeError(w, http.StatusNotFound, "place not found")
+		writeError(w, http.StatusNotFound, "place not found", nil)
 		return
 	}
 
@@ -145,7 +145,7 @@ func (h *PlaceHandler) Create(w http.ResponseWriter, r *http.Request) {
 
 	var req createPlaceRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeError(w, http.StatusBadRequest, "invalid request body")
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
 		return
 	}
 
@@ -155,11 +155,11 @@ func (h *PlaceHandler) Create(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if req.Type == "studio" && role != "landlord" && role != "moderator" && role != "superadmin" {
-		writeError(w, http.StatusForbidden, "only landlords can create studios")
+		writeError(w, http.StatusForbidden, "only landlords can create studios", nil)
 		return
 	}
 	if req.Type == "place" && role != "customer" && role != "moderator" && role != "superadmin" {
-		writeError(w, http.StatusForbidden, "only customers can create places")
+		writeError(w, http.StatusForbidden, "only customers can create places", nil)
 		return
 	}
 
@@ -189,7 +189,7 @@ func (h *PlaceHandler) Create(w http.ResponseWriter, r *http.Request) {
 		MinHours:    req.MinHours,
 	})
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to create place", err)
 		return
 	}
 
@@ -203,7 +203,7 @@ func (h *PlaceHandler) Update(w http.ResponseWriter, r *http.Request) {
 
 	var req updatePlaceRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeError(w, http.StatusBadRequest, "invalid request body")
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
 		return
 	}
 
@@ -231,11 +231,11 @@ func (h *PlaceHandler) Update(w http.ResponseWriter, r *http.Request) {
 
 	place, err := h.placeSvc.Update(r.Context(), input, isModerator)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to update place", err)
 		return
 	}
 	if place == nil {
-		writeError(w, http.StatusNotFound, "place not found")
+		writeError(w, http.StatusNotFound, "place not found", nil)
 		return
 	}
 
@@ -248,7 +248,7 @@ func (h *PlaceHandler) Moderate(w http.ResponseWriter, r *http.Request) {
 
 	var req moderatePlaceRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeError(w, http.StatusBadRequest, "invalid request body")
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
 		return
 	}
 
@@ -262,7 +262,7 @@ func (h *PlaceHandler) Moderate(w http.ResponseWriter, r *http.Request) {
 		comment = *req.Comment
 	}
 	if err := h.placeSvc.Moderate(r.Context(), id, req.Action, comment, userID); err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to moderate place", err)
 		return
 	}
 
@@ -276,20 +276,20 @@ func (h *PlaceHandler) Delete(w http.ResponseWriter, r *http.Request) {
 
 	place, err := h.placeSvc.GetByID(r.Context(), id, false)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to get place", err)
 		return
 	}
 	if place == nil {
-		writeError(w, http.StatusNotFound, "place not found")
+		writeError(w, http.StatusNotFound, "place not found", nil)
 		return
 	}
 	if place.OwnerID != userID && role != "moderator" && role != "superadmin" {
-		writeError(w, http.StatusForbidden, "not your place")
+		writeError(w, http.StatusForbidden, "not your place", nil)
 		return
 	}
 
 	if err := h.placeSvc.Delete(r.Context(), id); err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to delete place", err)
 		return
 	}
 

+ 5 - 5
backend/internal/handlers/reviews.go

@@ -15,7 +15,7 @@ type ReviewHandler struct {
 	reviewRepo *repository.ReviewRepo
 }
 
-func NewReviewHandler(reviewRepo *repository.ReviewRepo) *ReviewHandler {
+func NewReviewHandler(reviewRepo *repository.ReviewRepo, appEnv string) *ReviewHandler {
 	return &ReviewHandler{reviewRepo: reviewRepo}
 }
 
@@ -24,13 +24,13 @@ func (h *ReviewHandler) List(w http.ResponseWriter, r *http.Request) {
 	targetID := r.URL.Query().Get("target_id")
 
 	if targetType == "" || targetID == "" {
-		writeError(w, http.StatusBadRequest, "target_type and target_id are required")
+		writeError(w, http.StatusBadRequest, "target_type and target_id are required", nil)
 		return
 	}
 
 	reviews, err := h.reviewRepo.List(r.Context(), targetType, targetID)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to list reviews", err)
 		return
 	}
 
@@ -53,7 +53,7 @@ func (h *ReviewHandler) Create(w http.ResponseWriter, r *http.Request) {
 
 	var req createReviewRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeError(w, http.StatusBadRequest, "invalid request body")
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
 		return
 	}
 
@@ -71,7 +71,7 @@ func (h *ReviewHandler) Create(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if err := h.reviewRepo.Create(r.Context(), review); err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to create review", err)
 		return
 	}
 

+ 15 - 15
backend/internal/handlers/services.go

@@ -18,7 +18,7 @@ type ServiceHandler struct {
 	tagRepo     *repository.TagRepo
 }
 
-func NewServiceHandler(serviceRepo *repository.ServiceRepo, tagRepo *repository.TagRepo) *ServiceHandler {
+func NewServiceHandler(serviceRepo *repository.ServiceRepo, tagRepo *repository.TagRepo, appEnv string) *ServiceHandler {
 	return &ServiceHandler{serviceRepo: serviceRepo, tagRepo: tagRepo}
 }
 
@@ -52,7 +52,7 @@ func (h *ServiceHandler) List(w http.ResponseWriter, r *http.Request) {
 
 	services, err := h.serviceRepo.List(r.Context(), filter)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to list services", err)
 		return
 	}
 
@@ -68,11 +68,11 @@ func (h *ServiceHandler) GetByID(w http.ResponseWriter, r *http.Request) {
 
 	svc, err := h.serviceRepo.GetByID(r.Context(), id)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to get service", err)
 		return
 	}
 	if svc == nil {
-		writeError(w, http.StatusNotFound, "service not found")
+		writeError(w, http.StatusNotFound, "service not found", nil)
 		return
 	}
 
@@ -107,7 +107,7 @@ func (h *ServiceHandler) Create(w http.ResponseWriter, r *http.Request) {
 
 	var req createServiceRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeError(w, http.StatusBadRequest, "invalid request body")
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
 		return
 	}
 
@@ -133,7 +133,7 @@ func (h *ServiceHandler) Create(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if err := h.serviceRepo.Create(r.Context(), svc); err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to create service", err)
 		return
 	}
 
@@ -147,7 +147,7 @@ func (h *ServiceHandler) Update(w http.ResponseWriter, r *http.Request) {
 
 	var req updateServiceRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeError(w, http.StatusBadRequest, "invalid request body")
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
 		return
 	}
 
@@ -158,16 +158,16 @@ func (h *ServiceHandler) Update(w http.ResponseWriter, r *http.Request) {
 
 	svc, err := h.serviceRepo.GetByID(r.Context(), id)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to get service", err)
 		return
 	}
 	if svc == nil {
-		writeError(w, http.StatusNotFound, "service not found")
+		writeError(w, http.StatusNotFound, "service not found", nil)
 		return
 	}
 
 	if svc.ExecutorID != userID && role != "moderator" && role != "superadmin" {
-		writeError(w, http.StatusForbidden, "not your service")
+		writeError(w, http.StatusForbidden, "not your service", nil)
 		return
 	}
 
@@ -178,7 +178,7 @@ func (h *ServiceHandler) Update(w http.ResponseWriter, r *http.Request) {
 	if req.DurationMinutes != nil { svc.DurationMinutes = req.DurationMinutes }
 
 	if err := h.serviceRepo.Update(r.Context(), svc); err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to update service", err)
 		return
 	}
 
@@ -192,20 +192,20 @@ func (h *ServiceHandler) Delete(w http.ResponseWriter, r *http.Request) {
 
 	svc, err := h.serviceRepo.GetByID(r.Context(), id)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to get service", err)
 		return
 	}
 	if svc == nil {
-		writeError(w, http.StatusNotFound, "service not found")
+		writeError(w, http.StatusNotFound, "service not found", nil)
 		return
 	}
 	if svc.ExecutorID != userID && role != "moderator" && role != "superadmin" {
-		writeError(w, http.StatusForbidden, "not your service")
+		writeError(w, http.StatusForbidden, "not your service", nil)
 		return
 	}
 
 	if err := h.serviceRepo.SoftDelete(r.Context(), id); err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to delete service", err)
 		return
 	}
 	w.WriteHeader(http.StatusNoContent)

+ 9 - 9
backend/internal/handlers/tags.go

@@ -16,14 +16,14 @@ type TagHandler struct {
 	featureRepo *repository.FeatureRepo
 }
 
-func NewTagHandler(tagRepo *repository.TagRepo, featureRepo *repository.FeatureRepo) *TagHandler {
+func NewTagHandler(tagRepo *repository.TagRepo, featureRepo *repository.FeatureRepo, appEnv string) *TagHandler {
 	return &TagHandler{tagRepo: tagRepo, featureRepo: featureRepo}
 }
 
 func (h *TagHandler) ListTags(w http.ResponseWriter, r *http.Request) {
 	tags, err := h.tagRepo.GetAll(r.Context())
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to list tags", err)
 		return
 	}
 	writeJSON(w, http.StatusOK, tags)
@@ -32,7 +32,7 @@ func (h *TagHandler) ListTags(w http.ResponseWriter, r *http.Request) {
 func (h *TagHandler) ListFeatures(w http.ResponseWriter, r *http.Request) {
 	features, err := h.featureRepo.GetAll(r.Context())
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to list features", err)
 		return
 	}
 	writeJSON(w, http.StatusOK, features)
@@ -60,7 +60,7 @@ type deleteTagRequest struct {
 func (h *TagHandler) CreateTag(w http.ResponseWriter, r *http.Request) {
 	var req createTagRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeError(w, http.StatusBadRequest, "invalid request body")
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
 		return
 	}
 	if err := validator.Validate(req); err != nil {
@@ -69,7 +69,7 @@ func (h *TagHandler) CreateTag(w http.ResponseWriter, r *http.Request) {
 	}
 	tag := &models.Tag{ID: req.ID, Name: req.Name, Category: pointer.Str(req.Category), SortOrder: req.SortOrder}
 	if err := h.tagRepo.Create(r.Context(), tag); err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to create tag", err)
 		return
 	}
 	writeJSON(w, http.StatusCreated, tag)
@@ -85,7 +85,7 @@ func (h *TagHandler) DeleteTag(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	if err := h.tagRepo.Delete(r.Context(), req.ID); err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to delete tag", err)
 		return
 	}
 	w.WriteHeader(http.StatusNoContent)
@@ -94,7 +94,7 @@ func (h *TagHandler) DeleteTag(w http.ResponseWriter, r *http.Request) {
 func (h *TagHandler) CreateFeature(w http.ResponseWriter, r *http.Request) {
 	var req createFeatureRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeError(w, http.StatusBadRequest, "invalid request body")
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
 		return
 	}
 	if err := validator.Validate(req); err != nil {
@@ -103,7 +103,7 @@ func (h *TagHandler) CreateFeature(w http.ResponseWriter, r *http.Request) {
 	}
 	feature := &models.Feature{ID: req.ID, Name: req.Name, Category: pointer.Str(req.Category), Icon: pointer.Str(req.Icon), SortOrder: req.SortOrder}
 	if err := h.featureRepo.Create(r.Context(), feature); err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to create feature", err)
 		return
 	}
 	writeJSON(w, http.StatusCreated, feature)
@@ -117,7 +117,7 @@ func (h *TagHandler) DeleteFeature(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	if err := h.featureRepo.Delete(r.Context(), id); err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to delete feature", err)
 		return
 	}
 	w.WriteHeader(http.StatusNoContent)

+ 4 - 4
backend/internal/handlers/upload.go

@@ -18,7 +18,7 @@ type UploadHandler struct {
 	publicEndpoint string
 }
 
-func NewUploadHandler(endpoint, publicEndpoint, accessKey, secretKey, bucket string, useSSL bool) (*UploadHandler, error) {
+func NewUploadHandler(endpoint, publicEndpoint, accessKey, secretKey, bucket string, useSSL bool, appEnv string) (*UploadHandler, error) {
 	client, err := minio.New(endpoint, &minio.Options{
 		Creds:  credentials.NewStaticV4(accessKey, secretKey, ""),
 		Secure: useSSL,
@@ -49,7 +49,7 @@ type presignedURLResponse struct {
 func (h *UploadHandler) PresignedURL(w http.ResponseWriter, r *http.Request) {
 	var req presignedURLRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeError(w, http.StatusBadRequest, "invalid request body")
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
 		return
 	}
 
@@ -61,7 +61,7 @@ func (h *UploadHandler) PresignedURL(w http.ResponseWriter, r *http.Request) {
 		"image/jpeg": true, "image/png": true, "image/webp": true, "image/heic": true,
 	}
 	if !validContentTypes[req.ContentType] {
-		writeError(w, http.StatusBadRequest, "unsupported content type")
+		writeError(w, http.StatusBadRequest, "unsupported content type", nil)
 		return
 	}
 
@@ -86,7 +86,7 @@ func (h *UploadHandler) PresignedURL(w http.ResponseWriter, r *http.Request) {
 
 	url, formData, err := h.minioClient.PresignedPostPolicy(r.Context(), policy)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, "failed to generate upload URL")
+		writeError(w, http.StatusInternalServerError, "failed to generate upload URL", err)
 		return
 	}
 

+ 12 - 12
backend/internal/handlers/users.go

@@ -17,7 +17,7 @@ type UserHandler struct {
 	userRepo *repository.UserRepo
 }
 
-func NewUserHandler(userRepo *repository.UserRepo) *UserHandler {
+func NewUserHandler(userRepo *repository.UserRepo, appEnv string) *UserHandler {
 	return &UserHandler{userRepo: userRepo}
 }
 
@@ -29,11 +29,11 @@ func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
 
 	user, err := h.userRepo.GetByID(r.Context(), userID)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to get user", err)
 		return
 	}
 	if user == nil {
-		writeError(w, http.StatusNotFound, "user not found")
+		writeError(w, http.StatusNotFound, "user not found", nil)
 		return
 	}
 
@@ -53,17 +53,17 @@ func (h *UserHandler) UpdateMe(w http.ResponseWriter, r *http.Request) {
 
 	user, err := h.userRepo.GetByID(r.Context(), userID)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to get user", err)
 		return
 	}
 	if user == nil {
-		writeError(w, http.StatusNotFound, "user not found")
+		writeError(w, http.StatusNotFound, "user not found", nil)
 		return
 	}
 
 	var req updateMeRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeError(w, http.StatusBadRequest, "invalid request body")
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
 		return
 	}
 
@@ -79,7 +79,7 @@ func (h *UserHandler) UpdateMe(w http.ResponseWriter, r *http.Request) {
 	if req.Country != nil { user.Country = req.Country }
 
 	if err := h.userRepo.Update(r.Context(), user); err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to update user", err)
 		return
 	}
 
@@ -98,7 +98,7 @@ func (h *UserHandler) AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
 
 	var req adminUpdateUserRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		writeError(w, http.StatusBadRequest, "invalid request body")
+		writeError(w, http.StatusBadRequest, "invalid request body", err)
 		return
 	}
 
@@ -109,21 +109,21 @@ func (h *UserHandler) AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
 
 	if req.Role != nil {
 		if err := h.userRepo.UpdateRole(r.Context(), id, *req.Role); err != nil {
-			writeError(w, http.StatusInternalServerError, err.Error())
+			writeError(w, http.StatusInternalServerError, "failed to update role", err)
 			return
 		}
 	}
 
 	if req.Status != nil {
 		if err := h.userRepo.UpdateStatus(r.Context(), id, *req.Status); err != nil {
-			writeError(w, http.StatusInternalServerError, err.Error())
+			writeError(w, http.StatusInternalServerError, "failed to update status", err)
 			return
 		}
 	}
 
 	user, err := h.userRepo.GetByID(r.Context(), id)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to get user", err)
 		return
 	}
 
@@ -143,7 +143,7 @@ func (h *UserHandler) AdminListUsers(w http.ResponseWriter, r *http.Request) {
 
 	users, err := h.userRepo.List(r.Context(), filter)
 	if err != nil {
-		writeError(w, http.StatusInternalServerError, err.Error())
+		writeError(w, http.StatusInternalServerError, "failed to list users", err)
 		return
 	}