81e454d86d
Registration now goes through POST /api/auth/register, which admin-creates the Kratos identity (unverified email, NO auto-sent code). Kratos self-service registration never returns the verification flow id, so the client could never submit the user's code to the right flow; admin creation lets the client own a single verification flow instead. Also surface the live Kratos verified flag and fix Apple audience + team IDs. - kratos.Client.CreateIdentity via admin API; ErrIdentityExists / ErrInvalidCredentials - AuthService.Register + AuthHandler.Register + public POST /api/auth/register/ - CurrentUser overrides stale user_profile.verified with the live Kratos flag; UserRepository.MarkVerified mirrors it back - configmap: additional_id_token_audiences allows the .dev bundle id_token - fix Apple/APNs team id V3PF3M6B6U -> X86BR9WTLD in .env.example + dev init Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
901 lines
37 KiB
Go
901 lines
37 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"
|
|
"github.com/treytartt/honeydue-api/internal/kratos"
|
|
custommiddleware "github.com/treytartt/honeydue-api/internal/middleware"
|
|
"github.com/treytartt/honeydue-api/internal/monitoring"
|
|
"github.com/treytartt/honeydue-api/internal/prom"
|
|
"github.com/treytartt/honeydue-api/internal/push"
|
|
"github.com/treytartt/honeydue-api/internal/repositories"
|
|
"github.com/treytartt/honeydue-api/internal/services"
|
|
customvalidator "github.com/treytartt/honeydue-api/internal/validator"
|
|
"github.com/treytartt/honeydue-api/internal/worker"
|
|
"github.com/treytartt/honeydue-api/pkg/utils"
|
|
)
|
|
|
|
const Version = "2.0.0"
|
|
|
|
// Dependencies holds all dependencies needed by the router
|
|
type Dependencies struct {
|
|
DB *gorm.DB
|
|
Cache *services.CacheService
|
|
Config *config.Config
|
|
EmailService *services.EmailService
|
|
PDFService *services.PDFService
|
|
PushClient *push.Client // Direct APNs/FCM client
|
|
StorageService *services.StorageService
|
|
MonitoringService *monitoring.Service
|
|
// TaskEnqueuer is the Asynq client used to push background work onto the
|
|
// shared Redis queue. Optional — when nil, services that would enqueue
|
|
// (currently: task-completion notification fan-out) fall back to their
|
|
// inline implementation. Tests can omit it; production must wire it.
|
|
TaskEnqueuer worker.Enqueuer
|
|
}
|
|
|
|
// SetupRouter creates and configures the Echo router
|
|
func SetupRouter(deps *Dependencies) *echo.Echo {
|
|
cfg := deps.Config
|
|
|
|
e := echo.New()
|
|
e.HideBanner = true
|
|
e.Validator = customvalidator.NewCustomValidator()
|
|
e.HTTPErrorHandler = customHTTPErrorHandler
|
|
|
|
// NOTE: Removed AddTrailingSlash() middleware - it conflicted with admin routes
|
|
// which don't use trailing slashes. Mobile API routes explicitly include trailing slashes.
|
|
|
|
// Global middleware
|
|
e.Use(custommiddleware.RequestIDMiddleware())
|
|
e.Use(utils.EchoRecovery())
|
|
e.Use(custommiddleware.StructuredLogger())
|
|
|
|
// OpenTelemetry HTTP middleware — opens a span per request, attaches the
|
|
// route pattern, method, status, and request_id. Sits early so subsequent
|
|
// middleware + handlers run inside the request span.
|
|
e.Use(otelecho.Middleware("honeydue-api"))
|
|
|
|
// Security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, etc.)
|
|
//
|
|
// CSP is permissive enough to serve the marketing landing page at / (which
|
|
// loads same-origin CSS/JS/images and Google Fonts over https). JSON API
|
|
// responses are unaffected — they don't load any assets, so any CSP is fine.
|
|
// frame-ancestors stays 'none' to block clickjacking.
|
|
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
|
|
// XSSProtection deliberately empty (audit L7): the X-XSS-Protection
|
|
// header is deprecated and has itself caused XSS in legacy browsers.
|
|
XSSProtection: "",
|
|
ContentTypeNosniff: "nosniff",
|
|
XFrameOptions: "SAMEORIGIN",
|
|
HSTSMaxAge: 63072000, // 2 years — preload-eligible (audit L5/CODE-L3)
|
|
HSTSPreloadEnabled: true,
|
|
ReferrerPolicy: "strict-origin-when-cross-origin",
|
|
ContentSecurityPolicy: "default-src 'self'; " +
|
|
"style-src 'self' https://fonts.googleapis.com; " +
|
|
"font-src 'self' https://fonts.gstatic.com data:; " +
|
|
"img-src 'self' data:; " +
|
|
"script-src 'self'; " +
|
|
"connect-src 'self'; " +
|
|
"object-src 'none'; " + // audit L8 — disable plugins/embeds
|
|
"base-uri 'self'; " + // audit L8 — block <base> hijacking
|
|
"frame-ancestors 'none'",
|
|
}))
|
|
e.Use(middleware.BodyLimitWithConfig(middleware.BodyLimitConfig{
|
|
Limit: "1M", // 1MB default for JSON payloads
|
|
Skipper: func(c echo.Context) bool {
|
|
// Allow larger payloads for webhook endpoints (Apple/Google/Stripe notifications)
|
|
return strings.HasPrefix(c.Request().URL.Path, "/api/subscription/webhook")
|
|
},
|
|
}))
|
|
e.Use(middleware.TimeoutWithConfig(middleware.TimeoutConfig{
|
|
Timeout: 30 * time.Second,
|
|
Skipper: func(c echo.Context) bool {
|
|
path := c.Request().URL.Path
|
|
// Skip timeout for reverse proxy and WebSocket routes — the
|
|
// timeout middleware wraps the response writer in *http.timeoutWriter
|
|
// which does not implement http.Flusher, causing a panic when
|
|
// httputil.ReverseProxy or WebSocket upgraders try to flush.
|
|
// Also skip for admin subdomain (all requests proxied to Next.js).
|
|
adminHost := os.Getenv("ADMIN_HOST")
|
|
return (adminHost != "" && c.Request().Host == adminHost) ||
|
|
strings.HasPrefix(path, "/_next") ||
|
|
strings.HasSuffix(path, "/ws")
|
|
},
|
|
}))
|
|
e.Use(corsMiddleware(cfg))
|
|
e.Use(i18n.Middleware())
|
|
|
|
// Gzip compression (skip media endpoints since they serve binary files;
|
|
// also skip /metrics because promhttp does its own content negotiation,
|
|
// so wrapping it here produces double-gzipped output that breaks scrapers).
|
|
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
|
|
Level: 5,
|
|
Skipper: func(c echo.Context) bool {
|
|
path := c.Request().URL.Path
|
|
return strings.HasPrefix(path, "/api/media/") || path == "/metrics"
|
|
},
|
|
}))
|
|
|
|
// Monitoring metrics middleware (if monitoring is enabled)
|
|
if deps.MonitoringService != nil {
|
|
if metricsMiddleware := deps.MonitoringService.MetricsMiddleware(); metricsMiddleware != nil {
|
|
e.Use(metricsMiddleware)
|
|
}
|
|
}
|
|
|
|
// Prometheus metrics middleware — feeds VictoriaMetrics on
|
|
// obs.88oakapps.com via vmagent. Records http_request_duration_seconds
|
|
// labeled by route pattern, method, and status code.
|
|
e.Use(prom.HTTPMiddleware())
|
|
|
|
// /metrics endpoint for the in-cluster vmagent scrape (audit LIVE-L1).
|
|
// vmagent scrapes api pods directly (pod-to-pod), so its requests carry
|
|
// no X-Forwarded-For. Any request that DOES carry one reached us through
|
|
// Traefik/Cloudflare — i.e. the public internet — and is refused with a
|
|
// 404. The api pod port is not exposed outside the cluster, so a request
|
|
// cannot reach /metrics without going through Traefik, and Traefik always
|
|
// appends X-Forwarded-For — the check cannot be bypassed.
|
|
metricsHandler := prom.Handler()
|
|
e.GET("/metrics", func(c echo.Context) error {
|
|
if c.Request().Header.Get("X-Forwarded-For") != "" {
|
|
return echo.NewHTTPError(http.StatusNotFound)
|
|
}
|
|
return metricsHandler(c)
|
|
})
|
|
|
|
// Serve landing page static files (if static directory is configured)
|
|
staticDir := cfg.Server.StaticDir
|
|
if staticDir != "" {
|
|
e.Static("/css", staticDir+"/css")
|
|
e.Static("/js", staticDir+"/js")
|
|
e.Static("/images", staticDir+"/images")
|
|
e.File("/favicon.ico", staticDir+"/images/favicon.svg")
|
|
|
|
// Serve index.html at root
|
|
e.GET("/", func(c echo.Context) error {
|
|
return c.File(staticDir + "/index.html")
|
|
})
|
|
}
|
|
|
|
// Health check endpoints (no auth required)
|
|
e.GET("/api/health/", readinessCheck(deps))
|
|
e.GET("/api/health/live", liveCheck)
|
|
|
|
// Initialize onboarding email service for tracking handler
|
|
onboardingService := services.NewOnboardingEmailService(deps.DB, deps.EmailService, cfg.Server.BaseURL)
|
|
|
|
// Email tracking endpoint (no auth required - used by email tracking pixels)
|
|
trackingHandler := handlers.NewTrackingHandler(onboardingService)
|
|
e.GET("/api/track/open/:trackingID", trackingHandler.TrackEmailOpen)
|
|
|
|
// NOTE: Public static file serving removed for security.
|
|
// All uploaded media is now served through authenticated proxy endpoints:
|
|
// - GET /api/media/document/:id
|
|
// - GET /api/media/document-image/:id
|
|
// - GET /api/media/completion-image/:id
|
|
// These endpoints verify the user has access to the residence before serving files.
|
|
|
|
// Initialize repositories
|
|
userRepo := repositories.NewUserRepository(deps.DB)
|
|
residenceRepo := repositories.NewResidenceRepository(deps.DB)
|
|
taskRepo := repositories.NewTaskRepository(deps.DB)
|
|
contractorRepo := repositories.NewContractorRepository(deps.DB)
|
|
documentRepo := repositories.NewDocumentRepository(deps.DB)
|
|
notificationRepo := repositories.NewNotificationRepository(deps.DB)
|
|
subscriptionRepo := repositories.NewSubscriptionRepository(deps.DB)
|
|
taskTemplateRepo := repositories.NewTaskTemplateRepository(deps.DB)
|
|
|
|
// Initialize services
|
|
authService := services.NewAuthService(userRepo, cfg)
|
|
authService.SetNotificationRepository(notificationRepo)
|
|
userService := services.NewUserService(userRepo)
|
|
residenceService := services.NewResidenceService(residenceRepo, userRepo, cfg)
|
|
residenceService.SetTaskRepository(taskRepo) // Wire up task repo for statistics
|
|
taskService := services.NewTaskService(taskRepo, residenceRepo)
|
|
contractorService := services.NewContractorService(contractorRepo, residenceRepo)
|
|
documentService := services.NewDocumentService(documentRepo, residenceRepo)
|
|
notificationService := services.NewNotificationService(notificationRepo, deps.PushClient)
|
|
|
|
// Wire up notification, email, residence, and storage services to task service
|
|
taskService.SetNotificationService(notificationService)
|
|
taskService.SetEmailService(deps.EmailService)
|
|
taskService.SetResidenceService(residenceService) // For including TotalSummary in CRUD responses
|
|
taskService.SetStorageService(deps.StorageService) // For reading completion images for email
|
|
if deps.TaskEnqueuer != nil {
|
|
// Offload completion notifications (push + email + B2 image fetches)
|
|
// to the Asynq worker so POST /api/task-completions/ doesn't pay for
|
|
// them in the response path. When the enqueuer is absent (tests),
|
|
// task_service falls back to the inline implementation.
|
|
taskService.SetTaskCompletedNotificationEnqueuer(deps.TaskEnqueuer)
|
|
}
|
|
subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo)
|
|
residenceService.SetSubscriptionService(subscriptionService) // Wire up subscription service for tier limit enforcement
|
|
|
|
// Wire Redis cache for residence-ID lookups across the four services that
|
|
// read it on the request hot path. Cache is best-effort; nil cache is OK.
|
|
if deps.Cache != nil {
|
|
authService.SetCacheService(deps.Cache)
|
|
residenceService.SetCacheService(deps.Cache)
|
|
taskService.SetCacheService(deps.Cache)
|
|
contractorService.SetCacheService(deps.Cache)
|
|
documentService.SetCacheService(deps.Cache)
|
|
subscriptionService.SetCacheService(deps.Cache)
|
|
}
|
|
taskTemplateService := services.NewTaskTemplateService(taskTemplateRepo)
|
|
suggestionService := services.NewSuggestionService(deps.DB, residenceRepo)
|
|
|
|
// Initialize Stripe service
|
|
stripeService := services.NewStripeService(subscriptionRepo, userRepo)
|
|
if deps.Cache != nil {
|
|
stripeService.SetCacheService(deps.Cache)
|
|
}
|
|
|
|
// Initialize webhook event repo for deduplication
|
|
webhookEventRepo := repositories.NewWebhookEventRepository(deps.DB)
|
|
|
|
// Initialize webhook handler for Apple/Google/Stripe subscription notifications
|
|
subscriptionWebhookHandler := handlers.NewSubscriptionWebhookHandler(subscriptionRepo, userRepo, webhookEventRepo, cfg.Features.WebhooksEnabled)
|
|
subscriptionWebhookHandler.SetStripeService(stripeService)
|
|
subscriptionWebhookHandler.SetCacheService(deps.Cache)
|
|
|
|
// Initialize Kratos auth middleware (replaces hand-rolled token auth).
|
|
kratosClient := kratos.NewClient(cfg.Security.KratosPublicURL, cfg.Security.KratosAdminURL)
|
|
authMiddleware := custommiddleware.NewKratosAuth(kratosClient, deps.Cache, deps.DB)
|
|
authService.SetKratosClient(kratosClient) // account deletion removes the Kratos identity
|
|
|
|
// Initialize audit service for security event logging
|
|
auditService := services.NewAuditService(deps.DB)
|
|
|
|
// Initialize handlers
|
|
authHandler := handlers.NewAuthHandler(authService, deps.EmailService, deps.Cache)
|
|
authHandler.SetStorageService(deps.StorageService)
|
|
authHandler.SetAuditService(auditService)
|
|
userHandler := handlers.NewUserHandler(userService)
|
|
residenceHandler := handlers.NewResidenceHandler(residenceService, deps.PDFService, deps.EmailService, cfg.Features.PDFReportsEnabled)
|
|
taskHandler := handlers.NewTaskHandler(taskService, deps.StorageService)
|
|
contractorHandler := handlers.NewContractorHandler(contractorService)
|
|
documentHandler := handlers.NewDocumentHandler(documentService, deps.StorageService)
|
|
notificationHandler := handlers.NewNotificationHandler(notificationService)
|
|
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService, stripeService)
|
|
staticDataHandler := handlers.NewStaticDataHandler(residenceService, taskService, contractorService, taskTemplateService, deps.Cache)
|
|
taskTemplateHandler := handlers.NewTaskTemplateHandler(taskTemplateService)
|
|
suggestionHandler := handlers.NewSuggestionHandler(suggestionService)
|
|
|
|
// Initialize upload handler (if storage service is available)
|
|
var uploadHandler *handlers.UploadHandler
|
|
var mediaHandler *handlers.MediaHandler
|
|
if deps.StorageService != nil {
|
|
uploadHandler = handlers.NewUploadHandler(deps.StorageService, services.NewFileOwnershipService(deps.DB))
|
|
mediaHandler = handlers.NewMediaHandler(documentRepo, taskRepo, residenceRepo, deps.StorageService)
|
|
|
|
// Presigned-URL upload path requires S3-compatible backend. With local
|
|
// disk we silently skip; the route returns 500 if hit.
|
|
if s3 := deps.StorageService.S3Backend(); s3 != nil {
|
|
pendingUploadRepo := repositories.NewPendingUploadRepository(deps.DB)
|
|
uploadService := services.NewUploadService(pendingUploadRepo, s3, &cfg.Storage, deps.Cache)
|
|
uploadHandler.SetUploadService(uploadService)
|
|
// Task and document services need the upload service to claim
|
|
// pending_uploads rows when /api/task-completions/ or /api/documents/
|
|
// is called with `upload_ids: [..]` instead of multipart.
|
|
taskService.SetUploadService(uploadService)
|
|
documentService.SetStorageService(deps.StorageService)
|
|
documentService.SetUploadService(uploadService)
|
|
}
|
|
}
|
|
|
|
// Legacy Prometheus-shaped metrics from internal/monitoring (consumed by
|
|
// GoAdmin dashboard). Now lives at /metrics/legacy so the canonical /metrics
|
|
// route (registered above) emits proper Prometheus histograms with labels.
|
|
if deps.MonitoringService != nil {
|
|
e.GET("/metrics/legacy", prometheusMetrics(deps.MonitoringService))
|
|
}
|
|
|
|
// Set up admin routes with monitoring handler (if available)
|
|
var monitoringHandler *monitoring.Handler
|
|
if deps.MonitoringService != nil {
|
|
monitoringHandler = deps.MonitoringService.Handler()
|
|
}
|
|
|
|
adminDeps := &admin.Dependencies{
|
|
EmailService: deps.EmailService,
|
|
PushClient: deps.PushClient,
|
|
OnboardingService: onboardingService,
|
|
MonitoringHandler: monitoringHandler,
|
|
CacheService: deps.Cache,
|
|
}
|
|
admin.SetupRoutes(e, deps.DB, cfg, adminDeps)
|
|
|
|
// API group
|
|
api := e.Group("/api")
|
|
{
|
|
// Session lifecycle (login, logout, password reset, email verification)
|
|
// is handled directly by Ory Kratos from the client. Registration is the
|
|
// exception: it goes through this endpoint, which admin-creates the
|
|
// Kratos identity so no verification email is auto-sent to an
|
|
// unreachable flow (see handlers.AuthHandler.Register). Public — the
|
|
// caller has no session yet.
|
|
api.POST("/auth/register/", authHandler.Register)
|
|
|
|
// Public data routes (no auth required)
|
|
setupPublicDataRoutes(api, residenceHandler, taskHandler, contractorHandler, staticDataHandler, subscriptionHandler, taskTemplateHandler)
|
|
|
|
// Subscription webhook routes (no auth - called by Apple/Google servers)
|
|
setupWebhookRoutes(api, subscriptionWebhookHandler)
|
|
|
|
// Protected routes (auth required)
|
|
protected := api.Group("")
|
|
protected.Use(authMiddleware.Authenticate())
|
|
protected.Use(custommiddleware.TimezoneMiddleware())
|
|
{
|
|
setupProtectedAuthRoutes(protected, authHandler)
|
|
setupResidenceRoutes(protected, residenceHandler, authMiddleware.RequireVerified())
|
|
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 was removed — session lifecycle (login, register,
|
|
// logout, password reset, Apple/Google sign-in) is delegated to Ory Kratos.
|
|
|
|
// setupProtectedAuthRoutes configures protected auth routes.
|
|
// Session lifecycle (login, logout, password reset, email verification) is
|
|
// delegated to Ory Kratos — only profile and account-deletion routes remain.
|
|
func setupProtectedAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler) {
|
|
auth := api.Group("/auth")
|
|
{
|
|
auth.GET("/me/", authHandler.CurrentUser)
|
|
auth.PUT("/profile/", authHandler.UpdateProfile)
|
|
auth.PATCH("/profile/", authHandler.UpdateProfile)
|
|
auth.DELETE("/account/", authHandler.DeleteAccount)
|
|
}
|
|
}
|
|
|
|
// setupPublicDataRoutes configures public data routes (lookups, static data)
|
|
func setupPublicDataRoutes(api *echo.Group, residenceHandler *handlers.ResidenceHandler, taskHandler *handlers.TaskHandler, contractorHandler *handlers.ContractorHandler, staticDataHandler *handlers.StaticDataHandler, subscriptionHandler *handlers.SubscriptionHandler, taskTemplateHandler *handlers.TaskTemplateHandler) {
|
|
// Static data routes (public, cached)
|
|
staticData := api.Group("/static_data")
|
|
{
|
|
staticData.GET("/", staticDataHandler.GetStaticData)
|
|
staticData.POST("/refresh/", staticDataHandler.RefreshStaticData)
|
|
}
|
|
|
|
// Public subscription routes (upgrade triggers needed at app start before login)
|
|
subscriptionPublic := api.Group("/subscription")
|
|
{
|
|
subscriptionPublic.GET("/upgrade-triggers/", subscriptionHandler.GetAllUpgradeTriggers)
|
|
}
|
|
|
|
// Lookup routes (public)
|
|
api.GET("/residences/types/", residenceHandler.GetResidenceTypes)
|
|
api.GET("/tasks/categories/", taskHandler.GetCategories)
|
|
api.GET("/tasks/priorities/", taskHandler.GetPriorities)
|
|
api.GET("/tasks/frequencies/", taskHandler.GetFrequencies)
|
|
api.GET("/contractors/specialties/", contractorHandler.GetSpecialties)
|
|
|
|
// Task template routes (public, for app autocomplete)
|
|
templates := api.Group("/tasks/templates")
|
|
{
|
|
templates.GET("/", taskTemplateHandler.GetTemplates)
|
|
templates.GET("/grouped/", taskTemplateHandler.GetTemplatesGrouped)
|
|
templates.GET("/search/", taskTemplateHandler.SearchTemplates)
|
|
templates.GET("/by-category/:category_id/", taskTemplateHandler.GetTemplatesByCategory)
|
|
// /by-region/ removed — climate zone now participates in the main
|
|
// GET /api/tasks/suggestions/ scoring via the template JSON conditions.
|
|
templates.GET("/:id/", taskTemplateHandler.GetTemplate)
|
|
}
|
|
}
|
|
|
|
// setupResidenceRoutes configures residence routes
|
|
func setupResidenceRoutes(api *echo.Group, residenceHandler *handlers.ResidenceHandler, requireVerified echo.MiddlewareFunc) {
|
|
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)
|
|
// Audit LIVE-L19: generating a residence share code requires a
|
|
// verified email — it blocks bad-faith unverified signups from
|
|
// minting share codes.
|
|
residences.POST("/:id/generate-share-code/", residenceHandler.GenerateShareCode, requireVerified)
|
|
residences.POST("/:id/generate-share-package/", residenceHandler.GenerateSharePackage, requireVerified)
|
|
residences.POST("/:id/generate-tasks-report/", residenceHandler.GenerateTasksReport)
|
|
residences.GET("/:id/users/", residenceHandler.GetResidenceUsers)
|
|
residences.DELETE("/:id/users/:user_id/", residenceHandler.RemoveResidenceUser)
|
|
}
|
|
}
|
|
|
|
// setupTaskRoutes configures task routes
|
|
func setupTaskRoutes(api *echo.Group, taskHandler *handlers.TaskHandler) {
|
|
tasks := api.Group("/tasks")
|
|
{
|
|
tasks.GET("/", taskHandler.ListTasks)
|
|
tasks.POST("/", taskHandler.CreateTask)
|
|
tasks.POST("/bulk/", taskHandler.BulkCreateTasks)
|
|
tasks.GET("/by-residence/:residence_id/", taskHandler.GetTasksByResidence)
|
|
|
|
tasks.GET("/:id/", taskHandler.GetTask)
|
|
tasks.PUT("/:id/", taskHandler.UpdateTask)
|
|
tasks.PATCH("/:id/", taskHandler.UpdateTask)
|
|
tasks.DELETE("/:id/", taskHandler.DeleteTask)
|
|
|
|
tasks.POST("/:id/mark-in-progress/", taskHandler.MarkInProgress)
|
|
tasks.POST("/:id/cancel/", taskHandler.CancelTask)
|
|
tasks.POST("/:id/uncancel/", taskHandler.UncancelTask)
|
|
tasks.POST("/:id/archive/", taskHandler.ArchiveTask)
|
|
tasks.POST("/:id/unarchive/", taskHandler.UnarchiveTask)
|
|
tasks.POST("/:id/quick-complete/", taskHandler.QuickComplete)
|
|
tasks.GET("/:id/completions/", taskHandler.GetTaskCompletions)
|
|
}
|
|
|
|
// Task Completions
|
|
completions := api.Group("/task-completions")
|
|
{
|
|
completions.GET("/", taskHandler.ListCompletions)
|
|
completions.POST("/", taskHandler.CreateCompletion)
|
|
completions.GET("/:id/", taskHandler.GetCompletion)
|
|
completions.PUT("/:id/", taskHandler.UpdateCompletion)
|
|
completions.DELETE("/:id/", taskHandler.DeleteCompletion)
|
|
}
|
|
}
|
|
|
|
// setupSuggestionRoutes configures task suggestion routes
|
|
func setupSuggestionRoutes(api *echo.Group, suggestionHandler *handlers.SuggestionHandler) {
|
|
tasks := api.Group("/tasks")
|
|
{
|
|
tasks.GET("/suggestions/", suggestionHandler.GetSuggestions)
|
|
}
|
|
}
|
|
|
|
// setupContractorRoutes configures contractor routes
|
|
func setupContractorRoutes(api *echo.Group, contractorHandler *handlers.ContractorHandler) {
|
|
contractors := api.Group("/contractors")
|
|
{
|
|
contractors.GET("/", contractorHandler.ListContractors)
|
|
contractors.POST("/", contractorHandler.CreateContractor)
|
|
contractors.GET("/by-residence/:residence_id/", contractorHandler.ListContractorsByResidence)
|
|
contractors.GET("/:id/", contractorHandler.GetContractor)
|
|
contractors.PUT("/:id/", contractorHandler.UpdateContractor)
|
|
contractors.PATCH("/:id/", contractorHandler.UpdateContractor)
|
|
contractors.DELETE("/:id/", contractorHandler.DeleteContractor)
|
|
contractors.POST("/:id/toggle-favorite/", contractorHandler.ToggleFavorite)
|
|
contractors.GET("/:id/tasks/", contractorHandler.GetContractorTasks)
|
|
}
|
|
}
|
|
|
|
// setupDocumentRoutes configures document routes
|
|
func setupDocumentRoutes(api *echo.Group, documentHandler *handlers.DocumentHandler) {
|
|
documents := api.Group("/documents")
|
|
{
|
|
documents.GET("/", documentHandler.ListDocuments)
|
|
documents.POST("/", documentHandler.CreateDocument)
|
|
documents.GET("/warranties/", documentHandler.ListWarranties)
|
|
documents.GET("/:id/", documentHandler.GetDocument)
|
|
documents.PUT("/:id/", documentHandler.UpdateDocument)
|
|
documents.PATCH("/:id/", documentHandler.UpdateDocument)
|
|
documents.DELETE("/:id/", documentHandler.DeleteDocument)
|
|
documents.POST("/:id/activate/", documentHandler.ActivateDocument)
|
|
documents.POST("/:id/deactivate/", documentHandler.DeactivateDocument)
|
|
documents.POST("/:id/images/", documentHandler.UploadDocumentImage)
|
|
documents.DELETE("/:id/images/:imageId/", documentHandler.DeleteDocumentImage)
|
|
}
|
|
}
|
|
|
|
// setupNotificationRoutes configures notification routes
|
|
func setupNotificationRoutes(api *echo.Group, notificationHandler *handlers.NotificationHandler) {
|
|
notifications := api.Group("/notifications")
|
|
{
|
|
notifications.GET("/", notificationHandler.ListNotifications)
|
|
notifications.GET("/unread-count/", notificationHandler.GetUnreadCount)
|
|
notifications.POST("/mark-all-read/", notificationHandler.MarkAllAsRead)
|
|
notifications.POST("/:id/read/", notificationHandler.MarkAsRead)
|
|
|
|
notifications.POST("/devices/", notificationHandler.RegisterDevice)
|
|
notifications.POST("/devices/register/", notificationHandler.RegisterDevice) // Alias for mobile clients
|
|
notifications.POST("/devices/unregister/", notificationHandler.UnregisterDevice)
|
|
notifications.GET("/devices/", notificationHandler.ListDevices)
|
|
notifications.DELETE("/devices/:id/", notificationHandler.DeleteDevice)
|
|
|
|
notifications.GET("/preferences/", notificationHandler.GetPreferences)
|
|
notifications.PUT("/preferences/", notificationHandler.UpdatePreferences)
|
|
notifications.PATCH("/preferences/", notificationHandler.UpdatePreferences)
|
|
}
|
|
}
|
|
|
|
// setupSubscriptionRoutes configures subscription routes (authenticated)
|
|
// Note: /upgrade-triggers/ is in setupPublicDataRoutes (public, no auth required)
|
|
func setupSubscriptionRoutes(api *echo.Group, subscriptionHandler *handlers.SubscriptionHandler) {
|
|
subscription := api.Group("/subscription")
|
|
{
|
|
subscription.GET("/", subscriptionHandler.GetSubscription)
|
|
subscription.GET("/status/", subscriptionHandler.GetSubscriptionStatus)
|
|
subscription.GET("/upgrade-trigger/:key/", subscriptionHandler.GetUpgradeTrigger)
|
|
subscription.GET("/features/", subscriptionHandler.GetFeatureBenefits)
|
|
subscription.GET("/promotions/", subscriptionHandler.GetPromotions)
|
|
subscription.POST("/purchase/", subscriptionHandler.ProcessPurchase)
|
|
subscription.POST("/cancel/", subscriptionHandler.CancelSubscription)
|
|
subscription.POST("/restore/", subscriptionHandler.RestoreSubscription)
|
|
subscription.POST("/checkout/", subscriptionHandler.CreateCheckoutSession)
|
|
subscription.POST("/portal/", subscriptionHandler.CreatePortalSession)
|
|
}
|
|
}
|
|
|
|
// setupUserRoutes configures user routes
|
|
func setupUserRoutes(api *echo.Group, userHandler *handlers.UserHandler) {
|
|
users := api.Group("/users")
|
|
{
|
|
users.GET("/", userHandler.ListUsers)
|
|
users.GET("/:id/", userHandler.GetUser)
|
|
users.GET("/profiles/", userHandler.ListProfiles)
|
|
}
|
|
}
|
|
|
|
// setupUploadRoutes configures file upload routes
|
|
func setupUploadRoutes(api *echo.Group, uploadHandler *handlers.UploadHandler) {
|
|
uploads := api.Group("/uploads")
|
|
{
|
|
uploads.POST("/presign/", uploadHandler.PresignUpload)
|
|
uploads.DELETE("/", uploadHandler.DeleteFile)
|
|
}
|
|
}
|
|
|
|
// setupMediaRoutes configures authenticated media serving routes
|
|
func setupMediaRoutes(api *echo.Group, mediaHandler *handlers.MediaHandler) {
|
|
media := api.Group("/media")
|
|
{
|
|
media.GET("/document/:id", mediaHandler.ServeDocument)
|
|
media.GET("/document-image/:id", mediaHandler.ServeDocumentImage)
|
|
media.GET("/completion-image/:id", mediaHandler.ServeCompletionImage)
|
|
}
|
|
}
|
|
|
|
// setupWebhookRoutes configures subscription webhook routes for Apple/Google server-to-server notifications
|
|
// These routes are public (no auth) since they're called by Apple/Google servers
|
|
func setupWebhookRoutes(api *echo.Group, webhookHandler *handlers.SubscriptionWebhookHandler) {
|
|
webhooks := api.Group("/subscription/webhook")
|
|
{
|
|
webhooks.POST("/apple/", webhookHandler.HandleAppleWebhook)
|
|
webhooks.POST("/google/", webhookHandler.HandleGoogleWebhook)
|
|
webhooks.POST("/stripe/", webhookHandler.HandleStripeWebhook)
|
|
}
|
|
}
|
|
|
|
// customHTTPErrorHandler handles all errors returned from handlers in a consistent way.
|
|
// It converts AppErrors, validation errors, and Echo HTTPErrors to JSON responses.
|
|
// Also includes fallback handling for legacy service-level errors.
|
|
func customHTTPErrorHandler(err error, c echo.Context) {
|
|
// Already committed? Skip
|
|
if c.Response().Committed {
|
|
return
|
|
}
|
|
|
|
// Handle AppError (our custom application errors)
|
|
var appErr *apperrors.AppError
|
|
if errors.As(err, &appErr) {
|
|
message := i18n.LocalizedMessage(c, appErr.MessageKey)
|
|
// If i18n key not found (returns the key itself), use fallback message
|
|
if message == appErr.MessageKey && appErr.Message != "" {
|
|
message = appErr.Message
|
|
} else if message == appErr.MessageKey {
|
|
message = appErr.MessageKey // Use the key as last resort
|
|
}
|
|
|
|
// Log internal errors
|
|
if appErr.Err != nil {
|
|
log.Error().Err(appErr.Err).Str("message_key", appErr.MessageKey).Msg("Application error")
|
|
}
|
|
|
|
c.JSON(appErr.Code, responses.ErrorResponse{Error: message})
|
|
return
|
|
}
|
|
|
|
// Handle validation errors from go-playground/validator
|
|
var validationErrs validator.ValidationErrors
|
|
if errors.As(err, &validationErrs) {
|
|
c.JSON(http.StatusBadRequest, customvalidator.FormatValidationErrors(err))
|
|
return
|
|
}
|
|
|
|
// Handle Echo's built-in HTTPError
|
|
var httpErr *echo.HTTPError
|
|
if errors.As(err, &httpErr) {
|
|
msg := fmt.Sprintf("%v", httpErr.Message)
|
|
c.JSON(httpErr.Code, responses.ErrorResponse{Error: msg})
|
|
return
|
|
}
|
|
|
|
// Handle service-layer errors and map them to appropriate HTTP status codes
|
|
switch {
|
|
// Task errors - 404 Not Found
|
|
case errors.Is(err, services.ErrTaskNotFound):
|
|
c.JSON(http.StatusNotFound, responses.ErrorResponse{
|
|
Error: i18n.LocalizedMessage(c, "error.task_not_found"),
|
|
})
|
|
return
|
|
case errors.Is(err, services.ErrCompletionNotFound):
|
|
c.JSON(http.StatusNotFound, responses.ErrorResponse{
|
|
Error: i18n.LocalizedMessage(c, "error.completion_not_found"),
|
|
})
|
|
return
|
|
|
|
// Task errors - 403 Forbidden
|
|
case errors.Is(err, services.ErrTaskAccessDenied):
|
|
c.JSON(http.StatusForbidden, responses.ErrorResponse{
|
|
Error: i18n.LocalizedMessage(c, "error.task_access_denied"),
|
|
})
|
|
return
|
|
|
|
// Task errors - 400 Bad Request
|
|
case errors.Is(err, services.ErrTaskAlreadyCancelled):
|
|
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
|
|
Error: i18n.LocalizedMessage(c, "error.task_already_cancelled"),
|
|
})
|
|
return
|
|
case errors.Is(err, services.ErrTaskAlreadyArchived):
|
|
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
|
|
Error: i18n.LocalizedMessage(c, "error.task_already_archived"),
|
|
})
|
|
return
|
|
|
|
// Residence errors - 404 Not Found
|
|
case errors.Is(err, services.ErrResidenceNotFound):
|
|
c.JSON(http.StatusNotFound, responses.ErrorResponse{
|
|
Error: i18n.LocalizedMessage(c, "error.residence_not_found"),
|
|
})
|
|
return
|
|
|
|
// Residence errors - 403 Forbidden
|
|
case errors.Is(err, services.ErrResidenceAccessDenied):
|
|
c.JSON(http.StatusForbidden, responses.ErrorResponse{
|
|
Error: i18n.LocalizedMessage(c, "error.residence_access_denied"),
|
|
})
|
|
return
|
|
case errors.Is(err, services.ErrNotResidenceOwner):
|
|
c.JSON(http.StatusForbidden, responses.ErrorResponse{
|
|
Error: i18n.LocalizedMessage(c, "error.not_residence_owner"),
|
|
})
|
|
return
|
|
case errors.Is(err, services.ErrPropertiesLimitReached):
|
|
c.JSON(http.StatusForbidden, responses.ErrorResponse{
|
|
Error: i18n.LocalizedMessage(c, "error.properties_limit_reached"),
|
|
})
|
|
return
|
|
|
|
// Residence errors - 400 Bad Request
|
|
case errors.Is(err, services.ErrCannotRemoveOwner):
|
|
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
|
|
Error: i18n.LocalizedMessage(c, "error.cannot_remove_owner"),
|
|
})
|
|
return
|
|
case errors.Is(err, services.ErrShareCodeExpired):
|
|
c.JSON(http.StatusBadRequest, responses.ErrorResponse{
|
|
Error: i18n.LocalizedMessage(c, "error.share_code_expired"),
|
|
})
|
|
return
|
|
|
|
// Residence errors - 404 Not Found (share code)
|
|
case errors.Is(err, services.ErrShareCodeInvalid):
|
|
c.JSON(http.StatusNotFound, responses.ErrorResponse{
|
|
Error: i18n.LocalizedMessage(c, "error.share_code_invalid"),
|
|
})
|
|
return
|
|
|
|
// Residence errors - 409 Conflict
|
|
case errors.Is(err, services.ErrUserAlreadyMember):
|
|
c.JSON(http.StatusConflict, responses.ErrorResponse{
|
|
Error: i18n.LocalizedMessage(c, "error.user_already_member"),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Default: Internal server error (don't expose error details to client)
|
|
log.Error().Err(err).Msg("Unhandled error")
|
|
c.JSON(http.StatusInternalServerError, responses.ErrorResponse{
|
|
Error: i18n.LocalizedMessage(c, "error.internal"),
|
|
})
|
|
}
|