package router import ( "context" "errors" "fmt" "net/http" "os" "strings" "time" "github.com/go-playground/validator/v10" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/rs/zerolog/log" "go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho" "gorm.io/gorm" "github.com/treytartt/honeydue-api/internal/admin" "github.com/treytartt/honeydue-api/internal/apperrors" "github.com/treytartt/honeydue-api/internal/config" "github.com/treytartt/honeydue-api/internal/dto/responses" "github.com/treytartt/honeydue-api/internal/handlers" "github.com/treytartt/honeydue-api/internal/i18n" "github.com/treytartt/honeydue-api/internal/kratos" custommiddleware "github.com/treytartt/honeydue-api/internal/middleware" "github.com/treytartt/honeydue-api/internal/monitoring" "github.com/treytartt/honeydue-api/internal/prom" "github.com/treytartt/honeydue-api/internal/push" "github.com/treytartt/honeydue-api/internal/repositories" "github.com/treytartt/honeydue-api/internal/services" customvalidator "github.com/treytartt/honeydue-api/internal/validator" "github.com/treytartt/honeydue-api/internal/worker" "github.com/treytartt/honeydue-api/pkg/utils" ) const Version = "2.0.0" // Dependencies holds all dependencies needed by the router type Dependencies struct { DB *gorm.DB Cache *services.CacheService Config *config.Config EmailService *services.EmailService PDFService *services.PDFService PushClient *push.Client // Direct APNs/FCM client StorageService *services.StorageService MonitoringService *monitoring.Service // TaskEnqueuer is the Asynq client used to push background work onto the // shared Redis queue. Optional — when nil, services that would enqueue // (currently: task-completion notification fan-out) fall back to their // inline implementation. Tests can omit it; production must wire it. TaskEnqueuer worker.Enqueuer } // SetupRouter creates and configures the Echo router func SetupRouter(deps *Dependencies) *echo.Echo { cfg := deps.Config e := echo.New() e.HideBanner = true e.Validator = customvalidator.NewCustomValidator() e.HTTPErrorHandler = customHTTPErrorHandler // NOTE: Removed AddTrailingSlash() middleware - it conflicted with admin routes // which don't use trailing slashes. Mobile API routes explicitly include trailing slashes. // Global middleware e.Use(custommiddleware.RequestIDMiddleware()) e.Use(utils.EchoRecovery()) e.Use(custommiddleware.StructuredLogger()) // OpenTelemetry HTTP middleware — opens a span per request, attaches the // route pattern, method, status, and request_id. Sits early so subsequent // middleware + handlers run inside the request span. e.Use(otelecho.Middleware("honeydue-api")) // Security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, etc.) // // CSP is permissive enough to serve the marketing landing page at / (which // loads same-origin CSS/JS/images and Google Fonts over https). JSON API // responses are unaffected — they don't load any assets, so any CSP is fine. // frame-ancestors stays 'none' to block clickjacking. e.Use(middleware.SecureWithConfig(middleware.SecureConfig{ // XSSProtection deliberately empty (audit L7): the X-XSS-Protection // header is deprecated and has itself caused XSS in legacy browsers. XSSProtection: "", ContentTypeNosniff: "nosniff", XFrameOptions: "SAMEORIGIN", HSTSMaxAge: 63072000, // 2 years — preload-eligible (audit L5/CODE-L3) HSTSPreloadEnabled: true, ReferrerPolicy: "strict-origin-when-cross-origin", ContentSecurityPolicy: "default-src 'self'; " + "style-src 'self' https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com data:; " + "img-src 'self' data:; " + "script-src 'self'; " + "connect-src 'self'; " + "object-src 'none'; " + // audit L8 — disable plugins/embeds "base-uri 'self'; " + // audit L8 — block hijacking "frame-ancestors 'none'", })) e.Use(middleware.BodyLimitWithConfig(middleware.BodyLimitConfig{ Limit: "1M", // 1MB default for JSON payloads Skipper: func(c echo.Context) bool { // Allow larger payloads for webhook endpoints (Apple/Google/Stripe notifications) return strings.HasPrefix(c.Request().URL.Path, "/api/subscription/webhook") }, })) e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{ Timeout: 30 * time.Second, Skipper: func(c echo.Context) bool { path := c.Request().URL.Path // Skip timeout for reverse proxy and WebSocket routes — the // timeout middleware wraps the response writer in *http.timeoutWriter // which does not implement http.Flusher, causing a panic when // httputil.ReverseProxy or WebSocket upgraders try to flush. // Also skip for admin subdomain (all requests proxied to Next.js). adminHost := os.Getenv("ADMIN_HOST") return (adminHost != "" && c.Request().Host == adminHost) || strings.HasPrefix(path, "/_next") || strings.HasSuffix(path, "/ws") }, })) e.Use(corsMiddleware(cfg)) e.Use(i18n.Middleware()) // Gzip compression (skip media endpoints since they serve binary files; // also skip /metrics because promhttp does its own content negotiation, // so wrapping it here produces double-gzipped output that breaks scrapers). e.Use(middleware.GzipWithConfig(middleware.GzipConfig{ Level: 5, Skipper: func(c echo.Context) bool { path := c.Request().URL.Path return strings.HasPrefix(path, "/api/media/") || path == "/metrics" }, })) // Monitoring metrics middleware (if monitoring is enabled) if deps.MonitoringService != nil { if metricsMiddleware := deps.MonitoringService.MetricsMiddleware(); metricsMiddleware != nil { e.Use(metricsMiddleware) } } // Prometheus metrics middleware — feeds VictoriaMetrics on // obs.88oakapps.com via vmagent. Records http_request_duration_seconds // labeled by route pattern, method, and status code. e.Use(prom.HTTPMiddleware()) // /metrics endpoint for the in-cluster vmagent scrape (audit LIVE-L1). // vmagent scrapes api pods directly (pod-to-pod), so its requests carry // no X-Forwarded-For. Any request that DOES carry one reached us through // Traefik/Cloudflare — i.e. the public internet — and is refused with a // 404. The api pod port is not exposed outside the cluster, so a request // cannot reach /metrics without going through Traefik, and Traefik always // appends X-Forwarded-For — the check cannot be bypassed. metricsHandler := prom.Handler() e.GET("/metrics", func(c echo.Context) error { if c.Request().Header.Get("X-Forwarded-For") != "" { return echo.NewHTTPError(http.StatusNotFound) } return metricsHandler(c) }) // Serve landing page static files (if static directory is configured) staticDir := cfg.Server.StaticDir if staticDir != "" { 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 e.GET("/", func(c echo.Context) error { return c.File(staticDir + "/index.html") }) } // Health check endpoints (no auth required) e.GET("/api/health/", readinessCheck(deps)) e.GET("/api/health/live", liveCheck) // 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) 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: // - GET /api/media/document/:id // - GET /api/media/document-image/:id // - GET /api/media/completion-image/:id // These endpoints verify the user has access to the residence before serving files. // Initialize repositories userRepo := repositories.NewUserRepository(deps.DB) residenceRepo := repositories.NewResidenceRepository(deps.DB) taskRepo := repositories.NewTaskRepository(deps.DB) contractorRepo := repositories.NewContractorRepository(deps.DB) documentRepo := repositories.NewDocumentRepository(deps.DB) notificationRepo := repositories.NewNotificationRepository(deps.DB) subscriptionRepo := repositories.NewSubscriptionRepository(deps.DB) taskTemplateRepo := repositories.NewTaskTemplateRepository(deps.DB) // Initialize services authService := services.NewAuthService(userRepo, cfg) authService.SetNotificationRepository(notificationRepo) userService := services.NewUserService(userRepo) residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg) residenceService.SetTaskRepository(taskRepo) // Wire up task repo for statistics taskService := services.NewTaskService(taskRepo, residenceRepo) contractorService := services.NewContractorService(contractorRepo, residenceRepo) documentService := services.NewDocumentService(documentRepo, residenceRepo) notificationService := services.NewNotificationService(notificationRepo, deps.PushClient) // Wire up notification, email, residence, and storage services to task service taskService.SetNotificationService(notificationService) taskService.SetEmailService(deps.EmailService) taskService.SetResidenceService(residenceService) // For including TotalSummary in CRUD responses taskService.SetStorageService(deps.StorageService) // For reading completion images for email if deps.TaskEnqueuer != nil { // Offload completion notifications (push + email + B2 image fetches) // to the Asynq worker so POST /api/task-completions/ doesn't pay for // them in the response path. When the enqueuer is absent (tests), // task_service falls back to the inline implementation. taskService.SetTaskCompletedNotificationEnqueuer(deps.TaskEnqueuer) } subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo) residenceService.SetSubscriptionService(subscriptionService) // Wire up subscription service for tier limit enforcement // Wire Redis cache for residence-ID lookups across the four services that // read it on the request hot path. Cache is best-effort; nil cache is OK. if deps.Cache != nil { authService.SetCacheService(deps.Cache) residenceService.SetCacheService(deps.Cache) taskService.SetCacheService(deps.Cache) contractorService.SetCacheService(deps.Cache) documentService.SetCacheService(deps.Cache) subscriptionService.SetCacheService(deps.Cache) } taskTemplateService := services.NewTaskTemplateService(taskTemplateRepo) suggestionService := services.NewSuggestionService(deps.DB, residenceRepo) // Initialize Stripe service stripeService := services.NewStripeService(subscriptionRepo, userRepo) if deps.Cache != nil { stripeService.SetCacheService(deps.Cache) } // Initialize webhook event repo for deduplication webhookEventRepo := repositories.NewWebhookEventRepository(deps.DB) // Initialize webhook handler for Apple/Google/Stripe subscription notifications subscriptionWebhookHandler := handlers.NewSubscriptionWebhookHandler(subscriptionRepo, userRepo, webhookEventRepo, cfg.Features.WebhooksEnabled) subscriptionWebhookHandler.SetStripeService(stripeService) subscriptionWebhookHandler.SetCacheService(deps.Cache) // Initialize Kratos auth middleware (replaces hand-rolled token auth). kratosClient := kratos.NewClient(cfg.Security.KratosPublicURL, cfg.Security.KratosAdminURL) authMiddleware := custommiddleware.NewKratosAuth(kratosClient, deps.Cache, deps.DB) authService.SetKratosClient(kratosClient) // account deletion removes the Kratos identity // Initialize audit service for security event logging auditService := services.NewAuditService(deps.DB) // Initialize handlers authHandler := handlers.NewAuthHandler(authService, deps.EmailService, deps.Cache) authHandler.SetStorageService(deps.StorageService) authHandler.SetAuditService(auditService) userHandler := handlers.NewUserHandler(userService) residenceHandler := handlers.NewResidenceHandler(residenceService, deps.PDFService, deps.EmailService, cfg.Features.PDFReportsEnabled) taskHandler := handlers.NewTaskHandler(taskService, deps.StorageService) contractorHandler := handlers.NewContractorHandler(contractorService) documentHandler := handlers.NewDocumentHandler(documentService, deps.StorageService) notificationHandler := handlers.NewNotificationHandler(notificationService) subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService, stripeService) staticDataHandler := handlers.NewStaticDataHandler(residenceService, taskService, contractorService, taskTemplateService, deps.Cache) taskTemplateHandler := handlers.NewTaskTemplateHandler(taskTemplateService) suggestionHandler := handlers.NewSuggestionHandler(suggestionService) // Initialize upload handler (if storage service is available) var uploadHandler *handlers.UploadHandler var mediaHandler *handlers.MediaHandler if deps.StorageService != nil { uploadHandler = handlers.NewUploadHandler(deps.StorageService, services.NewFileOwnershipService(deps.DB)) mediaHandler = handlers.NewMediaHandler(documentRepo, taskRepo, residenceRepo, deps.StorageService) // Presigned-URL upload path requires S3-compatible backend. With local // disk we silently skip; the route returns 500 if hit. if s3 := deps.StorageService.S3Backend(); s3 != nil { pendingUploadRepo := repositories.NewPendingUploadRepository(deps.DB) uploadService := services.NewUploadService(pendingUploadRepo, s3, &cfg.Storage, deps.Cache) uploadHandler.SetUploadService(uploadService) // Task and document services need the upload service to claim // pending_uploads rows when /api/task-completions/ or /api/documents/ // is called with `upload_ids: [..]` instead of multipart. taskService.SetUploadService(uploadService) documentService.SetStorageService(deps.StorageService) documentService.SetUploadService(uploadService) } } // Legacy Prometheus-shaped metrics from internal/monitoring (consumed by // GoAdmin dashboard). Now lives at /metrics/legacy so the canonical /metrics // route (registered above) emits proper Prometheus histograms with labels. if deps.MonitoringService != nil { e.GET("/metrics/legacy", prometheusMetrics(deps.MonitoringService)) } // Set up admin routes with monitoring handler (if available) var monitoringHandler *monitoring.Handler if deps.MonitoringService != nil { monitoringHandler = deps.MonitoringService.Handler() } adminDeps := &admin.Dependencies{ EmailService: deps.EmailService, PushClient: deps.PushClient, OnboardingService: onboardingService, MonitoringHandler: monitoringHandler, CacheService: deps.Cache, } admin.SetupRoutes(e, deps.DB, cfg, adminDeps) // API group api := e.Group("/api") { // Session lifecycle (login, logout, password reset, email verification) // is handled directly by Ory Kratos from the client. Registration is the // exception: it goes through this endpoint, which admin-creates the // Kratos identity so no verification email is auto-sent to an // unreachable flow (see handlers.AuthHandler.Register). Public — the // caller has no session yet. api.POST("/auth/register/", authHandler.Register) // Public data routes (no auth required) setupPublicDataRoutes(api, residenceHandler, taskHandler, contractorHandler, staticDataHandler, subscriptionHandler, taskTemplateHandler) // Subscription webhook routes (no auth - called by Apple/Google servers) setupWebhookRoutes(api, subscriptionWebhookHandler) // Authenticated routes (valid session required). This level is the // sign-up / shell allow-list: an authenticated-but-UNVERIFIED user may // call these (read their own user + verification status, complete their // profile during sign-up). EVERYTHING else requires a verified email // (see the `verified` sub-group below). protected := api.Group("") protected.Use(authMiddleware.Authenticate()) protected.Use(custommiddleware.TimezoneMiddleware()) { // Allow-list — authenticated, may be unverified. setupProtectedAuthRoutes(protected, authHandler) // Verified routes (authenticated AND email-verified) — the DEFAULT // for all app data and actions. RequireVerified is applied ONCE at // the group level so verification is the default and every route // added under here is gated automatically. (The previous per-route // approach left ~70 routes unverified — see the LIVE auth audit.) verified := protected.Group("") verified.Use(authMiddleware.RequireVerified()) { setupResidenceRoutes(verified, residenceHandler) setupTaskRoutes(verified, taskHandler) setupSuggestionRoutes(verified, suggestionHandler) setupContractorRoutes(verified, contractorHandler) setupDocumentRoutes(verified, documentHandler) setupNotificationRoutes(verified, notificationHandler) setupSubscriptionRoutes(verified, subscriptionHandler) setupUserRoutes(verified, userHandler) // Upload routes (only if storage service is configured) if uploadHandler != nil { setupUploadRoutes(verified, uploadHandler) } // Media routes (verified media serving) if mediaHandler != nil { setupMediaRoutes(verified, mediaHandler) } } } } return e } // corsMiddleware configures CORS with restricted origins in production. // In debug mode, explicit localhost origins are allowed for development. // In production, origins are read from the CORS_ALLOWED_ORIGINS environment variable // (comma-separated), falling back to a restrictive default set. func corsMiddleware(cfg *config.Config) echo.MiddlewareFunc { var origins []string if cfg.Server.Debug { origins = []string{ "http://localhost:3000", "http://localhost:3001", "http://localhost:8080", "http://localhost:8000", "http://127.0.0.1:3000", "http://127.0.0.1:3001", "http://127.0.0.1:8080", "http://127.0.0.1:8000", } } else { origins = cfg.Server.CorsAllowedOrigins if len(origins) == 0 { // Restrictive default: only the known production domains origins = []string{ "https://api.myhoneydue.com", "https://myhoneydue.com", "https://admin.myhoneydue.com", } } } return middleware.CORSWithConfig(middleware.CORSConfig{ AllowOrigins: origins, 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, "X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset", "Retry-After"}, AllowCredentials: false, MaxAge: int((12 * time.Hour).Seconds()), }) } // liveCheck returns a simple 200 for Kubernetes liveness probes func liveCheck(c echo.Context) error { return c.JSON(http.StatusOK, map[string]interface{}{ "status": "alive", "version": Version, "timestamp": time.Now().UTC().Format(time.RFC3339), }) } // readinessCheck returns 200 if PostgreSQL and Redis are reachable, 503 otherwise. // This is used by Kubernetes readiness probes and load balancers. func readinessCheck(deps *Dependencies) echo.HandlerFunc { return func(c echo.Context) error { ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second) defer cancel() status := "healthy" httpStatus := http.StatusOK checks := make(map[string]string) // Check PostgreSQL sqlDB, err := deps.DB.DB() if err != nil { checks["postgres"] = fmt.Sprintf("failed to get sql.DB: %v", err) status = "unhealthy" httpStatus = http.StatusServiceUnavailable } else if err := sqlDB.PingContext(ctx); err != nil { checks["postgres"] = fmt.Sprintf("ping failed: %v", err) status = "unhealthy" httpStatus = http.StatusServiceUnavailable } else { checks["postgres"] = "ok" } // Check Redis (if cache service is available) if deps.Cache != nil { if err := deps.Cache.Client().Ping(ctx).Err(); err != nil { checks["redis"] = fmt.Sprintf("ping failed: %v", err) status = "unhealthy" httpStatus = http.StatusServiceUnavailable } else { checks["redis"] = "ok" } } else { checks["redis"] = "not configured" } return c.JSON(httpStatus, map[string]interface{}{ "status": status, "version": Version, "checks": checks, "timestamp": time.Now().UTC().Format(time.RFC3339), }) } } // prometheusMetrics returns an Echo handler that outputs metrics in Prometheus text format. // It uses the existing monitoring service's HTTP stats collector to avoid adding external dependencies. func prometheusMetrics(monSvc *monitoring.Service) echo.HandlerFunc { return func(c echo.Context) error { httpCollector := monSvc.HTTPCollector() if httpCollector == nil { return c.String(http.StatusOK, "# No HTTP metrics available (collector not initialized)\n") } stats := httpCollector.GetStats() var b strings.Builder // Request count by method+path+status b.WriteString("# HELP http_requests_total Total number of HTTP requests.\n") b.WriteString("# TYPE http_requests_total counter\n") for statusCode, count := range stats.ByStatusCode { fmt.Fprintf(&b, "http_requests_total{status_code=\"%d\"} %d\n", statusCode, count) } // Per-endpoint request count b.WriteString("# HELP http_endpoint_requests_total Total requests per endpoint.\n") b.WriteString("# TYPE http_endpoint_requests_total counter\n") for endpoint, epStats := range stats.ByEndpoint { // endpoint is "METHOD /path" parts := strings.SplitN(endpoint, " ", 2) method := endpoint path := "" if len(parts) == 2 { method = parts[0] path = parts[1] } fmt.Fprintf(&b, "http_endpoint_requests_total{method=\"%s\",path=\"%s\"} %d\n", method, path, epStats.Count) } // Request duration (avg latency as a gauge, since we don't have raw histogram buckets) b.WriteString("# HELP http_request_duration_ms Average request duration in milliseconds per endpoint.\n") b.WriteString("# TYPE http_request_duration_ms gauge\n") for endpoint, epStats := range stats.ByEndpoint { parts := strings.SplitN(endpoint, " ", 2) method := endpoint path := "" if len(parts) == 2 { method = parts[0] path = parts[1] } fmt.Fprintf(&b, "http_request_duration_ms{method=\"%s\",path=\"%s\",quantile=\"avg\"} %.2f\n", method, path, epStats.AvgLatencyMs) fmt.Fprintf(&b, "http_request_duration_ms{method=\"%s\",path=\"%s\",quantile=\"p95\"} %.2f\n", method, path, epStats.P95LatencyMs) } // Error rate b.WriteString("# HELP http_error_rate Overall error rate (4xx+5xx / total).\n") b.WriteString("# TYPE http_error_rate gauge\n") fmt.Fprintf(&b, "http_error_rate %.4f\n", stats.ErrorRate) // Requests per minute b.WriteString("# HELP http_requests_per_minute Current request rate.\n") b.WriteString("# TYPE http_requests_per_minute gauge\n") fmt.Fprintf(&b, "http_requests_per_minute %.2f\n", stats.RequestsPerMinute) c.Response().Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") return c.String(http.StatusOK, b.String()) } } // setupPublicAuthRoutes was removed — session lifecycle (login, register, // logout, password reset, Apple/Google sign-in) is delegated to Ory Kratos. // setupProtectedAuthRoutes configures protected auth routes. // Session lifecycle (login, logout, password reset, email verification) is // delegated to Ory Kratos — only profile and account-deletion routes remain. func setupProtectedAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler) { auth := api.Group("/auth") { auth.GET("/me/", authHandler.CurrentUser) auth.PUT("/profile/", authHandler.UpdateProfile) auth.PATCH("/profile/", authHandler.UpdateProfile) auth.DELETE("/account/", authHandler.DeleteAccount) } } // setupPublicDataRoutes configures public data routes (lookups, static data) 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") { staticData.GET("/", staticDataHandler.GetStaticData) staticData.POST("/refresh/", staticDataHandler.RefreshStaticData) } // Public subscription routes (upgrade triggers needed at app start before login) subscriptionPublic := api.Group("/subscription") { subscriptionPublic.GET("/upgrade-triggers/", subscriptionHandler.GetAllUpgradeTriggers) } // Lookup routes (public) api.GET("/residences/types/", residenceHandler.GetResidenceTypes) api.GET("/tasks/categories/", taskHandler.GetCategories) api.GET("/tasks/priorities/", taskHandler.GetPriorities) api.GET("/tasks/frequencies/", taskHandler.GetFrequencies) api.GET("/contractors/specialties/", contractorHandler.GetSpecialties) // Task template routes (public, for app autocomplete) templates := api.Group("/tasks/templates") { templates.GET("/", taskTemplateHandler.GetTemplates) templates.GET("/grouped/", taskTemplateHandler.GetTemplatesGrouped) templates.GET("/search/", taskTemplateHandler.SearchTemplates) templates.GET("/by-category/:category_id/", taskTemplateHandler.GetTemplatesByCategory) // /by-region/ removed — climate zone now participates in the main // GET /api/tasks/suggestions/ scoring via the template JSON conditions. templates.GET("/:id/", taskTemplateHandler.GetTemplate) } } // setupResidenceRoutes configures residence routes func setupResidenceRoutes(api *echo.Group, residenceHandler *handlers.ResidenceHandler) { residences := api.Group("/residences") { residences.GET("/", residenceHandler.ListResidences) residences.POST("/", residenceHandler.CreateResidence) residences.GET("/my-residences/", residenceHandler.GetMyResidences) residences.GET("/summary/", residenceHandler.GetSummary) residences.POST("/join-with-code/", residenceHandler.JoinWithCode) residences.GET("/:id/", residenceHandler.GetResidence) residences.PUT("/:id/", residenceHandler.UpdateResidence) residences.PATCH("/:id/", residenceHandler.UpdateResidence) residences.DELETE("/:id/", residenceHandler.DeleteResidence) residences.GET("/:id/share-code/", residenceHandler.GetShareCode) // Verification is now enforced at the group level for ALL residence // routes (see the `verified` group in SetupRouter) — the previous // per-route RequireVerified on just these two is no longer needed. residences.POST("/:id/generate-share-code/", residenceHandler.GenerateShareCode) residences.POST("/:id/generate-share-package/", residenceHandler.GenerateSharePackage) residences.POST("/:id/generate-tasks-report/", residenceHandler.GenerateTasksReport) residences.GET("/:id/users/", residenceHandler.GetResidenceUsers) residences.DELETE("/:id/users/:user_id/", residenceHandler.RemoveResidenceUser) } } // setupTaskRoutes configures task routes func setupTaskRoutes(api *echo.Group, taskHandler *handlers.TaskHandler) { tasks := api.Group("/tasks") { tasks.GET("/", taskHandler.ListTasks) tasks.POST("/", taskHandler.CreateTask) tasks.POST("/bulk/", taskHandler.BulkCreateTasks) tasks.GET("/by-residence/:residence_id/", taskHandler.GetTasksByResidence) tasks.GET("/:id/", taskHandler.GetTask) tasks.PUT("/:id/", taskHandler.UpdateTask) tasks.PATCH("/:id/", taskHandler.UpdateTask) tasks.DELETE("/:id/", taskHandler.DeleteTask) tasks.POST("/:id/mark-in-progress/", taskHandler.MarkInProgress) tasks.POST("/:id/cancel/", taskHandler.CancelTask) tasks.POST("/:id/uncancel/", taskHandler.UncancelTask) tasks.POST("/:id/archive/", taskHandler.ArchiveTask) tasks.POST("/:id/unarchive/", taskHandler.UnarchiveTask) tasks.POST("/:id/quick-complete/", taskHandler.QuickComplete) tasks.GET("/:id/completions/", taskHandler.GetTaskCompletions) } // Task Completions completions := api.Group("/task-completions") { completions.GET("/", taskHandler.ListCompletions) completions.POST("/", taskHandler.CreateCompletion) completions.GET("/:id/", taskHandler.GetCompletion) completions.PUT("/:id/", taskHandler.UpdateCompletion) completions.DELETE("/:id/", taskHandler.DeleteCompletion) } } // setupSuggestionRoutes configures task suggestion routes func setupSuggestionRoutes(api *echo.Group, suggestionHandler *handlers.SuggestionHandler) { tasks := api.Group("/tasks") { tasks.GET("/suggestions/", suggestionHandler.GetSuggestions) } } // setupContractorRoutes configures contractor routes func setupContractorRoutes(api *echo.Group, contractorHandler *handlers.ContractorHandler) { contractors := api.Group("/contractors") { contractors.GET("/", contractorHandler.ListContractors) contractors.POST("/", contractorHandler.CreateContractor) contractors.GET("/by-residence/:residence_id/", contractorHandler.ListContractorsByResidence) contractors.GET("/:id/", contractorHandler.GetContractor) contractors.PUT("/:id/", contractorHandler.UpdateContractor) contractors.PATCH("/:id/", contractorHandler.UpdateContractor) contractors.DELETE("/:id/", contractorHandler.DeleteContractor) contractors.POST("/:id/toggle-favorite/", contractorHandler.ToggleFavorite) contractors.GET("/:id/tasks/", contractorHandler.GetContractorTasks) } } // setupDocumentRoutes configures document routes func setupDocumentRoutes(api *echo.Group, documentHandler *handlers.DocumentHandler) { documents := api.Group("/documents") { documents.GET("/", documentHandler.ListDocuments) documents.POST("/", documentHandler.CreateDocument) documents.GET("/warranties/", documentHandler.ListWarranties) documents.GET("/:id/", documentHandler.GetDocument) documents.PUT("/:id/", documentHandler.UpdateDocument) documents.PATCH("/:id/", documentHandler.UpdateDocument) documents.DELETE("/:id/", documentHandler.DeleteDocument) documents.POST("/:id/activate/", documentHandler.ActivateDocument) documents.POST("/:id/deactivate/", documentHandler.DeactivateDocument) documents.POST("/:id/images/", documentHandler.UploadDocumentImage) documents.DELETE("/:id/images/:imageId/", documentHandler.DeleteDocumentImage) } } // setupNotificationRoutes configures notification routes func setupNotificationRoutes(api *echo.Group, notificationHandler *handlers.NotificationHandler) { notifications := api.Group("/notifications") { notifications.GET("/", notificationHandler.ListNotifications) notifications.GET("/unread-count/", notificationHandler.GetUnreadCount) notifications.POST("/mark-all-read/", notificationHandler.MarkAllAsRead) notifications.POST("/:id/read/", notificationHandler.MarkAsRead) notifications.POST("/devices/", notificationHandler.RegisterDevice) notifications.POST("/devices/register/", notificationHandler.RegisterDevice) // Alias for mobile clients notifications.POST("/devices/unregister/", notificationHandler.UnregisterDevice) notifications.GET("/devices/", notificationHandler.ListDevices) notifications.DELETE("/devices/:id/", notificationHandler.DeleteDevice) notifications.GET("/preferences/", notificationHandler.GetPreferences) notifications.PUT("/preferences/", notificationHandler.UpdatePreferences) notifications.PATCH("/preferences/", notificationHandler.UpdatePreferences) } } // setupSubscriptionRoutes configures subscription routes (authenticated) // Note: /upgrade-triggers/ is in setupPublicDataRoutes (public, no auth required) func setupSubscriptionRoutes(api *echo.Group, subscriptionHandler *handlers.SubscriptionHandler) { subscription := api.Group("/subscription") { subscription.GET("/", subscriptionHandler.GetSubscription) subscription.GET("/status/", subscriptionHandler.GetSubscriptionStatus) subscription.GET("/upgrade-trigger/:key/", subscriptionHandler.GetUpgradeTrigger) subscription.GET("/features/", subscriptionHandler.GetFeatureBenefits) subscription.GET("/promotions/", subscriptionHandler.GetPromotions) subscription.POST("/purchase/", subscriptionHandler.ProcessPurchase) subscription.POST("/cancel/", subscriptionHandler.CancelSubscription) subscription.POST("/restore/", subscriptionHandler.RestoreSubscription) subscription.POST("/checkout/", subscriptionHandler.CreateCheckoutSession) subscription.POST("/portal/", subscriptionHandler.CreatePortalSession) } } // setupUserRoutes configures user routes func setupUserRoutes(api *echo.Group, userHandler *handlers.UserHandler) { users := api.Group("/users") { users.GET("/", userHandler.ListUsers) users.GET("/:id/", userHandler.GetUser) users.GET("/profiles/", userHandler.ListProfiles) } } // setupUploadRoutes configures file upload routes func setupUploadRoutes(api *echo.Group, uploadHandler *handlers.UploadHandler) { uploads := api.Group("/uploads") { uploads.POST("/presign/", uploadHandler.PresignUpload) uploads.DELETE("/", uploadHandler.DeleteFile) } } // setupMediaRoutes configures authenticated media serving routes func setupMediaRoutes(api *echo.Group, mediaHandler *handlers.MediaHandler) { media := api.Group("/media") { media.GET("/document/:id", mediaHandler.ServeDocument) media.GET("/document-image/:id", mediaHandler.ServeDocumentImage) media.GET("/completion-image/:id", mediaHandler.ServeCompletionImage) } } // 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 *echo.Group, webhookHandler *handlers.SubscriptionWebhookHandler) { webhooks := api.Group("/subscription/webhook") { webhooks.POST("/apple/", webhookHandler.HandleAppleWebhook) webhooks.POST("/google/", webhookHandler.HandleGoogleWebhook) webhooks.POST("/stripe/", webhookHandler.HandleStripeWebhook) } } // 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"), }) }