Files
honeyDueAPI/internal/router/router.go
Trey T cb7080c460 Smart onboarding: residence home profile + suggestion engine
14 new optional residence fields (heating, cooling, water heater, roof,
pool, sprinkler, septic, fireplace, garage, basement, attic, exterior,
flooring, landscaping) with JSONB conditions on templates.

Suggestion engine scores templates against home profile: string match
+0.25, bool +0.3, property type +0.15, universal base 0.3. Graceful
degradation from minimal to full profile info.

GET /api/tasks/suggestions/?residence_id=X returns ranked templates.
54 template conditions across 44 templates in seed data.
8 suggestion service tests.
2026-03-30 09:02:03 -05:00

837 lines
34 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"
"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/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())
// Security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, etc.)
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
XSSProtection: "1; mode=block",
ContentTypeNosniff: "nosniff",
XFrameOptions: "SAMEORIGIN",
HSTSMaxAge: 31536000, // 1 year in seconds
ReferrerPolicy: "strict-origin-when-cross-origin",
ContentSecurityPolicy: "default-src 'none'; 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)
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
Level: 5,
Skipper: func(c echo.Context) bool {
return strings.HasPrefix(c.Request().URL.Path, "/api/media/")
},
}))
// Monitoring metrics middleware (if monitoring is enabled)
if deps.MonitoringService != nil {
if metricsMiddleware := deps.MonitoringService.MetricsMiddleware(); metricsMiddleware != nil {
e.Use(metricsMiddleware)
}
}
// 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
taskTemplateService := services.NewTaskTemplateService(taskTemplateRepo)
suggestionService := services.NewSuggestionService(deps.DB, residenceRepo)
// Initialize Stripe service
stripeService := services.NewStripeService(subscriptionRepo, userRepo)
// 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)
}
// Prometheus metrics endpoint (no auth required, for scraping)
if deps.MonitoringService != nil {
e.GET("/metrics", 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,
}
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)
templates.GET("/by-region/", taskTemplateHandler.GetTemplatesByRegion)
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.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"),
})
}