mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-04 04:22:59 +00:00
262 lines
8.1 KiB
Go
262 lines
8.1 KiB
Go
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
|
|
}
|