mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-04 04:22:59 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user