feat(core): consolidate auth service into backend and implement stripe billing
CI / Frontend (push) Successful in 9m54s
CI / Go - apps/auth-service (push) Failing after 24s
CI / Go - apps/backend (push) Failing after 5m43s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped

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:
Tomas Dvorak
2026-05-09 18:25:25 +02:00
parent cf3315e8fc
commit 164a37e997
69 changed files with 4630 additions and 5260 deletions
+314 -14
View File
@@ -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