Migrate from Gin to Echo framework and add comprehensive integration tests
Major changes: - Migrate all handlers from Gin to Echo framework - Add new apperrors, echohelpers, and validator packages - Update middleware for Echo compatibility - Add ArchivedHandler to task categorization chain (archived tasks go to cancelled_tasks column) - Add 6 new integration tests: - RecurringTaskLifecycle: NextDueDate advancement for weekly/monthly tasks - MultiUserSharing: Complex sharing with user removal - TaskStateTransitions: All state transitions and kanban column changes - DateBoundaryEdgeCases: Threshold boundary testing - CascadeOperations: Residence deletion cascade effects - MultiUserOperations: Shared residence collaboration - Add single-purpose repository functions for kanban columns (GetOverdueTasks, GetDueSoonTasks, etc.) - Fix RemoveUser route param mismatch (userId -> user_id) - Fix determineExpectedColumn helper to correctly prioritize in_progress over overdue 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,29 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/admin"
|
||||
"github.com/treytartt/casera-api/internal/apperrors"
|
||||
"github.com/treytartt/casera-api/internal/config"
|
||||
"github.com/treytartt/casera-api/internal/dto/responses"
|
||||
"github.com/treytartt/casera-api/internal/handlers"
|
||||
"github.com/treytartt/casera-api/internal/i18n"
|
||||
"github.com/treytartt/casera-api/internal/middleware"
|
||||
custommiddleware "github.com/treytartt/casera-api/internal/middleware"
|
||||
"github.com/treytartt/casera-api/internal/monitoring"
|
||||
"github.com/treytartt/casera-api/internal/push"
|
||||
"github.com/treytartt/casera-api/internal/repositories"
|
||||
"github.com/treytartt/casera-api/internal/services"
|
||||
customvalidator "github.com/treytartt/casera-api/internal/validator"
|
||||
"github.com/treytartt/casera-api/pkg/utils"
|
||||
)
|
||||
|
||||
@@ -34,55 +41,54 @@ type Dependencies struct {
|
||||
MonitoringService *monitoring.Service
|
||||
}
|
||||
|
||||
// SetupRouter creates and configures the Gin router
|
||||
func SetupRouter(deps *Dependencies) *gin.Engine {
|
||||
// SetupRouter creates and configures the Echo router
|
||||
func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
cfg := deps.Config
|
||||
|
||||
// Set Gin mode based on debug setting
|
||||
if cfg.Server.Debug {
|
||||
gin.SetMode(gin.DebugMode)
|
||||
} else {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.Validator = customvalidator.NewCustomValidator()
|
||||
e.HTTPErrorHandler = customHTTPErrorHandler
|
||||
|
||||
r := gin.New()
|
||||
// Add trailing slash middleware (before other middleware)
|
||||
e.Pre(middleware.AddTrailingSlash())
|
||||
|
||||
// Global middleware
|
||||
r.Use(utils.GinRecovery())
|
||||
r.Use(utils.GinLogger())
|
||||
r.Use(corsMiddleware(cfg))
|
||||
r.Use(i18n.Middleware())
|
||||
e.Use(utils.EchoRecovery())
|
||||
e.Use(utils.EchoLogger())
|
||||
e.Use(corsMiddleware(cfg))
|
||||
e.Use(i18n.Middleware())
|
||||
|
||||
// Monitoring metrics middleware (if monitoring is enabled)
|
||||
if deps.MonitoringService != nil {
|
||||
if metricsMiddleware := deps.MonitoringService.MetricsMiddleware(); metricsMiddleware != nil {
|
||||
r.Use(metricsMiddleware.(gin.HandlerFunc))
|
||||
e.Use(metricsMiddleware)
|
||||
}
|
||||
}
|
||||
|
||||
// Serve landing page static files (if static directory is configured)
|
||||
staticDir := cfg.Server.StaticDir
|
||||
if staticDir != "" {
|
||||
r.Static("/css", staticDir+"/css")
|
||||
r.Static("/js", staticDir+"/js")
|
||||
r.Static("/images", staticDir+"/images")
|
||||
r.StaticFile("/favicon.ico", staticDir+"/images/favicon.svg")
|
||||
e.Static("/css", staticDir+"/css")
|
||||
e.Static("/js", staticDir+"/js")
|
||||
e.Static("/images", staticDir+"/images")
|
||||
e.File("/favicon.ico", staticDir+"/images/favicon.svg")
|
||||
|
||||
// Serve index.html at root
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.File(staticDir + "/index.html")
|
||||
e.GET("/", func(c echo.Context) error {
|
||||
return c.File(staticDir + "/index.html")
|
||||
})
|
||||
}
|
||||
|
||||
// Health check endpoint (no auth required)
|
||||
r.GET("/api/health/", healthCheck)
|
||||
e.GET("/api/health/", healthCheck)
|
||||
|
||||
// Initialize onboarding email service for tracking handler
|
||||
onboardingService := services.NewOnboardingEmailService(deps.DB, deps.EmailService, cfg.Server.BaseURL)
|
||||
|
||||
// Email tracking endpoint (no auth required - used by email tracking pixels)
|
||||
trackingHandler := handlers.NewTrackingHandler(onboardingService)
|
||||
r.GET("/api/track/open/:trackingID", trackingHandler.TrackEmailOpen)
|
||||
e.GET("/api/track/open/:trackingID", trackingHandler.TrackEmailOpen)
|
||||
|
||||
// NOTE: Public static file serving removed for security.
|
||||
// All uploaded media is now served through authenticated proxy endpoints:
|
||||
@@ -123,7 +129,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
||||
subscriptionWebhookHandler := handlers.NewSubscriptionWebhookHandler(subscriptionRepo, userRepo)
|
||||
|
||||
// Initialize middleware
|
||||
authMiddleware := middleware.NewAuthMiddleware(deps.DB, deps.Cache)
|
||||
authMiddleware := custommiddleware.NewAuthMiddleware(deps.DB, deps.Cache)
|
||||
|
||||
// Initialize Apple auth service
|
||||
appleAuthService := services.NewAppleAuthService(deps.Cache, cfg)
|
||||
@@ -161,10 +167,10 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
||||
OnboardingService: onboardingService,
|
||||
MonitoringHandler: monitoringHandler,
|
||||
}
|
||||
admin.SetupRoutes(r, deps.DB, cfg, adminDeps)
|
||||
admin.SetupRoutes(e, deps.DB, cfg, adminDeps)
|
||||
|
||||
// API group
|
||||
api := r.Group("/api")
|
||||
api := e.Group("/api")
|
||||
{
|
||||
// Public auth routes (no auth required)
|
||||
setupPublicAuthRoutes(api, authHandler)
|
||||
@@ -178,7 +184,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
||||
// Protected routes (auth required)
|
||||
protected := api.Group("")
|
||||
protected.Use(authMiddleware.TokenAuth())
|
||||
protected.Use(middleware.TimezoneMiddleware())
|
||||
protected.Use(custommiddleware.TimezoneMiddleware())
|
||||
{
|
||||
setupProtectedAuthRoutes(protected, authHandler)
|
||||
setupResidenceRoutes(protected, residenceHandler)
|
||||
@@ -201,33 +207,33 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
return e
|
||||
}
|
||||
|
||||
// corsMiddleware configures CORS - allowing all origins for API access
|
||||
func corsMiddleware(cfg *config.Config) gin.HandlerFunc {
|
||||
return cors.New(cors.Config{
|
||||
AllowAllOrigins: true,
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With", "X-Timezone"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: false, // Must be false when AllowAllOrigins is true
|
||||
MaxAge: 12 * time.Hour,
|
||||
func corsMiddleware(cfg *config.Config) echo.MiddlewareFunc {
|
||||
return middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions},
|
||||
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization, "X-Requested-With", "X-Timezone"},
|
||||
ExposeHeaders: []string{echo.HeaderContentLength},
|
||||
AllowCredentials: false,
|
||||
MaxAge: int((12 * time.Hour).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// healthCheck returns API health status
|
||||
func healthCheck(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
func healthCheck(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"status": "healthy",
|
||||
"version": Version,
|
||||
"framework": "Gin",
|
||||
"framework": "Echo",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// setupPublicAuthRoutes configures public authentication routes
|
||||
func setupPublicAuthRoutes(api *gin.RouterGroup, authHandler *handlers.AuthHandler) {
|
||||
func setupPublicAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler) {
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/login/", authHandler.Login)
|
||||
@@ -240,7 +246,7 @@ func setupPublicAuthRoutes(api *gin.RouterGroup, authHandler *handlers.AuthHandl
|
||||
}
|
||||
|
||||
// setupProtectedAuthRoutes configures protected authentication routes
|
||||
func setupProtectedAuthRoutes(api *gin.RouterGroup, authHandler *handlers.AuthHandler) {
|
||||
func setupProtectedAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler) {
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/logout/", authHandler.Logout)
|
||||
@@ -254,7 +260,7 @@ func setupProtectedAuthRoutes(api *gin.RouterGroup, authHandler *handlers.AuthHa
|
||||
}
|
||||
|
||||
// setupPublicDataRoutes configures public data routes (lookups, static data)
|
||||
func setupPublicDataRoutes(api *gin.RouterGroup, residenceHandler *handlers.ResidenceHandler, taskHandler *handlers.TaskHandler, contractorHandler *handlers.ContractorHandler, staticDataHandler *handlers.StaticDataHandler, subscriptionHandler *handlers.SubscriptionHandler, taskTemplateHandler *handlers.TaskTemplateHandler) {
|
||||
func setupPublicDataRoutes(api *echo.Group, residenceHandler *handlers.ResidenceHandler, taskHandler *handlers.TaskHandler, contractorHandler *handlers.ContractorHandler, staticDataHandler *handlers.StaticDataHandler, subscriptionHandler *handlers.SubscriptionHandler, taskTemplateHandler *handlers.TaskTemplateHandler) {
|
||||
// Static data routes (public, cached)
|
||||
staticData := api.Group("/static_data")
|
||||
{
|
||||
@@ -287,7 +293,7 @@ func setupPublicDataRoutes(api *gin.RouterGroup, residenceHandler *handlers.Resi
|
||||
}
|
||||
|
||||
// setupResidenceRoutes configures residence routes
|
||||
func setupResidenceRoutes(api *gin.RouterGroup, residenceHandler *handlers.ResidenceHandler) {
|
||||
func setupResidenceRoutes(api *echo.Group, residenceHandler *handlers.ResidenceHandler) {
|
||||
residences := api.Group("/residences")
|
||||
{
|
||||
residences.GET("/", residenceHandler.ListResidences)
|
||||
@@ -310,7 +316,7 @@ func setupResidenceRoutes(api *gin.RouterGroup, residenceHandler *handlers.Resid
|
||||
}
|
||||
|
||||
// setupTaskRoutes configures task routes
|
||||
func setupTaskRoutes(api *gin.RouterGroup, taskHandler *handlers.TaskHandler) {
|
||||
func setupTaskRoutes(api *echo.Group, taskHandler *handlers.TaskHandler) {
|
||||
tasks := api.Group("/tasks")
|
||||
{
|
||||
tasks.GET("/", taskHandler.ListTasks)
|
||||
@@ -342,7 +348,7 @@ func setupTaskRoutes(api *gin.RouterGroup, taskHandler *handlers.TaskHandler) {
|
||||
}
|
||||
|
||||
// setupContractorRoutes configures contractor routes
|
||||
func setupContractorRoutes(api *gin.RouterGroup, contractorHandler *handlers.ContractorHandler) {
|
||||
func setupContractorRoutes(api *echo.Group, contractorHandler *handlers.ContractorHandler) {
|
||||
contractors := api.Group("/contractors")
|
||||
{
|
||||
contractors.GET("/", contractorHandler.ListContractors)
|
||||
@@ -358,7 +364,7 @@ func setupContractorRoutes(api *gin.RouterGroup, contractorHandler *handlers.Con
|
||||
}
|
||||
|
||||
// setupDocumentRoutes configures document routes
|
||||
func setupDocumentRoutes(api *gin.RouterGroup, documentHandler *handlers.DocumentHandler) {
|
||||
func setupDocumentRoutes(api *echo.Group, documentHandler *handlers.DocumentHandler) {
|
||||
documents := api.Group("/documents")
|
||||
{
|
||||
documents.GET("/", documentHandler.ListDocuments)
|
||||
@@ -374,7 +380,7 @@ func setupDocumentRoutes(api *gin.RouterGroup, documentHandler *handlers.Documen
|
||||
}
|
||||
|
||||
// setupNotificationRoutes configures notification routes
|
||||
func setupNotificationRoutes(api *gin.RouterGroup, notificationHandler *handlers.NotificationHandler) {
|
||||
func setupNotificationRoutes(api *echo.Group, notificationHandler *handlers.NotificationHandler) {
|
||||
notifications := api.Group("/notifications")
|
||||
{
|
||||
notifications.GET("/", notificationHandler.ListNotifications)
|
||||
@@ -395,7 +401,7 @@ func setupNotificationRoutes(api *gin.RouterGroup, notificationHandler *handlers
|
||||
|
||||
// setupSubscriptionRoutes configures subscription routes (authenticated)
|
||||
// Note: /upgrade-triggers/ is in setupPublicDataRoutes (public, no auth required)
|
||||
func setupSubscriptionRoutes(api *gin.RouterGroup, subscriptionHandler *handlers.SubscriptionHandler) {
|
||||
func setupSubscriptionRoutes(api *echo.Group, subscriptionHandler *handlers.SubscriptionHandler) {
|
||||
subscription := api.Group("/subscription")
|
||||
{
|
||||
subscription.GET("/", subscriptionHandler.GetSubscription)
|
||||
@@ -410,7 +416,7 @@ func setupSubscriptionRoutes(api *gin.RouterGroup, subscriptionHandler *handlers
|
||||
}
|
||||
|
||||
// setupUserRoutes configures user routes
|
||||
func setupUserRoutes(api *gin.RouterGroup, userHandler *handlers.UserHandler) {
|
||||
func setupUserRoutes(api *echo.Group, userHandler *handlers.UserHandler) {
|
||||
users := api.Group("/users")
|
||||
{
|
||||
users.GET("/", userHandler.ListUsers)
|
||||
@@ -420,7 +426,7 @@ func setupUserRoutes(api *gin.RouterGroup, userHandler *handlers.UserHandler) {
|
||||
}
|
||||
|
||||
// setupUploadRoutes configures file upload routes
|
||||
func setupUploadRoutes(api *gin.RouterGroup, uploadHandler *handlers.UploadHandler) {
|
||||
func setupUploadRoutes(api *echo.Group, uploadHandler *handlers.UploadHandler) {
|
||||
uploads := api.Group("/uploads")
|
||||
{
|
||||
uploads.POST("/image/", uploadHandler.UploadImage)
|
||||
@@ -431,7 +437,7 @@ func setupUploadRoutes(api *gin.RouterGroup, uploadHandler *handlers.UploadHandl
|
||||
}
|
||||
|
||||
// setupMediaRoutes configures authenticated media serving routes
|
||||
func setupMediaRoutes(api *gin.RouterGroup, mediaHandler *handlers.MediaHandler) {
|
||||
func setupMediaRoutes(api *echo.Group, mediaHandler *handlers.MediaHandler) {
|
||||
media := api.Group("/media")
|
||||
{
|
||||
media.GET("/document/:id", mediaHandler.ServeDocument)
|
||||
@@ -442,10 +448,145 @@ func setupMediaRoutes(api *gin.RouterGroup, mediaHandler *handlers.MediaHandler)
|
||||
|
||||
// setupWebhookRoutes configures subscription webhook routes for Apple/Google server-to-server notifications
|
||||
// These routes are public (no auth) since they're called by Apple/Google servers
|
||||
func setupWebhookRoutes(api *gin.RouterGroup, webhookHandler *handlers.SubscriptionWebhookHandler) {
|
||||
func setupWebhookRoutes(api *echo.Group, webhookHandler *handlers.SubscriptionWebhookHandler) {
|
||||
webhooks := api.Group("/subscription/webhook")
|
||||
{
|
||||
webhooks.POST("/apple/", webhookHandler.HandleAppleWebhook)
|
||||
webhooks.POST("/google/", webhookHandler.HandleGoogleWebhook)
|
||||
}
|
||||
}
|
||||
|
||||
// customHTTPErrorHandler handles all errors returned from handlers in a consistent way.
|
||||
// It converts AppErrors, validation errors, and Echo HTTPErrors to JSON responses.
|
||||
// Also includes fallback handling for legacy service-level errors.
|
||||
func customHTTPErrorHandler(err error, c echo.Context) {
|
||||
// Already committed? Skip
|
||||
if c.Response().Committed {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle AppError (our custom application errors)
|
||||
var appErr *apperrors.AppError
|
||||
if errors.As(err, &appErr) {
|
||||
message := i18n.LocalizedMessage(c, appErr.MessageKey)
|
||||
// If i18n key not found (returns the key itself), use fallback message
|
||||
if message == appErr.MessageKey && appErr.Message != "" {
|
||||
message = appErr.Message
|
||||
} else if message == appErr.MessageKey {
|
||||
message = appErr.MessageKey // Use the key as last resort
|
||||
}
|
||||
|
||||
// Log internal errors
|
||||
if appErr.Err != nil {
|
||||
log.Error().Err(appErr.Err).Str("message_key", appErr.MessageKey).Msg("Application error")
|
||||
}
|
||||
|
||||
c.JSON(appErr.Code, responses.ErrorResponse{Error: message})
|
||||
return
|
||||
}
|
||||
|
||||
// Handle validation errors from go-playground/validator
|
||||
var validationErrs validator.ValidationErrors
|
||||
if errors.As(err, &validationErrs) {
|
||||
c.JSON(http.StatusBadRequest, customvalidator.FormatValidationErrors(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Echo's built-in HTTPError
|
||||
var httpErr *echo.HTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
msg := fmt.Sprintf("%v", httpErr.Message)
|
||||
c.JSON(httpErr.Code, responses.ErrorResponse{Error: msg})
|
||||
return
|
||||
}
|
||||
|
||||
// Handle service-layer errors and map them to appropriate HTTP status codes
|
||||
switch {
|
||||
// Task errors - 404 Not Found
|
||||
case errors.Is(err, services.ErrTaskNotFound):
|
||||
c.JSON(http.StatusNotFound, responses.ErrorResponse{
|
||||
Error: i18n.LocalizedMessage(c, "error.task_not_found"),
|
||||
})
|
||||
return
|
||||
case errors.Is(err, services.ErrCompletionNotFound):
|
||||
c.JSON(http.StatusNotFound, responses.ErrorResponse{
|
||||
Error: i18n.LocalizedMessage(c, "error.completion_not_found"),
|
||||
})
|
||||
return
|
||||
|
||||
// Task errors - 403 Forbidden
|
||||
case errors.Is(err, services.ErrTaskAccessDenied):
|
||||
c.JSON(http.StatusForbidden, responses.ErrorResponse{
|
||||
Error: i18n.LocalizedMessage(c, "error.task_access_denied"),
|
||||
})
|
||||
return
|
||||
|
||||
// Task errors - 400 Bad Request
|
||||
case errors.Is(err, services.ErrTaskAlreadyCancelled):
|
||||
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
|
||||
Error: i18n.LocalizedMessage(c, "error.task_already_cancelled"),
|
||||
})
|
||||
return
|
||||
case errors.Is(err, services.ErrTaskAlreadyArchived):
|
||||
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
|
||||
Error: i18n.LocalizedMessage(c, "error.task_already_archived"),
|
||||
})
|
||||
return
|
||||
|
||||
// Residence errors - 404 Not Found
|
||||
case errors.Is(err, services.ErrResidenceNotFound):
|
||||
c.JSON(http.StatusNotFound, responses.ErrorResponse{
|
||||
Error: i18n.LocalizedMessage(c, "error.residence_not_found"),
|
||||
})
|
||||
return
|
||||
|
||||
// Residence errors - 403 Forbidden
|
||||
case errors.Is(err, services.ErrResidenceAccessDenied):
|
||||
c.JSON(http.StatusForbidden, responses.ErrorResponse{
|
||||
Error: i18n.LocalizedMessage(c, "error.residence_access_denied"),
|
||||
})
|
||||
return
|
||||
case errors.Is(err, services.ErrNotResidenceOwner):
|
||||
c.JSON(http.StatusForbidden, responses.ErrorResponse{
|
||||
Error: i18n.LocalizedMessage(c, "error.not_residence_owner"),
|
||||
})
|
||||
return
|
||||
case errors.Is(err, services.ErrPropertiesLimitReached):
|
||||
c.JSON(http.StatusForbidden, responses.ErrorResponse{
|
||||
Error: i18n.LocalizedMessage(c, "error.properties_limit_reached"),
|
||||
})
|
||||
return
|
||||
|
||||
// Residence errors - 400 Bad Request
|
||||
case errors.Is(err, services.ErrCannotRemoveOwner):
|
||||
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
|
||||
Error: i18n.LocalizedMessage(c, "error.cannot_remove_owner"),
|
||||
})
|
||||
return
|
||||
case errors.Is(err, services.ErrShareCodeExpired):
|
||||
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
|
||||
Error: i18n.LocalizedMessage(c, "error.share_code_expired"),
|
||||
})
|
||||
return
|
||||
|
||||
// Residence errors - 404 Not Found (share code)
|
||||
case errors.Is(err, services.ErrShareCodeInvalid):
|
||||
c.JSON(http.StatusNotFound, responses.ErrorResponse{
|
||||
Error: i18n.LocalizedMessage(c, "error.share_code_invalid"),
|
||||
})
|
||||
return
|
||||
|
||||
// Residence errors - 409 Conflict
|
||||
case errors.Is(err, services.ErrUserAlreadyMember):
|
||||
c.JSON(http.StatusConflict, responses.ErrorResponse{
|
||||
Error: i18n.LocalizedMessage(c, "error.user_already_member"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Default: Internal server error (don't expose error details to client)
|
||||
log.Error().Err(err).Msg("Unhandled error")
|
||||
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{
|
||||
Error: i18n.LocalizedMessage(c, "error.internal"),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user