package api import ( "errors" "io" "net/http" "time" "bookra/apps/backend/internal/auth" "bookra/apps/backend/internal/billing" "bookra/apps/backend/internal/bookings" "bookra/apps/backend/internal/catalog" "bookra/apps/backend/internal/config" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" "bookra/apps/backend/internal/httpx" "bookra/apps/backend/internal/notifications" "bookra/apps/backend/internal/tenancy" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "golang.org/x/time/rate" ) type Server struct { router *gin.Engine cfg config.Config pools *db.Pools verifier *auth.Verifier } func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { verifier, err := auth.NewVerifier(cfg.NeonAuthURL) if err != nil { return nil, err } repository := db.NewRepository(pools) bookingService := bookings.NewService(repository) tenantService := tenancy.NewService(repository) billingService := billing.NewService(cfg, repository) notificationService := notifications.NewService(cfg, repository) publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5) server := &Server{ router: gin.New(), cfg: cfg, pools: pools, verifier: verifier, } server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{ AllowOrigins: []string{cfg.FrontendURL}, AllowHeaders: []string{"Authorization", "Content-Type"}, AllowMethods: []string{"GET", "POST", "OPTIONS"}, AllowCredentials: true, }), httpx.SecurityHeaders()) server.router.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", "environment": cfg.Environment, "databaseConfigured": pools.DatabaseConfigured(), }) }) server.router.GET("/v1/meta/config", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "environment": cfg.Environment, "neonAuthEnabled": verifier.Enabled(), "apiUrl": cfg.APIURL, }) }) server.router.GET("/v1/public/tenants/:tenantSlug/availability", func(c *gin.Context) { response, err := bookingService.Availability(c.Request.Context(), c.Param("tenantSlug")) if err != nil { status := http.StatusInternalServerError if errors.Is(err, bookings.ErrTenantNotFound) { status = http.StatusNotFound } c.JSON(status, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, response) }) server.router.POST("/v1/public/bookings", publicRateLimiter.Middleware(), func(c *gin.Context) { var request struct { TenantSlug string `json:"tenantSlug" binding:"required"` BookingMode string `json:"bookingMode" binding:"required"` ServiceID *string `json:"serviceId"` ClassSessionID *string `json:"classSessionId"` StaffID *string `json:"staffId"` LocationID *string `json:"locationId"` CustomerName string `json:"customerName" binding:"required"` CustomerEmail string `json:"customerEmail" binding:"required,email"` Notes string `json:"notes"` StartsAt string `json:"startsAt" binding:"required"` EndsAt string `json:"endsAt" binding:"required"` } if err := c.ShouldBindJSON(&request); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) return } response, err := bookingService.Create(c.Request.Context(), domain.CreateBookingRequest(request)) if err != nil { status := http.StatusInternalServerError switch { case errors.Is(err, bookings.ErrInvalidBooking): status = http.StatusBadRequest case errors.Is(err, bookings.ErrTenantNotFound): status = http.StatusNotFound case errors.Is(err, bookings.ErrBookingConflict): status = http.StatusConflict } c.JSON(status, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, response) }) protected := server.router.Group("/v1") protected.Use(auth.RequireAuth(verifier, repository)) protected.GET("/dashboard/summary", func(c *gin.Context) { response, err := bookingService.DashboardSummary(c.Request.Context(), auth.PrincipalFromContext(c)) if err != nil { status := http.StatusInternalServerError if errors.Is(err, bookings.ErrTenantMembership) { status = http.StatusNotFound } c.JSON(status, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, response) }) protected.GET("/tenants/bootstrap", func(c *gin.Context) { response, err := tenantService.Bootstrap(c.Request.Context(), auth.PrincipalFromContext(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, response) }) protected.POST("/tenants/onboard", func(c *gin.Context) { var request domain.OnboardTenantRequest if err := c.ShouldBindJSON(&request); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) return } response, err := tenantService.Onboard(c.Request.Context(), auth.PrincipalFromContext(c), request) if err != nil { status := http.StatusInternalServerError switch { case errors.Is(err, tenancy.ErrInvalidOnboarding): status = http.StatusBadRequest case errors.Is(err, tenancy.ErrTenantAlreadyProvisioned), errors.Is(err, tenancy.ErrTenantSlugTaken): status = http.StatusConflict } c.JSON(status, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, response) }) _ = catalog.NewService() protected.GET("/billing/subscription", func(c *gin.Context) { response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c)) if err != nil { status := http.StatusInternalServerError if errors.Is(err, billing.ErrBillingMembership) { status = http.StatusNotFound } c.JSON(status, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, response) }) protected.POST("/billing/checkout", func(c *gin.Context) { var request domain.CheckoutSessionRequest if err := c.ShouldBindJSON(&request); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) return } response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode) if err != nil { status := http.StatusInternalServerError if errors.Is(err, billing.ErrBillingMembership) { status = http.StatusNotFound } if errors.Is(err, billing.ErrBillingPlanUnsupported) { status = http.StatusBadRequest } c.JSON(status, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, response) }) protected.POST("/billing/refresh", func(c *gin.Context) { response, err := billingService.Refresh(c.Request.Context(), auth.PrincipalFromContext(c)) if err != nil { status := http.StatusInternalServerError if errors.Is(err, billing.ErrBillingMembership) { status = http.StatusNotFound } c.JSON(status, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, response) }) server.router.POST("/v1/webhooks/stripe", func(c *gin.Context) { payload, err := c.GetRawData() if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_payload"}) return } if err := billingService.HandleWebhook(c.Request.Context(), c.GetHeader("Stripe-Signature"), payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "accepted"}) }) server.router.POST("/v1/internal/jobs/reminders/dispatch", func(c *gin.Context) { if !authorizeJobRunner(c, cfg) { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } var request domain.DispatchReminderJobsRequest if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"}) return } response, err := notificationService.DispatchDue(c.Request.Context(), request.Limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, response) }) return server, nil } func (s *Server) Handler() http.Handler { return s.router } func authorizeJobRunner(c *gin.Context, cfg config.Config) bool { if cfg.JobRunnerKey == "" { return cfg.Environment == "development" } return c.GetHeader("X-Bookra-Job-Key") == cfg.JobRunnerKey }