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:
Trey t
2025-12-16 13:52:08 -06:00
parent c51f1ce34a
commit 6dac34e373
98 changed files with 8209 additions and 4425 deletions

View File

@@ -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"),
})
}