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.
837 lines
34 KiB
Go
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"),
|
|
})
|
|
}
|