refactor(db): replace sqlc with handwritten pgx repos

- Finish Paddle-to-Stripe billing migration; drop paddle/stripe libs
- Renumber migrations; add 00012 memberships+manage_token,
  00013 location_type_capacity
- Add tests: admin, auth, bookings customer service
- Remove graphify-out artifacts and stale env files

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 20:21:34 +02:00
1 parent 2039669e2c
commit f2f7439050
93 files changed
+6987 -69060

No files matched your search

+101 -43
View File
@@ -49,8 +49,8 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
repository := db.NewRepository(pools, cfg.DemoMode)
notificationService := notifications.NewService(cfg, repository)
bookingService := bookings.NewService(repository, notificationService)
customerBookingService := bookings.NewCustomerService(repository, notificationService)
bookingService := bookings.NewService(repository, notificationService, cfg.FrontendURL)
customerBookingService := bookings.NewCustomerService(repository, notificationService, cfg.FrontendURL)
tenantService := tenancy.NewService(repository)
billingService := billing.NewService(cfg, repository)
catalogService := catalog.NewService(repository, billingService, notificationService)
@@ -73,9 +73,10 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
server.router.Use(gin.Logger(), gin.Recovery(), cors.New(cors.Config{
AllowOrigins: allowedOrigins(cfg),
AllowHeaders: []string{"Authorization", "Content-Type"},
AllowMethods: []string{"GET", "POST", "OPTIONS"},
AllowHeaders: []string{"Authorization", "Content-Type", "X-Admin-Email", "X-Admin-Key"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}), httpx.SecurityHeaders())
server.router.GET("/healthz", func(c *gin.Context) {
@@ -86,24 +87,24 @@ 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"})
}
})
if cfg.Environment == "development" || cfg.Environment == "test" {
// Test endpoints, only exposed outside production
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"})
})
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{
@@ -119,6 +120,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
// AUTH API
// ============================================
authGroup := server.router.Group("/v1/auth")
authGroup.Use(publicRateLimiter.Middleware())
{
authGroup.POST("/register", func(c *gin.Context) {
var request struct {
@@ -255,7 +257,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
// ADMIN API
// ============================================
adminGroup := server.router.Group("/v1/admin")
adminGroup.Use(admin.RequireAdmin(adminService, authService))
adminGroup.Use(admin.RequireAdmin(adminService, verifier, repository, cfg.DemoMode))
{
adminGroup.GET("/stats", func(c *gin.Context) {
stats, err := adminService.GetDashboardStats(c.Request.Context())
@@ -294,10 +296,9 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
adminUserID, _ := c.Get("userID")
err := adminService.UpdateUserRole(
c.Request.Context(),
adminUserID.(string),
admin.ActorFromContext(c),
c.Param("userID"),
request.Role,
c.ClientIP(),
@@ -433,6 +434,45 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusCreated, response)
})
protected.PUT("/tenants/brand", func(c *gin.Context) {
var request domain.UpdateBrandRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := tenantService.UpdateBrand(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.GET("/tenants/email-templates", func(c *gin.Context) {
response, err := tenantService.ListEmailTemplates(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"templates": response})
})
protected.PUT("/tenants/email-template", func(c *gin.Context) {
var request domain.UpdateEmailTemplateRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := tenantService.UpdateEmailTemplate(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, tenancy.ErrInvalidOnboarding) {
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
// ============================================
// CATALOG API - Locations / Zones
// ============================================
@@ -501,6 +541,29 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
protected.POST("/catalog/bookings", func(c *gin.Context) {
var request domain.DashboardBookingRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateBooking(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidBooking):
status = http.StatusBadRequest
case errors.Is(err, catalog.ErrBookingConflict):
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
// ============================================
// CATALOG API - Blocked Days
// ============================================
@@ -691,8 +754,11 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
response, err := customerBookingService.GetBookingByReference(c.Request.Context(), c.Param("reference"), token)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, bookings.ErrBookingNotFound) {
switch {
case errors.Is(err, bookings.ErrBookingNotFound):
status = http.StatusNotFound
case errors.Is(err, bookings.ErrUnauthorized):
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
@@ -712,10 +778,14 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
switch {
case errors.Is(err, bookings.ErrBookingNotFound):
status = http.StatusNotFound
case errors.Is(err, bookings.ErrUnauthorized):
status = http.StatusForbidden
case errors.Is(err, bookings.ErrBookingCancelled):
status = http.StatusConflict
case errors.Is(err, bookings.ErrInvalidReschedule):
status = http.StatusBadRequest
case errors.Is(err, bookings.ErrBookingConflict):
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": err.Error()})
return
@@ -730,6 +800,8 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
switch {
case errors.Is(err, bookings.ErrBookingNotFound):
status = http.StatusNotFound
case errors.Is(err, bookings.ErrUnauthorized):
status = http.StatusForbidden
case errors.Is(err, bookings.ErrBookingCancelled):
status = http.StatusConflict
}
@@ -766,7 +838,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
if errors.Is(err, billing.ErrBillingPlanUnsupported) {
status = http.StatusBadRequest
}
if errors.Is(err, billing.ErrPaddleNotConfigured) {
if errors.Is(err, billing.ErrStripeNotConfigured) {
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{"error": err.Error()})
@@ -781,7 +853,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
if errors.Is(err, billing.ErrBillingMembership) {
status = http.StatusNotFound
}
if errors.Is(err, billing.ErrPaddleNotConfigured) {
if errors.Is(err, billing.ErrStripeNotConfigured) {
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{"error": err.Error()})
@@ -798,7 +870,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
status = http.StatusNotFound
case errors.Is(err, billing.ErrBillingCustomerMissing):
status = http.StatusBadRequest
case errors.Is(err, billing.ErrPaddleNotConfigured):
case errors.Is(err, billing.ErrStripeNotConfigured):
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{"error": err.Error()})
@@ -942,13 +1014,6 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
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("/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()})
@@ -956,13 +1021,6 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
}
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) {