mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
776 lines
30 KiB
Go
776 lines
30 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"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, cfg.AuthJWTSecret)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
repository := db.NewRepository(pools, cfg.DemoMode)
|
|
notificationService := notifications.NewService(cfg, repository)
|
|
bookingService := bookings.NewService(repository, notificationService)
|
|
customerBookingService := bookings.NewCustomerService(repository, notificationService)
|
|
tenantService := tenancy.NewService(repository)
|
|
catalogService := catalog.NewService(repository)
|
|
billingService := billing.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: allowedOrigins(cfg),
|
|
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,
|
|
"demoMode": cfg.DemoMode,
|
|
})
|
|
})
|
|
|
|
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, cfg.DemoMode))
|
|
|
|
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 API - Locations / Zones
|
|
// ============================================
|
|
protected.GET("/catalog/locations", func(c *gin.Context) {
|
|
response, err := catalogService.ListLocations(c.Request.Context(), auth.PrincipalFromContext(c))
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, catalog.ErrTenantMembership) {
|
|
status = http.StatusNotFound
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response)
|
|
})
|
|
protected.POST("/catalog/locations", func(c *gin.Context) {
|
|
var request domain.CreateLocationRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
|
return
|
|
}
|
|
response, err := catalogService.CreateLocation(c.Request.Context(), auth.PrincipalFromContext(c), request)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, catalog.ErrTenantMembership) {
|
|
status = http.StatusNotFound
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, response)
|
|
})
|
|
protected.PUT("/catalog/locations/:locationID", func(c *gin.Context) {
|
|
var request domain.UpdateLocationRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
|
return
|
|
}
|
|
response, err := catalogService.UpdateLocation(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("locationID"), request)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, catalog.ErrTenantMembership) {
|
|
status = http.StatusNotFound
|
|
} else if errors.Is(err, catalog.ErrLocationNotFound) {
|
|
status = http.StatusNotFound
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response)
|
|
})
|
|
protected.DELETE("/catalog/locations/:locationID", func(c *gin.Context) {
|
|
err := catalogService.DeleteLocation(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("locationID"))
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, catalog.ErrTenantMembership) {
|
|
status = http.StatusNotFound
|
|
} else if errors.Is(err, catalog.ErrLocationNotFound) {
|
|
status = http.StatusNotFound
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
|
})
|
|
|
|
// ============================================
|
|
// CATALOG API - Blocked Days
|
|
// ============================================
|
|
protected.GET("/catalog/blocked-days", func(c *gin.Context) {
|
|
from := time.Now()
|
|
to := from.AddDate(0, 3, 0) // 3 months ahead
|
|
response, err := catalogService.ListBlockedDays(c.Request.Context(), auth.PrincipalFromContext(c), from, to)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, catalog.ErrTenantMembership) {
|
|
status = http.StatusNotFound
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response)
|
|
})
|
|
protected.POST("/catalog/blocked-days", func(c *gin.Context) {
|
|
var request domain.CreateBlockedDayRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
|
return
|
|
}
|
|
response, err := catalogService.CreateBlockedDay(c.Request.Context(), auth.PrincipalFromContext(c), request)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, catalog.ErrTenantMembership) {
|
|
status = http.StatusNotFound
|
|
} else if errors.Is(err, catalog.ErrInvalidBooking) {
|
|
status = http.StatusBadRequest
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, response)
|
|
})
|
|
protected.PUT("/catalog/blocked-days/:blockedDayID", func(c *gin.Context) {
|
|
var request domain.UpdateBlockedDayRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
|
return
|
|
}
|
|
response, err := catalogService.UpdateBlockedDay(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("blockedDayID"), request)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, catalog.ErrTenantMembership) {
|
|
status = http.StatusNotFound
|
|
} else if errors.Is(err, catalog.ErrBlockedDayNotFound) {
|
|
status = http.StatusNotFound
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response)
|
|
})
|
|
protected.DELETE("/catalog/blocked-days/:blockedDayID", func(c *gin.Context) {
|
|
err := catalogService.DeleteBlockedDay(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("blockedDayID"))
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, catalog.ErrTenantMembership) {
|
|
status = http.StatusNotFound
|
|
} else if errors.Is(err, catalog.ErrBlockedDayNotFound) {
|
|
status = http.StatusNotFound
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
|
})
|
|
|
|
// ============================================
|
|
// CATALOG API - Customers
|
|
// ============================================
|
|
protected.GET("/catalog/customers", func(c *gin.Context) {
|
|
limit := 50
|
|
offset := 0
|
|
if l, err := strconv.Atoi(c.Query("limit")); err == nil && l > 0 && l <= 100 {
|
|
limit = l
|
|
}
|
|
if o, err := strconv.Atoi(c.Query("offset")); err == nil && o >= 0 {
|
|
offset = o
|
|
}
|
|
response, err := catalogService.ListCustomers(c.Request.Context(), auth.PrincipalFromContext(c), limit, offset)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, catalog.ErrTenantMembership) {
|
|
status = http.StatusNotFound
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response)
|
|
})
|
|
protected.POST("/catalog/customers", func(c *gin.Context) {
|
|
var request domain.CreateCustomerRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
|
return
|
|
}
|
|
response, err := catalogService.CreateCustomer(c.Request.Context(), auth.PrincipalFromContext(c), request)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, catalog.ErrTenantMembership) {
|
|
status = http.StatusNotFound
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, response)
|
|
})
|
|
protected.PUT("/catalog/customers/:customerID", func(c *gin.Context) {
|
|
var request domain.UpdateCustomerRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
|
return
|
|
}
|
|
response, err := catalogService.UpdateCustomer(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("customerID"), request)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, catalog.ErrTenantMembership) {
|
|
status = http.StatusNotFound
|
|
} else if errors.Is(err, catalog.ErrCustomerNotFound) {
|
|
status = http.StatusNotFound
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response)
|
|
})
|
|
protected.DELETE("/catalog/customers/:customerID", func(c *gin.Context) {
|
|
err := catalogService.DeleteCustomer(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("customerID"))
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, catalog.ErrTenantMembership) {
|
|
status = http.StatusNotFound
|
|
} else if errors.Is(err, catalog.ErrCustomerNotFound) {
|
|
status = http.StatusNotFound
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
|
})
|
|
|
|
// ============================================
|
|
// CATALOG API - Working Hours
|
|
// ============================================
|
|
protected.GET("/catalog/working-hours", func(c *gin.Context) {
|
|
response, err := catalogService.ListWorkingHours(c.Request.Context(), auth.PrincipalFromContext(c))
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, catalog.ErrTenantMembership) {
|
|
status = http.StatusNotFound
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response)
|
|
})
|
|
protected.PUT("/catalog/working-hours/:dayOfWeek", func(c *gin.Context) {
|
|
dayOfWeek, err := strconv.Atoi(c.Param("dayOfWeek"))
|
|
if err != nil || dayOfWeek < 0 || dayOfWeek > 6 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_day_of_week"})
|
|
return
|
|
}
|
|
var request domain.UpdateWorkingHoursRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
|
return
|
|
}
|
|
err = catalogService.UpdateWorkingHours(c.Request.Context(), auth.PrincipalFromContext(c), dayOfWeek, request)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, catalog.ErrTenantMembership) {
|
|
status = http.StatusNotFound
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
|
})
|
|
|
|
// ============================================
|
|
// CUSTOMER BOOKING MANAGEMENT API (Public)
|
|
// ============================================
|
|
server.router.GET("/v1/public/bookings/:reference", func(c *gin.Context) {
|
|
token := c.Query("token")
|
|
response, err := customerBookingService.GetBookingByReference(c.Request.Context(), c.Param("reference"), token)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, bookings.ErrBookingNotFound) {
|
|
status = http.StatusNotFound
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response)
|
|
})
|
|
server.router.POST("/v1/public/bookings/:reference/reschedule", publicRateLimiter.Middleware(), func(c *gin.Context) {
|
|
token := c.Query("token")
|
|
var request domain.RescheduleBookingRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
|
return
|
|
}
|
|
err := customerBookingService.RescheduleBooking(c.Request.Context(), c.Param("reference"), request, token)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
switch {
|
|
case errors.Is(err, bookings.ErrBookingNotFound):
|
|
status = http.StatusNotFound
|
|
case errors.Is(err, bookings.ErrBookingCancelled):
|
|
status = http.StatusConflict
|
|
case errors.Is(err, bookings.ErrInvalidReschedule):
|
|
status = http.StatusBadRequest
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "rescheduled"})
|
|
})
|
|
server.router.POST("/v1/public/bookings/:reference/cancel", publicRateLimiter.Middleware(), func(c *gin.Context) {
|
|
token := c.Query("token")
|
|
err := customerBookingService.CancelBooking(c.Request.Context(), c.Param("reference"), token)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
switch {
|
|
case errors.Is(err, bookings.ErrBookingNotFound):
|
|
status = http.StatusNotFound
|
|
case errors.Is(err, bookings.ErrBookingCancelled):
|
|
status = http.StatusConflict
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
|
|
})
|
|
|
|
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, request.Currency)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, billing.ErrBillingMembership) {
|
|
status = http.StatusNotFound
|
|
}
|
|
if errors.Is(err, billing.ErrBillingPlanUnsupported) {
|
|
status = http.StatusBadRequest
|
|
}
|
|
if errors.Is(err, billing.ErrPaddleNotConfigured) {
|
|
status = http.StatusServiceUnavailable
|
|
}
|
|
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
|
|
}
|
|
if errors.Is(err, billing.ErrPaddleNotConfigured) {
|
|
status = http.StatusServiceUnavailable
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response)
|
|
})
|
|
protected.POST("/billing/portal", func(c *gin.Context) {
|
|
response, err := billingService.CreatePortalSession(c.Request.Context(), auth.PrincipalFromContext(c))
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
switch {
|
|
case errors.Is(err, billing.ErrBillingMembership):
|
|
status = http.StatusNotFound
|
|
case errors.Is(err, billing.ErrBillingCustomerMissing):
|
|
status = http.StatusBadRequest
|
|
case errors.Is(err, billing.ErrPaddleNotConfigured):
|
|
status = http.StatusServiceUnavailable
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, response)
|
|
})
|
|
|
|
server.router.POST("/v1/webhooks/paddle", func(c *gin.Context) {
|
|
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
|
|
})
|
|
server.router.POST("/api/paddle_webhook", func(c *gin.Context) {
|
|
if err := billingService.HandleWebhook(c.Request.Context(), c.Request); 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)
|
|
})
|
|
|
|
// Widget embeddable script endpoint - serves JavaScript for external sites
|
|
server.router.GET("/v1/public/widget.js", func(c *gin.Context) {
|
|
c.Header("Content-Type", "application/javascript; charset=utf-8")
|
|
c.Header("Cache-Control", "public, max-age=3600") // Cache for 1 hour
|
|
c.String(http.StatusOK, widgetJavaScript(cfg.APIURL))
|
|
})
|
|
|
|
return server, nil
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
return s.router
|
|
}
|
|
|
|
func (s *Server) Close() {
|
|
if s.verifier != nil {
|
|
s.verifier.Close()
|
|
}
|
|
}
|
|
|
|
func authorizeJobRunner(c *gin.Context, cfg config.Config) bool {
|
|
if cfg.JobRunnerKey == "" {
|
|
return false
|
|
}
|
|
return c.GetHeader("X-Bookra-Job-Key") == cfg.JobRunnerKey
|
|
}
|
|
|
|
func allowedOrigins(cfg config.Config) []string {
|
|
origins := []string{cfg.FrontendURL}
|
|
if cfg.Environment == "development" {
|
|
origins = append(origins,
|
|
"http://localhost:3000",
|
|
"http://127.0.0.1:3000",
|
|
"http://localhost:4173",
|
|
"http://127.0.0.1:4173",
|
|
)
|
|
}
|
|
|
|
seen := make(map[string]struct{}, len(origins))
|
|
unique := origins[:0]
|
|
for _, origin := range origins {
|
|
if origin == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[origin]; ok {
|
|
continue
|
|
}
|
|
seen[origin] = struct{}{}
|
|
unique = append(unique, origin)
|
|
}
|
|
return unique
|
|
}
|
|
|
|
// widgetJavaScript returns the embeddable widget script that can be included on external sites
|
|
func widgetJavaScript(apiURL string) string {
|
|
return `(function() {
|
|
'use strict';
|
|
|
|
// Bookra Widget v1.0 - Embeddable Booking Widget
|
|
// Usage: <script src="` + apiURL + `/v1/public/widget.js" data-tenant="your-slug" async defer></script>
|
|
|
|
const WIDGET_VERSION = '1.0.0';
|
|
const WIDGET_ORIGIN = '` + apiURL + `';
|
|
|
|
// Configuration from script tag attributes
|
|
const scripts = document.querySelectorAll('script[src*="widget.js"]');
|
|
|
|
scripts.forEach(function(script) {
|
|
const config = {
|
|
tenant: script.getAttribute('data-tenant') || script.getAttribute('data-tenant-slug'),
|
|
theme: script.getAttribute('data-theme') || 'auto',
|
|
size: script.getAttribute('data-size') || 'default',
|
|
color: script.getAttribute('data-color') || '#a65c3e',
|
|
position: script.getAttribute('data-position') || 'bottom-right',
|
|
widgetId: script.getAttribute('data-widget-id') || 'bookra-widget-' + Math.random().toString(36).substr(2, 9)
|
|
};
|
|
|
|
if (!config.tenant) {
|
|
console.error('[Bookra Widget] Missing data-tenant attribute');
|
|
return;
|
|
}
|
|
|
|
// Find or create widget container
|
|
let container = document.getElementById(config.widgetId);
|
|
if (!container) {
|
|
container = document.createElement('div');
|
|
container.id = config.widgetId;
|
|
container.className = 'bookra-widget-container';
|
|
|
|
// For floating widgets, append to body
|
|
if (config.size === 'floating' || script.getAttribute('data-floating') === 'true') {
|
|
container.style.cssText = 'position:fixed;' + getFloatingPosition(config.position) + ';z-index:9999;';
|
|
document.body.appendChild(container);
|
|
}
|
|
}
|
|
|
|
// Apply theme
|
|
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
const isDark = config.theme === 'dark' || (config.theme === 'auto' && prefersDark);
|
|
container.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
|
|
|
// Build booking URL
|
|
const bookingUrl = WIDGET_ORIGIN.replace('/api', '') + '/book/' + encodeURIComponent(config.tenant);
|
|
|
|
// Create widget content based on type
|
|
const widgetType = script.getAttribute('data-type') || 'iframe';
|
|
|
|
switch (widgetType) {
|
|
case 'button':
|
|
container.innerHTML = createButtonWidget(config, bookingUrl);
|
|
break;
|
|
case 'modal':
|
|
container.innerHTML = createModalWidget(config, bookingUrl);
|
|
break;
|
|
case 'floating':
|
|
container.innerHTML = createFloatingWidget(config, bookingUrl);
|
|
break;
|
|
case 'inline-calendar':
|
|
container.innerHTML = createCalendarWidget(config, bookingUrl, WIDGET_ORIGIN);
|
|
break;
|
|
default:
|
|
container.innerHTML = createIframeWidget(config, bookingUrl);
|
|
}
|
|
|
|
// Add styles
|
|
addWidgetStyles(config.color, isDark);
|
|
});
|
|
|
|
function getFloatingPosition(position) {
|
|
switch (position) {
|
|
case 'top-left': return 'top:20px;left:20px';
|
|
case 'top-right': return 'top:20px;right:20px';
|
|
case 'bottom-left': return 'bottom:20px;left:20px';
|
|
default: return 'bottom:20px;right:20px';
|
|
}
|
|
}
|
|
|
|
function createIframeWidget(config, url) {
|
|
const height = config.size === 'compact' ? '400px' : config.size === 'full' ? '100vh' : '760px';
|
|
return '<iframe src="' + url + '" style="width:100%;height:' + height + ';border:none;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,0.1);" loading="lazy" title="Book appointment"></iframe>';
|
|
}
|
|
|
|
function createButtonWidget(config, url) {
|
|
return '<button class="bookra-widget-btn" onclick="window.open(\'' + url + '\', \'_blank\')" style="background:' + config.color + ';color:white;padding:14px 28px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:8px;transition:opacity 0.2s;">' +
|
|
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>' +
|
|
'Book appointment</button>';
|
|
}
|
|
|
|
function createModalWidget(config, url) {
|
|
const modalId = config.widgetId + '-modal';
|
|
return '<button class="bookra-widget-btn" onclick="document.getElementById(\'' + modalId + '\').style.display=\'flex\'" style="background:' + config.color + ';color:white;padding:14px 28px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;">Book now</button>' +
|
|
'<div id="' + modalId + '" onclick="if(event.target===this)this.style.display=\'none\'" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;align-items:center;justify-content:center;padding:20px;">' +
|
|
'<div style="background:white;border-radius:16px;width:100%;max-width:900px;height:90vh;position:relative;overflow:hidden;">' +
|
|
'<button onclick="document.getElementById(\'' + modalId + '\').style.display=\'none\'" style="position:absolute;top:16px;right:16px;background:#f5f5f5;border:none;width:36px;height:36px;border-radius:50%;cursor:pointer;font-size:18px;z-index:10;">✕</button>' +
|
|
'<iframe src="' + url + '" style="width:100%;height:100%;border:none;"></iframe>' +
|
|
'</div></div>';
|
|
}
|
|
|
|
function createFloatingWidget(config, url) {
|
|
const modalId = config.widgetId + '-modal';
|
|
return '<button class="bookra-floating-btn" onclick="document.getElementById(\'' + modalId + '\').style.display=\'flex\'" style="background:' + config.color + ';color:white;width:60px;height:60px;border:none;border-radius:50%;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,0.15);display:flex;align-items:center;justify-content:center;transition:transform 0.2s;" onmouseover="this.style.transform=\'scale(1.1)\'" onmouseout="this.style.transform=\'scale(1)\'">' +
|
|
'<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>' +
|
|
'</button>' +
|
|
'<div id="' + modalId + '" onclick="if(event.target===this)this.style.display=\'none\'" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10000;align-items:center;justify-content:center;padding:20px;">' +
|
|
'<div style="background:white;border-radius:16px;width:100%;max-width:500px;max-height:90vh;position:relative;overflow:hidden;">' +
|
|
'<button onclick="document.getElementById(\'' + modalId + '\').style.display=\'none\'" style="position:absolute;top:16px;right:16px;background:#f5f5f5;border:none;width:36px;height:36px;border-radius:50%;cursor:pointer;font-size:18px;z-index:10;">✕</button>' +
|
|
'<iframe src="' + url + '" style="width:100%;height:80vh;border:none;"></iframe>' +
|
|
'</div></div>';
|
|
}
|
|
|
|
function createCalendarWidget(config, url, apiUrl) {
|
|
// Placeholder for inline calendar - would fetch availability and render mini-calendar
|
|
return '<div class="bookra-calendar-widget" style="background:white;border-radius:12px;padding:20px;box-shadow:0 2px 10px rgba(0,0,0,0.1);">' +
|
|
'<h3 style="margin:0 0 16px 0;font-size:18px;">Select a time</h3>' +
|
|
'<p style="color:#666;margin:0 0 16px 0;">Loading availability...</p>' +
|
|
'<a href="' + url + '" target="_blank" style="display:inline-block;background:' + config.color + ';color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:500;">View all times</a>' +
|
|
'</div>';
|
|
}
|
|
|
|
function addWidgetStyles(primaryColor, isDark) {
|
|
if (document.getElementById('bookra-widget-styles')) return;
|
|
|
|
const styles = document.createElement('style');
|
|
styles.id = 'bookra-widget-styles';
|
|
styles.textContent = '.bookra-widget-container { font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif; }' +
|
|
'.bookra-widget-container[data-theme="dark"] iframe { filter: invert(0.95) hue-rotate(180deg); }' +
|
|
'.bookra-floating-btn:hover { transform: scale(1.1); }' +
|
|
'@keyframes bookra-pulse { 0%, 100% { box-shadow: 0 4px 12px rgba(0,0,0,0.15); } 50% { box-shadow: 0 4px 20px rgba(0,0,0,0.25); } }' +
|
|
'.bookra-floating-btn { animation: bookra-pulse 2s infinite; }';
|
|
document.head.appendChild(styles);
|
|
}
|
|
|
|
// Log initialization
|
|
console.log('[Bookra Widget] v' + WIDGET_VERSION + ' initialized for ' + scripts.length + ' widget(s)');
|
|
})();`
|
|
}
|