Files
honeyDueAPI/internal/router/router.go
T
Trey t b67f7f9e6b
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Cache SubscriptionSettings + cut monitoring poll noise
Trace data revealed subscription_subscriptionsettings was consuming
1,983s of cumulative DB time per day (180× more than the next-largest
table) for a 32-byte singleton row of admin-toggleable global flags.
Root cause was a 30-second poll loop in monitoring.Service per pod
plus uncached reads on every authed status check / CreateResidence /
Stripe webhook. Fix is layered:

1. Redis cache for SubscriptionSettings — same shape as the
   residence-IDs cache. 30-min TTL, explicit invalidation on admin
   write. New CacheService.{Cache,GetCached,Invalidate}SubscriptionSettings
   plus a cachedSubscriptionSettings helper in services/.

2. SubscriptionService, StripeService, and both admin handlers
   (settings + limitations) now read through the cache. Admin write
   handlers invalidate so toggles propagate cluster-wide within ms
   instead of waiting for the TTL.

3. monitoring.Service.syncSettingsFromDB also reads from Redis first
   (raw redis.Client to avoid a services→monitoring import cycle).
   Polling interval bumped 30s → 5min. Combined with Redis-shared
   cache, cluster-wide DB hits from this poll go from ~480/hour to
   ~2/hour — a 240× reduction.

4. StripeService.CreateCheckoutSession now takes ctx so the cached
   settings span (and the Stripe webhook trace) stay attached to the
   request. Handler call site updated.

5. Admin handlers' direct h.db.First calls switched to
   db.WithContext(ctx) so the resulting orphan SQL spans nest under
   the admin request span in Jaeger.

Net DB query rate for subscription_subscriptionsettings should drop
from 0.101/sec to ~0/sec with occasional invalidation-driven refills,
and the table's cumulative DB time from 1,983s/day to ~10s/day.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:29:30 -05:00

885 lines
36 KiB
Go

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"
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/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
}
// 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: "1; mode=block",
ContentTypeNosniff: "nosniff",
XFrameOptions: "SAMEORIGIN",
HSTSMaxAge: 31536000,
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'; " +
"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 exposed for vmagent scrape. No auth — bound to
// the cluster network only; not exposed via Cloudflare.
e.GET("/metrics", prom.Handler())
// 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) // For creating notification preferences on registration
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
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 {
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)
// Initialize middleware
authMiddleware := custommiddleware.NewAuthMiddlewareWithConfig(deps.DB, deps.Cache, cfg)
// Initialize Apple auth service
appleAuthService := services.NewAppleAuthService(deps.Cache, cfg)
googleAuthService := services.NewGoogleAuthService(deps.Cache, cfg)
// Initialize audit service for security event logging
auditService := services.NewAuditService(deps.DB)
// Initialize handlers
authHandler := handlers.NewAuthHandler(authService, deps.EmailService, deps.Cache)
authHandler.SetAppleAuthService(appleAuthService)
authHandler.SetGoogleAuthService(googleAuthService)
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)
}
// 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")
{
// Public auth routes (no auth required)
setupPublicAuthRoutes(api, authHandler, cfg.Server.Debug)
// 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)
// Protected routes (auth required)
protected := api.Group("")
protected.Use(authMiddleware.TokenAuth())
protected.Use(custommiddleware.TimezoneMiddleware())
{
setupProtectedAuthRoutes(protected, authHandler)
setupResidenceRoutes(protected, residenceHandler)
setupTaskRoutes(protected, taskHandler)
setupSuggestionRoutes(protected, suggestionHandler)
setupContractorRoutes(protected, contractorHandler)
setupDocumentRoutes(protected, documentHandler)
setupNotificationRoutes(protected, notificationHandler)
setupSubscriptionRoutes(protected, subscriptionHandler)
setupUserRoutes(protected, userHandler)
// Upload routes (only if storage service is configured)
if uploadHandler != nil {
setupUploadRoutes(protected, uploadHandler)
}
// Media routes (authenticated media serving)
if mediaHandler != nil {
setupMediaRoutes(protected, 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 configures public authentication routes with
// per-endpoint rate limiters to mitigate brute-force and credential-stuffing.
// Rate limiters are disabled in debug mode to allow UI test suites to run
// without hitting 429 errors.
func setupPublicAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler, debug bool) {
auth := api.Group("/auth")
if debug {
// No rate limiters in debug/local mode
auth.POST("/login/", authHandler.Login)
auth.POST("/register/", authHandler.Register)
auth.POST("/forgot-password/", authHandler.ForgotPassword)
auth.POST("/verify-reset-code/", authHandler.VerifyResetCode)
auth.POST("/reset-password/", authHandler.ResetPassword)
auth.POST("/apple-sign-in/", authHandler.AppleSignIn)
auth.POST("/google-sign-in/", authHandler.GoogleSignIn)
} else {
// Rate limiters — created once, shared across requests.
loginRL := custommiddleware.LoginRateLimiter() // 10 req/min
registerRL := custommiddleware.RegistrationRateLimiter() // 5 req/min
passwordRL := custommiddleware.PasswordResetRateLimiter() // 3 req/min
auth.POST("/login/", authHandler.Login, loginRL)
auth.POST("/register/", authHandler.Register, registerRL)
auth.POST("/forgot-password/", authHandler.ForgotPassword, passwordRL)
auth.POST("/verify-reset-code/", authHandler.VerifyResetCode, passwordRL)
auth.POST("/reset-password/", authHandler.ResetPassword, passwordRL)
auth.POST("/apple-sign-in/", authHandler.AppleSignIn, loginRL)
auth.POST("/google-sign-in/", authHandler.GoogleSignIn, loginRL)
}
}
// setupProtectedAuthRoutes configures protected authentication routes
func setupProtectedAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler) {
auth := api.Group("/auth")
{
auth.POST("/logout/", authHandler.Logout)
auth.POST("/refresh/", authHandler.RefreshToken)
auth.GET("/me/", authHandler.CurrentUser)
auth.PUT("/profile/", authHandler.UpdateProfile)
auth.PATCH("/profile/", authHandler.UpdateProfile)
auth.POST("/verify/", authHandler.VerifyEmail) // Alias for mobile app compatibility
auth.POST("/verify-email/", authHandler.VerifyEmail) // Original route
auth.POST("/resend-verification/", authHandler.ResendVerification)
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)
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("/image/", uploadHandler.UploadImage)
uploads.POST("/document/", uploadHandler.UploadDocument)
uploads.POST("/completion/", uploadHandler.UploadCompletion)
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"),
})
}