first commit

This commit is contained in:
Tomas Dvorak
2026-04-10 12:01:36 +02:00
commit 035ac8ddb5
61 changed files with 6600 additions and 0 deletions
+261
View File
@@ -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
}