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

+74 -58
View File
@@ -6,8 +6,8 @@ import (
"errors"
"net/http"
"strings"
"time"
"bookra/apps/backend/internal/auth"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
@@ -21,9 +21,9 @@ var (
)
type Service struct {
repo db.Repository
adminEmail string
adminKey string
repo db.Repository
adminEmail string
adminKey string
}
func NewService(repo db.Repository, adminEmail, adminKey string) *Service {
@@ -52,20 +52,39 @@ func (s *Service) ValidateAdminLogin(email, key string) bool {
return emailMatch && keyMatch
}
// RequireAdmin is middleware that checks for admin authentication
// It supports two modes:
// 1. Admin credentials via X-Admin-Email and X-Admin-Key headers (for API access)
// 2. Session-based auth where the user has role "admin" or "superadmin"
func RequireAdmin(adminSvc *Service, authSvc interface{ IsAdmin(ctx context.Context, userID string) (bool, error) }) gin.HandlerFunc {
const adminActorKey = "adminActor"
// ActorFromContext returns the admin identity recorded by RequireAdmin.
func ActorFromContext(c *gin.Context) string {
value, ok := c.Get(adminActorKey)
if !ok {
return ""
}
actor, _ := value.(string)
return actor
}
// RequireAdmin is middleware that checks for admin authentication.
// It supports three modes:
// 1. Admin credentials via X-Admin-Email and X-Admin-Key headers (for API access)
// 2. Bearer tokens verified against the auth verifier, where the token subject
// maps to a user with role "admin" or "superadmin"
// 3. Demo mode, where the demo owner is treated as admin
func RequireAdmin(adminSvc *Service, verifier *auth.Verifier, repo db.Repository, demoMode bool) gin.HandlerFunc {
return func(c *gin.Context) {
if demoMode {
c.Set(adminActorKey, auth.DemoPrincipal.Subject)
c.Next()
return
}
// Check for admin header credentials (direct admin login)
adminEmail := c.GetHeader("X-Admin-Email")
adminKey := c.GetHeader("X-Admin-Key")
if adminEmail != "" && adminKey != "" {
if adminEmail != "" || adminKey != "" {
if adminSvc.ValidateAdminLogin(adminEmail, adminKey) {
c.Set("isAdmin", true)
c.Set("adminMode", "credentials")
c.Set(adminActorKey, adminEmail)
c.Next()
return
}
@@ -74,31 +93,39 @@ func RequireAdmin(adminSvc *Service, authSvc interface{ IsAdmin(ctx context.Cont
}
// Check for Bearer token with admin role
auth := c.GetHeader("Authorization")
if auth != "" && strings.HasPrefix(auth, "Bearer ") {
// The auth middleware should have already validated the token
// and set the user info in context
userID, exists := c.Get("userID")
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
isAdmin, err := authSvc.IsAdmin(c.Request.Context(), userID.(string))
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin status"})
return
}
if isAdmin {
c.Set("isAdmin", true)
c.Set("adminMode", "session")
c.Next()
return
}
tokenString, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
if !ok || strings.TrimSpace(tokenString) == "" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
return
}
if verifier == nil || !verifier.Enabled() {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "auth_not_configured"})
return
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
claims, err := verifier.Verify(strings.TrimSpace(tokenString))
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
return
}
subject, _ := claims["sub"].(string)
if strings.TrimSpace(subject) == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid_token_subject"})
return
}
isAdmin, err := repo.IsAdminSubject(c.Request.Context(), subject)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin status"})
return
}
if !isAdmin {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
return
}
c.Set(adminActorKey, subject)
c.Next()
}
}
@@ -110,11 +137,11 @@ func (s *Service) GetDashboardStats(ctx context.Context) (domain.AdminDashboardS
}
return domain.AdminDashboardStats{
TotalTenants: stats.TotalTenants,
TotalUsers: stats.TotalUsers,
ActiveSubscriptions: stats.ActiveSubscriptions,
TrialSubscriptions: stats.TrialSubscriptions,
BookingsThisMonth: stats.BookingsThisMonth,
TotalTenants: stats.TotalTenants,
TotalUsers: stats.TotalUsers,
ActiveSubscriptions: stats.ActiveSubscriptions,
TrialSubscriptions: stats.TrialSubscriptions,
BookingsThisMonth: stats.BookingsThisMonth,
RevenueThisMonthCents: stats.RevenueThisMonth,
}, nil
}
@@ -144,12 +171,12 @@ func (s *Service) ListTenants(ctx context.Context, page, pageSize int) (domain.A
for i, t := range tenants {
result.Tenants[i] = domain.AdminTenant{
ID: t.ID,
Slug: t.Slug,
Name: t.Name,
PlanCode: t.PlanCode,
ID: t.ID,
Slug: t.Slug,
Name: t.Name,
PlanCode: t.PlanCode,
SubscriptionStatus: t.SubscriptionStatus,
BillingProvider: t.BillingProvider,
BillingProvider: t.BillingProvider,
}
}
@@ -198,8 +225,8 @@ func (s *Service) ListUsers(ctx context.Context, page, pageSize int) (domain.Adm
func (s *Service) UpdateUserRole(ctx context.Context, adminUserID, targetUserID, newRole string, ip, userAgent string) error {
// Validate role
validRoles := map[string]bool{
"user": true,
"admin": true,
"user": true,
"admin": true,
"superadmin": true,
}
if !validRoles[newRole] {
@@ -224,20 +251,9 @@ func (s *Service) UpdateUserRole(ctx context.Context, adminUserID, targetUserID,
})
}
// SyncTenantSubscription manually syncs a tenant's subscription from Stripe
func (s *Service) SyncTenantSubscription(ctx context.Context, tenantID string) error {
// This will be called from the billing service
return nil
}
func stringPtrToStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func init() {
// Ensure time package is imported
_ = time.Now()
}