b67f7f9e6b
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>
885 lines
36 KiB
Go
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"),
|
|
})
|
|
}
|