mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-04 04:22:59 +00:00
feat(core): consolidate auth service into backend and implement stripe billing
This commit performs a major architectural refactor by migrating the standalone `auth-service` into the main `backend` application, enabling a unified codebase and simplified deployment. It also introduces comprehensive Stripe billing support and a new administrative dashboard.
Key changes:
- **Architecture**: Deleted `apps/auth-service` and integrated its functionality (JWT, magic links, OAuth, user management) into `apps/backend`.
- **Billing**: Added Stripe integration to `backend`, supporting both monthly and yearly subscription cycles with automatic plan entitlement enforcement (e.g., location limits).
- **Admin Dashboard**: Implemented a new administrative service and API endpoints to manage tenants, users, and view platform-wide statistics.
- **Frontend**:
- Added a new pricing page with monthly/yearly toggle and comparison table.
- Integrated Stripe and Sentry for payments and error tracking.
- Improved dashboard UX/UI and added i18n support for new features.
- Enhanced the public booking flow with better validation and contact form integration.
- **Database**: Added migrations for users, magic links, password resets, OAuth states, admin audit logs, and refresh tokens.
- **DevOps**: Updated environment configurations for Railway and Vercel, and streamlined the project's `package.json` scripts.
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/admin"
|
||||
"bookra/apps/backend/internal/auth"
|
||||
"bookra/apps/backend/internal/billing"
|
||||
"bookra/apps/backend/internal/bookings"
|
||||
@@ -18,16 +21,21 @@ import (
|
||||
"bookra/apps/backend/internal/notifications"
|
||||
"bookra/apps/backend/internal/tenancy"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"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
|
||||
router *gin.Engine
|
||||
cfg config.Config
|
||||
pools *db.Pools
|
||||
verifier *auth.Verifier
|
||||
authService *auth.Service
|
||||
adminService *admin.Service
|
||||
billingService *billing.Service
|
||||
notificationService *notifications.Service
|
||||
}
|
||||
|
||||
func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
@@ -41,15 +49,21 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
bookingService := bookings.NewService(repository, notificationService)
|
||||
customerBookingService := bookings.NewCustomerService(repository, notificationService)
|
||||
tenantService := tenancy.NewService(repository)
|
||||
catalogService := catalog.NewService(repository)
|
||||
billingService := billing.NewService(cfg, repository)
|
||||
catalogService := catalog.NewService(repository, billingService, notificationService)
|
||||
authService := auth.NewService(repository, cfg.AuthJWTSecret)
|
||||
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
|
||||
publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5)
|
||||
|
||||
server := &Server{
|
||||
router: gin.New(),
|
||||
cfg: cfg,
|
||||
pools: pools,
|
||||
verifier: verifier,
|
||||
router: gin.New(),
|
||||
cfg: cfg,
|
||||
pools: pools,
|
||||
verifier: verifier,
|
||||
authService: authService,
|
||||
adminService: adminService,
|
||||
billingService: billingService,
|
||||
notificationService: notificationService,
|
||||
}
|
||||
|
||||
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
|
||||
@@ -67,15 +81,241 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
})
|
||||
})
|
||||
|
||||
// Test endpoint for Sentry
|
||||
server.router.GET("/debug/sentry", func(c *gin.Context) {
|
||||
sentry.CaptureMessage("Test message from Bookra API")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "sent", "message": "Test error sent to Sentry"})
|
||||
})
|
||||
|
||||
// Test endpoint for billing
|
||||
server.router.GET("/debug/billing-test", func(c *gin.Context) {
|
||||
if billingService != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"billingService": "initialized",
|
||||
"message": "Billing service is working",
|
||||
})
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "billingService": "not initialized"})
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
"environment": cfg.Environment,
|
||||
"neonAuthEnabled": verifier.Enabled(),
|
||||
"apiUrl": cfg.APIURL,
|
||||
"demoMode": cfg.DemoMode,
|
||||
"adminLoginEnabled": adminService.IsConfigured(),
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// AUTH API
|
||||
// ============================================
|
||||
authGroup := server.router.Group("/v1/auth")
|
||||
{
|
||||
authGroup.POST("/register", func(c *gin.Context) {
|
||||
var request struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
user, tokens, err := authService.RegisterWithPassword(c.Request.Context(), request.Email, request.Password, request.Name)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrEmailAlreadyExists) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.ErrPasswordTooShort) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration_failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
},
|
||||
"accessToken": tokens.AccessToken,
|
||||
"refreshToken": tokens.RefreshToken,
|
||||
"tokenType": tokens.TokenType,
|
||||
"expiresIn": tokens.ExpiresIn,
|
||||
})
|
||||
})
|
||||
|
||||
authGroup.POST("/login", func(c *gin.Context) {
|
||||
var request struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
user, tokens, err := authService.LoginWithPassword(c.Request.Context(), request.Email, request.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "login_failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
},
|
||||
"accessToken": tokens.AccessToken,
|
||||
"refreshToken": tokens.RefreshToken,
|
||||
"tokenType": tokens.TokenType,
|
||||
"expiresIn": tokens.ExpiresIn,
|
||||
})
|
||||
})
|
||||
|
||||
authGroup.POST("/magic-link", func(c *gin.Context) {
|
||||
var request struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
_, err := authService.CreateMagicLink(c.Request.Context(), request.Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "magic_link_failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "magic_link_sent"})
|
||||
})
|
||||
|
||||
authGroup.POST("/verify", func(c *gin.Context) {
|
||||
var request struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
user, tokens, err := authService.VerifyMagicLink(c.Request.Context(), request.Token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
},
|
||||
"accessToken": tokens.AccessToken,
|
||||
"refreshToken": tokens.RefreshToken,
|
||||
"tokenType": tokens.TokenType,
|
||||
"expiresIn": tokens.ExpiresIn,
|
||||
})
|
||||
})
|
||||
|
||||
authGroup.POST("/refresh", func(c *gin.Context) {
|
||||
var request struct {
|
||||
RefreshToken string `json:"refreshToken" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
tokens, err := authService.RefreshToken(c.Request.Context(), request.RefreshToken)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"accessToken": tokens.AccessToken,
|
||||
"refreshToken": tokens.RefreshToken,
|
||||
"tokenType": tokens.TokenType,
|
||||
"expiresIn": tokens.ExpiresIn,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ADMIN API
|
||||
// ============================================
|
||||
adminGroup := server.router.Group("/v1/admin")
|
||||
adminGroup.Use(admin.RequireAdmin(adminService, authService))
|
||||
{
|
||||
adminGroup.GET("/stats", func(c *gin.Context) {
|
||||
stats, err := adminService.GetDashboardStats(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, stats)
|
||||
})
|
||||
|
||||
adminGroup.GET("/tenants", func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
result, err := adminService.ListTenants(c.Request.Context(), page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
})
|
||||
|
||||
adminGroup.GET("/users", func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
result, err := adminService.ListUsers(c.Request.Context(), page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
})
|
||||
|
||||
adminGroup.PUT("/users/:userID/role", func(c *gin.Context) {
|
||||
var request domain.UpdateUserRoleRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
adminUserID, _ := c.Get("userID")
|
||||
err := adminService.UpdateUserRole(
|
||||
c.Request.Context(),
|
||||
adminUserID.(string),
|
||||
c.Param("userID"),
|
||||
request.Role,
|
||||
c.ClientIP(),
|
||||
c.GetHeader("User-Agent"),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
})
|
||||
|
||||
// Trigger trial ending email check
|
||||
adminGroup.POST("/trigger-trial-emails", func(c *gin.Context) {
|
||||
err := billingService.CheckAndSendTrialEndingEmails(c.Request.Context(), notificationService)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "completed", "message": "Trial ending emails sent"})
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -126,6 +366,23 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
c.JSON(http.StatusCreated, response)
|
||||
})
|
||||
|
||||
server.router.POST("/v1/public/contact", publicRateLimiter.Middleware(), func(c *gin.Context) {
|
||||
var request struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Message string `json:"message" binding:"required,min=10"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
if err := notificationService.SendContactEmail(c.Request.Context(), request.Name, request.Email, request.Message); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send message"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
||||
})
|
||||
|
||||
protected := server.router.Group("/v1")
|
||||
protected.Use(auth.RequireAuth(verifier, repository, cfg.DemoMode))
|
||||
|
||||
@@ -196,6 +453,8 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, catalog.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
} else if errors.Is(err, catalog.ErrPlanLimitReached) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -492,7 +751,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode, request.Currency)
|
||||
response, err := billingService.CreateCheckoutSession(c.Request.Context(), auth.PrincipalFromContext(c), request.PlanCode, request.Currency, request.BillingInterval)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, billing.ErrBillingMembership) {
|
||||
@@ -549,6 +808,13 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "accepted"})
|
||||
})
|
||||
server.router.POST("/v1/webhooks/stripe", func(c *gin.Context) {
|
||||
if err := billingService.HandleStripeWebhook(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()})
|
||||
@@ -597,6 +863,40 @@ func (s *Server) Close() {
|
||||
}
|
||||
}
|
||||
|
||||
// StartBackgroundJobs runs periodic background tasks
|
||||
func (s *Server) StartBackgroundJobs() {
|
||||
// Run trial ending check every 6 hours
|
||||
ticker := time.NewTicker(6 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run immediately on startup after a brief delay
|
||||
time.Sleep(30 * time.Second)
|
||||
s.runTrialEndingCheck()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.runTrialEndingCheck()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) runTrialEndingCheck() {
|
||||
if s.billingService == nil || s.notificationService == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := s.billingService.CheckAndSendTrialEndingEmails(ctx, s.notificationService)
|
||||
if err != nil {
|
||||
log.Printf("Background job: trial ending check failed: %v", err)
|
||||
} else {
|
||||
log.Printf("Background job: trial ending check completed")
|
||||
}
|
||||
}
|
||||
|
||||
func authorizeJobRunner(c *gin.Context, cfg config.Config) bool {
|
||||
if cfg.JobRunnerKey == "" {
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user